python/volume-1/chapter-12ch 12 / 64 · 23 min

12Data with behaviour

In Chapter 11 we mastered the object's memory. We sized it to the byte, and with __slots__ we even packed its fields wall-to-wall. This chapter asks an older question that memory never touched: why does a song's data live in one place and the code that plays it in another? A class is where the two finally move in together. Here's the plan. We build a Song and a Playlist from scratch, watch a class stamp out objects that each carry their own data, and pin down the single rule that decides which of two attributes Python finds first. The whole way through, we keep asking the one thing that matters: when you write blinding.play(), where does Python go looking, and in what order? By the end you'll have taught Python what +, == and len() mean on objects you designed. So Blinding Lights (200s) added to Titanium (245s) returns a 445-second playlist that runs on rules you wrote — no magic anywhere, just a lookup you can trace by hand.

Data and behaviour, finally in one place self is not magic — it's the instance Two kinds of attribute, one lookup rule Inheritance: borrow a class, change one thing
scroll to begin

01Data and behaviour, finally in one place

Let's start with the crack we've been stepping over for nine chapters. You can describe a song two ways, and they never quite meet. Its data lives in a dict — {"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200} (chapter 7). Its behaviour lives somewhere else entirely, in a free-standing function that plays it (chapter 9). The two travel apart, and that gap is the quiet flaw. Every function has to be handed the right dict, by hand, every single time, and nothing on Earth stops you handing it the wrong one. Slip a pizza-order dict into your play-song function and Python doesn't so much as flinch. It runs happily along until it reaches for a "title" that was never there and dies mid-show with a KeyError. Keeping the pair together was your job, and that kind of discipline is exactly the stuff bugs are made of.

Now watch the dict try to defend itself. It can't. To Python a song-dict and a pizza-dict are the very same type — both are merely dict — so type(order) cannot tell one from the other at all. There's no seam between them, no stamp that says this one is a song. What you're missing isn't more data. It's a kind.

THE ONE IDEA TO CARRY FORWARD
A class is a blueprint that names, in one place, what every song has (title, artist, seconds) and what every song can do (play). An object — also called an instance — is one concrete thing stamped out of that blueprint, its data filled in. From here on, a song's data and the behaviour that acts on it stop travelling apart.

A class ends the separation. You write the blueprint once — class Song — describing what every song has and what every song can do. Then you stamp concrete objects out of it, each with its own data poured in. One blueprint can build a thousand objects, the way one architect's drawing builds a whole street of houses. And the behaviour comes welded on. To play a song you no longer go hunting for the right function and feed it the right dict. You just ask the object itself, blinding.play(), and it already knows everything it needs to know about itself.

Two loose piles, welded into one kindfigure
before — two loose piles, nothing checks DATA — a dict (ch 7) song = { 'title': 'Blinding Lights', 'artist': 'The Weeknd', ... } BEHAVIOUR — a free function (ch 9) def play(song): return f'Playing {song[...]}' hand it a pizza-dict → nothing checks → KeyError, mid-show after — one object, one kind Song object @0x…a0 title 'Blinding Lights' artist 'The Weeknd' seconds 200 play() → 'Playing Blinding Lights by The Weeknd' data + behaviour, welded into one kind type(obj) is Song · no pizza can pass for this
Fig 1 — Before, a song's data (a dict) and its behaviour (a free function) were two loose piles you had to pair by hand — and Python checked nothing. A class welds them into one object with its own kind: Song.

Here's the part that should steady you: you have not left the memory model. This is chapter 3's picture again, unchanged. Each object is a box on the heap, and blinding is just a name holding a reference to it. id(blinding) still returns its address, two objects still carry two different ids, and aliasing still bites: point a second name at the same object and both names see every change. You didn't switch to some alien paradigm. You learned to design your own shape of box for the heap you already understood.

One blueprint, a whole playlist of objectsinteractive
the blueprint — written once class Song title artist seconds __init__() play() what every Song HAS + CAN DO Song(…) the heap — every object lives here (ch 11) Song @0x…a0 Blinding Lights The Weeknd 200 s Song @0x…e8 Titanium David Guetta 245 s Song @0x…40 One More Time Daft Punk 320 s Song @0x…98 Levels Avicii 199 s Song @0x…10 Clarity Zedd 271 s Song @0x…78 Wake Me Up Avicii 249 s 1 class → 2 objects
2 objects
Slide it up. Every new card is its own object on the heap — its own data, its own address — yet they all share the single play() written once in the blueprint. One drawing, a whole street of houses.
Fig 2 — One blueprint, many objects. Each stamped object is its own box on the heap with its own data and its own address; the behaviour is written once, in the class. Drag from a single object up to a whole playlist.

In code it's almost anticlimactic — the whole idea fits in a dozen lines. The blueprint is the class block, and each Song(…) call stamps out one object:

song.pypython
class Song:                                # the blueprint — written once
    def __init__(self, title, artist, seconds):
        self.title   = title                # store each value ON this object
        self.artist  = artist               # (self = the object being built —
        self.seconds = seconds              #  unpacked in full next section)

    def play(self):                        # behaviour now lives WITH the data
        return f"Playing {self.title} by {self.artist}"

blinding = Song("Blinding Lights", "The Weeknd", 200)   # object no. 1
titanium = Song("Titanium", "David Guetta", 245)        # object no. 2

print(blinding.play())          # Playing Blinding Lights by The Weeknd
print(type(blinding).__name__)   # Song  — its own kind, not just 'dict'

Run it and Python confirms the new shape. blinding.play() prints its line without ever being handed a dict, and type(blinding) answers Song — its own kind at last, not one more anonymous dict. The two objects, blinding and titanium, keep their own data cleanly apart: nudge blinding.seconds and titanium.seconds doesn't so much as twitch.

Wait — a dict already bundles title, artist and seconds under one name. So isn't a class just a dict wearing a tie? Two things a dict can never do: it can't carry its own play() along for the ride, and to Python it has no kind — a song-dict and a pizza-dict are both flatly dict, indistinguishable. A class fixes both at once: the behaviour rides inside the object, and type(blinding) proudly answers Song.
↺ reframe
A class isn't a fancier container — it's a new noun in your program's language. Before today you had exactly one kind of box, the dict, impersonating songs, orders, users, everything, and hoping you never mixed them up. Now Song is a kind unto itself: isinstance(blinding, Song) draws a line no pizza-order can cross. You stopped storing songs and started minting them.
YOU'VE BEEN STAMPING OBJECTS SINCE LINE ONE
This isn't a new world bolted onto Python — it's the world you were already standing in. type(200) is int; type("Blinding Lights") is str; str and int are themselves classes, and every string you've ever typed is an object stamped from the str blueprint — which is the whole reason "blinding".upper() works: the behaviour rides along, exactly like play(). All class Song does is let you author a blueprint of your own.

✗ The myth

Objects are a whole new paradigm — to write a class you have to switch your brain out of "variables and memory" and into some separate mode of "objects and messages."

✓ The reality

It's the same heap and the same references from chapter 3. An object is just a box on the heap that happens to bundle named data with the functions that act on it. blinding is still a name holding a reference; id() still works; aliasing still bites. You never left the memory model — you designed a new shape of box for it.

The deeper cut — where the data lives, and where the behaviour lives

Both figures draw every object clutching its own private play(), and that is a labelled simplification — true enough to picture, and refined here once. Peek inside a real instance and only the data is actually there: blinding.__dict__ is {'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200}, with no play anywhere in it. The function lives exactly once, up on the class itself: 'play' in Song.__dict__ is True, while 'play' in blinding.__dict__ is False. So a thousand Song objects do not haul around a thousand copies of play(). They hold a thousand small bags of data and all reach back to the one shared function the instant you call it. That's why the slider can stamp object after object almost for free: new data is cheap, and the behaviour was paid for a single time. It also plants the first clue to a mystery. If there's only one play() for every song, how does blinding.play() know it's Blinding Lights and not Titanium? The object is quietly slipped in as an argument, and that handoff is the entire next section.

The cost of behaviour, at scaleinteractive
a playlist of Song objects — how many copies of play() must exist? 10,000,000 songs on the heap data bags on the heap: 10,000,000 — those you truly pay for; only the code is shared for free. IF EACH OBJECT KEPT ITS OWN play() 10,000,000 copies of the one identical function WITH ONE SHARED BLUEPRINT 1 function — written once, on the class DUPLICATED CODE SITTING IN RAM ~1.36 GB ≈ ten million clones of the same bytecode One class collapses 10,000,000 identical functions into 1 — the dict could hold the data, never the shared behaviour.
10,000,000 songs
Slide from a single song up to ten million. The data bags keep growing — those you genuinely pay for. But play() never multiplies: it is written once on the class and every object reaches back to that one copy. The gap between the two big numbers is the reason a class beats a bare dict — a dict can carry the data, never the shared behaviour.
Fig 2b — The price of behaviour, at scale. A naive object model would copy every method into every instance; Python's shared blueprint keeps exactly one. Drag from one song to ten million and watch the duplicate-code counter explode across the orders of magnitude while the shared copy never budges off 1.

You wrote self three times inside __init__ and never once passed it — yet blinding.play() somehow knows it is Blinding Lights and not Titanium. self isn't magic. It's the instance, quietly handed in. →

Wait — if type(blinding) is Song, what is type(Song)? It's type — the blueprint that stamps out blueprints. And type(type) is type again: the "stamped-from" tower climbs Song → type → type and simply stops at itself. Your class isn't standing outside the object world looking in — it's an object too, stamped from type the same way blinding was stamped from Song.
YOU ALREADY OWN THOUSANDS OF CLASSES
"blinding".upper() rides the exact rail you just built: it's str.upper("blinding")"BLINDING", the receiver slipped in as the first argument, no differently from Song.play(blinding). Every int, str and list you've touched since chapter 0 was an object stamped from a class — class Song just lets you author one of your own.
A CLASS BODY IS A SCRIPT, NOT A DECLARATION
Drop a print("building…") between class Song: and the first def and it fires exactly once — the moment Python reads the class, before a single object exists. The class block isn't a passive announcement the way it is in other languages; it's ordinary code run top-to-bottom to build the blueprint object, methods and all.
↺ the class keyword is sugar
class Song: … is a convenient spelling for a single function call: Song = type("Song", (), {…}) builds the very same class with no class block at all. And that type() is the one you've used since chapter 2 to ask "what kind is this?" — the kind-asker and the class-maker are the same function wearing two hats. Minting a new noun is just calling type.

02self is not magic — it's the instance

Two questions hide inside one innocent line, Song("Blinding Lights", "The Weeknd", 200). The first is what actually runs? Calling a class isn't like calling a function that returns a value you cooked up by hand. Python does two things, in order. First it builds a blank Song object on the heap — an empty little record with no title, no artist, no length yet. Then it automatically calls that class's __init__ method, the initializer, to fill the blank in. You never call __init__ yourself. Naming the class and opening a parenthesis is the whole ritual, and Python supplies the rest.

The second question is the one everyone trips on: what is self? Count the parameters in the definition. def __init__(self, title, artist, seconds) names four of them, yet you handed the call only three arguments. Where did the fourth come from? It came from Python. The extra parameter, self, is the brand-new blank object itself, delivered to __init__ as the very first argument so the initializer has something to fill. That's why self.title = title reads, quite literally, as: on this particular object, make an attribute named title and set it. And because every call to Song(…) builds a different blank object, every call hands __init__ a different self. That is the entire reason your blinding and your titanium can hold different titles even though they ran the exact same three lines of code.

THE ONE IDEA TO CARRY FORWARD
self is not a spell, a keyword, or a hidden pointer the language weaves in behind your back. It is the instance — the object you're building or acting on — arriving as an ordinary first parameter. Once you see that, every "mysterious" thing about methods collapses into one plain rewrite rule.
One call, two hidden steps: create blank, then initializefigure
calling a class is two steps, not one — build a blank object, then fill it in Song("Blinding Lights", "The Weeknd", 200) 1 · __new__ blank on the heap title · artist · seconds · hands it in as self 2 · __init__ same object, filled title Blinding Lights artist The Weeknd seconds 200 self on the left and self on the right are the same object — one gets filled into the other
Fig 04 — Song(…) first creates a blank object (that's __new__), then passes it into __init__ as self to be filled. __init__ doesn't build the object — it initializes the one already built.
Created first, initialized second
Step one is pure allocation: Python carves out a blank Song-shaped record on the heap — the right size, a type pointer already set to Song, and not one field filled in yet. Then step two hands that same blank object to __init__ as self to be filled. So __init__ is badly named if you read it as "make one" — it initializes an object that already exists. (The allocation step is itself a method, __new__ — a name you will almost never write or call by hand, so spend no memory on it; what matters is that blank-then-fill is two steps, and that split is exactly where self comes from.)

Now the second half of the section, the part that dissolves the last of the mystery. A method is nothing exotic. It's an ordinary function — the same kind you met back in chapter 9 — that happens to live inside a class and to take the instance as its first parameter. When you write blinding.play(), the dot is pure sugar (we'll refine that word in a moment). Python quietly rewrites it into Song.play(blinding). It looks up play on the class, then ships whatever sits to the left of the dot in as the first argument, self. That's the whole trick. The object doesn't "contain" the method and doesn't "run" it. It just becomes the first thing passed to a function that has always lived on the class. One rewrite rule, zero magic.

song.pypython
class Song:
    def __init__(self, title, artist, seconds):   # 4 parameters...
        self.title = title       # ...but the call passes 3 — Python supplies self
        self.artist = artist
        self.seconds = seconds
    def play(self):
        return f"▶ {self.title} — {self.artist} — {self.seconds} s"

blinding = Song("Blinding Lights", "The Weeknd", 200)   # self = the new object

blinding.play()          # what you write
Song.play(blinding)      # what Python actually runs — identical result

blinding.play            # no () → the method object itself (ch 9)
blinding.play()          # the () are what make it run
Pick an instance — watch the same one method bind a different selfinteractive
you write blinding.play() Python rewrites the dot-call what runs Song.play(blinding) class Song def play(self): return f"▶ {self.title} …" one function, stored once on the class — every song shares it blinding — one instance, its own data title Blinding Lights artist The Weeknd seconds 200 arrives as self → ▶ Blinding Lights — The Weeknd — 200 s self is blinding → True
blinding
Slide between the three songs. The method in the class box never changes — there is only one play. All that changes is which instance rides in as self, and so which object's title, artist, and seconds the line reads.
Fig 04b — One function on the class, many selves. obj.play() always means Song.play(obj); the object left of the dot is the only thing that varies.
self is not a keyword
Nowhere in Python's grammar does the word self appear — import keyword; keyword.iskeyword("self") answers False. It is only the conventional name of a method's first parameter. Rename it me and me.title = title works exactly the same. But every Python reader on earth expects self, so writing anything else is like spelling your own name wrong on purpose. Use self.
Wait — if self is just an ordinary first argument, what stops me from handing play the wrong object? Nothing does. Song.play(titanium) runs perfectly and prints Titanium's line — because self is only ever "whatever you passed first." The dot-form blinding.play() simply saves you from typing the receiver twice; it doesn't add a rule, it removes a keystroke.
↺ reframe
Stop picturing a method as a verb that lives inside an object, waiting to be told to act on itself. Picture it instead as a plain function bolted to the class, and the dot as a delivery service: obj.method(x) is a shipping label that means "run Class.method, and put obj in the first box." The object isn't the actor — it's the first parcel.

✗ The myth

self is special Python magic — a keyword the interpreter injects, a secret handle you must invoke to reach "this object."

✓ The reality

self is an everyday parameter with a boring name, and the only "magic" is a single rewrite the interpreter performs: obj.m(x) becomes type(obj).m(obj, x). You could do it by hand every time and Python wouldn't notice the difference.

The deeper cut — calling the dot "just sugar" is a labelled simplification

Saying blinding.play is "just" a text rewrite into Song.play(blinding) is a labelled simplification — true enough to reason with. Here's the precise version. Accessing a function through an instance actually produces a small object of its own: a bound method. Ask for blinding.play and Python doesn't return the raw function. It manufactures a wrapper that staples two things together: __func__, the one function stored on the class (blinding.play.__func__ is Song.playTrue), and __self__, the receiver (blinding.play.__self__ is blindingTrue). Calling that wrapper slots __self__ in as the first argument for you. The staple is fresh each time, so blinding.play is blinding.play is actually False: two separate wrappers around the same underlying function. The machinery that mints them is the descriptor protocol. Functions implement __get__, and instance.method secretly calls function.__get__(instance, cls), which hands back the bound method. So the "rewrite" is real. It's just performed by an object handshake rather than string-editing. Everywhere in this book we'll keep saying "the dot passes self," now that you know what performs the passing.

Wait — the interactive said play is stored once on the class, shared by every song — yet each song clearly has its own title. So where does title actually live, if not next to play? And if I ever set an attribute of the same name on both the class and the instance, which one wins when I write blinding.title?

Two homes, one search. Every dotted name — blinding.title, blinding.play — sends Python down a single, fixed lookup path that checks the instance first and the class second. Learn that one rule and attribute access never surprises you again. →

Wait — is blinding.play one fixed thing? No — blinding.play is blinding.play is False. Every time you touch the dot, Python mints a fresh little wrapper that staples the one shared function to this receiver. The function underneath never moves (blinding.play.__func__ is Song.playTrue); it's the throwaway staple that's new each time.
ONE def, TWO types
Reach the same method two ways and you get two different kinds of thing: type(Song.play) is function (the raw def — you pass self), while type(blinding.play) is method (a wrapper that fills self in for you). Same code, two envelopes — which is exactly why blinding.play() and Song.play(blinding) both work and land in the same place.
THE "MAGIC" IS A STRUCT WITH TWO FIELDS
A bound method isn't a spell — it's a wrapper you can take apart. blinding.play.__func__ is the one function stored on the class (is Song.playTrue) and blinding.play.__self__ is the receiver (is blindingTrue). Two fields, both printable. Calling the wrapper just slots __self__ in as the first argument — nothing is hidden from you.
Trace3 step the machine — idea · code · memory move together
The dot is a rewrite — blinding.play() becomes Song.play(blinding)
A method is just a function that lives on the class. Writing blinding.play() makes Python find play on Song and pass the object on the left of the dot in as the first argument — the parameter named self. self isn't magic; it is the instance. Press Next and watch one call get found, rewritten, and run.
1 rewriteself = blinding0 magic
The dot-call, rewritten and runwrite
blinding.play()
Python rewrites the dot ↓
Song.play(blinding)
blinding · instance
titleBlinding Lights
artistThe Weeknd
seconds200
self
class Song
def play(self):
return f"▶ {self.title} …"
one function · shared by every song
returns
In plain words
Under the hood
OPALU · the step
Program · song.py
play frame · self
Memory · resolving the dot
registers — the running state
bound method one function + one receiver
__func__
__self__
returns
what's happening
beat 1 / 1

03Two kinds of attribute, one lookup rule

Every time you wrote self.title = ... inside __init__, you dropped a value into one object's own private drawer — a little dict Python keeps on each instance, its __dict__. Those are instance attributes: one fresh set per object, exactly right for the data that differs song to song. But now picture a value that is the same for every song in the library — the station stamp "iolinked FM", identical on all ten thousand of them. Store it as an instance attribute and you pay for ten thousand identical copies on the heap, and renaming the station means rewriting every one. That is pure waste, and it points straight at a need: one value that lives in a single place yet every instance can read. Write a name straight in the class bodyplatform = "iolinked FM" below — and you get exactly that. It is stored once, on the class object itself, and seen by every instance through the one copy. That's a class attribute — one shelf shared by the whole warehouse. (Note the deliberately stripped-down Song here: two fields, title and seconds, no artist — a small drawer so the lookup rule stays the star, and it behaves identically with three. Build blinding = Song("Blinding Lights", 200) and blinding.__dict__ holds exactly {'title': 'Blinding Lights', 'seconds': 200}.)

So when you read blinding.platform, Python doesn't guess and it doesn't search everywhere. It runs one fixed rule, the same one every single time: (1) look in the instance's own __dict__ — nothing there; (2) miss, so climb to the class — found. First hit wins, lookup stops. That two-step is the entire machinery of the dot. Learn it once and attribute access never surprises you again.

attrs.pypython
class Song:
    platform = "iolinked FM"        # class attribute — one copy, on the class
    def __init__(self, title, seconds):
        self.title = title          # instance attributes — one set per object
        self.seconds = seconds

blinding = Song("Blinding Lights", 200)
titanium = Song("Titanium", 245)

print(blinding.platform)            # iolinked FM   — missed the instance, found the class
blinding.platform = "offline mix"   # plants an INSTANCE attribute
print(blinding.platform)            # offline mix   — its own copy now shadows the class
print(titanium.platform)            # iolinked FM   — the class copy never moved
THE ONE RULE TO CARRY FORWARD
Reading obj.name checks the instance's own __dict__ first, then climbs to the class. The first place that has name answers, and the climb stops there. Instance data sits in front; class data sits behind. Everything else in this section is a consequence of that one order.
Reading blinding.platform — two stops, first hit winsfigure
blinding.platform ? 1 — check the instance's own dict blinding.__dict__ title: 'Blinding Lights' seconds: 200 platform? ✗ not in here 2 — climb to the class class Song platform = 'iolinked FM' play() ✓ found — lookup stops
Fig 3a — The dot never searches at random: it reads the instance's own drawer, and only on a miss does it climb to the class. Here platform isn't in blinding, so the climb finds the class copy — the first and only hit.

That same rule explains the trickiest move in the whole topic — shadowing. When you ran blinding.platform = "offline mix", Python did not reach up and rewrite the class's copy. Assigning through an instance can only ever write into that instance's drawer, so it dropped a brand-new platform key into blinding.__dict__. Now step 1 finds it and the climb never happens, so blinding sees "offline mix". Meanwhile titanium, whose drawer has no such key, still climbs and reads "iolinked FM", and Song.platform itself never budged. Only an assignment to the class, Song.platform = ..., moves the shared copy. Peel the shadow off with del blinding.platform and the class value shines through again, untouched the whole time.

One class attribute, two instances — turn it, then shadow itinteractive
class Song volume = 70 one shared copy, stored on the class each instance climbs to the class blinding volume = 90 (its own copy) blinding.volume → 70 titanium (borrows the class copy) titanium.volume → 70
blinding.volume → 70 · titanium.volume → 70
Slide Song.volume and both instances move together — they're reading the one class copy. Now tick the box: blinding plants its own volume, its arrow to the class goes dim, and it freezes at 90 while titanium still follows the class. One shadow, one instance, no effect on the other.
Fig 3b — A single number on the class serves every instance — until one instance grows its own copy and stops climbing. Shadowing is local: it changes what that object sees and nothing else.
Wait — if assigning through an instance always writes to the instance, what does blinding.plays += 1 do to a counter I put on the class? Something sneaky. += is read-then-write: Python reads plays by the normal rule (misses the instance, climbs, finds the class's 0), adds one, and binds the result back onto the instance. So the shared class counter never moves — you've silently planted a per-object plays = 1 that shadows it. Everyone reaching for "a class-wide tally" via self.x += 1 has been bitten by this exact line.

✗ The myth

Every object carries its own copy of every method. A thousand Songs means a thousand play() functions crowding memory.

✓ The reality

Methods live once, on the class — play isn't in any instance's __dict__, it's in Song.__dict__. The dot climbs and borrows it at call time. That's why fixing a bug in the class instantly fixes it for every instance already alive, and why blinding.play and titanium.play are literally the same function underneath.

The memory bill falls straight out of this. Make a thousand songs and you get a thousand tiny per-instance dicts of data — each just a handful of keys — plus one class object carrying the shared attributes and every method exactly once. The behaviour doesn't multiply; only the data does. (If even those little dicts feel too heavy at scale, __slots__ deletes them and packs the fields into a fixed row, as in chapter 11.)

Two shelves, and you can inspect both
blinding.__dict__ (or vars(blinding)) shows only the instance's own data; Song.__dict__ holds the class attributes and the methods. The dot is just an automatic search across the two, in that order — nothing is hidden from you, and you can print either drawer to see exactly where a name lives.
A list as a class attribute is ONE list
Write songs = [] in the class body and you've built a single list, once, on the class — the shared shelf. Every instance's .songs.append(...) reads that same list by the climb and mutates it in place, so all objects pile into one bucket (the exact aliasing trap from chapter 3). Data that belongs to each object must be born in __init__: self.songs = [] runs afresh per object.
shared_list.pypython
class Playlist:
    songs = []              # ONE list, built once, living on the class

a = Playlist()
b = Playlist()
a.songs.append("Blinding Lights")
print(b.songs)              # ['Blinding Lights']  — b never touched it, yet there it is
print(a.songs is b.songs)   # True  — one shared list, not two

# the fix: give every object its own list in __init__
class Playlist:
    def __init__(self):
        self.songs = []    # a fresh list per object — no sharing
↺ reframe
Picture the class as a shared shelf bolted to the wall and each instance as a desk in front of it. Reading a name, you glance at your desk first; if it's bare, you look up at the shelf. Writing a name always puts a sticky note on your own desk — never on the shelf. That's the whole model: instances write locally, read locally-then-shared, and the shelf is only ever changed by naming the class directly.
The deeper cut — "instance first" is a useful lie, and the climb doesn't stop at one class

"Check the instance dict first" is a labelled simplification — true for every ordinary attribute you'll write, and worth building your model on, but not the literal order. The full rule has a step in front of it. Certain objects defined on the class, called data descriptors (a property is the everyday one), get to answer before the instance dict is even consulted. Force a same-named key into an instance's __dict__ and a class property still wins, because it's a data descriptor, so it outranks your drawer. Plain values and plain methods are not data descriptors, which is exactly why, for them, the instance genuinely does come first and shadowing works as shown. So the precise order is: data descriptors on the class → the instance's own dict → everything else on the class.

And "climb to the class" is really "climb the chain of classes." Python walks a fixed, ordered list — type(obj).__mro__, the method resolution order — checking each class in turn until a hit. For a plain Song that list is just [Song, object], two rungs, which is why it reads like a single hop. But add a parent class and the ladder grows a rung, and the very same first-hit-wins search now reaches past Song into whatever it was built from. That longer climb is not a new mechanism. It's this one, extended, and it is precisely the machinery that makes inheritance work.

One class, its behaviour shared by every instance that climbs to it. But what happens when you need a blueprint that's almost a Song — every attribute and method identical, save one twist? You won't copy the whole class. You'll borrow it, add a rung to that ladder, and change exactly one thing. →

Wait — the dot searches a dict — so can I open the dict myself? Yes. blinding.__dict__ is a live, ordinary dictionary: run blinding.__dict__['mood'] = 'nocturne' and now blinding.mood reads 'nocturne'. Attribute access is nothing but an automatic lookup over a drawer you're allowed to reach into by hand.
A THOUSAND SONGS, ONE play()
Make a thousand Songs and you get a thousand tiny data dicts — but play lives once, in Song.__dict__, and in no instance's drawer. That's why patching a bug in play() fixes every song already alive in the same instant: none of them was ever holding a copy to go stale. Behaviour doesn't multiply; only data does.
↺ writing never climbs
Reading blinding.platform may climb from desk to shelf — but blinding.platform = "offline mix" can only ever scribble on blinding's own desk. Assignment through an instance never reaches the class. That one asymmetry is the whole model: read locally-then-shared, but always write local. Shadowing isn't a special feature — it's just a local write that a later read happens to find first.
Trace3 step the machine — idea · code · memory move together
Where obj.x is found — the dot walks instance → class → object
Reading blinding.x is never a guess. Python walks one fixed ladder: the instance's own __dict__, then the class, then up the MRO to object. First hit wins and the climb stops; if every rung misses, it raises AttributeError. This trace walks the three plain cases — hit on the instance, hit on the class, total miss. Hold the surprising fourth from the prose above in view: a write like blinding.platform = 'offline mix' plants a key in rung 0, so the very next read wins there and never climbs — shadowing is just the ladder stopping one rung sooner. Press Next and watch the search arrow light the rung that answers.
3 rungsfirst hit winselse AttributeError
The dot's search pathattribute lookupidle
blinding.x?
1 · instanceblinding.__dict__
titleseconds
2 · classSong.__dict__
platformplay__init__
3 · top of MROobject
__repr____eq____class__
press Next to send the dot searching
In plain words
Under the hood
SCANALU · the membership test
Program · attrs.py
Memory · the search state
registers — where the dot is looking
blinding.__dict__ the drawer being searched
what's happening
beat 1 / 1

04Inheritance: borrow a class, change one thing

A live recording is still a song. Same title, same artist, same running time, the same idea of pressing play — plus one new thing: a venue. So how do you build a LiveSong class? The lazy answer is to copy the whole Song class and paste in a venue. You'd regret it twice. Every bug you ever wrote in Song now lives in two places, and the day you fix one you'll forget the other. There is a better move, and it's the backbone of every object system ever built: inheritance — say what's new, borrow the rest.

One line does it: class LiveSong(Song):. That parenthesis names Song the parent (or base class) and makes LiveSong its child (or subclass). The child starts life with everything the parent defines — even __init__ itself, if the child never writes its own. From there the child can do exactly three things. It can add something the parent lacks (the venue), override a method by defining its own version (a different play()), or extend one with super().__init__(title, artist, seconds) — which means "run Song's initializer on this same self first, then keep going with my own lines." Add, override, extend. That's the whole toolkit.

THE ONE IDEA TO CARRY FORWARD
A child IS-A parent. It writes down only what's different and borrows the rest by pointing at the parent — nothing is copied. "Override" is nothing mystical: it just means the child's version is the first one Python finds as it walks a fixed search path called the MRO. Learn that one search and inheritance stops being magic.
The chain: LiveSong → Song → objectfigure
class object the ancestor of every class class Song __init__ · play() · encore() class LiveSong(Song) __init__ · play() ← overrides · venue inherits inherits super() parent's setup, same self MRO — search order 1 · LiveSong 2 · Song 3 · object first match wins
Fig 04 — LiveSong inherits from Song, which inherits from object — the built-in root every class ends at. super() is how the child extends the parent's setup instead of throwing it away; the MRO is the fixed top-down path Python walks to find any method.

So when you write gig.play(), where does Python actually look? It walks that path — the MRO, the method resolution order: a fixed list of classes searched strictly in sequence, LiveSong, then Song, then object. The first class that defines play wins, and the search stops dead. That is the entire meaning of "override": the child's play is found before the parent's, so the parent's never runs. Ask for a name the child doesn't have — gig.encore() — and the search falls straight through to Song. And because a LiveSong genuinely is a Song, the built-in test isinstance(gig, Song) — "was this object stamped from this class, or from any ancestor of it?" — comes back True.

inherit.pypython
class Song:                                  # the parent — every song can play
    def __init__(self, title, artist, seconds):
        self.title, self.artist = title, artist
        self.seconds = seconds
    def play(self):
        return f"Now playing {self.title} by {self.artist}"
    def encore(self):                        # only Song defines this one
        return f"One more time: {self.title}!"

class LiveSong(Song):                        # a Song, plus a venue
    def __init__(self, title, artist, seconds, venue):
        super().__init__(title, artist, seconds)  # run Song's setup first
        self.venue = venue                        # then add the new bit
    def play(self):                          # override the parent's play
        return f"Playing {self.title} live at {self.venue}"

studio = Song("Blinding Lights", "The Weeknd", 200)
gig    = LiveSong("Blinding Lights", "The Weeknd", 210, "Coachella")

print(gig.play())              # Playing Blinding Lights live at Coachella
print(gig.encore())            # One more time: Blinding Lights!   (inherited)
print(gig.seconds)             # 210 — set by Song.__init__, via super()
print(isinstance(gig, Song))     # True — a LiveSong IS a Song
print([c.__name__ for c in LiveSong.__mro__])   # ['LiveSong', 'Song', 'object']

Notice the studio cut is the canonical 200 s you've carried since chapter 0. This live take runs a touch longer at 210 s — same song, different recording, and the venue is the only genuinely new field. Everything else was borrowed.

Forget super().__init__() and the parent never runs
If LiveSong.__init__ skips super().__init__(title, artist, seconds), then no code ever sets self.title. The object is born half-built, and the very next gig.play() dies with AttributeError: 'LiveSong' object has no attribute 'title'. Remember the split: writing your own __init__ replaces the parent's; super() is the one way to extend it instead.
Call a method — watch the search stop at the first class that has itinteractive
Python searches top-down and stops at the first class that defines the method MRO class LiveSong defines __init__, play ✓ found — runs here class Song defines __init__, play, encore has it — but never reached class object defines __str__, __repr__, … not reached gig.play() → LiveSong.play (child override wins)
gig.play()
Slide through the three calls. play is found in LiveSong (override) — Python never even looks at Song's play. encore misses LiveSong and lands in Song. str(gig) misses both and resolves all the way up in object, the class everything inherits from.
Fig 04b — One rule, three outcomes: the method that runs is simply the first one found walking LiveSong → Song → object. "Override," "inherited," and "built-in default" are the same search stopping at different rungs.
↺ reframe
Stop picturing a subclass as a photocopy of the parent with a few edits. Picture a fallback chain: Python asks LiveSong "do you have play?", and only if the answer is no does it ask Song, then object. Inheritance isn't copying behaviour down into the child — it's the child knowing who to ask next.

✗ The myth

A subclass copies its parent's methods into itself — inheritance is a tidy, one-time copy-paste that happens when you write class LiveSong(Song).

✓ The reality

Nothing is copied, ever. The child stores only what's new; every other name is found live, at call time by walking the MRO up to the parent. Add a method to Song after a LiveSong already exists and that existing object can call it immediately — because it was never holding a copy, only the knowledge of where to look.

isinstance asks "is-a", not "is-exactly"
isinstance(gig, Song) is True because it accepts the object's own class or any ancestor. When you need the exact class and nothing derived from it, that's a different question: type(gig) is Song (which is False here — gig is a LiveSong). The class-level twin is issubclass(LiveSong, Song), also True.
Wait — the search starts at the object's own class and happens at call time. So one identical line, track.play(), could run LiveSong's code or plain Song's code depending only on which object happens to be sitting in track right now. Couldn't it?
The deeper cut — "the child inherits everything" is a labelled simplification

All through this section we've said a child "gets" or "has" the parent's methods. That's a labelled simplification — clean enough to reason with. Here's the exact truth underneath it. A class copies nothing. It stores only the names written in its own body (in its __dict__), and any other lookup falls through, live, along the MRO to whichever class actually defines the name. The proof is almost eerie. Bolt a new method onto the parent after a child object already exists, and that old object can suddenly call it:

live_lookup.pypython
gig = LiveSong("Blinding Lights", "The Weeknd", 210, "Coachella")
Song.remix = lambda self: f"remix of {self.title}"   # add to the PARENT, now
print(gig.remix())        # remix of Blinding Lights — the child sees it instantly

If the method had been copied at class-definition time, gig could never have seen a method that didn't exist yet. It sees it because attribute lookup is a fresh walk up the MRO on every access: a search, not a snapshot.

The deeper cut — two parents, and what super() really means

Python allows more than one parent: class KaraokeTrack(Song, LyricsMixin):. The MRO is then computed by the C3 linearization algorithm, which guarantees two things: every child appears before its parents, and the parents you listed keep their left-to-right order. Read it any time with LiveSong.__mro__ or LiveSong.mro(). Here's the subtlety that trips everyone. super() does not mean "my parent." It means "the next class in the MRO after me." In a diamond — two classes sharing one grandparent — that distinction is exactly what makes cooperative super().__init__() chains run each ancestor's initializer once, never twice:

diamond.pypython
class Track:
    def __init__(self): print("Track")
class Recorded(Track):
    def __init__(self): print("Recorded"); super().__init__()
class Timed(Track):
    def __init__(self): print("Timed"); super().__init__()
class LiveCut(Recorded, Timed):
    def __init__(self): print("LiveCut"); super().__init__()

print([c.__name__ for c in LiveCut.__mro__])
# ['LiveCut', 'Recorded', 'Timed', 'Track', 'object']
LiveCut()   # prints LiveCut, Recorded, Timed, Track — and Track just ONCE

Inside Recorded, super() points at Timed, not at Track, because Timed is what comes next in LiveCut's MRO. That's how the diamond collapses to a single line through every class. And if Python cannot build a consistent order at all — say class Bad(A, B) where B is itself a subclass of A listed in the conflicting order — the class statement fails on the spot with TypeError: Cannot create a consistent method resolution order (MRO) for bases A, B.

That question in the curio — one line, several possible methods, the object itself deciding which runs — has a name, and it's the last big idea of this chapter. Next: how a bare loop over a pile of mismatched objects gets each one to do the right thing, with not a single if-check in sight.

Wait — teach the parent a new trick after the child object already exists — does the old child see it? Instantly. Build gig, then run Song.remix = lambda self: f"remix of {self.title}", and gig.remix() works right away. Nothing was copied when you wrote class LiveSong(Song); every dotted name is a fresh walk up the ladder, so a method that didn't exist a second ago is found the instant it does.
object IS THE FLOOR EVERYTHING STANDS ON
object.__mro__ is just (object,) — the ladder's bottom rung, ancestor of every class in the language. Before you write a line, dir(object) already lists 24 inherited names — __eq__, __repr__, __hash__, and more — which is why even a bare Song can be printed and compared. And it's universal: isinstance(5, object), isinstance("x", object), even isinstance(Song, object) are all True.
SOME CLASS DEFINITIONS ARE ILLEGAL
Ask for a contradiction — class Bad(A, B) where B is already a subclass of A, listed in the conflicting order — and Python won't even build it. The class statement itself dies with TypeError: Cannot create a consistent method resolution order. The search ladder must collapse to a single straight line; if no consistent order exists, you find out at definition, not from some baffling call far downstream.
↺ super() is not "my parent"
It means "the next class on the MRO after me." In a plain chain those coincide, so the shorthand is harmless. But in a diamond — LiveCut(Recorded, Timed), both built from Track — inside Recorded, super() points at Timed, a sibling, not an ancestor. That's the precise trick that threads one line through all four classes and runs Track's setup once, never twice: ['LiveCut','Recorded','Timed','Track','object'].
Trace3 step the machine — idea · code · memory move together
The resolution walk — gig.encore() climbs the MRO until a class has it
encore isn't defined on LiveSong. So Python doesn't give up — it walks the method-resolution order LiveSong → Song → object, checking each class's own __dict__ in turn, and runs the first one it finds. This trace shows the inherited case, where the climb falls through to Song. An override is the very same walk stopping sooner: give LiveSong its own encore and rung 0 answers immediately, the climb never starts — override and inherit aren't two mechanisms, just one search halting at different rungs. Press Next and watch the search step up rung by rung until encore turns up on Song.
3 rungsfirst match winsfound on Song
The MRO — searched top to bottomlooking for · encoreread mro
Call gig.encore() · gig is a LiveSong
walk
LiveSong
__init__play
waiting
Song
__init__playencore
waiting
object
__str____eq__
waiting
A dotted name is resolved by a live walk — a search, never a copy.
In plain words
Under the hood
MROALU · the operation
Program · inherit.py
names · type
Memory · the search state
registers — where the walk stands
type(gig).__mro__ the fixed search path
what's happening
beat 1 / 1

05Polymorphism: the object picks the method

Here is the payoff the whole chapter has been climbing toward. One line — track.play() — is the entire call site, and yet it produces three different results, one per song, with not a single if in sight. That is polymorphism (Greek for "many shapes"): the same call, answered differently depending on what object is standing left of the dot. The machinery underneath is dynamic dispatch. It's dynamic because it happens at run time, not when you wrote the line, and dispatch because Python has to route the call to exactly one piece of code. When the interpreter reaches track.play(), it looks at the actual object track is bound to at that instant, starts the method hunt at that object's own class, and walks up the MRO — the method-resolution order you met a section ago — until it finds a play. The caller never chooses which method runs. The object does.

PROGRAM TO THE VERB, NOT THE TYPE
The loop below asks every track to .play() and depends on nothing else — not its class, not its length, not how it was built. It knows that a track plays; it never learns how. That "knows-that-not-how" is abstraction, and it's the twin of polymorphism: because the object carries its own behaviour, you can drop a brand-new kind of track into the playlist and this loop never changes a character.

We built Song and its subclass LiveSong earlier in the chapter, where LiveSong overrides play() to announce a venue. Now add one more, Remix, and watch it closely. It defines no play() of its own; it only rewrites the title in its __init__. Then we line all three up and call the same method on each:

dispatch.pypython
class Remix(Song):                      # inherits play() untouched — defines none of its own
    def __init__(self, title, artist, seconds, remixer):
        super().__init__(f"{title} ({remixer} Remix)", artist, seconds)

playlist = [
    Song("Blinding Lights", "The Weeknd", 200),              # studio original
    LiveSong("Titanium", "David Guetta", 245, "Tomorrowland"),
    Remix("Blinding Lights", "The Weeknd", 200, "Kaskade"),
]

for track in playlist:
    print(track.play())        # one line — three behaviours
# Playing Blinding Lights by The Weeknd
# Playing Titanium live at Tomorrowland
# Playing Blinding Lights (Kaskade Remix) by The Weeknd
One call site, three destinationsfigure
for track in playlist: track.play() a Song a LiveSong a Remix Song.play() its own method LiveSong.play() overrides Song's Song.play() found by climbing ↑ the MRO Playing Blinding Lights by The Weeknd Playing Titanium live at Tomorrowland Playing Blinding Lights (Kaskade Remix)… the object left of the dot chooses the method — decided at run time
Fig 05a — One call site, three methods. Each object routes .play() through its own class's MRO, so the object — not the caller — decides what runs. The Remix owns no play(), so its call climbs one step to Song.

That third column is the one to sit with. The Remix has no play(), so the search climbs its MRO — Remix → Song → object — and stops at Song. But here's the sleight of hand. The MRO only decided which function to run; it never touched self. Song's code executes, yet self is still the Remix, so self.title is the remixed title the Remix wrote in its own __init__. Method borrowed from the parent; data owned by the child. Drive the slider and watch the search start in a different place, and, for the Remix, watch it climb.

Same call, three objects — watch where the search starts and where it landsinteractive
MRO: Song → object climb ↑ object no play() up here class Song ✓ play() runs here class LiveSong(Song) def play(self): … (overrides) class Remix(Song) (no play() of its own) call site track = Song("Blinding Lights", …) track.play() Playing Blinding Lights by The Weeknd
Song
One line, track.play(), three behaviours. The object picks where the search starts; the MRO picks the path it walks. For the Remix the start class is empty, so the search climbs to Song — yet the words it prints are the Remix's.
Fig 05b — The green outline marks the class whose play() actually runs. Move from Song to LiveSong to Remix: the search begins at the object's own class every time, and only climbs when that class has nothing to offer.
Wait — if the Remix runs Song's play(), how does it print the remix's title and not some plain "Blinding Lights"? Because the MRO only chose which function to run — it never swapped out self. Inside Song.play, self is still your Remix object, and its self.title was rewritten to "Blinding Lights (Kaskade Remix)" back in the Remix's __init__. Verify it yourself: type(remix).play is Song.play is True (the function really is Song's), while remix.play.__self__ is remix is also True (the data is still the Remix's).
↺ the dot is a question, not a command
Reading track.play(), it's tempting to think you've told Python which code to run. You haven't. You named a verb and asked the object, at that exact moment, "what does play mean for you?" The call site supplies the verb; the object supplies the meaning. That's why the very same line does three different things — you're not issuing three commands, you're asking three objects the same question.

And notice what the loop never does: it never asks track what type it is. It only asks it to .play(). Anything that answers that request qualifies, related by inheritance or not. This is duck typing: if it walks like a duck and quacks like a duck, treat it as a duck. A Podcast class that shares no ancestry with Song at all, but happens to define its own play(), drops into the playlist and the loop is none the wiser.

🦆 DUCK TYPING
Inheritance is one way to promise a method exists — but merely having the method is another, and Python is happy with either. class Podcast: with its own play() returning "Streaming Darknet Diaries, hosted by Jack Rhysider" isn't a Song, isn't a subclass of anything special, yet playlist + [Podcast(…)] loops without a hitch. The loop asked for a behaviour, not a bloodline.

✗ The myth

Polymorphism means writing a fork in the road — if isinstance(track, LiveSong): … elif isinstance(track, Remix): … — so the caller keeps a running list of every track type and what to do with each.

✓ The reality

Polymorphism is what deletes that ladder. The caller writes one line, track.play(), and each object carries its own answer. Add a Podcast, or a hundred new track types, and the call site doesn't gain a single branch — the if/elif tower is exactly the thing you traded away.

Duck typing is a convention, though. Nothing stops you from dropping in a track that forgot to define play(), and you'd only find out when the loop crashes on that item, halfway through the playlist. Want the promise enforced up front? An abstract base class declares a required method with no body, and Python then refuses to build any object whose class hasn't supplied one. That brings one new scrap of syntax below. A line beginning with @ is a decorator, and it is not vague at all — it's a single rewrite rule, in the same spirit as class unfolding to a type() call. @d written above def f means exactly f = d(f): define the function, hand it to d, and rebind the name to whatever d returns. So @abstractmethod over def play runs play = abstractmethod(play), and all abstractmethod does is set one flag on the function object, play.__isabstractmethod__ = True. The enforcement lives in ABC. It installs a metaclass (the class-builder type from this chapter's opener, doing extra work at build time — you never write one yourself) that scans for any still-flagged method and raises TypeError the instant you try to instantiate. A tag, yes, but a tag that reduces to one plain function call and one boolean, nothing hand-wavy.

enforce.pypython
from abc import ABC, abstractmethod

class Playable(ABC):          # ABC supplies the enforcement machinery
    @abstractmethod
    def play(self): ...       # a promise with no body

Playable()   # TypeError: Can't instantiate abstract class Playable
             #            without an implementation for abstract method 'play'
             # a subclass that forgets play() fails the very same way —
             # at build time, not as a surprise crash mid-playlist
The deeper cut — "the object picks the method" is a labelled simplification

Said plainly, "the object picks the method" is true enough to build your whole model on. But let's refine it once, precisely. Python doesn't really inspect the object; it inspects type(object) and searches that type's __mro__ for the name. The object matters only because it tells Python which type to start from. That's why dispatch follows the object's real class, never the variable: rebind track to a different object and the identical line routes somewhere new.

There's a subtler catch, too. Attribute lookup for track.play isn't a plain "search the class." Python's __getattribute__ checks the type's MRO for a data descriptor first, then the instance's own __dict__, and only then the MRO again for a class attribute. A plain method is a function, a non-data descriptor, so an instance attribute of the same name will quietly shadow it: set track.play = lambda: "shush" and track.play() returns "shush", no class method in sight (del track.play restores it). So it isn't that the class always wins. The class wins unless the instance overrode it.

Finding the function is only half the story. Reaching it through the instance triggers the descriptor protocol, which binds self for you and hands back a bound method whose __self__ is your object. That is exactly why the Remix's data rode along into Song's code. And one last honest caveat: this is single dispatch. Only the one object left of the dot steers. Languages with multiple dispatch choose a method from the types of all arguments at once; Python offers that only opt-in, via functools.singledispatch or third-party libraries. The dot itself always dispatches on precisely one object.

The object decides what .play() means. But could it also decide what +, ==, and len() mean — and hide its own insides while it's at it? Next: dunders, @property, and the privacy that isn't. →

Wait — the line track.play() carries no hint of which code runs — so who decides? The object, at that instant — never the variable. Rebind track to a Song, a LiveSong, a Remix, and the identical characters route three different ways. Python doesn't read your variable's declared "type" (there isn't one); it reads type(track) right now and starts the search from there.
THE DOT STEERS ON EXACTLY ONE OBJECT
track.play() dispatches on track and nothing else — Python is a single-dispatch language. Want the method chosen from the types of all the arguments at once? That's multiple dispatch, and Python offers it only opt-in, through functools.singledispatch or a library. The bare dot always picks on precisely one object: the one to its left.
AN INSTANCE CAN HIDE A METHOD
Methods usually live on the class — but don't always win. Set track.play = lambda: "shush" and track.play() returns "shush": a plain method is a non-data descriptor, so an instance attribute of the same name shadows it. del track.play and the real method shines back through. So it isn't "the class always wins" — it's "the class wins unless the instance overrode it."
Trace3 step the machine — idea · code · memory move together
One call, many shapes — the object picks the method
A single loop body runs track.play() over three different kinds of object. Watch the one moving part: each pass the loop rebinds track to a new object — a Song, then a LiveSong, then a Remix — and the identical characters track.play() reach a different method because Python resolves play against whatever object track holds right now, against its own type, at run time. Same call, many shapes: that's polymorphism, powered by dynamic dispatch. Press Next and watch the type tag route each call.
1 call site3 typesruntime dispatch
Dynamic dispatch · the object picks the methodloop
playlist · loop class tower Song "Blinding Lights" LiveSong …"Tomorrowland" Remix …"Kaskade" mix the one call track.play() climb ↑ object no play() up here class Song def play(self) LiveSong play() override Remix no play() STDOUT nothing printed yet
In plain words
Under the hood
LOOPALU · the resolution
Program · dispatch.py
variables · type
Memory · dispatch & stdout
registers — the running state
stdout the lines printed so far
what's happening
beat 1 / 1

06Dunders, @property, and the privacy that isn't

Here's a secret you've kept from yourself all course. Every time you wrote len(playlist) or a + b or print(song), you were calling a method — you just never saw its name. Python keeps a set of reserved method names wrapped in double underscores — dunders, for double underscore — and each one is a hook into a piece of the language's own grammar. Define the hook on your class and your object plugs straight into the syntax everyone else's objects use. len(x) quietly runs x.__len__(). a + b runs a.__add__(b). print(x) asks x.__str__() for a friendly string and, if you never wrote one, falls back to x.__repr__().

Skip those hooks and your objects stay second-class. Print a bare Song with no __repr__ and Python shrugs out something like <__main__.Song object at 0x1d8…> — the class name, then the object's address in memory. That hex number is id(song) from chapter 3, written in base 16: honest, and useless to you. But define the hooks and the class comes alive. len(party) just works, + merges two playlists (that's operator overloading), and == starts comparing what the songs are instead of falling back to whether they're the very same object, chapter 4's is. You stop describing your object to Python and start teaching Python to speak your object. And + is where that lesson turns literal. On a Playlist it merges — two song lists glued end to end — but drop below the playlist to the samples those songs are actually built from, and the very same + is forced into real arithmetic on the metal.

THE ONE IDEA TO CARRY FORWARD
A dunder is not magic — it's a named socket. The syntax a + b is sugar; the real event is the plain method call a.__add__(b). Learn which socket each scrap of syntax reaches for, and you can wire any object you build into +, len(), ==, [ ], for, and print — the whole language, not a roped-off corner of it.
An object is a board of sockets the syntax plugs intofigure
you write your Song object answers len(s) s + t s == t s[0] print(s) __len__ __add__ __eq__ __getitem__ __repr__
Fig 06 — The operators and built-ins are plugs; the dunder methods are the sockets on your object. Wire up a socket and that piece of syntax lights up for your type — no socket, and Python falls back to its dull defaults.
songs.pypython
class Song:
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds
    def __repr__(self):                     # what the shell and containers show
        return f"Song({self.title!r}, {self.artist!r}, {self.seconds})"
    def __eq__(self, other):                # what == means for two Songs
        if not isinstance(other, Song):
            return NotImplemented          # "not mine" — let the other side try
        return (self.title, self.artist) == (other.title, other.artist)

class Playlist:
    def __init__(self, songs):
        self.songs = list(songs)            # our own copy — no aliasing (ch 3)
    def __len__(self):                     # len(pl)
        return len(self.songs)
    def __add__(self, other):              # pl + other  → a merged Playlist
        return Playlist(self.songs + other.songs)
    def __getitem__(self, i):             # pl[i] — and, for free, looping
        return self.songs[i]

night = Playlist([Song("Blinding Lights", "The Weeknd", 200)])
gym   = Playlist([Song("Titanium", "David Guetta ft. Sia", 245)])

print(len(night + gym))       # 2   — __add__ builds it, __len__ counts it
print((night + gym)[1])       # Song('Titanium', 'David Guetta ft. Sia', 245)
for song in night + gym:      # __getitem__ drives the loop — no __iter__ needed
    print(song.title)          # Blinding Lights  /  Titanium
print(Song("Blinding Lights", "The Weeknd", 200)
      == Song("Blinding Lights", "The Weeknd", 200))   # True — value, not identity
Wait — I defined __getitem__ but never wrote an iterator, so how did for song in night + gym run? Python has a fallback older than the iterator protocol: an object with no __iter__ is looped by calling __getitem__(0), __getitem__(1), counting upward until an IndexError signals the end. One socket, two powers — indexing and iteration.

Now zoom that same Playlist down one floor. A Song carries a seconds200 for "Blinding Lights" — but that duration is human metadata, a label stapled to the object. It is not the music. The music is a waveform: a long run of numbers, each one the position of the speaker cone at a single instant. In mono 16-bit PCM every sample is a signed 16-bit integer — -32768..32767, with 0 the cone at rest — and the array('h') type packs them two bytes apiece. At 44100 samples a second, those 200 seconds come to about 8.8 million samples, and array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000]) is one eight-sample sliver of them. Those bytes are the metal the playlist has been standing on all along. A sample is an object like any other, so it too can own an __add__.

The same +, one floor down: mixing two samples on the metalfigure
mix.pypython
from array import array

class Sample:                              # one 16-bit PCM sample = a cone position
    __slots__ = ("v",)                     # ch 11: no per-sample __dict__
    LO, HI = -32768, 32767              # the hard rails a 16-bit sample can reach
    def __init__(self, v):
        self.v = v
    def __add__(self, other):              # a + b  →  mix the two cones
        mixed = self.v + other.v            # Python int: grows freely, never overflows
        if   mixed > Sample.HI: mixed = Sample.HI   # but the metal saturates:
        elif mixed < Sample.LO: mixed = Sample.LO   # clip to the rail, don't wrap
        return Sample(mixed)
    def __repr__(self):
        return f"Sample({self.v})"

track = array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000])
print(Sample(track[2]) + Sample(track[3]))   # 24000 + 18000 = 42000 → Sample(32767), clipped
print(Sample(track[0]) + Sample(track[4]))   # 0 + (-6000) = Sample(-6000), safely inside the rails
Fig 06c — Sample.__add__ is operator overloading doing genuine arithmetic — not a merge but a mix, and one bound by the machine's ceiling. 24000 + 18000 is 42000, which no 16-bit sample can hold, so it clips to the +32767 rail (with a matching -32768 at the floor) — the same saturation a real mixing desk performs when two loud tracks stack.
Step through an operation — watch the sugar melt into a method callinteractive
every operator and built-in is sugar over a method call · step through six of them YOU WRITE PYTHON RUNS YOU GET BACK len(night) night.__len__() 1
1 / 6
Slide through the six. Notice the middle line is always a plain method call on an object — the top line is only ever a prettier way to type it. That gap is the whole idea of a dunder.
Fig 06b — len, [ ], +, ==, print, and a property, each desugared to the exact method Python dispatches — with the real value it returns.
Clip, don't wrap — the sample carries a ceiling the playlist never did
Python's own int never overflows (chapter 0): 24000 + 18000 is simply 42000, free to keep growing. But a sample is 16 bits with fixed rails, so a mix that overshoots must saturate — pin to the nearest rail — rather than wrap around to some huge negative, which would fire as a violent click in the speaker. That is why Sample.__add__ tests the bound by hand before returning: here operator overloading isn't decorative sugar, it's the type enforcing its own physics. Merging two playlists can never fail this way — mixing two samples can, and that is exactly why + on the metal has to earn its meaning.

Two refinements turn a workable class into a graceful one. The first is @property, and by the rewrite rule you just met it is plain. @property over def minutes runs minutes = property(minutes), storing a property object on the class where the method's name used to be. That property object is a data descriptor: it carries a __get__ (and, once you add a setter, a __set__). That one fact is the whole trick. When you write bl.minutes, section 03's lookup runs, but a data descriptor on the class outranks the instance's own __dict__ (the single refinement to the instance-first rule). Python finds minutes on the class and calls type(bl).__dict__['minutes'].__get__(bl, Song), which runs your getter and hands back its value, recomputed fresh on every access. So minutes is derived from _seconds and can never drift out of sync with it. It was never stored; it is answered on demand. Add a matching @minutes.setter (again just minutes = minutes.setter(func)) and the descriptor's __set__ fires on bl.minutes = 5 — the perfect place to reject a negative running time before it can corrupt your data.

The second refinement is that leading underscore in _seconds. That single character is Python's whole notion of encapsulation: a naming convention that whispers "internal — reach me through the property, not directly." Nothing stops you from touching bl._seconds. The underscore is a handshake between programmers, enforced by politeness, not by the interpreter. That sets up the single biggest misconception in the language, because a great many people believe a second underscore upgrades that handshake into a lock. It does not.

property.pypython
class Song:
    def __init__(self, title, seconds):
        self.title = title
        self._seconds = seconds        # _ = "internal"; go through the property

    @property
    def minutes(self):                 # a method that READS like an attribute
        return self._seconds / 60

    @minutes.setter
    def minutes(self, value):          # runs on:  bl.minutes = ...
        if value < 0:
            raise ValueError("a song can't run negative time")
        self._seconds = value * 60

bl = Song("Blinding Lights", 200)
print(bl.minutes)      # 3.3333333333333335  — no parentheses, computed now
bl.minutes = 5         # goes through the setter, which validates first
print(bl._seconds)     # 300  — the setter wrote it back in seconds
A property with no setter is read-only
Leave off the @minutes.setter and the line bl.minutes = 5 raises AttributeError: property 'minutes' of 'Song' object has no setter. That's the cleanest way to publish a value the outside world can read but never write — computed, exposed, and frozen, all at once.

Now the lock that isn't. Try to make an attribute truly private and you meet name mangling — the trick nearly everyone misreads as access control. Give an attribute two leading underscores inside a class, and the compiler quietly rewrites the name. Inside class BankAccount, self.__balance is stored under the mangled name _BankAccount__balance. The double underscore was never a keep-out sign. It's a rename, invented so a subclass can't accidentally clobber a base class's __balance with one of its own. Step through what that actually does when someone pokes at it from outside:

"Making it private" — step through what really happensinteractive
YOU TYPE inside the class: self.__balance = 100 acct · a BankAccount object _BankAccount__balance 100 __balance ← brand-new, unrelated attribute -100 ✗ no attribute is named __balance RESULT stored under the mangled name _BankAccount__balance
1 / 4
Watch the real slot (gold) never move. A read of __balance from outside can't find it; a write doesn't touch it either — it just mints a red impostor beside it. There is no locked door here, only a rename.
Fig 06c — "Private" in Python is a naming convention plus a compiler rename — never an access check. The real balance sits untouched under its mangled name the entire time.
mangling.pypython
class BankAccount:
    def __init__(self):
        self.__balance = 100            # actually stored as _BankAccount__balance

acct = BankAccount()
# acct.__balance                       → AttributeError: no such name exists
print(acct._BankAccount__balance)     # 100   — the mangled name is the real one
acct.__balance = -100                  # NO error — creates a brand-new attribute
print(acct._BankAccount__balance)     # 100   — the real balance never moved
print(acct.__balance)                 # -100  — the impostor you just created

✗ The myth

A double underscore makes an attribute private, so acct.__balance = -100 from outside the class is blocked — an error, a locked door, tampering refused.

✓ The reality

Python has no private-access error. Reading acct.__balance fails only because nothing literally carries that name; writing it silently mints a new, unrelated attribute while the real balance rests safe under its mangled name. A rename, not a lock — privacy enforced by convention, not the interpreter.

Define __repr__ before __str__
Aim __repr__ at the programmer: unambiguous, and ideally something you could paste back into the shell to rebuild the object — which is exactly why {self.title!r} keeps the quotes. Add __str__ only when end users need something prettier. And remember that containers always use __repr__: a list of Songs prints each element's repr even if you gave the class a lovely __str__.
↺ reframe
Stop picturing +, len(), [ ], and == as operators that act on your object from the outside. They're method calls your object answers from the inside. The language isn't a fixed list of things you're permitted to do to data — it's a set of questions, and every dunder you define is your object volunteering an answer.
The deeper cut — four edges that bite in real code

One — define __eq__ and you silently lose __hash__. The instant you write __eq__, Python sets your class's __hash__ to None, and instances turn unhashable. A set literal or a dict key then raises TypeError: unhashable type (chapter 7). The fix is to hash the same fields you compared: def __hash__(self): return hash((self.title, self.artist)), so equal songs hash alike and dedupe correctly inside a set.

Two — return NotImplemented, never False, for a foreign type. When other isn't a Song, our __eq__ hands back the special NotImplemented sentinel. That isn't a failure. It's Python's way of saying "I don't handle this one; let the other operand try." Python then calls other.__eq__(self), the reflected dispatch that makes mixed-type comparisons work. Return a flat False and you'd slam that door shut.

Three — there is no method overloading. Write two def play(self) with different arguments in one class and the second simply replaces the first. A class body just binds names top to bottom, last one wins, so calling the old signature raises TypeError. Want two calling styles? Reach for default or *args parameters (chapter 9), not a second def.

Four — one __lt__ can buy all six comparisons. Decorate the class with @functools.total_ordering and define just __eq__ and __lt__, and Python synthesises <=, >, >=, and != from those two. That single __lt__ is also exactly what sorted(playlist.songs) reaches for. Teach a Song to be less than another and your whole library sorts itself.

Your Playlist now speaks the whole language — +, len(), [ ], ==, sorting, printing. But hand one of its methods a value it never expected and the program still dies mid-song, spilling a traceback down the screen. Next chapter, errors & robustness: how Python raises the alarm when something breaks, and how to catch a failure in flight instead of letting it take the whole playlist down with it. →

Wait — you've been calling dunders since chapter 0. 200 + 245 is really (200).__add__(245)445; "a" + "b" is str.__add__. Even a bare integer is an object answering a method — the + between two numbers is the same socket you wire up on Playlist, just soldered in at the factory. Overloading + isn't inventing a new power; it's joining a club int was already in. And that socket can hold arithmetic genuinely unlike int's. Wrap a single 8-bit value (chapter 0's eight switches, holding 0–255) in a Byte and give it def __add__(self, other): return Byte((self.v + other.v) % 256). Now Byte(200) + Byte(245) does not give 445 — a byte cannot hold 445 — it wraps to Byte(189), the exact overflow real hardware performs. Same +, same socket, but you taught it byte arithmetic instead of adding seconds.
DEFINE __eq__, LOSE YOUR HASH
The instant you write __eq__, Python quietly sets __hash__ to None and your objects turn unhashable{song} or using one as a dict key then raises TypeError: unhashable type. The one-line cure is to hash the same fields you compared: def __hash__(self): return hash((self.title, self.artist)) — so equal songs hash alike and dedupe correctly inside a set.
↺ the ugly default is an old friend
Print a Song with no __repr__ and Python shrugs out <__main__.Song object at 0x1d8…>. That hex tail isn't noise — it is id(song) from chapter 3, written in base 16: the object's address on the heap. Python isn't hiding behind gibberish; it's telling you the one true thing it knows — where the object lives — because you haven't yet taught it anything friendlier to say.
Trace3 step the machine — idea · code · memory move together
Every operator is sugar — a + b is really type(a).__add__(a, b)
The + is not an operation on your objects — it is a request routed to a method. Python looks up __add__ on the type of the left operand, then calls it with both operands as plain arguments. This trace follows the case where the left operand answers. When it instead returns NotImplemented (the "not mine, you try" sentinel from the deeper cut), the fork you can't see here fires: Python turns to the right operand and calls type(b).__radd__(b, a) — the reflected dispatch — and only if that also declines does it raise TypeError. Press Next and watch a bare + desugar, dispatch, and return.
operator → dunderlookup on type(a)left operand leads
Operator → dunder dispatcha + b → type(a).__add__(a,b)expression
a+b
desugars
to
type(a) · class Playlist
len(a)__len__
a + b__add__
a == b__eq__
print(a)__repr__
the real call
a + b evaluates to
In plain words
Under the hood
OPALU · the operation
Program · dunder_dispatch.py
variables · type
Memory · dispatch state
registers — operands & the resolved method
merged Playlist · the object __add__ returns
what's happening
beat 1 / 1
Trace3 step the machine — idea · code · memory move together
Every + gets a second chance — __radd__, the reflected fork
5 + Money(3) works even though int has never heard of Money. Python asks the left operand first; when int.__add__ returns the sentinel NotImplemented — a signal, not an error — it forks to the right operand's __radd__. Only if both decline is TypeError raised.
2 chancessentinel NotImplementedthen TypeError
a + b has a second chance — the dispatch forkstart
5 + Money(3)
LEFT operand · 5
int.__add__(5, m)
the fork is idle
RIGHT operand · Money(3)
Money.__radd__(m, 5)
result: pending
In plain words
Under the hood
OPALU · the dispatch
Program · money.py
Memory · dispatch outcomes
two chances, then a verdict
call Python is trying now
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 12, in working code

Chapter 12: an object bundles data together with the functions that act on it. Press Next and watch instances take shape, methods mutate their own self, and Python's dunder hooks quietly wire your classes into +, ==, len() and print.

a class is a blueprint
Define once with __init__; stamp out as many independent instances as you like.
objects that hold state
Methods read and mutate self; a @property computes a value on demand.
inheritance & polymorphism
Children reuse parents via super(), override to specialise, and answer the same call in their own way.
dunder methods — hooking into syntax
Special __methods__ let your objects work with print, ==, + and len().
class-level tools
Alternate constructors, namespaced helpers, and boilerplate-free data holders.
end of chapter 12 · six sections
iolinked.com
Written by Ajai Raj