B3.1.1

Evaluate the fundamentals of OOP

Object Oriented Programming (OOP) is a programming paradigm that organises software around objects rather than functions .

13 min read2,710 words

What is OOP?

Object-Oriented Programming (OOP) is a programming paradigm that organises software around objects rather than functions.

Traditional (Procedural) Approach

Programs are built as a sequence of instructions acting on separate data:

Data A  →  Function 1  →  Data B  →  Function 2  →  Result

Data and functions are loosely coupled — functions operate on data without strong ownership.

OOP Approach

Programs are built around objects — self-contained units that bundle data (attributes) and behaviour (methods) together:

     Object
┌─────────────────┐
│  Attributes     │  ← Data (variables)
│  ────────────   │
│  Methods        │  ← Behaviour (functions)
└─────────────────┘

Objects are created from a class, which acts as a blueprint.

Why Use OOP?

AdvantageExplanation
Real-world modellingObjects map naturally to real-world entities (e.g., a Student, BankAccount)
Data protectionEncapsulation hides internal data, preventing unintended access or modification
Code reuseInheritance allows new classes to reuse existing code without rewriting
Easier debuggingEach object is self-contained — errors are isolated to specific classes
ScalabilityLarge programs are easier to maintain by dividing responsibilities into objects

The Three Core OOP Concepts

ConceptCore ideaKey benefit
EncapsulationBundling data + methods inside a class; restricting accessData protection
InheritanceA class inherits attributes/methods from a parent classCode reuse
PolymorphismThe same method name behaves differently depending on the objectFlexibility

Classes and Objects

Class:  Dog  (blueprint)
        ├── attributes: name, breed, age
        └── methods:   bark(), eat(), sleep()

Objects:  (instances of Dog)
  ├─ Buddy  ─→ Dog("Buddy", "Labrador", 3)
  ├─ Max    ─→ Dog("Max",   "Beagle",   5)
  └─ Luna   ─→ Dog("Luna",  "Husky",   2)

Defining a Class in Python

class Dog:
    """A class to represent a dog."""

    # Class variable — shared by all instances
    species = "Canis familiaris"

    # Constructor — called when creating a new object
    def __init__(self, name: str, breed: str, age: int):
        # Instance variables — unique to each object
        self.name  = name
        self.breed = breed
        self.age   = age

    # Instance method — operates on the object
    def bark(self) -> str:
        return f"{self.name} says Woof!"

    def __str__(self) -> str:
        return f"{self.name} is a {self.age}-year-old {self.breed}."

Creating Objects

buddy = Dog("Buddy", "Labrador", 3)
luna  = Dog("Luna",  "Husky",    2)

print(buddy.bark())       # Buddy says Woof!
print(luna)               # Luna is a 2-year-old Husky.
print(Dog.species)        # Canis familiaris  (shared by all Dog objects)

The self Keyword

self is a reference to the current instance of a class. It is used inside methods to:

  • Access instance variables: self.name
  • Call other methods: self.bark()
  • Distinguish between instance variables and local variables with the same name
def __init__(self, name: str):     # 'name' is a local parameter
    self.name = name               # 'self.name' is the instance variable

Instance Variables vs. Class Variables

Instance VariableClass Variable
ScopeBelongs to a single objectShared by all objects of the class
Defined inInside __init__ using self.Directly inside the class body
MemorySeparate copy per objectOne copy shared across all instances
Accessobject.variableClassName.variable or object.variable
Exampleself.name = "Buddy"species = "Canis familiaris"
class Dog:
    species = "Canis familiaris"   # Class variable — shared

    def __init__(self, name: str):
        self.name = name           # Instance variable — unique

The __init__ Constructor

__init__ is a special method (also called a dunder method) that is automatically called when an object is created. It initializes the object’s attributes.

class BankAccount:
    def __init__(self, owner: str, balance: float):
        self.owner   = owner
        self.balance = balance

    def deposit(self, amount: float):
        self.balance += amount

    def withdraw(self, amount: float):
        if amount <= self.balance:
            self.balance -= amount
        else:
            print("Insufficient funds.")

account = BankAccount("Alice", 1000.00)   # __init__ is called automatically
account.deposit(500)
print(account.balance)   # 1500.0

The Four Pillars of OOP

1. Encapsulation

Encapsulation is the bundling of data (attributes) and methods (behaviour) into a single unit (a class), and restricting direct access to an object’s internal data.

Why it matters:

  • Prevents unauthorized modification of data
  • Ensures an object maintains a valid internal state
  • Allows the internal implementation to change without breaking code that uses the class

Access Modifiers in Python

Python uses conventions (not strict rules) to indicate visibility:

ModifierConventionMeaning
PublicattributeAccessible from anywhere
Protected_attributeIntended for internal use; a “soft warning”
Private__attributeName mangling makes it harder (but not impossible) to access from outside
class BankAccount:
    def __init__(self, balance: float):
        self.__balance = balance   # Private — name becomes _BankAccount__balance

    def deposit(self, amount: float):
        if amount > 0:
            self.__balance += amount

    def get_balance(self) -> float:
        return self.__balance     # Controlled access via a method
account = BankAccount(1000)
# account.__balance        → AttributeError: 'BankAccount' object has no attribute '__balance'
account.deposit(500)
print(account.get_balance())  # 1500.0  ✅ Controlled access

2. Inheritance

Inheritance allows a child class to reuse the attributes and methods of a parent class, without rewriting them.

    Animal  (Parent / Superclass)

        ├── eat()       ← inherited by all children
        └── breathe()   ← inherited by all children

   ┌────┴────┐
   │         │
  Dog      Cat  (Child / Subclass)
  bark()   meow()

Single Inheritance

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

    def eat(self):
        print(f"{self.name} is eating.")


class Dog(Animal):          # Dog inherits from Animal
    def __init__(self, name: str):
        super().__init__(name)

    def bark(self):
        print(f"{self.name} says Woof!")


buddy = Dog("Buddy")
buddy.eat()    # Inherited from Animal → Buddy is eating.
buddy.bark()   # Defined in Dog      → Buddy says Woof!

Multiple Inheritance

Python supports a class inheriting from multiple parent classes:

class Flyable:
    def fly(self):
        print("Flying!")

class Swimmable:
    def swim(self):
        print("Swimming!")

class Duck(Animal, Flyable, Swimmable):
    pass

duck = Duck("Donald")
duck.eat()   # From Animal
duck.fly()   # From Flyable
duck.swim()  # From Swimmable

The super() Function

super() calls a method from the parent class, commonly used in __init__ to extend (not replace) the parent’s initialization:

class Dog(Animal):
    def __init__(self, name: str, breed: str):
        super().__init__(name)   # Call parent's __init__
        self.breed = breed       # Add Dog-specific attribute

3. Polymorphism

Polymorphism (Greek: “many forms”) allows the same method name to behave differently depending on the object that calls it.

Method Overriding

A child class can override a method from its parent class:

class Animal:
    def speak(self):
        print("Some sound")

class Cat(Animal):
    def speak(self):              # Override parent's speak()
        print("Meow!")

class Dog(Animal):
    def speak(self):              # Override parent's speak()
        print("Woof!")

animals = [Animal("??"), Cat("Whiskers"), Dog("Buddy")]

for animal in animals:
    animal.speak()
# Some sound
# Meow!
# Woof!

Polymorphism with Functions

def makeSpeak(animal: Animal):
    animal.speak()

makeSpeak(Cat("Whiskers"))   # Meow!
makeSpeak(Dog("Buddy"))      # Woof!

The function makeSpeak works with any object that has a speak() method — regardless of the specific class.

4. Abstraction

Abstraction hides complex implementation details and exposes only the essential features of an object.

from abc import ABC, abstractmethod

class Shape(ABC):                    # Abstract class
    @abstractmethod
    def area(self) -> float:         # Abstract method — must be implemented by subclasses
        pass

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width  = width
        self.height = height

    def area(self) -> float:         # Must implement abstract method
        return self.width * self.height

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

    def area(self) -> float:
        import math
        return math.pi * self.radius ** 2

Four Pillars Summary

PillarPurposeMechanism
EncapsulationBundle data + methods; protect internal statePrivate attributes + public methods
InheritanceReuse code from parent classesclass Child(Parent)
PolymorphismSame method name, different behaviourMethod overriding + function parameters
AbstractionHide complexity; expose essential interfaceAbstract classes + abstract methods

Benefits of OOP — Summary

BenefitExplanation
ModularityEach class/object is self-contained — easy to develop and test independently
ReusabilityInheritance and composition allow existing classes to be reused and extended
ScalabilityWell-structured OOP code scales better for large, complex applications
MaintainabilityChanges to one class have minimal impact on others due to encapsulation
Real-world modellingObjects map directly to real-world entities, making design intuitive

OOP vs. Procedural Programming

ProceduralOOP
OrganisationFunctions + global dataObjects (data + methods together)
Data accessData is often global and exposedData is encapsulated within objects
Code reuseCopy-paste or function callsInheritance
State managementGlobal variables or parametersObject attributes
Best forSimple scripts, data processingLarge, complex, real-world applications
ExampleC, FortranPython, Java, C++

3## B3.1.2 Construct a design of classes, their methods and behavior

UML Class Diagrams

UML (Universal Modelling Language) provides a standardized way to visualize class structure before writing code. Each class is drawn as a three-compartment box:

┌─────────────────────────────────────┐
│            Class Name               │  ← Class name (bold, centred)
├─────────────────────────────────────┤
│  – attributeName: Type             │  ← Attributes (visibility: – private, + public, # protected)
│  – attributeName: Type             │
├─────────────────────────────────────┤
│  + methodName(): ReturnType        │  ← Methods (+ public, – private)
│  + methodName(param: Type): Return │
└─────────────────────────────────────┘

Visibility modifiers in UML:

SymbolModifierMeaning
+PublicAccessible from any class
PrivateAccessible only within this class
#ProtectedAccessible within this class and its subclasses

Example — Person class:

┌─────────────────────────────────────────┐
│                 Person                   │
├─────────────────────────────────────────┤
│  – name: String                        │
│  – dateOfBirth: String                 │
│  # address: String                     │
│  – age: int                            │
├─────────────────────────────────────────┤
│  + getName(): String                   │
│  + getAge(): int                       │
│  + setName(name: String): void         │
└─────────────────────────────────────────┘

Designing a Class — Step by Step

When designing a class, work through these steps:

  1. Identify the class name — a noun describing the entity (e.g., Person, Student)
  2. Identify attributes — what data does it store? (use visibility modifiers)
  3. Identify methods — what behaviours does it expose?
  4. Draw the UML diagram first — before writing any code
  5. Write the code from the diagram

Implementing a Class from UML in Python

Person Class

from datetime import date

class Person:
    """A class to represent a person."""

    def __init__(self, name: str, date_of_birth: str):
        self.__name = name                  # Private attribute
        self.__date_of_birth = date_of_birth  # Private attribute

    def get_name(self) -> str:              # + getName(): String
        return self.__name

    def get_age(self) -> int:               # + getAge(): int
        born = date.fromisoformat(self.__date_of_birth)
        today = date.today()
        age = today.year - born.year
        if (today.month, today.day) < (born.month, born.day):
            age -= 1
        return age

Student Class (Inherits from Person)

class Student(Person):
    """A class to represent a student, inheriting from Person."""

    def __init__(self, name: str, date_of_birth: str, student_id: str):
        super().__init__(name, date_of_birth)   # Call parent's __init__
        self.__student_id = student_id

    def get_student_id(self) -> str:              # + getStudentID(): String
        return self.__student_id
s = Student("Alice", "2006-03-15", "S001")
print(s.get_name())        # Alice  (inherited from Person)
print(s.get_age())         # 20     (computed from Person)
print(s.get_student_id()) # S001  (Student-specific)

OOP Relationships

Classes are rarely isolated — they relate to each other. There are four main types of relationships:

RelationshipStrengthSymbolDescription
InheritanceStrongest—▷ (hollow arrow)“is-a” — a subclass is a type of parent
CompositionStrong——◆ (filled diamond)“part-of” — parts cannot exist without the whole
AggregationModerate——◇ (hollow diamond)“has-a” — parts can exist independently
AssociationWeakest——>A uses B, but no ownership

1. Inheritance (“is-a”)

A subclass is a specialised version of its parent class.

        Person


        Student


   ┌──────┴──────┐
   │             │
PartTimeStudent  ExchangeStudent
  • UML symbol: hollow arrow ——▷ pointing from child to parent
  • Programming: class Child(Parent)
  • Example: Student “is-a” Person

2. Composition (“part-of”)

The whole owns its parts — if the whole is destroyed, the parts are destroyed too.

    Customer               Address
┌───────────────┐      ┌───────────────┐
│ – name        │      │ – address_id  │
│ – email       │◆─────│ – street      │
│               │      │ – city        │
└───────────────┘      └───────────────┘
              (filled diamond = composition)
class Address:
    def __init__(self, street: str, city: str):
        self.__street = street
        self.__city   = city

    def get_street(self) -> str:
        return self.__street


class Customer:
    def __init__(self, name: str, street: str, city: str):
        self.__name    = name
        self.__address = Address(street, city)   # Customer owns the Address object

    def get_address(self) -> Address:
        return self.__address

3. Aggregation (“has-a”)

A container holds references to parts, but the parts can exist independently of the container.

    Team                 Player
┌───────────┐      ┌─────────────┐
│ – players │◇─────│ – name      │
└───────────┘      └─────────────┘
              (hollow diamond = aggregation)
class Player:
    def __init__(self, name: str, jersey_number: int):
        self.__name = name
        self.__jersey_number = jersey_number

    def get_name(self) -> str:
        return self.__name


class Team:
    def __init__(self, team_name: str):
        self.__team_name = team_name
        self.__players: list[Player] = []

    def add_player(self, player: Player):    # Player is passed in — created outside
        self.__players.append(player)

    def get_players(self) -> list[Player]:
        return self.__players

4. Association

The weakest relationship — one class simply uses another, with no ownership.

    Order                Product
┌───────────┐      ┌──────────────┐
│ – date    │<─────│ – name       │
│ – items   │      │ – price      │
└───────────┘      └──────────────┘
class Product:
    def __init__(self, name: str, price: float):
        self.__name  = name
        self.__price = price

    def get_price(self) -> float:
        return self.__price


class Order:
    def __init__(self, order_date: str):
        self.__order_date = order_date
        self.__items: list[Product] = []

    def add_item(self, product: Product):
        self.__items.append(product)

    def get_total(self) -> float:
        return sum(item.get_price() for item in self.__items)

Relationship Summary

InheritanceCompositionAggregationAssociation
Analogy“is-a”“part-of”“has-a”“uses-a”
OwnershipParent owns structureWhole owns partsContainer holds partsNo ownership
LifecycleChild needs parentParts die with wholeParts independentLoose coupling
UML symbol——▷ (hollow arrow)——◆ (filled diamond)——◇ (hollow diamond)——> (arrow)
ExampleStudent is-a PersonCustomer has AddressTeam has PlayersOrder uses Product

Best Practices for Class Design

PracticeWhy it matters
Single Responsibility PrincipleEach class should have one clear purpose — avoid “god classes”
Start with UMLSketching the class structure before coding prevents redesign later
Encapsulate attributesMake attributes private; expose controlled access via getters/setters
Use meaningful namesClass names: nouns (Student, BankAccount). Method names: verbs (deposit, withdraw)
Minimise dependenciesClasses should be loosely coupled — avoid excessive cross-dependencies
Choose composition over inheritance when unsureInheritance creates tight coupling; composition is more flexible
Document behaviour, not implementationMethod comments should explain what it does, not how internally

Start typing to search all published objectives.