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.
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).
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)
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.
Always initialize mutable containers (lists, dictionaries) inside __init__ as instance attributes — self.items = [] — unless sharing state across all instances is explicitly intended.
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__ |
|---|---|---|
| Purpose | Creates the object | Initialises the object |
| Runs | Before creation finishes | After the object exists |
| Returns | An object instance | Nothing (None) |
| Typical use | Singletons, immutable types | Setting 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
('Toyota', 'Corolla')
('Toyota', 'Corolla')
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)
Unknown 0
Aye 0
Zaw 15
Encapsulation
Encapsulation bundles data and the methods that mutate it into a single unit, controlling access to protect integrity.
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
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())
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__])
['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.
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())
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)
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())
Dunder method reference
| Method | Triggered by | Purpose |
|---|---|---|
__init__ | ClassName(...) | Initialises instance state |
__str__ | print(obj), str(obj) | User-facing readable string |
__repr__ | Interactive shell, repr(obj) | Unambiguous code representation |
__eq__ | a == b | Value equality check |
__len__ | len(obj) | Returns object length |
__add__ | a + b | Defines behavior for + |
__iter__, __next__ | for loops, next() | Iterator protocol |
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]
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])
Savings 1000 (Aye): $750.00
Current 1001 (Zaw): $750.00
OSSD ICS4U track
ICS4U focuses on software architecture and design justification alongside syntax.
UML before implementation
Diagram class definitions, properties, access modifiers, and associations prior to writing Python modules.
Pillar justification
Articulate why a design uses composition instead of inheritance to model relationships cleanly.
Cambridge AS/A Level (9618) track
Cambridge 9618 assesses OOP primarily through structured pseudocode with explicit visibility specifiers.
| Python | Cambridge 9618 pseudocode |
|---|---|
|
|
Instructional sequence
- Classes & Objects: Blueprints vs. instances
- Attributes, methods, and
__init__ - String representation:
__str__and__repr__ - Encapsulation & properties
- Inheritance hierarchies &
super() - Polymorphism and duck typing
- Abstraction via interfaces and ABCs
- System integration via composition
Python OOP Mastery Quiz
Test your understanding of core and advanced OOP mechanics. Click "Show Answer & Explanation" under each question to verify your reasoning.
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)
Show Answer & Explanation
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 = [].
class Account:
def __init__(self):
self.__balance = 100
acc = Account()
print(acc._Account__balance)
Show Answer & Explanation
__balance, undergo name mangling: the interpreter renames them to _ClassName__attributeName. Thus, acc._Account__balance accesses the value directly.
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())
Show Answer & Explanation
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".
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?
Show Answer & Explanation
@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.
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
Show Answer & Explanation
@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.
class Item:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Item({self.name!r})"
item = Item("Book")
print(str(item))
Show Answer & Explanation
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').
class Coordinate:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
c = Coordinate(1, 2)
c.z = 3
Show Answer & Explanation
__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.
class Base:
def __init__(self):
print("Base Init")
class Derived(Base):
def __init__(self):
print("Derived Init")
super().__init__()
d = Derived()
Show Answer & Explanation
print("Derived Init") runs first. Then, super().__init__() delegates to the parent class constructor, executing print("Base Init") second.
class Vehicle:
pass
class Car(Vehicle):
pass
c = Car()
print(type(c) == Vehicle, isinstance(c, Vehicle))
Show Answer & Explanation
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.
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)
Show Answer & Explanation
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.
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.