Learning Goals
3 minBy the end of this lesson you can:
- Define
__init__(self, ...)with parameters for every required attribute. - Pass arguments when constructing:
Hero("Aisyah", 100, 12). - Use default parameter values for optional attributes.
- Recognise
__init__as a dunder (double-underscore) method and know there are more coming.
Warm-Up · The Setup Pain
5 minYesterday's Hero needed four lines of attribute setup every time:
aisyah = Hero() aisyah.name = "Aisyah" aisyah.hp = 100 aisyah.max_hp = 100 aisyah.attack = 12
For three heroes, that's twelve lines of grunt work. Plus it's easy to forget one and crash later when a method reads a missing attribute.
Today: one line.
aisyah = Hero("Aisyah", 100, 12)
__init__ is a special method Python calls automatically the moment you construct a new instance. It's where setup belongs.
New Concept · The Constructor
14 minThe shape
class Hero: def __init__(self, name, hp, attack): self.name = name self.hp = hp self.max_hp = hp self.attack = attack
Three new things:
__init__with double underscores on both sides — pronounced "dunder init".- Parameters after
selfbecome the arguments you pass when constructing. - Each assignment puts a value on
self— the new instance's attributes.
Constructing
aisyah = Hero("Aisyah", 100, 12) wei_jie = Hero("Wei Jie", 85, 18) print(aisyah.name, aisyah.hp, aisyah.attack) # → Aisyah 100 12 print(wei_jie.name, wei_jie.hp, wei_jie.attack) # → Wei Jie 85 18
You don't pass self at the call site — Python fills it in. You pass the rest of the parameters.
Default parameters
Make some attributes optional with defaults — same syntax as a regular function.
class Hero: def __init__(self, name, hp=100, attack=10): self.name = name self.hp = hp self.max_hp = hp self.attack = attack a = Hero("Aisyah") # uses defaults b = Hero("Wei Jie", 85) # custom hp, default attack c = Hero("Priya", attack=25) # custom attack only, default hp d = Hero("Iman", hp=70, attack=15) # both custom
Defaults shine for "sensible starting values" — most heroes start at 100 HP, some at different.
Default to an empty list — the famous trap
You'd think this is fine:
class Hero: def __init__(self, name, inventory=[]): # ❌ DON'T DO THIS self.name = name self.inventory = inventory a = Hero("Aisyah") b = Hero("Wei Jie") a.inventory.append("sword") print(b.inventory) # → ['sword'] -- WHAT?!
Bug! The default [] is created once at function-definition time and reused. Both heroes share the same list.
Fix: use None and check inside.
class Hero: def __init__(self, name, inventory=None): self.name = name self.inventory = inventory if inventory is not None else []
This trap catches every Python developer at least once. Remember the rule: never use mutable defaults (list, dict, set). Use None and create a fresh one inside.
__init__ can do more than assignments
It can call other methods, validate input, compute derived values, even print.
class Hero: def __init__(self, name, hp=100, attack=10): if hp <= 0: raise ValueError(f"hp must be positive, got {hp}") self.name = name self.hp = hp self.max_hp = hp self.attack = attack print(f" ✦ Hero {name} created with {hp} HP") a = Hero("Aisyah") # → ✦ Hero Aisyah created with 100 HP
Validation in __init__ means a bad Hero never exists in the first place — better than letting it slip through and crash later.
Dunders are coming
__init__ is the first of many "dunder" (double-underscore) methods. They let you hook into Python's built-in behaviour. Coming up later in Level 3:
__init__ construction Hero("Aisyah")
__str__ print formatting print(hero) → PY-L3-14
__eq__ equality hero1 == hero2 → PY-L3-15
__lt__ ordering hero1 < hero2 → PY-L3-15
__add__ addition a + b → PY-L3-16
__len__ len() len(hero) → PY-L3-16Each dunder is Python's way of saying "customise this builtin". __init__ customises construction — the rest customise other operators.
Worked Example · Hero v2
12 minSave as hero_v2.py:
# hero_v2.py — Hero with __init__ class Hero: def __init__(self, name, hp=100, attack=10, inventory=None): if hp <= 0: raise ValueError(f"hp must be positive, got {hp}") self.name = name self.hp = hp self.max_hp = hp self.attack = attack self.inventory = inventory if inventory is not None else [] def take_damage(self, amount): self.hp = max(0, self.hp - amount) def heal(self, amount): self.hp = min(self.max_hp, self.hp + amount) def is_alive(self): return self.hp > 0 def report(self): items = ", ".join(self.inventory) or "(empty)" return f"{self.name:<10} HP {self.hp:>3}/{self.max_hp:<3} ATK {self.attack:>2} inventory: {items}" # One-line construction! party = [ Hero("Aisyah", hp=100, attack=12, inventory=["sword", "potion"]), Hero("Wei Jie", hp=85, attack=18, inventory=["bow"]), Hero("Priya", attack=25), # uses default hp Hero("Iman"), # all defaults ] print(f"Party of {len(party)}:") for h in party: print(" " + h.report()) # Test the validation try: bad = Hero("Bug", hp=-1) except ValueError as e: print(f"\nCorrectly rejected: {e}")
Output
Party of 4: Aisyah HP 100/100 ATK 12 inventory: sword, potion Wei Jie HP 85/85 ATK 18 inventory: bow Priya HP 100/100 ATK 25 inventory: (empty) Iman HP 100/100 ATK 10 inventory: (empty) Correctly rejected: hp must be positive, got -1
Read the diff
Four heroes, four one-liners. Compare to PY-L3-03 — sixteen lines of attribute setup replaced by four constructor calls. The __init__ handles defaults, the mutable-default trap, validation, derived attributes (max_hp from hp), and is impossible to forget — Python has to call it.
Try It Yourself
13 minDefine Point with __init__(self, x, y). Make a couple of points and print them.
Hint
class Point: def __init__(self, x, y): self.x = x self.y = y p = Point(3, 4) print(p.x, p.y) # → 3 4
Define Rectangle(w, h). In __init__, also compute self.area = w * h. Test:
r = Rectangle(3, 4) print(r.area) # → 12
Hint
class Rectangle: def __init__(self, w, h): self.w = w self.h = h self.area = w * h
Note: this caches the area at construction. If you change r.w later, the area becomes stale. We'll meet @property in PY-L3-18 for proper computed attributes.
Define NoteBook with an entries list that should default to empty. Build two notebooks. Append to one. Confirm the other is unaffected.
Hint
class NoteBook: def __init__(self, entries=None): self.entries = entries if entries is not None else [] a = NoteBook() b = NoteBook() a.entries.append("Hello") print(a.entries) # → ['Hello'] print(b.entries) # → [] <-- not shared!
Build a broken version with entries=[] and watch the bug — then fix it. Seeing the trap once stops you falling into it forever.
Mini-Challenge · The Validated Email Class
8 minBuild email.py. Define an Email class that validates the address at construction time using the regex from PY-L2-44.
__init__(self, address)storesself.address.- If the address doesn't match a basic email regex,
raise ValueError. - Also compute and store
self.local(before the @) andself.domain(after). - Method
show()prints them on three lines.
Test: construct two valid ones; show; try a bad one in a try/except.
Show one possible solution
# email.py — Email class with validation import re EMAIL_PAT = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$") class Email: def __init__(self, address): address = address.strip() if not EMAIL_PAT.search(address): raise ValueError(f"Not a valid email: {address!r}") self.address = address local, domain = address.split("@", 1) self.local = local self.domain = domain def show(self): print(f" Full : {self.address}") print(f" Local : {self.local}") print(f" Domain : {self.domain}") # Good e1 = Email("aisyah@example.com") e1.show() print() e2 = Email("Wei.Jie+sales@school.edu.my") e2.show() # Bad try: Email("not-an-email") except ValueError as e: print(f"\nRejected: {e}")
Non-negotiables: regex validation inside __init__, derived attributes set there too, a ValueError raised for invalid input. The class guarantees that every Email instance is valid — that's a powerful contract.
Recap
3 min__init__ is the constructor — Python calls it automatically when you build an instance. Put every attribute assignment in there. Use default parameter values for optional attributes. Never use a mutable default like [] or {} — use None and create a fresh one inside. __init__ can also validate input by raising exceptions; bad instances never exist. __init__ is just the first of many dunders — more coming through Level 3.
Vocabulary Card
- __init__
- The constructor. Runs when you build an instance. Sets up attributes.
- dunder
- "Double underscore". Names like
__init__,__str__that hook into Python builtins. - mutable default trap
- A default value of
[]or{}is shared across all calls. UseNoneand create fresh. - derived attribute
- An attribute computed in
__init__from the others, likemax_hp = hp.
Homework
4 minConvert your PY-L3-03 Cart homework to use __init__ properly. The constructor should:
- Take an optional
holderparameter (default"guest"). - Initialise
self.itemsto an empty list — avoiding the mutable-default trap. - Initialise
self.discountto 0.
Add a method summary() that returns a single string with holder + total + discount applied. Test with two carts.
Sample · cart_v2.py
class Cart: def __init__(self, holder="guest"): self.holder = holder self.items = [] self.discount = 0 def add(self, name, price): self.items.append((name, price)) def total(self): raw = sum(p for _, p in self.items) return raw * (1 - self.discount / 100) def summary(self): return f"{self.holder}: {len(self.items)} items, total RM {self.total():.2f}" a = Cart("Aisyah") a.add("nasi lemak", 8.0) a.add("teh tarik", 3.5) a.discount = 10 print(a.summary()) b = Cart() # default holder "guest" b.add("milo", 4.5) print(b.summary())
Non-negotiables: __init__ with optional holder, fresh list per instance, all attributes set inside the constructor. The Cart no longer needs anyone to manually set cart.items = [] from outside — it does it itself.