02 - Interfaces and abstract classes in depth¶
What this session is¶
About ninety minutes. Chapter 01 told you to "program to interfaces." This session is the deep version: what interfaces really are, every feature they've grown (default methods, static methods, private methods, constants), what abstract classes add, exactly when to choose one over the other, and the design habits that make interfaces powerful instead of noise. By the end this is your reference for every "should this be an interface or a class?" decision you'll make.
The mental model: an interface is a promise¶
A class says what something is and how it works. An interface says what something can do - nothing about how. It's a promise: "any type that implements me guarantees these methods exist."
That's a promise with three clauses. A LightBulb, a Server, a Valve - completely unrelated things - can all make this promise. Code that depends on Switch works with all of them and never needs to know which it's holding:
cycle works on anything switchable, forever, including types that don't exist yet. That decoupling - the caller depends on the promise, not the implementation - is the entire point.
Implementing an interface¶
A class promises to fulfill an interface with implements, then provides every method:
class LightBulb implements Switch {
private boolean on = false;
public void turnOn() { on = true; }
public void turnOff() { on = false; }
public boolean isOn() { return on; }
}
Two rules that bite beginners:
- Interface methods are implicitly
public. When you implement them, you must writepublicexplicitly - leaving it off narrows the visibility, which the compiler rejects. - You must implement every method (unless your class is abstract - see below). Miss one and the class won't compile.
A class can implement many interfaces - this is the superpower inheritance lacks:
class SmartBulb implements Switch, Dimmable, NetworkConnected {
// must fulfill all three promises
}
A SmartBulb is switchable and dimmable and network-connected - three independent capabilities, combined freely. Try that with single inheritance and you can't.
Default methods: behavior on an interface¶
Since Java 8, an interface can provide a method body with the default keyword. Implementing classes inherit it for free but may override it.
interface Switch {
void turnOn();
void turnOff();
boolean isOn();
// A default method built from the abstract ones.
default void toggle() {
if (isOn()) turnOff();
else turnOn();
}
}
Every Switch now has toggle() without writing it. LightBulb, Server, all of them - they get toggle() for free, defined once.
Why default methods exist: they let an interface grow without breaking every existing implementation. Before Java 8, adding a method to an interface broke every class that implemented it (suddenly missing a method). default methods were added specifically so the JDK could add methods like List.sort() and Collection.stream() to interfaces that millions of classes already implemented. The new method has a default body, so old implementations keep compiling.
Use them for: convenience methods derived from the core (abstract) ones, like toggle() above, or Comparator's reversed() and thenComparing(). Don't use them to smuggle in lots of stateful behavior - interfaces have no fields, so default methods can only work through the other methods. That limit is a feature; it keeps interfaces honest.
Static methods on interfaces¶
Interfaces can also hold static methods - usually factories or helpers related to the type:
interface Switch {
void turnOn();
void turnOff();
boolean isOn();
// Static factory: makes a Switch that does nothing. Useful for tests/defaults.
static Switch noop() {
return new Switch() {
public void turnOn() {}
public void turnOff() {}
public boolean isOn() { return false; }
};
}
}
// Call it on the interface itself:
Switch s = Switch.noop();
You've used these without noticing: List.of(...), Map.of(...), Comparator.comparing(...), Path.of(...) are all static methods on interfaces. They're the modern idiom for "give me a ready-made instance of this type."
Private methods on interfaces¶
Since Java 9, interfaces can have private methods - used only to share code between default methods, hidden from implementers:
interface Logger {
void write(String line);
default void info(String msg) { write(format("INFO", msg)); }
default void warn(String msg) { write(format("WARN", msg)); }
default void error(String msg) { write(format("ERROR", msg)); }
// Private helper - not part of the public promise, just shared plumbing.
private String format(String level, String msg) {
return "[" + level + "] " + msg;
}
}
format isn't part of the contract - implementers never see it. It just removes duplication among the three default methods. Reach for private interface methods only when two or more default methods share logic.
Constants on interfaces (and why to be wary)¶
Fields in an interface are implicitly public static final - constants:
This works, but avoid the "constant interface" antipattern - making an interface whose only purpose is to hold constants, then implementing it to "get" them. It pollutes your type's public API with constants and abuses implements (which should mean "I fulfill this contract," not "I want these numbers"). Put constants in a final class with a private constructor, or an enum, instead. Constants directly relevant to an interface's methods are fine; a bag of unrelated constants is not.
Abstract classes: a partial implementation¶
An abstract class sits between an interface (pure promise) and a concrete class (full implementation). It can have everything a class has - fields, constructors, concrete methods - plus abstract methods with no body that subclasses must fill in. You can't instantiate it directly.
abstract class AbstractSwitch implements Switch {
private boolean on = false; // an interface can't have this field
// Concrete: shared state management, written once.
public boolean isOn() { return on; }
public void turnOn() { on = true; onChanged(); }
public void turnOff() { on = false; onChanged(); }
// Abstract: each subclass provides the device-specific reaction.
protected abstract void onChanged();
}
class Relay extends AbstractSwitch {
protected void onChanged() {
System.out.println("relay clicked, now " + (isOn() ? "closed" : "open"));
}
}
AbstractSwitch handles the on field and the bookkeeping; subclasses only supply onChanged(). This is the template method pattern: the base class defines the skeleton of an operation and defers specific steps to subclasses. The JDK uses it everywhere - AbstractList, AbstractMap, InputStream all provide most methods and leave a few abstract.
What an abstract class can do that an interface cannot:
- Hold instance fields (mutable state like
onabove). - Have constructors (to initialize that state).
- Have non-public members (
protected, package-private).
What an interface can do that an abstract class cannot:
- Be implemented by a class that already extends something else (a class extends one class but implements many interfaces).
The decision: interface or abstract class?¶
Here's the rule that resolves almost every case:
Default to an interface. Reach for an abstract class only when you need to share mutable state or constructor logic across implementations - and even then, consider composition first.
The longer reasoning:
| Question | Lean interface | Lean abstract class |
|---|---|---|
| Is it purely a capability/contract? | yes → interface | |
| Do implementers need to also extend something else? | yes → interface (they can't extend two classes) | |
| Is there shared mutable state (fields)? | yes → abstract class | |
| Is there constructor logic all subtypes need? | yes → abstract class | |
| Will there be many unrelated implementers? | yes → interface | |
| Is this a framework extension point with lots of plumbing? | often abstract class |
A powerful middle path the JDK uses: ship both. Define the interface (List), and provide an abstract skeleton (AbstractList) that implementers may extend for convenience but aren't forced to. Implementers who already extend something else implement List directly; everyone else extends AbstractList and saves work. You get the flexibility of an interface and the convenience of shared code.
A worked example: a plugin system¶
Let's design a small plugin system to see every tool in play.
// The contract every plugin promises.
interface Plugin {
String name();
void execute(Context ctx);
// Default: most plugins don't need setup, so give a no-op default.
default void init(Context ctx) {}
// Default: derived convenience.
default String describe() {
return name() + " plugin";
}
// Static factory for a trivial plugin from a lambda.
static Plugin of(String name, java.util.function.Consumer<Context> action) {
return new Plugin() {
public String name() { return name; }
public void execute(Context ctx) { action.accept(ctx); }
};
}
}
A simple plugin implements directly:
class GreetPlugin implements Plugin {
public String name() { return "greet"; }
public void execute(Context ctx) {
System.out.println("Hello from " + ctx.user());
}
}
A family of plugins that share setup uses an abstract base:
// Shared plumbing: every DB plugin needs a connection, opened once.
abstract class DatabasePlugin implements Plugin {
protected Connection conn; // shared mutable state - needs a class
public void init(Context ctx) {
this.conn = ctx.openConnection(); // constructor-like setup, written once
}
// Subclasses implement the actual query work; conn is ready for them.
public abstract void execute(Context ctx);
}
class BackupPlugin extends DatabasePlugin {
public String name() { return "backup"; }
public void execute(Context ctx) {
conn.run("BACKUP DATABASE"); // conn was opened by init()
}
}
And a throwaway plugin from the static factory:
Three styles, one contract. The host code only ever sees Plugin:
void runAll(List<Plugin> plugins, Context ctx) {
for (Plugin p : plugins) {
p.init(ctx);
System.out.println("running " + p.describe());
p.execute(ctx);
}
}
This is the shape of real plugin systems, servlet containers, build-tool task systems, and test frameworks. The interface is the contract; abstract classes provide optional shared scaffolding; static factories and lambdas make trivial cases cheap.
Try it¶
-
Build the Switch hierarchy. Write the
Switchinterface with thetoggle()default. ImplementLightBulband aFan(whereturnOnprints "spinning"). Calltoggle()on each twice. Confirm the default works for both without either class defining it. -
Add a capability. Add a
Dimmableinterface (void setBrightness(int pct),int brightness()). Make aSmartBulb implements Switch, Dimmable. Write a methoddimAll(List<Dimmable> ds)and a methodcycleAll(List<Switch> ss). Pass yourSmartBulbto both. One object, two contracts. -
Template method. Write the
AbstractSwitchwith theonChanged()hook. Make two subclasses that react differently. Notice you wrote theon-field logic exactly once. -
Spot the constant-interface antipattern. Find or write an interface that's only constants. Refactor it into a
finalclass with a private constructor (private Physics() {}) holdingpublic static finalfields. Access viaPhysics.SPEED_OF_LIGHT. Discuss why this is cleaner thanimplements Physics. -
Ship both. Take your
Plugininterface and write anAbstractPluginthat provides a defaultdescribe()using aprotected String categoryfield. Make one plugin extend it and one implementPlugindirectly. Both work throughrunAll.
What you might wonder¶
"If interfaces can have method bodies now, why have abstract classes at all?" State and constructors. Default methods can't touch instance fields (interfaces have none) and interfaces have no constructors. The moment your shared behavior needs to remember something between calls (the on field, the conn), you need an abstract class. Also: a class can only extend one abstract class, so abstract classes impose a hierarchy that interfaces don't.
"Can an interface extend another interface?" Yes - interface ColorSwitch extends Switch adds methods to the Switch promise. An interface can extend many interfaces: interface SmartDevice extends Switch, Dimmable, NetworkConnected. This is interface composition, and it's how you build up rich contracts from small ones.
"What's the diamond problem with default methods?" If a class implements two interfaces that both have a default method with the same signature, the compiler forces you to resolve the ambiguity by overriding it (you can call a specific one with InterfaceName.super.method()). Java makes the conflict a compile error rather than silently picking one - safer than C++'s implicit resolution.
"Should I make an interface for everything, just in case?" No. An interface with exactly one implementation that will never have another is usually premature - it adds indirection without flexibility. Introduce the interface when you have (or clearly foresee) a second implementation, a need to mock for tests, or a public API boundary. "Accept interfaces, return structs" (chapter 01) is the guide: interfaces earn their place at boundaries.
"sealed interfaces - what are those?" A sealed interface restricts which types may implement it (sealed interface Shape permits Circle, Square). It's the opposite of an open contract - used when you want an exhaustive, known set of implementations the compiler can check (great with pattern-matching switch). You met sealed types in From Scratch chapter 08; we'll use them again in chapter 07.
Done¶
- You know an interface is a promise - a contract decoupled from implementation.
- You know every interface feature: abstract methods,
default,static,private, constants (and the constant-interface antipattern to avoid). - You know abstract classes add state + constructors + the template-method pattern, at the cost of single inheritance.
- You can decide between them: default to interface, reach for abstract class when you need shared mutable state or constructor logic - and consider "ship both."
Next we make these contracts airtight: the equals, hashCode, and compareTo contracts that every Java object lives by.
Next: Equality, hashing, immutability →