// python · oop

Object-oriented Python: from loose dicts to classes that earn their keep

Learn how OOP actually works in Python by building a small food-delivery order system — classes, self, inheritance, dunder methods, and composition. Three simulators let you watch objects get built, method calls get dispatched, and operators turn into dunder calls.

mindmap — quick refresh OOP in Python — data + behavior travel together why classes dicts + loose functions drift apart a class glues state to the functions that own it anatomy of an object class = namespace of functions, built once instance = its own __dict__ of data o.total() is Order.total(o) — self is explicit the vocabulary member variable = attribute, per-object in __dict__ class attribute = one shared copy, on the class member function = method — self pre-bound method = function defined in a class + bound instance inheritance lookup: instance → class → bases (the MRO) same call site, different code = polymorphism super() runs the parent's version first dunder methods print / len / == are method calls in disguise no dunder found → TypeError, not magic __repr__ pays off in every traceback composition has-a beats is-a for wiring parts together inject the payment — don't inherit it when not to write a class two functions, no state → a module is fine pure data → @dataclass writes the boilerplate

Prerequisites: comfort with Python functions, lists, and dicts — no OOP background needed. This guide stands on its own.

Three facts make object-oriented Python easier than the theory suggests. First, everything in Python is already an object — 3, "hi", lists, functions, even classes themselves. Second, the "magic" syntax is a thin disguise: len(x) literally calls x.__len__(), and o.total() literally calls Order.total(o). Third, a class is just two things glued together: a dict of data and the functions allowed to touch it. Writing a class isn't switching paradigms — it's joining how the language already works.

We'll build the core of a small food-delivery app — orders, menu items, payments — because it's a domain where the objects are obvious: real systems like this are why OOP exists.

Why classes at all

Start with what goes wrong without them. Here's the no-class version of an order:

order = {"customer": "Asha", "items": []}

def add_item(order, name, price):
    order["items"].append((name, price))

def order_total(order):
    return sum(price for _, price in order["items"])

This works — until the app grows. The data shape lives in one place, the functions that depend on that shape live somewhere else, and nothing ties them together. Type order["cutsomer"] and Python happily returns a KeyError at some later, unrelated line. Pass the wrong dict to order_total and nothing complains until production. Every new teammate has to discover, by reading, which functions belong to which dict shape.

A class fixes exactly this — it puts the data and its functions in the same box, with a name:

class Order:
    def __init__(self, customer):
        self.customer = customer
        self.items = []            # list of (name, price) tuples

    def add(self, name, price):
        self.items.append((name, price))

    def total(self):
        return sum(price for _, price in self.items)

Same logic, but now Order is the shape. o.add("momo", 150) can't be called on the wrong thing by accident, autocomplete knows what an order can do, and there is one obvious place to add behavior.

Anatomy of an object

The mechanics are simpler than they look, and worth seeing once, literally:

  • The class statement runs once and builds a namespace holding the functions (__init__, add, total).
  • Calling Order("Asha") does two steps: Python allocates an empty object, then calls __init__(new_object, "Asha"). self is not a keyword — it's just the first parameter, and it is that new object.
  • Every self.x = ... line lands in that object's own __dict__. Data lives per-instance; the methods exist once, on the class.
  • o.add("momo", 150) triggers a lookup: is add in o.__dict__? No → check the class → found → call it with self=o.

Watch it happen — the class namespace on the left, each instance's __dict__ on the right:

That's the whole trick. Once you see that o1 and o2 share one copy of the methods but own separate __dict__s, most OOP confusion — "where does this attribute live?", "why did changing one object not affect the other?" — disappears.

The vocabulary: member variables, methods, and functions

OOP textbooks (and C++ or Java courses) use terms Python renames. If you're coming from either direction, this table is the translation layer:

Textbook / C++ / Java termPython termWhere it lives
member variableattribute (instance variable)in one object's __dict__self.customer
static / class member variableclass attributeon the class itself — one copy shared by all instances
member functionmethodon the class; self gets bound when you call it
constructor__init__on the class; runs right after the object is allocated

Two of these deserve a closer look, because the distinction does real work in Python.

Member variable vs class attribute. Everything assigned through self is a member variable — per-object data, in that object's __dict__. But you can also assign a variable directly in the class body, and then there's exactly one copy, shared:

class Order:
    delivery_fee = 50            # class attribute — ONE copy, on the class

    def __init__(self, customer):
        self.customer = customer # member variables (instance attributes) —
        self.items = []          # a fresh copy per object

The attribute lookup you just watched in the simulator explains how both work with the same dot syntax: o1.delivery_fee misses in o1.__dict__ and falls through to the class. Use class attributes for constants shared by every instance; use self.x for anything that varies per object. (Classic trap: a mutable class attribute like items = [] in the class body would be shared by every order — always create mutable state in __init__.)

Method vs function. In Python a method is not a different kind of thing — it's a plain function that lives in a class, plus one convenience:

def total(order):                # a function — you pass the data in explicitly
    return sum(p for _, p in order.items)

class Order:
    def total(self):             # the same function, as a method (member function)
        return sum(p for _, p in self.items)

o = Order("Asha")
Order.total                      # ← just a function; you'd call Order.total(o)
o.total                          # ← a *bound method*: o is pre-filled as self
o.total()                        # ← so this equals Order.total(o)

So the practical differences are exactly three: a method is defined inside a class, it receives the instance as its first parameter (self), and when you access it through an object Python hands you a bound method with self already filled in. That's why o.total() takes no arguments even though total declares one. A function stands alone and must be given all its data; a method travels with the object that owns the data.

Inheritance: lookup with a fallback chain

Our app takes payments, and payments come in kinds — card, wallet, QR. They share an amount and a receipt, but each pays differently. That's the shape inheritance is for:

class Payment:
    def __init__(self, amount):
        self.amount = amount

    def pay(self):
        raise NotImplementedError    # every subclass must provide this

    def receipt(self):
        return f"paid Rs {self.amount} via {type(self).__name__}"

class CardPayment(Payment):
    def __init__(self, amount, last4):
        super().__init__(amount)     # run Payment's __init__ first
        self.last4 = last4

    def pay(self):
        return f"charging card •••• {self.last4}"

class WalletPayment(Payment):
    def pay(self):
        return f"deducting Rs {self.amount} from wallet"

The checkout loop is where it pays off — one call site, no if payment_type == ... ladder:

for p in [CardPayment(850, "4242"), WalletPayment(120)]:
    print(p.pay())        # same line — different code runs
    print(p.receipt())    # not on the subclass; found on Payment

Two mechanisms are working here, and they're both just the attribute lookup from the last section, extended one hop:

  1. Inheritance = the lookup doesn't stop at the class. p.receipt misses on CardPayment, so Python climbs to Payment and finds it there. The climb order is the MRO — method resolution order.
  2. Polymorphism = the lookup starts at the object's own class. p.pay() finds CardPayment.pay for a card and WalletPayment.pay for a wallet — the same line of code dispatches to different behavior.

Step through the dispatch — including what happens when a subclass forgets to implement pay():

One rule of thumb before you build deep hierarchies: inherit for is-a with shared contract (a CardPayment is a Payment), not for code reuse alone. Two levels is usually plenty; we'll see the alternative below.

Dunder methods: speaking native Python

Right now our Order is a second-class citizen. print(o) shows <__main__.Order object at 0x104f83b90>, len(o) is a TypeError, and == compares memory addresses. Python's fix is the dunder (double-underscore) methods — the hooks the language calls behind its own syntax:

class Order:
    # ... __init__, add, total as before ...

    def __repr__(self):
        return f"Order({self.customer!r}, {len(self.items)} items)"

    def __len__(self):
        return len(self.items)

    def __eq__(self, other):
        return (isinstance(other, Order)
                and self.customer == other.customer
                and self.items == other.items)

Now the built-in syntax just works — because each piece of syntax is a method call in disguise:

The one to always implement is __repr__: it's what appears in tracebacks, logs, and the debugger, so ten minutes writing it repays itself on the first bad day. Beyond these three, the pattern generalizes: o[k] calls __getitem__, for x in o calls __iter__, with o: calls __enter__/__exit__, a + b calls __add__. You don't memorize the list — you look up the hook for the syntax you want your object to support.

Composition: has-a beats is-a

How do Order and Payment meet? The beginner trap is inheritance everywhere — class CardOrder(Order, CardPayment) — which welds the two hierarchies together and multiplies every future kind-of-order by every kind-of-payment. The real-world relationship isn't is-a, it's has-a: an order has a payment. So hold one, don't inherit one:

class Order:
    def __init__(self, customer, payment):
        self.customer = customer
        self.items = []
        self.payment = payment       # any object with .pay() — injected

    def checkout(self):
        return self.payment.pay()

order = Order("Asha", CardPayment(850, "4242"))
order.checkout()                     # 'charging card •••• 4242'

This is composition, and it's the workhorse of real Python systems: build small classes that each do one thing, then wire them together by passing objects in. Swapping card for wallet is now a one-argument change; testing Order needs only a fake object with a pay() method. When in doubt between inheritance and composition, reach for composition — you can always add inheritance later, but un-inheriting is surgery.

When not to write a class

OOP earns its keep when data and behavior genuinely travel together. Two common cases where a class is the wrong tool:

  • Behavior with no state. If it's two functions that share nothing between calls, a module with plain functions is the honest design. A class with only @staticmethods is a folder pretending to be an object.
  • State with no behavior. If it's pure data — a menu item has a name and a price, and that's it — write a dataclass and let Python generate __init__, __repr__, and __eq__ for you:
from dataclasses import dataclass

@dataclass
class MenuItem:
    name: str
    price: int

MenuItem("momo", 150)      # MenuItem(name='momo', price=150) — repr & == for free

@dataclass isn't a different kind of class — it's the same machinery from this guide with the boilerplate auto-written. Which is the right closing note: none of what you've seen is a separate "OOP layer" bolted onto Python. It's attribute lookup, __dict__s, and dunder hooks — the language you were already using.

Takeaways

  • A class glues data to the functions that own it — reach for one when dicts and loose functions start drifting apart, not because "OOP is proper".
  • self is just the object, passed explicitlyo.total() is Order.total(o); every self.x = ... lands in that one object's __dict__, while methods live once on the class.
  • Inheritance is attribute lookup with a fallback chain — the search runs instance → class → bases; polymorphism is that search starting at the object's own class.
  • Operators and built-ins are dunder calls in disguise — implement __repr__ always, then add the hook (__len__, __eq__, __iter__, …) for whatever syntax your object should support.
  • Prefer composition (has-a) over inheritance (is-a) for wiring parts together — inject the payment into the order; don't weld hierarchies with multiple inheritance.
  • Skip the class when it earns nothing — stateless behavior belongs in module functions, and pure data belongs in a @dataclass.

References

  • Python Software Foundation. (n.d.). Classes (The Python tutorial, chapter 9). Retrieved August 16, 2026, from https://docs.python.org/3/tutorial/classes.html
  • Python Software Foundation. (n.d.). Data model (The Python language reference). Retrieved August 16, 2026, from https://docs.python.org/3/reference/datamodel.html
  • Python Software Foundation. (n.d.). dataclasses — Data Classes (Python documentation). Retrieved August 16, 2026, from https://docs.python.org/3/library/dataclasses.html
  • Ramalho, L. (2022). Fluent Python: Clear, concise, and effective programming (2nd ed.). O'Reilly Media.