Classes, Objects, and Encapsulation

You can read this note directly if you know Python variables and functions.

Object-oriented programming adds one central idea: combine the data describing an entity with the methods that operate on that data.

The Beginner Problem OOP Solves

Suppose a program stores student marks using separate variables:

name1 = "Aisha"
mark1 = 72
name2 = "Bo"
mark2 = 45

The program must keep track of which name belongs to which mark. As the number of students and properties grows, the data becomes difficult to organise.

An object keeps the related values together:

s1 = Student("Aisha", 72)
s2 = Student("Bo", 45)

Now:

  • s1 represents one student;
  • s2 represents another student;
  • each object stores its own values;
  • the class can define behaviour shared by all students.

Class Versus Object

A class is a definition or blueprint describing the attributes and methods of a type of object.

An object is a particular instance created from that class.

Caption: One class definition supplies the same method design to every instance, while s1 and s2 contain separate instance state. The arrows represent two independent instantiations, not copies that share one mark.

class Student:
    def __init__(self, name, mark):
        if not isinstance(name, str) or not name.strip():
            raise ValueError("name must be non-empty text")
        if type(mark) is not int or not 0 <= mark <= 100:
            raise ValueError("mark must be an integer in 0..100")
        self.name = name.strip()
        self.mark = mark
 
    def is_pass(self):
        return self.mark >= 50
 
    def set_mark(self, mark):
        if type(mark) is not int or not 0 <= mark <= 100:
            return False
        self.mark = mark
        return True

Creating objects from the class is called instantiation:

s1 = Student("Aisha", 72)
s2 = Student("Bo", 45)
assert s1 is not s2
assert s1.set_mark(50) is True
assert (s1.mark, s2.mark) == (50, 45)
before = s1.mark
for invalid in (-1, 101, True, 50.5, "50"):
    assert s1.set_mark(invalid) is False
    assert s1.mark == before
 
for args in (("", 50), ("Aisha", -1), ("Aisha", 101), ("Aisha", True)):
    try:
        Student(*args)
        assert False
    except ValueError:
        pass
Objectnamemarkis_pass()
s1"Aisha"50True
s2"Bo"45False

The table shows the state after s1.set_mark(50): s1 changed while s2 remained at 45. The objects share the same class design, but each object has its own state.

Attributes and Methods

TermMeaningExample
attributedata stored for an object or classself.mark
methoda function defined inside a classis_pass()
instancea particular object created from a classs1
instantiationthe process of creating an objectStudent("Aisha", 72)

A method can read or change the attributes of the current object.

class Counter:
    def __init__(self):
        self.value = 0
 
    def increase(self):
        self.value = self.value + 1

Calling counter.increase() changes the state stored in that particular object.

counter = Counter()
counter.increase()
assert counter.value == 1

__init__() and self

At H2 Computing level, __init__() is commonly described as the constructor. More precisely in Python, the object has already been allocated when __init__() initialises its state.

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

The parameter self refers to the current object.

Read:

self.name = name

as:

store the parameter name in the name attribute of this object

When an instance method is called using dot notation, Python supplies the object as self.

Method callObject used as selfAttribute read
s1.is_pass()s1s1.mark
s2.is_pass()s2s2.mark

This is why self appears in the method definition but is not normally written as an argument in the call.

For tracing a simple instance method, read object.method(value) approximately as “find the appropriate class method, then call it with object as self and value as the next argument.” In an inheritance hierarchy, Python first resolves the method using the actual object’s class, which is why an overridden method can run.

Local variable versus instance attribute

class Student:
    def __init__(self, name):
        name = name

This does not create a lasting object attribute. The variable name is only local to the method.

The correct form is:

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

The prefix self. attaches the value to the object.

Displaying an Object

Without a suitable string method, printing an object may show a technical representation rather than useful information.

class DisplayStudent:
    def __init__(self, name, mark):
        self.name = name
        self.mark = mark
 
    def __str__(self):
        return f"{self.name}: {self.mark}"
s1 = DisplayStudent("Aisha", 72)
print(s1)

Output:

Aisha: 72

__str__() is not one of the named conceptual outcomes in section 2.5, but it is a useful beginner-level Python method for producing readable object output.

Class Diagrams

Caption: The three compartments show class name, attributes, and methods—not the current values inside an object. - and + express intended visibility in the design; a Python underscore is only an internal-use convention.

A simple class diagram contains three sections:

  1. class name;
  2. attributes;
  3. methods.

For example:

Student
----------------
- name
- mark
----------------
+ is_pass()
+ set_mark(mark)

The symbols commonly mean:

SymbolMeaning
-private or internal member
+public member

A class diagram describes the class design. It does not show the different values stored in every object.

This diagram is a design target with intended internal attributes and a public set_mark() operation. The first tracing class above deliberately uses public name and mark attributes to keep object state visible to beginners; a fuller implementation would align the code with the diagram using internal-use names such as _name and _mark plus the stated public methods.

For inheritance diagrams, place the general superclass above specialised subclasses. The hollow-triangle generalisation arrow points towards the superclass. This arrow describes an is-a relationship; it does not mean that pre-existing instance values flow into a new object automatically.

Instance Attributes and Class Attributes

An instance attribute belongs to one object.

self.name = name

Different objects may store different values:

s1.name = "Aisha"
s2.name = "Bo"

A class attribute belongs to the class and is shared through the class.

class CountedStudent:
    count = 0
 
    def __init__(self, name, mark):
        if type(mark) is not int or not 0 <= mark <= 100:
            raise ValueError("mark must be an integer in 0..100")
        self.name = name
        self.mark = mark
        CountedStudent.count = CountedStudent.count + 1

After creating three students, CountedStudent.count is 3.

FeatureInstance attributeClass attribute
belongs toone objectthe class
typical accessself.name or s1.nameCountedStudent.count
same value for all objects?not necessarilyshared unless shadowed
suitable useobject-specific stateshared count or setting

A common class-attribute trap

CountedStudent.count = 0
s1 = CountedStudent("Aisha", 72)
s2 = CountedStudent("Bo", 45)
assert CountedStudent.count == 2
assert s1.count == 2 and s2.count == 2
s1.count = 100
assert s1.count == 100          # new shadowing instance attribute
assert CountedStudent.count == 2 and s2.count == 2

This creates or changes an instance attribute named count for s1; it does not reliably update the shared class attribute.

To update the shared value clearly, use:

CountedStudent.count = 100

Caption: name and mark belong separately to each object, while CountedStudent.count is shared through the class. Assigning s1.count creates a shadowing instance attribute; it does not update the shared class value.

Encapsulation

Encapsulation combines attributes and the methods that operate on them inside one class.

Caption: The drawn boundary is the conceptual public interface. deposit() and withdraw() protect the non-negative balance invariant; _balance remains technically accessible in Python, but direct access bypasses the intended contract.

class BankAccount:
    def __init__(self, owner, balance=0):
        if not isinstance(owner, str) or not owner.strip():
            raise ValueError("owner must be non-empty text")
        if not self._valid_amount(balance) or balance < 0:
            raise ValueError("balance must be a non-negative integer")
        self.owner = owner.strip()
        self._balance = balance
 
    def _valid_amount(self, amount):
        return type(amount) is int
 
    def deposit(self, amount):
        if self._valid_amount(amount) and amount > 0:
            self._balance = self._balance + amount
            return True
        return False
 
    def withdraw(self, amount):
        if self._valid_amount(amount) and 0 < amount <= self._balance:
            self._balance = self._balance - amount
            return True
        return False
 
    def get_balance(self):
        return self._balance

The class stores account data and provides controlled operations on that data.

Contract and invariant:

  • the owner is non-empty text;
  • the balance is a non-negative integer; Boolean and fractional values are rejected;
  • successful deposits/withdrawals return True and update the balance;
  • invalid or unaffordable operations return False and leave the balance unchanged.
account = BankAccount("Aisha", 100)
assert account.deposit(50) is True
assert account.withdraw(20) is True
before = account.get_balance()
for invalid in (0, -1, True, 10.5, float("nan"), float("inf"), float("-inf"), "10", None):
    assert account.deposit(invalid) is False
    assert account.get_balance() == before
    assert account.withdraw(invalid) is False
    assert account.get_balance() == before
assert account.withdraw(1000) is False
assert account.get_balance() == before == 130
 
for args in (("", 0), ("Aisha", -1), ("Aisha", True), ("Aisha", 10.5), ("Aisha", float("nan")), ("Aisha", float("inf")), ("Aisha", float("-inf"))):
    try:
        BankAccount(*args)
        assert False
    except ValueError:
        pass

The withdraw() method can enforce the rule that the balance must not become negative. If outside code changes the balance directly, that rule can be bypassed.

Public Methods and Internal Attributes

A public method is intended to be used by code outside the class.

Examples:

account.deposit(50)
account.withdraw(20)
account.get_balance()

An underscore prefix such as _balance signals that an attribute is intended for internal use:

self._balance = balance

In Python, a single underscore is a convention rather than an absolute access barrier. Outside code can still access the attribute, but doing so ignores the intended interface.

For syllabus explanations, it is useful to distinguish:

  • design intention: internal data should be accessed or changed through public methods;
  • Python behaviour: an underscore does not make access impossible.

Getters and Setters

A getter returns an attribute value.

def get_balance(self):
    return self._balance

A setter changes an attribute value, usually after checking it.

def set_mark(self, mark):
    if type(mark) is int and 0 <= mark <= 100:
        self._mark = mark
        return True
    return False

Not every attribute needs both a getter and a setter. A setter is useful only when outside code should be allowed to change the value.

A controlled method may also be more meaningful than a general setter. For a bank account, deposit() and withdraw() express the allowed operations more clearly than set_balance().

Information Hiding

Information hiding means that outside code uses the public operations of a class without depending directly on its internal representation.

Preferred:

account.deposit(50)
account.withdraw(20)
balance = account.get_balance()

Avoid:

account._balance = -999

The second statement bypasses the class rules and may leave the object in an invalid state.

Encapsulation groups state and behaviour. Information hiding asks outside code to depend on the public interface rather than the representation. This supports implementation independence: internals may change while a stable public contract is preserved. Encapsulation supports these benefits, but by itself does not guarantee security, correctness, or valid state; the class methods must still enforce appropriate rules.

Information hiding supports:

  • data integrity;
  • controlled validation;
  • reduced dependence on implementation details;
  • easier modification of the class.

Implementation Independence

Caption: The same calling expression can work with either internal representation only when both preserve the full public contract: compatible parameters, return/error behaviour, state effects, and observable results.

Suppose BankAccount initially stores one number:

self._balance = balance

Later, the class may instead store a list of transactions and calculate the balance from that list.

If the following public operations remain part of the same contract:

deposit()
withdraw()
get_balance()

the code using the class may not need to change—provided method parameters, returns or exceptions, state changes, invariants, and other observable behaviour remain compatible. Matching names alone is not enough.

This is implementation independence: users of the class depend on what its public methods do, not on how the class performs the work internally.

Paper 1 and Paper 2 Emphasis

Paper 1-style explanation

Be ready to define and distinguish:

  • class and object;
  • attribute and method;
  • class attribute and instance attribute;
  • encapsulation and information hiding;
  • information hiding and implementation independence.

A strong answer states both the mechanism and the benefit. For example:

Encapsulation combines data and methods in a class. Access through public methods allows the class to validate changes and hides the internal representation from outside code.

Paper 2-style coding

Be ready to:

  • define a class;
  • initialise instance attributes using self;
  • instantiate multiple objects;
  • write methods that read or update state;
  • implement simple getters, setters, or validation methods;
  • use a class attribute for genuinely shared state;
  • trace object state after a sequence of method calls.

Common Mistakes

  • Saying that a class and an object are the same thing.
  • Forgetting self in an instance method definition.
  • Assigning to a local variable instead of an instance attribute.
  • Passing self explicitly in an ordinary method call.
  • Treating a class attribute as though every object has a separate copy.
  • Updating a shared class attribute through an instance and accidentally creating an instance attribute.
  • Claiming that a single underscore makes a Python attribute completely inaccessible.
  • Giving every attribute a setter even when the value should not be changed.
  • Describing information hiding without explaining the public method interface.
  • Mixing methods into the attribute section of a class diagram.

Check Your Understanding

  1. What is the difference between a class and an object?
  2. What does self refer to?
  3. Why does self.name = name create lasting object state but name = name does not?
  4. What is the difference between an instance attribute and a class attribute?
  5. Why should outside code call withdraw() instead of setting _balance directly?
  6. How does information hiding support implementation independence?

Answers:

  1. A class defines a type of object; an object is a particular instance created from the class.
  2. The current object using the method.
  3. self.name stores the value as an attribute of the object, whereas name alone is local to the method.
  4. An instance attribute belongs to one object; a class attribute is associated with the class and used as shared state.
  5. withdraw() can enforce rules and preserve a valid balance.
  6. Outside code depends on public methods, so the internal storage can change without necessarily changing the calling code.