You are viewing a free preview of this lesson.
Subscribe to unlock all 10 lessons in this course and every other course on LearningBro.
This lesson introduces the fundamental principles of Object-Oriented Programming (OOP) as required by the AQA A-Level Computer Science specification. OOP is a programming paradigm that organises software design around objects — self-contained units that bundle state and behaviour — rather than around free-standing functions operating on shared global data. Mastering OOP is essential for designing software that is maintainable, reusable, testable and scalable, and it underpins almost all modern commercial systems.
This lesson addresses the object-oriented programming paradigm within the Fundamentals of programming section of the AQA A-Level Computer Science (7517) specification (subject content area 4.1.2, programming paradigms). It covers the concept of a class as a template; objects as instances; attributes, methods and constructors; encapsulation and access modifiers; the four defining principles of OOP (encapsulation, inheritance, polymorphism and abstraction); class membership (instance versus class/static members); and the design of classes from a problem description. Inheritance and polymorphism are developed fully in the next lesson; this lesson establishes the vocabulary and the encapsulation principle on which everything else rests. The paradigm sits alongside the procedural and (in the wider specification) functional paradigms, and you are expected to be able to compare them and justify the choice of OOP for a given scenario.
A paradigm is a style of organising and reasoning about a program. In the procedural paradigm, you decompose a problem into a sequence of procedures (subroutines) that act on data passed to them or held in shared variables. The data and the code that manipulates it are kept separate. As programs grow, this separation becomes a liability: any procedure can modify any piece of shared data, so a single invalid update can corrupt the whole system, and tracing such a bug is laborious.
Object-Oriented Programming takes the opposite approach. The program is structured as a collection of cooperating objects. Each object packages together the data it is responsible for (its attributes, also called fields or properties) and the behaviour that operates on that data (its methods). Because the data and the operations live together, an object can guarantee that its own data is only ever changed in valid ways. This is the single most important idea in OOP and the reason it scales to systems of millions of lines.
The four defining principles — frequently called the four pillars of OOP — are summarised below.
| Pillar | Definition | Core benefit |
|---|---|---|
| Encapsulation | Bundling attributes and the methods that act on them inside a class, and restricting external access to the internal state. | Protects data integrity; reduces coupling. |
| Inheritance | Defining a new class that acquires (and may extend or override) the attributes and methods of an existing class. | Eliminates duplicated code; models hierarchies. |
| Polymorphism | Allowing objects of different classes to respond to the same method call in ways appropriate to each class. | Enables flexible, extensible code. |
| Abstraction | Exposing only the essential features of an object and hiding the implementation detail. | Manages complexity; defines clean interfaces. |
Exam Tip: When asked to "explain the principles of object-oriented programming", you must name and define all four pillars and, for top marks, give a one-line consequence or example of each. A bare list of the four words will not gain the application marks.
A class is a blueprint or template that defines the attributes and methods every object of that type will possess. It does not itself hold data for any particular real-world thing — it describes the shape of such data. An object (or instance) is a concrete realisation of a class, created at run time, with its own private set of attribute values. The act of creating an object from a class is called instantiation.
A useful analogy: the class House is the architect's drawing; the houses actually built from that drawing are the objects. Every house shares the same structure (the drawing) but has its own address, occupants and paint colour (its attribute values).
# AQA-style OOP pseudocode
CLASS Dog
PRIVATE name: STRING
PRIVATE breed: STRING
PRIVATE age: INTEGER
PUBLIC PROCEDURE new(n: STRING, b: STRING, a: INTEGER)
name = n
breed = b
age = a
END PROCEDURE
PUBLIC FUNCTION getName() RETURNS STRING
RETURN name
END FUNCTION
PUBLIC PROCEDURE birthday()
age = age + 1
END PROCEDURE
PUBLIC PROCEDURE bark()
OUTPUT name + " says Woof!"
END PROCEDURE
END CLASS
// Creating objects (instances)
myDog = NEW Dog("Rex", "Labrador", 5)
yourDog = NEW Dog("Bella", "Collie", 2)
myDog.bark() // Output: Rex says Woof!
myDog.birthday() // Rex is now 6; Bella is unaffected
Notice that the constructor in AQA pseudocode is conventionally written as a procedure called new. myDog and yourDog are two separate objects: changing myDog's age has no effect on yourDog, because each object owns its own copy of the instance attributes.
class Dog:
def __init__(self, name: str, breed: str, age: int):
self.__name = name # Private attribute (name-mangled)
self.__breed = breed # Private attribute
self.__age = age # Private attribute
def get_name(self) -> str:
return self.__name
def birthday(self) -> None:
self.__age += 1
def bark(self) -> None:
print(f"{self.__name} says Woof!")
# Creating two independent objects
my_dog = Dog("Rex", "Labrador", 5)
your_dog = Dog("Bella", "Collie", 2)
my_dog.bark() # Output: Rex says Woof!
my_dog.birthday() # Rex's private age becomes 6
In Python the constructor is the dunder method __init__. The leading double underscore on __name, __breed and __age triggers name mangling, which is Python's mechanism for approximating private attributes.
| Term | Definition |
|---|---|
| Class | A template defining the attributes and methods for a type of object. |
| Object / instance | A specific realisation of a class, holding its own attribute values. |
| Attribute (field) | A variable that belongs to an object and stores data about it. |
| Method | A subroutine defined inside a class that operates on the object's data. |
| Constructor | The special method (new / __init__) run when an object is created, used to initialise attributes. |
| Instantiation | The process of creating an object from a class. |
| Member | A collective term for an attribute or method of a class. |
Encapsulation is the principle of bundling the attributes and the methods that operate on them into a single class, and restricting direct external access to the internal state. The second half of that sentence is the part students most often forget: encapsulation is not merely "putting things in a class", it is information hiding — keeping the internal representation private and forcing all access to go through a controlled public interface. This is enforced through access modifiers.
| Modifier | UML symbol | Visibility |
|---|---|---|
| Public | + | Accessible from anywhere — inside or outside the class. |
| Private | - | Accessible only from within the class itself. |
| Protected | # | Accessible from within the class and its subclasses. |
Getters (accessors) and setters (mutators) are the controlled gateway to private attributes. A well-written setter is where validation lives:
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.__owner = owner
self.__balance = balance
def get_balance(self) -> float:
return self.__balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.__balance += amount
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.__balance:
raise ValueError("Insufficient funds")
self.__balance -= amount
Because __balance is private, no caller can write account.__balance = -500. The only routes that change the balance are deposit and withdraw, both of which enforce the rules. The class therefore guarantees an invariant: the balance can never become negative, no matter how the object is used.
Exam Tip: In encapsulation questions, always explain why an attribute is private. The marks are for the consequence — "so that the balance cannot be set to an invalid value from outside the class, which protects the object's internal state" — not for restating the definition.
self / this referenceInside an instance method, the keyword self (Python) or this (Java, C#, C++) refers to the particular object on which the method was called. It is how a method distinguishes one instance's data from another's. When my_dog.birthday() runs, self is my_dog; when your_dog.birthday() runs, self is your_dog.
class Student:
def __init__(self, name: str, year: int):
self.__name = name # 'self' binds the data to THIS object
self.__year = year
def promote(self) -> None:
self.__year += 1 # modifies only the student it was called on
def __str__(self) -> str:
return f"{self.__name} (Year {self.__year})"
A class diagram is a visual representation of a class in the Unified Modelling Language (UML). It is divided into three compartments: the class name, the attributes, and the methods. Access modifiers are shown with the +, - and # symbols introduced above.
classDiagram
class BankAccount {
-owner: String
-balance: Real
+new(owner, balance)
+getBalance() Real
+deposit(amount) void
+withdraw(amount) void
}
Exam Tip: You may be asked to draw or interpret a class diagram. The most commonly dropped marks are forgetting return types on functions and using the wrong visibility symbol. Remember:
+public,-private,#protected.
Most attributes belong to individual objects, but sometimes a value or behaviour belongs to the class as a whole. A static (class) member is shared by every instance; an instance member has a separate copy in each object.
| Feature | Instance members | Static / class members |
|---|---|---|
| Belongs to | A specific object | The class itself |
| Accessed via | An object reference | The class name |
| Copies | One per object | One, shared by all objects |
| Typical use | Data unique to each object | A shared counter, a constant, a factory method |
class Student:
student_count = 0 # class (static) attribute — one shared copy
def __init__(self, name: str):
self.__name = name # instance attribute — one per object
Student.student_count += 1 # update the shared counter
@staticmethod
def get_student_count() -> int:
return Student.student_count
a = Student("Ade")
b = Student("Beth")
print(Student.get_student_count()) # 2 — shared across all instances
A classic worked use of a static member is generating unique identifiers. Each new object reads and increments the shared counter, guaranteeing every instance receives a different id without any external coordination:
class Ticket:
next_id = 1 # class attribute — shared sequence
def __init__(self, event: str):
self.__id = Ticket.next_id # take the current value
Ticket.next_id += 1 # advance for the next ticket
self.__event = event
def get_id(self) -> int:
return self.__id
t1 = Ticket("Concert") # id 1
t2 = Ticket("Concert") # id 2
t3 = Ticket("Theatre") # id 3
Because next_id lives on the class, not on any object, all three tickets draw from one running sequence. If next_id had been an instance attribute, every ticket would start its own counter at 1 and the ids would collide.
Objects do not exist forever. Understanding their life cycle helps you reason about memory and about why constructors and clean-up methods exist.
| Stage | What happens |
|---|---|
| Instantiation | Memory is allocated for the object and the constructor runs to initialise its attributes. |
| Use | The object's methods are called and its attributes are read or updated through the public interface. |
| Becoming unreachable | When no variable references the object any longer, it can no longer be used. |
| Destruction / garbage collection | The run-time system reclaims the memory. In languages such as Python and Java this is automatic (garbage collection); in C++ it is manual. |
This life cycle is why the constructor is so important: it is the single guaranteed point at which an object is brought into a valid initial state. A well-written constructor leaves no attribute uninitialised, so the object's invariants hold from the very first method call. The same reasoning, in reverse, motivates the finally/destructor clean-up you will meet in the exception-handling lesson: resources acquired during an object's life must be released when it ends.
A frequent exam task gives a scenario in prose and asks you to identify the classes, attributes and methods. A reliable technique is noun–verb analysis:
Consider: "A shop lets customers place orders. Each order has a list of products and a delivery address, and can calculate its total cost including 20% VAT. Each product has a name and a unit price." Noun–verb analysis suggests classes Customer, Order and Product; the verb "calculate its total cost" becomes a method on Order.
class Product:
def __init__(self, name: str, unit_price: float):
self.__name = name
self.__unit_price = unit_price
def get_unit_price(self) -> float:
return self.__unit_price
class Order:
VAT_RATE = 0.20 # class constant — one shared rate
def __init__(self, address: str):
self.__address = address
self.__products: list = [] # composition: an Order HAS Products
def add_product(self, product: Product) -> None:
self.__products.append(product)
def total_cost(self) -> float:
net = sum(p.get_unit_price() for p in self.__products)
return net * (1 + Order.VAT_RATE)
Notice how the design decisions exercise every concept in this lesson at once: VAT_RATE is a class (static) constant; __products is private (encapsulation); the VAT calculation is a method that operates on the object's own data via self; and an Order contains Product objects (a "has-a" relationship — composition — rather than inheritance).
Exam Tip: When a design question asks for "suitable attributes and methods", justify your choices against the scenario. Marks come from a sensible, encapsulated design that matches the requirements, not from sheer quantity of members.
A common refinement examiners look for is recognising when a "noun" should become a class of its own rather than a mere attribute. In the shop example, the delivery address might at first appear to be a single string attribute of Order. But if the system needs the street, town and postcode separately — to validate the postcode, say, or to group orders by region — then Address deserves to be its own class, and Order has an Address (composition). Spotting this distinction between "this is just a value" and "this is a thing with its own structure and behaviour" is a sign of mature design thinking, and it often separates a competent answer from an excellent one.
Because the specification expects you to compare paradigms and justify a choice, it is worth laying the two side by side on the same problem.
| Aspect | Procedural | Object-oriented |
|---|---|---|
| Unit of organisation | The procedure (subroutine) | The object (data + methods) |
| Data and code | Separate; data often passed around or held globally | Bundled together inside objects |
| Data protection | Any procedure can change shared data | Private attributes guard their own state |
| Reuse mechanism | Calling existing procedures | Inheritance and composition |
| Scales to large systems | Becomes hard to maintain as shared state grows | Encapsulation localises change |
| Best suited to | Small, sequential, computation-focused tasks | Large systems modelling many interacting entities |
For a short script that reads three numbers and prints their average, the procedural style is simpler and perfectly appropriate — wrapping it in classes would be over-engineering. For a school management system with students, teachers, classes, timetables and reports, the object-oriented style pays for itself many times over, because each concept becomes a self-contained, independently testable class. The right answer in an exam is rarely "OOP is always better"; it is "OOP is better here, because…".
It is tempting to treat the four pillars as four isolated definitions to memorise, but their real value emerges from how they reinforce one another in a working system. Picture a large e-commerce platform. Encapsulation ensures that each Order, Customer and Payment object guards its own data, so a discount calculation can never accidentally corrupt a stored address. Inheritance lets StandardCustomer, PremiumCustomer and WholesaleCustomer share a common Customer core while differing only where they genuinely differ, eliminating duplicated code. Polymorphism lets the checkout process iterate over a basket of differing payment methods and call process() on each without knowing whether it is a card, a voucher or a digital wallet. Abstraction means the rest of the system depends only on a clean PaymentMethod contract, so a new payment provider can be added without touching the checkout logic.
Removing any one pillar weakens the others. Without encapsulation, the inheritance hierarchy exposes its internals and subclasses become entangled with superclass implementation detail. Without abstraction, polymorphism has no shared contract to dispatch against. The pillars are best understood as four facets of a single goal: managing the complexity of large systems by localising knowledge and decoupling the parts. This systemic view is what distinguishes a top-band answer from one that merely lists definitions.
Suppose the BankAccount class above originally stored its balance as a single float. Later, to support multiple currencies, the team decides to store the balance internally as an integer number of pennies plus a currency code. Because __balance was private and every interaction went through deposit, withdraw and get_balance, this sweeping change to the internal representation requires editing only those few methods. Not a single line of the thousands of lines of client code that uses BankAccount needs to change, because none of it ever touched the internal representation directly. This is encapsulation delivering its central promise — the freedom to change how a class works internally without breaking anyone who uses it — and it is the practical reason professional codebases insist on private state. A design where balance had been a public attribute would force every one of those client sites to be found and rewritten, a far costlier and more error-prone undertaking.
A useful mental model, originating with the earliest object-oriented languages, is to think of a program as objects sending messages to one another. Calling order.total_cost() is sending the order object a "what is your total?" message; the object decides for itself how to answer using its own private data. The caller does not reach in and compute the total from the order's internals — it asks. This message-passing perspective reinforces why encapsulation is fundamental: each object is responsible for its own behaviour, and collaboration happens only through the public interface. It also clarifies the difference from procedural code, where a free function would instead be given the data and compute the result externally, with no object taking ownership of the logic.
Two further behaviours round out a working understanding of objects. The first is the distinction between identity and equality. Two variables might reference the same object (identity), or reference two different objects that happen to hold equal values (equality). In Python, is tests identity (same object in memory) while == tests equality (equal contents, as defined by the class). Creating a = Dog("Rex", "Labrador", 5) and b = Dog("Rex", "Labrador", 5) produces two distinct objects: a is b is False (different objects), even though you might want a == b to be True because their attributes match. A class controls what equality means by defining the appropriate comparison method; otherwise the default is identity. This matters whenever objects are stored in collections or compared, and it is a subtle source of bugs for students who assume two equal-looking objects are "the same".
The second is the string representation of an object. By default, printing an object yields an unhelpful memory reference. Defining a method such as Python's __str__ gives the object a human-readable form, which is invaluable for debugging and output:
class Money:
def __init__(self, pounds: int, pence: int):
self.__pounds = pounds
self.__pence = pence
def __str__(self) -> str: # controls how the object prints
return f"£{self.__pounds}.{self.__pence:02d}"
print(Money(4, 5)) # £4.05 (not <Money object at 0x...>)
Providing a sensible string representation is good practice for any class whose objects are ever displayed or logged, and it is another example of a class taking responsibility for its own behaviour — the recurring theme of object orientation. Together, controlled equality and a meaningful string form make objects pleasant to work with and far easier to debug, which is why professional class designs almost always supply both.
push/enqueue/add methods preserve invariants.RETURN of the object.An online library system needs to represent borrowers. Each Member has a name, a membership number and a count of books currently on loan (which must never be negative and must never exceed a fixed limit of 10). The system also needs to know the total number of members that have ever been created.
(a) State two of the four principles of object-oriented programming and, for each, explain one benefit. [4]
(b) Using a programming language of your choice or pseudocode, design the Member class. Your class must use encapsulation appropriately, validate the loan count, and maintain a count of all members created. [8]
AO breakdown:
class Member:
count = 0
def __init__(self, name, number):
self.name = name
self.number = number
self.loans = 0
Member.count = Member.count + 1
def borrow(self):
self.loans = self.loans + 1
Two principles: encapsulation (keeps data together) and inheritance (lets you reuse code).
The mid-band answer creates the right attributes, increments a class-level counter, and names two principles. However, the attributes are public, so there is no real encapsulation; the loan count is never validated against 0 or the limit of 10; and there is no way to read the data through a controlled interface.
class Member:
member_count = 0
def __init__(self, name: str, number: str):
self.__name = name
self.__number = number
self.__loans = 0
Member.member_count += 1
def get_loans(self) -> int:
return self.__loans
def borrow(self) -> None:
if self.__loans >= 10:
raise ValueError("Loan limit reached")
self.__loans += 1
def return_book(self) -> None:
if self.__loans <= 0:
raise ValueError("No books on loan")
self.__loans -= 1
Two principles: encapsulation — bundling data with methods and hiding it, which protects data integrity; abstraction — exposing only borrow/return_book, which hides detail and simplifies use.
This response makes the attributes private, validates both the upper limit and the lower bound, provides a getter, and maintains the static counter correctly. The chosen principles are defined with a benefit.
class Member:
"""A library borrower. Enforces 0 <= loans <= MAX_LOANS."""
MAX_LOANS = 10 # class constant — shared by every member
member_count = 0 # class attribute — total ever created
def __init__(self, name: str, number: str):
self.__name = name
self.__number = number
self.__loans = 0
Member.member_count += 1
# --- controlled read access ---
def get_name(self) -> str:
return self.__name
def get_loans(self) -> int:
return self.__loans
# --- validated mutators preserve the invariant ---
def borrow(self) -> None:
if self.__loans >= Member.MAX_LOANS:
raise ValueError(
f"{self.__name} already has the maximum "
f"{Member.MAX_LOANS} books on loan"
)
self.__loans += 1
def return_book(self) -> None:
if self.__loans <= 0:
raise ValueError(f"{self.__name} has no books on loan")
self.__loans -= 1
@classmethod
def total_members(cls) -> int:
return cls.member_count
Two principles, each with a benefit: Encapsulation — the attributes are private and every change passes through a validated method, so the invariant 0 <= loans <= 10 can never be broken from outside the class; this protects data integrity and localises bugs. Abstraction — callers interact only with borrow, return_book and the getters; the internal storage of the loan count is hidden, so it could be re-implemented (for example, as a list of Loan objects) without changing any client code.
Examiner-style commentary: The top-band answer is distinguished not by length but by robustness and justification. The loan limit is a named class constant rather than a magic number; both bounds of the loan count are validated; the membership total is a class member accessed through a class method; and the prose explains the OOP principles in terms of their concrete effect on this design. The stronger answer would also score highly; the mid-band answer loses the encapsulation and validation marks because public attributes leave the invariant unenforced. A frequent and avoidable error at every band is incrementing the counter on the instance (self.member_count += 1), which silently creates a new per-object attribute and leaves the shared class total stuck at zero.
@property decorator lets you expose an attribute-like syntax (account.balance) while still running validation behind the scenes — encapsulation without verbose method calls. Investigate how this compares with Java's convention of explicit getX/setX methods.dataclass or a record type) is the better engineering choice.This content is aligned with the AQA A-Level Computer Science (7517) specification.