Understanding Interfaces in Java

Interfaces are one of the fundamental pillars of object-oriented programming in Java. They allow you to define contracts for classes, ensuring that certain operations will be implemented without dictating how these operations should be performed. In this post, we will explore what interfaces are, their purpose, basic and complex usage examples, and why using interfaces is a better practice than using inheritance. We will also look at a real-world use case example.


What is an Interface?

In Java, an interface is a reference type, similar to a class, but it can only contain constants, method signatures, default methods, static methods, and nested types. Methods in an interface are implicitly public and abstract, unless they are default or static.

Default Methods

Default methods were introduced in Java 8. A default method in an interface is a method that has a default implementation. This allows you to add new methods to existing interfaces without breaking the code of classes that already implement those interfaces.

Example 1: Default Method in an Interface

public interface Vehicle {
    void start();
    void stop();

    default void honk() {
        System.out.println("Honking");
    }
}

public class Car implements Vehicle {
    @Override
    public void start() {
        System.out.println("Car starting");
    }

    @Override
    public void stop() {
        System.out.println("Car stopping");
    }
}

public class Main {
    public static void main(String[] args) {
        Vehicle myCar = new Car();
        myCar.start();
        myCar.honk(); // Default method called
        myCar.stop();
    }
}

Example 2: Default Method with Common Implementation

public interface Logger {
    void log(String message);

    default void logError(String message) {
        log("ERROR: " + message);
    }
}

public class ConsoleLogger implements Logger {
    @Override
    public void log(String message) {
        System.out.println(message);
    }
}

public class Main {
    public static void main(String[] args) {
        Logger logger = new ConsoleLogger();
        logger.log("A simple message");
        logger.logError("A simple error message"); // Default method called
    }
}

Why Use Default Methods?

1. Maintain Backward Compatibility:

Default methods allow you to add new methods to an interface without requiring that all classes that implement the interface provide an implementation for the new method. This is crucial for maintaining backward compatibility.

2. Share Common Code:

They allow you to provide a default implementation that can be shared by all classes that implement the interface, reducing code duplication.

Practical Examples of Using Default Methods

Example 1: Adding Functionality to an Existing Library

Imagine you have an interface ListProcessor used throughout your application to process lists.

public interface ListProcessor {
    void processList(List<String> list);

    default void processAndPrintList(List<String> list) {
        processList(list);
        list.forEach(System.out::println);
    }
}

public class UpperCaseListProcessor implements ListProcessor {
    @Override
    public void processList(List<String> list) {
        list.replaceAll(String::toUpperCase);
    }
}

Here, the processAndPrintList method was added without breaking the existing implementations of ListProcessor.

Example 2: Implementing Utility Methods

Consider an interface TimeSeries for handling time series data.

public interface TimeSeries {
    double getValue(long timestamp);

    default double getAverage(long start, long end) {
        double sum = 0;
        int count = 0;
        for (long ts = start; ts <= end; ts++) {
            sum += getValue(ts);
            count++;
        }
        return count == 0 ? 0 : sum / count;
    }
}

public class SimpleTimeSeries implements TimeSeries {
    private Map<Long, Double> data;

    public SimpleTimeSeries(Map<Long, Double> data) {
        this.data = data;
    }

    @Override
    public double getValue(long timestamp) {
        return data.getOrDefault(timestamp, 0.0);
    }
}

The getAverage method provides a convenient way to calculate the average of values over a time interval without needing to be reimplemented in each concrete class.

Basic Examples

Let's start with some simple examples of interfaces to understand their basic functionality.

Example 1: Simple Interface

public interface Drawable {
    void draw();
}

public class Circle implements Drawable {
    @Override
    public void draw() {
        System.out.println("Drawing a Circle");
    }
}

public class Square implements Drawable {
    @Override
    public void draw() {
        System.out.println("Drawing a Square");
    }
}

Example 2: Interface with Default Methods

public interface Vehicle {
    void start();
    void stop();

    default void honk() {
        System.out.println("Honking");
    }
}

public class Car implements Vehicle {
    @Override
    public void start() {
        System.out.println("Car starting");
    }

    @Override
    public void stop() {
        System.out.println("Car stopping");
    }
}

Example 3: Interface with Static Methods

public interface MathOperations {
    int add(int a, int b);

    static int multiply(int a, int b) {
        return a * b;
    }
}

public class SimpleMath implements MathOperations {
    @Override
    public int add(int a, int b) {
        return a + b;
    }
}

Complex Examples

Now let's look at some more complex examples of interfaces, showing how they can be used in more advanced scenarios.

Example 1: Interface for Module Communication

public interface MessageService {
    void sendMessage(String message, String receiver);
}

public class EmailService implements MessageService {
    @Override
    public void sendMessage(String message, String receiver) {
        // logic to send email
        System.out.println("Email sent to " + receiver + " with Message=" + message);
    }
}

public class SMSService implements MessageService {
    @Override
    public void sendMessage(String message, String receiver) {
        // logic to send SMS
        System.out.println("SMS sent to " + receiver + " with Message=" + message);
    }
}

Example 2: Interface for Sorting Strategies

public interface SortStrategy {
    void sort(int[] array);
}

public class BubbleSort implements SortStrategy {
    @Override
    public void sort(int[] array) {
        // Implementation of Bubble Sort
    }
}

public class QuickSort implements SortStrategy {
    @Override
    public void sort(int[] array) {
        // Implementation of Quick Sort
    }
}

Example 3: Interface for Plugins

public interface Plugin {
    void initialize();
    void execute();
}

public class PluginA implements Plugin {
    @Override
    public void initialize() {
        // Initialization of PluginA
    }

    @Override
    public void execute() {
        // Execution of PluginA
    }
}

public class PluginB implements Plugin {
    @Override
    public void initialize() {
        // Initialization of PluginB
    }

    @Override
    public void execute() {
        // Execution of PluginB
    }
}

Example 4: Interface for Middleware

public interface Middleware {
    void process(Request request, Response response, Middleware next);
}

public class AuthenticationMiddleware implements Middleware {
    @Override
    public void process(Request request, Response response, Middleware next) {
        // Authentication check
        if (authenticated(request)) {
            next.process(request, response, next);
        } else {
            response.setStatus(401);
        }
    }
}

public class LoggingMiddleware implements Middleware {
    @Override
    public void process(Request request, Response response, Middleware next) {
        // Request logging
        System.out.println("Request: " + request);
        next.process(request, response, next);
    }
}

Why Using Interfaces is Better Than Inheritance

Flexibility and Reusability

Interfaces provide more flexibility than inheritance because a class can implement multiple interfaces but can inherit from only one class. This allows you to create more reusable and modular components.

Decoupling

Interfaces help decouple the code, allowing different parts of a system to evolve independently. The implementation of an interface can change without affecting the other parts of the system that depend on that interface.

Following SOLID Principles

  • Single Responsibility Principle: Each interface should have a single responsibility.

  • Open/Closed Principle: Classes should be open for extension but closed for modification.

  • Liskov Substitution Principle: Objects of a superclass should be replaceable with objects of a subclass without altering the expected behavior.

  • Interface Segregation Principle: Many specific interfaces are better than one general interface.

  • Dependency Inversion Principle: Depend on abstractions (interfaces), not on concrete implementations.

Real-World Use Case Example

Consider an online payment system that supports multiple payment methods. Using interfaces can facilitate adding new payment methods without modifying existing code.

public interface PaymentMethod {
    void pay(double amount);
}

public class CreditCardPayment implements PaymentMethod {
    @Override
    public void pay(double amount) {
        // logic to pay with credit card
        System.out.println("Paid " + amount + " using Credit Card");
    }
}

public class PayPalPayment implements PaymentMethod {
    @Override
    public void pay(double amount) {
        // logic to pay with PayPal
        System.out.println("Paid " + amount + " using PayPal");
    }
}

public class PaymentProcessor {
    private PaymentMethod paymentMethod;

    public PaymentProcessor(PaymentMethod paymentMethod) {
        this.paymentMethod = paymentMethod;
    }

    public void processPayment(double amount) {
        paymentMethod.pay(amount);
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        PaymentProcessor creditCardPayment = new PaymentProcessor(new CreditCardPayment());
        creditCardPayment.processPayment(100.0);

        PaymentProcessor payPalPayment = new PaymentProcessor(new PayPalPayment());
        payPalPayment.processPayment(200.0);
    }
}

Conclusion

Interfaces are a powerful tool in Java that allows greater flexibility, reusability, and maintenance of code. They are preferable to inheritance in many cases due to their ability to promote decoupling and adhere to SOLID principles. Using interfaces enables you to build robust and extensible systems that can evolve with the needs of your project.

1
Subscribe to my newsletter

Read articles from André Felipe Costa Bento directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

André Felipe Costa Bento
André Felipe Costa Bento

Fullstack Software Engineer.