Classes, Objects, and Methods
Context
A class is a template describing what a kind of thing knows and what it can do. Making one from the template produces an object with its own data, filled in by a setup step that runs at creation, and the functions defined on the class operate on that object's own data.
This is the shape the libraries ahead are built in. A model is created with its settings, then fitted, then asked to predict, and the settings and the learned state travel with it rather than being passed separately into every call. Writing your own is how a loader that holds a file path, a separator, and an encoding stops needing all three handed to each function in turn.
Without it, related data and the functions using that data drift apart. Six functions each taking the same four configuration values is the symptom, and the bug is the one call of the six that passes a stale value. The specific trap is forgetting that a method receives its own object first, which produces a mismatch in the number of arguments at the call rather than at the definition. The previous section organized code into files, this lesson organizes it around data.
Goal
- Define a class with an
__init__initializer that sets up each new instance. - Construct multiple instances of a class, each carrying its own independent attributes.
- Call methods on an instance and explain precisely what
selfrefers to and how Python supplies it. - Distinguish a method that returns a derived value from a method that mutates its instance.
- Rewrite an instance method call in its explicit, desugared form to prove how
selfis passed.
Prerequisites
Optional, neither is a gate. The lesson stands on its own, they are here if you want a first picture of an object before you build one.
- YouTube - "Python OOP Tutorial 1: Classes and Instances" by Corey Schafer. The full video, for the cleanest first picture of classes, instances and
self. - Official docs - The Python Tutorial, "A First Look at Classes" and "Instance Objects". Those two headings carry the authoritative model.
Table of Contents
- Class
- Object and Instance
__init__- Attribute
- self
- Method
Theory
Class
What. A class is a blueprint that describes a kind of object, what data it holds and what it can do, without being any of the objects built from it. Why. Real programs revolve around things, a dataset, a model, a transform, that each carry data and have behavior, and a class is how you define that kind of thing once and stamp out many of them. When. You reach for a class whenever you find yourself passing the same cluster of related values into function after function, a sign that the data and the operations on it want to live together.
Up to now your tool for grouping related values has been the dictionary. A dataset might have been a dict like {"name": "train", "rows": 1000}. That works, but it is a loose bag of keys with no fixed shape and no behavior attached, every record spells out its own keys, so one typo like "rowws" builds a record of the wrong shape, and the functions that operate on it sit elsewhere, disconnected from the data. A class fixes both problems: a setup step that runs at every creation gives each record the same structure, and its operations live right alongside its data.
Defining a class introduces a new type. After you write class Dataset:, the name Dataset is a thing you can build objects from, just as int and str are types you already build values from. The class body, written indented beneath the class line, is where you will describe what every Dataset knows and can do.
# 'class' introduces a new type named 'Dataset'
class Dataset:
# 'pass' is a placeholder body, this class holds nothing yet
passBy itself this Dataset does nothing useful, and it is already a real type you can build objects from. The rest of the lesson fills in the data and the behavior that make it worth defining.
# the loose, structureless alternative you are moving away from
sample = {"name": "train", "rows": 1000} # just a bag of keys
# nothing enforces which keys exist, a typo is a silent bug
print(sample["rows"]) # 1000
# the behavior that belongs WITH this data lives somewhere else entirelyA class is the dictionary's disciplined successor, with its structure declared once and somewhere for the behavior to live.
DON'T:
- DON'T reach for a class when a single value or a one-off tuple will do, classes earn their keep when data and behavior genuinely belong together.
- DON'T confuse defining a class with creating an object,
class Dataset:only describes the blueprint, it builds nothing yet.
Object and Instance
What. An object, also called an instance, is a concrete thing built from a class, the class is the blueprint, the instance is the house built from it. Why. A blueprint is useless until you build from it, instances are the actual values your program creates, stores, and operates on at runtime. When. You create an instance every time you need one of the things the class describes, often many of them, each independent of the others.
You build an instance by calling the class as if it were a function, with parentheses: Dataset(). Each such call produces a brand-new, separate object. Two instances built from the same class are distinct things in memory, the way two houses built from one blueprint are still two different houses you could paint different colors. This separateness is the entire point, it is what lets one program hold a training set and a test set as two independent Dataset objects.
The words "object" and "instance" are used interchangeably. "An instance of Dataset" and "a Dataset object" mean the same thing, the chosen word just emphasizes either the act of instantiation or the resulting thing.
class Dataset:
pass
# calling the class like a function BUILDS a new instance
train = Dataset() # one object built from the 'Dataset' blueprint
test = Dataset() # a SEPARATE, independent object
# 'is' tests object identity, are these the very same object?
print(train is test) # False, two distinct objects
# 'type' reports the class an object was built from
print(type(train)) # <class '__main__.Dataset'>is asks whether two names point at the same object, which is a different question from whether their contents match. That separateness, each instance standing on its own, is what the next sections give meaning to by attaching distinct data to each.
DON'T:
- DON'T expect two instances to share data just because they share a class, each
Dataset()call is independent. - DON'T compare instances with
isto check whether their contents match,istests identity (same object), not equality of values.
__init__
What. __init__ is a special method that runs automatically the instant a new instance is created, used to set up that instance's starting data.
Why. A freshly built object is usually useless empty, a Dataset needs a name and a row count to mean anything, and __init__ is where you install those starting values so every instance begins life valid.
When. You define __init__ on almost every class that carries data, which is nearly all of them, so it becomes the first thing you write in a class body.
The name has two leading and two trailing underscores, pronounced "dunder init" (for double underscore). You never call it to create an instance. Instead, when you write Dataset("train", 1000), Python builds the empty instance and then calls __init__ for you, passing along the arguments you supplied. Its first parameter is always self, the not-yet-finished instance being set up, and the parameters after it receive the arguments from the construction call.
Inside __init__, you assign the incoming values onto self, which stores them on the instance so they persist after the method returns. This is the moment a blank object becomes a meaningful one. The full story of what self is gets its own section shortly, for now read it as "the specific instance currently being built".
class Dataset:
# '__init__' runs automatically when a 'Dataset' is created
# 'self' is the new instance, 'name'/'rows' come from the call
def __init__(self, name, rows):
# store each input ONTO the instance so it persists
self.name = name
self.rows = rows
# arguments after the class name flow into '__init__' (after 'self')
train = Dataset("train", 1000)
# the values set inside '__init__' are now readable on the instance
print(train.name) # train
print(train.rows) # 1000Notice the argument count. You pass two and __init__ lists three parameters, because Python supplies self automatically, which is the theme this lesson is building toward.
class Dataset:
# parameters can have defaults, exactly like ordinary functions
def __init__(self, name, rows=0):
self.name = name
self.rows = rows
empty = Dataset("placeholder") # 'rows' falls back to its default 0
print(empty.rows) # 0Because __init__ is a function with special timing, the default-argument rules from 1.3.2 apply unchanged. Nothing new is being introduced, which is the point, a method is a function in a particular place.
DON'T:
- DON'T call
__init__again on a live instance liketrain.__init__(...), Python already invoked it when you constructed the instance withDataset(...). - DON'T forget to store inputs onto
self, a plainname = nameinside__init__sets a throwaway local and the data vanishes when the method returns.
Attribute
What. An attribute is a piece of data stored on an object, reached and set through the dot, like train.rows.
Why. Attributes are the object's memory, they are what makes one Dataset differ from another and what the object's methods read and update.
When. You use attributes whenever an object needs to carry state, which is the moment you stored anything on self in __init__.
Every value you assigned onto self in __init__ became an attribute. Crucially, attributes belong to the individual instance, not the class as a whole, train.rows and test.rows are separate slots holding separate numbers. This per-instance ownership is exactly the separateness from Object and Instance, now made concrete: each object built from the blueprint carries its own filled-in data.
You read an attribute with object.name and set one with object.name = value. You can even add a brand-new attribute to an existing instance simply by assigning to a dotted name that did not exist before, though doing that outside __init__ is usually a sign your structure is unclear. Reading these as named slots on a thing, rather than keys in a loose dict, is the upgrade classes provide.
class Dataset:
def __init__(self, name, rows):
self.name = name
self.rows = rows
train = Dataset("train", 1000)
test = Dataset("test", 200)
# each instance carries ITS OWN attribute values
print(train.rows, test.rows) # 1000 200
# setting an attribute on one instance does not touch the other
train.rows = 1500
print(train.rows, test.rows) # 1500 200This independence is the everyday behavior you rely on without noticing, two objects of the same kind quietly keeping their own state.
train = Dataset("train", 1000)
# attributes can be added after construction by assigning a new dotted name
train.shuffled = True # creates a new attribute on THIS instance only
print(train.shuffled) # True
# 'test' never got this attribute, so reading it would raise AttributeErrorThe warning is in the last comment. Only train has shuffled, so test.shuffled would raise AttributeError, and defining every attribute up front in __init__ is what keeps instances consistently shaped and avoids exactly that surprise.
DON'T:
- DON'T assume attributes are shared across instances,
train.rowsandtest.rowsare independent unless you deliberately make them shared (a topic of the next lesson). - DON'T scatter attribute creation across many methods, define an instance's attributes in
__init__so every instance has the same shape.
self
What. self is the reference to the particular instance a method is currently acting on, the bridge that lets one method definition operate correctly on any instance.
Why. A single method like add is defined once but must work on every instance, and self is how each call knows which object's data to read and change.
When. self appears as the first parameter of every instance method and inside every method body that touches the instance's own attributes, which is almost all of them.
Here is the idea that makes object-oriented Python click. When you write a.add(10), Python does something specific behind the scenes: it calls the add method and quietly passes a as the first argument. That first argument lands in the parameter named self. So inside add, self is a, and self.total means "a's total". Call b.add(5) and the same method runs again, but now self is b. One definition, the correct object every time, selected by which instance you called the method on.
self is not a keyword. It is just a parameter name, by overwhelming convention spelled self, and you should never spell it anything else, but Python attaches no magic to the word itself. The magic is in the calling mechanism: when an instance method is called through an instance, the object before the dot becomes the first argument. Understanding that single rewrite dissolves most of the confusion beginners have with classes.
class RunningStats:
def __init__(self, label):
# 'self' is whichever instance is being built right now
self.label = label
self.total = 0 # this instance's own running total
self.count = 0 # this instance's own count
# every instance method takes 'self' as its first parameter
def add(self, value):
# 'self.total' is THIS instance's total, not any other's
self.total += value
self.count += 1
a = RunningStats("a")
b = RunningStats("b")
a.add(10) # Python passes 'a' as 'self', so 'self.total' is a's total
a.add(20)
b.add(5) # here 'self' is 'b', so only b's total changes
# each instance kept its own running total via 'self'
print(a.total, b.total) # 30 5One add, defined once, updating whichever instance it was called on. The method body never names a or b, it says self, and Python fills in the right one per call.
# these two calls are EXACTLY equivalent
a.add(10) # the normal, idiomatic dotted form
RunningStats.add(a, 10) # the explicit form Python rewrites it intoThis is the demystification, and it is worth reading twice. The dotted call is syntactic sugar for reaching into the class, calling its plain add function, and passing the instance as the first argument by hand. self is simply the object you put before the dot. The dotted form is what you always write, and knowing the explicit form underneath is what makes self stop being mysterious.
DON'T:
- DON'T omit
selffrom a method's parameter list, without it the method cannot reach the instance's attributes and calling it raises aTypeErrorabout arguments. - DON'T pass the instance yourself in the dotted form, write
a.add(10), nevera.add(a, 10), the dot already suppliesself.
Method
What. A method is a function defined inside a class, and the ordinary kind, an instance method, acts on the class's instances, receiving the instance it is called through as self.
Why. Methods are the behavior half of an object, they bundle the operations on the data right next to the data, which is the whole reason classes beat loose dicts plus scattered functions.
When. You write a method for every operation that naturally belongs to the object, whether it computes something from the object's data or changes that data.
Because a method is a function, everything you know about functions still holds, parameters, defaults, return values, docstrings. The one difference is its first parameter self and the fact that you call it through an instance. Methods come in two broad flavors. A method can return a value derived from the instance's attributes without changing anything, leaving the object as it was. Or a method can mutate the instance, changing its attributes in place, this is a deliberate, controlled side effect, the disciplined cousin of the free-floating side effects you saw earlier, now scoped to a single object you can point at.
class Dataset:
def __init__(self, rows):
self.rows = rows # number of examples held
# a RETURNING method: derives a value, changes nothing
def size_mb(self):
# assume ~2 bytes per row, convert the total to megabytes
return self.rows * 2 / 1_000_000
# a MUTATING method: a controlled side effect on this instance
def append(self, n):
# change this instance's own 'rows' in place
self.rows += n
d = Dataset(500_000) # '_' only groups digits, 500000
print(d.size_mb()) # 1.0, computed from 'd.rows', 'd' unchanged
d.append(500_000) # mutates 'd', adding rows in place
print(d.rows) # 1000000
print(d.size_mb()) # 2.0, the same method now reflects new stateThe second size_mb() reporting 2.0 is the whole distinction in one line, the method did not change and the object did. This pairing of returning and mutating behavior, packaged with the data, is exactly the design you meet the moment you call .fit() (which mutates a model by learning parameters) and .predict() (which returns outputs) on the estimator objects of the ML libraries ahead. Those objects are data bundled with methods, precisely what you have just built by hand.
DON'T:
- DON'T mix a method's two roles carelessly, a method named
size_mbshould not also secretly mutate the object, surprising callers who expected a pure computation. - DON'T reach for a module-level function when the operation clearly acts on one object's data, that operation wants to be a method living with the data.
Lab Exercises
Lab Exercise 1: Two Points, and Proving They Are Two
Define a class Point whose __init__ takes x and y and stores them as attributes. Create two distinct points and print each one's coordinates.
Then prove they are two separate objects built from one class: compare them with is, print the type of one, and reassign one point's x before printing both x values again.
Finally, add an attribute to one point after construction by assigning a dotted name that did not exist, print it, and add a comment saying what reading that same name on the other point would do.
Lab Exercise 2: A Counter, an Accumulator, and the Call Underneath
Define a class Counter with an __init__ setting value = 0, an increment() method adding one, and a reset() method setting it back to zero. Create two counters, increment them a different number of times each, and print both values. Write the explicit desugared form of one increment() call in a comment.
Then define Accumulator holding a total and a count, with an add(value) method that updates both and a mean() method returning total / count, or 0.0 when nothing has been added. Print the mean before adding anything, feed it four numbers, then print total, count and the mean.
Add a comment naming which of the four methods you wrote return a value and which mutate their instance, and say how a caller can tell the two apart without reading the body.
Solution 1: Two Points, and Proving They Are Two
class Point:
def __init__(self, x, y):
self.x = x # stored ON the instance, so it outlives '__init__'
self.y = y
p = Point(1, 2)
q = Point(5, 9)
print(p.x, p.y) # 1 2
print(q.x, q.y) # 5 9
print(p is q) # False, two separate objects
print(type(p)) # <class '__main__.Point'>
p.x = 100
print(p.x, q.x) # 100 5p.shuffled = True # a NEW attribute, created by assigning a dotted name
print(p.shuffled) # True
# 'q.shuffled' would raise AttributeError. The attribute belongs to 'p'
# alone, which is exactly why every attribute an instance is meant to have
# belongs in '__init__', where every instance gets it.DON'T:
- DON'T write
x = xin__init__instead ofself.x = x, that sets a local which disappears when the method returns and leaves the instance without the attribute. - DON'T create attributes outside
__init__in real code, instances then have different shapes and the difference only surfaces where the missing one is read.
Solution 2: A Counter, an Accumulator, and the Call Underneath
class Counter:
def __init__(self):
self.value = 0
def increment(self): # mutates: changes this instance and returns nothing
self.value += 1
def reset(self):
self.value = 0
a = Counter()
b = Counter()
a.increment()
a.increment()
a.increment()
b.increment()
print(a.value, b.value) # 3 1
# a.increment() IS Counter.increment(a)
# The dotted form is sugar. The object before the dot is handed to the
# method as its first argument, which is the parameter named 'self'.class Accumulator:
def __init__(self):
self.total = 0
self.count = 0
def add(self, value): # mutates both attributes
self.total += value
self.count += 1
def mean(self): # returns a derived value, changes nothing
if self.count == 0: # an empty accumulator has no meaningful mean
return 0.0
return self.total / self.count
acc = Accumulator()
print(acc.mean()) # 0.0
for v in [10, 20, 30, 40]:
acc.add(v)
print(acc.total, acc.count, acc.mean()) # 100 4 25.0
# 'mean' RETURNS a derived value, 'increment', 'reset' and 'add' MUTATE.
# From the outside the tell here is the return value: all three mutating
# methods hand back None, so 'print(a.increment())' shows None.mean deriving the average on demand rather than keeping a self.mean attribute updated by hand is the design choice worth noticing. Two attributes that must agree can drift apart, one derived value cannot.
DON'T:
- DON'T drop the zero guard in
mean, an accumulator that has been fed nothing would raiseZeroDivisionErroron its first call. - DON'T write
Counter.increment()as the desugared form, the class function needs the instance passed in, and without it Python raises aTypeErrorabout the argument count.
Theory Assessment
Q1: The Blueprint, the Thing Built From It, and the Step in Between
Explain the difference between a class and an instance, and say which of the two Dataset() produces. Then explain what __init__ does, when it runs, and why the assignments inside it have to be onto self rather than to plain local names.
Hint: One of the two is a description and the other is a thing. For the second half, ask what survives after a function returns.
Q2: Whose Data It Is, and Whether the Method Changes It
Two Counter instances each have a value. Explain why incrementing one does not change the other, and say where the two values actually live. Then distinguish a method that returns a derived value from one that mutates its instance, and give one concrete risk of writing a method that quietly does both.
Hint: For the first half, ask whether the attribute belongs to the class or to the object. For the second, think about what a caller assumes from the method's name.
Q3: The Object Before the Dot
In a.add(10), explain what self refers to inside add and how Python decided what it should be. Then rewrite model.fit(data) in the explicit form Python uses underneath, and say what that rewrite reveals about self.
Hint: Look at what sits immediately before the dot. The rewrite reaches into the class and supplies that object by hand.
Q4: Two Objects, Six Calls, and One That Raises
Read the following without running it, then state every line of output in order, and name the exception the last line raises.
class Tally:
def __init__(self, label):
self.label = label
self.count = 0
def bump(self):
self.count += 1
def doubled(self):
return self.count * 2
a = Tally("a")
b = Tally("b")
a.bump()
a.bump()
Tally.bump(b)
a.doubled()
print(a.count, b.count)
print(a.doubled(), b.doubled())
a.note = "checked"
print(a.note)
print(b.note)Say why b ends up counting at all given that b.bump() is never written, what the bare a.doubled() line accomplished, and why the final line fails when the line above it succeeded.
Hint: One of the six calls is written in the form the other five are sugar for. And an attribute created by assignment belongs to exactly one object.
Answer 1:
A class is the blueprint, the description of what a kind of object knows and can do, while an instance is a concrete object built from it, the way a house is built from architectural plans. Dataset() produces an instance, calling the class constructs one actual object of that type. The class itself is not a Dataset you fill with data, it is the shared description every Dataset is made from.
__init__ is the initializer, and Python runs it automatically the moment an instance is created, handing it the new object as self followed by whatever arguments the construction call supplied. Its job is to install the instance's starting attributes. The assignments must be onto self because the method's own locals vanish when it returns, exactly as any other function's locals do, while what is attached to self stays on the instance after __init__ returns. Write name = name instead of self.name = name and the object is built without the attribute, and nothing complains until something reads it.
DON'T call __init__ again on an object that already exists, Python invokes it during Dataset(...), and calling it on a live object re-runs setup on something already set up.
Answer 2:
Incrementing one Counter cannot touch the other because each instance stores value in its own slot, created when __init__ ran for that object. The values live on the instances, not on the class, so a.value and b.value are two separate slots that happen to share a name. increment acts on self, and self is whichever counter the call was made through.
A returning method computes a value from the instance's attributes and leaves the object exactly as it was, like mean. A mutating method changes those attributes in place as a deliberate side effect, like add. The risk of a method that quietly does both is that its name makes a promise its body breaks: a caller who reads size_mb() expects a measurement and gets a state change they never asked for, which produces bugs that appear far from the call and have no obvious cause.
DON'T assume same-named attributes that each instance sets for itself, as __init__ does here, are one attribute, each instance has its own slot, though two slots can still hold the same object, as a list default from 1.3.2 does.
Answer 3:
Inside add, self refers to a, the instance the method was called on. Python decided that from the call and from what add is, an ordinary function defined on the class: in a.add(10), the object before the dot is passed as the method's first argument, and the first parameter is named self.
model.fit(data) is, underneath, Estimator.fit(model, data), where Estimator is model's class. The rewrite reveals that self carries no magic. It is an ordinary first parameter, filled by whatever object you put before the dot, and the dotted form is convenient sugar over a plain function call. That one rule governs every instance method you call through an instance.
DON'T supply the instance yourself in the dotted form, a.add(a, 10) passes it twice and raises a TypeError about too many arguments.
Answer 4:
The first three lines print:
2 1
4 2
checkedThen print(b.note) raises AttributeError: 'Tally' object has no attribute 'note'.
b counts because Tally.bump(b) is the desugared form of b.bump(). It reaches into the class, calls the plain bump function, and hands b in as the first argument, so self is b and b.count becomes 1. The other five calls are written in the dotted form, which is sugar for exactly this. Both spellings do the same work and only one of them is idiomatic.
The bare a.doubled() line accomplished nothing observable. It ran, computed 4, returned it, and the line ended with nobody keeping the value. doubled is a returning method, so discarding the return discards the entire point of the call, which is the same shape as the discarded strip() results from 1.5.1 and just as silent.
The final line fails because a.note = "checked" created an attribute on a alone. Assigning a dotted name that does not exist adds it to that one object, and b was built by an __init__ that sets label and count and nothing else, so b has no note to read. The line above it succeeded for the same reason the last one fails, and the two instances now have different shapes.
DON'T create attributes outside __init__ and then read them on every instance, define an object's full set of attributes in one place so every instance built from the class is shaped identically.
Assignment
Assignment: A Money Type With Operator Overloading
Define a class Money holding an integer number of cents, chosen so that no amount is ever stored as a float. Give it a dollars() method returning the float dollar amount, value equality so two equal-cent amounts compare equal, and operator overloading so two Money objects combine with + and a Money multiplies by an integer with *.
Demonstrate Money(150) + Money(75) and Money(150) * 3, printing the dollar amount of each result, and demonstrate the equality with a freshly built Money rather than with the same object twice.
Then show what the equality bought. Print the result of comparing two equal-valued Money objects with is alongside the same comparison with ==, and add a comment stating which of the two the default behavior would have given you had __eq__ not been written.
Hints:
__add__(self, other)and__mul__(self, factor)each return a newMoneyand never mutate either operand, the same discipline a returning method follows everywhere else.- Storing cents as an integer is the decision that makes the rest work,
0.10 + 0.20in floats is0.30000000000000004, while10 + 20in cents is exactly30.
Solution:
class Money:
def __init__(self, cents):
self.cents = cents # whole cents, an int, never a float
def dollars(self): # returns a derived view, mutates nothing
return self.cents / 100
def __eq__(self, other): # what '==' means for two Money objects
return self.cents == other.cents
def __add__(self, other): # what '+' means, returns a NEW Money
return Money(self.cents + other.cents)
def __mul__(self, factor): # what '*' by an int means
return Money(self.cents * factor)
a = Money(150) # $1.50
b = Money(75) # $0.75
print((a + b).dollars()) # 2.25
print((a * 3).dollars()) # 4.5
print(Money(225) == a + b) # True, a FRESH object, equal by valuex = Money(500)
y = Money(500)
print(x is y) # False, two separate objects
print(x == y) # True, because '__eq__' compares cents
# Without '__eq__', '==' falls back to identity and would have printed
# False here, the same answer 'is' gives. Two equal amounts of money would
# have compared unequal, which is almost never what a value type wants.The integer cents are the decision the rest rests on. Money in floats is the float-imprecision problem from 1.1.2 wearing a currency symbol, where a sum of amounts that should be exact quietly is not. Whole cents are exact integers, and dollars() produces the float only at the moment something needs to be displayed, which is the one place the imprecision cannot accumulate.
Each operator returns a new Money rather than changing an operand, and that is not a style preference. a + b mutating a would mean the expression a + b + c silently destroys a, and every caller who reasonably expected arithmetic to behave like arithmetic would be wrong. The rule is the same one the returning methods follow, an operation that reads should not write.
__eq__ is the piece that turns Money from a container into a value. A type whose instances mean "an amount" should compare on the amount, and the default identity comparison answers a question nobody asked, whether two names point at the same object. That default is right for a Counter, where two counters at the same value are still two different counters, and wrong for Money, where two amounts of 500 cents are the same amount.
DON'T let __add__ or __mul__ mutate self, and don't store money as a float, the first astonishes every caller and the second can be off after a single addition, as 0.10 + 0.20 shows. And don't read __eq__ as free. Defining it without a __hash__, as Money does, sets __hash__ to None, so a Money can no longer enter a set or act as a dictionary key, and trying raises the same TypeError: unhashable type 1.4.3 showed for a list. It also makes Money(150) == 150 raise AttributeError rather than answer False, because the comparison reaches for a .cents an integer does not carry.
Additional Resources
Optional, and nothing later depends on either. Worth opening if you want the mechanism under self or the full list of dunder names.
- Official docs - Descriptor Guide, "Functions and methods". Why a function stored on a class comes back as a bound method with
selfalready supplied. - Official docs - Python Language Reference, "Special method names". The catalog of dunder methods,
__add__and__mul__included, and the place that says why a class defining__eq__alone gets its__hash__(the method a dictionary key or a set member needs) set toNone.
Summary
Something quietly fundamental changed in this lesson, and it is worth naming. Until now your programs were verbs acting on nouns kept at arm's length, functions over dictionaries, logic over loose data. A class collapses that distance. It says the noun and its verbs belong together, that a dataset should know how to report its own size and a counter should know how to increment itself, because the data and the operations on it were never really separate to begin with. Once you feel that, a great deal of code you have already written starts to look like it was straining toward this shape without a name for it.
The center of the lesson was self, and it hides in plain sight. Every instance method quietly receives the object it was called on, and that single rewrite, a.add(10) becoming RunningStats.add(a, 10), is the hinge the entire paradigm turns on. There is no deeper magic to find. The instance before the dot becomes the first argument, the method reads and writes that instance's attributes through self, and one definition serves every object you ever build from the class. Hold that explicit form in your head and the implicit one stops being mysterious, which is exactly the moment objects become a tool you wield rather than a syntax you fear.
This is also the lesson where the libraries ahead stop being arbitrary. When you soon write model.fit(data) and model.predict(inputs), you will not be learning a new and alien interface, you will be using exactly what you built here, an object holding state and exposing methods that read and change it. The designers of those libraries did not invent something you have to memorize, they bundled data with behavior the same way you just did, and the row of estimators, transforms, and pipelines waiting in the modules ahead is that one idea repeated at scale. You have not just learned a feature of Python, you have learned the shape the rest of this curriculum is built in.
Cheat Sheet
| Concept | Syntax | What It Is | When to Avoid |
|---|---|---|---|
| Class | class Dataset: |
the blueprint / new type | a single scalar value |
| Instance | train = Dataset(...) |
a concrete object | confusing it with the class |
__init__ |
def __init__(self, ...): |
sets up a new instance | calling it again on a live object |
| Attribute | self.rows = rows |
per-instance data | expecting it shared across instances |
self |
first method parameter | the instance acted on | renaming it or passing it yourself |
| Method (return) | def size_mb(self): return ... |
derives a value | hiding mutation inside it |
| Method (mutate) | def append(self, n): self.rows += n |
controlled side effect | naming it like a pure computation |
| Desugaring | obj.m(x) means Cls.m(obj, x) |
what self really is |
omitting the instance |
class RunningStats: # blueprint / new type
def __init__(self, label): # runs at construction
self.label = label # per-instance attribute
self.total = 0 # per-instance state
self.count = 0
def add(self, value): # method, 'self' = the instance
self.total += value # mutate this instance
self.count += 1
def mean(self): # returning method
return self.total / self.count if self.count else 0.0
s = RunningStats("loss") # build an instance
s.add(0.3); s.add(0.1) # s.add(...) means RunningStats.add(s, ...)
print(s.mean()) # 0.2