Created by TrWaiLinHtet · Unit 3: Object-Oriented Programming

Object-Oriented Programming in Python

Authored and structured by TrWaiLinHtet. Aligned with OSSD ICS4U (Ontario Grade 12 Computer Science) and Cambridge AS/A Level Computer Science (9618) frameworks.

01 · Foundations

What is object-oriented programming?

OOP organises a program around objects — bundles of data (attributes) and behaviour (methods) — instead of a long list of separate functions and variables. A class is a blueprint that defines what attributes and methods its objects will have. An object (instance) is a concrete instance built from that blueprint, holding its own values in memory.

CLASS (blueprint) Dog name, age bark() instantiate dog1 name="Buddy" age=3 dog2 name="Milo", age=5 Each object has its own data, but shares the class's methods.
Fig. 1 — A class is a blueprint; objects are the instances made from it.

OOP rests on four core pillars: encapsulation (bundle data and methods, control access), inheritance (share and extend behaviour through a hierarchy), polymorphism (the same interface behaves differently across object types), and abstraction (hide implementation detail, expose interfaces).

02 · Foundations

Classes & objects

Attributes in Python are public by default and accessed using the dot operator. Access restriction is handled by naming conventions (_protected, __private) and property setters.

Defining a class

class Dog:
    species = "Canine"          # class attribute — shared by every instance

    def __init__(self, name, age):
        self.name = name        # instance attribute — unique per object
        self.age = age          # instance attribute


dog1 = Dog("Buddy", 3)
print(dog1.name)
print(dog1.species)
Output
Buddy
Canine

Class attributes vs instance attributes

A class attribute belongs to the class, not to any single instance. This is useful for constants, but requires caution with mutable containers.

class Basket:
    items = []              # class attribute — ONE list shared by all baskets

    def add(self, item):
        self.items.append(item)

b1, b2 = Basket(), Basket()
b1.add("apple")
print(b2.items)   # ['apple'] — b2 sees b1's change because the list is shared.
Important design pattern

Always initialize mutable containers (lists, dictionaries) inside __init__ as instance attributes — self.items = [] — unless sharing state across all instances is explicitly intended.

03 · Foundations

Constructors & self

__init__ runs automatically right after an object is created to set up its starting attribute values. Behind the scenes, __new__ creates the instance, then __init__ initialises it.

Feature__new____init__
PurposeCreates the objectInitialises the object
RunsBefore creation finishesAfter the object exists
ReturnsAn object instanceNothing (None)
Typical useSingletons, immutable typesSetting initial state

Default vs parameterised constructors

class Car:
    def __init__(self, make="Toyota", model="Corolla", year=2020):
        self.make = make
        self.model = model
        self.year = year

car = Car()
print(car.make, car.model, car.year)   # Toyota Corolla 2020

car2 = Car("Honda", "Civic", 2023)
print(car2.make, car2.model, car2.year)   # Honda Civic 2023

What self actually is

self is not a reserved keyword in Python; it is the conventional name for a method's first parameter, representing the instance invoking the method.

class Car:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

    def display(self):
        return self.brand, self.model

car1 = Car("Toyota", "Corolla")

# These two calls are equivalent:
print(car1.display())          # Instance call syntax
print(Car.display(car1))        # Explicit class-level call passing instance
Output
('Toyota', 'Corolla')
('Toyota', 'Corolla')
04 · Foundations

Default arguments in initialisation

Parameters in __init__ can define default values, allowing objects to be instantiated with optional arguments.

class Player:
    def __init__(self, name="Unknown", score=0):
        self.name = name
        self.score = score

p1 = Player()                 # Uses defaults
p2 = Player("Aye")            # Overrides name
p3 = Player("Zaw", 15)        # Overrides both

for p in (p1, p2, p3):
    print(p.name, p.score)
Output
Unknown 0
Aye 0
Zaw 15
05 · Four pillars

Encapsulation

Encapsulation bundles data and the methods that mutate it into a single unit, controlling access to protect integrity.

private protected public Public self.name — accessible from anywhere Protected self._age — convention: internal to class & subclasses Private self.__salary — name-mangled to prevent accidental access
Fig. 2 — Python's access levels are established conventions rather than hardware memory locks.

Access levels & name mangling

class Employee:
    def __init__(self, name, age, salary):
        self.name = name          # Public
        self._age = age           # Protected (convention)
        self.__salary = salary    # Private (mangled to _Employee__salary)

    def show_salary(self):
        print("Salary:", self.__salary)

emp = Employee("Robert", 34, 60000)
print(emp.name)               # Accessible
print(emp._age)               # Accessible (conventionally avoided externally)
emp.show_salary()             # Accessible via public interface
# emp.__salary                # Raises AttributeError

Using @property for managed attributes

Python provides the @property decorator to define getters, setters, and deleters, keeping interface syntax clean while providing input validation.

class Account:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = 0
        self.balance = balance            # Routed through setter validation

    @property
    def balance(self):
        return self.__balance

    @balance.setter
    def balance(self, v):
        if v < 0:
            raise ValueError("Balance cannot be negative")
        self.__balance = v

acc = Account("Aye", 500)
acc.balance = 800
print(acc.balance)   # 800
06 · Four pillars

Inheritance

Inheritance allows a subclass to reuse and extend code from a superclass. super() invokes methods defined on the parent class.

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

    def speak(self):
        raise NotImplementedError("Subclasses must implement speak()")

    def get_info(self):
        return f"{self.name} is {self.age} years old"


class Dog(Animal):
    def __init__(self, name, age, breed):
        super().__init__(name, age)
        self.breed = breed

    def speak(self):
        return "Woof"


class Cat(Animal):
    def speak(self):
        return "Meow"


d = Dog("Buddy", 3, "Labrador")
c = Cat("Kitty", 2)

print(d.get_info())
print(c.get_info())
print(d.speak(), c.speak())
Output
Buddy is 3 years old
Kitty is 2 years old
Woof Meow

Method Resolution Order (MRO)

In complex hierarchies or multiple inheritance, Python resolves attribute lookups through the C3 linearization algorithm, exposed via __mro__.

class Computer:
    def __init__(self, cpu, ram_gb):
        self.cpu = cpu
        self.ram_gb = ram_gb

    def specs(self):
        return f"CPU: {self.cpu}, RAM: {self.ram_gb}GB"


class Laptop(Computer):
    def __init__(self, cpu, ram_gb, battery_mah):
        super().__init__(cpu, ram_gb)
        self.battery_mah = battery_mah

    def specs(self):
        return f"{super().specs()}, Battery: {self.battery_mah}mAh"


class MacBook(Laptop):
    def __init__(self, model_name, chip, ram_gb, battery_mah):
        super().__init__(cpu=chip, ram_gb=ram_gb, battery_mah=battery_mah)
        self.model_name = model_name

print([c.__name__ for c in MacBook.__mro__])
Output
['MacBook', 'Laptop', 'Computer', 'object']

Inheritance vs composition

Inheritance represents an is-a relationship (a Dog is an Animal). If the relationship represents has-a (a Bank has accounts), composition should be used by storing objects inside instance attributes.

07 · Four pillars

Polymorphism

Polymorphism allows different classes to expose the same interface while providing different underlying implementations.

Method overriding & flexible signatures

class Animal:
    def sound(self):
        return "Generic sound"

class Dog(Animal):
    def sound(self):
        return "Bark"

class Cat(Animal):
    def sound(self):
        return "Meow"

animals = [Dog(), Cat(), Animal()]
for animal in animals:
    print(animal.sound())
Output
Bark
Meow
Generic sound

Operator overloading via dunder methods

Python allows user-defined classes to overload arithmetic and comparison operators by implementing special double-underscore methods.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

print(Point(1, 2) + Point(3, 4))   # Point(4, 6)
08 · Four pillars

Abstraction

Abstraction enforces contract specifications across subclasses using the abc (Abstract Base Classes) module.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, w, h):
        self.w, self.h = w, h

    def area(self):
        return self.w * self.h

rect = Rectangle(4, 5)
print("Area:", rect.area())
09 · Reference

Dunder method reference

MethodTriggered byPurpose
__init__ClassName(...)Initialises instance state
__str__print(obj), str(obj)User-facing readable string
__repr__Interactive shell, repr(obj)Unambiguous code representation
__eq__a == bValue equality check
__len__len(obj)Returns object length
__add__a + bDefines behavior for +
__iter__, __next__for loops, next()Iterator protocol
10 · Advanced Python

Iterators

Custom iterables implement __iter__ (returning the iterator itself) and __next__ (returning elements sequentially until raising StopIteration).

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        val = self.current
        self.current -= 1
        return val

print(list(Countdown(3)))   # [3, 2, 1]
11 · Project

Project: banking system

A comprehensive implementation demonstrating abstraction, inheritance, encapsulation, and composition.

from abc import ABC, abstractmethod

class InsufficientFunds(Exception):
    pass

class Account(ABC):
    _next_id = 1000

    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance
        self.id = Account._next_id
        Account._next_id += 1

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self._available():
            raise InsufficientFunds(f"Cannot withdraw {amount}")
        self._balance -= amount

    @abstractmethod
    def _available(self):
        pass

    def __str__(self):
        return f"{type(self).__name__} {self.id} ({self.owner}): ${self._balance:.2f}"


class Savings(Account):
    def __init__(self, owner, balance=0, rate=0.02):
        super().__init__(owner, balance)
        self.rate = rate

    def _available(self):
        return self._balance

    def add_interest(self):
        self._balance += self._balance * self.rate


class Current(Account):
    def __init__(self, owner, balance=0, overdraft=500):
        super().__init__(owner, balance)
        self.overdraft = overdraft

    def _available(self):
        return self._balance + self.overdraft


class Bank:
    def __init__(self):
        self._accounts = {}

    def open(self, acc):
        self._accounts[acc.id] = acc
        return acc.id

    def transfer(self, src_id, dst_id, amount):
        self._accounts[src_id].withdraw(amount)
        self._accounts[dst_id].deposit(amount)


bank = Bank()
sid = bank.open(Savings("Aye", 1000))
cid = bank.open(Current("Zaw", 500))

bank.transfer(sid, cid, 250)
print(bank._accounts[sid])
print(bank._accounts[cid])
Output
Savings 1000 (Aye): $750.00
Current 1001 (Zaw): $750.00
12 · Curriculum

OSSD ICS4U track

ICS4U focuses on software architecture and design justification alongside syntax.

Design focus

UML before implementation

Diagram class definitions, properties, access modifiers, and associations prior to writing Python modules.

Design focus

Pillar justification

Articulate why a design uses composition instead of inheritance to model relationships cleanly.

13 · Curriculum

Cambridge AS/A Level (9618) track

Cambridge 9618 assesses OOP primarily through structured pseudocode with explicit visibility specifiers.

PythonCambridge 9618 pseudocode
class Pet:
    def __init__(self, name):
        self.name = name
CLASS Pet
  PRIVATE Name : STRING
  PUBLIC PROCEDURE NEW(GivenName : STRING)
    Name ← GivenName
  ENDPROCEDURE
ENDCLASS
14 · Pedagogy

Instructional sequence

  1. Classes & Objects: Blueprints vs. instances
  2. Attributes, methods, and __init__
  3. String representation: __str__ and __repr__
  4. Encapsulation & properties
  5. Inheritance hierarchies & super()
  6. Polymorphism and duck typing
  7. Abstraction via interfaces and ABCs
  8. System integration via composition
15 · Assessment

Python OOP Mastery Quiz

Test your understanding of core and advanced OOP mechanics. Click "Show Answer & Explanation" under each question to verify your reasoning.

Question 1: Class vs. Instance Attributes
class Dog:
    tricks = []

    def __init__(self, name):
        self.name = name

    def add_trick(self, trick):
        self.tricks.append(trick)

d1 = Dog("Fido")
d2 = Dog("Buddy")
d1.add_trick("roll over")

print(d2.tricks)
  • A) []
  • B) ['roll over']
  • C) AttributeError: 'Dog' object has no trick
  • D) None
Show Answer & Explanation
Correct Answer: B tricks is defined at the class scope, making it a class attribute shared across all instances of Dog. Modifying the mutable list via d1 mutates the single shared list object referenced by both d1 and d2. To give each dog its own list, initialize it inside __init__ as self.tricks = [].
Question 2: Name Mangling and Private Attributes
class Account:
    def __init__(self):
        self.__balance = 100

acc = Account()
print(acc._Account__balance)
  • A) 100
  • B) AttributeError: 'Account' object has no attribute '_Account__balance'
  • C) AttributeError: Private variable cannot be accessed outside class
  • D) None
Show Answer & Explanation
Correct Answer: A Python does not enforce true private memory boundaries. Identifiers with two leading underscores (and at most one trailing underscore), like __balance, undergo name mangling: the interpreter renames them to _ClassName__attributeName. Thus, acc._Account__balance accesses the value directly.
Question 3: Method Resolution Order (MRO) with Diamond Inheritance
class A:
    def greet(self):
        return "A"

class B(A):
    def greet(self):
        return "B"

class C(A):
    def greet(self):
        return "C"

class D(B, C):
    pass

obj = D()
print(obj.greet())
  • A) A
  • B) B
  • C) C
  • D) TypeError: Cannot create a consistent method resolution order
Show Answer & Explanation
Correct Answer: B Python uses the C3 Linearization algorithm to resolve method calls. For class D(B, C), the MRO is D -> B -> C -> A -> object. When obj.greet() is called, Python checks D, finds no override, and moves to B, which immediately returns "B".
Question 4: @classmethod vs. @staticmethod
class MathUtils:
    multiplier = 2

    @classmethod
    def apply_multiplier(cls, x):
        return x * cls.multiplier

    @staticmethod
    def add(a, b):
        return a + b

Which statement accurately describes the difference between these two decorators?

  • A) @classmethod receives the class object cls as its first parameter; @staticmethod receives no implicit first argument.
  • B) @staticmethod can modify class state, whereas @classmethod cannot.
  • C) @classmethod can only be invoked on instances; @staticmethod can only be invoked on the class directly.
  • D) @staticmethod passes the instance self implicitly behind the scenes.
Show Answer & Explanation
Correct Answer: A A @classmethod receives the class itself (cls) as its implicit first argument, allowing it to inspect or modify class-level state or serve as alternative constructors. A @staticmethod behaves like a plain function placed inside a class's namespace, taking neither self nor cls.
Question 5: Modifying Attribute Access via Properties
class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def fahrenheit(self):
        return (self._celsius * 9 / 5) + 32

t = Temperature(0)
t.fahrenheit = 100
  • A) It updates self._celsius to approximately 37.78.
  • B) It dynamically overrides the fahrenheit method with the integer 100.
  • C) AttributeError: property 'fahrenheit' of 'Temperature' object has no setter
  • D) TypeError: property object is not callable
Show Answer & Explanation
Correct Answer: C Decorating a method with @property defines a getter, creating a read-only property by default. Without an explicit setter defined via @fahrenheit.setter, mutating the property raises an AttributeError.
Question 6: The __repr__ vs. __str__ Protocol
class Item:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"Item({self.name!r})"

item = Item("Book")
print(str(item))
  • A) <__main__.Item object at 0x...>
  • B) Item('Book')
  • C) Book
  • D) TypeError: __str__ returned non-string
Show Answer & Explanation
Correct Answer: B When str() or print() is invoked on an object, Python checks for __str__. If __str__ is not defined, Python falls back to __repr__. Since __repr__ is implemented, it returns Item('Book').
Question 7: Memory Optimization with __slots__
class Coordinate:
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        self.x = x
        self.y = y

c = Coordinate(1, 2)
c.z = 3
  • A) c.z is created and assigned the value 3.
  • B) AttributeError: 'Coordinate' object has no attribute 'z'
  • C) TypeError: __slots__ cannot be modified
  • D) SyntaxError: invalid syntax
Show Answer & Explanation
Correct Answer: B Defining __slots__ prevents the creation of a dynamic per-instance __dict__, saving memory by fixing attribute allocations to those specified ('x' and 'y'). Setting an unlisted attribute raises an AttributeError.
Question 8: Behavior of super() in Single Inheritance
class Base:
    def __init__(self):
        print("Base Init")

class Derived(Base):
    def __init__(self):
        print("Derived Init")
        super().__init__()

d = Derived()
  • A)
    Base Init
    Derived Init
  • B)
    Derived Init
    Base Init
  • C)
    Derived Init
  • D) TypeError: super() requires at least 1 argument
Show Answer & Explanation
Correct Answer: B Python executes statements in method bodies sequentially. print("Derived Init") runs first. Then, super().__init__() delegates to the parent class constructor, executing print("Base Init") second.
Question 9: isinstance() vs. type() Checking
class Vehicle:
    pass

class Car(Vehicle):
    pass

c = Car()
print(type(c) == Vehicle, isinstance(c, Vehicle))
  • A) True True
  • B) False False
  • C) False True
  • D) True False
Show Answer & Explanation
Correct Answer: C type(c) checks the exact class of the object (<class '__main__.Car'>), which does not equal Vehicle (evaluating to False). isinstance(c, Vehicle) accounts for inheritance hierarchies, evaluating to True.
Question 10: Abstract Base Classes (ABCs)
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, r):
        self.r = r

c = Circle(5)
  • A) The instance c is created successfully with r = 5.
  • B) TypeError: Can't instantiate abstract class Circle without an implementation for abstract method 'area'
  • C) NotImplementedError: Method area is abstract
  • D) Circle automatically returns None for area().
Show Answer & Explanation
Correct Answer: B Any subclass extending an ABC containing @abstractmethod decorators cannot be instantiated unless it provides concrete implementations for all declared abstract methods. Failing to do so triggers a TypeError upon instantiation.
16 · Academic Standards

References & Further Reading

  • Cambridge Assessment International Education: AS & A Level Computer Science (9618) Syllabus Guide — Section 19 (OOP & Pseudocode Standards).
  • Ontario Ministry of Education: Computer Studies Curriculum (Grades 11 and 12) — Course ICS4U (Grade 12 University Preparation).
  • Python Software Foundation: Python Data Model Documentation (Special Method Names, Descriptors, and C3 Linearization).
  • Python PEPs: PEP 8 – Style Guide for Python Code (Conventions for internal and private identifiers).
  • Ramalho, L. (2022): Fluent Python: Clear, Concise, and Effective Programming (2nd ed.). O'Reilly Media.