Lecture#

Class Methods and Static Methods#

Alternative method types that operate on the class rather than a specific instance.

Refer to L7_class_static_methods.py to follow along with the examples below.

The Three Method Types

Python classes support three kinds of methods. The right choice depends on whether the method needs access to instance state, class state, or neither.

Table 9 Comparison of Python method types#

Type

Decorator

First Param

Access

Instance method

(none)

self

Instance + class state

Class method

@classmethod

cls

Class state only

Static method

@staticmethod

(none)

No implicit access

The following example shows all three types in a single class:

class Robot:
    total_robots = 0

    def __init__(self, name: str, battery: int = 100):
        self._name = name
        self._battery = battery
        Robot.total_robots += 1

    def status(self):
        return f"{self._name}: {self._battery}%"

    @classmethod
    def get_fleet_size(cls):
        return f"Fleet size: {cls.total_robots}"

    @staticmethod
    def is_valid_battery(level: int) -> bool:
        return isinstance(level, int) and 0 <= level <= 100

scout = Robot("Scout")
print(scout.status())              # Scout: 100%
print(Robot.get_fleet_size())      # Fleet size: 1
print(Robot.is_valid_battery(50))  # True
  • status() is an instance method. It receives self and accesses per-instance state (_name, _battery).

  • get_fleet_size() is a class method. It receives cls and accesses class-level state (total_robots), which is shared across all instances.

  • is_valid_battery() is a static method. It receives neither self nor cls and performs a pure validation with no access to any state.

Instance Methods

Instance methods are the default. Python passes the calling instance as self automatically.

class Robot:
    def __init__(self, name: str, battery: int = 100):
        self._name = name
        self._battery = battery

    def status(self) -> str:
        return f"{self._name}: {self._battery}%"

scout = Robot("Scout")
print(scout.status())   # Scout: 100%
Class Methods and Factory Methods

Class methods receive the class itself as cls instead of an instance. They are the standard way to write factory methods – alternative constructors that build instances with a predefined configuration.

Use Cases

  • Alternative constructors: create instances from different input formats (from_dict, from_json, from_config).

  • Factory methods: return pre-configured instances with meaningful names (create_scout, create_heavy_lifter).

  • Accessing or modifying class attributes: read or update shared state that applies across all instances (fleet counters, registries).

  • Subclass-aware construction: when called on a subclass, cls refers to the subclass, so the factory returns the correct type automatically.

class Robot:
    def __init__(self, name: str, battery: int = 100):
        self._name = name
        self._battery = battery

    @classmethod
    def create_scout(cls) -> "Robot":
        return cls("Scout", 100)

    @classmethod
    def create_scout_team(
        cls, count: int = 3
    ) -> list["Robot"]:
        return [cls(f"Scout-{i+1}", 100)
                for i in range(count)]

scout = Robot.create_scout()
team  = Robot.create_scout_team(4)

Note

Calling cls(...) instead of Robot(...) ensures the factory works correctly in subclasses. create_scout() encapsulates the construction logic so callers do not need to know the default values. create_scout_team() shows that factory methods can return collections of instances, not just a single object.

Static Methods

Static methods receive no implicit first argument. They behave like plain functions that live inside the class namespace for organizational clarity.

Use Cases

  • Validation helpers: check whether a value is valid before creating an instance (is_valid_name, is_valid_battery_level).

  • Unit conversion: convert between units without needing instance or class state (meters_to_feet, degrees_to_radians).

  • Pure computations: perform a calculation logically related to the class but independent of any instance (compute_distance, normalize_angle).

  • Formatting utilities: produce a string representation of a value in a domain-specific format (battery_to_bar, status_to_label).

class Robot:
    def __init__(self, name: str, battery: int = 100):
        self._name = name
        self._battery = battery

    @staticmethod
    def is_valid_name(name: str) -> bool:
        return isinstance(name, str) and len(name) > 0

    @staticmethod
    def battery_to_bar(level: int) -> str:
        bars = level // 10
        return "[" + "#" * bars + "." * (10 - bars) + "]"

print(Robot.is_valid_name("Scout"))   # True
print(Robot.battery_to_bar(70))       # [#######...]

Note

is_valid_name() validates input before constructing a Robot. It does not need any instance to do its job. battery_to_bar() converts a number to a visual string – a pure computation with no side effects. Both can be called on the class directly (Robot.is_valid_name(...)) or on an instance, though calling on the class is the clearer style.

Object Relationships#

Modeling how objects interact and depend on one another.

Refer to L7_relationships.py to follow along with the examples below.

Types of Object Relationships

Before writing code, the design phase identifies how objects relate to each other. There are three fundamental relationships in OOP, each with different strength and lifetime implications.

Table 10 Object relationship comparison#

Relationship

Keyword

UML Symbol

Lifetime

Example

Association

“uses-a”

Arrow

Independent

Robot uses Task

Aggregation

“has-a”

Hollow diamond

Part outlives whole

Team has Robots

Composition

“has-a”

Filled diamond

Part destroyed with whole

Robot owns Sensors

The UML class diagram below shows all three relationships for the competition domain.

UML class diagram showing association, aggregation, and composition

Fig. 1 UML class diagram: association (Robot-Task), aggregation (Team-Robot), and composition (Robot-Sensor).#

UML class diagram showing association, aggregation, and composition
Association (uses-a)

Association is a relationship between two objects that establishes a connection for a certain period. One object can cause another to perform an action on its behalf.

  • Unidirectional: Only one class knows about the other.

  • Bidirectional: Both classes are aware of each other.

Physical world examples

  • A Driver uses a Car – the driver exists independently of any particular car.

  • A Doctor treats a Patient – neither owns the other.

  • A Student enrolls in a Course – both exist before and after the enrollment.

Robotics Competition examples

  • A Robot is assigned a Task – the task exists before and after the robot executes it.

  • A Robot uses a Sensor – the sensor can be shared or reassigned across robots.

  • A Referee monitors a Team – neither object owns the other.

Class Diagram

Unidirectional association from Robot to Task

Fig. 3 Unidirectional association from Robot to Task.#

Unidirectional association from Robot to Task

Reading the Diagram

  • This is a unidirectional association: Robot holds a reference to Task via _currentTask. Task has no reference back to Robot.

  • assignTask() sets _currentTask; performTask() uses it. Both methods take a Task parameter but the persistent reference is stored in _currentTask.

  • The cardinality 0..1 on the Robot side means a Task is assigned to at most one Robot, or to none at all.

  • The cardinality 0..* on the Task side means a Robot can have zero or more tasks over its lifetime.

  • Both ends use 0, making the relationship fully optional on both sides. A Task can exist without a Robot, and a Robot can exist with no task currently assigned.

Code Example

class Task:
    def __init__(self, name: str, priority: int):
        self._name = name
        self._priority = priority

class Robot:
    def __init__(self, name: str):
        self._name = name
        self._current_task: Task | None = None

    def assign_task(self, task: Task) -> None:
        self._current_task = task

pick  = Task("pick widget", priority=1)
scout = Robot("Scout")
scout.assign_task(pick)

What is Happening?

  • Robot holds a reference to a Task object created outside and passed in.

  • Task has no reference back to Robot. This is unidirectional.

  • _current_task: Task | None signals that the relationship is optional. A Robot can exist with no task assigned.

  • If pick is deleted, scout still exists. If scout is deleted, pick still exists.

Note

The associated object is passed in as a parameter, not created inside the class. In Python, calling del pick does not destroy the Task object as long as scout._current_task still references it. The garbage collector only destroys an object when its reference count reaches zero.

Aggregation (has-a, independent lifetime)

Aggregation is a “has-a” relationship where the part can exist independently of the whole. The part is created outside the container and passed in. Deleting the container does not destroy the part.

  • Whole: The containing object (e.g., Team).

  • Part: The contained object that can outlive the whole (e.g., Robot).

Physical world examples

  • A Library has Books – the books exist before and after the library closes.

  • A Playlist has Songs – deleting the playlist does not delete the songs.

  • A Department has Employees – employees exist independently of the department.

Robotics Competition examples

  • A Team has Robots – dissolving the team does not destroy the robots.

  • A Arena has Zones – zones can be reassigned to a different arena.

  • A TaskQueue has Tasks – tasks exist before being added to the queue.

Class Diagram

Aggregation from Team to Robot with hollow diamond

Fig. 5 Aggregation from Team to Robot (hollow diamond on Team side).#

Aggregation from Team to Robot with hollow diamond

Reading the Diagram

  • The hollow diamond is on the Team side, indicating Team is the whole and Robot is the part.

  • The cardinality 1..* on the Robot side means a team must have at least one robot.

  • The cardinality 0..1 on the Team side means a robot belongs to no team (e.g., out of commission) or exactly one team.

  • The relationship is bidirectional: Robot holds a _team reference back to its Team to enforce the 0..1 constraint.

  • Dissolving the Team has no effect on the Robot objects. They continue to exist.

Code Example

class Robot:
    def __init__(self, name: str, battery: int = 100):
        self._name = name
        self._battery = battery
        self._team: "Team | None" = None

    @property
    def name(self) -> str:
        return self._name

class Team:
    def __init__(self, team_name: str):
        self._team_name = team_name
        self._robots: list[Robot] = []

    def add_robot(self, robot: Robot) -> None:
        if robot._team is not None:
            raise ValueError(f"{robot.name} already in a team")
        self._robots.append(robot)
        robot._team = self

    def remove_robot(self, robot: Robot) -> None:
        self._robots.remove(robot)
        robot._team = None

scout = Robot("Scout")
alpha = Team("Alpha")
alpha.add_robot(scout)
del alpha
print(scout.name)   # Scout -- still exists

What is Happening?

  • Robot objects are created outside Team and passed in via add_robot().

  • Team holds a collection of robots but does not own them.

  • Robot holds a _team back-reference to enforce the 0..1 constraint. If a robot already belongs to a team, add_robot() raises a ValueError.

  • Deleting alpha does not delete scout. It continues to exist independently.

Note

Parts are created outside the container and passed in. Enforcing 0..1 membership requires a back-reference in Robot, making this a bidirectional aggregation. The hollow diamond in UML signals that the part can outlive the whole.

Composition (has-a, dependent lifetime)

Composition is a strong “has-a” relationship where the part cannot exist independently of the whole. The part is created inside the whole’s __init__ and is owned exclusively by it. Destroying the whole destroys the parts.

  • Whole: The containing object that owns its parts (e.g., Robot).

  • Part: The contained object whose lifetime is tied to the whole (e.g., Sensor).

Physical world examples

  • A House has Rooms – rooms cannot exist without the house they belong to.

  • A Car has an Engine – the engine is built as part of the car.

  • A Human has a Heart – the heart cannot exist independently.

Robotics Competition examples

  • A Robot owns its Sensors – sensors are created with the robot and destroyed with it.

  • A Robot owns its BatteryUnit – the battery is an integral part of the robot.

  • A Arena owns its Obstacles – obstacles are created as part of the arena layout.

Class Diagram

Composition from Robot to Sensor with filled diamond

Fig. 7 Composition from Robot to Sensor (filled diamond on Robot side).#

Composition from Robot to Sensor with filled diamond

Reading the Diagram

  • The filled diamond is on the Robot side, indicating Robot is the whole and Sensor is the part.

  • The cardinality 1..* on the Sensor side means a Robot must have at least one sensor.

  • The cardinality 1 on the Robot side means each Sensor belongs to exactly one Robot – it cannot be shared or reassigned.

  • There is no back-reference from Sensor to Robot. This is unidirectional: the robot knows its sensors; the sensors do not know their robot.

Code Example

class Sensor:
    def __init__(self, sensor_type: str, range_m: float):
        self._sensor_type = sensor_type
        self._range_m = range_m

    def read(self) -> float:
        return self._range_m

    def __repr__(self) -> str:
        return f"Sensor('{self._sensor_type}', {self._range_m})"

class Robot:
    def __init__(self, name: str):
        self._name = name
        # Sensors are created here -- they belong to this robot only
        self._sensors: list[Sensor] = [
            Sensor("lidar", 50.0),
            Sensor("camera", 30.0),
        ]

scout = Robot("Scout")
for s in scout._sensors:
    print(s)
# Sensor('lidar', 50.0)
# Sensor('camera', 30.0)

del scout   # Sensors are destroyed with the robot

What is Happening?

  • Sensor objects are created inside Robot.__init__. They have no existence outside the robot that owns them.

  • Robot holds the only references to its sensors. When the robot is destroyed, the reference count for each sensor drops to zero and the garbage collector reclaims them.

  • There is no way to pass a sensor from one robot to another – the relationship is exclusive by design.

  • This is the strongest form of “has-a”: the part’s lifetime is entirely controlled by the whole.

Note

The key question that distinguishes composition from aggregation is: “Can the part exist without the whole?” If yes, use aggregation (hollow diamond). If no, use composition (filled diamond). The key signal in code: the part is created inside __init__, not passed in as a parameter.

Inheritance#

Defining new classes that extend existing ones.

Refer to L7_inheritance.py to follow along with the examples below.

What Is Inheritance?

Inheritance is an “is-a” relationship. A child class (subclass) inherits all attributes and methods of its parent class (superclass) and can extend or override them. The child class is placed in parentheses after the class name.

  • Parent class (superclass): the class being inherited from.

  • Child class (subclass): the class that inherits and may extend the parent.

Physical world examples

  • A Car is a Vehicle – it inherits wheels, engine, and movement from the vehicle concept but adds car-specific features.

  • A GoldenRetriever is a Dog is an Animal – a multi-level chain.

  • A SavingsAccount is a BankAccount – it inherits deposit/withdraw behavior and adds interest calculation.

Robotics Competition examples

  • A MobileRobot is a Robot – it inherits battery, name, and task logic, and adds navigation speed.

  • A ManipulatorRobot is a Robot – it inherits the same base and adds arm reach and gripping behavior.

  • A ScoutRobot is a MobileRobot – a multi-level specialization.

Code Example

class Parent:
    def greet(self) -> None:
        print("Hello from Parent")

class Child(Parent):     # Child inherits from Parent
    def greet(self) -> None:
        print("Hello from Child")

What is Happening?

  • The child class is placed in parentheses after the class name.

  • Child inherits everything from Parent.

  • Child.greet() overrides Parent.greet().

The UML class diagram below shows the full robot hierarchy for the competition domain.

UML class diagram showing Robot, MobileRobot, and ManipulatorRobot hierarchy

Fig. 9 Inheritance hierarchy: MobileRobot and ManipulatorRobot are specializations of Robot.#

UML class diagram showing Robot, MobileRobot, and ManipulatorRobot hierarchy
Generalization vs. Specialization

There are two complementary ways to arrive at an inheritance hierarchy.

Generalization is the process of identifying common attributes and behaviors across multiple classes and moving them into a shared base class. It is a bottom-up design activity.

Start with Cat, Dog, and Bird defined independently. Each carries its own _name, _age, _weight, and methods such as eat() and sleep().

Cat, Dog, and Bird classes with common attributes

Fig. 11 Step 1: Cat, Dog, and Bird as independent classes sharing common attributes.#

Cat, Dog, and Bird classes with common attributes

The shared attributes and methods are highlighted: _name, _age, _weight, eat(), sleep(), make_sound(), and move() appear in all three classes.

Common attributes highlighted across Cat, Dog, and Bird

Fig. 13 Step 2: Common attributes and methods highlighted across all three classes.#

Common attributes highlighted across Cat, Dog, and Bird

Extract the shared members into a new Animal base class. Each subclass retains only what is unique to it.

Animal base class with Cat, Dog, and Bird as subclasses

Fig. 15 Step 3: Common attributes generalized into the Animal base class (bottom-up approach).#

Animal base class with Cat, Dog, and Bird as subclasses

UML Class Diagram

UML class diagram for the Animal hierarchy

Fig. 17 UML representation of the Animal hierarchy.#

UML class diagram for the Animal hierarchy

Reading the Diagram

  • The hollow-headed arrow points from the child to the parent and means “inherits from.”

  • The parent lists the attributes and methods shared by all subclasses.

  • The child lists only the attributes and methods it adds or overrides.

  • Everything in the parent is implicitly available in the child – it does not need to be repeated.

  • The relationship reads: “Dog is an Animal.”

Python Translation

class Animal:
    def __init__(self, name: str, age: int, weight: float) -> None:
        self._name = name
        self._age = age
        self._weight = weight

    def eat(self) -> None: ...
    def sleep(self) -> None: ...
    def make_sound(self) -> None: ...
    def move(self) -> None: ...

class Dog(Animal):
    def __init__(self, name: str, age: int, weight: float, breed: str) -> None:
        super().__init__(name, age, weight)
        self._breed = breed

    @property
    def breed(self) -> str:
        return self._breed

    def fetch(self) -> None: ...

What Is Happening?

  • class Dog(Animal) expresses the inheritance relationship: Dog is an Animal.

  • Animal defines the attributes and methods shared by all subclasses.

  • Dog declares only _breed and fetch(). Everything else is inherited.

  • super().__init__() delegates _name, _age, and _weight initialization to Animal.

Specialization is the reverse: starting from a general base class and creating derived classes that extend or override its behavior for a specific context. It is a top-down design activity.

What Is Wrong With This Design?

Consider an Animal class that tries to accommodate every possible animal type in a single class:

Bloated Animal class with None-valued attributes

Fig. 19 Design smell: a bloated Animal class carrying None values for attributes that only apply to some subclasses.#

Bloated Animal class with None-valued attributes
  • Does every animal have a wingspan?

  • Does every animal have a breed?

  • What do you set wingspan to for a Cat?

  • None values for inapplicable attributes are a design smell.

  • Adding a new animal type forces changes to a class that should not need to change.

  • The class becomes harder to maintain with every new animal type added.

Note

Specialization is the solution: keep shared attributes in Animal and push type-specific attributes down into dedicated subclasses. Each subclass extends the base with only what makes sense for that type.

After Specialization

Animal base class with Cat, Dog, and Bird specialized subclasses

Fig. 21 Animal specialized into Cat, Dog, and Bird, each extending the parent with only the attributes unique to that type (top-down approach).#

Animal base class with Cat, Dog, and Bird specialized subclasses

What Changed?

  • name, age, weight, eat(), sleep(), make_sound(), and move() live in Animal – every animal has them.

  • breed moves into Dog – only dogs have a breed.

  • wingspan and can_fly move into Bird – only birds have wings.

  • indoor_only moves into Cat – only cats have this attribute.

  • No subclass carries a None value for an attribute that does not apply to it.

Note

Each subclass extends Animal with only what makes it unique. Adding a Fish class tomorrow requires no changes to Dog, Cat, or Bird.

Types of Inheritance

Python supports four inheritance patterns:

Table 11 Types of inheritance in Python#

Type

Description

Example

Single

One child inherits from one parent

Cat(Animal)

Multi-level

A child inherits from a child

Kitten(Cat(Animal))

Multiple

One child inherits from several parents

Liger(Lion, Tiger)

Hierarchical

Several children share one parent

Cat, Dog, and Bird all inherit Animal

  • Single inheritance: one child extends one parent. Simple, predictable, and easy to follow. The recommended starting point.

  • Hierarchical inheritance: multiple children share one parent. Promotes code reuse and a consistent interface across subclasses.

  • Multi-level inheritance: a chain of inheritance across multiple levels. Useful for progressive specialization but deep chains are difficult to read and debug. Prefer shallow hierarchies.

  • Multiple inheritance: one child inherits from several parents. Powerful but introduces complexity around MRO and the diamond problem. Use with caution and prefer composition when possible.

Attribute Initialization with super()

When a child class defines __init__, it must call super().__init__() to ensure parent attributes are initialized. Always call it first.

class Robot:
    """Base class for all competition robots."""

    def __init__(self, name: str, battery: int = 100) -> None:
        self._name = name
        self._battery = battery

    def perform_task(self, task_name: str) -> None:
        if self._battery >= 10:
            print(f"{self._name} performing: {task_name}")
            self._battery -= 10
        else:
            print(f"{self._name} needs recharging!")

    def __repr__(self) -> str:
        return f"Robot(name='{self._name}', battery={self._battery})"

class MobileRobot(Robot):
    """A robot that can navigate -- extends Robot with speed."""

    def __init__(self, name: str, speed: float, battery: int = 100) -> None:
        super().__init__(name, battery)   # Initialize parent attributes first
        self._speed = speed               # Then add child-specific attributes

    def __repr__(self) -> str:
        return (f"MobileRobot(name='{self._name}', "
                f"battery={self._battery}, speed={self._speed})")

class ManipulatorRobot(Robot):
    """A robot with a manipulator arm -- extends Robot with reach."""

    def __init__(self, name: str, reach_m: float, battery: int = 100) -> None:
        super().__init__(name, battery)
        self._reach_m = reach_m

    def grip(self, object_name: str) -> None:
        print(f"{self._name} gripping: {object_name}")

    def __repr__(self) -> str:
        return (f"ManipulatorRobot(name='{self._name}', "
                f"battery={self._battery}, reach_m={self._reach_m})")

scout = MobileRobot("Scout", speed=1.5)
arm   = ManipulatorRobot("Arm-1", reach_m=0.8)

scout.perform_task("navigate to zone B")  # inherited from Robot
arm.perform_task("pick widget")           # inherited from Robot
arm.grip("widget-42")                     # ManipulatorRobot only

print(scout)  # MobileRobot(name='Scout', battery=90, speed=1.5)
print(arm)    # ManipulatorRobot(name='Arm-1', battery=90, reach_m=0.8)

Warning

If you omit super().__init__(), the parent’s __init__ is never called and parent attributes such as _name and _battery will not exist. Any method that accesses them will raise AttributeError.

Method Resolution Order (MRO)

When Python looks up a method or attribute, it follows the Method Resolution Order (MRO): a deterministic chain from the class itself up through its ancestors, computed by the C3 linearization algorithm.

Diagram showing MRO chain for MobileRobot

Fig. 23 MRO for MobileRobot: Python searches left to right along the chain.#

Diagram showing MRO chain for MobileRobot
print(MobileRobot.__mro__)
# (<class 'MobileRobot'>, <class 'Robot'>, <class 'object'>)

How Python resolves a method call:

  1. Look in MobileRobot first.

  2. If not found, look in Robot.

  3. If not found, look in object.

  4. If not found anywhere, raise AttributeError.

super() returns a proxy that routes calls to the next class in the MRO, not necessarily the direct parent. This is what makes super() work correctly in multiple inheritance scenarios.

Note

super() does not return the parent class. It returns a proxy object that knows your position in the MRO and delegates attribute lookups to the next class in the chain.

isinstance() and issubclass()

Two built-in functions let you inspect the class hierarchy at runtime.

scout = MobileRobot("Scout", speed=1.5)
arm   = ManipulatorRobot("Arm-1", reach_m=0.8)

# isinstance: is this object an instance of the given class (or a subclass)?
print(isinstance(scout, MobileRobot))      # True
print(isinstance(scout, Robot))             # True -- Scout is-a Robot
print(isinstance(scout, ManipulatorRobot))  # False

# issubclass: is the first class a subclass of the second?
print(issubclass(MobileRobot, Robot))       # True
print(issubclass(Robot, MobileRobot))       # False -- parent is not a subclass of child

Note

Prefer isinstance() over type(obj) == SomeClass. isinstance() correctly handles the full inheritance chain, while type() only matches the exact class.

Polymorphism#

The same interface, different behavior depending on the object.

Refer to L7_polymorphism.py to follow along with the examples below.

What Is Polymorphism?

Polymorphism (from Greek: poly = many, morphe = form) means that different objects respond to the same method call in their own way. The caller does not need to know the concrete type of the object – only that it supports the required interface.

  • Method overriding: a subclass provides its own implementation of an inherited method.

  • Duck typing: an object is compatible if it has the required methods, regardless of its class hierarchy.

Physical world examples

  • A remote control sends the same “play” signal to a TV, a DVD player, and a streaming device – each responds differently.

  • An on/off switch works on a lamp, a fan, and a heater – the same interface, different behavior.

Robotics Competition examples

  • perform_task("pick widget") on a MobileRobot triggers navigation; on a ManipulatorRobot it triggers arm extension – same call, different behavior.

  • make_sound() on a Cat prints “Meow”; on a Dog prints “Woof” – duck typing requires no shared base class.

Table 12 Duck typing vs. class-based polymorphism#

Duck Typing

Class-based Polymorphism

No shared base class required

Relies on a common base class or interface

Compatible if the method exists

Compatible if the class hierarchy matches

Checked at runtime

Can be checked statically

More flexible, less explicit

More explicit, better tooling support

Duck Typing

Duck typing is the mechanism Python uses to achieve polymorphism. An object is compatible with an interface if it has the required methods, regardless of its type or class hierarchy.

A single function processes a mixed list of Cat and Dog objects. Each class defines make_sound() independently. Since neither inherits it from Animal, this is pure duck typing: Python checks at runtime whether the object has make_sound(), and calls it if it does.

class Animal:
    def __init__(self, name: str):
        self._name = name

class Cat(Animal):
    def make_sound(self) -> None:
        print(f"{self._name} says: Meow")

class Dog(Animal):
    def make_sound(self) -> None:
        print(f"{self._name} says: Woof")

def chorus(animals: list[Animal]) -> None:
    for animal in animals:
        animal.make_sound()   # polymorphic call

chorus([Cat("Kitty"), Dog("Rex")])
# Kitty says: Meow
# Rex says: Woof

What is Happening?

  • Animal does not define make_sound(). Cat and Dog each add it independently.

  • chorus() does not check the type of each object. It simply calls make_sound().

  • Any object with make_sound() works here. This is duck typing.

  • Getting different outputs from the same call on a mixed list is polymorphism in action.

Warning

Nothing stops a developer from forgetting to implement make_sound() in a new subclass. Abstract base classes solve this, as we will see shortly.

Class Diagram

Class diagram showing Cat, Dog, and Bird each independently defining make_sound()

Fig. 25 Each subclass independently defines make_sound().#

Class diagram showing Cat, Dog, and Bird each independently defining make_sound()

Reading the Diagram

  • Animal does not declare make_sound(). It only defines shared attributes and eat() and sleep().

  • Cat, Dog, and Bird each add make_sound() independently, with no contract from the parent.

  • This is duck typing: the method exists on each subclass by convention, not enforcement.

  • eat() and sleep() are defined in Animal and inherited as-is by all subclasses.

  • Method overriding with enforcement will be introduced with abstract base classes in the next section.

Polymorphism via Method Overriding

When child classes override a parent method, calling the same method on different subclass instances produces different behavior.

class Robot:
    def __init__(self, name: str) -> None:
        self._name = name

    def perform_task(self, task_name: str) -> None:
        print(f"{self._name} performing: {task_name}")

class MobileRobot(Robot):
    def __init__(self, name: str, speed: float) -> None:
        super().__init__(name)
        self._speed = speed

    def perform_task(self, task_name: str) -> None:
        print(f"{self._name} navigating at {self._speed} m/s")
        super().perform_task(task_name)

class ManipulatorRobot(Robot):
    def __init__(self, name: str, reach_m: float) -> None:
        super().__init__(name)
        self._reach_m = reach_m

    def perform_task(self, task_name: str) -> None:
        print(f"{self._name} extending arm to {self._reach_m} m")
        super().perform_task(task_name)

robots: list[Robot] = [
    MobileRobot("Scout", speed=1.5),
    ManipulatorRobot("Arm-1", reach_m=0.8),
    Robot("Base"),
]

for robot in robots:
    robot.perform_task("pick widget")

# Scout navigating at 1.5 m/s
# Scout performing: pick widget
# Arm-1 extending arm to 0.8 m
# Arm-1 performing: pick widget
# Base performing: pick widget

The same call robot.perform_task("pick widget") produces different output depending on which subclass robot refers to at runtime.

Built-in Polymorphism

Python’s built-in functions achieve polymorphism through dunder methods. The same function call works on many types because each type implements the corresponding dunder method.

# len() calls __len__ on whatever object it receives
print(len("hello"))          # 5    (str.__len__)
print(len([1, 2, 3]))        # 3    (list.__len__)
print(len({"a": 1}))         # 1    (dict.__len__)

# str() calls __str__ on whatever object it receives
print(str(42))               # '42'
print(str(3.14))             # '3.14'
print(str(True))             # 'True'

# + calls __add__ on whatever object it receives
print(1 + 2)                 # 3
print("hello" + " world")    # hello world
print([1, 2] + [3, 4])       # [1, 2, 3, 4]

Every time you call len(), str(), or +, you are relying on polymorphism. Python’s built-in functions work with any object that implements the corresponding dunder method.

Operator Overriding

Operator overriding is a form of polymorphism. Every class inherits default dunder methods from object (such as __eq__, __add__, __lt__). Providing your own implementation overrides the inherited version, giving the operator a meaning specific to your class.

class Animal:
    def __init__(self, name: str, age: int, weight: float):
        self._name = name
        self._age = age
        self._weight = weight

    def __repr__(self) -> str:
        return (f"Animal(name={self._name!r}, age={self._age}, "
                f"weight={self._weight} kg)")

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Animal):
            return NotImplemented
        return self._name == other._name and self._age == other._age

    def __add__(self, other: "Animal") -> float:
        return self._weight + other._weight   # combined weight

kitty = Animal("Kitty", age=3, weight=4.2)
rex   = Animal("Rex",   age=5, weight=30.0)

print(kitty == rex)   # False
print(kitty + rex)    # 34.2  (combined weight)

Abstract Base Classes#

Defining interfaces that subclasses are required to implement.

Refer to L7_abstract_classes.py to follow along with the examples below.

What Is an Abstract Class?

An abstract class (circled A in UML, class name in italics) is a class that cannot be instantiated directly. It is designed to be subclassed and defines a set of methods that must be implemented by any concrete subclass (circled C in UML). This enforces a consistent interface across all subclasses.

  • Abstract method: declared but not implemented; subclasses must override it.

  • Concrete method: fully implemented; subclasses inherit it as-is.

Physical world examples

  • A Shape is abstract: it declares area() and perimeter() but cannot define them without knowing the actual shape. Circle and Rectangle are concrete.

  • A Vehicle is abstract: it declares move() but the implementation differs between a Car, a Boat, and a Plane.

Robotics Competition examples

  • Robot is abstract: it declares move() but the implementation differs between a MobileRobot and a ManipulatorRobot.

  • Attempting to instantiate Robot directly raises TypeError at instantiation time, catching the omission as early as possible.

The UML diagram below shows the notation for abstract and concrete classes.

UML notation for abstract and concrete classes

Fig. 27 UML notation: Animal is an abstract base class. Abstract class name appears in italics with a circled A; concrete subclasses carry a circled C. Abstract methods are also italicized.#

UML notation for abstract and concrete classes
The abc Module and @abstractmethod

Import ABC and abstractmethod from abc. Inheriting from ABC marks the class as abstract, but on its own it does not prevent instantiation and does not enforce any interface. TypeError is only raised when at least one @abstractmethod is declared and a subclass fails to implement it.

from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name: str):
        self._name = name

    @abstractmethod
    def make_sound(self) -> None: ...

    @abstractmethod
    def move(self) -> None: ...

class Cat(Animal):
    def make_sound(self) -> None:
        print(f"{self._name} says: Meow")

    def move(self) -> None:
        print(f"{self._name} walks gracefully")

# OK: all abstract methods implemented
kitty = Cat("Kitty")
kitty.make_sound()   # Kitty says: Meow

Note

@abstractmethod declares that a method must be overridden in every concrete subclass. A subclass that omits any abstract method cannot be instantiated – Python raises TypeError at instantiation time, not at the point where the missing method would be called. An abstract method can have a body (callable via super().make_sound()), but an empty body is the norm. ABC alone does not prevent instantiation; it is @abstractmethod that enforces the contract. ABC sets ABCMeta as the metaclass; class Animal(metaclass=ABCMeta) is equivalent but class Animal(ABC) is the preferred style.

Implementing an Abstract Class

A concrete class inherits from the abstract base and implements all abstract methods. If any abstract method is missing, Python raises TypeError at instantiation time – not at the point where the missing method is called.

UML Diagram

Abstract Animal with concrete Cat, Dog, and Bird subclasses

Fig. 29 Abstract Animal with concrete subclasses Cat, Dog, and Bird.#

Abstract Animal with concrete Cat, Dog, and Bird subclasses

Reading the Diagram

  • Animal carries a circled A and its name appears in italics, indicating it is abstract and cannot be instantiated.

  • make_sound() and move() appear in italics inside Animal, indicating they are abstract and must be overridden.

  • Cat, Dog, and Bird carry a circled C, indicating they are concrete and can be instantiated.

  • Each subclass provides its own implementation of make_sound() and move().

  • eat() and sleep() are concrete in Animal and inherited as-is by all subclasses.

Concrete and Abstract Methods Together

An abstract class can mix abstract and concrete methods. Concrete methods provide shared behavior inherited by all subclasses. Abstract methods enforce the interface each subclass must implement.

from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name: str, age: int):
        self._name = name
        self._age = age

    @abstractmethod
    def make_sound(self) -> None: ...

    @abstractmethod
    def move(self) -> None: ...

    # concrete: shared by all
    def eat(self) -> None:
        print(f"{self._name} is eating")

    # concrete: shared by all
    def __repr__(self) -> str:
        return (f"{type(self).__name__}"
                f"(name={self._name!r}, age={self._age})")

class Cat(Animal):
    def make_sound(self) -> None:
        print(f"{self._name} says: Meow")

    def move(self) -> None:
        print(f"{self._name} walks gracefully")

if __name__ == '__main__':
    kitty = Cat("Kitty", age=3)
    kitty.eat()         # inherited from Animal
    kitty.make_sound()  # overridden in Cat
    kitty.move()        # overridden in Cat
    print(kitty)        # Cat(name='Kitty', age=3)

ABCs, Polymorphism, and Method Overriding

In the duck typing section, Cat and Dog defined make_sound() independently with no contract from Animal. Nothing prevented a developer from forgetting to implement it in a new subclass. ABCs solve this.

from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name: str):
        self._name = name

    @abstractmethod
    def make_sound(self) -> None: ...  # contract: every subclass must override this

class Cat(Animal):
    def make_sound(self) -> None:      # overriding the abstract method
        print(f"{self._name} says: Meow")

class Dog(Animal):
    def make_sound(self) -> None:      # overriding the abstract method
        print(f"{self._name} says: Woof")

What Happens When You Forget

If a subclass does not implement all abstract methods, Python raises TypeError at instantiation time, not at the point where the missing method is called.

class Dog(Animal):
    def move(self) -> None:
        print(f"{self._name} runs")
    # make_sound() is NOT implemented -- forgot!

kitty = Cat("Kitty")   # OK
rex   = Dog("Rex")     # TypeError: Can't instantiate abstract class Dog
                       # without an implementation for abstract method 'make_sound'

Note

ABCs, method overriding, and polymorphism work together. The ABC defines what must exist. The subclass defines how it behaves. The polymorphic function uses the interface without knowing the concrete type.

Data Classes (FYI)#

Reducing boilerplate for data-centric classes.

Refer to L7_dataclasses.py to follow along with the examples below.

What Is a Data Class?

A data class is a regular Python class decorated with @dataclass. Python automatically generates __init__, __repr__, and __eq__ from the class’s type-annotated fields, eliminating repetitive boilerplate.

  • The decorator inspects the class body for type-annotated fields.

  • Generated methods are equivalent to what you would write by hand.

  • Additional dunder methods (__hash__, __lt__, etc.) can be enabled through decorator arguments.

Physical world examples

  • A Point(x: float, y: float) – two fields, needs __init__ and __repr__ but no behavior.

  • A Color(r: int, g: int, b: int) – pure data, equality comparison useful.

  • A Config(debug: bool, max_retries: int, timeout: float) – settings bundle.

Robotics Competition examples

  • A Pose(x: float, y: float, heading: float) – robot position, no behavior.

  • A SensorReading(sensor_id: str, value: float, timestamp: float) – logged data point.

  • A TaskResult(task_name: str, success: bool, duration_s: float) – result record.

Code Example

from dataclasses import dataclass

@dataclass
class Animal:
    name: str
    age: int
    weight: float

kitty = Animal("Kitty", 3, 4.2)
print(kitty)            # Animal(name='Kitty', age=3, weight=4.2)
print(kitty.name)       # Kitty

The @dataclass decorator is equivalent to writing:

class Animal:
    def __init__(self, name: str, age: int, weight: float) -> None:
        self.name = name
        self.age = age
        self.weight = weight

    def __repr__(self) -> str:
        return f"Animal(name={self.name!r}, age={self.age!r}, weight={self.weight!r})"

    def __eq__(self, other: object) -> bool:
        if isinstance(other, Animal):
            return (self.name, self.age, self.weight) == (other.name, other.age, other.weight)
        return NotImplemented

What is Happening?

  • @dataclass reads the type-annotated class-body fields in declaration order and builds __init__ with matching parameters.

  • __repr__ is generated to list all fields by name, making instances easy to inspect in the REPL and in logs.

  • __eq__ compares instances field-by-field, which is the natural equality semantics for data-centric classes.

Note

When to use ``@dataclass``: Use it for classes whose primary purpose is storing data with little or no behavior. For classes with significant logic, encapsulation requirements, or complex initialization, a regular class is usually clearer.

field() and Default Factories

Use field() from the dataclasses module when a field needs a mutable default, should be excluded from __repr__, or requires special initialization.

from dataclasses import dataclass, field

@dataclass
class Animal:
    name: str
    age: int
    weight: float
    nicknames: list[str] = field(
        default_factory=list
    )
    _id: int = field(
        default=0,
        repr=False,
        compare=False
    )

kitty = Animal("Kitty", 3, 4.2)
kitty.nicknames.append("Kit")
print(kitty)
# Animal(name='Kitty', age=3,
#        weight=4.2, nicknames=['Kit'])

rex = Animal("Rex", 5, 30.0)
print(rex.nicknames)   # []  (independent list)

Why ``field()``?

  • Mutable defaults like list or dict cannot be written as nicknames: list = []. Python would share the same list across all instances.

  • field(default_factory=list) creates a fresh list for each new instance.

Useful ``field()`` parameters:

  • default: a fixed default value (for immutable types).

  • default_factory: a callable that produces the default (for mutable types).

  • repr=False: exclude the field from __repr__.

  • compare=False: exclude the field from __eq__ comparisons.

  • init=False: exclude the field from __init__ entirely.

Warning

Never use a mutable object (list, dict, set) directly as a default value in a @dataclass. Use field(default_factory=...) instead.

__post_init__: Validation and Derived Attributes

__post_init__ is called automatically by the generated __init__ after all fields have been assigned. It is the correct place to validate field values or compute derived attributes.

Validation

from dataclasses import dataclass

@dataclass
class Animal:
    name: str
    age: int
    weight: float

    def __post_init__(self):
        if self.age < 0:
            raise ValueError(
                f"age cannot be negative: {self.age}"
            )
        if self.weight <= 0:
            raise ValueError(
                f"weight must be positive: {self.weight}"
            )

kitty = Animal("Kitty", age=3, weight=4.2)  # OK
bad   = Animal("Bad",   age=-1, weight=4.2)  # ValueError

Without __post_init__, nothing stops a caller from creating Animal("Kitty", age=-1, weight=-5.0). Validation here catches the error at construction time.

Derived Attributes

A derived attribute is computed from other fields rather than passed by the caller. Declare it with field(init=False) to exclude it from __init__, then assign it inside __post_init__.

from dataclasses import dataclass, field

@dataclass
class Animal:
    name: str
    age: int
    life_stage: str = field(init=False)

    def __post_init__(self):
        if self.age < 1:
            self.life_stage = "infant"
        elif self.age < 7:
            self.life_stage = "adult"
        else:
            self.life_stage = "senior"

kitty = Animal("Kitty", age=3)
print(kitty)
# Animal(name='Kitty', age=3, life_stage='adult')

life_stage is never passed by the caller. It is computed automatically from age every time an Animal is created.

Frozen Data Classes

Setting frozen=True makes the data class immutable after creation. Python generates __hash__, making instances usable as dictionary keys or set members.

from dataclasses import dataclass

@dataclass(frozen=True)
class Animal:
    name: str
    age: int
    weight: float

kitty = Animal("Kitty", 3, 4.2)
print(kitty)
# Animal(name='Kitty', age=3, weight=4.2)

kitty.age = 4
# FrozenInstanceError: cannot assign to field 'age'

# Frozen instances are hashable
animal_set = {kitty, Animal("Rex", 5, 30.0)}
lookup = {kitty: "indoor cat"}
print(lookup[kitty])   # indoor cat

What does frozen=True do?

  • Prevents any field from being modified after creation.

  • Any attempt to assign to a field raises FrozenInstanceError.

  • Automatically generates __hash__, making the instance usable as a dictionary key or set member.

When to use frozen data classes:

  • Records that should never change after creation (sensor readings, event logs, configuration snapshots).

  • Objects used as dictionary keys or stored in sets.

  • Anywhere immutability is a design requirement.

Note

A regular @dataclass sets __hash__ to None by default (making it unhashable) because mutable objects should not be hashed. frozen=True restores hashability safely.

Table 13 Data class vs. regular class decision guide#

Situation

Use @dataclass

Use regular class

Primary purpose

Storing data fields

Complex behavior and logic

Attribute access

Direct field access

Validated via @property

Immutability

frozen=True

@property with no setter

Inheritance

Simple hierarchies

Deep or complex hierarchies

Encapsulation

Not a priority

Central design concern

Animal Domain Example

  • Use @dataclass for AnimalRecord (name, species, date of birth, weight at intake): pure data, no behavior.

  • Use a regular class for Animal with @property for weight (validated, cannot be negative) and methods like make_sound() and move().

  • A frozen data class suits an ObservationLog entry: timestamp, observer name, species, notes – write once, never modify.

__slots__ (FYI)#

Restricting attributes and reducing memory overhead.

Refer to L7_slots.py to follow along with the examples below.

What Is __slots__?

__slots__ is a class-level declaration that replaces the per-instance __dict__ with a fixed, compact array of named attribute slots. The result is lower memory use and faster attribute access at the cost of no longer allowing dynamic attribute assignment.

  • Without __slots__: each instance carries a __dict__ (~232 bytes).

  • With __slots__: attributes are stored in a fixed array (~48 bytes).

Physical world examples

  • A form with fixed fields vs. a blank notepad – a form only allows the declared fields; a notepad lets you write anything anywhere.

  • A database row with a fixed schema vs. a Python dict.

Robotics Competition examples

  • A Pose object created millions of times in a trajectory planner – the memory savings from __slots__ are significant at that scale.

  • A SensorReading logged thousands of times per second – compact storage reduces GC pressure.

Code Example

class Pose:
    """Regular class -- has __dict__."""

    def __init__(self, x: float, y: float, heading: float) -> None:
        self._x = x
        self._y = y
        self._heading = heading

class PoseSlotted:
    """Slotted class -- no __dict__."""

    __slots__ = ("_x", "_y", "_heading")

    def __init__(self, x: float, y: float, heading: float) -> None:
        self._x = x
        self._y = y
        self._heading = heading
import sys

p  = Pose(1.0, 2.0, 0.5)
ps = PoseSlotted(1.0, 2.0, 0.5)

print(sys.getsizeof(p)  + sys.getsizeof(p.__dict__))   # ~280 bytes
print(sys.getsizeof(ps))                                # ~56 bytes

What is Happening?

  • Pose stores attributes in __dict__, which is a full Python dictionary with hash table overhead.

  • PoseSlotted stores attributes directly in a fixed C-level array – no hash table, no per-instance overhead beyond the slot values themselves.

  • Both classes behave identically for attribute reads and writes; the difference is invisible to callers.

Note

The exact byte counts vary by Python version and platform. The important result is that the slotted version uses significantly less memory.

Restrictions and Limitations
  • No dynamic attributes. Attempting to assign an attribute not listed in __slots__ raises AttributeError.

ps = PoseSlotted(1.0, 2.0, 0.5)
ps._extra = "dynamic"   # AttributeError: 'PoseSlotted' object has no attribute '_extra'
  • Some serialization tools may break. Libraries that rely on __dict__ (e.g., pickle in certain modes, some ORMs) may not work with slotted classes without extra configuration.

  • Use sparingly. __slots__ is an optimization. Reach for it only when profiling shows that __dict__ memory is a genuine bottleneck – typically when creating tens of thousands of instances.

__slots__ with Inheritance

Each class in a hierarchy should declare only the new slots it introduces. Python merges __slots__ from all classes in the chain automatically.

class PoseSlotted:
    __slots__ = ("_x", "_y", "_heading")

    def __init__(self, x: float, y: float, heading: float) -> None:
        self._x = x
        self._y = y
        self._heading = heading

class StampedPose(PoseSlotted):
    __slots__ = ("_timestamp",)   # Only declare the NEW attribute

    def __init__(self, x: float, y: float, heading: float, timestamp: float) -> None:
        super().__init__(x, y, heading)
        self._timestamp = timestamp

sp = StampedPose(1.0, 2.0, 0.5, timestamp=1712345678.0)
print(sp._x, sp._y, sp._heading, sp._timestamp)
# 1.0 2.0 0.5 1712345678.0

Warning

Do not redeclare a parent’s slot in the child. Doing so wastes memory and can cause subtle bugs because Python creates two slots for the same name.

Protocols (FYI)#

Structural subtyping without inheritance.

Refer to L7_protocols.py to follow along with the examples below.

What Is a Protocol?

A typing.Protocol defines an interface through structural subtyping: a class satisfies the Protocol simply by having the required methods and attributes, without needing to inherit from it. This is sometimes called “static duck typing” – the flexibility of duck typing with the explicitness of type annotations.

  • Nominal typing (ABCs): compatibility declared explicitly via inheritance.

  • Structural typing (Protocols): compatibility determined by structure alone.

Physical world examples

  • A USB port accepts any device that fits the connector and speaks the protocol – no registration required.

  • A power outlet accepts any plug of the right shape – the appliance does not need to be certified by the outlet manufacturer.

Robotics Competition examples

  • An Executable Protocol requires execute(robot_name) -> bool. Both PickTask and DeliverTask satisfy it without inheriting from anything.

  • A Loggable Protocol requires log_status() -> str. Any class that has that method qualifies – no shared base class needed.

Table 14 ABCs (nominal typing) vs. Protocols (structural typing)#

Aspect

ABC (Nominal)

Protocol (Structural)

Declaration

Must inherit from the ABC

No inheritance required

Enforcement

At instantiation time

At type-check time (mypy)

Runtime check

isinstance() always works

Needs @runtime_checkable

Flexibility

Tight coupling

Loose coupling

Best for

Shared base behavior

Independent implementations

Note

typing.Protocol was introduced in Python 3.8 (PEP 544). It is the preferred way to express interfaces in modern Python when you do not want to require explicit inheritance.

Defining a Protocol

Inherit from typing.Protocol. Add @runtime_checkable if you need to use isinstance() checks at runtime.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Executable(Protocol):
    """Any object that can be dispatched to a robot."""

    def execute(self, robot_name: str) -> bool:
        ...
Implementing a Protocol (Without Inheritance)

Neither PickTask nor DeliverTask inherits from Executable. They satisfy the protocol simply by having the required method.

class PickTask:
    """Satisfies Executable without inheriting from it."""

    def execute(self, robot_name: str) -> bool:
        print(f"{robot_name} picks an object")
        return True

class DeliverTask:
    """Also satisfies Executable -- no shared base with PickTask."""

    def execute(self, robot_name: str) -> bool:
        print(f"{robot_name} delivers to destination")
        return True

class SleepTask:
    """Does not satisfy Executable -- wrong method name."""

    def sleep(self, duration: float) -> None:
        print(f"sleeping for {duration}s")

def dispatch(task: Executable, robot_name: str) -> bool:
    """Accept any Executable -- structural typing in action."""
    return task.execute(robot_name)

pick    = PickTask()
deliver = DeliverTask()

dispatch(pick,    "Scout")
dispatch(deliver, "Arm-1")

Output:

Scout picks an object
Arm-1 delivers to destination
@runtime_checkable and isinstance()

With @runtime_checkable, isinstance() checks whether an object has the required methods. It does not verify method signatures.

pick    = PickTask()
deliver = DeliverTask()
sleep   = SleepTask()

print(isinstance(pick,    Executable))   # True
print(isinstance(deliver, Executable))   # True
print(isinstance(sleep,   Executable))   # False

Warning

@runtime_checkable only checks for the presence of methods, not their signatures. For full type safety, use a static type checker such as mypy or pyright in addition to runtime checks.

ABCs vs. Protocols – Decision Guide

Choose an ABC when…

Choose a Protocol when…

Subclasses share concrete behavior (inherited methods with a body)

Unrelated classes satisfy the same interface

You want enforcement at instantiation time (TypeError)

You prefer loose coupling across module or library boundaries

You control all implementing classes

You do not control the implementing classes

Nominal typing fits your design

Structural typing (“duck typing with annotations”) fits better