CMPUT-274 · Lesson 4 of 12 · 29 min
Object-oriented programming I: objects, classes, encapsulation and polymorphism
Object-oriented programming organizes a program around objects: bundles of state and the operations that act on it. Instead of passing data structures to functions that may change them anywhere, you ask an object to do something, and the object decides how to change its own state. This lesson covers the core ideas that every object-oriented language shares: objects and classes, encapsulation, messages and dynamic dispatch, and polymorphism. Lesson 5 continues with inheritance, composition and design principles. Python's object model is flexible and dynamic, so where Python is loose about a rule, we show how Java enforces it.
Objects, state and identity
An object has three properties. It has state, the values of its attributes. It has behaviour, the methods it responds to. And it has identity: two objects can have equal state and still be different objects, just as two people can share a name. In Python, `is` compares identity and `==` compares equality, which a class defines through its `__eq__` method. Identity matters for objects whose state changes over time: a bank account, a game character or an open file remains the same object as its state evolves.
class Counter:
"""A counter that remembers how many times it was incremented."""
def __init__(self, start=0):
self.value = start # state lives in the instance
def increment(self, by=1):
self.value += by
return self # allow chaining: c.increment().increment()
def __repr__(self):
return f"Counter({self.value})"
a = Counter(); b = Counter()
a.increment().increment(5)
print(a, b, "same object?", a is b)
c = a # a second name for the SAME object
c.increment()
print("a after c.increment():", a)Counter(6) Counter(0) same object? False a after c.increment(): Counter(7)
The last two lines show aliasing: `c` and `a` name the same object, so a change made through one name is visible through the other. Aliasing is the price of shared mutable state, and it is the object-oriented counterpart of the side effects discussed in lesson 2. Good object-oriented design keeps aliasing under control by giving each piece of state a clear owner and allowing changes only through that owner's methods.
Classes as templates
A class describes a family of objects: which attributes they have and which methods they support. Calling the class creates an instance, and the special method `__init__` sets its initial state. Methods are functions defined in the class whose first parameter, conventionally `self`, is the instance on which the method was called. Attributes set on `self` belong to the instance; attributes defined directly in the class body are shared by all instances. Confusing the two is a classic bug: a mutable list defined at class level is shared by every object.
class BadCart:
items = [] # class attribute: shared by ALL carts
def add(self, x):
self.items.append(x)
class GoodCart:
def __init__(self):
self.items = [] # instance attribute: one list per cart
def add(self, x):
self.items.append(x)
b1, b2 = BadCart(), BadCart(); b1.add("apple")
g1, g2 = GoodCart(), GoodCart(); g1.add("apple")
print("BadCart:", b1.items, b2.items)
print("GoodCart:", g1.items, g2.items)BadCart: ['apple'] ['apple'] GoodCart: ['apple'] []
Encapsulation and invariants
Encapsulation means hiding an object's internal representation behind its methods. Clients use the interface, the public methods, and never touch the fields directly. This has two benefits. First, the object can maintain an invariant, a condition on its state that is always true between method calls, because every change goes through code that checks it. Second, the representation can change without breaking clients, because they never depended on it. A date object might store year, month and day, or a single day count; clients that call `add_days` and `month()` do not care which.
Languages enforce encapsulation differently. Java and C++ have access modifiers: `private` fields are accessible only inside the class, and the compiler rejects outside access. Python relies on convention: a leading underscore, as in `_balance`, means internal, and a double underscore triggers name mangling that makes accidental access harder but not impossible. Python's `property` lets a class expose an attribute-like interface while running code on every read or write, so you can start with a plain attribute and add validation later without changing clients.
class Temperature:
"""Stores kelvin internally; invariant: kelvin >= 0."""
def __init__(self, celsius):
self.celsius = celsius # goes through the property setter
@property
def celsius(self):
return self._kelvin - 273.15
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError(f"{value} C is below absolute zero")
self._kelvin = value + 273.15
@property
def fahrenheit(self):
return self.celsius * 9 / 5 + 32
t = Temperature(25)
print(round(t.fahrenheit, 1), round(t._kelvin, 2))
t.celsius = -40
print(round(t.fahrenheit, 1))
try:
t.celsius = -300
except ValueError as err:
print("rejected:", err)77.0 298.15 -40.0 rejected: -300 C is below absolute zero
Clients read and write `t.celsius` as if it were a field, but every assignment is checked, and the class is free to store kelvin internally. Note that nothing stops a determined client from writing `t._kelvin = -5`; Python's underscore is a promise, not a lock. In Java, the equivalent field would be declared `private` and the compiler would refuse the assignment.
Worked problem
Choosing an invariant
Design a class for a fraction p/q. What invariant should it maintain, and why?
Show solution
- The denominator must never be zero, or the value is undefined.
- Store the fraction in lowest terms with a positive denominator: divide p and q by gcd(p, q), and move any minus sign to p.
- With this normal form, 2/4 and −1/−2 are both stored as 1/2.
- Equality can then compare (p, q) pairs directly, and hashing works, because equal fractions have identical representations.
Answer: Invariant: q > 0 and gcd(|p|, q) = 1. The constructor establishes it and every operation that builds a fraction re-establishes it, so equality and hashing are simple and correct.
Messages and dynamic dispatch
In object-oriented terms, calling `shape.area()` sends the message `area` to the object `shape`. Which code runs depends on the object's class, determined at run time, not on the variable's declared type or the place where the call is written. This is dynamic dispatch, and it is what makes object-oriented code extensible: you can add a new class that responds to `area` and every existing piece of code that sends `area` will work with it, unchanged.
Polymorphism
Polymorphism means one interface, many implementations. Code written against the interface works with any object that implements it. Object-oriented languages support several kinds. Subtype polymorphism, the kind dynamic dispatch provides, lets a value of a subclass stand wherever its superclass is expected. Duck typing, Python's default, goes further: any object with the right methods is acceptable, whether or not it is related by inheritance, because if it walks like a duck and quacks like a duck, it is treated as a duck. Parametric polymorphism, or generics, lets one piece of code work for many element types and is covered in lesson 7.
import math
class Circle:
def __init__(self, r): self.r = r
def area(self): return math.pi * self.r ** 2
def name(self): return "circle"
class Square:
def __init__(self, s): self.s = s
def area(self): return self.s ** 2
def name(self): return "square"
class Plot: # unrelated to shapes, but it has area() and name()
def __init__(self, w, d): self.w, self.d = w, d
def area(self): return self.w * self.d
def name(self): return "garden plot"
def report(things): # written once, works for all of them
for t in things:
print(f"{t.name():>12}: {t.area():8.2f}")
print(f"{'total':>12}: {sum(t.area() for t in things):8.2f}")
report([Circle(1.5), Square(3), Plot(4, 2.5)]) circle: 7.07
square: 9.00
garden plot: 10.00
total: 26.07`report` never asks what class an object is. It sends messages and trusts the objects to respond. Compare this with the pattern-matching version in lesson 3, where the `area` function examined each shape's variant. The two approaches are mirror images. With pattern matching, adding a new operation is easy, because you write one new function, but adding a new variant means editing every function. With objects and dispatch, adding a new class is easy, because you write one new class, but adding a new operation means editing every class. This trade-off, sometimes called the expression problem, is one of the most useful ideas for choosing between functional and object-oriented designs.
| Change you need to make | Functional design (data + match) | Object-oriented design (classes + dispatch) |
|---|---|---|
| Add a new kind of shape | Edit every function that matches on shapes | Add one class; existing code unchanged |
| Add a new operation, such as perimeter | Add one function; data unchanged | Edit every class to add the method |
| Check all cases are handled | Exhaustiveness checking (Rust, Java 21) | Abstract methods force subclasses to implement |
| Typical home | Compilers, data transformation, message processing | GUIs, simulations, plug-in systems |
Worked problem
Applying the expression problem
A drawing program has 6 shape types and 3 operations: area, draw and save. You expect to add many new shape types from plug-ins but rarely add operations. Which organization fits better?
Show solution
- New shape types arrive often, from code you do not control.
- In the object-oriented design, a plug-in adds one class implementing area, draw and save; no existing code changes.
- In the functional design, every new shape would require editing the three matching functions, which plug-in authors cannot do.
- Operations are rarely added, so the object-oriented weakness, editing every class for a new operation, seldom bites.
Answer: The object-oriented organization. When the set of variants is open and the set of operations is stable, classes with dynamic dispatch are the better fit; when operations change often and variants are fixed, data plus pattern matching fits better.
Objects in Java: classes, interfaces and access control
Java makes several things explicit that Python leaves to convention. Fields can be `private`, so encapsulation is enforced by the compiler. An `interface` declares methods that implementing classes must provide, and the compiler checks that every class claiming to implement it really does. Every variable has a declared type, and a call compiles only if that type has the method. The program below expresses the shape example with an interface and two classes.
import java.util.List;
public class Shapes {
interface Shape {
double area();
String name();
}
static final class Circle implements Shape {
private final double r; // private: invisible outside the class
Circle(double r) { this.r = r; }
public double area() { return Math.PI * r * r; }
public String name() { return "circle"; }
}
static final class Square implements Shape {
private final double side;
Square(double side) { this.side = side; }
public double area() { return side * side; }
public String name() { return "square"; }
}
public static void main(String[] args) {
List<Shape> shapes = List.of(new Circle(1.5), new Square(3));
double total = 0;
for (Shape s : shapes) { // dynamic dispatch on s.area()
System.out.printf("%10s: %8.2f%n", s.name(), s.area());
total += s.area();
}
System.out.printf("%10s: %8.2f%n", "total", total);
}
} circle: 7.07
square: 9.00
total: 16.07In Java, the `Plot` class from the Python example would not be accepted by a method that takes a `Shape` unless it declared `implements Shape`, even if it had the right methods. This is nominal typing: compatibility depends on declared names, not on structure. Python's duck typing is structural and checked at run time. Lesson 7 shows that Python's `Protocol` type gives a static checker structural typing too, combining the flexibility of duck typing with checking before the program runs.
Common mistakes
MistakeDefining a mutable default such as a list as a class attribute.
InsteadClass attributes are shared by every instance; create per-object state in __init__.
MistakeExposing every field and changing it from outside the class.
InsteadKeep state behind methods or properties so the class can maintain its invariants and change its representation.
MistakeAssuming a leading underscore makes a Python attribute private.
InsteadIt is only a convention; Python does not enforce access control the way Java's private does.
MistakeWriting if isinstance(x, Circle) ... elif isinstance(x, Square) chains inside clients.
InsteadLet each class implement the method and rely on dynamic dispatch; type tests in clients defeat polymorphism.
MistakeConfusing equality with identity.
InsteadUse == for equal state, defined by __eq__, and is for the same object; two separate objects can be equal.
Summary and key terms
- An object combines state, behaviour and identity; several names can refer to the same object, which is aliasing.
- A class is a template; instance attributes belong to one object and class attributes are shared by all.
- Encapsulation hides representation behind methods so that a class can keep its invariants and evolve its internals.
- Python uses conventions and properties for encapsulation; Java enforces it with access modifiers.
- Dynamic dispatch chooses the method from the receiver's run-time class.
- Polymorphism lets one piece of code work with many kinds of objects, through subtyping or duck typing.
- The expression problem: classes make new variants easy, pattern matching makes new operations easy.
- object
- A value that combines state, behaviour and identity.
- class
- A template that defines the attributes and methods of a family of objects.
- instance
- An object created from a class.
- encapsulation
- Hiding an object's internal representation and allowing access only through its interface.
- invariant
- A condition on an object's state that holds before and after every public method call.
- aliasing
- Two or more names referring to the same mutable object.
- dynamic dispatch
- Selecting the method to run based on the run-time class of the receiving object.
- polymorphism
- The ability of one interface to be used with values of many different types.
- duck typing
- Accepting any object that provides the required methods, regardless of its class.
- expression problem
- The trade-off between adding new variants and adding new operations in functional and object-oriented designs.
Practice problems
Work each problem on paper first, then reveal the answer to check.
What are the three properties of every object?
Show answer
State, behaviour and identity.
After `x = [1]; y = x; y.append(2)`, what is x, and why?
Show answer
[1, 2], because x and y refer to the same list object.
Write a Rectangle class with width and height and a method area.
Show answer
`class Rectangle:` with `def __init__(self, w, h): self.w, self.h = w, h` and `def area(self): return self.w * self.h`.
Add a property to a BankAccount class so that balance cannot be set to a negative value.
Show answer
Store `_balance`; define `@property def balance(self)` returning it and `@balance.setter` that raises ValueError for negative values before assigning.
What invariant would you choose for a class representing a time of day?
Show answer
For example 0 ≤ hour < 24 and 0 ≤ minute < 60, or a single count of minutes since midnight in [0, 1440).
Explain dynamic dispatch in one sentence.
Show answer
When a method is called on an object, the method that runs is chosen by the object's actual class at run time.
Why would `report` in the duck-typing example fail on an object without a name method?
Show answer
Python looks up the method at run time and raises AttributeError when the object does not have it.
Under the expression problem, which organization makes adding a perimeter operation easier?
Show answer
The functional organization: add one function with a case per shape, without touching the data definitions.
What is nominal typing?
Show answer
Type compatibility based on declared names and relationships, such as Java's implements, rather than on the methods an object happens to have.
Why does Java reject a class with area and name methods as a Shape if it does not declare implements Shape?
Show answer
Java uses nominal typing for interfaces; the class must declare the relationship for the compiler to accept it.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.