Unit 3 · Classes, Modules & Files

Lesson · Unit 3 · 9 min read

Methods and self, functions that live on an instance.

A method is just a function bound to an instance. Once you can write one, your classes start doing things, not just holding data. Here's how methods receive self, how to design ones that mutate vs ones that return, and the special methods that make your objects play nice with print() and ==.

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 120

When 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 self

By 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 string

Without __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)                 # 2

The 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.

Curriculum source

Lesson content is original to YorkSims. Topic structure aligns with Python for Everybody by Dr. Charles R. Severance (py4e.com), licensed under Creative Commons Attribution 3.0 Unported.