Section · 01
Defining a method
A method is a function defined inside a class. Its first parameter is always self — the instance the method is being called on:
class BankAccount:
def __init__(self, owner, opening_balance=0):
self.owner = owner
self.balance = opening_balance
def deposit(self, amount):
self.balance = self.balance + amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds.")
self.balance = self.balance - amount
acct = BankAccount("Ada", 100)
acct.deposit(50) # balance is now 150
acct.withdraw(30) # balance is now 120When you call acct.deposit(50), Python rewrites that as BankAccount.deposit(acct, 50) behind the scenes. You see one argument; the method sees selfplus that argument. That’s the whole mechanism.
Section · 02
Mutating methods vs returning methods
Two flavors of method, with different design rules:
1. Mutating methods change state.
def deposit(self, amount):
self.balance = self.balance + amount # changes selfBy convention, mutating methods return None (or nothing at all). Same rule you learned for list methods — .append(), .sort(), etc. — and for the same reason: if a method’s job is to change something, the caller doesn’t need a return value to use it, and forcing one invites the “x = x.append(y) erases x” bug.
2. Returning methods produce a new value.
def summary(self):
return f"{self.owner}: ${self.balance:.2f}"
acct.summary() # "Ada: $120.00"By convention, returning methods don’t mutate state. You can call them as often as you want without changing anything.
Mixing the two in one method is usually a bad idea. A method that both mutates and returns is harder to test (calling it twice changes behavior) and harder to read (the name has to convey both things).
Section · 03
Method calls can call other methods
A method can call another method on the same instance through self:
class BankAccount:
def __init__(self, owner, opening_balance=0):
self.owner = owner
self.balance = opening_balance
def deposit(self, amount):
self._guard_positive(amount)
self.balance = self.balance + amount
def withdraw(self, amount):
self._guard_positive(amount)
if amount > self.balance:
raise ValueError("Insufficient funds.")
self.balance = self.balance - amount
def _guard_positive(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive.")Two conventions in there:
1. _guard_positive starts with an underscore. By convention, that means
"this is internal — call it from inside the class, not from outside."
Python doesn't enforce it, but every reviewer will.
2. The "guard" pattern: extract the validation that runs at the top of
multiple methods into one helper. Now if the rule changes (e.g., "must
be positive AND under $10K"), there's exactly one line to update.Section · 04
Dunder methods — special hooks Python calls for you
Methods whose names start and end with double underscores (“dunder” methods) are called by Python automatically in specific situations. You already met one: __init__. Three more you’ll use a lot:
__str__ — what print() shows
class BankAccount:
# ... __init__ etc ...
def __str__(self):
return f"<BankAccount owner={self.owner!r} balance=${self.balance:.2f}>"
acct = BankAccount("Ada", 120)
print(acct) # <BankAccount owner='Ada' balance=$120.00>
str(acct) # same stringWithout __str__, printing an instance gives you the useless default <BankAccount object at 0x...>. Defining one makes your objects debuggable.
__eq__ — what == means
# Default behavior: == checks if it's the same object
a = BankAccount("Ada", 100)
b = BankAccount("Ada", 100)
a == b # False — different objects, even with same data
# Define __eq__ to compare by value instead:
class BankAccount:
def __init__(self, owner, opening_balance=0):
self.owner = owner
self.balance = opening_balance
def __eq__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.owner == other.owner and self.balance == other.balance
a = BankAccount("Ada", 100)
b = BankAccount("Ada", 100)
a == b # True now__len__ — what len() returns
class Playlist:
def __init__(self):
self.tracks = []
def add(self, track):
self.tracks.append(track)
def __len__(self):
return len(self.tracks)
p = Playlist()
p.add("Track 1")
p.add("Track 2")
len(p) # 2The pattern is consistent: built-in operations like print(), ==, len(), and many more all look for a corresponding dunder method on your object. If it’s there, they use it. If not, they fall back to a generic default. Defining the right ones is how you make a custom class feel like a first-class Python type instead of a weird foreign object.
Section · 05
The full small class
Putting it together — a complete, useful class in 25 lines:
class BankAccount:
def __init__(self, owner, opening_balance=0):
self.owner = owner
self.balance = opening_balance
def deposit(self, amount):
self._guard_positive(amount)
self.balance = self.balance + amount
def withdraw(self, amount):
self._guard_positive(amount)
if amount > self.balance:
raise ValueError("Insufficient funds.")
self.balance = self.balance - amount
def summary(self):
return f"{self.owner}: ${self.balance:.2f}"
def _guard_positive(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive.")
def __str__(self):
return f"<BankAccount {self.summary()}>"Compared to a flat dict and a pile of loose functions, this is much harder to misuse: balance can’t go negative, you can’t deposit -5, every account has the same shape, andprint(acct) gives you something useful. The investment is maybe 20 minutes; the payoff is every day after.