JAVA / ABSTRACT CLASSES AND INTERFACES
Abstract classes versus interfaces in practice
Decide between an abstract class and an interface by asking whether the shared part needs instance state and a constructor, or only a role any class can play.
What you will learn
- Pick an abstract class when subtypes must share instance fields or constructor checks
- Pick an interface when unrelated classes must play one role and keep extends free
- Know that interface fields are public static final and cannot hold per-object state
- Expose the interface as the parameter type and ship the abstract class as a base
Understanding Abstract classes versus interfaces in practice
Before Java 8 the split was easy to remember: abstract classes carried code, interfaces did not. That line is gone, and what still decides the design is memory and initialization. An abstract class can hold private instance fields and run a constructor that rejects bad arguments before any subclass touches the object, while an interface can only declare constants, since every field in an interface is implicitly public static final and no interface ever runs an initializer of its own.
The second deciding factor is the shape of the inheritance graph: a class has exactly one extends slot and any number of implements clauses. Choosing an abstract class spends a slot the implementor may have spent already, and an interface never competes for it. The mental model worth keeping is that a class says what an object is made of and what it remembers, while an interface says what callers may do with it, which is how an account and a login record can share a role while sharing no base class at all.
In production code the two usually stack rather than compete. The interface is the type that methods accept and tests substitute, and the abstract class is an optional base that implements the tedious half for whoever wants it, which is the arrangement between List and AbstractList in the JDK. The layering also pays off when requirements change, because adding an abstract method to a base class breaks every subclass already written, so a narrow published contract keeps the next version cheap.
Abstract class versus interface is decided by state and the single extends slot, not by whether the type is allowed to contain method bodies.
import java.util.List;
interface Auditable {
String auditLine();
}
abstract class Account implements Auditable {
private final String id;
private long cents;
protected Account(String id, long openingCents) {
if (openingCents < 0) {
throw new IllegalArgumentException("negative opening balance");
}
this.id = id;
this.cents = openingCents;
}
void deposit(long amount) {
cents += amount;
}
long balance() {
return cents;
}
abstract String kind();
@Override
public String auditLine() {
return kind() + " " + id + " = " + cents;
}
}
class Checking extends Account {
Checking(String id, long opening) {
super(id, opening);
}
@Override
String kind() {
return "checking";
}
}
class Savings extends Account {
private final int ratePct;
Savings(String id, long opening, int ratePct) {
super(id, opening);
this.ratePct = ratePct;
}
@Override
String kind() {
return "savings@" + ratePct + "%";
}
}
class LoginAttempt implements Auditable {
private final String user;
private final boolean ok;
LoginAttempt(String user, boolean ok) {
this.user = user;
this.ok = ok;
}
@Override
public String auditLine() {
return "login " + user + " " + (ok ? "ok" : "failed");
}
}
public class Main {
public static void main(String[] args) {
Checking c = new Checking("C-1", 5000);
c.deposit(2500);
Savings s = new Savings("S-1", 10000, 3);
List<Auditable> log = List.of(c, s, new LoginAttempt("dana", false));
for (Auditable a : log) {
System.out.println(a.auditLine());
}
System.out.println("checking balance = " + c.balance());
try {
new Checking("C-2", -1);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}Abstract class versus interface is decided by state and the single extends slot, not by whether the type is allowed to contain method bodies.
Worked examples
One base class, several roles
Shows why the state-holding type becomes the abstract class while the state-free roles become interfaces.
abstract class Widget {
private final String id;
protected Widget(String id) {
this.id = id;
}
public String id() {
return id;
}
}
interface Clickable {
void click();
}
interface Focusable {
void focus();
}
class Button extends Widget implements Clickable, Focusable {
private boolean focused;
Button(String id) {
super(id);
}
@Override
public void click() {
System.out.println(id() + " clicked (focused=" + focused + ")");
}
@Override
public void focus() {
focused = true;
System.out.println(id() + " focused");
}
}
class Label extends Widget {
Label(String id) {
super(id);
}
}
public class Main {
public static void main(String[] args) {
Button b = new Button("save");
b.focus();
b.click();
Widget label = new Label("total");
System.out.println(label.id() + " clickable? " + (label instanceof Clickable));
System.out.println(b.id() + " clickable? " + (b instanceof Clickable));
}
}Example explained
Line 1Button spends its single extends slot on Widget, the type that owns the id field, and takes the two behaviour roles as interfaces.
Line 2super(id) runs Widget's constructor, which an interface could not provide, so shared identity had to sit on the class side.
Line 3The focused flag is per-button memory, which is why Focusable declares only the method and stores nothing.
Line 4label instanceof Clickable prints false, so a caller can test for a capability without knowing whether the widget is a Button or a Label.
Constants versus per-object memory
Demonstrates that an interface can publish a limit but only a class can remember how much of that limit was used.
interface Retryable {
int MAX_ATTEMPTS = 3;
boolean attempt();
}
abstract class RetryingTask implements Retryable {
private int used;
public final boolean run() {
while (used < MAX_ATTEMPTS) {
used++;
if (attempt()) {
return true;
}
}
return false;
}
public int attemptsUsed() {
return used;
}
}
class FlakyTask extends RetryingTask {
private final int succeedOn;
private int calls;
FlakyTask(int succeedOn) {
this.succeedOn = succeedOn;
}
@Override
public boolean attempt() {
return ++calls >= succeedOn;
}
}
public class Main {
public static void main(String[] args) {
FlakyTask easy = new FlakyTask(1);
FlakyTask hopeless = new FlakyTask(5);
System.out.println(easy.run() + " after " + easy.attemptsUsed());
System.out.println(hopeless.run() + " after " + hopeless.attemptsUsed());
System.out.println("shared limit " + Retryable.MAX_ATTEMPTS);
}
}Example explained
Line 1int MAX_ATTEMPTS = 3 inside Retryable is implicitly public static final, one value for the whole program, so it can express a ceiling but never a count.
Line 2The used field must live in RetryingTask because an interface cannot declare an instance field, so the bookkeeping forces a class into the design.
Line 3The two tasks end with 1 and 3 used attempts, showing that used is per-object while MAX_ATTEMPTS is shared.
Line 4attempt() has to be declared public in FlakyTask, since an interface method is public by definition and an override may not narrow visibility.
Important notes
Do not choose between them on speed; the dispatch difference is not something application code can measure, so decide on state, constructors and the single extends slot.
An abstract class with no abstract methods is legal, and so is an interface with no methods; abstract controls who may call new, it is not a count of missing bodies.
Common mistakes
Writing int count = 0; inside an interface expecting each implementor to get its own counter: the field is implicitly public static final, so assigning to it does not compile and nothing about it is per-object.
Implementing an interface method without the public modifier, such as void click() in the class body: javac reports attempting to assign weaker access privileges, was public, and the class will not compile.
Publishing the abstract class as the parameter type instead of the interface: any implementor that already extends another class is locked out, and the only repair is extracting an interface and editing every signature.
Try it yourself
Change, predict, then run
Write an abstract class Sensor holding a String id set by a protected constructor and declaring abstract double read(), plus an interface Resettable with reset(). Create two sensors where only one implements Resettable, then loop over a List<Sensor> printing each reading and calling reset() only when the element is an instance of Resettable.
Open the Java workspaceCheck your understanding
A retryUntilSuccess helper must accept both a PdfExporter and an unrelated EmailSender, and EmailSender already extends AbstractNetworkClient. Where should the shared attempt() operation be declared?
- In an abstract class, so the retry counter can be stored in a field beside attempt()
- In an abstract class that EmailSender extends instead of AbstractNetworkClient
- In an interface, because the single extends slot of EmailSender is already taken
- Either one, since from Java 8 onwards an interface can also contain method bodies
Show answer
EmailSender may implement any number of interfaces but can extend only one class, and that slot is spent, so only an interface can cover both types without rewriting existing code. The first option is tempting because a retry count really is state, but that state belongs to the helper running the loop rather than to the shared type; the last option confuses the ability to carry code with the ability to carry instance fields and occupy the extends slot.