Week 7: OOP Part 2 — Inheritance, Polymorphism, Dataclasses and Type Hints
Month 2 | Main goal: The student uses inheritance and
super(), understands polymorphism and abstract classes, writes dunder methods (__len__,__eq__,__add__), and uses@dataclasstogether with type hints.
Teacher Preparation (read before the week starts)
- Read:
- Tutorial — Inheritance: https://docs.python.org/3/tutorial/classes.html#inheritance
abcmodule: https://docs.python.org/3/library/abc.html (only the top section and the@abstractmethodexample)dataclasses: https://docs.python.org/3/library/dataclasses.html (only the opening examples)- Type hints cheat sheet (mypy): https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html
- Review Week 6: make sure you can write a class with
__init__,@property,__str__without looking — this week builds on it. - Optional: install
mypyinside a venv (python -m pip install mypy) to show that type hints can be checked. Python itself does NOT check type hints at run time. - Prepare a folder:
python_kozi\wiki07\.
Week Goals (by the end of the week the student will be able to...)
- Create a subclass that inherits from a parent class, and use
super().__init__(...). - Override a method and call the parent's version with
super(). - Use
isinstance()andissubclass(). - Explain polymorphism and write code that works with objects of different types.
- Create an abstract class with
ABCand@abstractmethod. - Write
__len__,__eq__,__add__(and understand how dunders connect to operators). - Use
@dataclass(includingfield(default_factory=list)andorder=True). - Write type hints:
int,str,list[int],dict[str, float],Optional[str],str | None.
Session 1 — Inheritance, super() and Method Overriding
Session flow
- Review of the Week 6 homework (Simu, Joto, Duka JSON)
- The problem: repeated code in similar classes
- First inheritance:
class Mbwa(Mnyama)(Dog, Animal) super().__init__()- Break
- Overriding + calling the parent's method;
isinstance,issubclass - In-class exercise
- Q&A + homework
Explanation for the teacher (read this first)
Inheritance means a new class (child / subclass) gets all the attributes and methods of an existing class (parent / base / superclass), and can then add new ones or change existing ones.
class Mnyama: # parent ... class Mbwa(Mnyama): # child: "Mbwa ni Mnyama" ...
This relationship is called "is-a" (a Mbwa is a Mnyama). Compare with Week 6, composition "has-a" (a Duka has Bidhaa). A simple rule: if the sentence "X is a kind of Y" makes sense, use inheritance; otherwise composition.
Method overriding: the child writes a method with the same name as the parent's → the child's version is used for the child's objects.
super() returns the "parent" so that we can call its methods. It matters most in __init__: if the child has its own __init__, the parent's is not called automatically — we must call super().__init__(...), otherwise the parent's attributes will not be set.
When Python looks up obj.method: it checks the object → its class → the parent → the parent's parent ... → object (the ancestor of all classes). This order is called the MRO (Method Resolution Order): Mbwa.__mro__. Just mention it; multiple inheritance (class C(A, B)) exists but we do not teach it in depth.
isinstance(obj, Mnyama)→ True if obj is a Mnyama or a subclass of it.issubclass(Mbwa, Mnyama)→ True.
The Week 5 exceptions were already inheritance! class SalioHalitoshi(Exception) — that is why except Exception catches SalioHalitoshi too. This is a nice "aha moment" for the student.
Say this to the student
- "Inheritance is like family inheritance: a child inherits a parent's eye colour (attributes) and habits (methods), but can have their own talent (a new method), or do something their own way (override)."
- "A car, a motorbike and a bajaji are all means of transport: they have a registration number, and they can 'start'. Instead of writing that three times, we write
Chombo(vehicle) once." - "
super()is like saying to a parent: 'Dad, do your part first (filling in name and age), then I will add mine.'"
Type this code live (share screen)
Step 1: The problem — bila_urithi.py
class Mbwa: def __init__(self, jina, umri): self.jina = jina self.umri = umri def kula(self): print(f"{self.jina} anakula.") def toa_sauti(self): print("Woof!") class Paka: def __init__(self, jina, umri): self.jina = jina self.umri = umri def kula(self): print(f"{self.jina} anakula.") def toa_sauti(self): print("Meow!")
Ask: "What do you see being repeated?" (__init__ and kula.)
Step 2: Inheritance — urithi.py
class Mnyama: def __init__(self, jina, umri): self.jina = jina self.umri = umri def kula(self): print(f"{self.jina} anakula.") def toa_sauti(self): print("...") def __str__(self): return f"{type(self).__name__} {self.jina} ({self.umri} yrs)" class Mbwa(Mnyama): def toa_sauti(self): # override print("Woof!") class Paka(Mnyama): def toa_sauti(self): print("Meow!") m = Mbwa("Simba", 3) p = Paka("Pusi", 2) m.kula() # imerithiwa m.toa_sauti() # ya Mbwa p.toa_sauti() print(m, p, sep=" | ")
Output:
Simba anakula. Woof! Meow! Mbwa Simba (3 yrs) | Paka Pusi (2 yrs)
Explain type(self).__name__ — the name of the object's actual class.
Step 3: super().__init__
class Mbwa(Mnyama): def __init__(self, jina, umri, aina): super().__init__(jina, umri) # parent anaweka jina, umri self.aina = aina # child anaongeza def toa_sauti(self): print("Woof!") def leta_mpira(self): # method mpya print(f"{self.jina} ameleta mpira!") m = Mbwa("Simba", 3, "German Shepherd") print(m.jina, m.aina) m.leta_mpira()
Then show an error on purpose: delete the super().__init__ line and run → AttributeError: 'Mbwa' object has no attribute 'jina'. Put it back.
Step 4: Override + calling the parent — wafanyakazi.py
class Mfanyakazi: def __init__(self, jina, mshahara): self.jina = jina self.mshahara = mshahara def malipo_ya_mwezi(self): return self.mshahara def maelezo(self): return f"{self.jina}: TSh {self.malipo_ya_mwezi():,}" class Meneja(Mfanyakazi): def __init__(self, jina, mshahara, posho): super().__init__(jina, mshahara) self.posho = posho def malipo_ya_mwezi(self): return super().malipo_ya_mwezi() + self.posho def maelezo(self): return super().maelezo() + " (Meneja)" wafanyakazi = [ Mfanyakazi("Asha", 800_000), Meneja("Juma", 1_500_000, 300_000), ] for w in wafanyakazi: print(w.maelezo()) print(isinstance(wafanyakazi[1], Mfanyakazi)) # True print(isinstance(wafanyakazi[0], Meneja)) # False print(issubclass(Meneja, Mfanyakazi)) # True print(Meneja.__mro__)
Output:
Asha: TSh 800,000 Juma: TSh 1,800,000 (Meneja) True False True (<class '__main__.Meneja'>, <class '__main__.Mfanyakazi'>, <class 'object'>)
Emphasise: the parent's maelezo() calls self.malipo_ya_mwezi() — and for Juma, self is a Meneja (manager), so the Meneja version is used (with the allowance). This is very important and it introduces polymorphism.
Step 5: Exceptions are inheritance
class KosaLaBenki(Exception): pass class SalioHalitoshi(KosaLaBenki): pass class AkauntiImefungwa(KosaLaBenki): pass try: raise AkauntiImefungwa("Akaunti 1001 imefungwa.") except KosaLaBenki as e: # inashika subclasses zote print(type(e).__name__, "->", e)
Output: AkauntiImefungwa -> Akaunti 1001 imefungwa.
Questions to ask the student
- What does a child class get from its parent? — All attributes and methods.
- What is overriding? — The child writing a method with the same name to change the behaviour.
- If you do not call
super().__init__()in the child's__init__, what happens? — The parent's attributes are not set →AttributeErrorlater. - What is
isinstance(Mbwa(...), Mnyama)? — True. - What is the difference between "is-a" and "has-a"? — Inheritance (a Mbwa is a Mnyama) vs composition (a Duka has Bidhaa).
- Why does
except KosaLaBenkicatchSalioHalitoshi? — BecauseSalioHalitoshiis a subclass of it.
Common mistakes
| Mistake | Error message | Fix |
|---|---|---|
Forgetting super().__init__ |
AttributeError: 'Mbwa' object has no attribute 'jina' |
Add super().__init__(jina, umri) |
Passing self to super: super().__init__(self, jina, umri) |
TypeError: Mnyama.__init__() takes 3 positional arguments but 4 were given |
Do not pass self |
Forgetting () on super: super.__init__(...) |
TypeError: descriptor '__init__' requires a 'super' object but received a 'str' |
super().__init__(...) |
Inheriting from an object instead of a class: class Mbwa(Mnyama()) |
A metaclass/arguments TypeError |
Use the class name without () |
Forgetting return in an overridden method |
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int' |
return super().malipo_ya_mwezi() + ... |
In-class exercise
Exercise 1.1: Class Chombo (vehicle; namba number, kasi_ya_juu top speed) with maelezo() (description). Subclasses Gari (has viti, seats) and Pikipiki (motorbike; has ina_helmet: bool). Each one should have its own maelezo() that calls the parent's.
Exercise 1.2: Class Akaunti (mmiliki owner, salio balance, toa withdraw, weka deposit). A subclass AkauntiYaAkiba (savings account) that does not allow the balance to go below 10,000 (override toa), and has ongeza_riba(asilimia) (add interest).
Exercise 1.3: In wafanyakazi.py, add Mkandarasi(Mfanyakazi) (contractor), who is paid by the hour (saa hours, kiwango_kwa_saa hourly rate). Override malipo_ya_mwezi. Print everyone's total pay.
Answer
# 1.1 class Chombo: def __init__(self, namba, kasi_ya_juu): self.namba = namba self.kasi_ya_juu = kasi_ya_juu def maelezo(self): return f"{self.namba} (kasi ya juu {self.kasi_ya_juu} km/h)" class Gari(Chombo): def __init__(self, namba, kasi_ya_juu, viti): super().__init__(namba, kasi_ya_juu) self.viti = viti def maelezo(self): return "Gari " + super().maelezo() + f", viti {self.viti}" class Pikipiki(Chombo): def __init__(self, namba, kasi_ya_juu, ina_helmet): super().__init__(namba, kasi_ya_juu) self.ina_helmet = ina_helmet def maelezo(self): helmet = "ina helmet" if self.ina_helmet else "haina helmet" return "Pikipiki " + super().maelezo() + f", {helmet}" for c in [Gari("T 123 ABC", 180, 5), Pikipiki("MC 456 XYZ", 120, True)]: print(c.maelezo()) # 1.2 class Akaunti: def __init__(self, mmiliki, salio=0): self.mmiliki = mmiliki self.salio = salio def weka(self, kiasi): self.salio += kiasi def toa(self, kiasi): if kiasi > self.salio: raise ValueError("Salio halitoshi") self.salio -= kiasi class AkauntiYaAkiba(Akaunti): KIWANGO_CHA_CHINI = 10_000 def toa(self, kiasi): if self.salio - kiasi < self.KIWANGO_CHA_CHINI: raise ValueError(f"Lazima ubaki na angalau {self.KIWANGO_CHA_CHINI:,}") super().toa(kiasi) def ongeza_riba(self, asilimia): self.salio += self.salio * asilimia / 100 a = AkauntiYaAkiba("Neema", 50_000) a.toa(30_000) try: a.toa(15_000) except ValueError as e: print(e) # Lazima ubaki na angalau 10,000 a.ongeza_riba(5) print(a.salio) # 21000.0 # 1.3 class Mkandarasi(Mfanyakazi): def __init__(self, jina, saa, kiwango_kwa_saa): super().__init__(jina, 0) self.saa = saa self.kiwango_kwa_saa = kiwango_kwa_saa def malipo_ya_mwezi(self): return self.saa * self.kiwango_kwa_saa wote = [Mfanyakazi("Asha", 800_000), Meneja("Juma", 1_500_000, 300_000), Mkandarasi("Baraka", 120, 5_000)] jumla = 0 for w in wote: print(w.maelezo()) jumla += w.malipo_ya_mwezi() print(f"Jumla: TSh {jumla:,}") # Jumla: TSh 3,200,000
Session 2 — Polymorphism, Abstract Classes and Dunder Methods
Session flow
- Review of the homework (geometric shapes)
- Polymorphism + duck typing
- Abstract classes:
ABC,@abstractmethod - Break
- Dunder methods:
__len__,__eq__,__add__(+__lt__,__contains__briefly) - In-class exercise
- Q&A + homework
Explanation for the teacher (read this first)
Polymorphism ("many forms") — one piece of code works with objects of different types, each responding in its own way. Example: for m in wanyama: m.toa_sauti() — we do not care whether it is a Mbwa or a Paka (cat); each one knows its own sound. We already saw this with malipo_ya_mwezi().
Duck typing: Python does not need objects to have a shared parent for polymorphism to work — it is enough that they have a method with that name. "If it walks like a duck and quacks like a duck, then it is a duck." Even len() works on str, list, dict — built-in polymorphism.
Abstract class — a class not intended to have objects created from it directly; it is a "contract" saying "every subclass MUST have these methods."
from abc import ABC, abstractmethod class Umbo(ABC): @abstractmethod def eneo(self): ...
Umbo()→TypeError: Can't instantiate abstract class Umbo without an implementation for abstract method 'eneo'(the Python 3.12+ message).- A subclass that does not write
eneo→ cannot be created either. The benefit: the error shows up early (when the object is created), not later inside the program. - An abstract class can also have ordinary methods that use the abstract ones (e.g.
maelezo()callingself.eneo()). ...(Ellipsis) orpassin the body of an abstract method — either is fine.
Dunder methods (double underscore) are what make operators and built-in functions work with our objects:
| You write | Python calls |
|-----------|---------------|
| len(x) | x.__len__() — must return an int ≥ 0 |
| x == y | x.__eq__(y) |
| x + y | x.__add__(y) |
| x < y | x.__lt__(y) (needed for sorted()) |
| item in x | x.__contains__(item) |
| str(x), print(x) | x.__str__() |
Without __eq__, == compares identity (is it the same object?), not contents. Pesa(100) == Pesa(100) → False until we write __eq__.
Inside __eq__/__add__, if other is not a type we recognise, return NotImplemented (do not raise) — Python will try another way or raise TypeError itself.
Note: when you write __eq__, Python makes the object not "hashable" (you cannot put it in a set or use it as a dict key) until you write __hash__. Do not go into depth; only mention it if the student asks.
Say this to the student
- "Polymorphism is like a teacher saying 'Stand up!' in class. Each student stands up in their own way — one quickly, another slowly — but the command is one."
- "An abstract class is like a job advert: 'The applicant MUST know how to drive.' The advert itself does not drive; it is a requirement for whoever gets hired."
- "Dunder methods are the back door that lets Python use symbols like
+and==with our things. When you writea + b, behind the scenes Python saysa.__add__(b)."
Type this code live (share screen)
Step 1: Polymorphism — polymorphism.py
class Mbwa: def toa_sauti(self): return "Woof!" class Paka: def toa_sauti(self): return "Meow!" class Ng'ombe: pass
Stop! Show the error: SyntaxError: unterminated string literal — an apostrophe cannot be in the name. Change it to Ngombe (cow):
class Ngombe: def toa_sauti(self): return "Mooo!" class Redio: # si mnyama kabisa! def toa_sauti(self): return "Habari za saa hii..." for kitu in [Mbwa(), Paka(), Ngombe(), Redio()]: print(type(kitu).__name__, ":", kitu.toa_sauti())
Output:
Mbwa : Woof! Paka : Meow! Ngombe : Mooo! Redio : Habari za saa hii...
Say: "A Redio has nothing to do with animals, but because it has toa_sauti, it works. That is duck typing."
Step 2: Abstract class — maumbo.py
from abc import ABC, abstractmethod import math class Umbo(ABC): @abstractmethod def eneo(self): ... @abstractmethod def mzunguko(self): ... def maelezo(self): # method ya kawaida return (f"{type(self).__name__}: eneo={self.eneo():.2f}, " f"mzunguko={self.mzunguko():.2f}") class Mstatili(Umbo): def __init__(self, urefu, upana): self.urefu = urefu self.upana = upana def eneo(self): return self.urefu * self.upana def mzunguko(self): return 2 * (self.urefu + self.upana) class Duara(Umbo): def __init__(self, nusu_kipenyo): self.r = nusu_kipenyo def eneo(self): return math.pi * self.r ** 2 def mzunguko(self): return 2 * math.pi * self.r maumbo = [Mstatili(3, 4), Duara(1)] for u in maumbo: print(u.maelezo()) try: Umbo() except TypeError as e: print("Kosa:", e)
Output:
Mstatili: eneo=12.00, mzunguko=14.00 Duara: eneo=3.14, mzunguko=6.28 Kosa: Can't instantiate abstract class Umbo without an implementation for abstract methods 'eneo', 'mzunguko'
Then show: create class Pembetatu(Umbo) (triangle) with only eneo (without mzunguko) and try Pembetatu() → TypeError ... abstract method 'mzunguko'.
Step 3: Dunders — pesa.py
class Pesa: def __init__(self, kiasi, sarafu="TZS"): self.kiasi = kiasi self.sarafu = sarafu def __repr__(self): return f"Pesa({self.kiasi!r}, {self.sarafu!r})" def __str__(self): return f"{self.sarafu} {self.kiasi:,}" def __eq__(self, other): if not isinstance(other, Pesa): return NotImplemented return self.kiasi == other.kiasi and self.sarafu == other.sarafu def __add__(self, other): if not isinstance(other, Pesa): return NotImplemented if self.sarafu != other.sarafu: raise ValueError("Huwezi kuongeza sarafu tofauti.") return Pesa(self.kiasi + other.kiasi, self.sarafu) def __lt__(self, other): if not isinstance(other, Pesa): return NotImplemented return self.kiasi < other.kiasi a = Pesa(5000) b = Pesa(5000) c = Pesa(2000) print(a == b) # True (bila __eq__ ingekuwa False) print(a is b) # False (objects tofauti) print(a + c) # TZS 7,000 print(sorted([a, c, Pesa(100)])) try: a + Pesa(10, "USD") except ValueError as e: print(e) try: a + 100 except TypeError as e: print("TypeError:", e)
Output:
True False TZS 7,000 [Pesa(100, 'TZS'), Pesa(2000, 'TZS'), Pesa(5000, 'TZS')] Huwezi kuongeza sarafu tofauti. TypeError: unsupported operand type(s) for +: 'Pesa' and 'int'
Step 4: __len__ and __contains__ — orodha_ya_kucheza.py
class Wimbo: def __init__(self, jina, msanii, sekunde): self.jina = jina self.msanii = msanii self.sekunde = sekunde def __repr__(self): return f"Wimbo({self.jina!r})" class Playlist: def __init__(self, jina): self.jina = jina self._nyimbo = [] def ongeza(self, wimbo): self._nyimbo.append(wimbo) def __len__(self): return len(self._nyimbo) def __contains__(self, jina_la_wimbo): for w in self._nyimbo: if w.jina.lower() == jina_la_wimbo.lower(): return True return False def __add__(self, other): mpya = Playlist(f"{self.jina} + {other.jina}") mpya._nyimbo = self._nyimbo + other._nyimbo return mpya bongo = Playlist("Bongo Flava") bongo.ongeza(Wimbo("Wimbo A", "Msanii 1", 210)) bongo.ongeza(Wimbo("Wimbo B", "Msanii 2", 185)) injili = Playlist("Injili") injili.ongeza(Wimbo("Wimbo C", "Kwaya", 300)) print(len(bongo)) # 2 print("wimbo a" in bongo) # True print("Wimbo C" in bongo) # False zote = bongo + injili print(zote.jina, len(zote)) # Bongo Flava + Injili 3 if bongo: # __len__ > 0 => True print("Playlist si tupu")
Questions to ask the student
- What is polymorphism in one sentence? — Calling the same method on different objects, each responding in its own way.
- Why can we not create
Umbo()? — It is abstract; it has abstract methods with no implementation. - What is the benefit of an abstract class? — It forces subclasses to have certain methods; the error shows up early.
- Which method does
a == bcall? —a.__eq__(b). - Without
__eq__, what isPesa(5) == Pesa(5)? Why? — False; it compares identity (is). - Which dunder does
sorted()need for objects? —__lt__. - What must
__len__return? — A non-negative int.
Common mistakes
| Mistake | Error message | Fix |
|---|---|---|
| Instantiating an abstract class | TypeError: Can't instantiate abstract class Umbo without an implementation for abstract method 'eneo' |
Create a subclass that writes all the methods. |
Forgetting to inherit ABC |
@abstractmethod prevents nothing; Umbo() works |
class Umbo(ABC): |
Different method name in the subclass (Eneo vs eneo) |
The abstract error above | The names must match exactly. |
__len__ returns a float or None |
TypeError: 'float' object cannot be interpreted as an integer / 'NoneType' object cannot be interpreted as an integer |
return len(self._nyimbo) |
sorted() without __lt__ |
TypeError: '<' not supported between instances of 'Pesa' and 'Pesa' |
Write __lt__ (or use key=, Week 8). |
__add__ changes self instead of returning a new object |
a + b ruins a |
Return a new Pesa(...). |
__eq__ + putting objects in a set |
TypeError: unhashable type: 'Pesa' |
Add __hash__ (e.g. return hash((self.kiasi, self.sarafu))). |
In-class exercise
Exercise 2.1: Abstract class Malipo (payment) with @abstractmethod lipa(kiasi) (pay). Subclasses MPesa (1% fee), Kadi (card, 2.5% fee), Taslimu (cash, no fee); each lipa should return a description string including the fee. Write a function lipia_wote(njia, kiasi) that loops over a list of payment methods.
Exercise 2.2: Class Vekta (vector; x, y) with __add__, __eq__, __repr__, and a method urefu() (length, √(x²+y²)). Thinking question: why should we not use __len__ for a vector's length? (Answer: __len__ must return a non-negative int; a vector's length is a float, e.g. 2.236.)
Exercise 2.3: Add __len__ and __contains__ (by product name) to the Duka from Week 6.
Answer
# 2.1 from abc import ABC, abstractmethod class Malipo(ABC): ADA = 0.0 @abstractmethod def lipa(self, kiasi): ... def ada(self, kiasi): return round(kiasi * self.ADA) class MPesa(Malipo): ADA = 0.01 def lipa(self, kiasi): return f"M-Pesa: {kiasi:,} + ada {self.ada(kiasi):,}" class Kadi(Malipo): ADA = 0.025 def lipa(self, kiasi): return f"Kadi: {kiasi:,} + ada {self.ada(kiasi):,}" class Taslimu(Malipo): def lipa(self, kiasi): return f"Taslimu: {kiasi:,} (hakuna ada)" def lipia_wote(njia, kiasi): for n in njia: print(n.lipa(kiasi)) lipia_wote([MPesa(), Kadi(), Taslimu()], 20_000) # M-Pesa: 20,000 + ada 200 # Kadi: 20,000 + ada 500 # Taslimu: 20,000 (hakuna ada) # 2.2 import math class Vekta: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): if not isinstance(other, Vekta): return NotImplemented return Vekta(self.x + other.x, self.y + other.y) def __eq__(self, other): if not isinstance(other, Vekta): return NotImplemented return (self.x, self.y) == (other.x, other.y) def __repr__(self): return f"Vekta({self.x}, {self.y})" def urefu(self): return math.hypot(self.x, self.y) v = Vekta(1, 2) + Vekta(2, 2) print(v, v == Vekta(3, 4), v.urefu()) # Vekta(3, 4) True 5.0 # 2.3 -- ndani ya class Duka (Wiki 6) def __len__(self): return len(self._bidhaa) def __contains__(self, jina): return jina.lower() in self._bidhaa # len(duka) -> 3 ; "sukari" in duka -> True
Session 3 — Dataclasses and Type Hints
Session flow
- Review of the homework (Pesa / Malipo)
- Type hints: why, syntax,
list[int],dict[str, int],Optional,| @dataclass— writing a short class;field(default_factory=list);order=True;frozen=True- Break
- Mini project: Employee/payroll system (inheritance + abc + dataclass + hints)
- OOP summary (Weeks 6–7)
- Q&A + homework
Explanation for the teacher (read this first)
Type hints are notes stating the expected type of data:
def salimia(jina: str, mara: int = 1) -> str: return ("Habari " + jina + "! ") * mara
jina: str— the parameter is expected to be a str.-> str— the function returns a str.- Python does NOT check them at run time!
salimia(5)will still run (and produce an ordinary error inside). The benefits are for: (1) people reading the code, (2) VS Code (autocomplete, Pylance warning underlines), (3) tools likemypy. - Collection types (Python 3.9+):
list[int],dict[str, float],tuple[int, str],set[str]. - A value that may be None:
str | None(Python 3.10+) — exactly the same asOptional[str]fromtyping. Use|for new code; knowOptionalbecause you will see it in other people's code. - Multiple types:
int | float. - Variables:
alama: list[int] = []. - A function that returns nothing:
-> None.
Pylance (inside VS Code) shows type errors if you turn it on: Settings → search for "Type Checking Mode" → basic. Optional.
@dataclass (from dataclasses) — a decorator that writes __init__, __repr__ and __eq__ by itself from the type hints of the class attributes:
from dataclasses import dataclass @dataclass class Bidhaa: jina: str bei: int idadi: int = 0
This is equivalent to writing __init__(self, jina, bei, idadi=0), __repr__ and __eq__ by hand! Here type hints are required (they are what tell the dataclass which fields exist).
Rules/traps:
- Fields with a default must come after those without one (like function parameters), otherwise:
TypeError: non-default argument 'bei' follows default argument 'idadi'. - Default for a list/dict: you cannot write
vitu: list[str] = []→ValueError: mutable default <class 'list'> for field vitu is not allowed: use default_factory. Usefield(default_factory=list). @dataclass(order=True)→ adds<, >, <=, >=(compares fields in their order) —sorted()works.@dataclass(frozen=True)→ the object cannot be changed after creation (FrozenInstanceError); it also becomes hashable.__post_init__(self)→ called after the generated__init__; good for validation.- A dataclass is still an ordinary class: you can add methods,
@property, and inherit. dataclasses.asdict(obj)→ a dict (good for JSON — we will use it in Week 8!).
Say this to the student
- "Type hints are like labels on drawers: 'Knives', 'Spoons'. Labels do not stop someone putting a spoon in the knife drawer, but they help everyone know what goes where."
- "
@dataclassis like a pre-printed form. Instead of drawing the form's lines by hand every time (__init__,__repr__), you just write the names of the sections and the printer does the rest."
Type this code live (share screen)
Step 1: Type hints — hints.py
def wastani(alama: list[int]) -> float: return sum(alama) / len(alama) def tafuta_simu(jina: str, kitabu: dict[str, str]) -> str | None: return kitabu.get(jina) def salimia(jina: str, cheo: str | None = None) -> str: if cheo: return f"Habari {cheo} {jina}" return f"Habari {jina}" print(wastani([80, 70, 90])) kitabu = {"Asha": "0712000000"} print(tafuta_simu("Asha", kitabu)) print(tafuta_simu("Juma", kitabu)) print(salimia("Juma"), "|", salimia("Neema", "Dkt.")) print(wastani.__annotations__)
Output:
80.0
0712000000
None
Habari Juma | Habari Dkt. Neema
{'alama': list[int], 'return': <class 'float'>}
Then show that Python does not check: print(salimia(123)) → Habari 123 (it works!). Say: "Hints are advice, not law — but VS Code shows you." Hover over wastani in VS Code to show the tooltip.
Show Optional:
from typing import Optional def tafuta(jina: str) -> Optional[int]: # sawa na: int | None return None
(Optional, if mypy is installed in the venv):
python -m pip install mypy python -m mypy hints.py
If you add salimia(123), mypy will say: error: Argument 1 to "salimia" has incompatible type "int"; expected "str" [arg-type].
Step 2: Ordinary class vs dataclass — dc_demo.py
from dataclasses import dataclass, field, asdict class BidhaaYaZamani: def __init__(self, jina: str, bei: int, idadi: int = 0): self.jina = jina self.bei = bei self.idadi = idadi def __repr__(self): return f"BidhaaYaZamani(jina={self.jina!r}, bei={self.bei!r}, idadi={self.idadi!r})" def __eq__(self, other): if not isinstance(other, BidhaaYaZamani): return NotImplemented return (self.jina, self.bei, self.idadi) == (other.jina, other.bei, other.idadi) @dataclass class Bidhaa: jina: str bei: int idadi: int = 0 @property def thamani(self) -> int: return self.bei * self.idadi b = Bidhaa("Sukari", 3000, 10) print(b) print(b == Bidhaa("Sukari", 3000, 10)) print(b.thamani) print(asdict(b))
Output:
Bidhaa(jina='Sukari', bei=3000, idadi=10)
True
30000
{'jina': 'Sukari', 'bei': 3000, 'idadi': 10}
Say: "14 lines have become 4!"
Step 3: default_factory, order, frozen, __post_init__
from dataclasses import dataclass, field @dataclass class Mwanafunzi: jina: str alama: list[int] = field(default_factory=list) def __post_init__(self): if not self.jina.strip(): raise ValueError("Jina haliwezi kuwa tupu.") def wastani(self) -> float: return sum(self.alama) / len(self.alama) if self.alama else 0.0 a = Mwanafunzi("Asha") j = Mwanafunzi("Juma") a.alama.append(90) print(a, j) # alama za Juma bado ni tupu try: Mwanafunzi(" ") except ValueError as e: print(e) @dataclass(order=True) class Toleo: kuu: int ndogo: int matoleo = [Toleo(3, 12), Toleo(3, 9), Toleo(2, 7)] print(sorted(matoleo)) print(Toleo(3, 12) > Toleo(3, 9)) @dataclass(frozen=True) class Nukta: x: float y: float n = Nukta(1, 2) try: n.x = 5 except Exception as e: print(type(e).__name__, ":", e) print({n, Nukta(1, 2)}) # frozen => hashable, set ina moja tu
Output:
Mwanafunzi(jina='Asha', alama=[90]) Mwanafunzi(jina='Juma', alama=[])
Jina haliwezi kuwa tupu.
[Toleo(kuu=2, ndogo=7), Toleo(kuu=3, ndogo=9), Toleo(kuu=3, ndogo=12)]
True
FrozenInstanceError : cannot assign to field 'x'
{Nukta(x=1, y=2)}
Show the trap: change field(default_factory=list) to = [] → ValueError: mutable default <class 'list'> for field alama is not allowed: use default_factory.
Step 4: Mini project — Payroll — mishahara.py (everything from Weeks 6–7 together)
from abc import ABC, abstractmethod from dataclasses import dataclass, field @dataclass class Mfanyakazi(ABC): jina: str idara: str @abstractmethod def mshahara(self) -> int: ... def __str__(self) -> str: return f"{self.jina:<10}{self.idara:<10}{type(self).__name__:<12}{self.mshahara():>12,}" @dataclass class WaKudumu(Mfanyakazi): mshahara_wa_mwezi: int = 0 def mshahara(self) -> int: return self.mshahara_wa_mwezi @dataclass class WaSaa(Mfanyakazi): saa: int = 0 kiwango: int = 0 def mshahara(self) -> int: return self.saa * self.kiwango @dataclass class Kampuni: jina: str wafanyakazi: list[Mfanyakazi] = field(default_factory=list) def ajiri(self, m: Mfanyakazi) -> None: self.wafanyakazi.append(m) def __len__(self) -> int: return len(self.wafanyakazi) def jumla_ya_mishahara(self) -> int: return sum(m.mshahara() for m in self.wafanyakazi) def wa_idara(self, idara: str) -> list[Mfanyakazi]: return [m for m in self.wafanyakazi if m.idara == idara] def ripoti(self) -> None: print(f"=== {self.jina} ({len(self)} wafanyakazi) ===") for m in self.wafanyakazi: print(m) print(f"{'JUMLA':<32}{self.jumla_ya_mishahara():>12,}") k = Kampuni("Python Tech Ltd") k.ajiri(WaKudumu("Asha", "IT", 1_200_000)) k.ajiri(WaKudumu("Juma", "Mauzo", 900_000)) k.ajiri(WaSaa("Baraka", "IT", saa=80, kiwango=6_000)) k.ripoti() print("IT:", [m.jina for m in k.wa_idara("IT")])
Output:
=== Python Tech Ltd (3 wafanyakazi) === Asha IT WaKudumu 1,200,000 Juma Mauzo WaKudumu 900,000 Baraka IT WaSaa 480,000 JUMLA 2,580,000 IT: ['Asha', 'Baraka']
Notes for the teacher: A child dataclass orders its fields like this: first the parent's (jina, idara), then its own (saa, kiwango). If the parent had a field with a default, any child field without a default would cause TypeError: non-default argument 'saa' follows default argument. That is why we put = 0 on the child's fields — it is a safe habit. Also, @dataclass does not add __str__, so our __str__ is used by print, while __repr__ is the dataclass's. Comprehensions ([m.jina for m in ...]) are Week 8; it is fine to use them as a "preview".
Questions to ask the student
- Does Python block
salimia(123)ifjina: str? — No; hints are not checked at run time. str | NoneandOptional[str]— what is the difference? — None; they are the same.|is the newer syntax (3.10+).- Which methods does
@dataclasswrite for us? —__init__,__repr__,__eq__. - Why is
alama: list[int] = []rejected? — All objects would share one list (the Week 6 trap); usefield(default_factory=list). - What does
order=Trueadd? — Comparisons<,>, etc., sosorted()works. - What does
frozen=Truedo? — The object cannot be changed after creation. - Which should you use: an ordinary class or a dataclass? — A dataclass if the class mainly holds data; an ordinary class if there is a lot of
__init__logic or strict encapsulation.
Common mistakes
| Mistake | Error message | Fix |
|---|---|---|
A field without a type hint in a dataclass: jina = "x" |
It does not become a field (not in __init__): TypeError: Bidhaa.__init__() takes ... positional arguments |
Write jina: str = "x" |
| A default before a non-default | TypeError: non-default argument 'bei' follows default argument 'idadi' |
Put fields with defaults at the end. |
| A list as a default | ValueError: mutable default <class 'list'> for field alama is not allowed: use default_factory |
field(default_factory=list) |
Forgetting from dataclasses import dataclass |
NameError: name 'dataclass' is not defined |
Import it. |
| Changing a frozen dataclass | dataclasses.FrozenInstanceError: cannot assign to field 'x' |
Create a new object: dataclasses.replace(n, x=5) |
list[int] on old Python (<3.9) |
TypeError: 'type' object is not subscriptable |
Use Python 3.12 (or from typing import List). |
In-class exercise
Exercise 3.1: Add type hints to 3 functions you wrote in Weeks 4–5 (e.g. angalia_alama, hifadhi_mawasiliano).
Exercise 3.2: Dataclass Kitabu (book; isbn: str, kichwa: str title, mwandishi: str author, mwaka: int year, kimeazimwa: bool = False borrowed). Dataclass Rafu (shelf) with vitabu: list[Kitabu] (default_factory), methods ongeza(k) (add), tafuta_mwandishi(jina) -> list[Kitabu] (find by author), __len__. (This is preparation for the Week 8 project!)
Exercise 3.3: Convert the Pesa from Session 2 into a @dataclass(frozen=True, order=True) and add only __add__. Do you still need to write __eq__ and __lt__?
Answer
# 3.1 (mifano) def angalia_alama(alama: int) -> str: ... def hifadhi_mawasiliano(jina: str, simu: str) -> None: ... def bei_ya(bidhaa: str, orodha: dict[str, int]) -> int: ... # 3.2 from dataclasses import dataclass, field @dataclass class Kitabu: isbn: str kichwa: str mwandishi: str mwaka: int kimeazimwa: bool = False @dataclass class Rafu: vitabu: list[Kitabu] = field(default_factory=list) def ongeza(self, k: Kitabu) -> None: self.vitabu.append(k) def tafuta_mwandishi(self, jina: str) -> list[Kitabu]: matokeo = [] for k in self.vitabu: if jina.lower() in k.mwandishi.lower(): matokeo.append(k) return matokeo def __len__(self) -> int: return len(self.vitabu) r = Rafu() r.ongeza(Kitabu("978-1", "Kinjeketile", "Ebrahim Hussein", 1969)) r.ongeza(Kitabu("978-2", "Rosa Mistika", "Euphrase Kezilahabi", 1971)) print(len(r)) print(r.tafuta_mwandishi("kezilahabi")) # 3.3 from dataclasses import dataclass @dataclass(frozen=True, order=True) class Pesa: kiasi: int sarafu: str = "TZS" def __add__(self, other: "Pesa") -> "Pesa": if not isinstance(other, Pesa): return NotImplemented if self.sarafu != other.sarafu: raise ValueError("Sarafu tofauti") return Pesa(self.kiasi + other.kiasi, self.sarafu) print(Pesa(100) + Pesa(50)) # Pesa(kiasi=150, sarafu='TZS') print(Pesa(100) == Pesa(100)) # True print(sorted([Pesa(5), Pesa(1)])) # [Pesa(kiasi=1, ...), Pesa(kiasi=5, ...)] # Hapana: dataclass inatengeneza __eq__ na __lt__ yenyewe. # (Tahadhari: order=True inalinganisha (kiasi, sarafu) kama tuple.)
"Pesa" in quotes: inside the class, the name Pesa is not yet complete, so we write it as a string (forward reference). Just mention it.
Homework
(Due: next session)
Assignment 1 — Shapes (after Session 1): Class Umbo (shape; without abc yet) with eneo() (area) returning 0. Subclasses Mraba(upande) (square, side), Pembetatu(kitako, kimo) (triangle, base, height), Duara(r) (circle) overriding eneo(). Create a list of 5 shapes and print the total area.
Assignment 2 — Farm animal system (after Session 2): Abstract class Mnyama (jina, abstract toa_sauti() make a sound and chakula() food), 3 subclasses. Class Shamba (farm) with __len__, __contains__ (by name), __add__ (merging two farms), and a method wakati_wa_kula() (feeding time) that loops over all the animals (polymorphism).
Assignment 3 — Bookshelf (after Session 3): Extend Exercise 3.2: add to Rafu the methods azima(isbn) -> None (borrow; raise a custom exception KitabuHakipo book not found, or KimeshaAzimwa already borrowed), rudisha(isbn) (return), vinavyopatikana() -> list[Kitabu] (available). Type hints everywhere. This will be the foundation of the Month 2 Project — bring it!
Answer
# Kazi 1 import math class Umbo: def eneo(self) -> float: return 0 class Mraba(Umbo): def __init__(self, upande: float): self.upande = upande def eneo(self) -> float: return self.upande ** 2 class Pembetatu(Umbo): def __init__(self, kitako: float, kimo: float): self.kitako = kitako self.kimo = kimo def eneo(self) -> float: return 0.5 * self.kitako * self.kimo class Duara(Umbo): def __init__(self, r: float): self.r = r def eneo(self) -> float: return math.pi * self.r ** 2 maumbo = [Mraba(2), Mraba(3), Pembetatu(4, 5), Duara(1), Duara(2)] jumla = 0 for u in maumbo: jumla += u.eneo() print(f"Eneo la jumla: {jumla:.2f}") # 4+9+10+3.14+12.57 = 38.71
# Kazi 2 from abc import ABC, abstractmethod class Mnyama(ABC): def __init__(self, jina: str): self.jina = jina @abstractmethod def toa_sauti(self) -> str: ... @abstractmethod def chakula(self) -> str: ... class Ngombe(Mnyama): def toa_sauti(self): return "Mooo" def chakula(self): return "majani" class Kuku(Mnyama): def toa_sauti(self): return "Kokoriko" def chakula(self): return "mahindi" class Mbuzi(Mnyama): def toa_sauti(self): return "Meee" def chakula(self): return "majani na matawi" class Shamba: def __init__(self, jina: str): self.jina = jina self.wanyama: list[Mnyama] = [] def ongeza(self, m: Mnyama) -> None: self.wanyama.append(m) def __len__(self) -> int: return len(self.wanyama) def __contains__(self, jina: str) -> bool: return any(m.jina == jina for m in self.wanyama) def __add__(self, other: "Shamba") -> "Shamba": mpya = Shamba(f"{self.jina} & {other.jina}") mpya.wanyama = self.wanyama + other.wanyama return mpya def wakati_wa_kula(self) -> None: for m in self.wanyama: print(f"{m.jina} ({type(m).__name__}) anasema {m.toa_sauti()} na anakula {m.chakula()}") a = Shamba("Shamba la Babu") a.ongeza(Ngombe("Bahati")) a.ongeza(Kuku("Kiroboto")) b = Shamba("Shamba la Mama") b.ongeza(Mbuzi("Zawadi")) c = a + b print(len(c), "Zawadi" in c, "Simba" in c) # 3 True False c.wakati_wa_kula()
# Kazi 3 from dataclasses import dataclass, field class KosaLaMaktaba(Exception): pass class KitabuHakipo(KosaLaMaktaba): pass class KimeshaAzimwa(KosaLaMaktaba): pass @dataclass class Kitabu: isbn: str kichwa: str mwandishi: str mwaka: int kimeazimwa: bool = False @dataclass class Rafu: vitabu: list[Kitabu] = field(default_factory=list) def ongeza(self, k: Kitabu) -> None: self.vitabu.append(k) def _tafuta(self, isbn: str) -> Kitabu: for k in self.vitabu: if k.isbn == isbn: return k raise KitabuHakipo(f"Hakuna kitabu chenye ISBN {isbn}") def azima(self, isbn: str) -> None: k = self._tafuta(isbn) if k.kimeazimwa: raise KimeshaAzimwa(f"'{k.kichwa}' kimeshaazimwa.") k.kimeazimwa = True def rudisha(self, isbn: str) -> None: k = self._tafuta(isbn) k.kimeazimwa = False def vinavyopatikana(self) -> list[Kitabu]: return [k for k in self.vitabu if not k.kimeazimwa] def __len__(self) -> int: return len(self.vitabu) r = Rafu() r.ongeza(Kitabu("978-1", "Kinjeketile", "Ebrahim Hussein", 1969)) r.ongeza(Kitabu("978-2", "Rosa Mistika", "Euphrase Kezilahabi", 1971)) r.azima("978-1") try: r.azima("978-1") except KosaLaMaktaba as e: print(e) # 'Kinjeketile' kimeshaazimwa. print([k.kichwa for k in r.vinavyopatikana()]) # ['Rosa Mistika']
If you are asked a question you don't know
- Say: "That's a deep question — I'll look into it and bring you the answer in Session 2/Session 3." In the REPL try
help(dataclass),Meneja.__mro__,dir(obj). - Search: "python super() explained", "python abstract class example", "python dataclass default_factory". Docs: inheritance https://docs.python.org/3/tutorial/classes.html#inheritance | abc https://docs.python.org/3/library/abc.html | dataclasses https://docs.python.org/3/library/dataclasses.html | typing https://docs.python.org/3/library/typing.html | data model (dunders) https://docs.python.org/3/reference/datamodel.html#special-method-names
- For questions on multiple inheritance or metaclasses: say it is a topic beyond this course — you do not need to know it now.
End-of-week checklist
- Creates a subclass and uses
super().__init__()correctly. - Overrides a method and calls the parent's version with
super(). - Uses
isinstanceand understands whyexcept Exceptioncatches custom exceptions. - Explains polymorphism and duck typing with an example.
- Creates an abstract class with
ABC+@abstractmethodand understands the error from instantiating it directly. - Writes
__len__,__eq__,__add__(and knowsNotImplemented). - Uses
@dataclass,field(default_factory=list),order=True,frozen=True. - Writes type hints:
list[int],dict[str, int],str | None,-> None. - Understands that Python does not check type hints at run time.
- Assignment 3 (Bookshelf) is complete — it will be used in the Week 8 project.