classes and interfaces
items 15–25: accessibility, immutability, composition, interfaces.
- chapter
- chapter 16.03
- reading time
- 14 min read
items 15 to 25 are about the shape of your types. hide everything you can (encapsulation), make objects immutable unless there's a reason not to, compose instead of extending classes you don't control, and model "one of these kinds" as a sealed hierarchy instead of a class with a type tag.
- item 15make everything as private as it can bemodules
- item 16public classes expose methods, not fieldsrecords
- item 17make things immutablerecords, List.copyOf
- item 18compose, don't extend
- item 19design for subclassing, or forbid itsealed classes
- item 20prefer interfaces to abstract classesdefault methods
- item 21default methods can't save a bad interfacejava 8 learned this
- item 22interfaces are types, not bags of constants
- item 23no tagged classes, use a hierarchysealed + switch patterns
- item 24nested classes: static unless you need the outer thisjavac drops unused this$0
- item 25one top-level class per source filemulti-file launcher
hiding things
item 15 · make everything as private as it can be
encapsulation is what lets you change a class without breaking its users. start every member at private, and loosen only when a real caller needs it. protected feels cozy but it's public API: any subclass anywhere can depend on it forever.
the package, plus subclasses anywhere. it's part of your exported API forever, just like public.
one sneaky leak: a public static final array. final stops you from replacing the array, not from changing what's in it.
class Config {
public static final String[] MODES =
{"read", "write"};
}
void main() {
Config.MODES[0] = "delete";
IO.println(Config.MODES[0]);
}delete
class Config {
public static final
List<String> MODES =
List.of("read", "write");
}
// Config.MODES.set(0, "x") throws
// UnsupportedOperationExceptionitem 16 · public classes expose methods, not fields
a public field can't validate, can't change representation, can't be made thread-safe, and can't be removed. an accessor can do all four. (package-private and private nested classes can expose fields, they're not API.)
public class Point {
public int x;
public int y;
}public record Point(int x, int y) {}
// p.x() is an accessor. the field
// stays private and final.item 17 · make things immutable
an immutable object has one state, forever. it's thread-safe without locks, it can be shared and cached freely, and it makes a perfect map key or set element. the rules: no setters, a final class (or private constructors), private final fields, and no way for anyone to reach a mutable field inside.
that last rule is the one records don't do for you. a record is shallowly immutable:
record Team(String name,
List<String> members) {}
void main() {
var list = new ArrayList<String>();
list.add("ada");
var team = new Team("core", list);
list.add("bo");
IO.println(team.members());
}[ada, bo]
record Team(String name,
List<String> members) {
Team {
members = List.copyOf(members);
}
}
void main() {
var list = new ArrayList<String>();
list.add("ada");
var team = new Team("core", list);
list.add("bo");
IO.println(team.members());
}[ada]
"change" an immutable object by returning a new one (BigDecimal.add, LocalDate.plusDays, String.strip). the cost is an allocation per step, which is usually nothing (see item 6). when it isn't, a mutable companion (like StringBuilder for String) does the heavy lifting. the full story, including why String is immutable, is in immutability.
inheritance
item 18 · compose, don't extend
extending a class you don't control ties your correctness to its implementation details. the classic failure: you override one method and assume the others route through it.
add, but addAll and add(index, e) copy straight into ArrayList's array. what you assumed isn't what happens, so nulls get in.import java.util.*;
class NoNullList<E> extends ArrayList<E> {
@Override public boolean add(E e) {
return super.add(Objects.requireNonNull(e, "no nulls here"));
}
}
public class Main {
public static void main(String[] args) {
var names = new NoNullList<String>();
names.add("ada");
names.addAll(Arrays.asList("bo", null)); // doesn't go through add()
names.add(0, null); // neither does this
System.out.println(names);
}
}[null, ada, bo, null]
and it's fragile in both directions: if a future JDK made addAll call add, a subclass that counted adds would suddenly double count. composition doesn't care. wrap the list, expose only what you check, forward the rest:
final class NoNullList<E> {
private final List<E> items = new ArrayList<>();
void add(E e) { items.add(Objects.requireNonNull(e, "no nulls here")); }
void addAll(Collection<? extends E> es) { es.forEach(this::add); }
E get(int i) { return items.get(i); }
int size() { return items.size(); }
}inheritance is fine inside one package you own, or for classes designed to be extended (next item). it's also only right for a true "is-a". more in composition over inheritance.
item 19 · design for subclassing, or forbid it
a class meant to be extended must document which overridable methods it calls on itself, and must never call them from a constructor: the subclass's override runs before the subclass's own constructor has set anything.
class Widget {
Widget() { render(); } // calls an overridable method
void render() { }
}
class Label extends Widget {
private final String text;
Label(String text) { this.text = text; }
@Override void render() { IO.println("rendering " + text); }
}
void main() {
new Label("hi").render();
}if you didn't design for it, forbid it: make the class final, or give it only private constructors and static factories.
interfaces
item 20 · prefer interfaces to abstract classes
a class can implement many interfaces but extend only one class, so an interface can be retrofitted onto existing classes and used as a mixin (Comparable, AutoCloseable). pair the interface with a skeletal implementation (AbstractList, AbstractMap) and implementers write only the essentials:
static List<Integer> squares(int n) {
return new AbstractList<>() {
@Override public Integer get(int i) { return Objects.checkIndex(i, n) * i; }
@Override public int size() { return n; }
};
}
void main() {
var sq = squares(6);
IO.println(sq); // toString, iteration, equals: all inherited
IO.println(sq.contains(16) + " " + sq.indexOf(25) + " " + sq.subList(1, 3));
}[0, 1, 4, 9, 16, 25] true 5 [1, 4]
two methods written, a full read-only List back: contains, indexOf, subList, iterator, equals, hashCode, all derived from get and size.
item 21 · default methods can't save a bad interface
adding a default method to a published interface injects code into every existing implementation, without the authors knowing. java 8's own Collection.removeIf default was correct in general, but wrong for synchronized wrapper classes written before it existed: they didn't override it, so it ran without taking their lock. design interfaces carefully up front, and test them with several implementations before release. after that, they're hard to change.
item 22 · interfaces are types, not bags of constants
an interface should say "an object of this type can do X". an interface that only holds constants (so classes can implement it to skip the prefix) leaks those constants into the implementing class's API forever.
interface Physics {
double G = 6.674e-11;
}
class Orbit implements Physics { }final class Physics {
private Physics() {}
static final double G = 6.674e-11;
}
// import static ...Physics.G;item 23 · no tagged classes, use a hierarchy
a "tagged class" has a kind field and a switch on it in every method, plus fields that only make sense for some kinds. it's verbose, error-prone (add a kind, miss a switch) and wastes memory. the fix is a type per kind.
class Shape {
enum Kind { CIRCLE, RECT }
final Kind kind;
double r; // circles only
double w, h; // rects only
double area() {
switch (kind) {
case CIRCLE: return Math.PI * r * r;
case RECT: return w * h;
default: throw new AssertionError();
}
}
}sealed interface Shape
permits Circle, Rect, Tri {}
record Circle(double r)
implements Shape {}
record Rect(double w, double h)
implements Shape {}
record Tri(double b, double h)
implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c ->
Math.PI * c.r() * c.r();
case Rect(var w, var h) -> w * h;
case Tri(var b, var h) ->
b * h / 2;
};
}
void main() {
IO.println(area(new Circle(1)));
IO.println(area(new Rect(2, 3)));
IO.println(area(new Tri(4, 5)));
}3.141592653589793 6.0 10.0
no default branch in the good version, on purpose: the switch is exhaustive over a sealed type, so adding a fourth record turns every unhandled switch into a compile error instead of a runtime AssertionError.
odds and ends
item 24 · nested classes: static unless you need the outer this
there are four kinds, and only one quietly holds a reference to its enclosing object:
- ├── static member classa regular class that lives in another's namespace. the default choice
- ├── inner classnon-static. each instance points at an outer instance
- ├── anonymous classdeclared and instantiated in one expression. mostly lambdas now
- └── local classdeclared inside a method. rare
that hidden reference costs memory and can keep a big outer object alive (an inner-class listener registered somewhere global is a textbook leak). if the nested class doesn't need the outer instance, declare it static.
public class Outer {
int clicks;
class Handler { void run() { clicks++; } } // uses the outer instance
class Quiet { } // doesn't
static class Nested { }
public static void main(String[] args) {
for (var c : new Class<?>[] { Handler.class, Quiet.class, Nested.class }) {
var names = java.util.Arrays.stream(c.getDeclaredFields()).map(f -> f.getName()).toList();
System.out.println(c.getSimpleName() + " " + names);
}
}
}Handler [this$0] Quiet [] Nested []
this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.
item 25 · one top-level class per source file
java lets you put several top-level classes in one file, and if two files define the same top-level class, which one you get depends on the order you pass files to javac. one class per file, named after it, makes that impossible. if you want helpers next to their user, make them static nested classes.
- start private. protected and public are promises you keep forever.
- immutable objects are simple, shareable and thread-safe. records are only shallowly so: copy mutable components.
- extending a class you don't own ties you to its internals. compose and forward.
- design a class for inheritance (no overridable calls in constructors) or make it final or sealed.
- interfaces for types, skeletal classes for convenience, never interfaces for constants.
- a type tag plus switches wants to be a sealed hierarchy of records.