все статьи
4 авг. 2026 · 8 мин чтения

Скелетная реализация

Если вы когда-нибудь заглядывали в исходники JDK, вы видели странную пару: интерфейс Collection и рядом абстрактный класс AbstractCollection. Интерфейс List - и AbstractList. Map - и AbstractMap.

Зачем два типа на одну сущность? Почему нельзя было обойтись чем-то одним?

Ответ - паттерн, который Джошуа Блох в Effective Java называет скелетной реализацией (skeletal implementation). Разберём его на живом примере: сначала увидим боль, потом придём к решению и посмотрим на ограничения.

Наблюдение, с которого всё начинается

Возьмём небольшой интерфейс - хранилище заметок:

public interface Notes {

    void add(String text);
    List<String> all();
    void deleteAt(int index);

    int count();
    boolean isEmpty();
    String last();
    void deleteAll();
}

Семь методов. Любой класс с implements Notes обязан реализовать все семь.

Но присмотритесь к списку. Не все семь пунктов равноценны.

Настоящих операций здесь три. Чтобы реализовать add, all и deleteAt, нужно принять решение: где физически лежат данные. В ArrayList? В файле? В SQLite? Без этого решения написать их невозможно.

Остальные четыре - производные. Они выражаются через первые три и не требуют никаких новых знаний о хранилище:

Производная операция Выражается как
count() all().size()
isEmpty() count() == 0
last() all().get(count() - 1)
deleteAll() deleteAt() в цикле

Обратите внимание на важную деталь: count() реализуется одинаково для любого хранилища. Берём all(), считаем длину. Данные в памяти, в файле, в сети - код не меняется.

Это и есть наблюдение, из которого растёт весь паттерн: методы интерфейса делятся на примитивные (требуют решения о реализации) и производные (выводятся из примитивных механически).

Боль: что происходит без паттерна

Напишем две реализации.

public class MemoryNotes implements Notes {
    private final List<String> list = new ArrayList<>();

    @Override public void add(String text) { 
        list.add(text); 
    }

    @Override public List<String> all() {
        return List.copyOf(list); 
    }

    @Override public void deleteAt(int i) { 
        list.remove(i); 
    }

    @Override public int count() { 
        return list.size(); 
    }

    @Override public boolean isEmpty() { 
        return list.isEmpty(); 
    }

    @Override public String last() { 
        return list.get(list.size() - 1); 
    }

    @Override public void deleteAll() {
        list.clear(); 
    }
}
public class FileNotes implements Notes {
    private final Path file;

    @Override public void add(String text) { 
        /* дописать строку в файл */ 
    }
    
    @Override public List<String> all() { 
        /* прочитать файл */ 
    }

    @Override public void deleteAt(int i) { 
        /* перезаписать файл */ 
    }

    @Override public int count() { 
        return all().size(); 
    }

    @Override public boolean isEmpty() { 
        return count() == 0; 
    }

    @Override public String last() { 
        return all().get(count() - 1); 
    }

    @Override public void deleteAll() { 
        /* цикл deleteAt */ 
    }
}

Первая тройка методов различается по существу - так и должно быть, это разные хранилища.

А вот последняя четвёрка - дублирование. return all().size() будет написано столько раз, сколько у нас реализаций. Появится SqliteNotes - напишем в третий раз.

Почему это не "просто некрасиво"

Копипаста опасна не только в плане эстетики. Посмотрите на last():

@Override public String last() {
    return all().get(count() - 1);   // список пуст => IndexOutOfBoundsException
}

Кейс с IndexOutOfBoundException не обработан. И он не обработан в каждой копии независимо. Автор MemoryNotes мог добавить проверку, автор FileNotes - забыть. Баг найден в одном классе, исправлен в одном классе, в остальных остался жить.

Каждая новая реализация - это новая возможность повторить ту же ошибку. Причём ошибку в коде, который к сути реализации не имеет никакого отношения.

Решение: скелетная реализация

Идея прямая: реализовать производные операции один раз, а примитивные оставить абстрактными.

/**
 * Скелетная реализация {@link Notes}.
 * Реализует производные операции через три примитива:
 * add(), all(), deleteAt().
 *
 * Внутренние вызовы (self-use) - читать перед переопределением:
 *   count()     => all()
 *   isEmpty()   => count()
 *   last()      => isEmpty(), all(), count()
 *   deleteAll() => count(), deleteAt()
 */
public abstract class AbstractNotes implements Notes {

    // add(), all(), deleteAt() остаются абстрактными:
    // решение о хранилище принимает подкласс

    @Override
    public int count() {
        return all().size();
    }

    @Override
    public boolean isEmpty() {
        return count() == 0;
    }

    @Override
    public String last() {
        if (isEmpty()) {
            throw new NoSuchElementException("notes are empty");
        }
        return all().get(count() - 1);
    }

    @Override
    public void deleteAll() {
        // идём с конца: удаление сдвигает индексы
        for (int i = count() - 1; i >= 0; i--) {
            deleteAt(i);
        }
    }
}

Класс объявлен abstract - создать его нельзя, он намеренно неполный. Отсюда и название: скелет есть, реализации нет.

Теперь конкретные классы:

public final class MemoryNotes extends AbstractNotes {
    private final List<String> list = new ArrayList<>();

    @Override 
    public void add(String text) { 
        list.add(text); 
    }
    
    @Override 
    public List<String> all() { 
        return List.copyOf(list); 
    }

    @Override 
    public void deleteAt(int i) { 
        list.remove(i); 
    }

    // count() и deleteAll() переопределены не потому, что скелет неверен,
    // а потому, что у ArrayList они O(1) против O(n) у скелета
    @Override 
    public int count() { 
        return list.size();
    }

    @Override 
    public void deleteAll() { 
        list.clear(); 
    }
}
public final class FileNotes extends AbstractNotes {
    private final Path file;

    public FileNotes(Path file) { 
        this.file = file; 
    }

    @Override 
    public void add(String text) { 
        /* дописать строку */ 
    }

    @Override 
    public List<String> all() { 
        /* прочитать файл */ 
    }

    @Override 
    public void deleteAt(int i) { 
        /* перезаписать файл */ 
    }
}

FileNotes - три метода вместо семи. При этом count(), isEmpty(), last(), deleteAll() полностью работоспособны, включая проверку на пустоту в last(), написанную и протестированную один раз.

Автор скелета не знал, что кто-то станет хранить заметки в файле. Ему и не нужно было знать - он опирался только на контракт трёх примитивов.

Ключевой вопрос: зачем тогда интерфейс?

Eсли есть AbstractNotes, зачем вообще держать интерфейс Notes? Оставим один абстрактный класс.

Ответ - в главном свойстве паттерна: интерфейс задаёт тип, скелет остаётся необязательной помощью.

Клиентский код всегда работает с интерфейсом:

void render(Notes notes) { ... }

А теперь представьте разработчика, чей класс уже кого-то расширяет:

public class SyncedNotes extends AbstractCustomRepository { ... }

В Java единственный слот extends занят. Если бы скелет был обязательным, этот код было бы не написать. С интерфейсом выход есть: implements Notes и семь методов вручную. Скучно - но возможно.

Именно поэтому в JDK пара Collection / AbstractCollection, а не один абстрактный класс. Наследоваться от AbstractCollection - удобство, реализовать Collection напрямую - право.

Есть и промежуточный вариант - имитация множественного наследования: скелет используется через приватный внутренний класс, а внешний класс пересылает в него вызовы.

public class SyncedNotes extends AbstractCustomRepository implements Notes {

    private final Notes impl = new AbstractNotes() {
        @Override public void add(String text) { /* ... */ }
        @Override public List<String> all()    { /* ... */ }
        @Override public void deleteAt(int i)  { /* ... */ }
    };

    @Override public void add(String text) { impl.add(text); }
    @Override public List<String> all()    { return impl.all(); }
    @Override public int count()           { return impl.count(); }
    // остальное пересылается так же
}

Слот наследования отдан фреймворку, функциональность скелета при этом доступна.

Что ещё даёт паттерн

Реализация equals, hashCode, toString. Интерфейс не может их предоставить - это запрет на уровне языка. Абстрактный класс может, выразив их через примитивы. Это одна из причин, по которой AbstractList не исчез после появления default-методов: он даёт всем спискам корректные equals и hashCode.

Ограничения, о которых нужно знать

Паттерн не бесплатный. Четыре вещи стоит держать в голове.

1. Это по-прежнему наследование

Скелет не отменяет проблему хрупкого базового класса, а лишь ограничивает её. last() внутри вызывает isEmpty(), isEmpty() вызывает count(). Если подкласс переопределит count() неаккуратно, то сломается last(), которого он не касался.

Отсюда блок self-use в javadoc из примера выше. Это не документация "для порядка": без неё подкласс ломается непредсказуемо. И этот блок становится частью публичного контракта навсегда - поменяв isEmpty() с count() == 0 на all().isEmpty(), вы сломаете подклассы, переопределившие только count().

2. Производные реализации иногда неоптимальны

count() через all().size() в FileNotes - это чтение всего файла ради одного числа.

Скелет обязан быть универсальным, а универсальное почти всегда медленнее специализированного. Реальный риск в том, что скелет создаёт иллюзию завершённости, а реализующие классы радуются трём методам и не замечают, что получили O(n) там, где хранилище давало O(1). Поэтому в документации скелета стоит отдельно перечислять методы, которые имеет смысл переопределить ради производительности.

3. Два артефакта нужно держать в синхроне

Добавили метод в интерфейс - решите, примитив он или производный, и допишите в скелет. Добавили новый абстрактный метод в скелет - сломали все существующие подклассы. Паттерн облегчает эволюцию интерфейса, но сам эволюционирует хуже, чем интерфейс.

Скелет или default-методы?

С Java 8 производные операции можно положить прямо в интерфейс:

public interface Notes {
    void add(String text);
    List<String> all();
    void deleteAt(int index);

    default int count()       { return all().size(); }
    default boolean isEmpty() { return count() == 0; }
}

Отдельный класс не нужен, слот extends свободен. Но у default-методов есть жёсткие границы - из-за них скелеты не устарели:

  • Нет состояния. У интерфейса нет полей. Нельзя закешировать count, нельзя лениво инициализировать, нельзя хранить счётчик.
  • Нельзя реализовать equals / hashCode / toString. Прямой запрет языка.
  • Нельзя объявить protected-хелпер. Default-методы публичны и попадают в API интерфейса, хотите вы этого или нет.
  • Нет конструктора, а значит, нет места для проверки инвариантов.

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

Производные операции без состояния => default-методы в интерфейсе. Всё, где нужны поля, Object-методы, protected-хелперы или конструктор => скелетный класс.

Часто применяют оба сразу: тривиальное в интерфейс, тяжёлое в скелет.

Комментарии

Пока нет комментариев. Будьте первым.