4. Interfaces and Abstraction Contracts
Chapter 3 used inheritance to share implementation down a single family tree. This chapter introduces the other half of Java's abstraction toolkit: the interface, which describes what a type can do without committing to how or to any particular ancestry. Interfaces are how unrelated classes acquire a common capability, how libraries stay swappable, and how lambdas, collections, and nearly every framework API are wired together; the chapter closes with enums, records, and nested types, the specialised class forms that most often appear alongside interface-driven designs.
<a id="41-interface-fundamentals"></a>
4.1 Interface Fundamentals
An interface is a named set of abilities that any class can promise to provide, regardless of what it already extends.
Declaring and Implementing Interfaces
Inheritance couples a subclass to one specific parent. That is fine when the
types genuinely form a family, but most real requirements cut across families.
A Robot, a Dog, and a Car share no sensible superclass, yet all three can
move. You want a way to say "these types support this operation" without
inventing an artificial common ancestor.
An interface does exactly that. Think of it as a job description rather than a family tree: it lists the tasks a candidate must be able to perform and says nothing about who they are or where they came from.
public interface Movable {
void move(int distance); // implicitly public and abstract
void stop(); // no body: the interface states WHAT, not HOW
}
Two rules of interface declarations save a lot of confusion:
- Every method is implicitly
public abstractunless it isdefault,static, orprivate(covered in 4.2). Writingpublic abstractis legal but redundant, and most codebases omit it. - An interface cannot be instantiated, because none of its abstract methods have bodies.
A class signs the contract with implements and must supply a body for every
abstract method it inherits:
public class Robot implements Movable {
private int position;
@Override // compiler-checked: catches typos
public void move(int distance) {
position += distance;
System.out.println("Robot rolled to " + position);
}
@Override
public void stop() {
System.out.println("Robot powered down");
}
}
public class Dog implements Movable { // unrelated to Robot entirely
@Override
public void move(int distance) {
System.out.println("Dog ran " + distance + " metres");
}
@Override
public void stop() {
System.out.println("Dog sat down");
}
}
The implementing method must be public. This surprises people: the interface
method is implicitly public, and chapter 3 established that an override may
never reduce visibility, so anything less than public fails to compile.
public class Broken implements Movable {
@Override
void move(int distance) { } // ERROR: attempting to assign weaker
// access privileges; was public
...
}
If a class cannot implement every method, it must be declared abstract and
leave the remainder to its own subclasses:
public abstract class PartialMover implements Movable {
@Override
public void stop() { System.out.println("Halted"); }
// move(int) is left abstract; a concrete subclass must supply it
}
<<interface>>
Movable
+move(int): void
+stop(): void
^
| implements (dashed in UML)
+--------+--------+
| | |
Robot Dog Car no shared superclass required
Key Takeaways
- An interface declares capability without implementation, letting unrelated classes share a type.
- Interface methods are implicitly
public abstract; implementations must declare thempublic.- A class uses
implementsand must define every abstract method, or be declaredabstractitself.- Interfaces cannot be instantiated directly.
@Overrideon implementing methods is optional but catches signature mistakes at compile time.
🧪 Practice
- Write a
Drawableinterface with adraw()method and implement it inCircleandTextLabel.- Try implementing an interface method with default (package-private) visibility and read the compiler error carefully.
- Interview: Why can an interface not be instantiated, and what exactly does
new Runnable() { ... }create then? (Hint: something is being declared between those braces.)
Interfaces as Behavioral Contracts
A signature is only half of what an interface promises. int compare(T a, T b)
tells the compiler the shape of the call, but not that the result must be
negative, zero, or positive consistently, or that compare(a, b) and
compare(b, a) must disagree in sign. Those rules live in the documentation
and are every bit as binding: code that calls the method relies on them, and an
implementation that breaks them produces corrupt behaviour far from the bug.
This is the difference between the syntactic contract (enforced by the compiler) and the semantic contract (enforced only by discipline and tests).
/**
* A rule for accepting or rejecting an order.
*
* Implementations MUST be side-effect free and MUST return the same
* answer for the same order. Callers may invoke check() repeatedly,
* in any order, and from multiple threads.
*/
public interface OrderRule {
boolean check(Order order);
}
// Honours the contract: pure, repeatable, thread-safe.
public class MinimumTotalRule implements OrderRule {
private final double minimum;
public MinimumTotalRule(double minimum) { this.minimum = minimum; }
@Override
public boolean check(Order order) { return order.total() >= minimum; }
}
// Compiles perfectly; violates the contract.
public class OncePerOrderRule implements OrderRule {
private boolean used = false;
@Override
public boolean check(Order order) {
if (used) return false; // second call gives a different answer
used = true; // hidden mutable state
return true;
}
}
Both classes satisfy the compiler. Only the first is usable by a framework that caches, retries, or parallelises rule evaluation. When you write an interface, document the obligations; when you implement one, read them.
Contracts commonly specify:
| Obligation | Typical wording |
|---|---|
| Null handling | "throws NullPointerException if order is null" |
| Return range | "returns a value in [0, 1]" |
| Idempotence | "repeated calls have no additional effect" |
| Exceptions | "throws IllegalStateException if not yet opened" |
| Threading | "implementations must be safe for concurrent use" |
| Ordering relation | "must be transitive and consistent with equals" |
Key Takeaways
- An interface carries a syntactic contract the compiler checks and a semantic contract only documentation and tests can enforce.
- Document preconditions, postconditions, null behaviour, exceptions, and threading expectations on the interface, not on each implementation.
- Code that compiles can still violate the contract, usually by hiding mutable state or returning inconsistent results.
- Callers are entitled to rely on the documented contract; that reliance is what makes implementations interchangeable.
🧪 Practice
- Write Javadoc for a
Validatorinterface stating null handling and idempotence, then implement it twice.- Deliberately write an implementation that breaks the stated contract and describe a caller that would misbehave as a result.
- Interview:
Comparablerequires consistency withequals. What goes wrong in aTreeSetif an implementation ignores that? (Hint: think about which method the set actually uses to detect duplicates.)
Implementing Multiple Interfaces
Java permits only one superclass because inheriting two sets of fields and implementations creates ambiguity. Inheriting two sets of requirements does not: if two interfaces each demand a method, the class writes one method that satisfies both. So a class may implement any number of interfaces.
This is how Java gets the useful part of multiple inheritance without the
diamond problem. A type can be a Vehicle by ancestry while separately being
Serializable, Comparable, and Rentable by capability.
public interface Swimmer { void swim(); }
public interface Flyer { void fly(); }
public interface Diver { void dive(); }
public class Duck extends Bird implements Swimmer, Flyer, Diver {
@Override public void swim() { System.out.println("Paddling"); }
@Override public void fly() { System.out.println("Flapping"); }
@Override public void dive() { System.out.println("Ducking under"); }
}
The order matters syntactically: extends comes before implements, and there
may be only one extends clause for a class.
// public class Duck implements Swimmer extends Bird { } // ERROR: wrong order
When two interfaces declare a method with the same signature, one implementation satisfies both:
public interface Walker { void move(); }
public interface Roller { void move(); }
public class Skateboarder implements Walker, Roller {
@Override
public void move() { System.out.println("Pushing off"); } // satisfies both
}
If the signatures match but the return types are incompatible, the class is simply impossible to write and the compiler rejects it:
public interface A { int value(); }
public interface B { long value(); }
// public class C implements A, B { } // ERROR: unrelated return types
Each implemented interface is an independent view of the object:
Duck duck = new Duck();
Swimmer s = duck; // seen as a swimmer
Flyer f = duck; // seen as a flyer
s.swim();
f.fly();
// s.fly(); // ERROR: the Swimmer view has no fly()
Key Takeaways
- A class may implement unlimited interfaces but extend only one class.
extendsmust precedeimplementsin the declaration.- Identical abstract signatures from different interfaces are satisfied by a single method.
- Same-name methods with incompatible return types make the class uncompilable.
- Each interface gives a narrower view of the same object, exposing only its own members.
🧪 Practice
- Define
PrintableandScannableand write oneMultiFunctionDeviceimplementing both.- Create two interfaces declaring
String id()andint id()and try to implement both in one class; explain the error.- Interview: Java bans multiple class inheritance but allows multiple interface inheritance. What changed in Java 8 that made this slightly less clear-cut? (Hint: interfaces gained something they never had before.)
Interface Constants
Every field declared in an interface is implicitly public static final. There
is no such thing as an instance field in an interface, because an interface has
no instances and no state of its own.
public interface Physics {
double GRAVITY = 9.81; // implicitly public static final
int MAX_RETRIES = 3; // must be initialised at declaration
// int counter; // ERROR: = expected (no value given)
}
Because the modifiers are implicit, the constant is accessible through the interface name, and, more confusingly, through any implementing class:
System.out.println(Physics.GRAVITY); // preferred: names the source
public class Rocket implements Physics {
public double weight(double mass) {
return mass * GRAVITY; // inherited, usable unqualified
}
}
System.out.println(Rocket.GRAVITY); // legal, but obscures the origin
That last line illustrates the constant interface antipattern: implementing an interface purely to import its constants. It pollutes the class's public API with fields that are an implementation detail, and it commits the type to an interface it has no behavioural relationship with. Prefer a final class of static members, or an enum:
public final class PhysicsConstants {
public static final double GRAVITY = 9.81;
private PhysicsConstants() { } // prevent instantiation
}
// At the call site, if you want unqualified names:
import static com.example.PhysicsConstants.GRAVITY;
| Placement | When it is appropriate |
|---|---|
| Constant in an interface | Value is part of the contract itself, e.g. a sentinel returned by the interface's methods |
public final utility class | General shared constants unrelated to a contract |
enum | A fixed set of related named values |
private static final field | Constant used only inside one class |
Key Takeaways
- Interface fields are always
public static final, and must be assigned at declaration.- Interfaces cannot hold instance state.
- Constants are inherited by implementing classes and can be referenced unqualified, which hides where they came from.
- Implementing an interface only to gain its constants is an antipattern; use a utility class with static import, or an enum.
🧪 Practice
- Declare an interface with two constants and read one through the interface name and through an implementing class.
- Attempt to declare a non-final or uninitialised field in an interface and note the errors.
- Rewrite a constant interface as a final utility class with a private constructor, and use
import staticat the call site.
Interface Inheritance with extends
Interfaces form their own hierarchy, separate from the class hierarchy. One
interface extends another to say "everything that contract requires, plus
more". Unlike classes, an interface may extend several interfaces at once,
because it only accumulates requirements and never conflicting state.
public interface Readable { String read(); }
public interface Writable { void write(String data); }
public interface ReadWrite extends Readable, Writable { // multiple extends
default void copyFrom(ReadWrite other) { // adds its own member
write(other.read());
}
}
A class implementing ReadWrite must supply read(), write(String), and may
inherit copyFrom:
public class MemoryBuffer implements ReadWrite {
private String contents = "";
@Override public String read() { return contents; }
@Override public void write(String data) { this.contents = data; }
}
The subtype relationship works exactly as you would expect:
MemoryBuffer buffer = new MemoryBuffer();
Readable r = buffer; // ReadWrite IS-A Readable
Writable w = buffer;
System.out.println(buffer instanceof Readable); // true
Readable Writable interfaces may have many parents
\ /
\ /
ReadWrite extends both
^
: implements
MemoryBuffer a class implements the leaf
Note the deliberate asymmetry in vocabulary, which is a common exam question:
| Relationship | Keyword | How many allowed |
|---|---|---|
| Class to class | extends | Exactly one |
| Class to interface | implements | Any number |
| Interface to interface | extends | Any number |
| Interface to class | not possible | - |
Key Takeaways
- An interface extends other interfaces with
extends, neverimplements.- Interface inheritance is multiple: one interface may extend many.
- A sub-interface inherits all abstract and default methods and may add more.
- Implementing a sub-interface obliges the class to satisfy the entire inherited set.
- Every ancestor interface is a valid supertype for assignment and
instanceof.
🧪 Practice
- Build
Identifiable,Timestamped, andAuditRecord extends Identifiable, Timestamped; implement the last in one class.- Try writing
interface X implements Yand explain the compiler error.- Interview:
ListextendsCollectionextendsIterable. What does a method takingIterable<String>gain over one takingList<String>? (Hint: think about which callers you exclude.)
Interfaces as Reference Types
The real payoff of interfaces appears at the declaration site. Declaring a variable, parameter, field, or return type as the interface rather than the concrete class means the code depends only on the capability, so the implementation behind it can change without touching a single caller. This is programming to an interface.
// Tightly coupled: this method can only ever accept an ArrayList.
public void printAll(ArrayList<String> items) { ... }
// Flexible: accepts ArrayList, LinkedList, Arrays.asList(...), and any
// future List implementation, including test doubles.
public void printAll(List<String> items) { ... }
The same principle applies to local variables and fields:
List<String> names = new ArrayList<>(); // swap the right side freely
names = new LinkedList<>(names); // no other line needs to change
Map<String, Integer> scores = new HashMap<>();
Polymorphic dispatch works through interface references exactly as it does through class references: the method that runs is decided by the object, not the reference:
List<Movable> fleet = List.of(new Robot(), new Dog(), new Car());
for (Movable m : fleet) {
m.move(10); // Robot.move, Dog.move, or Car.move, chosen at runtime
}
An interface reference exposes only the interface's members. To reach
implementation-specific behaviour you must downcast, guarded by instanceof:
Movable m = new Robot();
// m.recharge(); // ERROR: not part of Movable
if (m instanceof Robot robot) { // pattern matching, Java 16+
robot.recharge(); // safe: the compiler knows the type here
}
Needing that cast often is a design smell: it suggests the interface is missing a method, or the parameter type is too abstract for what the code actually does.
Interfaces also make testing straightforward, because a hand-written or mocked implementation slots in wherever the real one does:
public class OrderService {
private final PaymentGateway gateway; // an interface
public OrderService(PaymentGateway gateway) { // injected, not created
this.gateway = gateway;
}
}
// In production:
new OrderService(new StripeGateway());
// In a unit test:
new OrderService(order -> true); // a trivial stub implementation
Key Takeaways
- Declare variables, parameters, and return types with the most general interface that supports what the code needs.
- The reference type decides which members are visible; the object decides which implementation runs.
- Programming to interfaces lets implementations be swapped, including for test doubles, without changing callers.
- Frequent downcasting from an interface signals a design problem.
- Interface-typed constructor parameters are the basis of dependency injection, revisited in chapter 13.
🧪 Practice
- Rewrite a method that takes
ArrayList<Order>to takeList<Order>and call it withList.of(...).- Store several implementations of one interface in a
Listand loop over them, observing dynamic dispatch.- Interview: What does "programming to an interface, not an implementation" buy you in practice? (Hint: name two things that become possible that were not before, one about change and one about testing.)
<a id="42-modern-interface-features"></a>
4.2 Modern Interface Features
Java 8 and later relaxed the rule that interfaces hold no code, so that libraries could grow without breaking the classes that already implement them.
Default Methods and Backward Compatibility
Before Java 8, adding a method to a published interface was a breaking change:
every existing implementation stopped compiling overnight. That froze the
standard library. Collection could not gain stream() without invalidating
every custom collection ever written.
A default method solves this by shipping an implementation inside the interface. Existing classes inherit it silently and keep compiling; classes that want different behaviour override it.
public interface Logger {
void log(String message); // still abstract
default void logError(String message) { // new, with a body
log("ERROR: " + message); // may call abstract methods
}
default void logAll(List<String> messages) {
for (String m : messages) log(m);
}
}
public class ConsoleLogger implements Logger {
@Override
public void log(String message) { System.out.println(message); }
// logError and logAll come for free
}
ConsoleLogger logger = new ConsoleLogger();
logger.logError("disk full"); // prints "ERROR: disk full"
Any implementation may override a default to specialise it:
public class TimestampLogger implements Logger {
@Override public void log(String message) { System.out.println(message); }
@Override
public void logError(String message) { // replaces the default
log("[" + java.time.Instant.now() + "] ERROR: " + message);
}
}
Inside an overriding method you can still reach the inherited default with the
InterfaceName.super syntax:
public class CountingLogger implements Logger {
private int errors = 0;
@Override public void log(String message) { System.out.println(message); }
@Override
public void logError(String message) {
errors++;
Logger.super.logError(message); // delegate to the interface default
}
}
Two limits keep defaults honest. A default method has no access to instance
fields, because interfaces have none, so it can only work through other
interface methods. And a default cannot override a method of Object:
public interface Bad {
// default String toString() { return "nope"; } // ERROR: not allowed
}
Real examples from the standard library are worth recognising:
Collection.stream(), Collection.removeIf(...), List.sort(...),
Iterable.forEach(...), Comparator.reversed(), Map.getOrDefault(...).
Key Takeaways
defaultmethods carry an implementation inside an interface so new methods can be added without breaking existing implementors.- They may call the interface's abstract methods but cannot touch instance fields, because interfaces have no state.
- Any implementing class may override a default;
Interface.super.method()calls the inherited version.- Defaults cannot override
equals,hashCode, ortoString.- Defaults are for evolution and convenience, not a replacement for abstract classes.
🧪 Practice
- Add a
default String describe()to an existing interface and confirm old implementations still compile.- Override that default in one class and call the inherited version with
Interface.super.- Interview: Do default methods give Java multiple inheritance? (Hint: distinguish inheritance of behaviour from inheritance of state.)
Static Interface Methods
Helper methods related to an interface used to live in a separate companion
class: Collections for Collection, Arrays for arrays, Paths for Path.
That split forced readers to know two names for one concept. Since Java 8 an
interface can hold its own static methods, keeping the factory and utility
code next to the contract it serves.
public interface Shape {
double area();
static Shape circle(double radius) { // factory method
return () -> Math.PI * radius * radius; // a lambda IS a Shape here
}
static Shape rectangle(double w, double h) {
return () -> w * h;
}
static Shape largest(Shape a, Shape b) { // utility method
return a.area() >= b.area() ? a : b;
}
}
Shape c = Shape.circle(2); // called on the interface name only
Shape r = Shape.rectangle(3, 4);
System.out.println(Shape.largest(c, r).area());
The critical difference from default methods: static interface methods are not inherited. They are not visible through an implementing class or an instance reference.
public class Square implements Shape {
private final double side;
public Square(double side) { this.side = side; }
@Override public double area() { return side * side; }
}
// Square.circle(2); // ERROR: not inherited
// new Square(2).circle(2); // ERROR: static method on an instance
Shape.circle(2); // the only legal form
| Member kind | Has a body | Inherited | Called on |
|---|---|---|---|
abstract | No | Yes | An instance |
default | Yes | Yes | An instance |
static | Yes | No | The interface name |
private | Yes | No | Inside the interface |
Standard library examples: List.of(...), Map.entry(...),
Comparator.comparing(...), Function.identity(), Predicate.not(...).
Key Takeaways
- Interfaces may declare
staticmethods with bodies, typically factories or utilities for the type.- Static interface methods are not inherited and must be qualified with the interface name.
- They remove the need for separate companion utility classes.
- They cannot be overridden, so their behaviour is fixed.
🧪 Practice
- Add a static
of(...)factory to an interface you wrote and use it to create instances.- Try calling that static method through an implementing class and explain the error.
- Compare
Collections.sort(list)withlist.sort(null)and say which method kind each one is.
Private Interface Methods
Once interfaces could hold code, they could hold duplicated code. Two default
methods that share logic had nowhere to put it: a second default would expose
an implementation detail as public API, and a static method could not touch the
instance. Java 9 added private interface methods for exactly this.
public interface Report {
List<String> rows();
default String asCsv() {
return format(","); // shared logic, hidden from callers
}
default String asTsv() {
return format("\t");
}
private String format(String separator) { // instance private helper
return String.join(separator, rows()); // may call abstract methods
}
static Report empty() {
return withRows(List.of());
}
private static Report withRows(List<String> rows) { // static private
return () -> rows;
}
}
The rules are short:
- A
privatemethod must have a body and cannot beabstract. - A private instance method may be called from
defaultand other private instance methods, and can invoke abstract methods. - A
private staticmethod may be called fromstatic,default, and private methods. - Neither form is inherited, overridable, or visible outside the interface.
public class SalesReport implements Report {
@Override public List<String> rows() { return List.of("a", "b"); }
// format(...) is invisible here; only asCsv() and asTsv() are inherited
}
// new SalesReport().format(","); // ERROR: format has private access
Key Takeaways
privateinterface methods hold logic shared between default or static methods without exposing it.- They must have a body;
private abstractis a contradiction and illegal.- Private instance methods serve
defaultmethods;private staticmethods servestaticones.- They are never inherited and never part of the published contract.
🧪 Practice
- Write an interface with two default methods that duplicate logic, then extract the shared part into a private method.
- Try calling the private method from an implementing class and note the error.
- Explain when a helper should be
privateversusprivate staticin an interface.
Resolving Conflicting Default Methods
Multiple interface inheritance was safe while interfaces carried no code. Once defaults arrived, a class could inherit two competing bodies for the same signature, which is the diamond problem in a new form. Java does not guess: it refuses to compile and makes you choose.
public interface Alarm { default void trigger() { System.out.println("Beep"); } }
public interface Siren { default void trigger() { System.out.println("Wail"); } }
// public class Device implements Alarm, Siren { }
// ERROR: class Device inherits unrelated defaults for trigger() from
// types Alarm and Siren
The class must override and may delegate explicitly:
public class Device implements Alarm, Siren {
@Override
public void trigger() {
Alarm.super.trigger(); // pick one
Siren.super.trigger(); // or both, in your chosen order
System.out.println("Flashing lights"); // or something else entirely
}
}
When there is no genuine ambiguity, Java applies two resolution rules in order:
1. Classes win over interfaces. A method inherited from a superclass beats any default with the same signature.
public class BaseDevice {
public void trigger() { System.out.println("Class wins"); }
}
public class Siren2 extends BaseDevice implements Alarm { }
new Siren2().trigger(); // "Class wins": no error, no ambiguity
2. The most specific interface wins. If one interface extends the other, its default overrides the inherited one.
public interface LoudAlarm extends Alarm {
@Override default void trigger() { System.out.println("BEEEEP"); }
}
public class Klaxon implements Alarm, LoudAlarm { } // fine
new Klaxon().trigger(); // "BEEEEP": LoudAlarm is more specific
Conflict? -> Is one inherited from a CLASS? -> yes: the class method wins
| no
Is one interface a SUBTYPE of the other? -> yes: subtype wins
| no
COMPILE ERROR: the class must override and choose
Note that an abstract redeclaration cancels a default, forcing implementors to decide:
public interface StrictAlarm extends Alarm {
@Override void trigger(); // reinstated as abstract: no default now
}
Key Takeaways
- Inheriting unrelated defaults with the same signature is a compile error, not a silent choice.
- The fix is to override the method, optionally delegating with
Interface.super.method().- A superclass method always beats an interface default.
- The most specific interface wins when one extends the other.
- Redeclaring a method as abstract in a sub-interface removes the inherited default.
🧪 Practice
- Create two interfaces with identical default methods and a class implementing both; resolve the error two different ways.
- Demonstrate the class-wins rule by adding a superclass with the same method.
- Interview: Why does Java report an error here instead of choosing the first interface listed? (Hint: what would happen to your program if someone reordered the
implementsclause?)
Marker Interfaces
Some information is about a type as a whole rather than about any method it
has: "instances of this class may be serialised", "this class supports
cloning". A marker interface carries that fact and nothing else, with an
empty body. Implementing it is a statement about the class, checkable at
runtime with instanceof.
public interface Auditable { } // no members at all
public class Invoice implements Auditable { }
public class TempFile { } // deliberately not auditable
public void process(Object record) {
if (record instanceof Auditable) { // the marker is the whole test
writeAuditTrail(record);
}
}
The standard library uses several: Serializable, Cloneable,
RandomAccess, and Remote. Each changes how other code, often inside the
JVM or the library itself, treats the object. Collections.binarySearch, for
instance, chooses a different algorithm for a List that implements
RandomAccess.
Markers have a real advantage over annotations: they create a type, so the compiler can enforce them in a signature.
public void archive(Auditable record) { ... } // compile-time guarantee
archive(new Invoice()); // fine
// archive(new TempFile()); // ERROR: caught at compile time
| Aspect | Marker interface | Annotation (@Retention(RUNTIME)) |
|---|---|---|
| Creates a type | Yes | No |
| Compile-time checking | Yes, via parameters | No, only reflection at runtime |
| Carries extra data | No | Yes, through elements |
| Applies to | Classes and interfaces | Classes, methods, fields, params |
| Inherited by subclasses | Always | Only with @Inherited |
Modern Java usually prefers annotations when the marker needs parameters or must apply to methods, and marker interfaces when the mark should be enforced by the compiler. Annotations are covered in chapter 10.
Key Takeaways
- A marker interface has no members and exists to tag a type with metadata.
- Tests are written with
instanceofor enforced through parameter types.Serializable,Cloneable, andRandomAccessare the canonical examples.- Markers give compile-time safety; annotations give flexibility and data.
- Do not add a marker where a real method would express the capability better.
🧪 Practice
- Define a
Cacheablemarker and a method that only caches objects implementing it.- Rewrite the same idea as a runtime annotation and compare how the check looks.
- Interview:
Cloneableis often called a broken marker. Why? (Hint: which class declaresclone(), and what visibility does it have?)
Functional Interfaces and @FunctionalInterface
An interface with exactly one abstract method describes a single operation, so an instance of it is effectively just a function. Java 8 made that observation into a language feature: wherever such an interface is expected, you can supply a lambda expression or a method reference instead of writing a class.
Such an interface is called a functional interface, and its single abstract method is the SAM (single abstract method).
@FunctionalInterface // optional, but compiler-enforced
public interface Transformer {
String apply(String input); // exactly one abstract method
}
Transformer upper = s -> s.toUpperCase(); // lambda
Transformer trimmed = String::trim; // method reference
Transformer verbose = new Transformer() { // anonymous class, pre-Java 8
@Override public String apply(String input) { return "<" + input + ">"; }
};
System.out.println(upper.apply("hello")); // HELLO
The annotation does not make an interface functional; the single abstract
method does. What @FunctionalInterface adds is a compile-time guard against
accidentally breaking that property later:
@FunctionalInterface
public interface Broken {
String apply(String input);
// int count(); // ERROR: Broken is not a functional interface
// (multiple non-overriding abstract methods)
}
Three kinds of member do not count against the single-abstract-method rule:
@FunctionalInterface
public interface StillFunctional {
String apply(String input); // the one SAM
default String twice(String s) { // defaults don't count
return apply(apply(s));
}
static StillFunctional identity() { // statics don't count
return s -> s;
}
boolean equals(Object other); // public Object methods
// don't count either
}
That last rule explains why Comparator is a functional interface despite
declaring both compare and equals: equals is already public in Object,
so it is not counted.
The standard library ships a full set of ready-made functional interfaces in
java.util.function, covered in chapter 8:
| Interface | Abstract method | Typical use |
|---|---|---|
Runnable | void run() | A task with no input/output |
Supplier<T> | T get() | Produces a value |
Consumer<T> | void accept(T) | Uses a value |
Function<T,R> | R apply(T) | Transforms a value |
Predicate<T> | boolean test(T) | Tests a condition |
Comparator<T> | int compare(T,T) | Defines an ordering |
List<String> names = new ArrayList<>(List.of("bob", "ann", "cy"));
names.removeIf(n -> n.length() < 3); // Predicate<String>
names.forEach(System.out::println); // Consumer<String>
names.sort(Comparator.comparing(String::length));// Comparator<String>
Key Takeaways
- A functional interface has exactly one abstract method and can be implemented by a lambda or method reference.
@FunctionalInterfaceis optional documentation that makes the compiler enforce the single-method rule.default,static,private, and publicObjectmethods do not break functionality.java.util.functionprovides standard functional interfaces; prefer them over inventing your own.- Lambdas are the syntax; functional interfaces are the type system that makes them work.
🧪 Practice
- Write a
@FunctionalInterfacecalledValidatorwithboolean validate(String)and implement it with a lambda.- Add a second abstract method and observe the compiler error, then convert that method into a
defaultand watch it compile again.- Interview: Why is
Comparatorstill a functional interface even though it declares two abstract methods? (Hint: look up whereequalsis originally declared.)
<a id="43-abstract-classes-versus-interfaces"></a>
4.3 Abstract Classes versus Interfaces
Both express abstraction, and since Java 8 they overlap more than they used to, so choosing between them is a design decision rather than a technical one.
Capability Comparison
Chapter 3 introduced abstract classes; this chapter introduced interfaces. Because interfaces gained method bodies in Java 8 and private methods in Java 9, the practical gap between them has narrowed to two things: state and how many you can have. Everything else is detail.
public abstract class AbstractAccount {
protected double balance; // state: only a class can do this
private final String id; // private instance field
protected AbstractAccount(String id) { this.id = id; } // constructor
public abstract double interestRate(); // abstract method
public double interestEarned() { return balance * interestRate(); }
protected void audit() { } // protected: interfaces cannot
}
public interface Account {
double interestRate(); // abstract
double MIN_BALANCE = 0.0; // public static final only
default double interestEarned(double balance) {
return balance * interestRate(); // must receive state as a param
}
}
| Feature | Abstract class | Interface |
|---|---|---|
| Instance fields (state) | Yes | No |
| Constants | Any modifier | public static final only |
| Constructors | Yes, for subclass use | No |
| Abstract methods | Yes | Yes |
| Concrete methods | Yes | Yes, as default/static |
private methods | Yes | Yes, since Java 9 |
protected members | Yes | No |
static methods | Yes, inherited | Yes, not inherited |
final methods | Yes | No |
| Can be instantiated | No | No |
| How many per class | Exactly one (extends) | Unlimited (implements) |
Can override Object methods | Yes | No |
| Initialisation blocks | Yes | No |
| Default member access | Package-private | public |
Two rows deserve emphasis because they are the source of most exam questions.
An interface cannot declare protected or final methods, and it cannot
provide toString, equals, or hashCode. An abstract class can do all of
that, but a class can only have one of them.
Key Takeaways
- Interfaces cannot hold instance state, constructors, or
protectedandfinalmethods.- Abstract classes can do everything an interface can except be implemented more than once per class.
- Interface members are public by default; class members are package-private by default.
- Neither can be instantiated directly.
- Default methods narrowed the gap but did not close it: state remains the dividing line.
🧪 Practice
- Write the same small contract twice, once as an abstract class and once as an interface, and list what each version can express that the other cannot.
- Try declaring a
protected defaultmethod in an interface and read the error.- Attempt to give an interface a constructor and explain why the language forbids it.
State versus Behavior Contracts
The cleanest way to decide is to ask what you are actually sharing. If subtypes must share data and the invariants that protect it, that is a class. If they must share only an ability, that is an interface.
An abstract class can own fields and therefore enforce invariants in a constructor. Every subclass inherits a valid, initialised state:
public abstract class Employee {
private final String name; // shared state, guarded
private double salary;
protected Employee(String name, double salary) {
if (salary < 0) throw new IllegalArgumentException("negative salary");
this.name = name; // the invariant holds for EVERY subclass
this.salary = salary;
}
public final String name() { return name; } // final: cannot be subverted
public abstract double bonus(); // subclasses differ here
}
An interface has no such power. A default method can only reach state through abstract accessors that the implementing class supplies:
public interface Taxable {
double taxableIncome(); // the class provides state
default double taxDue(double rate) { // behaviour only
return taxableIncome() * rate;
}
}
Trying to keep state in an interface fails immediately:
public interface Counter {
// private int count = 0; // ERROR: fields are public static final,
// and shared by ALL implementors
int count(); // the honest alternative
}
The consequence is important: a field in an interface is one value shared
across the whole program, not per object. If you write int count = 0; in an
interface, you have created a constant that no instance can change.
| You need to share | Use |
|---|---|
| Fields and the invariants protecting them | Abstract class |
| A constructor that validates on creation | Abstract class |
| A capability unrelated types can adopt | Interface |
| A method signature and nothing more | Interface |
| Behaviour derived entirely from other methods | Interface with defaults |
The two combine well. A common arrangement is a public interface for the contract plus an abstract class that holds the shared state for those implementations that want it, which is the skeletal implementation pattern covered at the end of this subchapter.
Key Takeaways
- Abstract classes exist to share state plus the invariants that protect it.
- Interfaces exist to share capability across unrelated types.
- Interface fields are constants shared program-wide, never per-instance state.
- Interface default methods can only reach state through abstract accessors.
- A constructor that must validate arguments requires a class.
🧪 Practice
- Design
Vehicleas an abstract class with a validatedwheelsfield andRentableas an interface, then combine them inRentalCar.- Declare
int total = 0;inside an interface and try to modify it from two instances; explain what happens.- Convert an abstract class that has no fields into an interface and note what you had to change.
Multiple Inheritance of Type
Java separates three things that other languages bundle together: inheritance
of type (being usable as a T), of implementation (getting method bodies), and
of state (getting fields). Java allows multiple inheritance of the first two,
and single inheritance of the third.
| Kind | Multiple allowed? | Mechanism |
|---|---|---|
| Type (subtyping) | Yes | implements |
| Implementation (method bodies) | Yes, since Java 8 | default methods |
| State (fields) | No | extends only |
Restricting state is what keeps the diamond problem tractable. If a class could inherit the same field through two paths, the JVM would have to decide whether there is one copy or two, which is the C++ virtual-inheritance rabbit hole. Method bodies have no such problem: conflicts are rare, detectable at compile time, and resolved by the rules in 4.2.
public interface Serializer { default String serialize() { return "{}"; } }
public interface Validator { default boolean isValid() { return true; } }
public interface Auditable { default String auditLine() { return "ok"; } }
public class Order extends BaseEntity // one state parent
implements Serializer, Validator, Auditable { // many capabilities
}
Order now has four usable types, and any of them can be the declared type of
a variable:
Order order = new Order();
BaseEntity e = order;
Serializer s = order;
Validator v = order;
Auditable a = order;
BaseEntity Serializer Validator Auditable
| state \ | / behaviour + type
| \ | /
+--------------- Order ---+--------+
one class, four supertypes
This is the structural reason interface-based designs scale: adding a capability to a type never disturbs its existing hierarchy, so capabilities can be introduced by different teams, in different modules, at different times.
Key Takeaways
- Java allows multiple inheritance of type and, since Java 8, of implementation, but never of state.
- Banning inherited state is what keeps the diamond problem manageable.
- A class has one superclass but as many supertypes as it implements interfaces.
- Any supertype may be used as the declared type of a reference.
- Capabilities can be added to a class without altering its class hierarchy.
🧪 Practice
- Give one class three interfaces and assign the same object to four differently typed references.
- Draw the type graph for
ArrayList, listing every supertype you can find in the Javadoc.- Interview: Java inherits implementation from multiple interfaces but state from only one class. Why is state the dangerous one? (Hint: ask how many copies of a duplicated field an object should contain.)
Choosing the Right Abstraction
With the mechanics settled, the decision comes down to a few questions asked in order. Work down the list and stop at the first clear answer.
- Will unrelated classes need this? If the types share no natural parent,
it must be an interface. A class hierarchy cannot span
File,Socket, andConnection, butAutoCloseablecan. - Is there shared mutable state or a constructor invariant? If yes, an abstract class, because interfaces cannot hold fields.
- Will implementors need to also extend something else? If yes, an
interface, because they have only one
extendsto spend. - Is this a capability or an identity? Capabilities ("can be compared", "can be closed") are interfaces. Identity ("is an employee") is usually a class.
- Do you expect to add methods later? Interfaces with defaults evolve well; abstract classes evolve well too, but they cost the implementor their single inheritance slot.
A useful naming heuristic follows from question 4: interfaces are often
adjectives or -able words (Comparable, Runnable, Closeable,
Serializable), while abstract classes are nouns (AbstractList,
InputStream, Employee).
// Capability shared by unrelated types -> interface
public interface Exportable {
byte[] export();
}
// Identity with shared, validated state -> abstract class
public abstract class Document {
private final String title;
protected Document(String title) {
this.title = Objects.requireNonNull(title);
}
public String title() { return title; }
public abstract int pageCount();
}
// Both together
public class Invoice extends Document implements Exportable {
public Invoice(String title) { super(title); }
@Override public int pageCount() { return 1; }
@Override public byte[] export() { return title().getBytes(); }
}
The modern default in library design is to start with an interface. It costs the implementor nothing, keeps every future option open, and can always be paired with an abstract helper class later. Reach for an abstract class only when shared state or a validated constructor makes it necessary.
Key Takeaways
- Use an interface when unrelated types need the capability or when implementors need their
extendsslot.- Use an abstract class when subtypes must share fields or a validating constructor.
- Capability abstractions tend to be
-ableinterfaces; identity abstractions tend to be noun classes.- A type can and often should do both: extend a class and implement several interfaces.
- Default to an interface, and add an abstract class only when state demands it.
🧪 Practice
- For each of
Comparable,InputStream,Runnable, andAbstractMap, say why the library chose interface or abstract class.- Model a plugin system and justify your choice of abstraction in two sentences.
- Interview: You are designing a public library API. Would you publish an interface or an abstract class, and what does each choice commit you to? (Hint: think about what you can add in version 2 without breaking callers.)
Skeletal Implementation Pattern
Interfaces give flexibility; abstract classes give code reuse. The skeletal implementation pattern, popularised by Effective Java, takes both: publish the interface as the type, and ship an abstract class beside it that implements the tedious parts in terms of a few primitive operations.
The Java Collections Framework is built this way. List is the interface,
AbstractList is the skeleton, and ArrayList is a concrete class that
extends the skeleton. A custom list can either extend AbstractList and write
two methods, or implement List directly and write twenty-five.
public interface Cache<K, V> {
V get(K key);
void put(K key, V value);
boolean contains(K key);
void clear();
int size();
boolean isEmpty();
V getOrDefault(K key, V fallback);
}
// The skeleton: implements everything derivable from the primitives.
public abstract class AbstractCache<K, V> implements Cache<K, V> {
// Primitive operations: subclasses must supply these.
@Override public abstract V get(K key);
@Override public abstract void put(K key, V value);
@Override public abstract int size();
// Derived operations: written once, in terms of the primitives.
@Override
public boolean contains(K key) { return get(key) != null; }
@Override
public boolean isEmpty() { return size() == 0; }
@Override
public V getOrDefault(K key, V fallback) {
V found = get(key);
return found != null ? found : fallback;
}
}
// A concrete cache now needs only the three primitives plus clear().
public class MapCache<K, V> extends AbstractCache<K, V> {
private final Map<K, V> store = new HashMap<>();
@Override public V get(K key) { return store.get(key); }
@Override public void put(K key, V v) { store.put(key, v); }
@Override public int size() { return store.size(); }
@Override public void clear() { store.clear(); }
}
Crucially, the skeleton is optional. A class that already extends something
else can implement Cache directly, and can still reuse the skeleton through
composition by forwarding to a private inner subclass. That escape hatch is
what makes the pattern safe to offer.
<<interface>> Cache the published type; what callers use
^
: implements
AbstractCache (abstract) optional reuse; the derived methods
^
| extends
MapCache the primitives only
Naming conventions to recognise in the wild: AbstractList, AbstractSet,
AbstractMap, and AbstractQueue in java.util; the same idea appears as
*Adapter classes such as MouseAdapter and WindowAdapter in older UI
libraries.
Key Takeaways
- Publish the interface as the type and offer an abstract skeleton for reuse.
- The skeleton implements every method that can be derived from a small set of primitive operations.
- Implementors choose the skeleton for convenience or the interface directly for freedom.
AbstractList,AbstractSet, andAbstractMapare the standard library's examples.- Default methods can absorb some of this role, but only for logic that needs no state.
🧪 Practice
- Write a
Stack<T>interface with six methods and anAbstractStackthat implements four of them from two primitives.- Extend your skeleton with an array-backed concrete class, then write a second implementation that implements the interface directly.
- Interview: When would you move a skeleton's derived methods into the interface as
defaultmethods instead? (Hint: what does the skeleton have access to that a default method does not?)
<a id="44-core-java-interfaces"></a>
4.4 Core Java Interfaces
A handful of standard interfaces appear everywhere in Java; implementing them correctly plugs your own classes into language features and library machinery.
Comparable and Natural Ordering
Sorting requires knowing which of two objects comes first. For int the
answer is built in, but for an Employee only the class itself knows. Rather
than teach every sorting algorithm about every type, Java inverts the
responsibility: a class declares its own natural ordering by implementing
Comparable, and all sorting machinery then works with it.
public interface Comparable<T> {
int compareTo(T other);
}
The return value is a sign, not a magnitude:
a.compareTo(b) returns | Meaning |
|---|---|
| Negative | a comes before b |
| Zero | a and b rank equally |
| Positive | a comes after b |
public class Employee implements Comparable<Employee> {
private final String name;
private final int salary;
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String name() { return name; } // used by 4.4's comparators
public int salary() { return salary; }
@Override
public int compareTo(Employee other) {
return Integer.compare(this.salary, other.salary); // ascending salary
// NOT this.salary - other.salary: that overflows for large values
}
@Override public String toString() { return name + "(" + salary + ")"; }
}
List<Employee> staff = new ArrayList<>(List.of(
new Employee("Ada", 90_000),
new Employee("Bo", 70_000),
new Employee("Cy", 110_000)));
Collections.sort(staff); // uses compareTo
staff.sort(null); // same thing: null means natural ordering
System.out.println(staff); // [Bo(70000), Ada(90000), Cy(110000)]
TreeSet<Employee> ranked = new TreeSet<>(staff); // sorted set needs ordering
The overflow warning above is not theoretical. this.salary - other.salary
wraps around for values near Integer.MIN_VALUE or MAX_VALUE and silently
returns the wrong sign. Always use Integer.compare, Double.compare, or
delegate to an existing compareTo.
The contract has four obligations:
- Sign reversal:
a.compareTo(b)andb.compareTo(a)must have opposite signs. - Transitivity: if
a > bandb > c, thena > c. - Consistency: if
a.compareTo(b) == 0, thena.compareTo(c)andb.compareTo(c)must agree. - Consistency with
equals(strongly recommended, not required):a.compareTo(b) == 0should implya.equals(b).
That last point has a visible consequence. Sorted collections use compareTo,
not equals, to detect duplicates:
// compareTo compares salary only, so two different people "tie"
Set<Employee> set = new TreeSet<>();
set.add(new Employee("Ada", 90_000));
set.add(new Employee("Zoe", 90_000));
System.out.println(set.size()); // 1: Zoe was treated as a duplicate
Set<Employee> hashed = new HashSet<>(); // uses equals/hashCode instead
// ... would contain 2
For multi-field ordering, compare in priority order and return at the first difference:
@Override
public int compareTo(Employee other) {
int bySalary = Integer.compare(this.salary, other.salary);
if (bySalary != 0) return bySalary; // primary key decided it
return this.name.compareTo(other.name); // tie-break on name
}
Key Takeaways
Comparabledefines a single natural ordering inside the class itself.compareToreturns a negative, zero, or positive number, never a meaningful magnitude.- Use
Integer.compareand friends; subtraction overflows.- The ordering must be antisymmetric, transitive, and consistent, and should agree with
equals.- Sorted collections such as
TreeSetandTreeMapusecompareTofor duplicate detection, notequals.
🧪 Practice
- Make a
Bookclass comparable by title and sort a list of them.- Extend
compareToto sort by year first and title second, then verify with a list containing ties.- Interview: Why can subtracting two ints be a bug inside
compareTo? (Hint: try it withInteger.MIN_VALUEand a positive number.)
Comparator and Custom Ordering
Comparable gives a type one ordering, fixed at compile time inside the class.
That is not enough when you need several orderings, when the ordering depends
on context, or when the class is not yours to modify. Comparator moves the
comparison outside the class, so any number of orderings can coexist.
public interface Comparator<T> {
int compare(T first, T second); // the SAM; same sign convention
}
Because it is a functional interface, a comparator is usually a lambda or, more readably, built from the static factory methods:
Comparator<Employee> bySalary = (a, b) -> Integer.compare(a.salary(), b.salary());
Comparator<Employee> byName = Comparator.comparing(Employee::name);
Comparator<Employee> byLength = Comparator.comparingInt(e -> e.name().length());
The combinators are where Comparator earns its place. They chain into
readable, multi-key orderings:
Comparator<Employee> ordering =
Comparator.comparing(Employee::department) // primary key
.thenComparing(Employee::salary, // tie-break
Comparator.reverseOrder())
.thenComparing(Employee::name); // final tie-break
staff.sort(ordering);
staff.sort(ordering.reversed()); // whole order flipped
| Factory or combinator | Effect |
|---|---|
Comparator.comparing(fn) | Order by an extracted Comparable key |
comparingInt/Long/Double(fn) | Same, without boxing the key |
thenComparing(fn) | Tie-break with a second key |
reversed() | Invert the whole ordering |
Comparator.naturalOrder() | Delegate to compareTo |
Comparator.reverseOrder() | Reverse of the natural ordering |
nullsFirst(cmp) / nullsLast(cmp) | Define where null elements sort |
Null handling is a frequent source of NullPointerException, and the wrappers
solve it without writing branches:
Comparator<String> safe = Comparator.nullsFirst(Comparator.naturalOrder());
List<String> values = new ArrayList<>(Arrays.asList("b", null, "a"));
values.sort(safe); // [null, a, b]
Anywhere a sorted structure is built, a comparator can be supplied in place of the natural ordering:
staff.sort(byName); // List.sort
Collections.max(staff, bySalary); // single extreme
TreeSet<Employee> byPay = new TreeSet<>(bySalary); // sorted set
TreeMap<Employee, String> map = new TreeMap<>(byName); // sorted map
| Question | Comparable | Comparator |
|---|---|---|
| Where does the logic live | Inside the class | Outside, in its own object |
| Method | compareTo(T) | compare(T, T) |
| How many orderings | One | Unlimited |
| Can order classes you don't own | No | Yes |
| Package | java.lang | java.util |
| Lambda-friendly | No, it needs this | Yes, it is a SAM |
Key Takeaways
Comparatorexternalises ordering so one type can have many orderings.- It is a functional interface, so lambdas and method references work directly.
comparing(...).thenComparing(...).reversed()composes multi-key orderings readably.- Use
nullsFirstornullsLastrather than hand-writing null checks.- Prefer
Comparablefor the one obvious ordering andComparatorfor everything else.
🧪 Practice
- Sort a list of
Bookby author, then by year descending, usingComparatorcombinators.- Build a
TreeSetof strings ordered by length then alphabetically.- Interview: When would you add
Comparableto a class rather than always passing aComparator? (Hint: is there one ordering everyone would expect?)
Iterable and Iterator
The enhanced for loop is not magic syntax reserved for arrays and library
collections. It is defined in terms of an interface, so any class that
implements Iterable can be used with it. Two interfaces work together:
public interface Iterable<T> {
Iterator<T> iterator(); // hands out a fresh cursor
}
public interface Iterator<T> {
boolean hasNext(); // is there another element?
T next(); // return it and advance
default void remove() { throw new UnsupportedOperationException(); }
}
Iterable says "I can be traversed". Iterator is the cursor that does the
traversal and holds the position. Separating them means several independent
traversals of the same collection can run at once.
public class Playlist implements Iterable<String> {
private final String[] tracks;
public Playlist(String... tracks) { this.tracks = tracks; }
@Override
public Iterator<String> iterator() {
return new Iterator<>() { // anonymous class, see 4.6
private int index = 0; // the cursor's own position
@Override public boolean hasNext() { return index < tracks.length; }
@Override public String next() { return tracks[index++]; }
};
}
}
Playlist playlist = new Playlist("Intro", "Verse", "Chorus");
for (String track : playlist) { // works because Playlist is Iterable
System.out.println(track);
}
playlist.forEach(System.out::println); // default method on Iterable
The compiler expands the enhanced for into exactly this:
Iterator<String> it = playlist.iterator();
while (it.hasNext()) {
String track = it.next();
System.out.println(track);
}
Knowing the expansion explains two things that puzzle beginners. First,
next() must throw NoSuchElementException when exhausted, and a correct
iterator checks. Second, removing from a collection inside an enhanced for
fails, because the loop's hidden iterator detects the structural change:
List<String> items = new ArrayList<>(List.of("a", "b", "c"));
// for (String s : items) { if (s.equals("b")) items.remove(s); }
// -> ConcurrentModificationException
Iterator<String> it = items.iterator();
while (it.hasNext()) {
if (it.next().equals("b")) it.remove(); // the safe way
}
items.removeIf(s -> s.equals("b")); // the modern way
Key Takeaways
Iterablesupplies anIterator; the enhancedforloop works with anyIterable.Iteratorholds the traversal position, so multiple cursors can coexist.next()should throwNoSuchElementExceptionpast the end.- Removing during an enhanced
forthrowsConcurrentModificationException; useIterator.remove()orremoveIf.Iterable.forEachis a default method added in Java 8.
🧪 Practice
- Make a
Rangeclass iterable over the integers fromstarttoend.- Write an iterator that returns only the even elements of an array.
- Interview: Why are
IterableandIteratorseparate interfaces rather than one? (Hint: what breaks if you nest two loops over the same object?)
AutoCloseable and Resource Contracts
Files, sockets, and database connections hold operating-system resources that
must be released. Releasing them in a finally block works but is verbose and
easy to get wrong, especially with several resources or an exception thrown
during cleanup. AutoCloseable lets the language do it.
public interface AutoCloseable {
void close() throws Exception;
}
public interface Closeable extends AutoCloseable { // java.io
@Override void close() throws IOException; // narrower exception
}
Any class implementing AutoCloseable can be declared in a try-with-resources
header, and close() runs automatically when the block exits, whether
normally or by exception.
public class Connection implements AutoCloseable {
private final String name;
public Connection(String name) {
this.name = name;
System.out.println("Opened " + name);
}
public void send(String data) { System.out.println(name + " <- " + data); }
@Override
public void close() { // narrowed: throws nothing at all
System.out.println("Closed " + name);
}
}
try (Connection primary = new Connection("primary"); // both are closed
Connection backup = new Connection("backup")) { // in REVERSE order
primary.send("ping");
throw new RuntimeException("boom"); // close still runs
} catch (RuntimeException e) {
System.out.println("Handled: " + e.getMessage());
}
Output:
Opened primary
Opened backup
primary <- ping
Closed backup <- reverse of declaration order
Closed primary
Handled: boom
The contract carries three obligations worth knowing:
close()should be idempotent: calling it twice must be harmless.close()should not throw if it can avoid it, because an exception there can mask the real failure. Java records the masked one as a suppressed exception, reachable viagetSuppressed().- Implementations are strongly encouraged to declare a narrower exception type
than
Exception, or none at all, so callers are not forced into broadcatchclauses.
Resources declared in the header must be final or effectively final. Since Java 9 an existing effectively final variable can be named directly:
Connection existing = new Connection("existing");
try (existing) { // Java 9+: no redundant new variable
existing.send("data");
}
Chapter 5 covers try-with-resources, suppressed exceptions, and cleanup ordering in full.
Key Takeaways
AutoCloseablemakes a class usable in try-with-resources, which closes it automatically.- Resources close in reverse declaration order, even when the block throws.
CloseableextendsAutoCloseableand narrows the exception toIOException.- Implement
close()to be idempotent and, ideally, non-throwing.- Exceptions thrown from
close()are suppressed, not lost, and readable viagetSuppressed().
🧪 Practice
- Write a
Timerclass implementingAutoCloseablethat prints the elapsed time inclose(), and use it in try-with-resources.- Open two of your resources in one header, throw inside the block, and observe the closing order.
- Interview: Why should
close()avoid throwing? (Hint: two exceptions are in flight at once; which one does the caller need to see?)
Serializable and Object Persistence
An object lives in heap memory as a graph of references. To save it to disk or send it over a network, that graph must be flattened into a byte stream (serialisation) and rebuilt later (deserialisation). Java's built-in mechanism is driven by a marker interface.
import java.io.Serializable;
public class Session implements Serializable { // no methods to implement
private static final long serialVersionUID = 1L; // explicit version stamp
private String user;
private int requestCount;
private transient String authToken; // deliberately NOT saved
private static String appName = "demo"; // static: never saved
}
Session session = new Session();
try (ObjectOutputStream out =
new ObjectOutputStream(new FileOutputStream("session.bin"))) {
out.writeObject(session); // whole reachable graph
}
try (ObjectInputStream in =
new ObjectInputStream(new FileInputStream("session.bin"))) {
Session restored = (Session) in.readObject(); // cast: stream is untyped
}
Four rules explain most serialisation behaviour:
| Element | Serialised? |
|---|---|
| Instance field | Yes, if its type is also serialisable |
transient field | No; restored as null, 0, or false |
static field | No; it belongs to the class, not the instance |
| Referenced object | Yes, recursively, the whole reachable graph |
A field whose type is not serialisable throws NotSerializableException at
write time, which is why transient exists for caches, threads, streams, and
secrets.
serialVersionUID is the compatibility stamp. If you omit it, the compiler
derives one from the class structure, and any later edit, even adding a method,
changes it and makes old files unreadable with
InvalidClassException. Declaring it explicitly puts you in control.
Deserialisation bypasses constructors. The JVM allocates the object and fills fields directly from the stream, so constructor validation never runs:
public class Age implements Serializable {
private final int value;
public Age(int value) {
if (value < 0) throw new IllegalArgumentException(); // NOT run on read
this.value = value;
}
private void readObject(ObjectInputStream in) // the hook
throws IOException, ClassNotFoundException {
in.defaultReadObject();
if (value < 0) throw new InvalidObjectException("negative age");
}
}
This bypass is also a security concern: deserialising untrusted bytes can
instantiate arbitrary classes on the classpath and has produced serious
vulnerabilities. Modern guidance is to avoid Java serialisation for anything
crossing a trust boundary and to use JSON, Protocol Buffers, or another
explicit format instead. Chapter 10 revisits the mechanism, including
writeObject, Externalizable, and filtering.
Key Takeaways
Serializableis a marker; the JVM supplies the read and write machinery.transientandstaticfields are not written to the stream.- The entire reachable object graph is serialised, and every referenced type must be serialisable.
- Declare
serialVersionUIDexplicitly or class changes will break existing data.- Deserialisation skips constructors, so invariants need
readObjectvalidation; never deserialise untrusted input.
🧪 Practice
- Serialise an object with one
transientfield to a file, read it back, and print the field's value.- Serialise a class, add a field, recompile without a
serialVersionUID, and try to read the old file.- Interview: Why is deserialising untrusted data dangerous? (Hint: what code runs before you ever see the resulting object?)
<a id="45-enums-and-records"></a>
4.5 Enums and Records
Enums and records are specialised class forms that the compiler writes most of for you: one fixes a type to a known set of instances, the other reduces a data holder to a single line.
Enum Declaration and Constants
Before enums, a fixed set of options was encoded as integer or string constants. Nothing stopped a caller passing a value outside the set, typos went unnoticed until runtime, and error messages printed unhelpful numbers.
// The old way: fragile.
public static final int STATUS_NEW = 0;
public static final int STATUS_PAID = 1;
process(7); // compiles happily; means nothing
An enum makes the set a type. Only the declared constants exist, the compiler
rejects anything else, and each constant is a real object with a name.
public enum Status {
NEW, PAID, SHIPPED, CANCELLED // the complete set of instances
}
Status status = Status.PAID;
// Status bad = 7; // ERROR: incompatible types
System.out.println(status); // PAID: toString gives the name
Every enum implicitly extends java.lang.Enum, which supplies these members:
| Member | Returns |
|---|---|
name() | The constant's declared identifier as a String |
ordinal() | Its zero-based position in the declaration |
values() | An array of all constants, in declaration order |
valueOf(String) | The constant with that exact name, or throws |
compareTo(E) | Comparison by ordinal (enums are Comparable) |
equals / hashCode | Identity-based, and final |
for (Status s : Status.values()) {
System.out.println(s.ordinal() + " -> " + s.name());
}
// 0 -> NEW, 1 -> PAID, 2 -> SHIPPED, 3 -> CANCELLED
Status parsed = Status.valueOf("PAID"); // exact match, case-sensitive
// Status.valueOf("paid"); // IllegalArgumentException
Because the constants are singletons created by the JVM, comparison with ==
is both safe and preferred; it is null-safe where equals is not:
if (status == Status.PAID) { ... } // idiomatic and null-safe
Enums work especially well in switch, where the constant names need no
qualification and the compiler can warn about unhandled cases:
String message = switch (status) { // arrow switch, Java 14+
case NEW -> "Awaiting payment"; // no Status. prefix needed
case PAID -> "Ready to ship";
case SHIPPED -> "On its way";
case CANCELLED -> "Cancelled";
}; // exhaustive: no default needed
One caution: ordinal() is a position, not an identity. Reordering the
constants silently changes every ordinal, so never persist it or build logic on
it. Store name() instead, or an explicit code field as shown in the next
topic.
Key Takeaways
- An
enumdefines a fixed, compiler-checked set of named instances.- Every enum extends
java.lang.Enumand gainsname,ordinal,values, andvalueOf.- Constants are singletons, so compare them with
==.- A switch over an enum can be exhaustive without a
defaultbranch.- Never persist or depend on
ordinal(); it changes when constants are reordered.
🧪 Practice
- Define a
Directionenum with four constants and print each one's name and ordinal.- Write a switch expression over
Directionthat returns the opposite direction.- Interview: Why is
==preferred overequalsfor enum constants? (Hint: how many instances of each constant can exist, and what happens if the reference is null?)
Enums with Fields, Constructors, and Methods
An enum constant is a full object, so it can carry data. This turns an enum from a list of names into a compact, type-safe lookup table: the constant, its associated values, and the logic over them all live in one place.
public enum Planet {
// Each constant calls the constructor with its own arguments.
MERCURY(3.303e+23, 2.4397e6),
EARTH (5.976e+24, 6.37814e6),
MARS (6.421e+23, 3.3972e6); // semicolon required before members
private static final double G = 6.67300E-11;
private final double mass; // fields should be final
private final double radius;
Planet(double mass, double radius) { // implicitly private; cannot be public
this.mass = mass;
this.radius = radius;
}
public double surfaceGravity() { // ordinary instance method
return G * mass / (radius * radius);
}
public double weightOf(double earthKilos) {
return earthKilos * surfaceGravity();
}
}
for (Planet p : Planet.values()) {
System.out.printf("%s: %.2f m/s^2%n", p, p.surfaceGravity());
}
System.out.println(Planet.MARS.weightOf(75));
Four rules govern this form:
- The constant list comes first, and a semicolon separates it from the rest of the body.
- The constructor is implicitly
private; declaring itpublicorprotectedis a compile error. You cannot writenew Planet(...). - All constants are constructed eagerly, when the enum class is first loaded.
- A constructor cannot access static fields of the enum that are not compile- time constants, because those are initialised after the constants.
A very common pattern is an external code plus a reverse lookup map:
public enum HttpStatus {
OK(200), NOT_FOUND(404), SERVER_ERROR(500);
private final int code;
HttpStatus(int code) { this.code = code; }
public int code() { return code; }
// Built once, in a static initialiser that runs AFTER the constants.
private static final Map<Integer, HttpStatus> BY_CODE = new HashMap<>();
static {
for (HttpStatus s : values()) BY_CODE.put(s.code, s);
}
public static HttpStatus fromCode(int code) {
HttpStatus found = BY_CODE.get(code);
if (found == null) throw new IllegalArgumentException("code " + code);
return found;
}
}
System.out.println(HttpStatus.fromCode(404)); // NOT_FOUND
System.out.println(HttpStatus.NOT_FOUND.code()); // 404
Storing an explicit code rather than relying on ordinal() is what makes
this safe against reordering and safe to persist.
Key Takeaways
- Enum constants may carry fields supplied through constructor arguments.
- The constant list ends with a semicolon before any fields or methods.
- Enum constructors are implicitly private and run once per constant at class load.
- Static members initialise after the constants, so reverse-lookup maps go in a
staticblock.- Prefer an explicit code field over
ordinal()for anything stored or transmitted.
🧪 Practice
- Write a
Currencyenum with a symbol and aformat(double)method.- Add a static
fromSymbol(String)lookup backed by a map built in a static initialiser.- Interview: Why can an enum constructor not be public? (Hint: how many instances of a constant may ever exist?)
Constant-Specific Method Bodies
Sometimes each constant needs genuinely different behaviour, not just different data. The naive approach switches on the constant inside a method, which works but rots: adding a constant compiles fine and silently falls through to the default, and the logic drifts away from the constant it belongs to.
// Fragile: add a MODULO constant and this method quietly misbehaves.
public enum BadOperation {
PLUS, MINUS, TIMES;
public double apply(double x, double y) {
switch (this) {
case PLUS: return x + y;
case MINUS: return x - y;
case TIMES: return x * y;
}
throw new AssertionError("unknown op " + this);
}
}
A constant-specific method body attaches the implementation to the constant
itself. The enum declares the method abstract, and each constant supplies its
own body in braces. Now a new constant that forgets the method will not
compile.
public enum Operation {
PLUS("+") { @Override public double apply(double x, double y) { return x + y; } },
MINUS("-") { @Override public double apply(double x, double y) { return x - y; } },
TIMES("*") { @Override public double apply(double x, double y) { return x * y; } },
DIVIDE("/") {
@Override
public double apply(double x, double y) {
if (y == 0) throw new ArithmeticException("divide by zero");
return x / y; // per-constant validation
}
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
public abstract double apply(double x, double y); // every constant must
@Override public String toString() { return symbol; }
}
for (Operation op : Operation.values()) {
System.out.printf("3 %s 4 = %.1f%n", op, op.apply(3, 4));
}
// 3 + 4 = 7.0, 3 - 4 = -1.0, 3 * 4 = 12.0, 3 / 4 = 0.8
Mechanically, each constant with a body becomes an anonymous subclass of the
enum. That explains two facts: an enum with constant bodies cannot be final
under the hood, and getClass() on such a constant returns something like
Operation$1 rather than Operation.
Constants may also override a concrete method rather than implement an abstract one, which gives a default plus exceptions:
public enum Level {
INFO, WARN,
ERROR { @Override public boolean isFatal() { return true; } };
public boolean isFatal() { return false; } // default for INFO and WARN
}
Key Takeaways
- Constant-specific bodies attach behaviour to individual enum constants.
- Declaring the method
abstractforces every constant to implement it, so new constants cannot be forgotten.- Each constant with a body compiles into an anonymous subclass of the enum.
- Constants may override a concrete method to deviate from a shared default.
- Prefer this to switching on
thisinside the enum.
🧪 Practice
- Write a
TrafficLightenum where each constant returns the next light from a constant-specificnext()method.- Convert a switch-on-
thisenum method into constant-specific bodies.- Interview: What breaks if you add a constant to an enum whose method switches on
this, and how do constant-specific bodies prevent it? (Hint: which error appears at compile time and which at runtime?)
Enums Implementing Interfaces
An enum cannot extend a class, because it already extends java.lang.Enum. It
can implement any number of interfaces, which is what lets enum constants be
passed wherever the interface is expected.
public interface Calculable {
double apply(double x, double y);
default double applyTwice(double x) { return apply(apply(x, x), x); }
}
public enum MathOp implements Calculable {
ADD { @Override public double apply(double x, double y) { return x + y; } },
MUL { @Override public double apply(double x, double y) { return x * y; } };
}
Calculable op = MathOp.ADD; // an enum constant used as the interface
System.out.println(op.apply(2, 3)); // 5.0
System.out.println(op.applyTwice(2)); // inherits the interface default
List<Calculable> pipeline = List.of(MathOp.ADD, MathOp.MUL, (x, y) -> x - y);
That last line is the point: a lambda and an enum constant are interchangeable behind the interface. The enum provides a fixed, named, exhaustively enumerable set of implementations; a lambda provides an ad hoc one.
If the interface is functional, an enum constant can be passed to any API expecting that function type:
public enum Rounding implements java.util.function.DoubleUnaryOperator {
FLOOR { @Override public double applyAsDouble(double v) { return Math.floor(v); } },
CEILING { @Override public double applyAsDouble(double v) { return Math.ceil(v); } },
NEAREST { @Override public double applyAsDouble(double v) { return Math.round(v); } };
}
double result = Rounding.NEAREST.applyAsDouble(2.6); // 3.0
This is also the standard way to write a singleton in Java. An enum with one constant gives serialisation safety, reflection safety, and thread-safe lazy class-loading initialisation for free:
public enum ConfigRegistry implements Registry {
INSTANCE; // the single instance
private final Map<String, String> values = new ConcurrentHashMap<>();
@Override public String get(String key) { return values.get(key); }
@Override public void put(String key, String v) { values.put(key, v); }
}
ConfigRegistry.INSTANCE.put("env", "prod");
Key Takeaways
- Enums may implement interfaces but can never extend a class.
- An interface plus an enum gives a closed, named set of implementations.
- Enum constants are interchangeable with lambdas behind a functional interface.
- Interface default methods are inherited by every constant.
- A single-constant enum is the safest Java singleton idiom.
🧪 Practice
- Define a
Validatorinterface and an enum of three named validation rules implementing it.- Pass an enum constant to a method that takes
Predicate<String>.- Interview: Why is a single-constant enum considered the best singleton implementation? (Hint: think about what reflection and deserialisation can do to a private constructor.)
EnumSet and EnumMap
HashSet and HashMap must work for any key type, so they hash, allocate
buckets, and box values. When the keys are enum constants the problem is far
simpler: the set of keys is known, finite, and numbered. EnumSet and
EnumMap exploit that, giving dramatically better performance and predictable
iteration order.
EnumSet represents membership as a bit vector. For an enum of 64 or fewer
constants the entire set is one long, so operations are single machine
instructions.
public enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }
EnumSet<Day> weekend = EnumSet.of(Day.SAT, Day.SUN);
EnumSet<Day> weekdays = EnumSet.complementOf(weekend); // everything else
EnumSet<Day> midweek = EnumSet.range(Day.TUE, Day.THU); // declaration order
EnumSet<Day> none = EnumSet.noneOf(Day.class); // empty, typed
EnumSet<Day> all = EnumSet.allOf(Day.class);
System.out.println(weekdays); // [MON, TUE, WED, THU, FRI]
System.out.println(weekend.contains(Day.SAT)); // true, a bit test
Note that there is no public constructor; EnumSet is created only through its
static factories, which pick a 64-bit or larger implementation for you.
EnumMap stores values in a plain array indexed by ordinal. No hashing, no
collisions, no Map.Entry objects for lookup.
EnumMap<Day, String> schedule = new EnumMap<>(Day.class); // class is required
schedule.put(Day.WED, "Sprint review");
schedule.put(Day.MON, "Standup");
System.out.println(schedule); // {MON=Standup, WED=Sprint review}
// iteration follows declaration order
The Day.class argument is not optional: the map needs the constant list to
size its internal array, and generics are erased at runtime.
| Aspect | EnumSet / EnumMap | HashSet / HashMap |
|---|---|---|
| Internal storage | Bit vector / ordinal array | Hash buckets |
| Lookup cost | Constant, no hashing | Constant, with hashing |
| Memory | Very compact | Larger, per-entry objects |
| Iteration order | Enum declaration order | Unspecified |
| Null keys | Not permitted | One null key allowed |
| Keys accepted | One enum type only | Any type |
Use them as a flag set, the type-safe replacement for bitmask integers:
public enum Permission { READ, WRITE, EXECUTE, DELETE }
EnumSet<Permission> granted = EnumSet.of(Permission.READ, Permission.WRITE);
granted.add(Permission.EXECUTE);
granted.removeIf(p -> p == Permission.WRITE);
if (granted.contains(Permission.EXECUTE)) { ... }
Key Takeaways
EnumSetis a bit vector andEnumMapan ordinal-indexed array, both far leaner than their hash-based counterparts.- Both iterate in enum declaration order.
EnumSetis created only through static factories such asof,range,allOf,noneOf, andcomplementOf.EnumMaprequires the enum'sClassobject in its constructor.- Neither accepts null keys; prefer them to
HashSetandHashMapwhenever the key is an enum.
🧪 Practice
- Build an
EnumSet<Permission>for a user and test membership.- Use
EnumMap<Day, List<String>>to store tasks per day and print it.- Interview: Why can
EnumSetoutperformHashSetso decisively? (Hint: how many bits does it take to record membership of a 7-constant enum?)
Records as Transparent Data Carriers
Many classes exist only to carry a few values together. Written by hand, such a
class is a page of boilerplate: fields, a constructor, accessors, equals,
hashCode, and toString, all mechanically derived from the same list of
components and all of it a place for bugs to hide.
// Roughly 40 lines by hand, all of it derivable.
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
@Override public boolean equals(Object o) { /* ... */ }
@Override public int hashCode() { /* ... */ }
@Override public String toString() { /* ... */ }
}
A record (Java 16 onwards) declares the same thing in one line. The header lists the components, and the compiler generates the rest.
public record Point(int x, int y) { }
Point p = new Point(3, 4);
System.out.println(p.x()); // 3: accessor is x(), not getX()
System.out.println(p); // Point[x=3, y=4]
System.out.println(p.equals(new Point(3, 4))); // true: value-based equals
What the compiler generates:
| Generated member | Behaviour |
|---|---|
private final field | One per component |
| Canonical constructor | Takes all components in declaration order |
| Accessor per component | Named exactly like the component, e.g. x() |
equals | Compares every component |
hashCode | Derived from every component |
toString | Name[c1=v1, c2=v2] |
Records are deliberately restricted, and the restrictions are the point:
- The class is implicitly
final, so it cannot be extended. - It cannot extend another class; it already extends
java.lang.Record. - It cannot declare additional instance fields, only static ones. All state is in the header.
- Components are final; a record is shallowly immutable.
Within those limits a record is an ordinary class. It may implement interfaces, declare static members, add methods, and override the generated ones:
public record Money(String currency, long cents) implements Comparable<Money> {
public static final Money ZERO = new Money("EUR", 0); // static is fine
public Money plus(Money other) { // extra method
if (!currency.equals(other.currency))
throw new IllegalArgumentException("currency mismatch");
return new Money(currency, cents + other.cents); // returns a new one
}
@Override
public int compareTo(Money other) { return Long.compare(cents, other.cents); }
@Override
public String toString() { // may be overridden
return String.format("%.2f %s", cents / 100.0, currency);
}
}
One caveat: immutability is shallow. A record holding a List still hands
out the same mutable list object, so defensive copying is on you.
public record Team(String name, List<String> members) {
public Team { // compact constructor, below
members = List.copyOf(members); // defensive, unmodifiable
}
}
Records also work with pattern matching, which makes them natural partners for sealed hierarchies from chapter 3:
Object shape = new Point(1, 2);
if (shape instanceof Point(int x, int y)) { // record deconstruction
System.out.println(x + y); // 3
}
Key Takeaways
- A record declares its state in its header and generates the constructor, accessors,
equals,hashCode, andtoString.- Accessors are named after components,
x()rather thangetX().- Records are implicitly final, cannot extend a class, and cannot add instance fields.
- They may implement interfaces, add methods, and override generated members.
- Immutability is shallow; copy mutable components defensively.
🧪 Practice
- Convert a hand-written immutable
Bookclass into a record and confirmequalsandtoStringbehave.- Add a derived method and a static factory to that record.
- Interview: When is a record the wrong choice? (Hint: what does a record forbid that an ordinary class allows, regarding state and inheritance?)
Compact Constructors and Record Validation
The generated canonical constructor simply assigns each component, which leaves nowhere to validate or normalise. Rewriting the whole constructor works but reintroduces the boilerplate a record was meant to remove. The compact constructor solves this: it has no parameter list and no assignments, just the checks.
public record Fraction(int numerator, int denominator) {
public Fraction { // compact: no parentheses content
if (denominator == 0) {
throw new IllegalArgumentException("denominator must not be zero");
}
int gcd = gcd(Math.abs(numerator), Math.abs(denominator));
numerator /= gcd; // reassigning the PARAMETER
denominator /= gcd; // normalises the stored value
} // fields assigned implicitly here
private static int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }
}
Fraction half = new Fraction(2, 4);
System.out.println(half); // Fraction[numerator=1, denominator=2]
// new Fraction(1, 0); // IllegalArgumentException
The mechanism is worth stating precisely, because it is the part people get
wrong: inside a compact constructor you assign to the parameters, not the
fields. The compiler appends this.numerator = numerator; for every component
at the end of the block. Writing this.numerator = ... yourself is a compile
error in a compact constructor.
Null and defensive handling follow the same shape:
public record Order(String id, List<String> items, Instant placedAt) {
public Order {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(placedAt, "placedAt");
items = items == null ? List.of() : List.copyOf(items); // normalise
}
}
You may instead write the canonical constructor in full when you need something the compact form cannot express, but then every assignment is yours:
public record Range(int low, int high) {
public Range(int low, int high) { // explicit canonical form
if (low > high) throw new IllegalArgumentException("low > high");
this.low = low; // assignments are REQUIRED here
this.high = high;
}
}
Additional constructors must delegate to the canonical one, exactly as in an ordinary class:
public record Money(String currency, long cents) {
public Money {
if (cents < 0) throw new IllegalArgumentException("negative amount");
}
public Money(long cents) { // alternative constructor
this("EUR", cents); // MUST delegate with this(...)
}
}
| Form | Parameter list | Assignments | Use when |
|---|---|---|---|
| Compact constructor | Omitted | Implicit, added by compiler | Validating or normalising |
| Canonical constructor | Full, matching components | Written by hand | Full control needed |
| Alternative constructor | Any | Must call this(...) | Convenient defaults |
A closing note on deserialisation: unlike ordinary serialisable classes, records are deserialised through the canonical constructor, so the validation above does run. That makes records a safer carrier for data crossing a boundary than a conventional serialisable class.
Key Takeaways
- A compact constructor has no parameter list and no field assignments, only validation and normalisation.
- Inside it you reassign the parameters; the compiler assigns the fields afterwards.
- Write the full canonical constructor only when the compact form is insufficient, and then assign every field yourself.
- Alternative constructors must delegate to the canonical one with
this(...).- Record deserialisation runs the canonical constructor, so validation is not bypassed.
🧪 Practice
- Add a compact constructor to a
Temperaturerecord rejecting values below absolute zero.- Write a record holding a
Listand make it defensively copy in the compact constructor; prove the original list can no longer affect it.- Interview: Why is a record safer to deserialise than an ordinary
Serializableclass? (Hint: which code path is used to rebuild the object?)
<a id="46-nested-and-anonymous-types"></a>
4.6 Nested and Anonymous Types
A class does not have to live in its own file; Java offers four ways to declare one inside another, each with a different relationship to the enclosing code.
Inner Classes and Enclosing Instances
Some helper types make no sense on their own. A Node belongs to its
LinkedList; an Entry belongs to its Map. Putting them in separate
top-level files scatters one concept across the package and forces the helper's
fields to be visible more widely than they should be. Nesting keeps them
together.
Java has two kinds of nested class, and the distinction is entirely about
static:
public class Outer {
private int value = 42;
static class StaticNested { // NO link to an Outer instance
void show() {
// System.out.println(value); // ERROR: needs an instance
System.out.println("independent");
}
}
class Inner { // holds a hidden Outer reference
void show() {
System.out.println(value); // reaches Outer's private field
System.out.println(Outer.this.value); // the explicit form
}
}
}
The consequence shows up at creation time. A static nested class is created like any other; an inner class needs an existing outer instance:
Outer.StaticNested nested = new Outer.StaticNested(); // no Outer needed
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner(); // odd but correct
That outer.new Inner() syntax exists because each Inner object stores a
hidden reference to its Outer, which is what lets it read value. The hidden
reference is also the main hazard: an inner class instance keeps its entire
outer object alive in memory, so a long-lived inner instance, say a cached
listener, can pin a large object graph the garbage collector would otherwise
reclaim.
public class LinkedStack<T> {
private Node<T> top; // uses the nested type
private static class Node<T> { // static: no outer reference
final T value;
Node<T> next;
Node(T value, Node<T> next) { this.value = value; this.next = next; }
}
public void push(T value) { top = new Node<>(value, top); }
}
| Aspect | Static nested | Inner (non-static) |
|---|---|---|
| Reference to enclosing object | No | Yes, implicit |
| Access outer instance fields | No | Yes, including private |
| Instantiated with | new Outer.Nested() | outer.new Inner() |
| Can declare static members | Yes | Yes, since Java 16 |
| Memory implication | Independent | Pins the outer object |
| Default choice | Yes | Only if it needs outer state |
The practical rule: make a nested class static unless it genuinely needs
the enclosing instance. Most do not, and the static form is cheaper and
safer.
Key Takeaways
- Nested classes keep a helper type next to the only code that uses it.
- A static nested class is independent; an inner class carries a hidden reference to its enclosing instance.
- Inner classes are created with
outer.new Inner()and can read the outer object's private members.- The hidden reference can prevent garbage collection of the outer object.
- Default to
staticand drop it only when outer state is required.
🧪 Practice
- Write an
Outerclass with both a static nested and an inner class, and instantiate each correctly.- Access an outer private field from the inner class using
Outer.this.- Interview: Why should a nested class usually be static? (Hint: think about what the non-static version silently holds on to.)
Local Classes in Method Scope
Occasionally a helper type is needed by exactly one method. A nested class at class level would advertise it to the whole class; a local class, declared inside the method body, limits it to the block where it is used, just like a local variable.
public class Validation {
public List<String> validate(List<String> inputs, int maxLength) {
class Problem { // visible only inside validate
final String input;
final String reason;
Problem(String input, String reason) {
this.input = input;
this.reason = reason;
}
String describe() {
// captures maxLength from the enclosing method
return input + ": " + reason + " (limit " + maxLength + ")";
}
}
List<Problem> problems = new ArrayList<>();
for (String input : inputs) {
if (input.isBlank()) problems.add(new Problem(input, "blank"));
else if (input.length() > maxLength) problems.add(new Problem(input, "too long"));
}
return problems.stream().map(Problem::describe).toList();
}
}
// new Problem("a", "b"); // ERROR outside validate: Problem is out of scope
Local classes obey a short set of rules:
- Scope is the enclosing block; the type name is unusable outside it.
- They may not be
public,protected,private, orstatic, because access modifiers are meaningless for something only one block can see. - They can use the enclosing method's local variables, but only effectively final ones (the next topic).
- Declared inside an instance method, they can also reach the enclosing object's fields; inside a static method, they cannot.
public class Counter {
private int total; // instance field
public Runnable makeIncrementer(int step) {
class Incrementer implements Runnable {
@Override public void run() {
total += step; // outer field + captured local
}
}
return new Incrementer();
}
}
In modern code a local class is comparatively rare: a lambda covers the single-method case and a record covers the simple data-holder case. Reach for a local class when the helper needs several methods, its own state, or a name that makes the code clearer.
// A local record, allowed since Java 16, often replaces a local class.
public List<String> validate(List<String> inputs, int maxLength) {
record Problem(String input, String reason) { } // one line
...
}
Key Takeaways
- A local class is declared inside a method and visible only within that block.
- It cannot carry access modifiers or be
static.- It captures effectively final local variables and, in instance methods, the enclosing object.
- Lambdas and local records usually replace them in modern code.
- Use one when the helper needs multiple methods, its own state, or a descriptive name.
🧪 Practice
- Write a method that defines a local class implementing
Comparatorand uses it to sort a list.- Try to declare that local class
public staticand explain both errors.- Rewrite the same helper as a local record and compare the line counts.
Anonymous Inner Classes
When you need a one-off implementation of an interface or a one-off subclass, naming it adds noise. An anonymous inner class declares the class and creates its only instance in a single expression.
Comparator<String> byLength = new Comparator<String>() { // no class name
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
}; // semicolon: it is
// still an expression
Read the syntax as two things at once: new Type(args) names the supertype,
and the following braces are the body of an unnamed class that implements or
extends it. It works with an interface, where the argument list is always
empty, and with a class, where the arguments go to the superclass constructor.
// From an interface: no constructor arguments possible.
Runnable task = new Runnable() {
@Override public void run() { System.out.println("running"); }
};
// From a class: arguments are passed to the superclass constructor.
Thread worker = new Thread("worker-1") { // Thread(String name)
@Override public void run() { System.out.println(getName()); }
};
The most common use is passing behaviour to an API, which is exactly the role lambdas took over in Java 8:
button.addActionListener(new ActionListener() { // pre-Java 8
@Override public void actionPerformed(ActionEvent e) { save(); }
});
button.addActionListener(e -> save()); // Java 8+
Lambdas do not make anonymous classes obsolete. A lambda only works for a functional interface, has no state, and cannot be a subclass. An anonymous class is still required when you need any of these:
| Situation | Lambda | Anonymous class |
|---|---|---|
| Interface with one abstract method | Yes | Yes |
| Interface with several abstract methods | No | Yes |
| Subclassing a concrete or abstract class | No | Yes |
| Declaring fields to hold state | No | Yes |
Meaning of this | Enclosing object | The anonymous instance |
That last row causes real bugs, so it is worth a demonstration:
public class Widget {
private String name = "widget";
public void demo() {
Runnable anonymous = new Runnable() {
private int runs = 0; // anonymous classes may have state
@Override public void run() {
runs++;
// this.name; // ERROR: 'this' is the Runnable
System.out.println(Widget.this.name + " " + runs);
}
};
Runnable lambda = () -> System.out.println(this.name); // 'this' is Widget
}
}
Anonymous classes are also the mechanism behind two idioms you will encounter: the iterator returned in 4.4, and the anonymous subclasses the compiler creates for constant-specific enum bodies in 4.5.
Key Takeaways
- An anonymous inner class declares a type and instantiates it in one expression.
- It may implement an interface or extend a class, passing arguments to the superclass constructor.
- Unlike a lambda it can hold state, override several methods, and subclass a class.
- Inside an anonymous class
thisrefers to the anonymous instance; inside a lambda it refers to the enclosing object.- Prefer a lambda for functional interfaces and an anonymous class for everything else.
🧪 Practice
- Sort a list with an anonymous
Comparator, then rewrite it as a lambda.- Create an anonymous subclass of an abstract class that overrides two methods, and explain why a lambda cannot do this.
- Interview: What does
thisrefer to inside an anonymous class versus inside a lambda? (Hint: one of them introduces a new scope forthisand the other does not.)
Effectively Final Variable Capture
A local class, anonymous class, or lambda can use local variables from the method around it. This is called capture, and it comes with a restriction that catches everyone once: the captured variable must be final or effectively final, meaning it is never reassigned after initialisation.
public Runnable make() {
int count = 0; // effectively final: never reassigned
Runnable r = () -> System.out.println(count); // capture is allowed
// count = 1; // adding this line breaks the lambda above:
// "local variables referenced from a lambda
// expression must be final or effectively final"
return r;
}
The reason is lifetime. A local variable lives on the method's stack frame, which disappears when the method returns. The lambda may outlive it, so Java does not share the variable; it copies the value into the object at creation time. If the original could still change, the copy and the original would diverge, and nobody could say which value the lambda should see. Java avoids the question by forbidding reassignment.
make() stack frame heap
+----------------+ +---------------------+
| count = 0 | -- copy->| Runnable object |
+----------------+ | captured count: 0 |
frame destroyed on return +---------------------+
object survives with its copy
Capturing fields is different and unrestricted, because a field lives in the object on the heap and what is captured is the reference to that object:
public class Counter {
private int total = 0; // a FIELD, not a local
public Runnable incrementer() {
return () -> total++; // legal: fields may be reassigned
}
}
The same loophole explains the standard workaround for needing mutable captured state: put the value inside an object, and mutate the object rather than the variable.
// Does not compile:
// int sum = 0;
// list.forEach(n -> sum += n);
int[] sum = {0}; // the array reference is final
list.forEach(n -> sum[0] += n); // mutating the contents is fine
AtomicInteger safeSum = new AtomicInteger(); // the thread-safe version
list.forEach(safeSum::addAndGet);
int total = list.stream().mapToInt(Integer::intValue).sum(); // usually better
One more trap: the loop variable of a classic for loop is reassigned each
iteration and therefore not effectively final, while the variable of an
enhanced for loop is a fresh variable per iteration and is capturable.
List<Runnable> tasks = new ArrayList<>();
// for (int i = 0; i < 3; i++) tasks.add(() -> System.out.println(i)); // ERROR
for (int i = 0; i < 3; i++) {
int copy = i; // fresh effectively final local
tasks.add(() -> System.out.println(copy)); // fine
}
for (String s : List.of("a", "b")) {
tasks.add(() -> System.out.println(s)); // fine: s is effectively final
}
Key Takeaways
- Captured local variables must be final or effectively final, meaning never reassigned.
- The restriction exists because the value is copied when the lambda or inner class is created, while the stack frame is temporary.
- Instance and static fields can be captured and mutated freely; they live on the heap.
- Mutable captured state is emulated with a one-element array or an
AtomicInteger, though a stream reduction is usually cleaner.- A classic
forloop variable is not effectively final; an enhancedforvariable is.
🧪 Practice
- Write a lambda that captures a local variable, then reassign the variable and read the error.
- Accumulate a sum inside
forEachusing anAtomicInteger, then rewrite it with a stream.- Interview: Why does Java require effectively final capture when other languages allow full closures over mutable locals? (Hint: where does a local variable live, and how long does it survive?)
Choosing Among Nested Type Forms
With five forms available, the choice is usually settled by two questions: how much of the enclosing context the helper needs, and how widely it should be visible.
| Form | Scope | Access to enclosing | Typical use |
|---|---|---|---|
| Static nested class | Enclosing class | Static members only | Helper types, builders, nodes |
| Inner class | Enclosing class | Full outer instance | Iterators, views over the outer |
| Local class | One block | Effectively final locals | Multi-method helper for one method |
| Anonymous class | One expression | Effectively final locals | One-off implementation with state |
| Lambda | One expression | Effectively final locals | One-off functional interface |
A decision sequence that resolves most cases:
Does it implement a functional interface with no extra state?
yes -> LAMBDA
no v
Is it needed only in one expression?
yes -> ANONYMOUS CLASS
no v
Is it needed only inside one method?
yes -> LOCAL CLASS (or a local record for pure data)
no v
Does it need the enclosing object's instance state?
yes -> INNER CLASS
no -> STATIC NESTED CLASS
Two further guidelines matter in practice. Any nested type that grows past
roughly fifty lines, or that is used by more than its enclosing class, belongs
in its own file: nesting is for helpers, not for hiding significant types.
And a nested type visible in the enclosing class's public API, such as a
returned Map.Entry, should be static and public rather than inner, so callers
are not forced to hold an enclosing instance.
public class Grid {
public record Cell(int row, int column) { } // public static by default
private static class Bucket { ... } // private helper
private class RowView implements Iterable<Cell> { // needs Grid's state
...
}
public Iterable<Cell> row(int index) {
return new RowView(index);
}
public void forEachCell(Consumer<Cell> action) {
cells.forEach(action); // a lambda at the call site
}
}
Key Takeaways
- Choose by context needed and scope wanted: lambda, anonymous, local, inner, static nested, in that order of increasing weight.
- A static nested class is the default; make it inner only when it needs the outer instance.
- Local and anonymous forms keep single-use helpers out of the class's namespace.
- Promote a nested type to its own file once it grows large or gains other users.
- Nested types appearing in a public API should be static so callers need no enclosing instance.
🧪 Practice
- Given a
Matrixclass needing aPositiondata holder and a row iterator, pick a form for each and justify it.- Refactor an anonymous
Comparatorinto a static nested class and say what you gained and lost.- Interview: You see a 200-line inner class in a code review. What two questions would you ask about it? (Hint: one is about size and one is about that hidden reference.)
<a id="5-exception-handling-and-robustness"></a>