Contents

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 @dataclass together with type hints.

Teacher Preparation (read before the week starts)

Week Goals (by the end of the week the student will be able to...)

  1. Create a subclass that inherits from a parent class, and use super().__init__(...).
  2. Override a method and call the parent's version with super().
  3. Use isinstance() and issubclass().
  4. Explain polymorphism and write code that works with objects of different types.
  5. Create an abstract class with ABC and @abstractmethod.
  6. Write __len__, __eq__, __add__ (and understand how dunders connect to operators).
  7. Use @dataclass (including field(default_factory=list) and order=True).
  8. Write type hints: int, str, list[int], dict[str, float], Optional[str], str | None.

Session 1 — Inheritance, super() and Method Overriding

Session flow

  1. Review of the Week 6 homework (Simu, Joto, Duka JSON)
  2. The problem: repeated code in similar classes
  3. First inheritance: class Mbwa(Mnyama) (Dog, Animal)
  4. super().__init__()
  5. Break
  6. Overriding + calling the parent's method; isinstance, issubclass
  7. In-class exercise
  8. 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.

python
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

python
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

python
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:

text
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__

python
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

python
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:

text
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

python
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

  1. What does a child class get from its parent? — All attributes and methods.
  2. What is overriding? — The child writing a method with the same name to change the behaviour.
  3. If you do not call super().__init__() in the child's __init__, what happens? — The parent's attributes are not set → AttributeError later.
  4. What is isinstance(Mbwa(...), Mnyama)? — True.
  5. What is the difference between "is-a" and "has-a"? — Inheritance (a Mbwa is a Mnyama) vs composition (a Duka has Bidhaa).
  6. Why does except KosaLaBenki catch SalioHalitoshi? — Because SalioHalitoshi is 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
python
# 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

  1. Review of the homework (geometric shapes)
  2. Polymorphism + duck typing
  3. Abstract classes: ABC, @abstractmethod
  4. Break
  5. Dunder methods: __len__, __eq__, __add__ (+ __lt__, __contains__ briefly)
  6. In-class exercise
  7. 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."

python
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() calling self.eneo()).
  • ... (Ellipsis) or pass in 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 write a + b, behind the scenes Python says a.__add__(b)."

Type this code live (share screen)

Step 1: Polymorphism — polymorphism.py

python
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):

python
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:

text
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

python
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:

text
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

python
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:

text
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

python
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

  1. What is polymorphism in one sentence? — Calling the same method on different objects, each responding in its own way.
  2. Why can we not create Umbo()? — It is abstract; it has abstract methods with no implementation.
  3. What is the benefit of an abstract class? — It forces subclasses to have certain methods; the error shows up early.
  4. Which method does a == b call? — a.__eq__(b).
  5. Without __eq__, what is Pesa(5) == Pesa(5)? Why? — False; it compares identity (is).
  6. Which dunder does sorted() need for objects? — __lt__.
  7. 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
python
# 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

  1. Review of the homework (Pesa / Malipo)
  2. Type hints: why, syntax, list[int], dict[str, int], Optional, |
  3. @dataclass — writing a short class; field(default_factory=list); order=True; frozen=True
  4. Break
  5. Mini project: Employee/payroll system (inheritance + abc + dataclass + hints)
  6. OOP summary (Weeks 6–7)
  7. Q&A + homework

Explanation for the teacher (read this first)

Type hints are notes stating the expected type of data:

python
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 like mypy.
  • 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 as Optional[str] from typing. Use | for new code; know Optional because 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:

python
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. Use field(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."
  • "@dataclass is 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

python
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:

text
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:

python
from typing import Optional

def tafuta(jina: str) -> Optional[int]:   # sawa na: int | None
    return None

(Optional, if mypy is installed in the venv):

powershell
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

python
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:

text
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__

python
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:

text
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)

python
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:

text
=== 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

  1. Does Python block salimia(123) if jina: str? — No; hints are not checked at run time.
  2. str | None and Optional[str] — what is the difference? — None; they are the same. | is the newer syntax (3.10+).
  3. Which methods does @dataclass write for us? — __init__, __repr__, __eq__.
  4. Why is alama: list[int] = [] rejected? — All objects would share one list (the Week 6 trap); use field(default_factory=list).
  5. What does order=True add? — Comparisons <, >, etc., so sorted() works.
  6. What does frozen=True do? — The object cannot be changed after creation.
  7. 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
python
# 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
python
# 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
python
# 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()
python
# 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

End-of-week checklist

  • Creates a subclass and uses super().__init__() correctly.
  • Overrides a method and calls the parent's version with super().
  • Uses isinstance and understands why except Exception catches custom exceptions.
  • Explains polymorphism and duck typing with an example.
  • Creates an abstract class with ABC + @abstractmethod and understands the error from instantiating it directly.
  • Writes __len__, __eq__, __add__ (and knows NotImplemented).
  • 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.