JAVA / ABSTRACT CLASSES AND INTERFACES
Interfaces as contracts between unrelated classes
Use an interface to let classes with no common superclass be handled by one method, and write callers that depend on the contract, not concrete types.
What you will learn
- Declare one interface so unrelated classes fit in a single List and a single loop.
- Type parameters, fields and collections as the interface, not the concrete class.
- Only signatures are compiler-checked; behavioural rules belong in the contract's docs.
- Spot when an instanceof-plus-cast puts the concrete types back into the caller.
Understanding Interfaces as contracts between unrelated classes
Java gives each class exactly one superclass, so two classes that genuinely have nothing in common, such as an email message and a temperature reading, cannot be given a shared parent without inventing a parent that lies about both of them. An interface sidesteps that by declaring only method signatures: interface Archivable { String archiveKey(); long sizeInBytes(); } says nothing about fields, constructors or ancestry, so a class anywhere in the hierarchy can promise those two methods. Once both classes carry implements Archivable they are interchangeable everywhere the surrounding code needs only those two operations, even though neither class knows the other exists.
The mental model is a promise checked in two stages. At compile time the reference type is all the compiler consults: inside a loop over List<Archivable> it confirms that archiveKey() is on the contract and rejects anything that is not, which is why the archiving code compiles without ever naming EmailMessage. At run time the JVM dispatches on the object's real class, so the body that executes belongs to whichever implementer happens to be in the list. That split is the payoff: an implementer written next year needs no edit and no recompile of the caller.
The compiler enforces the shape of the contract and never its meaning. Nothing stops sizeInBytes() from returning -1, so obligations like "the key is stable and unique" and "the size is never negative" live in the interface's documentation and are relied on by both sides. This is how the JDK joins hopelessly unrelated types: Comparable is implemented by String, BigDecimal and File, and Runnable by anything with work to do, purely because each one promises a single well-specified method.
import java.util.List;
interface Archivable {
String archiveKey();
long sizeInBytes();
}
class EmailMessage implements Archivable {
private final String subject;
private final String body;
EmailMessage(String subject, String body) {
this.subject = subject;
this.body = body;
}
public String archiveKey() {
return "mail/" + subject.toLowerCase().replace(' ', '-');
}
public long sizeInBytes() {
return subject.length() + body.length();
}
}
class SensorReading implements Archivable {
private final int sensorId;
private final double celsius;
SensorReading(int sensorId, double celsius) {
this.sensorId = sensorId;
this.celsius = celsius;
}
public String archiveKey() {
return "sensor/" + sensorId;
}
public long sizeInBytes() {
return Integer.BYTES + Double.BYTES; // packed id + value
}
}
public class Main {
static long archiveAll(List<Archivable> items) {
long total = 0;
for (Archivable item : items) {
System.out.println("storing " + item.archiveKey() + " (" + item.sizeInBytes() + " bytes)");
total += item.sizeInBytes();
}
return total;
}
public static void main(String[] args) {
List<Archivable> batch = List.of(
new EmailMessage("Quarterly Report", "Numbers attached."),
new SensorReading(7, 21.5),
new EmailMessage("Lunch", "1pm?"));
long total = archiveAll(batch);
System.out.println("total " + total + " bytes");
}
}An interface is a capability promise that lets one piece of code serve classes with no shared ancestry, because the caller depends on declared methods rather than on any concrete class.
Worked examples
Crossing an existing inheritance chain
A class that already extends a foreign base class joins the same contract as a class whose only ancestor is Object.
interface Validatable {
boolean isValid();
}
class LegacyForm {
protected final String raw;
LegacyForm(String raw) {
this.raw = raw;
}
}
class SignupForm extends LegacyForm implements Validatable {
SignupForm(String raw) {
super(raw);
}
public boolean isValid() {
return raw.contains("@");
}
}
class Coordinate implements Validatable {
private final double lat;
private final double lon;
Coordinate(double lat, double lon) {
this.lat = lat;
this.lon = lon;
}
public boolean isValid() {
return Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
}
}
public class Main {
static void report(Validatable v) {
System.out.println(v.getClass().getSimpleName()
+ " extends " + v.getClass().getSuperclass().getSimpleName()
+ " -> valid=" + v.isValid());
}
public static void main(String[] args) {
report(new SignupForm("ada@example.com"));
report(new SignupForm("no-at-sign"));
report(new Coordinate(48.85, 2.35));
report(new Coordinate(120.0, 2.35));
}
}Example explained
Line 1SignupForm keeps LegacyForm as its superclass and still adds implements Validatable, because the number of interfaces a class may implement is unlimited.
Line 2Coordinate reaches the same contract from Object, which the printed superclass names make visible.
Line 3report takes Validatable, so one method body serves both chains and would serve a third without being touched.
Line 4The two isValid() bodies share no code and no fields; the contract fixes the signature, each class decides what valid means for its own data.
What the interface reference hides
Shows that the interface narrows what the compiler will allow, and that casting back re-establishes the dependency you removed.
interface Playable {
void play();
}
class Podcast implements Playable {
public void play() {
System.out.println("streaming episode 12");
}
void downloadTranscript() {
System.out.println("saved transcript-12.txt");
}
}
public class Main {
public static void main(String[] args) {
Playable item = new Podcast();
item.play();
// item.downloadTranscript(); // rejected at compile time: not on the contract
if (item instanceof Podcast) {
((Podcast) item).downloadTranscript();
}
System.out.println(item.getClass().getSimpleName() + " held as " + Playable.class.getSimpleName());
}
}Example explained
Line 1Playable item = new Podcast() leaves play() as the only method the compiler will accept on item.
Line 2The commented call is a compile error rather than a runtime one, because method resolution uses the declared type.
Line 3The cast reaches downloadTranscript() again, but the code now names Podcast and stops working for other implementers.
Line 4getClass() still answers Podcast, so the interface hid the concrete type from the compiler, not from the running object.
Important notes
The methods declared in an interface are implicitly public, and Java forbids narrowing access, so writing boolean isValid() without public in the implementing class fails with "attempting to assign weaker access privileges; was public".
Implementing the same interface does not make two classes related to each other: an EmailMessage cannot be assigned to a SensorReading variable, and a List<EmailMessage> cannot be passed where List<Archivable> is expected.
Common mistakes
Inventing a shared superclass just to hold one method: the next class you need already extends something else, and because Java permits only one superclass it can never join, so you copy the method instead of reusing the caller.
Declaring the interface but leaving the caller typed as EmailMessage or List<EmailMessage>: it compiles, so the mistake is invisible until the second implementer arrives and does not fit.
Reaching class-specific methods with a chain of instanceof and casts inside the loop: every new implementer now forces an edit to that loop, and a forgotten branch silently skips objects instead of failing.
Try it yourself
Change, predict, then run
Write a Refundable interface with String reference() and double refundAmount(), implement it on an unrelated ConcertTicket and SubscriptionMonth, then write one static method taking List<Refundable> that prints each reference and the total to refund.
Open the Java workspaceCheck your understanding
A class already extends a framework's BaseJob class, and you need it usable by a loop that calls estimateCost() on every element. Which approach lets the loop serve this class and unrelated future classes without changing BaseJob?
- Add estimateCost() to the class and have the loop take Object, casting each element to its concrete type
- Make the class extend both BaseJob and a new CostAware class
- Declare an interface with estimateCost() and add it to the class's implements clause
- Duplicate the class so one copy extends BaseJob and another extends CostAware
Show answer
Java allows one superclass but any number of interfaces, so the contract can be bolted onto a class that is already committed to BaseJob, and the loop can be typed against that interface. Option 1 compiles, which makes it tempting, but the loop then has to name every concrete type it might receive, so each new implementer forces an edit to the code the interface was meant to keep stable; option 2 is rejected by the compiler outright.