История эволюции интерфейсов в Java

Интерфейс в Java сильно эволюционировал за прошедшие годы. Давайте рассмотрим, какие изменения произошли в процессе его развития.

Оригинальные интерфейсы

Интерфейсы в Java 1.0 были достаточно простыми по сравнению с тем, какие они сейчас (идея интерфейсов заимствована из языка Objective C). Они могли содержать лишь два типа элементов: константы и публичные абстрактные методы.

Поля-константы

Интерфейсы могут содержать поля, так же как и обычные классы, но с несколькими отличиями:
  • поля должны быть проинициализированы
  • по умолчанию поля являются public static final константами
  • эти модификаторы избыточно указывать явно, поскольку они «проставляются» автоматически
public interface MyInterface {
    int MY_CONSTANT = 9;
}

Абстрактные методы

Наиболее важными элементами интерфейса являются его методы. Они также отличаются от методов обычного класса:
  • не имеют тела
  • их реализация выполняется классами, реализующими данный интерфейс
  • по умолчанию считаются public и abstract (избыточно задавать явно данные модификаторы)
  • не могут быть final, поскольку в Java комбинация модификаторов abstract и final запрещена
public interface MyInterface {
    int doSomething1();
    String doSomething2();
}

Вложенность

Java 1.1 представила концепцию классов, которые можно размещать внутри других классов. Такие классы бывают двух видов: статические и нестатические. Интерфейсы так же могут содержать внутри себя другие интерфейсы и классы.
Даже если это не задано явно, такие интерфейсы и классы считаются публичными и статическими.
public interface MyInterface {
    class MyClass {
        //...
    }

    interface MyOtherInterface {
        //...
    }
}

Перечисления и аннотации

В Java 5 были введены два новых типа: перечисления (Enums) и аннотации (Annotations). Их также можно размещать внутри интерфейсов.
public interface MyInterface {
    enum MyEnum {
        FOO, BAR;
    }

    @interface MyAnnotation {
        //...
    }
}

Дженерики (Generics)

В Java 5 появилась концепция дженериков — обобщённых типов. Дженерики позволяют использовать обобщенный тип вместо указания конкретного типа. Таким образом, вы можете писать код, который работает с различным количеством типов, не жертвуя при этом безопасностью и не предоставляя отдельную реализацию для каждого типа.
В интерфейсах, начиная с Java 5, вы можете определить обобщенный тип, а затем использовать его в качестве типа возвращаемого значения метода или в качестве типа аргумента метода.
Следующий интерфейс Box<T> работает независимо от того, используете ли вы его для хранения объектов типа String, Integer, List, Shoe или каких-либо других.
interface Box<T> {
    void insert(T item);
}

class ShoeBox implements Box<Shoe> {
    public void insert(Shoe item) {
        //...
    }
}

Статические методы

Начиная с Java 8, вы можете включать в интерфейсы статические методы. Данный подход изменил привычный способ работы с интерфейсами. Теперь они функционируют совершенно иначе, чем до Java 8. Изначально все методы в интерфейсах были абстрактными. Это означало, что интерфейс предоставлял лишь сигнатуру, но не реализацию. Реализация оставалась за классами, реализующими интерфейс.
При использовании статических методов в интерфейсах вам необходимо предоставить реализацию тела метода. Для добавления такого метода в интерфейс, укажите у него ключевое слово static. Статические методы по умолчанию являются public.
public interface MyInterface {
    
    // Работает
    static int foo() {
        return 0;
    }

    // Не работает, поскольку у статических методов
    // в интерфейсах должно быть тело
    static int bar();
}

Наследование статических методов

В отличие от обычных статических методов, статические методы в интерфейсах не наследуются. Это означает, что вызов такого метода следует делать напрямую из интерфейса, а не из реализующего его класса.
MyInterface.staticMethod();
Такое поведение очень полезно для избежания проблем при множественном наследовании. Представьте, что у вас есть класс, реализующий два интерфейса. У каждого из интерфейсов есть статический метод с одинаковым именем и сигнатурой. Какой из них должен быть использован в первую очередь? Обращение к методу через указание конкретного интерфейса, позволяет решить эту проблему.

Почему это полезно

Представьте, что у вас есть интерфейс и целый набор вспомогательных методов, которые работают с классами, реализующими этот интерфейс.
Традиционно существовал подход в использовании класса-компаньона. В дополнение к интерфейсу создавался утилитный класс с очень похожим именем, содержащий статические методы, принадлежащие интерфейсу.
Вы можете найти примеры использования данного подхода прямо в JDK: интерфейс java.util.Collection и сопутствующий ему утилитный класс java.util.Collections.
Со статическими методами в интерфейсах этот подход больше не актуален, не нужен и не рекомендован. Теперь вы можете размещать все в одном месте.

Методы по умолчанию

Методы по умолчанию похожи на статические методы тем, что для них вы также должны предоставлять тело. Для объявления метода по умолчанию используйте ключевое слово default.
public interface MyInterface {
    default int doSomething() {
        return 0;
    }
}
В отличие от статических методов, методы по умолчанию наследуются классами, реализующими интерфейс. Что важно, такие классы могут при необходимости переопределять их поведение.
Однако есть одно исключение. В интерфейсе не может быть методов по умолчанию с такой же сигнатурой, как у методов toString(), equals() и hashCode() класса Object. Взгляните на ответ Брайана Гётца, чтобы понять обоснованность такого решения: Allow default methods to override Object's methods.

Почему это полезно

Идея с реализацией методов прямо в интерфейсе выглядит не совсем правильной. Тогда зачем была введена эта функциональность?
У интерфейсов есть одна проблема. Как только вы предоставляете свои интерфейсы другим людям, их становится очень сложно менять. Выставленный для всеобщего использования API навсегда «каменеет» (его в будущем нельзя будет безболезненно модернизировать). Если только вы не хотите сломать код всех пользователей вашего интерфейса.
Традиционно Java очень серьезно относится к обратной совместимости. Методы по умолчанию предоставляют способ без последствий расширить возможности существующих интерфейсов.
Важно отметить, что поскольку методы по умолчанию уже предоставляют некоторую реализацию, то реализующие ваш интерфейс классы, не нуждаются в реализации этих методов. При необходимости методы по умолчанию можно будет переопределить в любое время, если их реализация перестанет подходить. Таким образом, вы можете предоставить новую функциональность существующим классам, реализующим ваш интерфейс, сохраняя при этом совместимость и не ломая их.

Конфликты методов

Пусть имеется класс, реализующий два интерфейса. В каждом из этих интерфейсов есть метод по умолчанию с одинаковым именем и сигнатурой.
interface A {
    default int doSomething() {
        return 0;
    }
}

interface B {
    default int doSomething() {
        return 42;
    }
}

class MyClass implements A, B {
}
Теперь один и тот же метод по умолчанию с одинаковой сигнатурой наследуется из двух разных интерфейсов. При этом каждый интерфейс предоставляет свою реализацию.
Возникает вопрос: как наш класс определит, какую из двух различных реализаций ему использовать?
Ответ: никак. Код выше приведет к ошибке компиляции. Ее можно исправить, переопределив конфликтующий метод в вашем классе.
class MyClass implements A, B {

    // Без этого метода будет ошибка компиляции
    @Override
    public int doSomething() {
        return 256;
    }
}

Приватные методы

С появлением Java 8, методов по умолчанию и статических методов, у интерфейсов появилась возможность содержать не только сигнатуры методов, но и их реализации. При написании реализаций рекомендуется разделять сложные методы на более простые. Такой код легче переиспользовать, поддерживать и понимать.
Для подобной цели, в обычном классе, вы бы использовали приватные методы, поскольку они могут содержать все детали реализации, которые не должны быть видимы и использованы извне.
К сожалению, в Java 8 интерфейс не мог содержать приватные методы. Приходилось использовать:
  1. длинные, сложные и трудные в понимании тела методов
  2. вспомогательные методы, которые являются частью интерфейса
Второй подход нарушает инкапсуляцию, а также загрязняет публичный API интерфейса и реализующие его классы.
К счастью, начиная с Java 9, вы можете использовать приватные методы в интерфейсах. Они обладают следующими характеристиками:
  • имеют тело и не являются абстрактными
  • могут быть как статическими, так и нестатическими
  • не наследуются реализующими классами и интерфейсами
  • могут вызывать другие методы интерфейса
  • приватные нестатические методы могут вызывать абстрактные, методы по умолчанию, статические и приватные методы
  • приватные статические методы могут вызывать только статические и приватные статические методы
public interface MyInterface {

    private static int staticMethod() {
        return 42;
    }

    private int nonStaticMethod() {
        return 0;
    }
}

Современное развитие интерфейсов (Java 10+)

Хотя основные возможности интерфейсов были сформированы к Java 9, развитие продолжилось и в последующих версиях языка. Рассмотрим ключевые улучшения, которые сделали работу с интерфейсами еще более удобной и выразительной.

Records в интерфейсах

С появлением records в Java 16, интерфейсы получили возможность определять record-классы непосредственно внутри себя. Это особенно полезно для создания DTO (Data Transfer Objects) и моделей данных, тесно связанных с интерфейсом.
public interface UserRepository {
    record UserInfo(String username, String email, int age) {}
    
    UserInfo findUserById(int id);
    List<UserInfo> findUsersByAge(int minAge, int maxAge);
}
Record в интерфейсе автоматически является static final, что делает его идеальным выбором для представления DTO, Value Objects и любых других неизменяемых данных в рамках контракта интерфейса.

Sealed-интерфейсы

Sealed-интерфейсы позволяют контролировать, какие классы или интерфейсы могут их реализовывать/расширять. Это мощный инструмент для создания безопасных иерархий типов.
// Объявление sealed-интерфейса
public sealed interface Shape
        permits Circle, Triangle {

    double area();

    double perimeter();

    // Метод по умолчанию, использующий преимущества sealed-иерархии
    default boolean isPolygonal() {
        return this instanceof Triangle;
    }
}
// Реализация интерфейса
final class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    public double getRadius() {
        return radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }

    @Override
    public double perimeter() {
        return 2 * Math.PI * radius;
    }
}
// Реализация интерфейса
non-sealed class Triangle implements Shape {
    private final double sideA;
    private final double sideB;
    private final double sideC;

    public Triangle(double sideA, double sideB, double sideC) {
        this.sideA = sideA;
        this.sideB = sideB;
        this.sideC = sideC;
    }

    public double getSideA() {
        return sideA;
    }

    public double getSideB() {
        return sideB;
    }

    public double getSideC() {
        return sideC;
    }

    @Override
    public double area() {
        double semiPerimeter = perimeter() / 2;
        return Math.sqrt(semiPerimeter *
                (semiPerimeter - sideA) *
                (semiPerimeter - sideB) *
                (semiPerimeter - sideC));
    }

    @Override
    public double perimeter() {
        return sideA + sideB + sideC;
    }
}
Данный код демонстрирует, как sealed-интерфейс Shape явно ограничивает круг своих реализаций только двумя классами. Это позволяет компилятору точно знать все возможные типы фигур и проверять полноту обработки всех случаев в switch-выражениях. Таким образом, вы получаете безопасную иерархию типов, где невозможно случайно добавить новую реализацию без явного разрешения.

Pattern Matching для switch

В Java 21 паттерн-матчинг для switch-выражений работает с sealed-интерфейсами, предоставляя элегантный способ обработки различных реализаций. Ниже представлен класс для запуска примера с фигурами:
public class ShapeProcessor {

    public static String describeShape(Shape shape) {
        return switch (shape) {
            case Circle c ->
                    String.format("Круг с радиусом %.1f (площадь: %.2f)",
                    c.getRadius(), c.area());
            case Triangle t ->
                    String.format("Треугольник со сторонами %.1f, %.1f, %.1f (площадь: %.2f)",
                    t.getSideA(), t.getSideB(), t.getSideC(), t.area());
        };
    }
}
Данный подход позволяет:
  • извлекать данные из объектов прямо в условии case, как показано в примере с Circle c, где мы сразу получаем доступ к радиусу без явного приведения типов
  • компилятор проверяет полноту покрытия всех вариантов sealed-интерфейса, что исключает возможность пропустить обработку какого-либо типа фигуры
  • синтаксис становится лаконичнее и выразительнее: вместо многословных if-else с instanceof мы получаем четкую структуру, где каждый case автономно описывает логику для конкретного типа

Функциональные интерфейсы и лямбда-выражения

Хотя функциональные интерфейсы были введены в Java 8, их важность в современном Java невозможно переоценить. Интерфейсы с одним абстрактным методом стали основой функционального программирования в Java:
@FunctionalInterface
public interface Validator<T> {
    boolean isValid(T value);
    
    default Validator<T> and(Validator<T> other) {
        return value -> this.isValid(value) && other.isValid(value);
    }
    
    default Validator<T> or(Validator<T> other) {
        return value -> this.isValid(value) || other.isValid(value);
    }
    
    static <T> Validator<T> alwaysValid() {
        return value -> true;
    }
}

Хронологический порядок

Ниже представлен хронологический перечень изменений по версиям Java:
Java 1.1 (1997)
вложенные классы
вложенные интерфейсы
Java 5 (2004)
дженерики
вложенные перечисления
вложенные аннотации
Java 8 (2014)
методы по умолчанию
статические методы
функциональные интерфейсы
Java 9 (2017)
приватные методы
Java 16 (2021)
Records в интерфейсах
Java 17 (2021)
Sealed-интерфейсы
Java 21 (2023)
Pattern Matching для sealed-интерфейсов
Эволюция интерфейсов в Java продолжается, и с каждой новой версией языка они становятся все более мощным и выразительным инструментом для создания гибкой и поддерживаемой архитектуры приложений.
Оригинал статьи «History of Java interface feature changes»
Оцените статью, если она вам понравилась!