CMP-220 · Lesson 2 of 12 · 20 min

Classes, objects and encapsulation

Jonah's first task is to replace the six parallel arrays in Tally.java with a Detection class. Dana gives him one rule to follow: after the change, it must be impossible for any code in the Hub to create or hold a detection that breaks the contract. The contract, written in the Hub's design notes, says that a detection has a node identifier from 1 to 65,535, a lane from 1 to 4, a timestamp, a vehicle length between 50 cm and 3,000 cm, and a speed that is either unknown or between 0 and 250 km/h. This lesson builds that class step by step and explains the ideas behind each step: fields and methods, access control, invariants, accessors, immutability, and the difference between instance and static members.

Anatomy of a Java class

A Java class declaration names the class and lists its members. Fields hold each object's data. Constructors, covered fully in lesson 3, initialize a new object. Methods define what the object can do. Each member carries an access modifier. A private member is visible only inside the class itself. A public member is visible everywhere. A protected member is visible to subclasses and to classes in the same package, and a member with no modifier, called package-private, is visible only within the same package. The rule of thumb is to make fields private and expose only the methods that callers genuinely need.

The first version of Detection: private final fields, one validating constructor and narrow public methods.
package ca.northway.hub.model;

import java.time.Instant;

public final class Detection {
    private final int nodeId;          // 1..65535
    private final int lane;            // 1..4
    private final Instant time;
    private final int lengthCm;        // 50..3000
    private final Integer speedKmh;    // null means unknown, else 0..250

    public Detection(int nodeId, int lane, Instant time, int lengthCm, Integer speedKmh) {
        if (nodeId < 1 || nodeId > 65535) throw new IllegalArgumentException("node " + nodeId);
        if (lane < 1 || lane > 4) throw new IllegalArgumentException("lane " + lane);
        if (time == null) throw new IllegalArgumentException("time is required");
        if (lengthCm < 50 || lengthCm > 3000) throw new IllegalArgumentException("length " + lengthCm);
        if (speedKmh != null && (speedKmh < 0 || speedKmh > 250))
            throw new IllegalArgumentException("speed " + speedKmh);
        this.nodeId = nodeId; this.lane = lane; this.time = time;
        this.lengthCm = lengthCm; this.speedKmh = speedKmh;
    }

    public int nodeId()        { return nodeId; }
    public int lane()          { return lane; }
    public Instant time()      { return time; }
    public int lengthCm()      { return lengthCm; }
    public boolean hasSpeed()  { return speedKmh != null; }

    public double speedMs() {
        if (speedKmh == null) throw new IllegalStateException("speed unknown");
        return speedKmh / 3.6;
    }
}

Read the class from the outside first. A caller can construct a Detection, ask for its node, lane, time and length, ask whether it has a speed, and get the speed in metres per second. It cannot change any field after construction, it cannot construct a detection with an impossible lane, and it cannot accidentally read an unknown speed as a number, because speedMs refuses. Every one of the bugs Dana listed in Tally.java is now impossible to write, not merely discouraged.

Invariants and why private matters

An invariant is a condition that is always true for every object of a class whenever outside code can observe it. For Detection, the invariants are the ranges in the contract. Encapsulation is what makes invariants enforceable. If the fields were public, any code anywhere could write d.lane = 7, and the class could promise nothing. Because the fields are private, the only way to set them is through the class's own code, so checking the invariants in that code is enough. This is the same principle as a sealed module in hardware: if only the module's terminals are accessible, its internal state can be guaranteed by its own design.

There is a second, equally important benefit. Because callers never see the fields, the class can change how it stores its data without breaking them. Dana mentions that the Hub stores billions of detections, and that the team may later pack the lane and node into one int to save memory, or store the time as a long count of milliseconds rather than an Instant object. If callers only ever use lane() and time(), those changes touch one class. In Tally.java, the same change would touch every one of the ninety methods.

A UML class diagram box divided into three compartments. The top compartment holds the class name Detection. The middle compartment lists fields with a minus sign for private: nodeId, lane, time, lengthCm and speedKmh with their types. The bottom compartment lists methods with a plus sign for public: the constructor, nodeId, lane, time, lengthCm, hasSpeed and speedMs with return types. A key at the side explains that plus means public, minus means private, hash means protected and tilde means package.
Detection drawn as a UML class: name, then fields, then methods. The symbols + − # ~ mark public, private, protected and package access.

The Unified Modeling Language, UML, is the standard notation for drawing classes and their relationships. A class is a box with three compartments: name, attributes and operations. Visibility is shown with a plus sign for public, a minus sign for private, a hash sign for protected and a tilde for package-private. Types follow a colon after each name. You will see UML diagrams throughout this course; CMPUT-301 Software Engineering covers the full notation and how teams use it to plan larger designs.

Accessors, mutators and Tell, Don't Ask

Methods that return a field's value are called accessors or getters; methods that change a field are mutators or setters. Many programmers generate a getter and setter for every field automatically. That habit quietly throws away encapsulation: a class with private fields and public setters for all of them is a structure with extra typing, because any code can still put it into any state. Add a setter only when changing that value after construction is a real need, and make it check the invariants. Detection has no setters at all, because a detection is a historical fact; the length the radar measured at 07:42:13 will never change.

A related guideline is called Tell, Don't Ask. Instead of asking an object for its data and making a decision outside it, tell the object what you want and let it decide. Tally.java's report code asked for the length and speed and classified the vehicle itself, in four different places with three slightly different thresholds. Moving that decision into a method on the object, or into a dedicated classifier object as lesson 7 will do, puts the knowledge in one place. Getters are still fine for simple facts that callers truly need, such as the lane for grouping counts.

Immutability

An immutable object cannot change after it is constructed. Detection is immutable: its class is final, so nobody can create a subclass that adds mutable state; its fields are private and final, so they are assigned once in the constructor; and it has no mutators. Immutable objects are simple to reason about, because their state is fixed; safe to share between threads without locks, which matters in the Hub, where a dozen threads read the same detections at once; and safe to use as keys in hash tables, because their hash code cannot change while they are stored. Java's String, Integer and Instant are immutable for the same reasons.

One trap remains. A final field that refers to a mutable object, such as an array or a list, protects only the reference, not the contents. If a class stores a list passed to its constructor, the caller can still change the list afterwards and break the class's invariants. The defence is to make a defensive copy on the way in and to return either a copy or an unmodifiable view on the way out. Java's List.copyOf does both jobs, producing an unmodifiable copy.

Records and data classes

Since Java 16, a record declares an immutable data carrier in one line: record Reading(int nodeId, double volts, Instant time) creates a final class with private final fields, a constructor, accessors named after the fields, and correct equals, hashCode and toString methods. A compact constructor inside the record can validate the arguments. Records are ideal for value-like objects such as a voltage reading or an interval count. Python offers the same convenience with dataclasses, and its frozen=True option makes instances immutable.

A record: an immutable value class with generated accessors, equals, hashCode and toString.
public record IntervalCount(int stationId, Instant start, int lane, VehicleClass cls, int count) {
    public IntervalCount {                       // compact constructor: validate, then fields are set
        if (lane < 1 || lane > 4) throw new IllegalArgumentException("lane " + lane);
        if (count < 0) throw new IllegalArgumentException("count " + count);
    }
}

Encapsulation in Python

Python has no private keyword. By convention, a name that begins with one underscore, such as _length_cm, is internal and should not be used outside the class, and a name beginning with two underscores is mangled to make accidental use from a subclass harder. The language trusts programmers to respect the convention; a saying in the Python community is that we are all consenting adults here. Python's property decorator lets a class expose an attribute that looks like a field to callers but is computed or validated by a method, so a class can start with a plain attribute and add checks later without changing any calling code.

from dataclasses import dataclass

class Detection:
    """A vehicle detection that can never break its contract."""
    def __init__(self, node_id, lane, length_cm, speed_kmh=None):
        if not 1 <= node_id <= 65535: raise ValueError(f"node {node_id}")
        if not 1 <= lane <= 4:        raise ValueError(f"lane {lane}")
        if not 50 <= length_cm <= 3000: raise ValueError(f"length {length_cm}")
        if speed_kmh is not None and not 0 <= speed_kmh <= 250:
            raise ValueError(f"speed {speed_kmh}")
        self._node_id, self._lane = node_id, lane
        self._length_cm, self._speed_kmh = length_cm, speed_kmh

    @property
    def lane(self):                  # read-only: there is no setter
        return self._lane

    @property
    def speed_ms(self):
        if self._speed_kmh is None:
            raise ValueError("speed unknown")
        return round(self._speed_kmh / 3.6, 2)

d = Detection(17, 2, 455, 62)
print("lane", d.lane, "speed", d.speed_ms, "m/s")
for bad in [(17, 7, 455, 62), (17, 2, 20, 62), (17, 2, 455, 300)]:
    try:
        Detection(*bad)
    except ValueError as e:
        print("rejected:", e)
try:
    d.lane = 3
except AttributeError:
    print("lane is read-only")

@dataclass(frozen=True)
class Reading:                       # Python's counterpart of a Java record
    node_id: int
    volts: float

r = Reading(17, 12.6)
print(r, r == Reading(17, 12.6))
Output
lane 2 speed 17.22 m/s
rejected: lane 7
rejected: length 20
rejected: speed 300
lane is read-only
Reading(node_id=17, volts=12.6) True

Instance members and static members

Most members belong to individual objects: each detection has its own lane. A static member belongs to the class itself and is shared by every object. Static fields suit true constants, such as public static final int MAX_LANES = 4, and static methods suit operations that need no object, such as a factory method or a utility like Math.max. Tally.java used static fields as global variables, which is the main reason any code could change any data. A good test: if a static field changes while the program runs, ask whether it should really be a field of some object instead.

Worked problem

Choosing what to expose

The Hub needs a Node class with an identifier, a set of watched lanes, the time of the last upload and a battery voltage. Operators may change the watched lanes when a node is re-aimed. The upload time and voltage change on every upload. Decide which fields need mutators and what each should check.

Show solution
  1. The identifier never changes for a node, so it is final with an accessor only.
  2. Watched lanes change rarely and only by operators. Provide one method, reaim(Set<Integer> lanes), that checks every lane is 1 to 4 and that the set is not empty, then stores an unmodifiable copy.
  3. Upload time and voltage change together on each upload. Rather than two setters, provide recordUpload(Instant time, double volts), which checks that the time is not earlier than the previous upload and that the voltage is plausible (0 to 20 V), then updates both.
  4. Callers ask isSilent(Instant now, Duration limit) instead of reading the upload time and comparing it themselves: Tell, Don't Ask.

Answer: Node has one accessor for its identifier, a read-only view of its lanes, and two intention-revealing methods, reaim and recordUpload, instead of four setters. Each method checks the invariants it could break.

Common mistakes

  • MistakeMaking fields public, or adding getters and setters for every field by reflex.

    InsteadKeep fields private and add only the methods callers need; give each mutator a clear purpose and checks.

  • MistakeStoring a caller's list in a final field and returning it directly.

    InsteadCopy it on the way in and return an unmodifiable view or copy, for example with List.copyOf.

  • MistakeUsing static fields as shared global state.

    InsteadReserve static fields for constants; put changing data in objects with clear owners.

  • MistakeValidating data at every place it is used.

    InsteadValidate once, when the object is created or changed, so every object that exists is valid.

  • MistakeAssuming a leading underscore makes a Python attribute private.

    InsteadIt is a convention only; properties and careful design provide real protection.

Summary and key terms

  • A class declares fields, constructors and methods, each with an access modifier: private, package-private, protected or public.
  • Encapsulation lets a class enforce invariants and change its internal representation without breaking callers.
  • Prefer intention-revealing methods to blanket getters and setters, and tell objects what to do rather than asking for their data.
  • Immutable objects (final class, private final fields, no mutators, defensive copies) are simple, thread-safe and safe as hash keys.
  • Records in Java and frozen dataclasses in Python declare immutable value classes concisely; static members belong to the class, not to objects.
Access modifier
A keyword such as private or public that controls where a member can be used.
Invariant
A condition that holds for every object of a class whenever outside code can observe it.
Accessor
A method that returns information about an object without changing it.
Immutable object
An object whose state cannot change after construction.
Defensive copy
A copy made of mutable data passed in or out so that outside code cannot change an object's internals.
Static member
A field or method that belongs to the class rather than to any one object.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. List the three things that make Detection immutable.

    Show answer

    The class is final, the fields are private and final, and there are no mutators; any mutable field would also need defensive copies.

  2. Why can't Tally.java's lane rule be enforced if lane is a public field?

    Show answer

    Any code can assign any value directly, bypassing whatever checks the class has.

  3. A class stores List<Integer> lanes = lanesArg; in its constructor. What can go wrong?

    Show answer

    The caller still holds the list and can change it later, breaking the invariant; store List.copyOf(lanesArg) instead.

  4. In UML, what does − speedKmh : Integer mean?

    Show answer

    A private attribute named speedKmh of type Integer.

  5. Give one good use of a static field and one bad use.

    Show answer

    Good: a constant such as MAX_LANES. Bad: a changing count of detections shared by all code.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz