Interfaces, Default Methods, and the Trait Analogy
Prerequisites: generics-type-erasure
What You'll Learn
- Distinguish a general interface from a functional interface (SAM interface) and explain why only SAM interfaces enable lambda syntax.
- Write a default method in an interface and explain when to use it versus an abstract class.
- Explain the diamond problem and how Java resolves it.
- Write a sealed interface with a
permitsclause and explain how it enables exhaustive switch expressions. - Contrast Java's dynamic dispatch with Rust's static dispatch (monomorphization), including the real performance implications.
Why This Matters
If you are coming from Rust, interfaces are the first thing you will reach for — they look like traits. And in many ways they are: both define contracts that types must fulfill, both support default method implementations, both enable polymorphic code. But the analogy breaks down in important ways that will cause real bugs if you assume they are identical.
The most critical difference is not about syntax — it is about when and where you can implement an interface. In Rust, you can implement a trait for a type anywhere, at any time, as long as either the trait or the type is in your crate. In Java, a class declares which interfaces it implements at the point of its own definition. You cannot add an interface implementation to someone else's class after the fact. This single constraint shapes how Java libraries are designed and how you must structure your own code.
This module also introduces two terms that the rest of this plan uses constantly. You need their definitions before moving forward.
Core Concept
Defining Our Terms First
Note: This module canonically defines two terms used throughout all subsequent modules.
interface — A Java contract type declared with the
interfacekeyword. It may have zero or more abstract methods, default methods (concrete implementations), and static methods. It cannot hold instance fields or have a constructor. A class declares that it implements an interface at the point of class definition.functional interface — An interface with exactly one abstract method (SAM: Single Abstract Method), annotated with
@FunctionalInterface. The presence of exactly one abstract method is what enables lambda syntax: the compiler can unambiguously map a lambda to that one method. Standard examples:Comparator<T>,Predicate<T>,Function<T, R>.
A SAM interface is a strict subset of interfaces. Every functional interface is an interface; not every interface is a functional interface.
Interfaces as Contracts
An interface declares what a class must be able to do, without saying how. Multiple interfaces can be implemented simultaneously — this is how Java achieves multiple inheritance of behavior without the ambiguity of multiple class inheritance.
// Java
interface Printable {
void print();
}
interface Saveable {
void save(String path);
}
class Invoice implements Printable, Saveable {
@Override
public void print() { /* ... */ }
@Override
public void save(String path) { /* ... */ }
}// Rust equivalent
trait Printable {
fn print(&self);
}
trait Saveable {
fn save(&self, path: &str);
}
struct Invoice;
impl Printable for Invoice {
fn print(&self) { /* ... */ }
}
impl Saveable for Invoice {
fn save(&self, path: &str) { /* ... */ }
}
The structural difference is immediately visible: in Rust, impl Printable for Invoice is a separate block that can live in any file in your crate. In Java, implements Printable, Saveable is part of the class declaration itself. You cannot split them out.
Default Methods
Since Java 8, interfaces can include concrete method implementations using the default keyword. This was added primarily to let library authors add new methods to existing interfaces without breaking every class that already implemented them.
// Java
interface Writer {
void write(String text);
// Default method — implementing classes inherit this unless they override it
default void writeLine(String text) {
write(text + "\n");
}
}// Rust equivalent
trait Writer {
fn write(&mut self, text: &str);
fn write_line(&mut self, text: &str) {
self.write(text);
self.write("\n");
}
}
These are nearly identical in purpose and syntax. The practical difference: Rust's default method can reference self with full type information, while Java's default method can only call the interface's own abstract or default methods — it cannot access any fields of the implementing class (because interfaces have no instance fields).
When should you use a default method instead of an abstract class? The rule is straightforward: use a default method when you need multiple inheritance of behavior and do not need shared state. Use an abstract class when you need instance fields or a constructor shared by all subclasses.
The Diamond Problem
When two interfaces define the same default method, and a class implements both, the compiler cannot choose for you:
// Java
interface Poet {
default void write() { System.out.println("Poetry"); }
}
interface Engineer {
default void write() { System.out.println("Code"); }
}
class EngineerPoet implements Poet, Engineer {
// Compiler error if you do not resolve this
@Override
public void write() {
Poet.super.write(); // Explicitly delegate to Poet's version
}
}
The resolution syntax is InterfaceName.super.method(). You must pick one, implement your own, or call both explicitly. Rust avoids this problem structurally: each trait is implemented in its own impl block, so <EngineerPoet as Poet>::write() and <EngineerPoet as Engineer>::write() are always unambiguous.
Dynamic Dispatch
Dispatch: Defined
This module uses two dispatch terms consistently. Here are their canonical definitions side by side:
Java Rust Dynamic dispatch Runtime method resolution via vtable lookup. The JVM inspects the object's actual runtime type and calls the right implementation. This is Java's default for interface method calls. Opt-in with dyn Trait.Static dispatch Java has no language-level static dispatch. The JIT compiler may devirtualize monomorphic call sites at runtime as an optimization, but this is not a compile-time guarantee. Default via monomorphization: the compiler generates a separate version of any generic function for each concrete type argument. No runtime lookup needed. Module 03 introduced both terms in the context of generics. This module is where dynamic dispatch becomes central to Java programming.
When you write validator.validate(payment) where validator is typed as an interface, the JVM looks up the actual implementation class of the object at runtime and calls the right method. This lookup happens every time the call site is reached, unless the JIT has devirtualized it.
In Java, calling a method through an interface reference is always dynamic dispatch. The JVM does optimize this aggressively — the just-in-time (JIT) compiler can inline monomorphic call sites (where only one implementation is ever used). But this is a runtime optimization, not a compile-time guarantee, and it breaks down at polymorphic call sites.
Rust comparison: In Rust,
fn process<V: Validator>(v: &V, p: &Payment)uses static dispatch — the compiler generatesprocess::<ConcreteValidator>with the call inlined. If you want dynamic dispatch in Rust, you opt in explicitly withdyn Validator. Java has no equivalent opt-in: interface calls are always dynamically dispatched, and you cannot ask the compiler to monomorphize for you.
Trait Comparison Table
| Feature | Java interface | Rust trait |
|---|---|---|
| Instance fields | No | No |
| Constructor | No | No |
| Default methods | Yes (default keyword) | Yes (method body in trait) |
| Implementation location | At class definition only | Separate impl Trait for Type anywhere in crate |
| Default dispatch | Dynamic (vtable) | Static (monomorphization) |
| Explicit dynamic dispatch | Always (no choice) | Opt-in with dyn Trait |
| Multiple bounds | implements A, B | T: A + B |
| Adding to external types | Not possible | Possible (orphan rule permitting) |
The most important row: "Implementation location." This is the constraint that shapes everything. If a third-party library gives you a Payment class, you cannot make it implement your Printable interface without subclassing or wrapping it. In Rust, you could just write impl Printable for Payment in your crate.
Functional Interfaces and Lambda Syntax
A functional interface has exactly one abstract method. That single-method constraint is what makes lambda syntax possible: when you write a lambda where a functional interface is expected, the compiler knows exactly which method to implement.
// Java
@FunctionalInterface
interface FraudDetector {
boolean isSuspicious(Payment payment);
}
// Lambda works because FraudDetector has exactly one abstract method
FraudDetector detector = p -> p.amount() > 10_000;
The @FunctionalInterface annotation is optional — any interface with exactly one abstract method is a functional interface whether you annotate it or not. The annotation adds two things: documentation clarity, and a compile-time check that prevents you from accidentally adding a second abstract method later.
Standard library functional interfaces you will see constantly: Predicate<T> (one-argument boolean test), Function<T, R> (maps T to R), Consumer<T> (accepts T, returns nothing), Supplier<T> (takes nothing, produces T), Comparator<T> (compares two T values).
Rust comparison: Java's functional interfaces are structurally similar to Rust's
Fntraits.FraudDetectorabove is roughly equivalent toimpl Fn(&Payment) -> bool. The key difference: Rust'sFn/FnMut/FnOncehierarchy captures ownership and mutability semantics that Java has no equivalent for. Java lambdas can only close over effectively-final variables (Module 07 covers this in depth).
Sealed Interfaces
A sealed interface restricts which types may implement it. You declare the permitted types in a permits clause:
// Java
sealed interface PaymentResult permits PaymentSuccess, PaymentFailure {}
Every type named in permits must implement the interface. No other type can implement it. This means the compiler knows the complete set of possible implementations at compile time — which enables exhaustive switch expressions (Module 05 covers the full pattern matching syntax):
// Java — the compiler verifies all cases are handled
String summary = switch (result) {
case PaymentSuccess s -> "Success: " + s.transactionId();
case PaymentFailure f -> "Failed: " + f.reason();
// No default needed — compiler knows these are all possibilities
};
Note: Both
sealed classandsealed interfaceare sealed type hierarchies and use the samesealedandpermitskeywords. The only difference is the declaration target (class vs. interface). Module 05 introduces sealed classes and will refer back to this definition.
Rust comparison: This is the closest Java gets to a Rust
enum. Not in syntax, but in what the compiler guarantees: a sealed interface with N permitted types means the compiler can verify you handled all N cases in a switch expression. The difference is that Rust enums hold data per variant directly; Java's sealed interface permits separate record types that each carry their own fields.
The permitted types must either be in the same package as the sealed interface, or declared in the same file. Each permitted type must declare itself as final, sealed, or non-sealed:
final— no further subtyping allowed.sealed— can be further restricted with its ownpermitsclause.non-sealed— opens the hierarchy back up (use sparingly).
Concrete Example
The running example for this module: define PaymentValidator as an interface with a default method, and FraudDetector as a functional interface.
// Java
package com.example.payments;
// A general interface — not a SAM interface
// Has one abstract method and one default method
public interface PaymentValidator {
// The contract: implementing classes must provide this
boolean validate(Payment payment);
// Default method built on top of the abstract method
// Note: cannot access any fields from implementing classes here
default boolean isValid(Payment payment) {
return validate(payment);
}
}
// A functional interface — exactly one abstract method
// The @FunctionalInterface annotation enforces this at compile time
@FunctionalInterface
public interface FraudDetector {
boolean isSuspicious(Payment payment);
}
Now using them:
// Java
// An implementation of PaymentValidator — must provide validate()
public class BasicPaymentValidator implements PaymentValidator {
@Override
public boolean validate(Payment payment) {
if (payment.amount() <= 0) return false;
if (payment.fromAccount() == null || payment.toAccount() == null) return false;
return true;
}
// isValid() is inherited from PaymentValidator — no override needed
}
// FraudDetector implemented via lambda — works because it is a SAM interface
FraudDetector highValueDetector = p -> p.amount() > 10_000;
// Dynamic dispatch in action: the JVM looks up the actual implementation at runtime
PaymentValidator validator = new BasicPaymentValidator();
boolean valid = validator.isValid(new Payment(1L, "ACC-001", "ACC-002", 500.0, "USD"));
When validator.isValid(payment) is called, the JVM checks the actual runtime type of validator (it is a BasicPaymentValidator) and dispatches to the correct validate() implementation. This happens at runtime, not compile time.
Analogy
Think of a Java interface as a job posting, and a class as a person applying for the job. The job posting (interface) lists required skills (abstract methods) and may include standard operating procedures that come with the role (default methods). When someone is hired (the class is declared), they must explicitly list which job postings they satisfy on their application (the implements clause at class definition time).
In Rust, the job posting system works differently: a person can add new certifications (trait impls) any time after they are hired, as long as either the certification program or the person is "from your organization" (the orphan rule). Java requires all certifications to be declared upfront.
Sealed interfaces add a twist: the hiring manager knows exactly who is eligible and lists them by name in the job posting (permits clause). No surprises at runtime.
Going Deeper
Why Can't You Implement Interfaces Retroactively?
This limitation exists because Java's type system is built around nominal typing: a class is a subtype of an interface only if the class explicitly declares implements InterfaceName. The runtime's vtable is built from these declarations.
If you could add interface implementations retroactively, the JVM's dispatch tables would need to be rebuilt dynamically, class loading semantics would become significantly more complex, and binary compatibility guarantees would break. Rust's trait system avoids this problem because trait impls are monomorphized at compile time — there is no dynamic dispatch table to invalidate.
This is the design choice, not a limitation to be worked around. Java's predictability — you can always look at a class declaration and know exactly which interfaces it satisfies — is considered a feature in large codebases.
Interface Static Methods and Private Methods
Since Java 8, interfaces can also have static methods, which belong to the interface type itself (not to any implementing class):
// Java
interface PaymentValidator {
static PaymentValidator noOp() {
return p -> true; // always valid — a no-op implementation
}
}
Since Java 9, interfaces can have private methods — used to share implementation between default methods without exposing the helper as part of the interface's public API.
Abstract Class vs. Interface with Default Methods
The modern rule: reach for an interface with default methods first. Only use an abstract class when you need:
- Instance state (fields shared by all subclasses).
- A constructor that initializes shared state.
- Access control weaker than
publicfor methods (protectedmethods; interface methods are always effectivelypublic).
A class can implement many interfaces but extend only one class. Abstract class inheritance is a scarce resource — do not spend it on something an interface can provide.
Common Misconceptions
1. "Implementing multiple interfaces always causes the diamond problem."
The diamond problem only occurs when two interfaces define a default method with the same signature. If the conflicting methods are both abstract (no implementation), there is no conflict — the implementing class simply provides one concrete implementation that satisfies both. The diamond problem is narrowly scoped to default methods with the same name and parameter list.
2. "Java interfaces are just like Rust traits."
The surface similarity is real; the differences are important. Beyond dispatch (dynamic vs. static), the key structural difference is implementation location. In Rust, you can implement a trait for a type in your crate regardless of where the type or trait was defined (within the orphan rules). In Java, you must own the class definition to add interface implementations. This means if a library gives you a Payment class that does not implement Printable, your only options are subclassing or wrapping — you cannot add the implementation externally.
3. "A sealed interface prevents the types from doing anything else."
Sealing an interface only restricts who can implement it. The permitted types are free to implement other interfaces, have their own inheritance hierarchy, and contain whatever methods and fields they need. Sealing gives the compiler a closed, exhaustive list for pattern matching — it does not otherwise constrain the types' behavior.
Check Your Understanding
-
Why can't you add a second abstract method to a
@FunctionalInterfaceand still use it with lambda syntax?Answer: Lambda syntax requires the compiler to know exactly which method to implement. With two abstract methods, the compiler has no way to determine which one a given lambda is meant to implement. The
@FunctionalInterfaceannotation causes a compile error if you try, catching this mistake early. -
You have two interfaces,
AuditableandLoggable, both with adefault void record()method. You write a class that implements both. What happens, and how do you fix it?Answer: The compiler rejects the class with a compilation error because it cannot determine which default
record()to inherit. You fix it by overridingrecord()in the implementing class and explicitly delegating usingAuditable.super.record()and/orLoggable.super.record()as needed. -
You have a
PaymentValidator validatorvariable typed as the interface. What determines whichvalidate()implementation is called, and when is that determination made?Answer: Dynamic dispatch determines the implementation. The determination is made at runtime — the JVM inspects the actual object stored in
validator(its runtime type) and looks up the correctvalidate()in that class's vtable. The compile-time type (PaymentValidator) only tells the compiler which methods are available; it does not determine which code runs. -
In Rust, you can write
impl MyTrait for ExternalTypein your crate. What is the closest Java alternative when you need a third-party class to satisfy an interface you define?Answer: Java provides no direct equivalent. Your options are: (a) the Adapter pattern — write a wrapper class that implements your interface and delegates to the external type; (b) subclassing — extend the external class and add
implements YourInterface(only if the class is notfinal). Neither is as clean as a Rust trait impl. -
You declare
sealed interface Shape permits Circle, Rectangle {}. A colleague wants to addTriangleto the system. What change is required, and what does the compiler force them to do afterward?Answer: They must add
Triangleto thepermitsclause:sealed interface Shape permits Circle, Rectangle, Triangle {}. The compiler then reports an error at every exhaustive switch expression overShapethat does not handle theTrianglecase — ensuring all call sites are updated before the code compiles.
Key Takeaways
- An interface defines a contract; a functional interface (SAM interface) has exactly one abstract method and enables lambda syntax — every functional interface is an interface, but not vice versa.
- Default methods add concrete implementations to interfaces since Java 8; use them for multiple inheritance of behavior when you do not need shared state or constructors (those require an abstract class).
- Java uses dynamic dispatch for interface method calls by default — the JVM resolves the implementation at runtime via vtable lookup. Rust uses static dispatch by default via monomorphization; dynamic dispatch is an explicit opt-in with
dyn Trait. Java has no language-level static dispatch. - The most significant structural difference from Rust: you can only add interface implementations to a class at the point of class definition. You cannot retroactively implement your interface for a third-party type.
- Sealed interfaces (
sealed ... permits) give the compiler a closed, exhaustive list of permitted implementations, enabling compile-time verified exhaustive switch expressions — the closest Java equivalent to a Rustenumin terms of compiler guarantees.
References
- Sealed Classes and Interfaces — Oracle Java 21 Documentation — Authoritative reference for
sealedandpermitssyntax, compiler checks, and permitted type constraints. - JEP 409: Sealed Classes — The original JEP that finalized sealed classes and interfaces in Java 17; explains the design motivation and trade-offs.
- Functional Interfaces in Java — Baeldung — Comprehensive walkthrough of
@FunctionalInterface, the standard library functional interface types, and how they enable lambda syntax. - Rust Traits Are Better Interfaces — A direct Java-vs-Rust comparison focused on implementation location, dispatch, and composability trade-offs.
- Interface with Default Methods vs Abstract Class — Baeldung — Covers the state, constructor, and access-control distinctions that determine when to use each.