Defining a class, __init__ and self
Two pieces of syntax confuse nearly everybody at first: __init__ and self.
Neither is complicated once you see what they are doing.
The smallest class
class Expense:
pass
class, a name, a colon, an indented body. pass from module 3 fills an empty
block.
Class names use CapWords — Expense, BankAccount, HttpClient. Not
expense, not bank_account. This is the one place Python does not use
snake_case, and following it means a reader can tell a class from a function at
a glance.
Creating an object:
e = Expense()
print(e)
<__main__.Expense object at 0x7f3c8a1b2d50>
An object exists. It has nothing in it, and prints unhelpfully — both fixable.
init
class Expense:
def __init__(self, amount, category):
self.amount = amount
self.category = category
lunch = Expense(250, "food")
print(lunch.amount) # 250
__init__ runs automatically when you create an object. Its job is to set up
the object's starting state.
Note what you did not write: Expense.__init__(...). Writing Expense(250, "food") creates the object and calls __init__ on it for you.
It is not a constructor in the sense other languages mean — the object already
exists by the time __init__ runs. It initialises rather than creates, which is
what the name says.
The double underscores mark it as special to Python. There are many such methods, covered two lessons from now.
self
self is the object the method was called on.
lunch = Expense(250, "food")
Python calls __init__ with lunch as the first argument. Inside, self is
lunch, so self.amount = amount sets the amount on that particular object.
train = Expense(1200, "transport")
Now self is train. Same code, different object, separate data.
Every method takes self as its first parameter, and you never pass it —
Python supplies it. This:
lunch.with_gst()
is really:
Expense.with_gst(lunch)
Both work, and the first is what anybody writes. The second occasionally shows up in a traceback and is worth recognising.
Forget self and you get a characteristic error:
class Expense:
def describe():
return "..."
Expense().describe()
TypeError: describe() takes 0 positional arguments but 1 was given
"But I passed none!" You did; Python passed the object. A TypeError about
one extra argument on a method almost always means a missing self.
self is not a keyword. It is a convention so strong that breaking it will get
your code rejected in review, but the language does not enforce it.
Attributes
Values on an object are attributes, set with self.name = value:
class Expense:
def __init__(self, amount, category, note=""):
self.amount = amount
self.category = category
self.note = note
self.is_settled = False # not every attribute is a parameter
is_settled is derived rather than passed. A sensible starting state belongs in
__init__ just as much as the arguments do.
Reading and writing from outside:
lunch = Expense(250, "food")
print(lunch.amount)
lunch.amount = 300
Python lets you do that freely. Whether you should is the next lesson.
Methods
Functions defined in a class:
class Expense:
def __init__(self, amount: float, category: str, note: str = ""):
self.amount = amount
self.category = category
self.note = note
def with_gst(self) -> float:
"""Return the amount including 18% GST."""
return round(self.amount * 1.18, 2)
def describe(self) -> str:
"""Return a one-line summary."""
return f"{self.category}: ₹{self.amount:,.2f}"
def apply_discount(self, percent: float) -> None:
"""Reduce the amount by a percentage."""
if not 0 <= percent <= 100:
raise ValueError(f"Percent must be 0-100, got {percent}")
self.amount = round(self.amount * (1 - percent / 100), 2)
Everything from module 5 applies — type hints, docstrings, guard clauses,
returning rather than printing. A method is a function that happens to live in a
class and receive self.
Note the difference between with_gst, which computes and returns without
changing anything, and apply_discount, which modifies the object and returns
None. That is the same in-place-versus-new distinction from module 4, and it
is worth being deliberate about.
Class attributes
A value on the class rather than on each object:
class Expense:
GST_RATE = 0.18
VALID_CATEGORIES = {"food", "transport", "rent", "other"}
def __init__(self, amount, category):
if category not in self.VALID_CATEGORIES:
raise ValueError(f"Unknown category: {category!r}")
self.amount = amount
self.category = category
def with_gst(self):
return round(self.amount * (1 + self.GST_RATE), 2)
GST_RATE is shared by every instance. Accessible as self.GST_RATE or
Expense.GST_RATE. Ideal for constants that belong to the concept rather than
to one object.
The trap
A mutable class attribute is shared, and this is module 5's default argument problem wearing new clothes:
class Basket:
items = [] # one list, shared by every basket
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(b.items)
['apple']
Two baskets, one list. Put mutable state in __init__ instead:
class Basket:
def __init__(self):
self.items = [] # a new list per object
Class attributes for constants. Instance attributes for anything that
changes. The same rule as basket=None in module 5, and the same underlying
cause.
Putting it together
class BankAccount:
"""A bank account that cannot go overdrawn."""
def __init__(self, owner: str, balance: float = 0.0):
if balance < 0:
raise ValueError("Opening balance cannot be negative")
self.owner = owner
self.balance = balance
self.transactions: list[str] = []
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
self.transactions.append(f"Deposited {amount}")
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self.balance:
raise ValueError(f"Insufficient funds: balance is {self.balance}")
self.balance -= amount
self.transactions.append(f"Withdrew {amount}")
Every rule from the invariants exercise in the last lesson is enforced in one place, and no sequence of valid method calls can produce a negative balance.
Check your work
The missing self.
TypeError: describe() takes 0 positional arguments but 1 was given
"But I passed none!" You did — Python passed the object. A TypeError about
one extra argument on a method almost always means a missing self.
Class attributes are shared. Changing Expense.GST_RATE changes it for
every instance, because there is one value on the class rather than one per
object.
The Basket trap.
class Basket:
items = [] # one list, shared by every basket
a, b = Basket(), Basket()
a.add("apple")
print(b.items) # ['apple']
This is module 5's mutable default in new clothes. Put mutable state in
__init__:
def __init__(self):
self.items = [] # a new list per object
Class attributes for constants. Instance attributes for anything that changes.
Making BankAccount go negative. You cannot, through its methods — every
route is guarded. You can by assigning account.balance = -999 directly,
which is the next lesson's subject.
A derived attribute.
def __init__(self, amount, category):
...
self.created_at = datetime.now()
Not every attribute is a parameter. A sensible starting state belongs in
__init__ just as much as the arguments do.
Practice
- Write an
Expenseclass with__init__taking amount, category and an optional note. Create two and print their attributes. - Leave
selfout of a method. Read theTypeErrorand explain it. - Add
with_gstanddescribe. Call them on both objects. - Add
apply_discountwith validation. Call it with 20 and with 150. - Add
GST_RATEas a class attribute. Change it on the class and confirm both objects see the change. - Build the
Basketclass withitems = []at class level. Create two, add to one, and print the other. Then fix it. - Write
BankAccountand try every way you can think of to make the balance negative. Confirm you cannot. - Add an attribute in
__init__that is not a parameter — a created-at timestamp usingdatetime.now().
Next: controlling what the outside world can do to your objects.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship