Python – OOP

July 30, 20265 min readUpdated 8/20/2026

Object-oriented programming in Python works differently from the languages it borrowed the vocabulary from. There are no interfaces to declare and no private to enforce. What matters is what an object can do, and this post covers how that changes the advice.

Inheritance

class Account:
    def __init__(self, number, balance):
        self.number = number
        self.balance = balance

    def describe(self):
        return f"{self.kind()} {self.number}: ${self.balance:,.2f}"

    def kind(self):
        return "Account"

class Savings(Account):
    def __init__(self, number, balance, rate):
        super().__init__(number, balance)
        self.rate = rate

    def kind(self):
        return "Savings"

    def add_interest(self):
        self.balance += self.balance * self.rate

s = Savings("2001-0001", 8400.50, 0.02)
s.add_interest()

print(s.describe())     # Output: Savings 2001-0001: $8,568.51

class Savings(Account) inherits everything. super().__init__(...) runs the parent's initialiser — call it first, before your own setup, and do not skip it or the parent's attributes never get created.

Notice what describe() did: it is defined on Account, but it called kind() and got the subclass version. That is polymorphism, and Python does it by default — every method is overridable and lookup always starts at the actual class.

Duck typing: no interface required

Python does not care what class an object is, only whether it has the method you are about to call:

class Card:
    def pay(self, amount):
        return f"charged ${amount} to card"

class BankTransfer:
    def pay(self, amount):
        return f"transferred ${amount}"

def checkout(method, amount):
    return method.pay(amount)          # no shared base class needed

print(checkout(Card(), 25))            # Output: charged $25 to card
print(checkout(BankTransfer(), 25))    # Output: transferred $25

These two classes are unrelated. Neither declares that it implements anything. checkout works because both have a pay method — "if it walks like a duck".

The consequence for design: an interface in Python is a set of method names people agree on, not a declaration. That is why you will see far less inheritance in Python than in Java — most of what inheritance is used for there is achieved here by simply having the right methods.

Abstract base classes, when you want the promise enforced

Duck typing fails late — you find out a method is missing when something calls it. When that is not good enough, abc makes the requirement explicit:

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def pay(self, amount): ...

class Voucher(PaymentMethod):
    pass          # never implemented pay()

Voucher()

That raises TypeError: Can't instantiate abstract class Voucher at the moment you try to create one, rather than later when something calls pay. The class becomes a contract you cannot half-implement.

Use one when several classes must be interchangeable and forgetting a method is a real risk. For two classes in the same file, duck typing is enough.

Properties instead of getters and setters

Do not write get_balance() and set_balance(). Expose the attribute, and if you later need logic, add it without changing a single caller:

class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value

a = Account(100)
a.balance = 250
print(a.balance)      # Output: 250

try:
    a.balance = -1
except ValueError as error:
    print(error)      # Output: balance cannot be negative

Callers still write a.balance and a.balance = 250. This is why Java's habit of writing getters for every field up front is unnecessary here: you can start with a plain attribute and introduce a property later without breaking anything.

Prefer composition

Inheritance says "is a"; composition says "has a". The second is right more often:

class InterestCalculator:
    def __init__(self, rate):
        self.rate = rate

    def yearly(self, balance):
        return round(balance * self.rate, 2)

class Account:
    def __init__(self, balance, calculator):
        self.balance = balance
        self.calculator = calculator      # HAS a calculator

    def projected(self):
        return self.balance + self.calculator.yearly(self.balance)

a = Account(1000.0, InterestCalculator(0.05))
print(a.projected())      # Output: 1050.0

The calculator can be swapped, tested on its own, and reused by something that is not an account. A class SavingsAccount(Account) hierarchy that grows a branch per product ends up with subclasses inheriting behaviour they do not want.

The test: if you are inheriting to reuse code rather than because the subclass genuinely is a kind of the parent, use composition.

Exceptions are the clearest hierarchy you will write

Where inheritance does earn its place is exception types. The bank app this track draws on defines one base class so a single except catches everything the app raises on purpose:

class BankError(Exception):
    ...

class AuthenticationError(BankError):
    ...

class ValidationError(BankError):
    ...

class InsufficientFundsError(BankError):
    ...

(The docstrings are elided here; in the app each class carries one explaining exactly when it is raised, which is the only documentation an exception class usually needs.)

except BankError then catches every rule the app enforces and nothing else — while except Exception would also swallow the typos in your own code. Exception Handling covers this properly.

The method resolution order

When a class has more than one parent, Python needs a rule for which method wins. That rule has a name and you can read it:

class Auditable:
    def save(self):
        return "audited"

class Timestamped:
    def save(self):
        return "timestamped"

class Account(Auditable, Timestamped):
    pass

print(Account().save())                          # Output: audited
print([c.__name__ for c in Account.__mro__])
# Output: ['Account', 'Auditable', 'Timestamped', 'object']

__mro__ is the method resolution order — the exact list Python walks, left to right, looking for a name. Auditable comes first in the class statement, so its save wins.

Every class ends at object, which is where the default __repr__ and __eq__ you have been overriding come from. When multiple inheritance confuses you, print the MRO — it turns a guess into a fact.

What Python does not have

  • No private. A leading underscore is a convention. There is no keyword and no enforcement.
  • No method overloading. One method per name — a second def replaces the first. Use default arguments or *args instead.
  • No interfaces. Duck typing, or an ABC when you want it checked.
  • Multiple inheritance is allowed, and mostly used for small mixins rather than two real parents.

Next: Dataclasses & Type Hints, which removes most of the boilerplate from everything above.