Skip to Content
OOP Part II

OOP Part II

The four pillars of object-oriented programming — Encapsulation, Inheritance, Polymorphism, and Abstraction — shape how classes communicate with each other and how code is structured for reuse and flexibility.

These four ideas come up constantly in technical interviews. Knowing the terms is not enough. Interviewers want practical examples, the tricky corners (like MRO in multiple inheritance, or why name mangling exists but is rarely the right answer), and the ability to distinguish between similar concepts like isinstance() vs type(), or ABC vs Protocol.

Encapsulation

Encapsulation bundles data and behavior into a single unit and controls how the outside world can access that data. The goal is to expose a clean public interface while hiding implementation details.

Theory

Without encapsulation, any part of a program can read or modify the internal state of any object. This makes the code fragile: a change in one place can break things in unexpected places. Encapsulation solves this by making objects responsible for their own state and exposing only what is necessary.

In strongly-typed languages like Java, encapsulation is enforced by private and protected keywords. Python takes a different approach — it trusts developers to follow conventions.

Access Levels in Python

Python does not enforce access control at the language level. Instead, it relies on naming conventions.

ConventionPrefixEnforced?Meaning
PublicattrNoAccessible from anywhere
Protected_attrNoConvention: internal or subclass use only
Private (name-mangled)__attrPartiallyName becomes _ClassName__attr
class BankAccount: def __init__(self, balance): self.owner = "Alice" # public self._balance = balance # protected by convention self.__pin = 4802 # name-mangled def deposit(self, amount): if amount > 0: self._balance += amount def get_balance(self): return self._balance acc = BankAccount(2000) print(acc.owner) # Alice print(acc._balance) # 2000 -- accessible, but discouraged # print(acc.__pin) # AttributeError -- name is mangled print(acc._BankAccount__pin) # 4802 -- the mangled name is accessible

Name mangling transforms __attr into _ClassName__attr at compile time. It prevents accidental access and name clashes when subclasses are involved. It is not true privacy; the mangled name is always reachable. Dunder names (e.g. __init__, __str__) are not mangled — mangling only applies to names with two or more leading underscores and at most one trailing underscore.

Encapsulation with @property

The clean Python idiom for encapsulation is @property: expose a public interface and validate or compute internally.

class Speed: def __init__(self, value): self._value = 0 self.value = value # runs the setter immediately @property def value(self): return self._value @value.setter def value(self, v): if v < 0: raise ValueError("the speed cannot be negative") self._value = v s = Speed(60) s.value = 80 print(s.value) # 80 # s.value = -4 # ValueError

This keeps _value hidden while providing a clean s.value interface. The caller never interacts with the underscore-prefixed internal storage.

Inheritance

Inheritance lets one class (the subclass or child) acquire the attributes and methods of another class (the superclass or parent). It models an “is-a” relationship and promotes code reuse.

Theory

Without inheritance, every class would need to rewrite code shared by related classes. Inheritance solves this by placing shared logic in a parent class and letting children reuse or specialize it. A Dog is an Animal, so Dog can inherit everything Animal knows and override only what differs.

Inheritance also establishes substitutability: wherever an Animal is expected, a Dog can be used. This is the Liskov Substitution Principle.

Single Inheritance

class Animal: def __init__(self, name): self.name = name def speak(self): return f"{self.name} makes a sound" class Dog(Animal): def speak(self): return f"{self.name} barks" class Cat(Animal): def speak(self): return f"{self.name} meows" d = Dog("Rex") c = Cat("Whiskers") print(d.speak()) # Rex barks print(c.speak()) # Whiskers meows print(d.name) # Rex -- inherited from Animal

Dog inherits __init__ and name from Animal. It overrides speak with its own version.

super()

super() provides a proxy to the next class in the hierarchy. It is used to call the parent’s version of a method, most commonly __init__, to avoid duplicating the parent’s setup logic.

class Animal: def __init__(self, name): self.name = name class Dog(Animal): def __init__(self, name, breed): super().__init__(name) # calls Animal.__init__ self.breed = breed d = Dog("Rex", "Labrador") print(d.name) # Rex print(d.breed) # Labrador

Without super().__init__(name), self.name would never be set and accessing it would raise AttributeError.

Multiple Inheritance

Python allows a class to inherit from more than one parent simultaneously.

class Flyable: def fly(self): return "flying" class Swimmable: def swim(self): return "swimming" class Duck(Flyable, Swimmable): pass d = Duck() print(d.fly()) # flying print(d.swim()) # swimming

Method Resolution Order (MRO)

When a method is called, Python searches the class hierarchy in a specific order called the Method Resolution Order (MRO). Python uses the C3 linearization algorithm to compute it, which guarantees a consistent, predictable order. Use ClassName.__mro__ or ClassName.mro() to inspect it.

class A: def hello(self): return "A" class B(A): def hello(self): return "B" class C(A): def hello(self): return "C" class D(B, C): pass print(D.__mro__) # (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>) print(D().hello()) # B -- first class in MRO that defines hello

super() follows the MRO cooperatively. Each class that needs to initialize must call super().__init__(...) so the next class in the chain also runs.

class A: def __init__(self): print("A init") super().__init__() class B(A): def __init__(self): print("B init") super().__init__() class C(A): def __init__(self): print("C init") super().__init__() class D(B, C): def __init__(self): print("D init") super().__init__() D() # D init # B init # C init # A init

The MRO for D is D -> B -> C -> A -> object. Each super().__init__() calls the next class in that chain. A is initialized exactly once.

The Diamond Problem

The diamond problem occurs when a class has two parents that both inherit from the same grandparent. In some languages this causes ambiguity. Python’s C3 MRO resolves it cleanly: the shared ancestor appears exactly once in the MRO and is initialized exactly once.

A / \ B C \ / D

D.__mro__ is D -> B -> C -> A -> object. A.__init__ runs once, at the right time, as long as every class calls super() cooperatively.

isinstance() and issubclass()

FunctionReturns True when
isinstance(obj, Class)obj is an instance of Class or any subclass
issubclass(Sub, Super)Sub is Super or inherits from it
class Animal: pass class Dog(Animal): pass d = Dog() print(isinstance(d, Dog)) # True print(isinstance(d, Animal)) # True -- subclass counts print(issubclass(Dog, Animal)) # True print(issubclass(Dog, object)) # True -- all classes inherit from object

Both accept a tuple as the second argument: isinstance(obj, (A, B)) returns True if obj is an instance of A or B.

Mixin Classes

A mixin is a class that provides specific behavior to other classes through multiple inheritance but is not meant to stand alone. Mixins add reusable behavior without forming a strict “is-a” relationship.

class JsonMixin: def to_json(self): import json return json.dumps(self.__dict__) class LogMixin: def log(self, message): print(f"[{self.__class__.__name__}] {message}") class User(JsonMixin, LogMixin): def __init__(self, name, age): self.name = name self.age = age u = User("Alice", 28) print(u.to_json()) # {"name": "Alice", "age": 28} u.log("logged in") # [User] logged in

Mixins should avoid defining __init__ and should not be instantiated directly. They work best as narrow, focused, reusable behavior units.

Polymorphism

Polymorphism means many forms. In Python, it is the ability of different objects to respond to the same method call or interface in their own way. The caller does not need to know which type it is working with.

Theory

Polymorphism removes the need for if type(x) == ...: do_this elif type(x) == ...: do_that branching. Instead, every object knows how to handle the call itself. This makes code easier to extend: adding a new type requires no changes to the calling code.

There are two main forms of polymorphism in Python: method overriding (through inheritance) and duck typing (through shared interface, without inheritance).

Method Overriding

A subclass redefines a method from the parent. When the method is called on the subclass instance, the subclass version runs.

class Shape: def area(self): return 0 class Square(Shape): def __init__(self, side): self.side = side def area(self): return self.side ** 2 class RoundShape(Shape): def __init__(self, radius): self.radius = radius def area(self): import math return math.pi * self.radius ** 2 shapes = [Square(4), RoundShape(2), Square(6)] for s in shapes: print(s.area()) # each calls its own version of area()

The loop does not know or care which type each object is. It calls .area() and each class handles it differently. Adding a new shape requires only a new class with an area() method; the loop stays unchanged.

Duck Typing

Python extends polymorphism beyond inheritance. Objects do not need to share a base class to be used interchangeably. If they have the right methods, they work. This is called duck typing: “if it walks like a duck and quacks like a duck, it is a duck.”

class FileWriter: def write(self, data): print(f"writing to file: {data}") class NetworkSender: def write(self, data): print(f"sending over network: {data}") class NullWriter: def write(self, data): pass # discards output silently def send(output, data): output.write(data) # any object with .write() works send(FileWriter(), "hello") # writing to file: hello send(NetworkSender(), "hello") # sending over network: hello send(NullWriter(), "hello") # (silent)

None of these classes inherit from each other. They share an interface purely by method name.

isinstance() vs type() in Polymorphic Code

CheckReturns True whenUse in polymorphism
isinstance(obj, Class)Exact class or any subclassYes — preferred
type(obj) == ClassExact class onlyNo — breaks inheritance
type(obj) is ClassExact class onlyNo — same limitation

type(obj) == SomeClass breaks when subclasses are involved. In polymorphic code, always use isinstance().

class Animal: pass class Dog(Animal): pass d = Dog() type(d) == Animal # False -- exact type check fails for subclass isinstance(d, Animal) # True -- subclass-aware

Abstraction

Abstraction hides implementation details and exposes only a clean interface. The user of a class works through its public API without needing to know how it works internally.

Theory

Abstraction is about managing complexity. A developer using a Shape class should not need to care whether the area is computed using trigonometry or a lookup table — they just call .area(). This separation of “what it does” from “how it does it” makes code easier to use and change independently.

In Python, abstraction is formalized through Abstract Base Classes (ABCs) from the abc module.

Abstract Base Classes

An abstract class defines methods that its subclasses must implement. It cannot be instantiated directly. A method marked with @abstractmethod is the contract — any concrete subclass must provide a real implementation.

from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: pass @abstractmethod def perimeter(self) -> float: pass def describe(self): return f"area={self.area():.2f}, perimeter={self.perimeter():.2f}" # Shape() # TypeError: Can't instantiate abstract class Shape
class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height def perimeter(self): return 2 * (self.width + self.height) r = Rectangle(4, 6) print(r.area()) # 24 print(r.perimeter()) # 20 print(r.describe()) # area=24.00, perimeter=20.00

A subclass that leaves any @abstractmethod unimplemented also cannot be instantiated — the TypeError propagates down the chain.

Concrete Methods in Abstract Classes

Abstract classes can contain concrete (non-abstract) methods. The describe() method above is concrete: it is defined once in Shape and inherited by all subclasses. It calls self.area() and self.perimeter(), which are guaranteed to be implemented by the time any concrete instance exists.

This is the power of the pattern: the abstract class defines the skeleton, and the subclasses fill in the specific parts.

Abstract Properties

@abstractmethod can combine with @property to require a property in every subclass.

from abc import ABC, abstractmethod class Vehicle(ABC): @property @abstractmethod def max_speed(self) -> int: pass class Car(Vehicle): @property def max_speed(self): return 200 class Bicycle(Vehicle): @property def max_speed(self): return 40 print(Car().max_speed) # 200 print(Bicycle().max_speed) # 40

The order of decorators matters: @property goes first (outermost), @abstractmethod goes second (innermost). Reversing the order leads to incorrect behavior.

ABC vs ABCMeta

ApproachCodeNotes
Inherit from ABCclass MyClass(ABC):Cleaner, preferred
Use ABCMeta directlyclass MyClass(metaclass=ABCMeta):Necessary when combining with another metaclass

Inheriting from ABC is the common form. ABCMeta is used directly only when a class already uses another metaclass and there would be a conflict.

Registering Virtual Subclasses

ABC.register() lets a class pass isinstance() checks for an ABC without directly inheriting from it. This is useful when integrating third-party classes that cannot be modified.

from abc import ABC class Drawable(ABC): pass class LegacyChart: def draw(self): print("drawing chart") Drawable.register(LegacyChart) print(isinstance(LegacyChart(), Drawable)) # True

Registration does not add any methods. It only affects isinstance() and issubclass() checks.

Protocol (Structural Subtyping)

Python 3.8 introduced Protocol from typing for structural subtyping. A class “implements” a protocol if it has the right methods, without inheriting from anything. It is the formal, type-checker-friendly version of duck typing.

from typing import Protocol class Writable(Protocol): def write(self, data: str) -> None: ... class FileWriter: def write(self, data: str) -> None: print(f"file: {data}") def send(output: Writable, data: str) -> None: output.write(data) send(FileWriter(), "hello") # file: hello
FeatureABCProtocol
Inheritance requiredYesNo
isinstance() checkYesOnly with @runtime_checkable
Prevents direct instantiationYesNo
Works with type checkersYesYes
Use forEnforced interface in a class hierarchyStructural duck-typing with type hints

@runtime_checkable makes a Protocol usable with isinstance() at runtime:

from typing import Protocol, runtime_checkable @runtime_checkable class Writable(Protocol): def write(self, data: str) -> None: ... print(isinstance(FileWriter(), Writable)) # True

Tricky Behaviors

Encapsulation - name mangling is not true privacy

__attr becomes _ClassName__attr. The mangled name is always accessible. Name mangling exists to avoid accidental attribute clashes in subclasses, not to enforce security. Anyone who knows the class name can access the mangled attribute.

Encapsulation - single underscore is a convention only

_attr is not protected by the interpreter. Any code can read or write it. The convention signals “internal,” but nothing prevents access from outside the class.

Encapsulation - name mangling applies at class definition, not at access time

Python transforms __attr to _ClassName__attr when the class body is compiled. Attribute accesses written inside the class body get mangled. Accessing obj.__pin from outside the class body does not get mangled and raises AttributeError instead.

Encapsulation - name mangling in subclasses uses the subclass’s own name

If a subclass defines its own __attr, it gets mangled to _SubclassName__attr, not _ParentName__attr. Parent and subclass can each have an attribute spelled __attr without collision, because they are stored under different mangled names.

class Parent: def __init__(self): self.__secret = 42 # stored as _Parent__secret class Child(Parent): def __init__(self): super().__init__() self.__secret = 100 # stored as _Child__secret; separate attribute c = Child() print(c._Parent__secret) # 42 print(c._Child__secret) # 100

Inheritance - forgetting super().__init__() in a subclass

If a subclass defines __init__ and does not call super().__init__(), all parent initialization is skipped. Parent attributes like self.name are never set and raise AttributeError at the first access.

Inheritance - super() follows the MRO, not just the direct parent

super() inside a class calls the next class in the full MRO, not necessarily the direct parent. In class D(B, C) where both inherit from A, super() inside B calls C (based on D’s MRO), not A directly. Each class must call super().__init__() cooperatively for the entire chain to run.

Inheritance - calling the parent class directly breaks cooperative multiple inheritance

ParentClass.__init__(self) hardcodes the parent and bypasses the MRO. In single inheritance this works, but in multiple inheritance it means the next class in the MRO after ParentClass is never called. Always use super() so the full chain runs correctly.

Inheritance - all classes inherit from object

Every class in Python implicitly inherits from object. This provides default implementations of __repr__, __eq__, __hash__, and others. Writing class Dog: is equivalent to class Dog(object):.

Inheritance - isinstance() and issubclass() accept tuples

isinstance(obj, (A, B)) returns True if obj is an instance of A or B. Same for issubclass(cls, (A, B)). This is often cleaner than chaining or expressions.

Inheritance - method overloading does not exist in Python

Python does not allow defining the same method name multiple times with different signatures. The second definition silently replaces the first. The idiomatic alternative is to use default arguments, *args, or **kwargs to handle multiple calling patterns inside a single method.

class Greeter: def greet(self, name="World"): return f"Hello, {name}" # handles both greet() and greet("Alice")

Polymorphism - duck typing fails at the call site

If an object is passed to a function that expects a specific interface and that object is missing a method, AttributeError is raised at the point of the call, not when the object is created or passed. Type checking with isinstance() or Protocol catches this problem earlier.

Polymorphism - type() breaks for subclasses

Checking type(obj) == SomeClass returns False for instances of subclasses. Polymorphic code that uses type() for dispatch becomes fragile whenever inheritance is involved. Always use isinstance().

Polymorphism - operator overloading is a form of polymorphism

Dunder methods like __add__, __eq__, and __lt__ let the same operator behave differently depending on the types involved. a + b calls type(a).__add__(a, b). If that returns NotImplemented, Python tries type(b).__radd__(b, a), giving the right-hand operand a chance to handle the operation.

Abstraction - abstract class cannot be instantiated even partly

Python raises TypeError when any @abstractmethod is unimplemented. This propagates to subclasses: if a subclass also leaves any abstract method unimplemented, it too cannot be instantiated.

Abstraction - @property and @abstractmethod decorator order matters

@property @abstractmethod def speed(self): ...

@property must be the outer decorator (written first, applied last). Reversing the order does not raise an error immediately but produces incorrect behavior at runtime.

Abstraction - register() does not add or enforce methods

ABC.register(SomeClass) makes isinstance(SomeClass(), MyABC) return True, but it does not force SomeClass to implement any abstract methods. Registered classes bypass the abstract method check entirely. This is intentional for integrating legacy code, but it is a potential source of bugs.

Abstraction - Protocol does not enforce at runtime by default

A Protocol is checked only by static type checkers (like mypy or pyright) unless @runtime_checkable is added. Without it, isinstance(obj, SomeProtocol) raises TypeError at runtime.

Interview Questions

What is encapsulation and how does Python implement it?

Encapsulation bundles data and behavior together and restricts direct access to internal state. Python uses naming conventions: _attr signals “internal, do not modify from outside,” and __attr triggers name mangling (transforming it to _ClassName__attr). Neither is enforced by the language. The recommended Python idiom is @property with a validating setter to expose data safely.

What is name mangling and what is its purpose?

An attribute with two or more leading underscores and at most one trailing underscore (like __pin) is transformed to _ClassName__pin at compile time. It prevents accidental overriding or name clashes in subclasses, not unauthorized access from outside. The mangled name is always reachable.

What is the difference between _attr and __attr?

_attr is a convention meaning “internal or protected” and is accessible from anywhere. __attr triggers name mangling and becomes _ClassName__attr, making it invisible under its original name from outside the class and harder to clash with in subclasses. Neither is enforced by the interpreter.

What is inheritance and why use it?

Inheritance lets a subclass reuse and extend the code of a parent class. It models an “is-a” relationship (Dog is an Animal). Without it, the same logic would need to be written in every class separately. Subclasses can override inherited methods and add new ones.

What is the Liskov Substitution Principle?

The Liskov Substitution Principle states that a subclass should be usable anywhere its parent class is expected without breaking the program. In Python terms: if Dog inherits from Animal, any code that works with an Animal should also work correctly with a Dog. Violating this principle — for example by overriding a method to raise NotImplementedError — breaks the substitutability guarantee and undermines polymorphism.

What does super() do and why is it needed?

super() returns a proxy to the next class in the MRO. It is used to call the parent’s version of a method — most often __init__ — without hardcoding the parent class name. In multiple inheritance, super() enables cooperative initialization by following the full MRO rather than jumping straight to the direct parent.

What is the difference between super() and calling the parent class directly?

ParentClass.__init__(self) hardcodes the parent and bypasses the MRO. In single inheritance this works, but in multiple inheritance it skips every class that comes after ParentClass in the MRO. super() always calls the next class in the full MRO, which is why cooperative multiple inheritance requires it.

What does super() call in multiple inheritance?

super() calls the next class in the full MRO, not necessarily the direct parent. In class D(B, C) where both inherit from A, super() inside B.__init__ calls C.__init__ (based on D’s MRO), not A.__init__ directly. For all parents to run, each class must call super().__init__() cooperatively.

What is the Method Resolution Order (MRO)?

The MRO is the order Python uses to look up attributes and methods in a class hierarchy. It is computed by the C3 linearization algorithm. For class D(B, C), Python searches D, then B, then C, then their shared ancestors, then object. Use D.__mro__ to inspect it.

What is the diamond problem and how does Python solve it?

When class D(B, C) and both B and C inherit from A, there is potential ambiguity about which path to A to follow. Python’s C3 MRO guarantees a single, consistent path. A appears once in the MRO and is initialized exactly once, as long as every class in the chain calls super() cooperatively.

What happens when two parents both define the same method?

The MRO determines which version runs. Python searches left to right through the parent list. In class D(B, C), if both B and C define hello(), D().hello() calls B’s version because B appears first in the MRO. Use D.__mro__ to see the full lookup order.

What is the purpose of a mixin class?

A mixin provides specific behavior (like JSON serialization or logging) to other classes through multiple inheritance. It is not meant to be used on its own and typically avoids defining __init__. Mixins are a clean way to add reusable, focused behavior to multiple unrelated classes without building a deep inheritance tree.

Does Python support method overloading?

No. Defining the same method name twice silently replaces the first definition with the second. The idiomatic alternative is to use default arguments or *args/**kwargs to handle multiple calling patterns inside a single method.

What is polymorphism and how does Python support it?

Polymorphism lets different types be used through the same interface. Python supports it through method overriding (subclasses redefining parent methods) and duck typing (any object with the right methods works, regardless of its class). Python does not require a shared base class for polymorphism.

What is duck typing?

Duck typing means that an object’s suitability is determined by its methods and properties, not by its type or class hierarchy. If an object has the interface the code expects, it works, regardless of what class it belongs to. The name comes from “if it walks like a duck and quacks like a duck, it is a duck.”

What is the difference between isinstance() and type() == in polymorphic code?

type(obj) == SomeClass is exact: it returns True only for the exact class, not subclasses. isinstance(obj, SomeClass) returns True for the class and all subclasses. In polymorphic code, always use isinstance() because it respects the class hierarchy.

What is operator overloading and how does it relate to polymorphism?

Operator overloading means defining dunder methods like __add__, __eq__, or __lt__ so that operators work on custom objects. It is a form of polymorphism: a + b behaves differently depending on the types of a and b. If a.__add__(b) returns NotImplemented, Python tries b.__radd__(a), letting the right-hand operand handle the operation.

What is abstraction and what is an Abstract Base Class?

Abstraction hides implementation details and exposes only a defined interface. An Abstract Base Class (ABC) defines the interface that subclasses must provide using @abstractmethod. It cannot be instantiated directly. A subclass that leaves any abstract method unimplemented also cannot be instantiated.

Why can an abstract class not be instantiated?

Python tracks unimplemented abstract methods in the __abstractmethods__ attribute. If it is non-empty, Python raises TypeError when instantiation is attempted. A subclass clears this attribute only by implementing every abstract method. Leaving even one unimplemented keeps the subclass abstract too.

Can a concrete subclass of an ABC define its own abstract methods?

Yes. A subclass that inherits from an ABC can add new @abstractmethod declarations. That subclass then becomes abstract itself and cannot be instantiated until all abstract methods — inherited ones and newly declared ones — are implemented by a further concrete subclass.

What is the difference between ABC and Protocol?

ABC with @abstractmethod requires explicit inheritance. Subclasses must inherit from the ABC to be considered compliant. Protocol (Python 3.8+) uses structural subtyping: any class with the right method signatures implements the protocol without inheriting from it. Protocol is the type-checker-friendly version of duck typing. Use ABC for enforced interfaces in a class hierarchy; use Protocol for flexible interface definitions that work across unrelated classes.

How would you implement a singleton using __new__?

__new__ is called before __init__ and returns the instance. To implement a singleton, store the single instance on the class and return it from __new__ if it already exists.

class Singleton: _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance a = Singleton() b = Singleton() print(a is b) # True -- both variables point to the same object

This is one approach. Thread-safe singletons require additional synchronization (e.g. a lock around the check-and-create step).

Last updated on