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. In this one we ask the 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, then watch a class stamp out objects that each carry their own data. We pin down the single rule that decides which of two attributes Python finds first. And the whole way through we keep asking the one thing that actually 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.
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. The pairing was your discipline to keep, and 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) can no more tell a plate of food from a Weeknd single than a cardboard box can tell socks from a violin. 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.
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.
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; 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.
play() written once in the blueprint. One drawing, a whole street of houses.In code it's almost anticlimactic, since the whole idea fits in a dozen lines. The blueprint is the class block, and each Song(…) call stamps out one object:
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. Two objects, blinding and titanium, keep their own data cleanly apart. Nudge blinding.seconds and titanium.seconds doesn't so much as twitch.
Song(…) presses a full copy of the blueprint into the new object — so once a song is built it owns everything the class block wrote, and a later edit to Song can never reach objects that already exist.class Song:
platform = "iolinked FM" # written in the class body
a, b = Song(), Song()
Song.platform = "iolinked AM" # edit the CLASS -- after both objects exist
print(a.platform, b.platform)
print(a.__dict__)iolinked AM iolinked AM
{}{} is the receipt — an object's own drawer starts empty, so every read of a.platform is a live lookup on the class at that instant and both existing songs feel the edit immediately; only assignment onto the instance — self.x = … inside a method, a.x = … outside — ever puts something in the drawer.class Song: ends in a colon, and the body is indented. Running it builds one class object and binds the name Song to it.self.self.title = … writes into this object's drawer. A bare platform = … in the class body writes one shared copy on the class.Song(…) stamps an object; blinding.play() runs behaviour. Drop either pair of parentheses and nothing happens — you just named the thing.you type
class Song: # 1 - the class statement mints a new kind
platform = "iolinked FM" # 2 - class attribute: one copy, shared
def __init__(self, title, artist, seconds): # 3 - the initializer, called for you
self.title = title # 4 - instance attributes: one set per object
self.artist = artist
self.seconds = seconds
def play(self): # 5 - a method: a function that takes the instance
return f"Playing {self.title} by {self.artist}"
blinding = Song("Blinding Lights", "The Weeknd", 200) # 6 - instantiation
print(blinding.play())
print(blinding.title, "|", blinding.platform)
print(type(blinding).__name__, isinstance(blinding, Song))
print(blinding.__dict__)
print(sorted(k for k in Song.__dict__ if not k.startswith("__")))you see
Playing Blinding Lights by The Weeknd
Blinding Lights | iolinked FM
Song True
{'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200}
['platform', 'play']- Leave
selfout of thedefline and the first call dies:TypeError: Song.play() takes 0 positional arguments but 1 was given. Python passed the object; your method had nowhere to put it. - Write
title = titleinstead ofself.title = titleand you set a local that dies at the return. The object stays empty, and the crash comes later. __init__takes two underscores on each side._init_is a perfectly legal ordinary method that Python will never call.Songis the blueprint;Song(…)is one object. Printing the bare name shows the class, not an instance.- Everything indented under
classbelongs to it. Un-indent adefby four spaces and it quietly becomes a free function again.
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.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.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, 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.
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.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. →
# song_grows.py -- one class, four stages. Nothing is thrown away; each stage adds one part.
# ---- stage 1: the bare class. A new kind, and nothing else. ----
class Song:
pass
s1 = Song()
print("stage 1:", type(s1).__name__, "|", repr(s1).split(" 0x")[0] + " 0x...>")
# ---- stage 2: __init__ pours the data in ----
class Song:
def __init__(self, title, artist, seconds):
self.title = title
self.artist = artist
self.seconds = seconds
s2 = Song("Blinding Lights", "The Weeknd", 200)
print("stage 2:", s2.title, "|", s2.__dict__)
# ---- stage 3: a method -- behaviour welded to the data ----
class Song:
def __init__(self, title, artist, seconds):
self.title = title
self.artist = artist
self.seconds = seconds
def play(self):
return f"Playing {self.title} by {self.artist}"
s3 = Song("Blinding Lights", "The Weeknd", 200)
print("stage 3:", s3.play(), "|", Song.play(s3))
# ---- stage 4: __repr__ -- the object learns to say what it is ----
class Song:
def __init__(self, title, artist, seconds):
self.title = title
self.artist = artist
self.seconds = seconds
def play(self):
return f"Playing {self.title} by {self.artist}"
def __repr__(self):
return f"Song({self.title!r}, {self.artist!r}, {self.seconds})"
library = [Song("Blinding Lights", "The Weeknd", 200),
Song("Titanium", "David Guetta", 245)]
print("stage 4:", library[0])
print("stage 4:", library)
print("stage 4:", sum(s.seconds for s in library), "seconds of music")
stage 1: Song | <__main__.Song object at 0x...>
stage 2: Blinding Lights | {'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200}
stage 3: Playing Blinding Lights by The Weeknd | Playing Blinding Lights by The Weeknd
stage 4: Song('Blinding Lights', 'The Weeknd', 200)
stage 4: [Song('Blinding Lights', 'The Weeknd', 200), Song('Titanium', 'David Guetta', 245)]
stage 4: 445 seconds of music
song_grows.py and run it. Then do the thing that makes this work: comment out stages 2, 3 and 4, run, uncomment stage 2, run again, and keep going. You are watching one class grow a part at a time, and each stage answers the complaint the stage before it left you with. Stage 1 is a kind and nothing else — Song() already builds a real object with a real address, which is all class ever promised. It prints as <__main__.Song object at 0x…>, and that hex tail is id() from chapter 3; the little .split(" 0x") is only there so the line reads the same on your machine as on mine. Stage 2 adds __init__, and now s2.__dict__ shows the data actually landing in the object's own drawer. Stage 3 adds a method, and prints it both ways — s3.play() and Song.play(s3) return the identical string, which is the whole of the next section in one line. Stage 4 adds __repr__, and watch what that buys: the object stops describing its address and starts describing itself, even from inside a list. One thing to try. Delete the __repr__ from stage 4, run it again, and look at what a list of songs turns into.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."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.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.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. Python delivers it 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 every call to Song(…) builds a different blank object. So every call hands __init__ a different self. That's the entire reason your blinding and your titanium can hold different titles, even though they ran the exact same three lines of code.
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.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.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, and 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 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.
method up on the class, then ships whatever stands left of the dot in as the first argument.def play(self) names one parameter; blinding.play() passes zero arguments. The books balance because the dot supplied the missing one.blinding.play is a small wrapper minted fresh each time, holding __func__ (the shared function) and __self__ (this object). Calling it slots one into the other.Song.play(titanium) is legal and runs. self is only ever whatever arrived first — the dot saves a keystroke, it does not add a rule.you type
class Song:
def __init__(self, title, seconds):
self.title = title
self.seconds = seconds
def play(self):
return f"Playing {self.title}"
blinding = Song("Blinding Lights", 200)
titanium = Song("Titanium", 245)
print(blinding.play()) # the dot form
print(Song.play(blinding)) # the honest form -- identical work
print(blinding.play() == Song.play(blinding))
print(type(Song.play).__name__, type(blinding.play).__name__)
print(blinding.play.__func__ is Song.play)
print(blinding.play.__self__ is blinding)
print(Song.play(titanium)) # self is only ever "whatever came first"you see
Playing Blinding Lights
Playing Blinding Lights
True
function method
True
True
Playing Titanium- Forget
selfin thedefline and the dot form breaks:TypeError: Song.play() takes 0 positional arguments but 1 was given. - Call
Song.play()with nothing in the parentheses and you getTypeError: Song.play() missing 1 required positional argument: 'self'. Type 2 means you pass the object. blinding.playwithout parentheses returns the bound method and prints nothing useful. The( )are a separate operator, exactly as in chapter 9.selfis not a keyword —keyword.iskeyword("self")isFalse. Rename itmeand it works; do it anyway and every reader on earth stumbles.type(Song.play)isfunctionbuttype(blinding.play)ismethod. Same code, two envelopes: one wantsselffrom you, one already has it.
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 runplay. All that changes is which instance rides in as self, and so which object's title, artist, and seconds the line reads.obj.play() always means Song.play(obj); the object left of the dot is the only thing that varies.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.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.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. The first is __func__, the one function stored on the class (blinding.play.__func__ is Song.play → True). The second is __self__, the receiver (blinding.play.__self__ is blinding → True). 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. And 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. And everywhere in this book we'll keep saying "the dot passes self," now that you know what performs the passing.
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. →
Track for the iolinked library. Every track is built from a title, an artist and a running time in seconds, plus one optional flag, explicit, which defaults to False. Give it a docstring — one line, imperative, chapter 9's habit. Then three things to build, in order. First, the initializer: four parameters after self, each stored on the object so that two tracks never share a value. Second, a method label() that returns one display line in exactly this shape: bad guy - Billie Eilish - 3:14 [E]. The minutes come from divmod(self.seconds, 60), the seconds are zero-padded to two digits, and the [E] appears only for explicit tracks. Third, a __repr__ — the same one you met in stage 4 of song_grows.py — returning Track('bad guy', 'Billie Eilish', 194), quotes and all, so a list of tracks prints something a human can read. Now prove all three. Build bad_guy (explicit) and levels (not), print both labels, print the bare object, print a list holding both, and then call Track.label(levels) — the Type 2 spelling — to show it lands in the same place. Finish by printing bad_guy.__dict__. Two hints, no more: the quotes in the repr come free from !r inside the f-string, and {secs:02d} is what pads 14 to 14 and 3 to 03.show the solution
class Track:
"""One track in the iolinked library."""
def __init__(self, title, artist, seconds, explicit=False):
self.title = title
self.artist = artist
self.seconds = seconds
self.explicit = explicit
def label(self):
minutes, secs = divmod(self.seconds, 60)
mark = " [E]" if self.explicit else ""
return f"{self.title} - {self.artist} - {minutes}:{secs:02d}{mark}"
def __repr__(self):
return f"Track({self.title!r}, {self.artist!r}, {self.seconds})"
bad_guy = Track("bad guy", "Billie Eilish", 194, explicit=True)
levels = Track("Levels", "Avicii", 203)
print(bad_guy.label())
print(levels.label())
print(bad_guy)
print([bad_guy, levels])
print(Track.label(levels))
print(bad_guy.__dict__)
print(sorted(k for k in Track.__dict__ if not k.startswith("_")))
# ------------- what it prints -------------
bad guy - Billie Eilish - 3:14 [E]
Levels - Avicii - 3:23
Track('bad guy', 'Billie Eilish', 194)
[Track('bad guy', 'Billie Eilish', 194), Track('Levels', 'Avicii', 203)]
Levels - Avicii - 3:23
{'title': 'bad guy', 'artist': 'Billie Eilish', 'seconds': 194, 'explicit': True}
['label']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.play → True); it's the throwaway staple that's new each time.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.blinding.play.__func__ is the one function stored on the class (is Song.play → True) and blinding.play.__self__ is the receiver (is blinding → True). Two fields, both printable. Calling the wrapper just slots __self__ in as the first argument — nothing is hidden from you.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. That drawer is 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. Renaming the station then 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 body — platform = "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. It's 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) That's a 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.
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 movedobj.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.blinding.platform — two stops, first hit winsfigureplatform 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: 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.
__init__ as self.x = …. One fresh set per object — the right home for anything that differs object to object.obj.name checks the instance's __dict__ first, then climbs to the class. First hit wins, and the climb stops there.Song.count += 1, never self.count += 1.you type
class Song:
platform = "iolinked FM" # one shared value, stored on the class
count = 0 # one shared tally
def __init__(self, title):
self.title = title # one per object
Song.count += 1 # name the CLASS to move the shared copy
blinding = Song("Blinding Lights")
titanium = Song("Titanium")
print(Song.count, blinding.count, titanium.count)
blinding.platform = "offline mix" # plants an INSTANCE attribute
print(blinding.platform, "|", titanium.platform, "|", Song.platform)
print(blinding.__dict__)
del blinding.platform # peel the shadow off
print(blinding.platform)
class Broken:
count = 0
def __init__(self):
self.count += 1 # reads the class, writes the instance
b1, b2 = Broken(), Broken()
print(Broken.count, b1.count, b2.count)you see
2 2 2
offline mix | iolinked FM | iolinked FM
{'title': 'Blinding Lights', 'platform': 'offline mix'}
iolinked FM
0 1 1self.count += 1reads the class value, adds one, and binds the result onto the instance. The shared tally never moves — watchBrokenbelow print0 1 1.- A mutable class attribute is one object.
songs = []in the class body gives every instance the same list, and.appendmutates it for all of them. blinding.platform = …looks like it edits the class. It does not. OnlySong.platform = …moves the shared copy.del blinding.platformdeletes the shadow, not the class value. Run it twice and the second raisesAttributeError— there is no local copy left to delete.- Reading a class attribute through an instance is fine and idiomatic. It is writing through the instance that quietly changes the meaning of the name.
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.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. On top of that sits 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 — chapter 11.)
Traceback (most recent call last):
File "song.py", line 6, in <module>
blinding = Song("Blinding Lights", 200)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Song() takes no arguments_init_, one underscore a side. Python is really saying it looked for __init__, found none, and fell back to object's, which accepts nothing. Your _init_ sits on the class as an ordinary method nobody will ever call — the class isn't broken, the hook is misspelled.__init__. When a class refuses arguments you know it takes, check that spelling first.Traceback (most recent call last):
File "song.py", line 6, in <module>
blinding = Song("Blinding Lights", "The Weeknd", 200)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Song.__init__() takes 3 positional arguments but 4 were givendef __init__(self, title, artist) names 3 slots counting self, and the call delivered 4 values counting the brand-new blank object Python passed in first. The books balance the moment you count self on both sides.def is one parameter short — add seconds. When this message reads off by exactly one, the one is self.Traceback (most recent call last):
File "song.py", line 8, in <module>
print(Song.title)
^^^^^^^^^^
AttributeError: type object 'Song' has no attribute 'title'title is born in __init__ as self.title = …, so it lives in each song's own drawer; the class object holds only what the class body wrote (platform, the methods). You asked the blueprint for one particular house's address — and note the wording type object: the error itself confirms Song is an object too.blinding.title. Ask Song only for what the class body defined.# bank_account.py -- from Ajai's OOP notes, cleaned. The balance is the object's business.
class BankAccount:
bank = "iolinked Savings" # class attribute: one copy for every account
opened = 0 # a shared tally of accounts ever opened
def __init__(self, owner, initial_balance=0):
if initial_balance < 0:
raise ValueError("an account cannot open in the red")
self.owner = owner # instance attribute: one per account
self._balance = initial_balance # one underscore: internal, please knock
BankAccount.opened += 1 # name the CLASS to move the shared tally
def deposit(self, amount):
if amount <= 0:
return False # the guard the first draft was missing
self._balance += amount
return True
def withdraw(self, amount):
if amount <= 0 or amount > self._balance:
return False # refuse; never let the balance go negative
self._balance -= amount
return True
def get_balance(self):
return self._balance
acct = BankAccount("Ajai", 100)
print(acct.bank, "|", acct.owner, "|", acct.get_balance())
print("deposit 50 ->", acct.deposit(50), "| balance", acct.get_balance())
print("withdraw 75 ->", acct.withdraw(75), "| balance", acct.get_balance())
print("withdraw 500 ->", acct.withdraw(500), "| balance", acct.get_balance())
print("deposit -20 ->", acct.deposit(-20), "| balance", acct.get_balance())
second = BankAccount("Anu", 20)
second.deposit(5)
print(second.owner, second.get_balance(), "|", acct.owner, acct.get_balance())
print("accounts opened:", BankAccount.opened, "| shared bank:", second.bank)
try:
BankAccount("Ghost", -5)
except ValueError as e:
print("ValueError:", e)
iolinked Savings | Ajai | 100 deposit 50 -> True | balance 150 withdraw 75 -> True | balance 75 withdraw 500 -> False | balance 75 deposit -20 -> False | balance 75 Anu 25 | Ajai 75 accounts opened: 2 | shared bank: iolinked Savings ValueError: an account cannot open in the red
BankAccount, kept in its original shape and repaired in three places. Save it as bank_account.py and run it, then read the run in his order: 100, deposit 50, 150, withdraw 75, 75. That is the exact sequence in his file, and it still works line for line. Now the three repairs, because a fixed bug is a lesson. One, his draft misspelled the parameter intial_balance; harmless until the day a caller writes BankAccount(initial_balance=100) and gets a TypeError for a word nobody can see is wrong. Two, his deposit had no guard, so deposit(-20) was a silent withdrawal that skipped the overdraft check entirely — here it returns False and the balance holds at 75. Three, his balance was self.__balance with two underscores, presented as private; section 06 shows that double underscore is a rename, not a lock, so we use the honest single underscore that means internal, please knock. What has not changed is his real point, and it is the right one: the balance is the object's business. Nothing outside the class adds or subtracts; callers ask deposit and withdraw, which can refuse. Notice too where each value lives — bank and opened sit once on the class, while owner and _balance sit in each account's own drawer. Two things to try. Add a transfer(self, other, amount) that withdraws here and deposits there, and returns False unless both succeed. Then set acct._balance = -999 from outside and watch Python allow it — that underscore was a handshake, and this chapter never lies about which promises the interpreter actually keeps.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.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.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 sharingThe 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. It's true for every ordinary attribute you'll write, and worth building your model on, but it's 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. It's a data descriptor, so it outranks your drawer. Plain values and plain methods are not data descriptors. That's 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. 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. →
class Playlist: with songs = [] written straight in the class body, and one method, def add(self, title): self.songs.append(title). Build two playlists, night and gym. Add "Blinding Lights" to night and "Titanium" to gym. Before you run a line, write down what you expect each playlist to hold. Now run it. Both hold both songs, and neither of them asked for that. Your job is three steps. First, prove where the memory lives: print night.songs is gym.songs, then print Playlist.songs, then print night.__dict__ — and sit with that last one, because the instance's own drawer comes back completely empty. Nothing was ever stored on either object; both .songs reads climbed to the class and found the one list. Second, explain the asymmetry in one sentence: why does .append reach the shared list when a plain assignment like night.songs = [] would not? Third, repair it so each playlist owns its own list, without changing a single one of the four calling lines. Then re-run all three proofs and watch is flip to False and the drawer fill up. One hint and no more: the class body runs once, but __init__ runs once per object — and that difference is the entire bug.show the solution
# the bug, run honestly
class Playlist:
songs = [] # ONE list, built once, living on the class
def add(self, title):
self.songs.append(title) # climbs to the class, then mutates in place
night = Playlist()
gym = Playlist()
night.add("Blinding Lights")
gym.add("Titanium")
print("night:", night.songs)
print("gym :", gym.songs)
print("same list?", night.songs is gym.songs)
print("on the class:", Playlist.songs)
print("night's own drawer:", night.__dict__)
# the fix: the list is born in __init__, so every object gets its own
class Playlist:
def __init__(self):
self.songs = []
def add(self, title):
self.songs.append(title)
night = Playlist()
gym = Playlist()
night.add("Blinding Lights")
gym.add("Titanium")
print()
print("night:", night.songs)
print("gym :", gym.songs)
print("same list?", night.songs is gym.songs)
print("on the class:", "songs" in Playlist.__dict__)
print("night's own drawer:", night.__dict__)
# ------------- what it prints -------------
night: ['Blinding Lights', 'Titanium']
gym : ['Blinding Lights', 'Titanium']
same list? True
on the class: ['Blinding Lights', 'Titanium']
night's own drawer: {}
night: ['Blinding Lights']
gym : ['Titanium']
same list? False
on the class: False
night's own drawer: {'songs': ['Blinding Lights']}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.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.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.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.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, which means say what's new and 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). It can override a method by defining its own version (a different play()). Or it can 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.
super().__init__(…) runs the parent's initializer on this same object, then your lines continue. Replace versus extend is the whole choice.Remix is a Song. For the exact class and nothing derived, ask type(obj) is Song.issubclass(Remix, Song) is True; the reverse is False, because inheritance runs one way only.you type
class Song:
def __init__(self, title, artist, seconds):
self.title, self.artist, self.seconds = title, artist, seconds
def play(self):
return f"Playing {self.title} by {self.artist}"
def encore(self):
return f"One more time: {self.title}!"
class Remix(Song): # say what is new, borrow the rest
def __init__(self, title, artist, seconds, remixer):
super().__init__(title, artist, seconds) # the parent's setup, same self
self.remixer = remixer # then the new field
def play(self): # override: same name, new body
return f"Playing {self.title} ({self.remixer} Remix)"
blinding = Song("Blinding Lights", "The Weeknd", 200)
kaskade = Remix("Blinding Lights", "The Weeknd", 200, "Kaskade")
print(blinding.play())
print(kaskade.play())
print(kaskade.encore()) # inherited -- Remix never wrote it
print(kaskade.artist) # set by Song.__init__, reached through super()
print(isinstance(kaskade, Remix), isinstance(kaskade, Song), type(kaskade) is Song)
print(issubclass(Remix, Song), issubclass(Song, Remix))
print([c.__name__ for c in Remix.__mro__])you see
Playing Blinding Lights by The Weeknd
Playing Blinding Lights (Kaskade Remix)
One more time: Blinding Lights!
The Weeknd
True True False
True False
['Remix', 'Song', 'object']- Write your own
__init__and it replaces the parent's. Skipsuper().__init__(…)andself.artistis never set — the object is born half-built. super().__init__(title, artist, seconds)passes noself.super()already knows which object it is standing on.- Nothing is copied at
classtime. Add a method to the parent later and every existing child object can call it immediately. type(kaskade) is SongisFalseeven thoughisinstance(kaskade, Song)isTrue. Two different questions; pick the one you actually mean.- Inheritance is for is-a, never for needs-the-code. If the child is not honestly a kind of the parent, you want a field, not a base class.
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 or 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.
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.
# live_set.py -- one parent, three children, and the only three moves: add, override, extend.
class Song:
def __init__(self, title, artist, seconds):
self.title = title
self.artist = artist
self.seconds = seconds
def play(self):
return f"Playing {self.title} by {self.artist}"
def encore(self):
return f"One more time: {self.title}!"
def __repr__(self):
return f"{type(self).__name__}({self.title!r}, {self.seconds})"
class LiveSong(Song): # EXTEND: the parent's setup, plus a venue
def __init__(self, title, artist, seconds, venue):
super().__init__(title, artist, seconds)
self.venue = venue
def play(self): # OVERRIDE: same name, new body
return f"Playing {self.title} live at {self.venue}"
class Remix(Song): # ADD: a new field, and no play() at all
def __init__(self, title, artist, seconds, remixer):
super().__init__(f"{title} ({remixer} Remix)", artist, seconds)
self.remixer = remixer
class Careless(Song): # the classic mistake, made on purpose
def __init__(self, title):
self.title = title # no super() -- artist is never set
setlist = [
Song("Blinding Lights", "The Weeknd", 200),
LiveSong("Titanium", "David Guetta", 245, "Tomorrowland"),
Remix("Blinding Lights", "The Weeknd", 200, "Kaskade"),
]
for track in setlist:
print(f"{type(track).__name__:9} {track.play()}")
print()
print(setlist)
print("encore, inherited by all three :", setlist[1].encore())
print("seconds, set through super() :", setlist[1].seconds)
print("isinstance / issubclass :", isinstance(setlist[1], Song), issubclass(Remix, Song))
print("LiveSong MRO :", [c.__name__ for c in LiveSong.__mro__])
print()
broken = Careless("Levels")
try:
print(broken.play())
except AttributeError as e:
print("AttributeError:", e)
Song Playing Blinding Lights by The Weeknd
LiveSong Playing Titanium live at Tomorrowland
Remix Playing Blinding Lights (Kaskade Remix) by The Weeknd
[Song('Blinding Lights', 200), LiveSong('Titanium', 245), Remix('Blinding Lights (Kaskade Remix)', 200)]
encore, inherited by all three : One more time: Titanium!
seconds, set through super() : 245
isinstance / issubclass : True True
LiveSong MRO : ['LiveSong', 'Song', 'object']
AttributeError: 'Careless' object has no attribute 'artist'
live_set.py and run it. One parent and three children, each demonstrating exactly one of the three moves. LiveSong extends — super().__init__ runs Song's setup on this same object, then self.venue is added — and it overrides play(). Remix only adds: it rewrites the title on the way past and never defines a play() at all, which is why its line still reads by The Weeknd in Song's own words, with the remixed title threaded through. That single line is the whole idea: method borrowed from the parent, data owned by the child. Read the __repr__ too — it is written once on Song, yet the list prints Song(…), LiveSong(…) and Remix(…), because type(self).__name__ asks the object, at run time, what it actually is. Then there is Careless, and it is on purpose. It writes its own __init__ and forgets super(), so self.artist is never set. Python builds the object happily and says nothing. The bill arrives later, somewhere else in your program: AttributeError: 'Careless' object has no attribute 'artist'. Writing your own __init__ replaces the parent's; super() is the only way to extend it instead. Two things to try. Give Remix its own play() and watch the third line change while the first two stand still. Then add super().__init__(title, "unknown", 0) to Careless and watch the AttributeError disappear.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.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.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(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.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__. 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:
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 instantlyIf 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. It's 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:
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 ONCEInside 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 sometimes 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. Then 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.
BadPlaylist inherits a seconds it can only lie about.self.songs instead, then add __len__ and __getitem__ so your own type still behaves like one.Playlist has Songs, while a LiveSong is one. The two are answers to different questions.you type
class Song:
def __init__(self, title, seconds):
self.title, self.seconds = title, seconds
class Playlist: # HAS-A: a playlist holds songs
def __init__(self, name):
self.name = name
self.songs = [] # the parts live in a field
def add(self, song):
self.songs.append(song)
return self
def total_seconds(self):
return sum(s.seconds for s in self.songs)
class LiveSong(Song): # IS-A: a live cut is a song, with a venue
def __init__(self, title, seconds, venue):
super().__init__(title, seconds)
self.venue = venue
night = Playlist("Night drive")
night.add(Song("Blinding Lights", 200)).add(LiveSong("Titanium", 245, "Tomorrowland"))
print(night.name, len(night.songs), night.total_seconds())
print(isinstance(night, Song), isinstance(night.songs[1], Song))
class BadPlaylist(Song): # the wrong tree: inheriting what you merely own
pass
wrong = BadPlaylist("Night drive", 0)
print(wrong.title, wrong.seconds) # a playlist with a running time of its own?
print(isinstance(wrong, Song)) # True -- and that is exactly the bugyou see
Night drive 2 445
False True
Night drive 0
True- "I need the parent's code" is not a reason to inherit. It is a reason to hold an instance of it and call it — the child would inherit the parent's identity too.
isinstance(night, Song)isFalse, and that is the design working. Nothing that expects a song will ever be handed a playlist by mistake.- Inherit wrongly and every method you never wanted comes along.
BadPlaylist("Night drive", 0)is a playlist with its own running time — nonsense that type-checks. - Composition needs no ceremony: a field and a few short methods that pass work through. That plainness is the point, not a missing feature.
- Deep hierarchies are the usual symptom. Three or four levels of
classalmost always mean a has-a was written as an is-a somewhere near the top.
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.__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.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.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'].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. 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. It's 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. It 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.
.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 — 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:
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.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.
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.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.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).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.
class B(A)), HAS-A (hold one in a field), or NEITHER (unrelated types that merely share a verb) — and write one sentence of reason before you look. 1. Album and Song. 2. AcousticVersion and Song. 3. Playlist and the built-in list. 4. PodcastEpisode and Song, given that both define play(). Now make the answers stand up in code. Write all four classes, small ones, then prove each verdict with a line that would fail if you had chosen differently: for the has-a cases print isinstance(album, Song) and isinstance(pl, list) and show them coming back False; for the is-a case print isinstance(ac, Song) coming back True and show AcousticVersion overriding play(); for the last one, drop a Song and a PodcastEpisode into the same playlist and loop over them calling .play() with no if anywhere. That last loop is the answer to scenario 4 in one line: they share a verb, not a bloodline. One hint. Ask the question out loud in plain English — "an album is a song" is obviously false the moment you hear it, and that is the entire test.show the solution
# four scenarios, and the code that settles each one
class Song:
def __init__(self, title, artist, seconds):
self.title, self.artist, self.seconds = title, artist, seconds
def play(self):
return f"Playing {self.title} by {self.artist}"
def __repr__(self):
return f"Song({self.title!r})"
# 1 - Album and Song -> HAS-A. An album is not a song; it holds songs.
class Album:
def __init__(self, title, songs):
self.title = title
self.songs = list(songs)
def total_seconds(self):
return sum(s.seconds for s in self.songs)
# 2 - AcousticVersion and Song -> IS-A. The same thing, one twist.
class AcousticVersion(Song):
def play(self):
return f"Playing {self.title} (acoustic) by {self.artist}"
# 3 - Playlist and list -> HAS-A. Hold a list; do not become one.
class Playlist:
def __init__(self, name):
self.name = name
self.items = []
def add(self, item):
self.items.append(item)
def __len__(self):
return len(self.items)
# 4 - PodcastEpisode and Song -> NEITHER. No shared ancestor, just the same verb.
class PodcastEpisode:
def __init__(self, show, host, seconds):
self.show, self.host, self.seconds = show, host, seconds
def play(self):
return f"Streaming {self.show}, hosted by {self.host}"
bl = Song("Blinding Lights", "The Weeknd", 200)
ac = AcousticVersion("Titanium", "David Guetta", 230)
ep = PodcastEpisode("Darknet Diaries", "Jack Rhysider", 3600)
album = Album("After Hours", [bl, ac])
print("1 has-a :", album.title, "|", len(album.songs), "songs |", album.total_seconds(), "s |",
"isinstance(album, Song) ->", isinstance(album, Song))
print("2 is-a :", ac.play(), "| isinstance(ac, Song) ->", isinstance(ac, Song))
pl = Playlist("Night drive")
pl.add(bl)
pl.add(ep)
print("3 has-a :", len(pl), "items | isinstance(pl, list) ->", isinstance(pl, list))
print("4 duck :", [item.play() for item in pl.items])
# ------------- what it prints -------------
1 has-a : After Hours | 2 songs | 430 s | isinstance(album, Song) -> False
2 is-a : Playing Titanium (acoustic) by David Guetta | isinstance(ac, Song) -> True
3 has-a : 2 items | isinstance(pl, list) -> False
4 duck : ['Playing Blinding Lights by The Weeknd', 'Streaming Darknet Diaries, hosted by Jack Rhysider']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. 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). 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.
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-playlistThe 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's 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. →
self. Use it whenever the answer depends on this particular thing.cls. The everyday use is an alternate constructor: Song.from_string("…") parses a row and hands back a finished object.cls follows the caller. Remix.from_string(…) builds a Remix; hard-coding Song(…) would silently build the wrong kind for every subclass.Song.mmss(245) reads better than a loose mmss floating in the module.@classmethod above def f means exactly f = classmethod(f) — the same one-line rewrite you met with @abstractmethod. It stores a different kind of object on the class, and that object decides what fills slot one.you type
class Song:
def __init__(self, title, artist, seconds):
self.title, self.artist, self.seconds = title, artist, seconds
def play(self): # instance method: self is the object
return f"Playing {self.title}"
@classmethod
def from_string(cls, row): # cls is the CLASS itself
title, artist, seconds = row.split(" - ")
return cls(title, artist, int(seconds)) # cls(...) builds the right kind
@staticmethod
def mmss(seconds): # no self, no cls: a plain helper
minutes, secs = divmod(seconds, 60)
return f"{minutes}:{secs:02d}"
class Remix(Song):
pass
blinding = Song.from_string("Blinding Lights - The Weeknd - 200")
kaskade = Remix.from_string("Blinding Lights - The Weeknd - 205")
print(blinding.title, blinding.seconds, Song.mmss(blinding.seconds))
print(type(blinding).__name__, type(kaskade).__name__) # cls followed the caller
print(Song.mmss(245), blinding.mmss(245))
print(Song.from_string.__self__ is Song, Remix.from_string.__self__ is Remix)
print(type(Song.__dict__["mmss"]).__name__, type(Song.__dict__["from_string"]).__name__)you see
Blinding Lights 200 3:20
Song Remix
4:05 4:05
True True
staticmethod classmethod- Write
@classmethodand then forgetclsin the parameter list, and the first call fails — Python still passes the class, and your slot one is already taken bytext. - A classmethod that returns
Song(…)instead ofcls(…)looks identical until someone subclasses you. Then every subclass factory quietly hands back the parent. - A staticmethod that reaches for
selforclsraisesNameError. If it needs either one, it was never static. @staticmethodis not a performance trick. It is a readability claim: this helper needs nothing from the object or the class.- The decorator lines carry no parentheses:
@classmethod, not@classmethod(). The second form calls it with no arguments and fails immediately.
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.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.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."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 but 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.
print(x) quietly uses __repr__ instead, which is why __repr__ comes first.__repr__, however lovely your __str__ is. That is not a bug; a container is showing you its contents, not addressing you.== falls back to identity — two songs with identical data compare False because they are two objects. With it, you decide which fields count as sameness.len(x) to your own count, and gives you truthiness for free: an object with __len__ returning 0 is falsy in an if.you type
class Song: # no dunders of its own
def __init__(self, title, artist, seconds):
self.title, self.artist, self.seconds = title, artist, seconds
class Track: # the same data, wired into the language
def __init__(self, title, artist, seconds):
self.title, self.artist, self.seconds = title, artist, seconds
def __repr__(self): # for the programmer: unambiguous
return f"Track({self.title!r}, {self.artist!r}, {self.seconds})"
def __str__(self): # for the human reading the screen
return f"{self.title} - {self.artist}"
def __eq__(self, other): # what == means for two Tracks
if not isinstance(other, Track):
return NotImplemented # "not mine" -- let the other side try
return (self.title, self.artist) == (other.title, other.artist)
class Playlist:
def __init__(self, tracks):
self.tracks = list(tracks)
def __len__(self): # len(pl)
return len(self.tracks)
a = Song("Blinding Lights", "The Weeknd", 200)
b = Song("Blinding Lights", "The Weeknd", 200)
print(a == b, a is b) # no __eq__: == falls back to identity
x = Track("Blinding Lights", "The Weeknd", 200)
y = Track("Blinding Lights", "The Weeknd", 200)
print(x == y, x is y) # same value, two objects
print(str(x))
print(repr(x))
print(x) # print asks __str__ ...
print([x, y]) # ... a container always asks __repr__
print(len(Playlist([x, y])))
print(x == "Blinding Lights") # NotImplemented -> Python falls backyou see
False False
True False
Blinding Lights - The Weeknd
Track('Blinding Lights', 'The Weeknd', 200)
Blinding Lights - The Weeknd
[Track('Blinding Lights', 'The Weeknd', 200), Track('Blinding Lights', 'The Weeknd', 200)]
2
False- Define
__eq__and Python sets__hash__toNone. Your objects turn unhashable, and asetor dict key raisesTypeError. Add__hash__over the same fields. - Return
NotImplemented, neverFalse, for a type you do not handle.Falseslams the door;NotImplementedlets Python ask the other operand. NotImplementedis a value you return.NotImplementedErroris an exception you raise. Different words, different jobs, and the confusion is universal.__str__and__repr__must return astr. Return an int and Python raisesTypeError: __str__ returned non-string (type int).__len__must hand back a non-negativeint. Return a float andlen()refuses it outright.
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.__init__. The decorator reads them in order and generates the initializer from them.__init__, __repr__ and __eq__, free and correct. Three of the four you hand-wrote a moment ago, for one line of import and one decorator.field(default_factory=list) calls list() once per object instead of sharing one list.frozen=True makes assignment raise and the object hashable. order=True adds the four comparisons, so sorted() works on the fields in declaration order.you type
from dataclasses import dataclass, field
@dataclass
class Song:
title: str
artist: str
seconds: int = 0
@dataclass
class Playlist:
name: str
songs: list = field(default_factory=list) # a FRESH list for every object
a = Song("Blinding Lights", "The Weeknd", 200)
b = Song("Blinding Lights", "The Weeknd", 200)
print(a) # __repr__, written for you
print(a == b, a is b) # __eq__, field by field
print(a.title, a.seconds)
night, gym = Playlist("Night"), Playlist("Gym")
night.songs.append(a)
print(len(night.songs), len(gym.songs), night.songs is gym.songs)
print(Playlist("Empty"))
@dataclass(frozen=True, order=True)
class Sample:
value: int
print(sorted([Sample(3), Sample(-1), Sample(2)]))
try:
Sample(1).value = 9
except Exception as e:
print(type(e).__name__ + ":", e)you see
Song(title='Blinding Lights', artist='The Weeknd', seconds=200)
True False
Blinding Lights 200
1 0 False
Playlist(name='Empty', songs=[])
[Sample(value=-1), Sample(value=2), Sample(value=3)]
FrozenInstanceError: cannot assign to field 'value'songs: list = []is refused the moment the class is built:ValueError: mutable default <class 'list'> for field songs is not allowed: use default_factory. Chapter 9's trap, caught for you.- A field with a default cannot precede one without. Put
seconds: int = 0aboveartist: strand you getTypeError: non-default argument 'artist' follows default argument— the same rule as anydefline. - The annotation is not enforced.
Song(1, 2, 3)builds happily —strandinthere are documentation for humans and type-checkers, not a runtime guard. - A field with no annotation is not a field at all. Write
title = ""without the: strand the decorator ignores it entirely. order=Truecompares fields in declaration order, top to bottom. If that is not the ordering you meant, write__lt__yourself.
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__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 seconds — 200 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. The array 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. And a sample is an object like any other, so it too can own an __add__.
a + b. Return a new object and leave both operands alone — that is what everyone expects from +, and breaking it is a cruel surprise.__radd__ before giving up.unsupported operand type(s) for +. That message is the sound of two objects both saying "not mine."sorted(), min() and max() all start working on your objects. They ask nothing else — ordering in Python is built on less than alone.__eq__ and __lt__, add the decorator, and Python synthesises <=, >, >=. Four methods you never have to keep consistent by hand.you type
class Song:
def __init__(self, title, seconds):
self.title, self.seconds = title, seconds
def __repr__(self):
return f"Song({self.title!r}, {self.seconds})"
def __lt__(self, other): # one method, and sorted() starts working
if not isinstance(other, Song):
return NotImplemented
return self.seconds < other.seconds
def __add__(self, other): # song + song -> a Playlist
if isinstance(other, Song):
return Playlist([self, other])
return NotImplemented # honest: "not mine, try yours"
class Playlist:
def __init__(self, songs):
self.songs = list(songs)
def __repr__(self):
return f"Playlist({self.songs!r})"
def __len__(self):
return len(self.songs)
def __add__(self, other):
return Playlist(self.songs + other.songs)
a = Song("Blinding Lights", 200)
b = Song("Titanium", 245)
c = Song("bad guy", 194)
print(a < b, b < a)
print(sorted([a, b, c]))
print(min([a, b, c]), "|", max([a, b, c]))
print(a + b, "|", len(a + b))
print(len(Playlist([a, b]) + Playlist([c])))
try:
a + 5
except TypeError as e:
print("TypeError:", e)you see
True False
[Song('bad guy', 194), Song('Blinding Lights', 200), Song('Titanium', 245)]
Song('bad guy', 194) | Song('Titanium', 245)
Playlist([Song('Blinding Lights', 200), Song('Titanium', 245)]) | 2
3
TypeError: unsupported operand type(s) for +: 'Song' and 'int'- Mutating
selfinside__add__and returning it makesa + bsilently changea. Build something new; that is what+promises. - Return
Falseinstead ofNotImplementedand mixed-type comparisons break in ways that are miserable to trace — the other operand never gets its turn. sorted()needs__lt__, not__eq__. A class with equality and no ordering raisesTypeError: '<' not supported between instances of 'Song' and 'Song'.- You do not always need a dunder to sort.
sorted(songs, key=lambda s: s.seconds)works on any object, and is the right call when the order is one report's idea of order, not the type's. +=falls back to__add__and rebinds the name. Define__iadd__only when you truly mean in-place mutation, and know it changes what the caller is holding.
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 railsSample.__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.Track, holding title, artist and seconds, with a __repr__ that prints Track('bad guy', 194). Build four of them: a and b identical (Blinding Lights, The Weeknd, 200), plus Titanium at 245 and bad guy at 194. Four jobs, in this order, running the proofs as you go. One — equality by value. Right now a == b is False, because == without __eq__ compares identity. Write __eq__ so two tracks are equal when title and artist match, and return NotImplemented for anything that is not a Track. Prove it: a == b is True, a is b is still False, and a == "Blinding Lights" comes back a clean False rather than an exception. Two — repair the hash. Now put all four in a set. It raises TypeError: unhashable type, because defining __eq__ set __hash__ to None. Fix it by hashing the same fields you compared, then show len({a, b, c}) is 2 and that {a: "night drive"}[b] finds the entry. Three — order. Write __lt__ so tracks sort by running time, with the title breaking ties, and check that sorted(), min() and max() all come alive from that one method. Four — the other four comparisons. Add @total_ordering from functools and confirm >= and <= now work without another line. One hint: a tuple compares element by element, so (self.seconds, self.title) < (other.seconds, other.title) is the whole of job three.show the solution
from functools import total_ordering
@total_ordering
class Track:
def __init__(self, title, artist, seconds):
self.title, self.artist, self.seconds = title, artist, seconds
def __repr__(self):
return f"Track({self.title!r}, {self.seconds})"
def __eq__(self, other):
if not isinstance(other, Track):
return NotImplemented
return (self.title, self.artist) == (other.title, other.artist)
def __hash__(self): # __eq__ took the free one away
return hash((self.title, self.artist))
def __lt__(self, other):
if not isinstance(other, Track):
return NotImplemented
return (self.seconds, self.title) < (other.seconds, other.title)
a = Track("Blinding Lights", "The Weeknd", 200)
b = Track("Blinding Lights", "The Weeknd", 200)
c = Track("Titanium", "David Guetta", 245)
d = Track("bad guy", "Billie Eilish", 194)
print("value equality :", a == b, "| identity:", a is b)
print("a != c :", a != c)
print("sorted :", sorted([a, c, d]))
print("min / max :", min([a, c, d]), "|", max([a, c, d]))
print("total_ordering :", a < c, c >= a, d <= a)
print("set dedupes :", len({a, b, c}))
print("foreign type :", a == "Blinding Lights")
print("dict key :", {a: "night drive"}[b])
# ------------- what it prints -------------
value equality : True | identity: False
a != c : True
sorted : [Track('bad guy', 194), Track('Blinding Lights', 200), Track('Titanium', 245)]
min / max : Track('bad guy', 194) | Track('Titanium', 245)
total_ordering : True True True
set dedupes : 2
foreign type : False
dict key : night drivelen, [ ], +, ==, print, and a property, each desugared to the exact method Python dispatches — with the real value it returns.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. That's the perfect place to reject a negative running time before it can corrupt your data.
@property over def minutes is just minutes = property(minutes). It stores a property object on the class where the method's name used to be.bl.minutes runs the body on every read. It is derived from _seconds, so it can never drift out of sync — there is no second copy to go stale.@seconds.setter is seconds = seconds.setter(func). It fires on assignment, which is the one place a guard can stop a bad value before it lands.AttributeError: property 'minutes' of 'Song' object has no setter. Published, readable, and frozen — in three lines.self.seconds; the day it needs validating, turn it into a property and every bl.seconds already written keeps working.you type
class Song:
def __init__(self, title, seconds):
self.title = title
self.seconds = seconds # this goes through the setter below
@property
def seconds(self): # the getter: reads like plain data
return self._seconds
@seconds.setter
def seconds(self, value): # runs on every assignment
if value < 0:
raise ValueError("a song cannot run negative time")
self._seconds = value
@property
def minutes(self): # computed on demand, never stored
return round(self._seconds / 60, 2)
bl = Song("Blinding Lights", 200)
print(bl.seconds, bl.minutes) # no parentheses at the call site
bl.seconds = 245
print(bl.seconds, bl.minutes) # recomputed -- it cannot drift
print(bl.__dict__) # only _seconds was ever stored
try:
bl.seconds = -1
except ValueError as e:
print("ValueError:", e)
try:
bl.minutes = 5
except AttributeError as e:
print("AttributeError:", e)
print(type(Song.__dict__["minutes"]).__name__)you see
200 3.33
245 4.08
{'title': 'Blinding Lights', '_seconds': 245}
ValueError: a song cannot run negative time
AttributeError: property 'minutes' of 'Song' object has no setter
property- The getter and the underlying field must have different names.
self.secondsbacked byself.secondscalls the setter from inside the setter, forever, untilRecursionError. - The setter's
defreuses the property's name:@seconds.setterabovedef seconds(self, value). Rename it and you get two unrelated attributes. - A property is a data descriptor, so it outranks the instance's own
__dict__— the one refinement to section 03's instance-first rule. You cannot shadow it by assigning. - Properties belong on the class, never on an instance. Setting
bl.minutes = property(…)stores a plain object; the protocol only fires for class attributes. - Do not hide expensive work behind a property. Readers expect
obj.xto be cheap; if it queries a database, make it a method with parentheses and be honest about the cost.
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. And that sets up the single biggest misconception in the language. A great many people believe a second underscore upgrades that handshake into a lock. It does not.
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@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:
__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.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.
__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__.+, 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__. Python then 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. →
- A class puts data and behaviour in one place: the blueprint is written once, and every
Song(…)stamps an object onto the heap with its own drawer of data and its own kind. selfis just the first parameter —blinding.play()isSong.play(blinding), the object left of the dot shipped in as argument number one.- Every dotted read runs one fixed climb — this object's drawer first, then the class, first hit wins — and a plain write never climbs: it lands in the object's own drawer (until section 06's
@propertysteps in front of it). - Inheritance copies nothing: a child stores only what's new, every call walks the MRO fresh at that moment — so the object, never the caller, picks which
play()runs. - A dunder is a named socket into the syntax — wire up
__add__,__len__,__eq__and your type speaks+,len()and==— and "privacy" is a rename plus a handshake, never a lock.
__dict__ drawer; chapter 9 gave you functions — a method is one with the instance as its first argument; chapters 10 and 11 gave you the comprehensions, sorted() and __slots__ the Playlist's methods are made of. This chapter didn't hand you new parts — it moved them in together.# playlist.py -- the capstone. A Playlist HAS Songs, and speaks Python's own language.
class Song:
def __init__(self, title, artist, seconds):
self.title = title
self.artist = artist
self.seconds = seconds
def __repr__(self):
return f"Song({self.title!r}, {self.artist!r}, {self.seconds})"
def __str__(self):
minutes, secs = divmod(self.seconds, 60)
return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"
class Playlist:
def __init__(self, name, songs=None):
self.name = name
self.songs = list(songs) if songs else [] # our own list, never the caller's
# --- behaviour, built from chapters 6 to 10 ---
def add(self, song):
self.songs.append(song) # ch 6
return self # so calls can chain
def remove(self, title):
before = len(self.songs)
self.songs = [s for s in self.songs if s.title != title] # ch 10
return before - len(self.songs)
def total_seconds(self):
return sum(s.seconds for s in self.songs) # ch 10
def longest(self):
return max(self.songs, key=lambda s: s.seconds) if self.songs else None
def by_artist(self):
index = {}
for s in self.songs:
index.setdefault(s.artist, []).append(s.title) # ch 7
return index
def sorted_by_length(self):
return sorted(self.songs, key=lambda s: s.seconds) # ch 10
# --- the sockets, from this chapter ---
def __len__(self):
return len(self.songs)
def __getitem__(self, i):
return self.songs[i]
def __add__(self, other):
return Playlist(f"{self.name} + {other.name}", self.songs + other.songs)
def __str__(self):
minutes, secs = divmod(self.total_seconds(), 60)
lines = [f"{self.name} - {len(self)} songs - {minutes}:{secs:02d}"]
lines += [f" {i}. {song}" for i, song in enumerate(self.songs, 1)]
return "\n".join(lines)
night = Playlist("Night drive")
night.add(Song("Blinding Lights", "The Weeknd", 200))
night.add(Song("Titanium", "David Guetta", 245))
night.add(Song("Levels", "Avicii", 203))
print(night)
print()
print("len(night) ->", len(night))
print("night[0] ->", night[0])
print("total_seconds() ->", night.total_seconds())
print("longest() ->", night.longest())
print("sorted_by_length() ->", [s.title for s in night.sorted_by_length()])
print("by_artist() ->", night.by_artist())
print("remove('Levels') ->", night.remove("Levels"), "removed,", len(night), "left")
gym = Playlist("Gym", [Song("Uptown Funk", "Mark Ronson", 270)])
both = night + gym
print()
print(both)
print("looping the object ->", [s.title for s in both])
Night drive - 3 songs - 10:48
1. Blinding Lights - The Weeknd (3:20)
2. Titanium - David Guetta (4:05)
3. Levels - Avicii (3:23)
len(night) -> 3
night[0] -> Blinding Lights - The Weeknd (3:20)
total_seconds() -> 648
longest() -> Titanium - David Guetta (4:05)
sorted_by_length() -> ['Blinding Lights', 'Levels', 'Titanium']
by_artist() -> {'The Weeknd': ['Blinding Lights'], 'David Guetta': ['Titanium'], 'Avicii': ['Levels']}
remove('Levels') -> 1 removed, 2 left
Night drive + Gym - 3 songs - 11:55
1. Blinding Lights - The Weeknd (3:20)
2. Titanium - David Guetta (4:05)
3. Uptown Funk - Mark Ronson (4:30)
looping the object -> ['Blinding Lights', 'Titanium', 'Uptown Funk']
playlist.py and run it. This is the chapter's capstone, and every thread of the course arrives in it at once. The design decision is the first line of the class: a Playlist HAS songs, so they live in a field, and it is emphatically not a subclass of Song or of list. Then look at what fills the methods — nothing new, all of it yours already. add is .append from chapter 6; remove is a list comprehension with a condition, chapter 10; total_seconds is a generator expression inside sum; longest is max with a key= lambda; by_artist is dict.setdefault from chapter 7; sorted_by_length is sorted(key=…). The only genuinely new thing this chapter added is where those lines now live: inside an object that owns the data they run on. Then the sockets. __len__ makes len(night) work; __getitem__ makes night[0] work and, for free, makes for song in both loop — no __iter__ anywhere; __add__ merges two playlists into a third, naming it as it goes; and __str__ builds the whole multi-line listing with enumerate and "\n".join. Notice add returns self, which is why calls can chain. Notice too that __init__ does list(songs) rather than storing the caller's list — chapter 3's aliasing, refused on purpose. Three things to try. Add a shuffle() using random.sample that returns a new Playlist and leaves this one in order. Give Song a __lt__ on seconds and watch sorted(night) start working with no key at all. Then add a Podcast class with its own __str__, drop one into the songs list, and see how much of this still runs — and exactly which line is the first to complain.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.__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.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.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.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.
__init__; stamp out as many independent instances as you like.self; a @property computes a value on demand.super(), override to specialise, and answer the same call in their own way.__methods__ let your objects work with print, ==, + and len().