Series: Java Core for Beginners Lecture: 06 of 12 Topics: Defining interfaces · Implementing interfaces · Default & static methods · Functional interfaces · Encapsulation · Interface vs abstract class · Multiple implementation
What Is an Interface?
In Lecture 5 you learned that inheritance lets a child class reuse and specialize a parent class. But inheritance has a fundamental constraint: a class can only extend one parent. What happens when a concept naturally belongs to multiple categories?
Consider a Duck. A duck is an Animal (it eats, it sleeps). But a duck can also fly like a Bird and swim like a Fish. If you tried to model this with inheritance alone, you would hit Java’s single-inheritance wall immediately.
Interfaces solve this. An interface is a contract — a set of method signatures that a class promises to implement. A class can implement as many interfaces as it needs, and any object that implements an interface can be treated as that interface type.
The distinction is important:
- Abstract class → what something is (“a Dog is an Animal”)
- Interface → what something can do (“a Duck can fly, can swim, can make a sound”)
Think of interfaces as capabilities or roles, not identities. A Document can be Printable and Exportable and Shareable all at once — those are capabilities, not what a document fundamentally is.
Defining and Implementing an Interface
Defining an interface
Use the interface keyword. By default, all methods in an interface are public and abstract — you do not need to write those modifiers explicitly:
public interface Flyable {
void fly(); // implicitly public abstract
int getMaxAltitude(); // implicitly public abstract
String getFlightDescription(); // implicitly public abstract
}
All fields in an interface are implicitly public static final — they are constants:
public interface SpeedLimits {
int WALK_SPEED = 5; // implicitly public static final
int RUN_SPEED = 15;
int DRIVE_SPEED = 120;
}
Implementing an interface
A class implements an interface using the implements keyword and must provide a concrete body for every abstract method in the interface:
public class Eagle extends Animal implements Flyable {
public Eagle(String name, int age) {
super(name, age);
}
@Override
public void fly() {
System.out.println(getName() + " soars on thermal currents.");
}
@Override
public int getMaxAltitude() {
return 3000; // metres
}
@Override
public String getFlightDescription() {
return "Powerful, soaring flight with wingspans up to 2.4 metres.";
}
@Override
public void makeSound() {
System.out.println(getName() + " shrieks!");
}
}
If a class does not implement all abstract methods from an interface, it must be declared abstract itself.
Using the interface type
Once a class implements an interface, its objects can be referenced by the interface type — exactly like polymorphism with classes:
Flyable flier = new Eagle("Liberty", 5);
flier.fly(); // "Liberty soars on thermal currents."
flier.getMaxAltitude(); // 3000
This is the power of interfaces: flier can hold any object that implements Flyable — an Eagle, a Plane, a Superman, whatever. The code working with flier does not need to know the concrete type.
Multiple Interface Implementation
A class can implement multiple interfaces, separated by commas. This is Java’s solution to the multiple-inheritance problem:
public interface Swimmable {
void swim();
double getSwimSpeed(); // metres per second
}
public interface Divable {
void dive(int depth); // depth in metres
int getMaxDepth();
}
public class Duck extends Animal implements Flyable, Swimmable {
public Duck(String name, int age) {
super(name, age);
}
// From Flyable
@Override
public void fly() {
System.out.println(getName() + " flaps its wings and takes off.");
}
@Override
public int getMaxAltitude() {
return 500;
}
@Override
public String getFlightDescription() {
return "Short, rapid wingbeats. Not built for soaring.";
}
// From Swimmable
@Override
public void swim() {
System.out.println(getName() + " paddles across the pond.");
}
@Override
public double getSwimSpeed() {
return 0.5;
}
// From Animal (abstract)
@Override
public void makeSound() {
System.out.println(getName() + " says: Quack!");
}
@Override
public String getDiet() {
return "omnivore";
}
}
Now a Duck object can be used as an Animal, a Flyable, or a Swimmable:
Duck donald = new Duck("Donald", 3);
Animal a = donald; // valid — Duck is an Animal
Flyable f = donald; // valid — Duck implements Flyable
Swimmable s = donald; // valid — Duck implements Swimmable
f.fly(); // "Donald flaps its wings and takes off."
s.swim(); // "Donald paddles across the pond."
donald.makeSound(); // "Donald says: Quack!"
Interface types in polymorphic collections
List<Flyable> fliers = new ArrayList<>();
fliers.add(new Eagle("Liberty", 5));
fliers.add(new Duck("Donald", 3));
// fliers.add(new Dog("Rex", 3)); // COMPILE ERROR — Dog does not implement Flyable
for (Flyable flier : fliers) {
flier.fly();
System.out.println("Max altitude: " + flier.getMaxAltitude() + "m");
}
The List holds any object that implements Flyable, regardless of what class it is or what other interfaces it implements.
Default Methods
Before Java 8, interfaces could only contain abstract methods. This created a painful problem: if you added a new method to a widely-used interface, every single class implementing that interface immediately broke — all had to be updated.
Default methods (Java 8+) solve this by allowing interfaces to provide a method with a concrete implementation. Classes that implement the interface inherit the default method automatically but can override it if they need custom behavior:
public interface Flyable {
void fly();
int getMaxAltitude();
String getFlightDescription();
// Default method — provides a standard implementation
default void land() {
System.out.println("Landing using standard approach.");
}
default void printFlightInfo() {
System.out.println("Flight description: " + getFlightDescription());
System.out.println("Maximum altitude: " + getMaxAltitude() + "m");
}
}
Any class implementing Flyable gets land() and printFlightInfo() for free. An Eagle might override land() with a more specific implementation:
public class Eagle extends Animal implements Flyable {
// ...other methods...
@Override
public void land() {
System.out.println(getName() + " extends talons and lands on a branch.");
}
// printFlightInfo() is inherited as-is from Flyable
}
Default method conflict resolution
When a class implements two interfaces that both define a default method with the same name, the class must override the method to resolve the ambiguity:
public interface A {
default void greet() { System.out.println("Hello from A"); }
}
public interface B {
default void greet() { System.out.println("Hello from B"); }
}
public class C implements A, B {
@Override
public void greet() {
A.super.greet(); // explicitly call A's version
// or B.super.greet(), or your own implementation
}
}
The compiler forces you to resolve the conflict explicitly — you cannot accidentally inherit the wrong version silently.
Static Methods in Interfaces
Since Java 8, interfaces can also have static methods. Unlike default methods, static methods are not inherited by implementing classes — they belong to the interface itself and are called directly on the interface name:
public interface Validator {
boolean validate(String input);
// Static factory / utility method
static Validator nonEmpty() {
return input -> input != null && !input.isBlank();
}
static Validator maxLength(int max) {
return input -> input != null && input.length() <= max;
}
static Validator emailFormat() {
return input -> input != null && input.contains("@") && input.contains(".");
}
}
Usage:
Validator notEmpty = Validator.nonEmpty();
Validator shortText = Validator.maxLength(50);
Validator isEmail = Validator.emailFormat();
System.out.println(notEmpty.validate("hello")); // true
System.out.println(notEmpty.validate("")); // false
System.out.println(isEmail.validate("user@mail.com")); // true
System.out.println(isEmail.validate("not-an-email")); // false
Static interface methods are typically used as factory methods or utility helpers that are semantically related to the interface’s concept.
Functional Interfaces — A First Look
A functional interface is an interface with exactly one abstract method. This single constraint makes it special: it can be implemented using a lambda expression — a concise anonymous function.
@FunctionalInterface
public interface Transformer {
String transform(String input); // single abstract method
}
The @FunctionalInterface annotation is optional but recommended — it makes the intent explicit and causes the compiler to enforce the “exactly one abstract method” rule.
Implementing with a lambda
Before Java 8, you implemented an interface by creating a class or an anonymous class:
// Old style — verbose anonymous class
Transformer upper = new Transformer() {
@Override
public String transform(String input) {
return input.toUpperCase();
}
};
With lambdas (Java 8+), you can write the same thing in one line:
// Lambda — concise and clear
Transformer upper = input -> input.toUpperCase();
Transformer lower = input -> input.toLowerCase();
Transformer trimmed = input -> input.trim();
Transformer reversed = input -> new StringBuilder(input).reverse().toString();
The lambda input -> input.toUpperCase() means: “given a parameter input, return input.toUpperCase()“. Java infers that this lambda implements the transform(String input) method because that is what the Transformer type expects.
Built-in functional interfaces
Java provides a rich set of ready-made functional interfaces in java.util.function. You will use these constantly in Lecture 12 (Lambda & Stream API):
| Interface | Method signature | Use case |
|---|---|---|
Predicate |
boolean test(T t) |
Testing a condition |
Function |
R apply(T t) |
Transforming a value |
Consumer |
void accept(T t) |
Performing an action |
Supplier |
T get() |
Producing a value |
BiFunction |
R apply(T t, U u) |
Transforming two inputs |
import java.util.function.*;
Predicate<String> isLong = s -> s.length() > 10;
Function<String, Integer> length = String::length; // method reference
Consumer<String> printer = System.out::println;
Supplier<String> greeting = () -> "Hello, World!";
System.out.println(isLong.test("short")); // false
System.out.println(isLong.test("a longer string")); // true
System.out.println(length.apply("Java")); // 4
printer.accept("Printed via Consumer");
System.out.println(greeting.get()); // Hello, World!
This is just a preview — Lecture 12 will cover lambdas and the Stream API in depth. For now, understand that functional interfaces are the mechanism that makes lambda expressions possible in Java.
Interface vs Abstract Class
Both interfaces and abstract classes enable abstraction and polymorphism. Choosing between them is one of the most important design decisions in Java. Here is a clear comparison:
| Aspect | Interface | Abstract class |
|---|---|---|
| Multiple inheritance | A class can implement many | A class can extend only one |
| Constructor | Not allowed | Allowed |
| Fields | Only public static final constants |
Any type of field |
| Method types | Abstract + default + static | Abstract + concrete |
| Access modifiers | Methods are public by default |
Any access modifier |
| State | Cannot hold instance state | Can hold instance state |
| Relationship | “can-do” (capability) | “is-a” (identity) |
Decision guide
Choose an interface when:
- You want to define a capability or role that unrelated classes can share
- You need multiple inheritance of type
- You are designing a public API that others will implement
- You want to define a contract without any shared state
// Good interface candidates — capabilities shared across unrelated types
Printable, Serializable, Comparable, Runnable, Closeable
Choose an abstract class when:
- You have shared state (fields) that all subclasses need
- You want to provide a partial implementation with some abstract methods
- The relationship is genuinely “is-a” and subclasses are closely related
- You need constructors to enforce initialization
// Good abstract class candidates — shared identity and state
Animal (has name, age, species)
Shape (has color, position)
Vehicle (has make, model, year)
A common modern pattern
In practice, these two tools are often used together:
// Interface defines the contract
public interface Payable {
double calculatePayment();
String getPaymentDetails();
}
// Abstract class provides shared state and partial implementation
public abstract class Employee implements Payable {
private final String name;
private final double baseSalary;
public Employee(String name, double baseSalary) {
this.name = name;
this.baseSalary = baseSalary;
}
public String getName() { return name; }
public double getBaseSalary() { return baseSalary; }
@Override
public String getPaymentDetails() {
return String.format("%s — base: $%.2f, total: $%.2f",
name, baseSalary, calculatePayment());
}
// calculatePayment() left abstract — each employee type defines its own
}
// Concrete class inherits from abstract class, satisfies the interface
public class ContractEmployee extends Employee {
private final int hoursWorked;
private final double hourlyRate;
public ContractEmployee(String name, int hoursWorked, double hourlyRate) {
super(name, 0); // no base salary
this.hoursWorked = hoursWorked;
this.hourlyRate = hourlyRate;
}
@Override
public double calculatePayment() {
return hoursWorked * hourlyRate;
}
}
Encapsulation — Designing Robust Classes
You were introduced to encapsulation in Lecture 4. Now that you understand interfaces and inheritance, it is worth revisiting encapsulation at a deeper level — because good encapsulation is what makes classes genuinely reusable and safe.
The encapsulation contract
Encapsulation is not just about making fields private. It is about protecting the object’s invariants — the rules that must always be true about an object’s state. Every public method is an entry point that outside code can use to interact with the object. Every one of those methods should uphold the invariants.
public class BankAccount {
private double balance; // invariant: balance >= 0
private final String accountNumber; // invariant: never changes after creation
private final List<String> transactionLog = new ArrayList<>();
public BankAccount(String accountNumber, double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative.");
}
this.accountNumber = accountNumber;
this.balance = initialBalance;
transactionLog.add("Account created with balance: " + initialBalance);
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive.");
}
balance += amount;
transactionLog.add("Deposited: " + amount);
}
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive.");
}
if (amount > balance) {
throw new IllegalStateException("Insufficient funds.");
}
balance -= amount;
transactionLog.add("Withdrawn: " + amount);
}
// Read-only access to balance
public double getBalance() {
return balance;
}
// Returns a COPY of the log — prevents outside code from modifying it
public List<String> getTransactionLog() {
return new ArrayList<>(transactionLog); // defensive copy
}
}
Defensive copying
Notice getTransactionLog() returns new ArrayList<>(transactionLog) — a copy, not the original. If it returned the original list, outside code could do this:
List<String> log = account.getTransactionLog();
log.clear(); // would destroy the internal transaction history!
By returning a copy, the internal transactionLog is protected. This pattern is called defensive copying and is essential for collections and mutable objects returned from getters.
Immutable classes
The strongest form of encapsulation is immutability — making objects whose state cannot change after construction. Immutable objects are inherently thread-safe, easier to reason about, and can be shared freely without defensive copying.
To make a class immutable:
- Declare the class
final(prevents subclassing) - Declare all fields
private final - Initialize all fields in the constructor
- Do not provide setters
- Defensively copy mutable objects received in the constructor and returned from getters
public final class Money {
private final long amountInCents; // store as long to avoid floating-point issues
private final String currency;
public Money(long amountInCents, String currency) {
if (amountInCents < 0) throw new IllegalArgumentException("Amount cannot be negative.");
if (currency == null || currency.isBlank()) throw new IllegalArgumentException("Currency required.");
this.amountInCents = amountInCents;
this.currency = currency;
}
public long getAmountInCents() { return amountInCents; }
public String getCurrency() { return currency; }
// Operations return new Money objects — original is unchanged
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies.");
}
return new Money(this.amountInCents + other.amountInCents, this.currency);
}
public Money subtract(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot subtract different currencies.");
}
if (other.amountInCents > this.amountInCents) {
throw new IllegalArgumentException("Result would be negative.");
}
return new Money(this.amountInCents - other.amountInCents, this.currency);
}
@Override
public String toString() {
return String.format("%s %.2f", currency, amountInCents / 100.0);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money other)) return false;
return amountInCents == other.amountInCents && currency.equals(other.currency);
}
@Override
public int hashCode() {
return java.util.Objects.hash(amountInCents, currency);
}
}
Money price = new Money(1999, "USD"); // $19.99
Money tax = new Money(160, "USD"); // $1.60
Money total = price.add(tax); // $21.59 — price and tax unchanged
Money discount = new Money(500, "USD"); // $5.00
Money final_ = total.subtract(discount); // $16.59
System.out.println(price); // USD 19.99
System.out.println(total); // USD 21.59
System.out.println(final_); // USD 16.59
Putting It Together — A Complete Example
Let us model a notification system that combines interfaces, abstract classes, default methods, and encapsulation:
// Interface — a capability
public interface Notifiable {
boolean send(String recipient, String message);
default String formatMessage(String sender, String message) {
return String.format("[%s] %s: %s",
java.time.LocalDateTime.now().toString().substring(0, 16),
sender,
message);
}
static Notifiable noOp() {
return (recipient, message) -> {
System.out.println("No-op notifier: message discarded.");
return false;
};
}
}
// Interface — a second capability
public interface Loggable {
void log(String event);
default void logSuccess(String recipient) {
log("SUCCESS — delivered to: " + recipient);
}
default void logFailure(String recipient, String reason) {
log("FAILURE — could not deliver to " + recipient + ": " + reason);
}
}
// Abstract class — shared state + partial implementation
public abstract class BaseNotifier implements Notifiable, Loggable {
private final String senderName;
private final List<String> logEntries = new ArrayList<>();
public BaseNotifier(String senderName) {
this.senderName = senderName;
}
protected String getSenderName() { return senderName; }
@Override
public void log(String event) {
String entry = "[LOG] " + event;
logEntries.add(entry);
System.out.println(entry);
}
public List<String> getLogEntries() {
return new ArrayList<>(logEntries); // defensive copy
}
// Template method pattern: defines the algorithm, delegates specifics to subclasses
@Override
public final boolean send(String recipient, String message) {
if (recipient == null || recipient.isBlank()) {
logFailure(recipient, "recipient is blank");
return false;
}
String formatted = formatMessage(senderName, message);
boolean success = doSend(recipient, formatted);
if (success) logSuccess(recipient);
else logFailure(recipient, "delivery failed");
return success;
}
// Abstract — subclasses implement the actual delivery mechanism
protected abstract boolean doSend(String recipient, String formattedMessage);
}
// Concrete subclass — email delivery
public class EmailNotifier extends BaseNotifier {
private final String smtpServer;
public EmailNotifier(String senderName, String smtpServer) {
super(senderName);
this.smtpServer = smtpServer;
}
@Override
protected boolean doSend(String recipient, String formattedMessage) {
// Simulate sending email (in real code, use JavaMail API)
if (!recipient.contains("@")) {
return false; // invalid email
}
System.out.println("EMAIL via " + smtpServer + " → " + recipient);
System.out.println(" Body: " + formattedMessage);
return true;
}
}
// Concrete subclass — SMS delivery
public class SmsNotifier extends BaseNotifier {
public SmsNotifier(String senderName) {
super(senderName);
}
@Override
protected boolean doSend(String recipient, String formattedMessage) {
// Simulate SMS (recipient must start with +)
if (!recipient.startsWith("+")) {
return false;
}
System.out.println("SMS → " + recipient + ": " + formattedMessage);
return true;
}
}
// Demonstration
public class Main {
public static void main(String[] args) {
List<Notifiable> notifiers = List.of(
new EmailNotifier("OrderSystem", "smtp.example.com"),
new SmsNotifier("OrderSystem"),
Notifiable.noOp()
);
String[][] deliveries = {
{"alice@example.com", "Your order #1001 has shipped."},
{"+1234567890", "Your order #1002 has shipped."},
{"invalid-recipient", "Your order #1003 has shipped."}
};
for (Notifiable notifier : notifiers) {
System.out.println("\n--- " + notifier.getClass().getSimpleName() + " ---");
for (String[] d : deliveries) {
notifier.send(d[0], d[1]);
}
}
}
}
This example demonstrates:
- Two interfaces (
Notifiable,Loggable) implemented by the same abstract class - Default methods (
formatMessage,logSuccess,logFailure) providing shared utility - Static interface method (
Notifiable.noOp()) as a factory - Template method pattern —
send()isfinalinBaseNotifierand delegates to the abstractdoSend(), ensuring the logging logic always runs regardless of subclass - Defensive copying in
getLogEntries() - Polymorphic collection holding
EmailNotifier,SmsNotifier, and the no-op in the sameList
Summary
- An interface defines a contract — a set of method signatures a class promises to implement. It expresses capability (“can-do”), not identity.
- Interfaces support multiple implementation: a class can implement any number of interfaces.
- All methods in an interface are implicitly
public abstract. All fields are implicitlypublic static final. No instance state is allowed. - Default methods (Java 8+) provide concrete implementations in interfaces, enabling backward-compatible API evolution. Conflicts between default methods must be resolved explicitly.
- Static methods in interfaces are utility or factory methods that belong to the interface itself and are not inherited.
- A functional interface has exactly one abstract method and can be implemented with a lambda expression. Built-in examples:
Predicate,Function,Consumer,Supplier. - Abstract class vs interface: use an abstract class for “is-a” relationships with shared state; use an interface for “can-do” capabilities. They are often combined — an interface defines the contract, an abstract class provides the partial implementation.
- Encapsulation means protecting invariants through controlled access. Key techniques: private fields, validated setters, defensive copying of collections, and immutable classes.
- An immutable class is
final, hasprivate finalfields, no setters, and returns copies of any mutable state. Immutable objects are safe to share across threads without synchronization.
Exercises
Exercise 1 — Printable and Exportable Define two interfaces: Printable (with void print()) and Exportable (with String export(String format) where format is "JSON" or "CSV"). Create a class Report with fields title (String) and data (Listprint() method should print each data item with a numbered line. The export() method should return the data formatted as either JSON or CSV. Demonstrate both capabilities through interface-type variables.
Exercise 2 — Default method conflict Create two interfaces Logger and Monitor that both declare a default method void alert(String message). Logger‘s version prints "[LOG] " + message; Monitor‘s version prints "[ALERT] " + message. Create a class SystemService that implements both and resolves the conflict by calling both versions. Write a main method to verify.
Exercise 3 — Functional interface pipeline Create a functional interface StringProcessor with a single method String process(String input). Write a method applyAll(String input, List that applies each processor in sequence (the output of one becomes the input of the next). Use it with a pipeline of: trimming whitespace, converting to lowercase, replacing spaces with underscores, and appending ".java". Implement each processor as a lambda.
Exercise 4 — Interface + abstract class design Model a media player system:
- Interface
Playable:void play(),void pause(),void stop(),int getDuration() - Interface
Seekable:void seekTo(int seconds),int getCurrentPosition() - Abstract class
MediaFileimplementing both: hastitleandfilePathfields, provides a concretestop()that resets position to 0, and leavesplay(),pause(),seekTo(),getDuration(),getCurrentPosition()abstract - Concrete class
AudioFileandVideoFilewith appropriate implementations
Write a method playAndSeek(Playable p, int seekPosition) that plays, seeks, and prints the position. It should work with any Playable — demonstrate with both an AudioFile and a VideoFile.
Exercise 5 — Immutable class Design an immutable class DateRange representing a closed interval between two java.time.LocalDate values. It should:
- Have
private finalfieldsstartDateandendDate - Validate that
startDateis not afterendDate - Provide
contains(LocalDate date)returningboolean - Provide
overlaps(DateRange other)returningboolean - Provide
lengthInDays()returninglong - Provide
extend(int days)returning a newDateRangewith the end date extended - Override
equals(),hashCode(), andtoString()
Write tests in main that demonstrate each method, including an overlap check between two ranges.
Up next: Lecture 7 — Arrays & Strings — where you will master Java’s built-in array type, explore the full String API, discover StringBuilder for efficient string construction, and learn how to format output precisely.
