A reminder before we start: the mutable default trap
Recalling lesson 2-2, the problem with def register(user, users=[]) is that the list default is created once, so every call shares one list.
The [] is built a single time, when the def line runs, not on each call. The fix is to default to None and create the list inside the body. This is worth having fresh in mind, because class attributes have a similar sharing gotcha later in this unit.
It is not standard, harmless Python, lists have no trouble holding strings, and the parameter order is fine. The sharing is the whole issue.
When a dict is not enough
Almost every large Python codebase, from web frameworks to data libraries to the standard library itself, is organized around classes, so this unit is where your code starts to look like a working engineer's. The motivation is concrete.
In Python for Beginners you modeled things with dicts: player = {"name": "mia", "score": 0}. That works right up until there is behavior that belongs to the data. The code that levels a player up has nowhere natural to live. You end up with loose functions that each take a player dict, spread across the file, and nothing ties them to the shape of data they assume.
A class bundles data and behavior into one unit. It is a blueprint. Each value stamped from the blueprint is an object (also called an instance) with its own data:
class Player: def __init__(self, name): self.name = name self.score = 0
__init__ is the initializer: Python calls it automatically when you write Player("mia"). self is the object being created, and self.name = name stores data on that object as an attribute.
Two instances, two separate scores
mia and leo are stamped from the same blueprint but each keeps its own data. Changing one never touches the other.
class Player: def __init__(self, name): self.name = name self.score = 0 mia = Player("mia") leo = Player("leo") mia.score = 50 print(mia.name, mia.score) print(leo.name, leo.score)
Output
mia 50 leo 0
Each Player(...) call runs __init__ again on a brand-new object, so self.score = 0 creates a separate score attribute per instance. This per-instance isolation is the whole point of a class, and it is exactly what a single shared dict would not give you.
Storing parameters as attributes
A Book class whose __init__ takes title and pages and stores both on the instance, then two books created from it.
This is the plainest possible __init__: every parameter is copied straight onto self. Each Book(...) call produces a separate object with its own title and pages.
class Book: def __init__(self, title, pages): self.title = title self.pages = pages dune = Book("Dune", 412) hamlet = Book("Hamlet", 160) print(dune.title, dune.pages) print(hamlet.title, hamlet.pages)
Output
Dune 412 Hamlet 160
Three things this code depends on
selfis always the first parameter of__init__, and Python fills it in for you.Book("Dune", 412)passes two arguments, not three.self.title = titleis what makes the value outlive the call. A baretitle = titlewould only touch the local parameter and vanish when__init__returns.- Attributes are read back with dot notation,
dune.title, andduneandhamletnever share state.
Computing an attribute at creation time
__init__ can compute attributes, not only copy parameters. This Circle stores radius and also derives self.diameter as radius * 2 the moment the object is built.
Any expression can appear on the right-hand side, so anything cheap and always-needed can be settled once here rather than recalculated at every use.
class Circle: def __init__(self, radius): self.radius = radius self.diameter = radius * 2 c = Circle(5) print(c.radius, c.diameter)
Output
5 10
What to take away
- Any expression can go on the right inside
__init__, as inself.diameter = radius * 2. - Both attributes live on the instance afterwards, so
c.radiusis 5 andc.diameteris 10. - There is a trade-off.
diameteris computed once, so reassigningc.radiuslater leavesc.diameterstale. For values that must always agree, a computed property (unit 4) is the better tool.
When __init__ runs
In p = Player("mia"), __init__ runs automatically, as part of creating the new object.
Calling the class like a function creates a fresh instance, and Python immediately hands that instance to __init__ as self along with the rest of the arguments. You almost never call __init__ by hand.
So it is not something you invoke yourself with p.__init__(), it does not run once at class-definition time (that is when the class body executes, which is a different moment), and it has nothing to do with program shutdown. The method that runs at teardown is __del__, which is rarely used.