Skip to main content
Software Engineering Fundamentals

Object-Oriented Programming (OOP)

A complete guide to OOP from first principles — classes, the four pillars, composition vs inheritance, object relationships, and how OOP differs across Java, Python, and C++.

10 sections · 25 min read

What is Object-Oriented Programming

Object-Oriented Programming (OOP) is a programming paradigm that organizes software around objects — entities that bundle data (attributes) and behavior (methods) together. Instead of writing a sequence of instructions (procedural style), you model your problem as a collection of interacting objects.

Three major paradigms exist side by side. Procedural programming (C, Pascal) structures code as a sequence of functions operating on shared data — simple but hard to scale. OOP (Java, Python, C++) groups data and behavior into self-contained objects that communicate by sending messages. Functional programming (Haskell, Elixir) treats computation as evaluation of mathematical functions and avoids shared state.

OOP dominates enterprise software, game development, and systems design interviews because it maps naturally to real-world entities. A bank system has Account objects, Transaction objects, and Customer objects. Each knows its own data and exposes only what other objects need.

The four pillars — Encapsulation, Inheritance, Polymorphism, and Abstraction — are the foundation. Every OOP interview question traces back to at least one of them.

Code example

# Procedural vs OOP — same problem, two styles

# Procedural: functions operating on raw data
def calculate_area(shape_type, width, height):
    if shape_type == "rectangle":
        return width * height
    elif shape_type == "triangle":
        return 0.5 * width * height

# OOP: objects that know how to describe themselves
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    def area(self):
        return self.width * self.height

class Triangle:
    def __init__(self, base, height):
        self.base = base
        self.height = height
    def area(self):
        return 0.5 * self.base * self.height

r = Rectangle(5, 3)
print(r.area())  # 15

Classes and Objects

A class is a blueprint that defines the structure and behavior shared by all objects of that type. An object is a live instance of a class — allocated in memory, holding its own copy of the instance variables defined by the class.

Every class typically has: fields (instance variables holding state), a constructor (special method that initializes the object), methods (functions defining behavior), and optionally a destructor/finalizer (cleanup when the object is garbage collected or goes out of scope).

The this keyword (self in Python) refers to the current object instance. It distinguishes instance variables from local variables with the same name and allows methods to call other methods on the same object.

Object lifecycle: memory is allocated → the constructor runs → the object lives and is used → when no references remain, the garbage collector (or manual free in C++) reclaims the memory → the destructor/finalizer runs if defined.

Code example

class BankAccount:
    # Class variable — shared by ALL instances
    interest_rate = 0.04

    # Constructor — runs when object is created
    def __init__(self, owner, balance=0):
        self.owner = owner        # instance variable
        self.balance = balance    # instance variable
        self._transactions = []   # private by convention

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.balance += amount
        self._transactions.append(("deposit", amount))

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self._transactions.append(("withdraw", amount))

    def __repr__(self):
        return f"BankAccount({self.owner!r}, balance={self.balance})"

# Creating objects (instances)
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob")

acc1.deposit(500)
acc2.deposit(200)
print(acc1)   # BankAccount('Alice', balance=1500)
print(BankAccount.interest_rate)  # 0.04 — class variable

Encapsulation

Encapsulation bundles data and the methods that operate on it into a single unit (the class), and controls access to that data from the outside. It protects an object's internal state from accidental corruption and hides implementation details.

Access modifiers control visibility. Public members are accessible from anywhere. Protected members (single underscore _ in Python, protected in Java/C++) are accessible within the class and its subclasses. Private members (double underscore __ in Python, private in Java/C++) are accessible only within the class.

Getters and setters (properties in Python) provide controlled access to private fields. They let you add validation, logging, or computed values without changing the public interface. If you expose a raw field and later need to add validation, you must change every caller — but if you exposed a getter, you only change the getter.

Information hiding (a related concept) means hiding not just data but also implementation choices. A Stack class exposes push() and pop() — callers don't need to know if it's backed by an array or a linked list.

Code example

class Temperature:
    def __init__(self, celsius):
        self._celsius = None       # private storage
        self.celsius = celsius     # use the setter

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:        # validation in setter
            raise ValueError("Below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):          # computed property
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9   # delegates to celsius setter

t = Temperature(100)
print(t.fahrenheit)    # 212.0
t.fahrenheit = 32
print(t.celsius)       # 0.0
# t._celsius = -999    # possible but bad practice — bypass the guard

Inheritance

Inheritance lets a new class (child/subclass) acquire the attributes and methods of an existing class (parent/superclass), then extend or specialize them. It models IS-A relationships: a Dog IS-A Animal, a SavingsAccount IS-A BankAccount.

Types of inheritance: Single (one parent), Multiple (two or more parents — supported in Python and C++, not Java), Multilevel (A → B → C), Hierarchical (one parent, many children), Hybrid (combination).

The super() function calls a parent class method. It's critical in constructors to ensure the parent is properly initialized before the child adds its own setup.

Method overriding: a child class redefines a parent method with the same name and signature. At runtime, Python/Java/C++ calls the most derived version (dynamic dispatch). To prevent a method from being overridden in Java, use final. In C++, use virtual in the parent to enable override.

The Liskov Substitution Principle (from SOLID) says a subclass should be usable wherever the parent is expected without breaking the program — inheritance should never surprise the caller.

Code example

class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

    def speak(self):
        return f"{self.name} says {self.sound}"

    def __str__(self):
        return self.name

class Dog(Animal):                 # single inheritance
    def __init__(self, name):
        super().__init__(name, "Woof")   # call parent constructor
        self.tricks = []

    def learn_trick(self, trick):
        self.tricks.append(trick)

    def speak(self):               # override parent method
        base = super().speak()     # reuse parent logic
        return base + "!"

class GuideDog(Dog):              # multilevel inheritance
    def __init__(self, name, owner):
        super().__init__(name)
        self.owner = owner

    def guide(self):
        return f"{self.name} guides {self.owner}"

d = Dog("Rex")
d.learn_trick("sit")
print(d.speak())         # Rex says Woof!
print(d.tricks)          # ['sit']

g = GuideDog("Buddy", "Alice")
print(g.guide())         # Buddy guides Alice
print(g.speak())         # Buddy says Woof! — inherited from Dog

Polymorphism

Polymorphism means "many forms" — the same interface produces different behavior depending on the actual object type. It's what lets you write generic code that works with objects you haven't seen yet.

Compile-time polymorphism (static dispatch) is method overloading: same method name, different parameter lists. The compiler picks the right version based on argument types at compile time. Python doesn't support this natively (no type signatures), but Java and C++ do.

Runtime polymorphism (dynamic dispatch) is method overriding via inheritance. The correct method is determined at runtime based on the actual object type, not the declared variable type. This is the backbone of every design pattern that uses a common interface.

Duck typing (Python's approach): if an object has the method I call, it works — we don't check the type. This enables structural polymorphism without formal inheritance.

Virtual functions in C++: by default, C++ uses static dispatch. You must declare a method virtual in the base class to get dynamic dispatch. Python and Java always use dynamic dispatch for instance methods.

Code example

# Runtime polymorphism — same call, different behavior
class Shape:
    def area(self):
        raise NotImplementedError("Subclass must implement area()")

    def describe(self):
        return f"I am a {type(self).__name__} with area {self.area():.2f}"

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, w, h):
        self.w, self.h = w, h
    def area(self):
        return self.w * self.h

class Triangle(Shape):
    def __init__(self, base, height):
        self.base, self.height = base, height
    def area(self):
        return 0.5 * self.base * self.height

# Generic function — works for ANY Shape subclass
def print_all_areas(shapes):
    for shape in shapes:
        print(shape.describe())   # dynamic dispatch picks right area()

shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
print_all_areas(shapes)
# I am a Circle with area 78.54
# I am a Rectangle with area 24.00
# I am a Triangle with area 12.00

Abstraction

Abstraction means hiding complexity behind a simple interface. You expose what the caller needs and hide how it works. The caller of .sort() doesn't need to know whether it's Timsort or quicksort — they just call sort().

Abstract classes define a partial implementation and declare abstract methods that subclasses must implement. You cannot instantiate an abstract class directly. In Python, use the abc module. In Java, use the abstract keyword.

Interfaces (Java/C#) define a contract — a set of method signatures — with no implementation. A class can implement multiple interfaces. Interfaces enable polymorphism across unrelated class hierarchies.

When to use abstract class vs interface: Use an abstract class when subclasses share common code or state. Use an interface when you want to define a capability that unrelated classes can implement (Serializable, Comparable).

HAS-A vs IS-A: Inheritance models IS-A (Dog IS-A Animal). Composition models HAS-A (Car HAS-A Engine). Prefer HAS-A when the relationship isn't truly hierarchical.

Code example

from abc import ABC, abstractmethod

# Abstract class — defines contract + some shared logic
class DataExporter(ABC):
    def __init__(self, filename):
        self.filename = filename

    @abstractmethod
    def serialize(self, data):
        """Convert data to the target format — must implement."""
        pass

    def export(self, data):           # template method — shared logic
        content = self.serialize(data)
        with open(self.filename, 'w') as f:
            f.write(content)
        print(f"Exported to {self.filename}")

class JSONExporter(DataExporter):
    def serialize(self, data):
        import json
        return json.dumps(data, indent=2)

class CSVExporter(DataExporter):
    def serialize(self, data):
        lines = [",".join(str(v) for v in row) for row in data]
        return "
".join(lines)

# exporter = DataExporter("x.txt")   # TypeError: can't instantiate abstract class
j = JSONExporter("out.json")
c = CSVExporter("out.csv")
# Both use the same export() but different serialize()

Composition vs Inheritance

"Favour object composition over class inheritance" — this quote from the Gang of Four Design Patterns book is one of the most important principles in OOP. It's not that inheritance is bad; it's that composition is more flexible in most cases.

Problems with deep inheritance hierarchies: the fragile base class problem (changing a parent breaks children), tight coupling (subclasses depend on parent internals), the gorilla-banana problem ("you wanted a banana but you got a gorilla holding the banana and the entire jungle"). Multiple inheritance adds the diamond problem.

Composition models HAS-A: a Car HAS-A Engine. The Car class holds a reference to an Engine object and delegates engine-related behavior to it. You can swap engines at runtime. You can use different Engine types without changing Car. You can test Car and Engine independently.

When inheritance IS right: a true IS-A relationship where the subclass really is a specialization, the parent hierarchy is stable and shallow, you need to reuse both interface and implementation.

Mixin classes (Python): thin classes that add one capability. Combine them via multiple inheritance for fine-grained composition. LoggingMixin adds logging, SerializableMixin adds JSON export — drop them into any class that needs them.

Code example

# Composition — Engine is a component, not a parent
class Engine:
    def __init__(self, horsepower, fuel_type):
        self.horsepower = horsepower
        self.fuel = fuel_type

    def start(self):
        return f"{self.fuel} engine starting ({self.horsepower}hp)"

class ElectricMotor:
    def __init__(self, kw):
        self.kw = kw

    def start(self):
        return f"Electric motor spinning up ({self.kw}kW)"

class Car:
    def __init__(self, model, engine):   # inject the engine
        self.model = model
        self._engine = engine            # HAS-A relationship

    def start(self):
        return f"{self.model}: {self._engine.start()}"

# Swap the powertrain at construction time — no subclassing needed
gas_car  = Car("Sedan",   Engine(200, "petrol"))
elec_car = Car("Model S", ElectricMotor(450))

print(gas_car.start())   # Sedan: petrol engine starting (200hp)
print(elec_car.start())  # Model S: Electric motor spinning up (450kW)

# Both share the Car interface — no inheritance required

Object Relationships

OOP objects relate to each other in three ways, each with different ownership semantics.

Association is the loosest relationship: objects know about each other but neither owns the other. A Student associates with a Course — the Student can switch courses, and the Course exists independently. Both can exist without each other.

Aggregation is a "whole-part" relationship where the part can exist independently. A Department HAS-A list of Employees. If the Department dissolves, the Employees still exist. The lifecycle of the part is independent of the whole.

Composition is the strongest "whole-part" relationship — the part cannot exist without the whole. A House HAS-A Room. Destroy the House, the Rooms are destroyed too. In code, the whole creates and owns the part directly.

UML notation: association → plain arrow. Aggregation → hollow diamond at the "whole" end. Composition → filled diamond at the "whole" end. Multiplicity is written as 1, *, 0..1, 1..* at each end.

Code example

# Association: Teacher and Course — independent lifecycles
class Teacher:
    def __init__(self, name):
        self.name = name

class Course:
    def __init__(self, title, teacher: Teacher):
        self.title = title
        self.teacher = teacher   # reference, not ownership

# Aggregation: Department has Employees, but Employees exist independently
class Employee:
    def __init__(self, name):
        self.name = name

class Department:
    def __init__(self, name):
        self.name = name
        self.employees = []     # aggregation — Employee created outside

    def add_employee(self, emp: Employee):
        self.employees.append(emp)

# Composition: House creates and owns its Rooms
class Room:
    def __init__(self, name, area):
        self.name = name
        self.area = area

class House:
    def __init__(self, address, room_specs):
        self.address = address
        # House creates its own rooms — composition
        self.rooms = [Room(name, area) for name, area in room_specs]

    def total_area(self):
        return sum(r.area for r in self.rooms)

h = House("123 Main St", [("Living Room", 25), ("Bedroom", 15)])
print(h.total_area())  # 40

OOP in Different Languages

Java enforces strict OOP: everything (except primitives) is an object inside a class. Single inheritance only — use interfaces for multiple type contracts. All methods are virtual by default (use final to prevent overriding). Garbage collected.

Python is multi-paradigm: OOP is available but not mandatory. Multiple inheritance supported with MRO (Method Resolution Order using C3 linearization). No true private members — underscore is a convention. Dynamic typing means duck typing works naturally. Everything is an object, including functions and classes themselves.

C++ gives you the most control: manual memory management (or smart pointers), true multiple inheritance, explicit virtual/non-virtual dispatch, and operator overloading. More power = more responsibility.

Key differences table: Java — single inheritance, interfaces, always virtual, GC. Python — multiple inheritance, duck typing, convention-based privacy, GC. C++ — multiple inheritance, explicit virtual, manual memory, templates for generic programming.

Code example

# Python multiple inheritance with MRO
class Flyable:
    def move(self):
        return "flying"

class Swimmable:
    def move(self):
        return "swimming"

class Duck(Flyable, Swimmable):    # multiple inheritance
    def move(self):
        # MRO: Duck → Flyable → Swimmable
        return f"Duck can do both: {Flyable.move(self)} and {Swimmable.move(self)}"

print(Duck.__mro__)  # shows resolution order
d = Duck()
print(d.move())      # Duck can do both: flying and swimming

# Java equivalent would be:
# interface Flyable { String move(); }
# interface Swimmable { String move(); }
# class Duck implements Flyable, Swimmable {
#     public String move() { return "Duck moves"; }
# }
# — Java cannot call two conflicting interface methods directly
# Python's super() chain and explicit class calls give more control

Common OOP Interview Questions

These are the questions that come up most often in OOP rounds. For each, know the definition, a code example, and a real-world analogy.

What are the four pillars of OOP? Encapsulation (data hiding + bundling), Inheritance (IS-A reuse), Polymorphism (one interface, many forms), Abstraction (hide complexity, expose interface).

What is the difference between abstract class and interface? Abstract class has partial implementation and state; interface has only method signatures (pre-Java 8). Use abstract class for shared code, interface for type contracts across unrelated hierarchies.

Can we override static methods? No — static methods are bound to the class, not the instance. You can hide a static method in a subclass (method hiding), but dynamic dispatch does not apply.

What is method hiding vs method overriding? Overriding affects runtime behavior via dynamic dispatch (instance methods). Hiding replaces a static method in the subclass — calling via superclass reference uses the parent version.

What is the diamond problem? In multiple inheritance, if two parents define the same method and a child inherits from both, which version runs? Python solves this with C3 MRO. Java avoids it by disallowing multiple class inheritance (interfaces are fine).

What is object cloning? Shallow copy duplicates the object but shares references to nested objects. Deep copy recursively duplicates everything. Python: copy.copy() vs copy.deepcopy(). Java: implement Cloneable and override clone().

Continue Learning

Ready to test your knowledge?

Apply what you learned with curated practice problems.

Find this useful?

This guide is completely free. If it helped, consider buying me a coffee — it keeps new content coming.

Support on Ko-fi
Buy me a coffee