Access, final, and Immutability
Lesson 7.2 already covered where a variable lives — class, method, or block scope — and what happens when a local variable or parameter shadows a field. This lesson builds on that background rather than repeating it: it's about two different questions. Who, outside a class, is allowed to touch a piece of its data? And what can be guaranteed to never change once it's set?
Access Modifiers: Four Levels
Java actually has four access levels, not two. public and private are the ones used everywhere so far; the other two sit in between:
public class Intake
{
public String status; // reachable from anywhere
protected double maxSpeed; // reachable from the same package, or any subclass
double gearRatio; // no modifier — reachable only from the same package
private boolean calibrated; // reachable only from inside Intake itself
}
| Modifier | Same class | Same package | Subclass (different package) | Everywhere |
|---|---|---|---|---|
public | Yes | Yes | Yes | Yes |
protected | Yes | Yes | Yes | No |
| (none) — package-private | Yes | Yes | No | No |
private | Yes | No | No | No |
protected came up briefly in Lesson 17.2: it's what lets a subclass reach a superclass's member directly, without going through a getter — this table is the full picture, including package-private (no keyword at all, the default when a field or class has none of the other three). This course keeps fields private and reaches them through getters/setters or super(...), but real WPILib code uses all four.
final: Not Just for Fields
final can mark a local variable, a parameter, a field, a method, or a whole class — and it means something related but slightly different in each place.
Locals and parameters: a final local variable or parameter can be assigned exactly once, same as a field:
public double clamp(final double target, double limit)
{
// target = -target; would be a compile error here — target is final
return Math.min(target, limit);
}
Fields, and the blank final rule: a final field doesn't have to be given a value in its declaration — it can stay blank there, as long as every constructor assigns it exactly once, on every path through that constructor:
class BatteryMonitor
{
private final String label; // no initializer here — that's legal
public BatteryMonitor(String label)
{
this.label = label; // as long as every constructor assigns it exactly once
}
}
Leave a blank final field unassigned in some constructor, and it's a compile error — variable label might not have been initialized — not a null at runtime.
Methods: a final method can't be overridden by a subclass, full stop:
class RobotPart
{
public final String getName() { return name; }
// ...
}
class Intake extends RobotPart
{
@Override
public String getName() { return "Intake"; }
// COMPILE ERROR — getName() is final in RobotPart
}
Classes: a final class can't be extended by anything, anywhere — String is one, which is why class MyString extends String never compiles. Records (below) are implicitly final too.
Effectively final: a local variable captured by a lambda (Lesson 19.1) doesn't have to be declared final, but it does have to behave like one — never reassigned anywhere after its first assignment, anywhere in the method:
double baseSpeed = 0.5;
baseSpeed = 0.6;
Runnable r = () -> System.out.println(baseSpeed);
// COMPILE ERROR — baseSpeed isn't effectively final
That reassignment on the line before disqualifies baseSpeed the moment the lambda tries to capture it: local variables referenced from a lambda expression must be final or effectively final.
On an object reference, all of this only ever locks the reference itself — a final Encoder armEncoder = new Encoder(0, 1); can never be pointed at a different Encoder (WPILib's real org.wpilib.hardware.rotation.Encoder, getRate() reads its current rate), but nothing about final stops armEncoder's own internal state from changing as it spins. That gap between "the reference can't change" and "nothing about the object can change" is exactly what the rest of this lesson is about.
Encapsulation Leaks: Defensive Copies
Marking a field private isn't automatically enough to protect it — a getter that hands back a direct reference to a mutable field leaks the class's internal state anyway:
class PitNotes
{
private final ArrayList<String> notes = new ArrayList<String>();
public ArrayList<String> getNotes()
{
return notes; // leak — the caller now holds the SAME list object
}
}
Even though notes is private and final, this getter hands out a reference to the exact list PitNotes uses internally. Anything the caller does to that returned list — getNotes().clear(), getNotes().add(...) — changes PitNotes's own data, without ever calling one of PitNotes's own methods. private only stops outside code from writing somePitNotes.notes directly; it does nothing once a reference to the same object escapes through a getter.
The fix is a defensive copy — hand back a new object with the same contents, instead of the original:
public List<String> getNotes()
{
// a defensive copy — an unmodifiable snapshot, not the real list
return List.copyOf(notes);
}
Now the caller gets its own snapshot; mutating it can't touch PitNotes's real data at all, and List.copyOf(...) refuses to be modified even by accident.
Records: Immutable Data, Built In
When a class is really just a bundle of values — no behavior beyond holding them — Java's record (16+) writes the immutable version of it for you:
public record Reading(double volts, double timestampSeconds)
{
public Reading
{
if (volts < 0)
{
throw new IllegalArgumentException("volts can't be negative");
}
}
}
Reading r = new Reading(12.6, 41.2);
System.out.println(r.volts()); // an accessor named after the field, not getVolts()
System.out.println(r); // Reading[volts=12.6, timestampSeconds=41.2]
One line — record Reading(double volts, double timestampSeconds) — generates private final fields for both, a constructor, read-only accessors (volts(), timestampSeconds(), no get prefix), and working equals, hashCode, and toString, all for free. The block after the field list, called a compact constructor, is optional — it runs before the fields are assigned, purely to validate the arguments (as above); no this.volts = volts; is written or needed there, since that assignment still happens automatically. A record is implicitly final — nothing can extend it.
An Immutability Checklist
Pulling the pieces above together, a class is genuinely immutable once every one of these holds:
- Every field is
private final. - Nothing but a constructor (or compact constructor, for a record) ever assigns a field — no setters.
- Any field that's itself a mutable type (an array, or a collection like
ArrayList) is defensively copied going in, through the constructor, and copied going out, through any getter —finalalone only locks the reference, not the referenced object's own contents. - When the class is really just a bundle of values, reach for a
recordfirst — it satisfies every bullet above by construction.
Common Pitfalls
- Returning a mutable field directly from a getter.
privatedoesn't protect a field once a reference to it escapes through a getter — return a defensive copy (List.copyOf(...), ornew ArrayList<>(original)) instead of the original object. - Leaving a blank
finalfield unassigned on some constructor path. Every constructor must assign it exactly once — missing it on even one path is a compile error, not anullwaiting to happen. - Reassigning a variable after using it in a lambda. A captured local only has to be effectively final, but reassigning it anywhere in the method — even after the lambda — breaks that, and the lambda's capture is what fails to compile.
Key Takeaways
- Java has four access levels —
public,protected, package-private (no modifier), andprivate— differing in whether same-package code and subclasses in other packages can reach a member; Lesson 17.2 introducedprotected, this table is the full comparison. finalmeans "assign exactly once" for locals, parameters, and fields (with the blank-final rule allowing one later assignment per constructor); on a method it blocks overriding, and on a class it blocks extending entirely.- A lambda can only capture a local variable that's final or effectively final — never reassigned anywhere in the method.
- A
privatefield isn't automatically safe from outside mutation — a getter that hands back the same mutable object leaks it; return a defensive copy instead. - A
recordgenerates an immutable, implicitly-finalclass — fields, accessors,equals,hashCode, andtoString— from one line, with an optional compact constructor for validation. - Scope and shadowing (where a variable lives, and what happens when a local name matches a field's) were covered in Lesson 7.2.
Derived from csawesome-2026: unit-3-class-creation/3.08-scope-and-access.md (CSA 3.8)
Deck context: mechacoder-test/src/lessons/java-2.js, slide 9 ("Encapsulation & Final") — refocused 2026-09-25 on access modifiers, final, encapsulation leaks, and records (scope moved to Lesson 7.2); real-API check: org.wpilib.hardware.rotation.Encoder (getRate()), allwpilib main commit e985b0fdfce935cb1484d4b9e8d1b98bd391bb51 (2026-09-25)