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.

Objects have state, behaviour and identity; names refer to objects
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)
Output
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 attributes are shared; instance attributes belong to one 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)
Output
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.

A property keeps the invariant: no temperature below absolute zero
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)
Output
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
  1. The denominator must never be zero, or the value is undefined.
  2. 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.
  3. With this normal form, 2/4 and −1/−2 are both stored as 1/2.
  4. 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.

A caller sends the message area to an object. A dispatcher looks up the object's class and its method table, then runs Circle.area, Square.area or Hexagon.area depending on the class.
Dynamic dispatch: the receiver's class decides which method runs

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.

Duck typing: report works with any object that has area and name
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)])
Output
      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.

The expression problem: two ways to organize variants and operations
Change you need to makeFunctional design (data + match)Object-oriented design (classes + dispatch)
Add a new kind of shapeEdit every function that matches on shapesAdd one class; existing code unchanged
Add a new operation, such as perimeterAdd one function; data unchangedEdit every class to add the method
Check all cases are handledExhaustiveness checking (Rust, Java 21)Abstract methods force subclasses to implement
Typical homeCompilers, data transformation, message processingGUIs, 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
  1. New shape types arrive often, from code you do not control.
  2. In the object-oriented design, a plug-in adds one class implementing area, draw and save; no existing code changes.
  3. In the functional design, every new shape would require editing the three matching functions, which plug-in authors cannot do.
  4. 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.

A Java interface: the compiler checks that each class provides area and name
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);
    }
}
Output
    circle:     7.07
    square:     9.00
     total:    16.07

In 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.

  1. What are the three properties of every object?

    Show answer

    State, behaviour and identity.

  2. 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.

  3. 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`.

  4. 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.

  5. 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).

  6. 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.

  7. 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.

  8. 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.

  9. 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.

  10. 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.

Take the quiz