Key Features of an OOP Language
🎯 (SE-11-01, SE-11-02, SE-11-08)
📌 Apply the key features of an object-oriented programming (OOP) language
- objects
- classes
- encapsulation
- abstraction
- inheritance
- generalisation
- polymorphism
This section uses one running example, a small lending system for Riverbend Library. The library lends books and DVDs to members. Every code block is real Python 3 that you can paste into an editor and run; the output shown was produced by running it. The same classes come back in later sections, where they are tested, refactored and drawn as a class diagram. NESA's syllabus glossary defines object-oriented programming as "a paradigm based on the concept of 'objects' that can contain data and code in the form of procedures". The Course Specifications add that students are expected to be able to code in Python, so the examples use it.
| Key feature | What it means | Where you will see it at Riverbend Library |
|---|---|---|
| Class | A blueprint that defines the attributes and methods every object of that type has. | Book, DVD, Member, Library |
| Object | One instance of a class, holding its own attribute values. | The book "River Road" and the DVD "Star Coast" |
| Encapsulation | Bundling data with the methods that use it, and controlling access to the data. | A member's loans can only change through borrow() and give_back() |
| Abstraction | Showing only what a user of the class needs and hiding the rest. | Code calls item.loan_days() without knowing how each item type decides |
| Inheritance | A subclass reuses the attributes and methods of a superclass. | Book and DVD inherit from LibraryItem |
| Generalisation | Finding what several classes share and moving it up into one superclass. | Title, id and loan status pulled out of Book and DVD into LibraryItem |
| Polymorphism | One message, many forms: different objects respond to the same method call in their own way. | describe() gives a different result for a book and a DVD |
Objects and Classes
A class is a blueprint. It lists the attributes (the data each object holds) and the methods (the behaviour each object can perform). An object is one instance built from that blueprint while the program runs. Two objects of the same class have the same attributes and methods, but each keeps its own values. The constructor (__init__ in Python) runs when an object is created and sets its starting state. Inside a method, self means "the object this method was called on".
class Book:
def __init__(self, title, author): # constructor: runs when an object is created
self.title = title # attributes: this object's data
self.author = author
self.on_loan = False
def describe(self): # method: behaviour
status = "on loan" if self.on_loan else "available"
return f"{self.title} by {self.author} [{status}]"
book1 = Book("River Road", "A. Nguyen") # object 1
book2 = Book("Salt Country", "M. Okafor") # object 2
book1.on_loan = True # changes book1 only
print(book1.describe())
print(book2.describe())
print(book1 is book2) # two separate objects
River Road by A. Nguyen [on loan]
Salt Country by M. Okafor [available]
False
Attributes set inside __init__ belong to each object (instance attributes). A value written directly in the class body, such as MAX_LOANS = 3 in the full program later, belongs to the class and is shared by every object (a class attribute). Use a class attribute for a rule that is the same for everyone and an instance attribute for anything that differs between objects.
Encapsulation
Encapsulation bundles an object's data with the methods that operate on it, and controls how outside code can reach that data. The aim is that an object can never be left in an invalid state by accident: every change goes through a method that can check it first. In Python, a leading underscore (_name) is a convention meaning "internal, please don't touch", and a double underscore (__loans) triggers name mangling, which renames the attribute to _Member__loans so it is awkward to reach by accident. Python does not have a true private keyword as Java and C# do, so private here is a strong signal rather than a lock. What matters for the HSC is the design intent: keep data hidden and expose a small, controlled interface.
A getter reads a value and a setter changes it, usually after validating it. Python's @property decorator lets you write them so that outside code still uses simple attribute syntax.
class Member:
def __init__(self, name, email):
self._name = name
self.email = email # goes through the setter below
self.__loans = [] # private list of borrowed items
@property
def email(self): # getter
return self._email
@email.setter
def email(self, value): # setter with validation
if "@" not in value:
raise ValueError("email must contain @")
self._email = value
@property
def loan_count(self): # read-only: there is no setter
return len(self.__loans)
m = Member("Priya", "priya@example.com")
m.email = "priya.s@example.com" # allowed: passes validation
print(m.email, m.loan_count)
try:
m.email = "not-an-email" # rejected by the setter
except ValueError as err:
print("Rejected:", err)
try:
m.loan_count = 5 # no setter, so this is refused
except AttributeError as err:
print("Refused:", err)
print(hasattr(m, "__loans"), hasattr(m, "_Member__loans")) # name mangling in action
priya.s@example.com 0
Rejected: email must contain @
Refused: property 'loan_count' of 'Member' object has no setter
False True
Python's exact error wording differs slightly between versions (for example, the abstract-class message below), but the type of error is the same.
Concept diagram
Encapsulation: what outside code can reach
Outside code uses the public methods; the private data can only change through them.
What to notice
- The only route to the private data is through methods that can check the request.
- This is why a bank account can never be given a negative balance by mistake.
Try thisWhat would go wrong if __balance were public?
Try it
Objects and encapsulation: the Member class
Send messages to Member objects and see why encapsulation protects the rules of a class. Enable JavaScript to use this tool. In short: borrow() checks the loan limit before changing the private list of loans, and code that reaches inside can break the rule.
Abstraction
Abstraction means modelling only the details that matter for the problem and hiding the rest behind a simple interface. A Member class for a library needs a name and an email address; it does not need height or favourite colour. A user of the class calls borrow(item) without knowing how the loan list is stored, and the developer can change the storage from a list to a database later without breaking any calling code. This is why abstraction reduces complexity and allows the internal implementation to change without affecting the parts of the program that use it.
An abstract class takes this further. It defines a method that every subclass must provide, but does not say how, and it cannot be instantiated itself. Python provides this through the abc module.
from abc import ABC, abstractmethod
class LibraryItem(ABC):
def __init__(self, title):
self._title = title
@abstractmethod
def loan_days(self): # a promise: every item type must answer this
...
class Book(LibraryItem):
def loan_days(self):
return 14
class DVD(LibraryItem):
def loan_days(self):
return 7
print(Book("River Road").loan_days(), DVD("Star Coast").loan_days())
try:
LibraryItem("Something") # an abstract idea, not a real item
except TypeError as err:
print("TypeError:", err)
14 7
TypeError: Can't instantiate abstract class LibraryItem with abstract method loan_days
Inheritance
Inheritance lets a new class (the subclass or child) take on the attributes and methods of an existing class (the superclass or parent) and then add or change behaviour. It models an is-a relationship: a Book is a library item. A subclass can add new attributes and methods (a Book has an author), reuse inherited ones unchanged (check_out()), or override a method by defining one with the same name. Inside an overriding method, super() calls the parent's version so the subclass can extend it rather than repeat it.
class LibraryItem:
def __init__(self, item_id, title):
self._item_id = item_id
self._title = title
self._on_loan = False
def check_out(self):
if self._on_loan:
return False
self._on_loan = True
return True
def describe(self):
status = "on loan" if self._on_loan else "available"
return f"{self._title} [{status}]"
class Book(LibraryItem):
def __init__(self, item_id, title, author):
super().__init__(item_id, title) # reuse the parent's constructor
self._author = author # add something new
def describe(self): # override, but extend using super()
return f"{super().describe()} by {self._author}"
class ReferenceBook(Book):
def check_out(self): # override completely: never lendable
return False
atlas = ReferenceBook("R1", "Atlas of Australia", "P. Smith")
print(atlas.check_out()) # ReferenceBook's own version
print(atlas.describe()) # Book's version, which calls LibraryItem's
print(isinstance(atlas, Book), isinstance(atlas, LibraryItem))
False
Atlas of Australia [available] by P. Smith
True True
Use inheritance only when the is-a test is honest. A Member is not a kind of LibraryItem, so it must not inherit from it. When the relationship is has-a (a library has items; a member has loans), use composition: hold the other object in an attribute. Deep chains of inheritance are harder to read than shallow ones, so two or three levels is plenty for a school project.
Class diagram
Class diagram: a vehicle hierarchy
Car, Motorcycle and Truck inherit from Vehicle and add their own attributes and methods.
What to notice
- Each class box has three parts: name, attributes, methods.
- The filled arrowhead points to the parent class (inheritance), as in the NESA Course Specifications.
- The plain arrow with 1 and 1 is a relationship: each vehicle has exactly one engine.
Try thisAdd an ElectricCar class. Which class should it inherit from, and what attribute would it add?
Generalisation
Generalisation is the design activity that produces a superclass. You notice that several classes repeat the same attributes and methods, so you move the shared parts up into a new parent class and leave only the differences in each child. Inheritance is the mechanism; generalisation is the reasoning that decides what to inherit. The opposite direction, adding a more specific subclass to an existing class, is specialisation.
class Book:
def __init__(self, item_id, title, author):
self._item_id = item_id # duplicated
self._title = title # duplicated
self._on_loan = False # duplicated
self._author = author
class DVD:
def __init__(self, item_id, title, minutes):
self._item_id = item_id # duplicated
self._title = title # duplicated
self._on_loan = False # duplicated
self._minutes = minutes
class LibraryItem: # the generalisation
def __init__(self, item_id, title):
self._item_id = item_id
self._title = title
self._on_loan = False
class Book(LibraryItem): # only what makes a Book different
def __init__(self, item_id, title, author):
super().__init__(item_id, title)
self._author = author
class DVD(LibraryItem): # only what makes a DVD different
def __init__(self, item_id, title, minutes):
super().__init__(item_id, title)
self._minutes = minutes
The benefit is that a rule such as "an item cannot be checked out twice" is written once. Fix a bug in LibraryItem and every subclass is fixed as well.
Polymorphism
Polymorphism means "many forms". The same message (method call) can be sent to objects of different classes, and each responds in the way that suits its own class. The calling code does not need to check what type it has. Python gives you polymorphism in two ways:
- Overriding (inheritance-based polymorphism): subclasses provide their own version of a method that the superclass defines. Python decides which version to run when the call happens, by looking at the class of the object receiving the message.
- Duck typing: if an object has the method being called, it works, whatever its class. "If it walks like a duck and quacks like a duck, it is treated as a duck." The classes do not need a shared parent.
class LibraryItem:
def __init__(self, title):
self._title = title
def describe(self):
return f"{self._title}"
def loan_days(self):
return 0
class Book(LibraryItem):
def loan_days(self): # override
return 14
def describe(self):
return f"Book: {self._title}"
class DVD(LibraryItem):
def loan_days(self): # override
return 7
def describe(self):
return f"DVD: {self._title}"
class MobileLibraryVan: # NOT a LibraryItem: no shared parent
def describe(self):
return "Van: Riverbend Mobile Library"
def loan_days(self):
return 3
things = [Book("River Road"), DVD("Star Coast"), MobileLibraryVan()]
for thing in things: # the loop never checks the type
print(thing.describe(), "->", thing.loan_days(), "days")
Book: River Road -> 14 days
DVD: Star Coast -> 7 days
Van: Riverbend Mobile Library -> 3 days
The MobileLibraryVan works in the loop purely by duck typing: it has describe() and loan_days(), so the loop accepts it. The benefit is that new types can be added without changing existing code that sends the messages. Note that Python does not support method overloading (several methods with one name but different parameter lists) in the way Java does. The last definition wins, and you use default arguments instead. On the HSC, "polymorphism" almost always means overriding.
A fast, beginner-friendly tour of the four pillars — encapsulation, abstraction, inheritance, and polymorphism — with simple real-world analogies.
A clear, hands-on introduction to classes and objects — the core idea behind object-oriented programming.
Try it
Which feature of OOP?
Practise naming encapsulation, abstraction, inheritance, polymorphism and generalisation. Enable JavaScript to use this tool. In short: encapsulation hides data behind methods, abstraction shows only what is needed, inheritance reuses a parent class, polymorphism answers one message in different ways, and generalisation moves shared features into a parent.
Comparing Procedural Programming with OOP
🎯 (SE-11-02)
📌 Compare procedural programming with OOP
Both paradigms can solve the same problem. The difference is how the code is organised: procedural programs are a sequence of functions that act on data passed between them, while OOP programs are made of objects that keep their data and the methods that use it together. Procedural programming is one style of the imperative paradigm (statements that change state, organised into subroutines), so the two are related rather than opposites: an OOP method body is itself imperative code.
| Aspect | Procedural Programming | Object-Oriented Programming |
|---|---|---|
| Core Focus | Step-by-step logic and function calls. | Interaction between modular objects. |
| Data Handling | Data is kept apart from the functions, passed as parameters or shared as global variables. | Data and methods are encapsulated together; access can be restricted. |
| Reusability | Reuse through functions and modules; supporting a new kind of data usually means editing or copying functions. | Reuse through classes, inheritance and composition; a new kind of object can be added as a subclass. |
| Maintenance | A change to how data is stored can ripple through every function that touches it. | Changes to internal details stay inside the class as long as its interface stays the same. |
| Modelling | Models the process: what happens, in what order. | Models the things in the problem and what each is responsible for. |
| Best suited to | Short scripts, calculations and pipelines with a clear start-to-finish flow. | Larger systems with several kinds of entity that have their own data and behaviour. |
Concept diagram
Procedural and object-oriented code
The same goals, organised differently.
What to notice
- Procedural code keeps data separate from the functions that change it.
- OOP keeps each object's data with its own methods.
Try thisWould you use procedural or OOP for a one-off script that renames files? Why?
The same task, two ways
balance = 100
def withdraw(balance, amount):
return balance - amount
balance = withdraw(balance, 30) # any code can change balance directly
class Account:
def __init__(self, balance):
self.__balance = balance # hidden inside the object
def withdraw(self, amount):
if amount <= self.__balance: # the object protects its own data
self.__balance -= amount
acc = Account(100)
acc.withdraw(30)
Notice that the procedural withdraw() happily returns a negative balance, and nothing stops other code from writing balance = -500. In the OOP version the rule lives beside the data it protects.
What changes when a new type is added?
The real difference shows up when the program has to grow. At Riverbend Library, books are lent for 14 days. The library now adds DVDs, lent for 7 days. Compare the two designs.
def loan_days(item):
if item["type"] == "book":
return 14
elif item["type"] == "dvd": # edited to add DVDs
return 7
def describe(item):
if item["type"] == "book":
return f"Book: {item['title']}"
elif item["type"] == "dvd": # edited again
return f"DVD: {item['title']} ({item['minutes']} min)"
items = [{"type": "book", "title": "River Road"},
{"type": "dvd", "title": "Star Coast", "minutes": 95}]
for item in items:
print(describe(item), loan_days(item))
Book: River Road 14
DVD: Star Coast (95 min) 7
class Book:
def __init__(self, title):
self.title = title
def loan_days(self):
return 14
def describe(self):
return f"Book: {self.title}"
class DVD: # the only new code
def __init__(self, title, minutes):
self.title = title
self.minutes = minutes
def loan_days(self):
return 7
def describe(self):
return f"DVD: {self.title} ({self.minutes} min)"
items = [Book("River Road"), DVD("Star Coast", 95)]
for item in items: # this loop did not change
print(item.describe(), item.loan_days())
Book: River Road 14
DVD: Star Coast (95 min) 7
In the procedural version, adding a third type (say, an audio book) means finding and editing every function that has an if on type, and forgetting one causes a bug. In the OOP version it means writing one new class. That is the practical meaning of "easier to extend and maintain".
Other paradigms in the Course Specifications
The Course Specifications name four paradigms. You are expected to know what each involves, and this course asks you to compare procedural code with OOP specifically. In one line each:
| Paradigm | Organised around | Typical building blocks |
|---|---|---|
| Imperative / procedural | Commands that change state, grouped into subroutines | Variables, assignment, control structures, subroutines |
| Object-oriented | Objects that hold state and respond to messages | Classes, objects, inheritance, polymorphism, encapsulation, message passing |
| Functional | Functions applied to values, avoiding shared changing state | Function calls, recursion, functions as first-class values, collections |
| Logic | Facts and rules from which the system works out answers | Facts, rules, queries |
Python supports several of these in one program, so a real project often mixes them: OOP for the overall structure, with small functional-style helpers inside methods.
Representing a System: DFDs, Structure Charts and Class Diagrams
🎯 (SE-11-01, SE-11-06)
📌 Use data flow diagrams, structure charts and class diagrams to represent a system
The Course Specifications give you three modelling tools. Each shows a different view of the same system, so choosing the right one is part of the skill. All three use NESA's notation, summarised on the Course Tools & Specs page.
| Tool | Question it answers | Use it in OOP to show |
|---|---|---|
| Class diagram (Course Specifications p. 9) | What are the classes and how are they related? | Classes with their attributes and methods, inheritance, and relationships with multiplicities. The primary tool for OOP. |
| Data flow diagram (p. 5) | Where does data come from, where does it go and how is it changed? | How data moves between external entities, processes and data stores, before you decide which classes will do the work. |
| Structure chart (p. 7) | How is the program broken into modules and what do they pass to each other? | The calls and parameters between modules or methods, such as a mainline calling subroutines. Common in top-down design. |
Class diagrams: NESA notation
A class diagram models classes, their attributes and methods, and the relationships between classes. It shows the structure of the system, not the order in which things happen. The symbols in the Course Specifications are:
| Symbol | Meaning |
|---|---|
| Box in three parts | Class name at the top, attributes in the middle, methods at the bottom. |
| Line with a closed triangle head | Inheritance. The triangle touches the parent class; the line starts at the child. |
| Line with an open arrow head, and a label | A relationship between two classes, named with a verb such as "borrows". |
1..1 0..* 1..* 0..1 m..n | Multiplicity: only one; zero or more; one or more; zero or only one; at least m and at most n. |
Class diagram
Class diagram: the Riverbend Library system
The running example from this page: three item classes, a member and the library.
What to notice
- Book and DVD point to LibraryItem with the triangle at the parent, and ReferenceBook points to Book: a three-level hierarchy.
- Only the differences appear in the child boxes. Book does not repeat itemId or title, because it inherits them.
- 0..1 at the Member end and 0..3 at the LibraryItem end together say: an item is on loan to at most one member, and a member has up to three items.
Try thisAdd a Fine class. A member can have zero or more fines, and each fine belongs to exactly one member. Which multiplicity goes at which end?
Reading multiplicities
A multiplicity is written at the end of the line next to the class it counts, and answers "for one object at the other end, how many objects of this class?". Read each line in both directions and say it as a sentence. That is also how to check your own diagram.
| On the diagram | Read as |
|---|---|
Library 1..1 holds 0..* LibraryItem | One library holds zero or more items. Each item belongs to exactly one library. |
Member 0..1 borrows 0..3 LibraryItem | A member borrows between zero and three items. An item is borrowed by zero or one member (zero when it is on the shelf). |
Student 0..* studies 1..* Subject (Course Specifications example) | A student must study one or more subjects. A subject can have zero or more students enrolled. |
From diagram to code
A class diagram is a plan for code, and the mapping is direct. Each box becomes a class, each attribute becomes an instance attribute, each method becomes a method, an inheritance arrow becomes the parent in brackets, and a relationship with a multiplicity becomes an attribute that holds the other object or a collection of them. The upper limit of a multiplicity (the 3 in 0..3) becomes a check in the code.
from abc import ABC, abstractmethod
class LibraryItem(ABC): # box: LibraryItem
def __init__(self, item_id, title):
self._item_id = item_id # attribute: itemId
self._title = title # attribute: title
self._on_loan = False # attribute: onLoan
@abstractmethod
def loan_days(self): ... # method: loanDays()
class Book(LibraryItem): # inheritance arrow: Book -> LibraryItem
def __init__(self, item_id, title, author):
super().__init__(item_id, title)
self._author = author # attribute only Book has
def loan_days(self):
return 14
class ReferenceBook(Book): # inheritance arrow: ReferenceBook -> Book
pass
class Member:
MAX_LOANS = 3 # multiplicity 0..3 becomes a limit
def __init__(self):
self.__loans = [] # relationship "borrows": zero or more items
print([c.__name__ for c in ReferenceBook.__mro__]) # inheritance chain, child first (ABC and object are Python's own)
['ReferenceBook', 'Book', 'LibraryItem', 'ABC', 'object']
Data flow diagrams and structure charts in an OOP project
- Data flow diagram (Course Specifications p. 5): useful early, for showing how data moves between external entities (a member), processes (Check out item) and data stores (Loans). It shows the transformation of data and does not mention classes, so it helps you discover what the classes must do.
- Structure chart (Course Specifications p. 7): a hierarchy of modules with the data and flags passed between them. In OOP it is a good way to plan the calls inside one operation, for example a
checkout()method that callsfind_member(),find_item()andborrow().
Structure chart
Structure chart: student management
The modules of the system and what they pass to each other.
What to notice
- The diamond means only one of the three main tasks runs each time.
- Enrol student sends Details down and gets a Valid flag back.
Try thisDraw the structure chart for Produce report, refining it into smaller modules.
Data flow diagram
Data flow diagram: canteen ordering (Level 1)
How data moves between people, processes and stored data.
What to notice
- Processes are circles, data stores are open-ended rectangles, and external entities are rectangles.
- Every arrow is labelled with the data it carries.
Try thisAdd a process 3 Take payment. Which entity and data store would it connect to?
Online Tool Suggestion: Try Lucidchart, Draw.io, or Mermaid.js to create professional UML Class Diagrams and DFDs. Tools may add UML extras (visibility signs, diamonds for composition) that are not in the Course Specifications; in an assessment, stick to NESA's symbols.
Which diagram for which question?
| Situation | Best tool | Why |
|---|---|---|
| "Show how the library's classes are related." | Class diagram | Structure: classes, inheritance and multiplicities. |
| "Show how a loan request flows from the member to the records." | Data flow diagram | Movement of data between entities, processes and stores. |
| "Show how the checkout task is divided into modules." | Structure chart | Module hierarchy with the data passed between modules. |
| "Show the order in which two objects exchange messages." | Sequence diagram (used on this page for message passing; not one of the three required tools) | Time order of calls between objects. |
0..* or 1..*. NESA's published 2025 HSC marking guidelines (the only published exam so far, so treat it as one example, not a promise) included a 3-mark class diagram question about inheritance and removing redundant attributes, and marker feedback asked for "appropriate symbols for class diagrams".The Design Process in OOP
🎯 (SE-11-01)
📌 Describe the process of design used to develop code in an OOP language
- task definition
- top-down and bottom-up
- facade pattern
- agility
Design is what happens between "we have a need" and "we start typing code". In OOP the process is about deciding which classes exist, what each is responsible for and how they work together. The four parts below are usually used together, not one after another.
Task Definition
The initial phase where engineers formally outline the problem scope and define the specific objects and classes required to solve it. This involves understanding the requirements, identifying the key entities (objects) in the problem domain, and determining their relationships and responsibilities. A useful technique is to write the need as a short statement and then read it for nouns (candidate classes or attributes) and verbs (candidate methods).
| Found in the statement | Design decision |
|---|---|
| Nouns: library, member, item, book, DVD, reference book | Candidate classes. "Book", "DVD" and "reference book" are kinds of item, so LibraryItem is a generalisation and Book, DVD and ReferenceBook are subclasses. |
| Nouns that describe a noun: title, author, minutes, email | Attributes of those classes (title belongs to every item; author only to a book). |
| Verbs: lends, borrow, check out, check in, print | Methods: borrow(), check_out(), check_in(), report(). |
| Rules: up to three items; 14 days; 7 days; never borrowed | A limit in Member, and overriding of loan_days() and check_out() in the subclasses. |
| Inputs and outputs: member id and item id in; a message and a report out | The parameters and return values of Library.checkout() and Library.report(). |
The result of task definition is a written list of requirements, a first list of classes with responsibilities, and a draft class diagram (see the previous section).
Top-down and Bottom-up Design
Top-down design starts with high-level system requirements and breaks them down into smaller sub-systems or classes. This approach defines the overall structure first, then fills in details for each class. Bottom-up design involves building and testing robust base classes first, then combining them to create the final application. Effective OOP engineering often uses a hybrid approach, ensuring individual objects are functional (bottom-up) while meeting the overall system architecture (top-down).
| Top-down | Bottom-up | |
|---|---|---|
| Starts with | The whole system and its main task | The smallest reusable classes |
| Riverbend example | Write Library.checkout() first, calling classes that do not exist yet | Write and test LibraryItem and Book first, then Member, then Library |
| Needs | Stubs for the parts not yet written | A test for each class before it is combined |
| Strength | The overall structure is clear early; teams can work in parallel against agreed interfaces | Every finished part is already tested and reusable |
| Risk | Details discovered later may force the top to change | The parts may not fit together into what the user needs |
A top-down start looks like this. The top-level behaviour is written and can already be run, while each lower-level class is only a stub (a placeholder that returns a fixed answer) until it is developed properly.
class Member: # stub: real rules not written yet
def borrow(self, item):
return True # always says yes for now
class Book: # stub
def __init__(self, title):
self.title = title
class Library: # the top of the design
def checkout(self, member, item):
if member.borrow(item):
return f"Checked out {item.title}"
return f"Cannot lend {item.title}"
print(Library().checkout(Member(), Book("River Road")))
Checked out River Road
The stubs let you run and test the shape of the program on day one. Later you replace each stub with the real class (Member with the three-item limit, Book inheriting from LibraryItem) and the same Library.checkout() keeps working.
Facade Pattern
A design pattern is a proven, reusable solution to a common design problem. The facade pattern provides one simplified interface to a complex set of classes, hiding the internal complexity from the user. A single facade class coordinates the internal classes, so the rest of the program deals only with the facade. This reduces coupling: the client no longer depends on every subsystem class, and the subsystems can change without the client noticing.
Checking out a book involves three subsystems: checking the member's card, finding the item in the catalogue and recording the loan. A LibraryDesk facade lets the client do all three with one call.
class MemberRegistry: # subsystem 1
def __init__(self):
self._cards = {"M1": "Priya", "M2": "Tom"}
def is_valid(self, member_id):
return member_id in self._cards
class Catalogue: # subsystem 2
def __init__(self):
self._titles = {"B1": "River Road", "B2": "Salt Country"}
def find(self, item_id):
return self._titles.get(item_id)
class LoanRecorder: # subsystem 3
def __init__(self):
self._loans = []
def record(self, member_id, item_id):
self._loans.append((member_id, item_id))
class LibraryDesk: # the FACADE: one simple interface
def __init__(self):
self._registry = MemberRegistry()
self._catalogue = Catalogue()
self._recorder = LoanRecorder()
def borrow(self, member_id, item_id):
if not self._registry.is_valid(member_id):
return "Invalid member card"
title = self._catalogue.find(item_id)
if title is None:
return "No such item"
self._recorder.record(member_id, item_id)
return f"Loan recorded: {title}"
desk = LibraryDesk() # the client only ever meets the facade
print(desk.borrow("M1", "B2"))
print(desk.borrow("M9", "B1"))
print(desk.borrow("M2", "B7"))
Loan recorded: Salt Country
Invalid member card
No such item
The client never creates or calls MemberRegistry, Catalogue or LoanRecorder. If the catalogue is later moved from a dictionary to a database, only LibraryDesk changes. Use a facade when a group of classes is complicated to use directly; do not add one when a single class would do, because an unnecessary layer is extra code to maintain. The Library.checkout() method in the full program later on this page is a small facade of the same kind.
Agility
Agility in OOP design refers to implementing an iterative development cycle where code is regularly reviewed and updated based on testing, user feedback, and changing requirements. Agile approaches favour frequent refactoring, continuous improvement, and adaptive design rather than rigid upfront planning. This flexibility allows software to evolve as understanding of the problem deepens.
OOP suits an agile approach because well-separated classes can be changed one at a time. A possible sequence for the library:
- Iteration 1: a
Bookclass that can be checked out and returned, with tests. Show it to the librarian. - Iteration 2: feedback says DVDs are needed. Duplicated code appears, so generalise it into
LibraryItemand re-run the tests to prove nothing broke. - Iteration 3: feedback asks for a loan limit per member. Add it inside
Member.borrow(); no other class changes.
Each pass is short, is tested and is checked with the client, so wrong assumptions are found early and cheaply. Agile does not mean "no design": the class diagram is still drawn, but it is updated as the design changes. See the SDLC Guide for how agile compares with a plan-driven approach.
Library.checkout() first using stubs". If a question names a pattern, say what problem it solves and how the classes are arranged: for a facade, "one class gives a simple interface to several subsystem classes". Do not confuse top-down and bottom-up, and do not describe a facade as inheritance.Assessing the Effectiveness of Code
🎯 (SE-11-07)
📌 Assess the effectiveness of programming code developed to implement an algorithm
To assess code, you make a judgement about how well it implements the algorithm, backed by evidence from testing and reading the code. Use the criteria below, and say which ones matter most for the task.
Evaluation Criteria
- Correctness: Does the code produce the expected output for all valid inputs? Does it correctly implement the algorithm's logic?
- Efficiency: Does the code run efficiently in terms of time complexity (execution speed) and space complexity (memory usage)? Can it handle large datasets?
- Maintainability: Is the code well-structured, clearly commented, and easy for other developers to understand and modify? Does it follow OOP principles?
- Robustness: Does the code handle edge cases and errors gracefully without crashing?
- Scalability: Can the code be extended with new features or adapted to larger problems without major restructuring?
In OOP, effectiveness is enhanced through proper use of classes, inheritance, and encapsulation. A well-designed class hierarchy makes code more testable, reusable, and maintainable. Poor design, such as overly deep inheritance chains or god objects (classes that do too much), reduces effectiveness.
Where the evidence comes from
| Criterion | Evidence you can gather |
|---|---|
| Correctness | A test table of normal, boundary and abnormal inputs with expected and actual results (see the testing section). |
| Efficiency | Count the steps the algorithm takes for a small and a large input, or time it, and say how it grows. |
| Maintainability | Read the code: clear names, one task per method, no duplicated blocks, private data behind methods, comments that explain why. |
| Robustness | Try invalid input (empty, negative, wrong type, missing item) and see whether the program fails safely with a useful message. |
| Scalability | Ask what it would take to add a new item type or ten thousand records. If the answer is "edit five methods", the design does not scale. |
Worked assessment: two ways to find a member
Riverbend Library needs a method that finds a member by id. Two developers implement it. Both pass the same tests, so both are correct. The step counts below are measured by the program itself, so they are the same every time it is run.
class Member:
def __init__(self, member_id, name):
self.member_id = member_id
self.name = name
def find_in_list(members, member_id): # Version A: scan the list
steps = 0
for m in members:
steps += 1
if m.member_id == member_id:
return m, steps
return None, steps
def find_in_dict(index, member_id): # Version B: look up in a dictionary
return index.get(member_id), 1
for size in (10, 1000):
members = [Member(f"M{i}", f"Person {i}") for i in range(size)]
index = {m.member_id: m for m in members}
last = f"M{size - 1}" # worst case for the list
_, a = find_in_list(members, last)
_, b = find_in_dict(index, last)
print(f"{size} members: list needs {a} steps, dictionary needs {b}")
print("empty library:", find_in_list([], "M1")[0], find_in_dict({}, "M1")[0]) # robustness
10 members: list needs 10 steps, dictionary needs 1
1000 members: list needs 1000 steps, dictionary needs 1
empty library: None None
An assessment of these two might read: Both versions are correct and both handle an empty library by returning None (robustness). Maintainability is similar, because each is short and clearly named. Efficiency differs: the list version's steps grow with the number of members, whereas the dictionary version takes one step however large the library gets. Because a public library could have thousands of members, Version B is the more effective implementation, at the cost of keeping the dictionary in step with the list if members are added or removed. Notice the pattern: a claim, evidence, and a weighed judgement that includes a trade-off.
Design problems that reduce effectiveness in OOP
| Problem | What it looks like | Effect | Fix |
|---|---|---|---|
| God object | One Library class that stores data, validates input, prints reports and sends emails | Hard to test and to change; every edit risks breaking something unrelated | Split into classes with one responsibility each |
| Public data | Other code writes member.loans = [] directly | Rules such as the three-item limit can be bypassed | Encapsulate; change state only through methods |
| Duplicated code in subclasses | Book and DVD both copy the same check-out logic | A fix has to be made in several places | Generalise into a superclass |
| Wrong or very deep inheritance | A Member inheriting from LibraryItem to reuse one method | Confusing design; changes to the parent break unrelated children | Use inheritance only for a true is-a relationship; otherwise use composition |
| Tight coupling | A class reaches into another class's internal attributes | Changing one class forces changes in the other | Send messages through public methods |
Message-Passing Between Objects
🎯 (SE-11-08)
📌 Investigate how OOP languages handle message-passing between objects
In an object-oriented program, objects work by asking each other to do things. When Object A calls a method on Object B, Object A is sending a message to Object B requesting it to perform a specific action. The syntax looks like objectB.methodName(arguments). This allows for decoupling: Object A needs to know what Object B can do (its interface), but not how it does it (its implementation).
Anatomy of a message
| Part | In account.withdraw(500) | Role |
|---|---|---|
| Receiver | account | The object that receives the message and decides how to respond. |
| Method name | withdraw | Names the request. Together with the parameters it forms the receiver's interface. |
| Arguments | 500 | Extra information the receiver needs (parameter passing). |
| Reply | the returned True or False | What comes back to the sender. A method with no return replies with None. |
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
# Message received: "withdraw this amount from me"
if amount <= self.balance:
self.balance -= amount
return True
return False
account = BankAccount(800)
result = account.withdraw(500) # the caller sends the message and gets a reply
print(result, account.balance)
print(account.withdraw(500), account.balance) # same message, different reply
True 300
False 300
How Python handles a message
Python decides what a message means at run time. When the line item.describe() runs, Python looks at the class of the object that item refers to, searches that class and then its parent classes for a method called describe, and runs the first one it finds. This is called dynamic dispatch. It is the mechanism that makes polymorphism work: the same message goes to different receivers and each finds its own method. If no class in the chain has the method, Python raises an AttributeError, which is the receiver saying "I do not understand that message".
class Book:
def describe(self):
return "a book"
class DVD:
def describe(self):
return "a DVD"
class Chair: # no describe() method at all
pass
for receiver in (Book(), DVD(), Chair()):
try:
print(receiver.describe())
except AttributeError as err:
print("Not understood:", err)
a book
a DVD
Not understood: 'Chair' object has no attribute 'describe'
Objects collaborating through messages
A real operation is a chain of messages. When the librarian asks the library to check out an item, the library asks the member to borrow it, the member asks the item to check itself out, and each replies to whoever called it. No object reaches inside another to change its data. This trace uses the classes from the full program in the design section:
| Step | Sender | Message | Receiver | Reply |
|---|---|---|---|---|
| 1 | main program | checkout("M1", "B1") | Library | a message string (at the end) |
| 2 | Library | borrow(item) | Member | True or False |
| 3 | Member | check_out() | LibraryItem | True (item was free) or False (already on loan) |
| 4 | Member | adds the item to its own loan list, then replies to the Library | True | |
| 5 | Library | loan_days() and title | LibraryItem | 14 and "River Road", used to build the reply for step 1 |
Tell, don't ask
Good message passing tells an object to do something rather than asking for its data and then making the decision for it. The first version below breaks encapsulation, because the caller has to know how the loan list works and repeats the rule; the second sends one message and leaves the rule inside Member.
class Member:
MAX_LOANS = 3
def __init__(self):
self.loans = [] # public: anyone can reach in
def borrow(self, item): # the rule lives here
if len(self.loans) >= Member.MAX_LOANS:
return False
self.loans.append(item)
return True
m = Member()
for title in ("A", "B", "C"):
m.borrow(title)
# ASK: the caller looks inside and repeats the rule, but writes <= instead of <
if len(m.loans) <= 3:
m.loans.append("D")
print("asking version:", len(m.loans), "loans") # the limit has been broken
# TELL: one message; Member applies its own rule.
print("telling version:", m.borrow("E"))
asking version: 4 loans
telling version: False
The "asking" version copied the rule into the caller and got it slightly wrong, so the member ended up with four items. Every place that copies a rule is another place for that mistake. In the "telling" version the rule exists once, inside borrow(), and the request was correctly refused. Making loans private as well would stop code from bypassing borrow() altogether.
Benefits and limits
This investigated feature enables several OOP benefits:
- Modularity: Each object is independent and can be tested, modified, or replaced without affecting other objects that use its interface.
- Flexibility: Polymorphism allows different objects to respond to the same message in different ways based on their class type.
- Scalability: New classes can be added without modifying existing code that sends messages through a common interface.
- Maintainability: Changes to an object's internal implementation don't affect other objects as long as the interface (method signatures) remains the same.
There are limits and costs. Each message is a function call, so a design with very many tiny objects sending long chains of messages can be harder to follow and slightly slower. A change to a method's name or parameters is a change to the contract, and every sender must be updated. In a dynamically typed language such as Python, a misspelt message is only discovered when that line runs, which is one more reason for tests. Weigh these against the gains: for most systems the clarity of small, well-defined interfaces is worth far more than the cost of the calls.
Sequence diagram
Objects passing messages
The client asks the account to withdraw; the account checks with a validator and replies.
What to notice
- Each arrow is a message: one object calling another object's method.
- The client never sees how the balance is checked, only the result.
Try thisAdd a message that records the withdrawal in a TransactionLog object.
account.withdraw(500), explain how the language handles it (the method is found in the receiver's class at run time, which is why the same message can produce different results), and give at least one benefit and one limitation. A frequent mistake is describing message passing as "sending data" without saying that the receiver runs a method.Code Optimisation
🎯 (SE-11-08)
📌 Explain code optimisation in software engineering
Code optimisation means changing a program so that it uses fewer resources (time, memory, storage or network) while producing exactly the same results. Optimised code performs better on the target hardware, consumes fewer resources, and provides a better user experience. The important word is same: if the behaviour changes, that is a new feature or a bug, not an optimisation.
Measure first
Programmers are notoriously poor at guessing which part of a program is slow. The professional approach is to profile the code, meaning to measure where the time or memory actually goes, and then to optimise only that part. Python has the time module for simple timing and cProfile for a full breakdown by function.
import time
names = [f"Person {i}" for i in range(20000)]
lookup = {name: True for name in names}
target = "Person 19999" # worst case for the list
start = time.perf_counter()
for _ in range(200):
target in names # scans the list
list_time = time.perf_counter() - start
start = time.perf_counter()
for _ in range(200):
target in lookup # one hash lookup
dict_time = time.perf_counter() - start
print("dictionary faster:", dict_time < list_time) # exact times vary between computers
dictionary faster: True
The exact times differ from computer to computer, so a good report compares ratios or shows the timing method rather than quoting one number.
Optimisation Techniques in OOP
- Algorithm Refinement: Improving the efficiency of algorithms within methods by choosing faster data structures or reducing iterations. Replacing a list scan with a dictionary lookup, as in the worked assessment earlier, is the classic case.
- Lazy Instantiation: Creating objects only when needed, rather than upfront, to reduce memory overhead and start-up time.
- Caching: Storing results of expensive operations to avoid recalculating them.
- Avoiding repeated work: Moving a calculation that gives the same answer every time out of a loop, or storing a value in a variable instead of asking for it again.
- Inheritance Hierarchy Optimisation: Keeping inheritance chains as shallow as the design allows. In Python a call walks up the chain until it finds the method, which costs very little, so the main gain from a shallow hierarchy is that the code is simpler to read and change.
- Code Profiling: Identifying bottlenecks (slow parts) in code using profiling tools to target optimisation efforts where they matter most.
class ReportGenerator:
def __init__(self):
print(" (building the report generator: slow, uses memory)")
def make(self, title):
return f"Report: {title}"
class Library:
def __init__(self):
self._reports = None # nothing built yet
@property
def reports(self):
if self._reports is None: # build on first use only
self._reports = ReportGenerator()
return self._reports
lib = Library()
print("Library created") # no ReportGenerator yet
print(lib.reports.make("Overdue items")) # built now
print(lib.reports.make("New members")) # reused, not rebuilt
Library created
(building the report generator: slow, uses memory)
Report: Overdue items
Report: New members
Before and after
class Report:
def __init__(self, marks):
self.marks = marks
self._average = None # not calculated yet
def average(self):
if self._average is None: # calculate once…
self._average = sum(self.marks) / len(self.marks)
return self._average # …then reuse it
If a web page calls average() 50 times, the original version adds up every mark 50 times; the optimised version does it once. The output is the same, but the program does less work. The cost is that the stored value goes out of date if marks changes, so a cached class must reset _average whenever the data it depends on changes. Python also provides functools.lru_cache to cache the results of a function automatically.
from functools import lru_cache
calls = 0
@lru_cache(maxsize=None)
def loan_fee(days_late):
global calls
calls += 1 # counts real calculations
return round(0.25 * days_late, 2)
for _ in range(1000):
loan_fee(8) # 1000 requests for the same answer
print(loan_fee(8), "calculated", calls, "time(s)")
2.0 calculated 1 time(s)
Structure supports optimisation
Well-structured code is easier to optimise. Because each method does one task, a profiler points at one small method rather than at a 200-line function, and because callers use only the interface, the method's insides can be replaced without touching anything else.
class SystemManager:
def __init__(self):
self.active = True # Initialise state
def run_task(self): # One logical task per subroutine
if self.active:
self.__log_event() # Modular call
def __log_event(self): # Private helper
pass # Ease of maintenance
The trade-off: speed against readability
Optimisation has costs: more code, more complexity and often more bugs. Premature optimisation, tuning code before you know it is slow, wastes effort and makes code harder to understand without providing meaningful performance gains. A sensible order is: make it correct, make it clear, measure, and only then make it faster where the measurements say it matters. Keep the tests from earlier so that you can prove each optimisation left the results unchanged.
OOP Features That Support Collaboration
🎯 (SE-11-01, SE-11-06)
📌 Outline the features of OOP that support collaborative code development
- consistency
- code commenting
- version control
- feedback
Large programs are written by teams, and even a solo student project is read by a teacher, a partner and your own future self. OOP helps because a program is divided into classes with clear interfaces, so different people can build different classes at the same time and agree only on the interface. The four supports below are the ones the syllabus names.
Consistency
Using classes and standard naming conventions ensures the codebase is predictable for all team members. A team agrees the conventions once and everyone follows them, so that any class looks familiar even if someone else wrote it. Python's community style guide (PEP 8) is a common choice. Consistency also means the same structure for every class (constructor first, then properties, then methods), one class per file in a larger project, and the same way of handling errors.
| Item | Convention | Example |
|---|---|---|
| Class names | PascalCase, a noun | LibraryItem, Member |
| Method and variable names | snake_case, methods are verbs | check_out(), loan_count |
| Constants | UPPER_SNAKE_CASE | MAX_LOANS |
| Internal attributes | Leading underscore (or two for name mangling) | _title, __loans |
The class diagram is also a consistency tool: it is the team's agreed contract. If the diagram says Member has borrow(item), the person writing Library can call it before it exists, using a stub (see the implementation section).
Code Commenting
Professional documentation within classes (docstrings) explains the purpose of methods to other developers. Comments describe why the code does something and anything that is not obvious; they should not repeat what the code plainly says. A docstring is a string placed first inside a class or method. Python stores it, so tools and other programmers can read it without opening the source file.
class Booking:
"""A single room booking.
Attributes:
room (str): room code, e.g. "L204"
hours (int): length of the booking in whole hours
"""
def __init__(self, room, hours):
self.room = room
self.hours = hours
def cost(self, rate):
"""Return the cost in dollars: hours multiplied by the hourly rate."""
return self.hours * rate
help(Booking.cost) # a teammate can read this without the source
print(Booking("L204", 3).cost(12.5))
Help on function cost in module __main__:
cost(self, rate)
Return the cost in dollars: hours multiplied by the hourly rate.
37.5
Version Control
Tools like Git allow multiple engineers to work on different classes simultaneously and merge their work into a single repository. Every change is saved as a commit with a message, the full history is kept, and any earlier version can be restored. Because OOP puts each class in its own file, two people editing different classes rarely change the same lines, which keeps merge conflicts rare.
| Git command | What it does |
|---|---|
git clone <url> | Copies the shared repository to your computer. |
git switch -c add-dvd-class | Creates a branch so your work does not disturb others. |
git add dvd.py then git commit -m "Add DVD subclass with 7 day loan" | Saves a snapshot with a clear message. |
git push | Shares your branch. A pull request then asks a teammate to review it before it is merged. |
git pull | Brings teammates' merged changes to your computer. |
Feedback
Peer reviews of class structures and logic ensure that code is modular, reusable, and free of logic errors. Feedback also comes from test results, from the client trying an iteration, and from pair programming (two people at one computer, one typing and one reviewing). Good feedback is specific and about the code, not the person. A short checklist keeps a review of OOP code focused:
- Does each class have one clear responsibility, and is its name a noun that says what it is?
- Is data private, with changes made only through methods?
- Does inheritance pass the is-a test, and is duplicated code generalised?
- Do methods do one task and have names that are verbs?
- Are there docstrings and tests, and do the tests pass?
Designing and Implementing OOP Programs
🎯 (SE-11-02)
📌 Design and implement computer programs involving branching, iteration and functions in an OOP language for an identified need or opportunity
The Course Specifications expect you to define classes, objects, attributes and methods, use inheritance, polymorphism and encapsulation, perform message passing, and use control structures and variables. This section puts all of that together in one complete, runnable program for an identified need: the Riverbend Library statement from the design section.
Program Structure with Control Flow
Effective OOP programs combine three fundamental control structures:
- Branching: Using if/elif/else (or
matchin modern Python) to make decisions based on object state or input. For example,Member.borrow()checks whether the member is at their limit and whether the item is free. - Iteration: Using
forandwhileloops to process collections of objects or repeat operations. For example, looping through every item to print a report. - Functions (Methods): Encapsulating logic in methods that accept parameters and return results. Each method should perform one logical task.
Real-World Application
When designing OOP programs for actual needs, engineers must:
- Identify the objects in the problem domain (e.g., Student, Course, Enrolment for a school system).
- Define classes with appropriate attributes and methods.
- Design relationships between classes (inheritance, composition).
- Implement logic using branching and iteration within methods.
- Test each class independently before integration.
Library Management System — Class Diagram
A Library Management System is an ideal HSC project because it has multiple entity types with clear IS-A and HAS-A relationships. The first-cut diagram below shows how Book, Member, and Library interact. It uses + and - visibility marks, which come from UML; the Course Specifications notation leaves them out.
Class diagram
Class diagram: a library system
The classes of a library system and how they relate.
What to notice
- Multiplicities such as 1 and 0..* show how many objects take part in a relationship.
- Inheritance arrows point from the child class to the parent class.
Try thisAdd a Loan class between Member and Book. What multiplicities would it have?
Writing the code exposed something the first cut missed: books and DVDs share most of their data and behaviour. So the design was generalised, and the refined diagram from the modelling section (Book, DVD and ReferenceBook inheriting from LibraryItem) is the one implemented below. Revising a diagram after learning something is a normal part of design.
Python Implementation: the complete program
The classes below use encapsulation (private data, properties), abstraction (an abstract LibraryItem), inheritance (Book, DVD and ReferenceBook), polymorphism (each subclass answers loan_days() and describe() in its own way) and message passing (the library asks the member, who asks the item). The mainline at the bottom is short: it creates objects and sends messages.
from abc import ABC, abstractmethod
class LibraryItem(ABC):
"""Anything the library lends. Abstract: only real item types can be created."""
def __init__(self, item_id, title):
self._item_id = item_id
self._title = title
self._on_loan = False
@property
def item_id(self):
return self._item_id
@property
def title(self):
return self._title
@property
def on_loan(self):
return self._on_loan
@abstractmethod
def loan_days(self):
"""How many days this kind of item may be kept."""
def check_out(self):
if self._on_loan: # branching
return False
self._on_loan = True
return True
def check_in(self):
self._on_loan = False
def describe(self):
status = "on loan" if self._on_loan else "available"
return f"{self._title} [{status}]"
class Book(LibraryItem):
def __init__(self, item_id, title, author):
super().__init__(item_id, title)
self._author = author
def loan_days(self):
return 14
def describe(self):
return f"{super().describe()} by {self._author}"
class ReferenceBook(Book):
def check_out(self):
return False # reference books never leave the library
class DVD(LibraryItem):
def __init__(self, item_id, title, minutes):
super().__init__(item_id, title)
self._minutes = minutes
def loan_days(self):
return 7
def describe(self):
return f"{super().describe()}, {self._minutes} min"
class Member:
MAX_LOANS = 3 # class attribute: the same rule for everyone
def __init__(self, member_id, name, email):
self._member_id = member_id
self._name = name
self.email = email # runs the validating setter
self.__loans = [] # private
@property
def member_id(self):
return self._member_id
@property
def name(self):
return self._name
@property
def email(self):
return self._email
@email.setter
def email(self, value):
if "@" not in value:
raise ValueError("email must contain @")
self._email = value
@property
def loan_count(self):
return len(self.__loans)
def borrow(self, item):
if len(self.__loans) >= Member.MAX_LOANS:
return False
if not item.check_out(): # message passing: ask the item
return False
self.__loans.append(item)
return True
def give_back(self, item):
if item in self.__loans:
self.__loans.remove(item)
item.check_in()
class Library:
def __init__(self):
self._items = {} # data structure: dictionary keyed by id
self._members = {}
def add_item(self, item):
self._items[item.item_id] = item
def join(self, member):
self._members[member.member_id] = member
def checkout(self, member_id, item_id):
member = self._members.get(member_id)
item = self._items.get(item_id)
if member is None or item is None:
return "Unknown member or item"
if member.borrow(item):
return f"{member.name} borrowed {item.title} for {item.loan_days()} days"
return f"{member.name} could not borrow {item.title}"
def report(self):
for item in self._items.values(): # iteration
print(item.describe()) # polymorphism: each item describes itself
def main():
library = Library()
library.add_item(Book("B1", "River Road", "A. Nguyen"))
library.add_item(DVD("D1", "Star Coast", 95))
library.add_item(ReferenceBook("R1", "Atlas of Australia", "P. Smith"))
library.join(Member("M1", "Priya", "priya@example.com"))
print(library.checkout("M1", "B1"))
print(library.checkout("M1", "D1"))
print(library.checkout("M1", "R1"))
print(library.checkout("M1", "B1"))
print(library.checkout("M9", "B1"))
library.report()
main()
Priya borrowed River Road for 14 days
Priya borrowed Star Coast for 7 days
Priya could not borrow Atlas of Australia
Priya could not borrow River Road
Unknown member or item
River Road [on loan] by A. Nguyen
Star Coast [on loan], 95 min
Atlas of Australia [available] by P. Smith
Reading the program against the design
| Requirement from the task definition | Where the code meets it |
|---|---|
| A member can borrow up to three items | Member.MAX_LOANS checked in borrow() (branching) |
| Books 14 days, DVDs 7 | Each subclass overrides loan_days() |
| Reference books are never borrowed | ReferenceBook overrides check_out() to return False. No other class changed. |
| An item cannot be borrowed twice | The _on_loan flag, tested in LibraryItem.check_out() |
| Staff can print what is on the shelves | Library.report() loops (iteration) and calls describe() on each item |
| Bad input is handled | An unknown id returns a message instead of crashing; a bad email raises ValueError |
Extending the program
A design is good if new needs can be met without rewriting what works. The library now buys audio books, lent for 21 days. Because Library only sends the messages loan_days() and describe(), the only new code is one subclass. Nothing in Library, Member or LibraryItem is edited.
class AudioBook(Book):
def __init__(self, item_id, title, author, hours):
super().__init__(item_id, title, author)
self._hours = hours
def loan_days(self):
return 21
def describe(self):
return f"{super().describe()}, {self._hours} h audio"
library = Library()
library.add_item(AudioBook("A1", "Salt Country", "M. Okafor", 9))
library.join(Member("M2", "Tom", "tom@example.com"))
print(library.checkout("M2", "A1"))
library.report()
Tom borrowed Salt Country for 21 days
Salt Country [on loan] by M. Okafor, 9 h audio
OOP Design Checklist — Use This for Your HSC Project
- Have you identified all objects/entities in your system?
- Are attributes private with getters/setters where needed?
- Is there appropriate use of inheritance (IS-A relationship)?
- Is composition used for HAS-A relationships?
- Are method names clear and do they follow single responsibility?
- Have you drawn a UML class diagram in your documentation?
When NOT to Use OOP
- Simple scripts (e.g., a one-off data conversion) don't need classes — plain functions are simpler and faster to write.
- OOP shines when you have multiple similar objects that share structure but differ in state or behaviour (e.g., many
Bookobjects in a library). - Data-heavy transformations (e.g., mathematical pipelines, batch processing) may suit a functional or procedural style more naturally.
- For your HSC project: if your system involves 3 or more entity types that each have their own data and behaviour, OOP is the right choice and will earn better marks for design quality.
Implementing and Modifying OOP Code
🎯 (SE-11-06)
📌 Implement and modify OOP programming code
- clear and uncluttered mainline
- one logical task per subroutine
- use of stubs
- use of control structures and data structures
- ease of maintenance
- version control
- regular backup
Implementing a class and modifying existing code are different skills. Modifying is what most programmers do most of the time, and the practices below are what make it safe. Each one is shown on the Riverbend Library code.
Clear and Uncluttered Mainline
The main program should be clean and easy to read, delegating complex logic to helper methods. Avoid embedding business logic directly in the main method. Instead, create objects and call their methods. This separation of concerns makes the flow of the program obvious and maintainable. Compare the two mainlines below, which produce the same output.
loans = {"M1": 2}
limit = 3
items = {"B1": True, "D1": False} # True = available
member_id, item_id = "M1", "B1"
if member_id in loans:
if item_id in items:
if items[item_id]:
if loans[member_id] < limit:
items[item_id] = False
loans[member_id] += 1
print("Borrowed", item_id)
else:
print("Limit reached")
else:
print("Already on loan")
else:
print("No such item")
else:
print("No such member")
Borrowed B1
def main():
library = Library()
library.add_item(Book("B1", "River Road", "A. Nguyen"))
library.join(Member("M1", "Priya", "priya@example.com"))
print(library.checkout("M1", "B1"))
main()
Priya borrowed River Road for 14 days
The second mainline reads like a description of what the program does. The four levels of nested if have not vanished: they live in Library.checkout() and Member.borrow(), where each is small, named and can be tested alone.
One Logical Task Per Subroutine
Each method should do one thing and do it well (the Single Responsibility Principle). A method named process_loan() should handle the loan only, not also print receipts and update statistics. This makes code easier to understand, test and reuse, and it means a bug has fewer places to hide. If you have to use the word "and" to describe what a method does, split it.
class LoanDesk:
def __init__(self):
self.count = 0
# BEFORE: validates, records, counts AND formats, all in one method
def process_loan_v1(self, member, title, days):
if days <= 0:
return "Invalid loan period"
self.count += 1
return f"Loan {self.count}: {member} has {title} for {days} days"
# AFTER: each method has one job
def _is_valid(self, days):
return days > 0
def _record(self):
self.count += 1
return self.count
def _receipt(self, number, member, title, days):
return f"Loan {number}: {member} has {title} for {days} days"
def process_loan_v2(self, member, title, days):
if not self._is_valid(days):
return "Invalid loan period"
return self._receipt(self._record(), member, title, days)
desk1, desk2 = LoanDesk(), LoanDesk()
print(desk1.process_loan_v1("Priya", "River Road", 14))
print(desk2.process_loan_v2("Priya", "River Road", 14)) # same behaviour
print(desk2.process_loan_v2("Priya", "River Road", 0))
Loan 1: Priya has River Road for 14 days
Loan 1: Priya has River Road for 14 days
Invalid loan period
Changing the structure of code without changing what it does is called refactoring. The identical output above is the proof that the behaviour was kept, which is why refactoring is always done with tests to run before and after.
Use of Stubs
A stub is a minimal implementation or placeholder for a subroutine, method, or class that will be fully developed later. Stubs allow high-level logic to be tested before all detailed methods are complete. This enables parallel development: one student can write Library against a stub while another writes the real Member. A stub should either return a fixed, believable value or say clearly that it is not finished, so that nobody mistakes it for real code.
class Database:
def save(self, record): # stub 1: fixed behaviour so callers can run
print("Saving...", record)
return True
def load(self, record_id): # stub 2: refuses to pretend
raise NotImplementedError("load() is not written yet")
db = Database()
print(db.save({"item": "B1"}))
try:
db.load("B1")
except NotImplementedError as err:
print("Stub reached:", err)
Saving... {'item': 'B1'}
True
Stub reached: load() is not written yet
Control Structures and Data Structures
Implementation must effectively use both control structures (if/else, loops) to direct program flow and data structures (arrays, lists, trees) to organise data. Choosing the right combination improves both efficiency and code clarity.
| Need | Structure | Riverbend example |
|---|---|---|
| An ordered group where the same kind of thing is added and removed | List | A member's loans (__loans) |
| Fast lookup by a key | Dictionary | Items and members keyed by id |
| Membership only, no duplicates | Set | Ids of overdue items |
| Do something for every object | for loop | Library.report() |
| Repeat until a condition changes | while loop | A menu that runs until the librarian chooses Exit |
| Choose between actions | if/elif/else | The checks inside borrow() |
Ease of Maintenance
Code should be maintainable through:
- Clear naming conventions for classes, methods, and variables.
- Proper encapsulation, hiding implementation details behind clear interfaces.
- Comprehensive comments explaining non-obvious logic.
- Avoiding deep nesting and overly complex methods.
- Following consistent formatting and style.
- Named constants in place of "magic numbers", so a rule is changed in one place.
A maintenance task shows the payoff. The council extends loans on all books from 14 to 21 days. In the Riverbend code that is a change to one line inside Book.loan_days(), and every part of the program that asks for the loan period now gets the new answer. Had the number 14 been typed into several methods, one of them would almost certainly have been missed.
class Book(LibraryItem):
def loan_days(self):
return 21 # was 14: the only line that changes
Version Control
Using version control systems (Git, GitLab, Bitbucket) is essential for OOP development. Version control allows multiple developers to work on different classes simultaneously, track changes, revert to previous versions if needed, and maintain an audit trail of who changed what and why. Commits should be frequent and small, each with a message that says what changed and why.
| Weak commit message | Better commit message |
|---|---|
fixed stuff | Stop members borrowing a fourth item |
update | Add ReferenceBook: overrides check_out to refuse loans |
final version 2 | Extend book loans from 14 to 21 days |
Regular Backup
Beyond version control, regular backups of the entire codebase ensure protection against data loss from hardware failures. Backups should be stored off-site or in the cloud to protect against local disasters, and automated backups are preferred to ensure consistency and reliability. A common guideline is the 3-2-1 rule: three copies of the data, on two different kinds of storage, with one copy off-site. A backup is only proven when you have tested restoring from it.
| Version control | Backup | |
|---|---|---|
| Main purpose | Track and share changes; return to any earlier version | Recover the whole project after loss or damage |
| Protects against | A bad change; two people overwriting each other | A failed disk, theft, fire, accidental deletion, ransomware |
| Works if the computer is lost? | Only if the repository is also hosted somewhere else | Yes, if a copy is off-site |
Testing and Evaluating Code
🎯 (SE-11-06, SE-11-07)
📌 Apply methodologies to test and evaluate code
- unit, subsystem and system testing
- black, white and grey box testing
- quality assurance
Testing is how you gather the evidence that code works. It is done at several levels and with several viewpoints, and it is never finished on the first pass: every time code changes, the tests are run again. The examples below test the Riverbend Library program from the design section.
Testing Hierarchy: Unit, Subsystem, and System
Testing occurs at multiple levels, building from smallest to largest components:
Unit Testing
Testing individual classes or methods in isolation to ensure they perform their intended function correctly. For example, testing a Member.borrow() method with various inputs (a free item, an item already on loan, a member already at the limit). Unit tests are the most granular and should be written by the developer who wrote the code.
A unit test can be as simple as a function that sets up an object, sends it a message and uses assert to state what must be true. If the condition is false, Python raises an AssertionError and the test fails. Good unit tests cover normal data, boundary data (at the edge of a rule) and abnormal data (invalid input).
| Test | Kind of data | Input | Expected result |
|---|---|---|---|
| A new item is available | Normal | New Book | on_loan is False |
| Borrow a free book | Normal | Member with 0 loans | True; loan count 1 |
| Third item allowed | Boundary (last allowed) | Member borrows 3 items | All three succeed |
| Fourth item refused | Boundary (first refused) | Member borrows a 4th | False; count stays 3 |
| Reference book | Abnormal for a loan | Any member borrows it | False |
| Invalid email | Abnormal | "no-at-sign" | ValueError raised |
def test_new_item_is_available():
assert Book("B1", "River Road", "A. Nguyen").on_loan is False
def test_borrow_free_book():
member = Member("M1", "Priya", "p@example.com")
book = Book("B1", "River Road", "A. Nguyen")
assert member.borrow(book) is True
assert member.loan_count == 1
def test_limit_boundary():
member = Member("M1", "Priya", "p@example.com")
for n in range(3):
book = Book(f"B{n}", "Title", "Author")
assert member.borrow(book) is True, f"item {n + 1} should be allowed"
extra = Book("B9", "Title", "Author")
assert member.borrow(extra) is False, "fourth item should be refused"
assert member.loan_count == 3
def test_reference_book_never_lent():
member = Member("M1", "Priya", "p@example.com")
atlas = ReferenceBook("R1", "Atlas", "P. Smith")
assert member.borrow(atlas) is False
def test_bad_email_rejected():
try:
Member("M1", "Priya", "no-at-sign")
except ValueError:
return # the expected outcome
assert False, "ValueError was not raised"
tests = [test_new_item_is_available, test_borrow_free_book, test_limit_boundary,
test_reference_book_never_lent, test_bad_email_rejected]
passed = 0
for test in tests:
try:
test()
print("PASS", test.__name__)
passed += 1
except AssertionError as err:
print("FAIL", test.__name__, err)
print(f"{passed} of {len(tests)} tests passed")
PASS test_new_item_is_available
PASS test_borrow_free_book
PASS test_limit_boundary
PASS test_reference_book_never_lent
PASS test_bad_email_rejected
5 of 5 tests passed
The tests exercise the objects only through their public methods and properties (borrow(), loan_count), never by reaching into private data. That keeps them valid when the internals are refactored.
A failing test is useful because it points at the fault. Here a developer wrote the limit check with the wrong comparison, and the boundary tests catch it. Boundary values are where off-by-one errors live, so always test on both sides of the edge.
def can_borrow(current_loans, limit=3):
return current_loans <= limit # BUG: should be < (allows a fourth item)
def test_can_borrow():
assert can_borrow(0) is True, "0 loans: should be allowed"
assert can_borrow(2) is True, "2 loans: third item should be allowed"
assert can_borrow(3) is False, "3 loans: fourth item should be refused"
try:
test_can_borrow()
print("PASS")
except AssertionError as err:
print("FAIL:", err)
FAIL: 3 loans: fourth item should be refused
Subsystem Testing
Testing groups of related classes that work together as a functional subsystem. For example, testing the interaction between Library, Member and LibraryItem to check that a checkout changes the state of both the member and the item. This level catches integration issues that unit tests might miss: each class can pass its own tests and still be called with the wrong arguments or in the wrong order by another.
library = Library()
book = Book("B1", "River Road", "A. Nguyen")
priya = Member("M1", "Priya", "priya@example.com")
library.add_item(book)
library.join(priya)
message = library.checkout("M1", "B1")
assert message == "Priya borrowed River Road for 14 days"
assert book.on_loan is True # the item changed...
assert priya.loan_count == 1 # ...and so did the member
priya.give_back(book)
assert book.on_loan is False and priya.loan_count == 0
print("Subsystem test passed")
Subsystem test passed
System Testing
Testing the entire integrated application end-to-end to ensure all components work together and meet the overall requirements. System testing includes testing the application's interaction with external systems (databases, APIs, etc.) and verifying that the entire workflow functions as expected, using the program as a user would.
import io
from contextlib import redirect_stdout
buffer = io.StringIO()
with redirect_stdout(buffer): # capture everything the program prints
main()
lines = buffer.getvalue().splitlines()
assert lines[0] == "Priya borrowed River Road for 14 days"
assert lines[2] == "Priya could not borrow Atlas of Australia"
assert lines[4] == "Unknown member or item"
assert len(lines) == 8
print("System test passed:", len(lines), "lines of output checked")
System test passed: 8 lines of output checked
Testing Approaches: Black, White, and Grey Box
These describe how much the tester knows about the code, not how big the thing under test is. The same unit, Member.borrow(), can be tested in all three ways.
Black Box Testing
Testing inputs and outputs without access to or knowledge of the internal code structure. The tester treats the software as a "black box" and tests it purely based on specifications. This approach ensures the software meets functional requirements from a user's perspective. For example, using only the statement "a member can borrow up to three items", try borrowing one, three and four items and check the results, without reading borrow(). The unit tests above are black-box tests.
White Box Testing
Testing with complete access to and understanding of the internal code structure. The tester examines the code logic, control flow, and data flow to ensure all paths and conditions are tested. This approach is critical for security, ensuring all potential vulnerabilities and logic errors are identified. For borrow(), reading the code shows three paths, so at least three tests are needed to run every branch (path coverage):
Path through borrow() | Test that reaches it | Result |
|---|---|---|
1. len(loans) >= MAX_LOANS is true | Member already holds 3 items | False |
2. Limit not reached, but check_out() returns False | Item already on loan, or a ReferenceBook | False |
| 3. Both checks pass | Free item, member under the limit | True, item appended |
Grey Box Testing
A hybrid approach where the tester has partial knowledge of the internal structure (like knowing which classes exist and their general purpose) but not the implementation details. This allows more targeted testing than black box while being less resource-intensive than white box. For example, knowing that Member delegates to LibraryItem.check_out() and that the limit is a constant, the tester chooses the "item already on loan" and "third versus fourth item" cases deliberately, but still checks only through the public interface.
- Black box testing: "A testing technique where there is no knowledge of the internal workings of an application. A user will provide inputs and observe the outputs in order to determine functionality of the program."
- White box testing: "A testing technique used to examine the internal workings of an application including the design, code and inner workings of software. This technique gives the ability to verify the flow of inputs and outputs through the application."
- Grey box testing: "A testing technique where there is a partial knowledge of the internal workings of an application. Its purpose is to search for defects related to improper code structure or the improper use of functions. This technique gives the ability to test both the code and the presentation layer of an application."
Source: NESA, Software Engineering 11–12 syllabus glossary.
Testing objects: things to remember
- Test through the interface. Check state with public methods and properties. A test that reads a private attribute breaks when the class is refactored.
- Test polymorphism by looping. Run the same test over every subclass (Book, DVD, ReferenceBook), since each may override behaviour. A subclass must still honour what the superclass promises, unless its documentation says otherwise.
- Isolate with stubs. To unit-test
Librarywithout a realMember, pass in a stub that returns fixed answers. - Re-run after every change. Regression testing means running the old tests again to prove new code did not break old behaviour. This is what makes refactoring safe.
Try it
Which kind of testing?
Practise naming levels and approaches of testing. Enable JavaScript to use this tool. In short: unit, subsystem and system testing describe what is tested; black, white and grey box describe how much of the code the tester can see.
Quality Assurance Process
Quality Assurance (QA) is the systematic process of ensuring software meets specified engineering standards and quality criteria. It looks at the whole process, not just at running tests. QA includes:
- Requirements Verification: Ensuring the code implements all specified requirements (each line of the task definition has a passing test).
- Code Review: Peer examination of code for logic errors, adherence to standards, and maintainability.
- Automated Testing: Using test suites that run automatically on every code change to catch regressions.
- Documentation: Ensuring code is properly documented so maintainability is assured.
- Performance Testing: Verifying the code meets performance requirements under expected and peak loads.
- Security Testing: Identifying and fixing security vulnerabilities. This is developed further in Year 12 (Secure Software Architecture).