Интерфейс или абстрактный класс?

Один из самых частых вопросов джуниор- и мидл-собеседований — и почти всегда с подвохом: «а зачем нам вообще два механизма, если оба про наследование поведения?»

Вопрос с собеседования: «Когда вы выберете интерфейс, а когда — абстрактный класс? И что изменилось после появления default-методов в интерфейсах?»

Формально в Java оба инструмента позволяют описать «контракт», которому должны следовать классы-наследники. Но правильный ответ — не «оба варианта работают», а понимание конкретных ограничений каждого.

Что выведет этот код?

Сначала посмотрим на код, который компилируется без единой ошибки, — и разберёмся, почему.

interface Flyable {
    void fly(); // абстрактный метод — реализации нет

    default void announce() { // default-метод — реализация есть прямо в интерфейсе
        System.out.println("Приготовиться к полёту!");
    }
}

abstract class Bird {
    String name;
    Bird(String name) { this.name = name; }
    abstract void makeSound(); // абстрактный метод — тела нет
    void sleep() { // обычный метод — тело есть
        System.out.println(name + " спит.");
    }
}

class Eagle extends Bird implements Flyable {
    Eagle(String name) { super(name); }

    @Override
    void makeSound() {
        System.out.println(name + " кричит: Клекочет!");
    }

    @Override
    public void fly() {
        System.out.println(name + " взлетает.");
    }
}

public class Main {
    public static void main(String[] args) {
        Eagle eagle = new Eagle("Орёл");
        eagle.makeSound();
        eagle.sleep();
        eagle.announce();
        eagle.fly();
    }
}

Вывод:

Орёл кричит: Клекочет!
Орёл спит.
Приготовиться к полёту!
Орёл взлетает.

Класс Eagle одновременно наследует состояние и часть поведения от абстрактного класса Bird (поле name, готовый метод sleep()) и реализует контракт интерфейса Flyable — при этом бесплатно получает метод announce(), который вообще не пришлось переопределять. Это и есть ключ к вопросу: интерфейс и абстрактный класс решают разные задачи, и здесь понадобились оба сразу.

Как это работает под капотом

Абстрактный класс — это обычный класс (abstract class), у которого может быть состояние (поля), конструктор, обычные методы с телом и абстрактные методы без тела, которые обязан реализовать первый неабстрактный наследник. Использовать new напрямую для него нельзя — только через подкласс. Главное ограничение: у класса в Java может быть только один родитель, поэтому наследовать сразу два абстрактных класса невозможно.

Интерфейс исторически был чистым контрактом — набором сигнатур методов без реализации и без состояния (только константы public static final). Класс мог реализовывать (implements) сколько угодно интерфейсов одновременно — это и есть подобие «множественного наследования», недоступное для классов.

С Java 8 в интерфейсах появились default-методы — методы с готовой реализацией прямо внутри интерфейса, помеченные ключевым словом default. Это стёрло часть границы: теперь интерфейс тоже может «раздавать» готовое поведение, а не только требовать его реализации. Придумали default-методы не ради архитектурной моды, а по конкретной причине: разработчики Java хотели добавить новые методы в существующие интерфейсы стандартной библиотеки (например, forEach() в Collection), не ломая при этом тысячи уже написанных классов, которые эти интерфейсы реализуют. Без default-метода добавление нового абстрактного метода в интерфейс заставило бы переписывать весь код, который его реализует.

Множественное наследование интерфейсов: что будет при конфликте?

Раз класс может реализовывать несколько интерфейсов, а у каждого может быть default-метод с одинаковой сигнатурой — что произойдёт при столкновении?

interface Swimmer {
    default void move() {
        System.out.println("Плыву brasом.");
    }
}

interface Runner {
    default void move() {
        System.out.println("Бегу трусцой.");
    }
}

class Triathlete implements Swimmer, Runner {
    @Override
    public void move() { // ОБЯЗАН переопределить — иначе ошибка компиляции
        Swimmer.super.move(); // явный выбор: какой из default-методов вызвать
        Runner.super.move();
        System.out.println("Триатлон завершён.");
    }
}

public class Main {
    public static void main(String[] args) {
        new Triathlete().move();
    }
}

Вывод:

Плыву brasом.
Бегу трусцой.
Триатлон завершён.

Если убрать переопределение move() в Triathlete, код вообще не скомпилируется — компилятор Java не может сам решить, какой из двух одинаковых default-методов «главнее», и заставляет программиста разрешить конфликт явно. Именно синтаксис ИмяИнтерфейса.super.метод() позволяет внутри переопределённого метода вызвать конкретную default-реализацию нужного интерфейса.

Практическое правило выбора

На практике эти два инструмента редко конкурируют «или-или» — чаще они дополняют друг друга, как в первом примере с Bird и Flyable. Абстрактный класс отвечает на вопрос «что представляет собой объект» (иерархия «это разновидность»), а интерфейс — на вопрос «что объект умеет делать» (способность, не привязанная к конкретной иерархии наследования).

Признак ситуацииЧто выбрать
Нужно общее состояние (поля) и общий код в конструкторе для группы похожих классовАбстрактный класс
Классы из совсем разных иерархий должны уметь одно и то же (например, и Bird, и Airplane умеют fly())Интерфейс
Нужно «подмешать» одну способность в разные, не связанные между собой классыИнтерфейс (можно реализовать сразу несколько)
Хотите зафиксировать жёсткую иерархию «это разновидность того-то»Абстрактный класс

Показательный пример из стандартной библиотеки Java: и ArrayList, и LinkedList реализуют интерфейс List (контракт «умею быть списком»), но при этом происходят от разных абстрактных классов (AbstractList и AbstractSequentialList соответственно), которые уже дают им часть готовой реализации, общей для похожих по устройству списков.

Частые ошибки на собеседовании

  • Говорить, что интерфейсы «заменили» абстрактные классы после Java 8. Это не так: у интерфейса по-прежнему нет полей состояния (кроме констант) и нет конструктора — default-методы дали только готовое поведение, но не состояние.
  • Путать «может быть» и «должен быть» абстрактным. В абстрактном классе не все методы обязаны быть абстрактными — там могут спокойно соседствовать готовые и незаконченные методы. А вот если класс не абстрактный, у него абстрактных методов быть не может вообще.
  • Забывать про конфликт default-методов. Если кандидат не может объяснить, что произойдёт при реализации двух интерфейсов с одинаковым default-методом, — это явный пробел, который проверяющие любят находить.
  • Утверждать, что интерфейс может хранить обычные поля. Поля в интерфейсе автоматически становятся public static final — то есть константами, а не изменяемым состоянием экземпляра.

Итоги-шпаргалка

  • Абстрактный класс — для общего состояния и части готового кода внутри одной иерархии «это разновидность». Наследуется только один.
  • Интерфейс — для контракта поведения, который может пригодиться классам из разных, не связанных иерархий. Реализовать можно сколько угодно сразу.
  • Default-методы (с Java 8) дали интерфейсам готовую реализацию — но не состояние (поля) и не конструктор.
  • При конфликте двух default-методов с одинаковой сигнатурой класс обязан переопределить метод сам, при необходимости выбирая нужную реализацию через Интерфейс.super.метод().
Проверьте себя
1. Что из перечисленного НЕ может быть в обычном интерфейсе Java (без учёта static-методов)?
AАбстрактный метод без реализации
BDefault-метод с готовой реализацией
CИзменяемое поле экземпляра (не константа)
DКонстанта public static final
2. Класс реализует два интерфейса с default-методами move() с одинаковой сигнатурой. Что произойдёт при компиляции, если класс не переопределит move() сам?
AJava автоматически возьмёт метод из первого интерфейса по порядку implements
BКод не скомпилируется — конфликт нужно разрешить явным переопределением
CОба метода выполнятся один за другим автоматически
DБудет выброшено исключение во время выполнения программы