Java ООП в Java: классы, интерфейсы, наследование, records и sealed-типы
0%

ООП в Java: классы, интерфейсы, наследование, records и sealed-типы

ООП в Java: классы, интерфейсы, наследование, records и sealed-типы

В Java нельзя написать программу вне класса — но это не значит, что вы автоматически пишете объектно-ориентированный код. Большая часть боли в старых Java-проектах идёт именно отсюда: класс использовали как мешок для полей, наследование — как способ переиспользовать три строки кода, а интерфейс — потому что «так принято».

Эта статья разбирает объектную модель Java не как список ключевых слов, а через два вопроса, на которые она отвечает:

  1. Как ограничить количество допустимых состояний программы? Это инкапсуляция, иммутабельность, records и sealed-иерархии.
  2. Как выбрать поведение, не зная заранее конкретный тип? Это наследование, интерфейсы, виртуальная диспетчеризация и pattern matching.

Параллельно будем смотреть, во что это превращается на уровне JVM: без понимания заголовка объекта и таблицы виртуальных методов разговор про «дорогое наследование» или «интерфейсы медленные» превращается в фольклор. Глубоко JVM разбирается в статье JVM изнутри, здесь берём ровно тот минимум, без которого объектная модель непонятна.

Предполагается, что базовый синтаксис и разница между примитивами и ссылками уже знакомы — если нет, начните с Основ Java.

Что такое объект на самом деле

Java-объект — это блок памяти в куче, состоящий из служебного заголовка и полей. Переменная типа Order не содержит объект: она содержит ссылку (на 64-битной HotSpot это, как правило, 32-битный сжатый указатель — compressed oop).

Раскладка Java-объекта в куче: заголовок, поля, выравнивание

Три следствия, которые полностью определяют стиль кода на Java:

  • Каждый объект стоит минимум 16 байт. Заголовок в 12 байт (8 — mark word, 4 — указатель на класс) плюс выравнивание до кратности 8. Обёртка Integer — это 16 байт ради 4 байт данных плюс разыменование указателя. Отсюда IntStream, примитивные массивы и вечная борьба с боксингом.
  • Поведение хранится не в объекте, а в классе. Один InstanceKlass в Metaspace на миллион экземпляров. Поэтому добавление метода не увеличивает размер объекта, а добавление поля — увеличивает, и сразу у всех наследников.
  • Идентичность и равенство — разные вещи. == сравнивает ссылки, equals — то, что вы написали. В Java нет пользовательских value-типов (проект Valhalla их готовит, openjdk.org/projects/valhalla, но в 25 их ещё нет), поэтому record — это всё равно объект в куче со ссылочной идентичностью, просто с равенством по значению. Это заметное отличие от C#, где есть struct и record struct; сравнение см. в Основах C#.

Проверить раскладку на своей JVM можно инструментом JOL (openjdk.org/projects/code-tools/jol) — это единственный честный способ ответить на вопрос «сколько занимает мой объект».

Класс: инкапсуляция как сужение множества состояний

Инкапсуляция — не «сделай поля private и сгенерируй геттеры». Смысл в том, что класс даёт гарантию (инвариант), которую снаружи невозможно нарушить. Если класс имеет поля amount и currency, но допускает amount = -5 — инкапсуляции нет, есть бойлерплейт.

// Единица денег: инвариант — сумма не отрицательна, валюта известна.
public final class Money {
    private final long minorUnits;   // храним в копейках: double для денег запрещён
    private final Currency currency;

    public Money(long minorUnits, Currency currency) {
        if (minorUnits < 0) {
            throw new IllegalArgumentException("Отрицательная сумма: " + minorUnits);
        }
        this.minorUnits = minorUnits;
        this.currency = Objects.requireNonNull(currency, "currency");
    }

    // Операции возвращают новый объект — состояние никогда не мутирует.
    public Money plus(Money other) {
        requireSameCurrency(other);
        return new Money(this.minorUnits + other.minorUnits, currency);
    }

    private void requireSameCurrency(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Разные валюты: " + currency + " и " + other.currency);
        }
    }
}

Ключевые решения здесь: final на классе (никто не подменит поведение наследованием), final на полях (объект неизменяем и потокобезопасен «бесплатно»), проверка в конструкторе (невалидный объект просто не может существовать).

Порядок инициализации и главная ловушка конструкторов

Инициализация в Java идёт строго по шагам, и в этом порядке кроется классический баг.

Обратите внимание: конструктор суперкласса выполняется до инициализаторов полей подкласса. Значит, если суперкласс вызовет переопределённый метод, тот увидит поля наследника в состоянии «ещё нули».

class Base {
    Base() {
        init();  // ГРАБЛИ: вызов переопределяемого метода из конструктора
    }
    void init() { }
}

class Child extends Base {
    private final List<String> items = new ArrayList<>();

    @Override
    void init() {
        items.add("первый");  // NullPointerException: items ещё null
    }
}

Правило (Effective Java, item 19): конструктор не должен вызывать переопределяемые методы. Метод должен быть private, static или final. С JDK 21 компилятор умеет предупреждать об этом: javac -Xlint:this-escape. Включайте вместе с -Werror.

Вторая ловушка того же семейства — публикация this наружу из конструктора (регистрация в слушателях, передача в другой поток). Пока конструктор не завершился, final-поля не имеют гарантий видимости в модели памяти Java, и другой поток может увидеть недостроенный объект. Детали — в Конкурентности.

Наследование: что происходит при вызове метода

extends в Java даёт две вещи одновременно: наследование интерфейса (подтипизацию) и наследование реализации (переиспользование кода). Первое полезно почти всегда, второе — источник большинства проблем.

Механически всё выглядит так.

Виртуальная диспетчеризация в JVM: vtable, itable и инлайн-кэш JIT

Важные выводы из этой схемы:

  • Все нестатические, не-private, не-final методы в Java виртуальны по умолчанию. Это отличие от C#, где нужно явное virtual/override. Java выбрала другой дефолт — и переложила ответственность на JIT.
  • Виртуальный вызов в мономорфной точке стоит примерно ноль. JIT видит, что за сотни тысяч вызовов сюда приходил только Circle, ставит проверку klass-указателя и инлайнит тело. Расплата приходит на мегаморфных точках (3+ реальных типа) — там инлайнинг отключается, и вместе с ним умирают все последующие оптимизации.
  • invokeinterface чуть дороже invokevirtual, потому что индекс метода в itable не фиксирован так жёстко, как в vtable. На практике разница видна только в микробенчмарках — измеряйте через JMH (openjdk.org/projects/code-tools/jmh), а не гадайте.

Посмотреть байткод своими глазами полезно и совсем не сложно:

javac Shapes.java
javap -c -p Shapes.class   # -c дизассемблирует, -p показывает private-члены

Переопределение против перегрузки — источник тихих багов

Переопределение (override) разрешается в рантайме по реальному типу объекта. Перегрузка (overload) разрешается компилятором по статическим типам аргументов. Смешение этих механизмов даёт удивительные результаты.

class Printer {
    void print(Object o)  { System.out.println("Object: " + o); }
    void print(String s)  { System.out.println("String: " + s); }
}

Object value = "привет";     // статический тип — Object
new Printer().print(value);  // напечатает "Object: привет", а не "String"

Сюда же — набор правил, которые надо просто знать:

Ситуация Что происходит Как поймать
Метод в наследнике с другой сигнатурой это перегрузка, а не переопределение всегда ставьте @Override
static-метод «переопределён» это hiding: вызов по статическому типу не наследуйте статику
Поле с тем же именем в наследнике это shadowing: два поля, доступ по статическому типу не переиспользуйте имена полей
Сужение видимости при override ошибка компиляции компилятор поймает
Ковариантный возвращаемый тип разрешён и полезен (clone(), билдеры)
Переопределение equals(MyType) перегрузка; HashMap вызовет equals(Object) @Override спасёт

@Override — не украшение, а единственная защита от трёх строк этой таблицы. Настройте IDE и статический анализ так, чтобы её отсутствие было ошибкой.

Контракт equals/hashCode

Это самый нарушаемый контракт в Java. Правила из документации Object: рефлексивность, симметричность, транзитивность, согласованность, и x.equals(null) всегда false. Плюс главное: равные объекты обязаны иметь равный hashCode.

public final class Isbn {
    private final String value;

    public Isbn(String value) { this.value = Objects.requireNonNull(value); }

    @Override
    public boolean equals(Object o) {
        // Класс final, поэтому getClass и instanceof здесь эквивалентны.
        if (this == o) return true;
        if (!(o instanceof Isbn other)) return false;   // pattern matching, Java 16+
        return value.equals(other.value);
    }

    @Override
    public int hashCode() { return value.hashCode(); }  // Objects.hash(...) для нескольких полей

    @Override
    public String toString() { return "Isbn[" + value + "]"; }
}

Три практических предупреждения:

  1. Наследование ломает симметричность equals. Если Point сравнивается через instanceof, а ColorPoint extends Point добавляет поле цвета, то point.equals(colorPoint) вернёт true, а обратное — false. Общий вывод Effective Java (item 10): расширить конкретный класс новым «значимым» полем, сохранив контракт, невозможно. Отсюда либо final-классы, либо композиция.
  2. Изменяемые поля в ключе HashMap — потерянные объекты. Положили объект, изменили поле, участвующее в hashCode — и map.get(key) больше его не найдёт, потому что ищет в другой корзине.
  3. compareTo должен быть согласован с equals. Иначе TreeSet и HashSet для одних и тех же данных дадут разный размер. Классический пример — BigDecimal: new BigDecimal("1.0").equals(new BigDecimal("1.00")) даёт false, а compareTo даёт 0.

Интерфейсы: контракт, эволюция и множественное наследование поведения

Интерфейс отвечает на вопрос «что объект умеет», ничего не говоря о том, как он устроен. В Java класс наследует ровно один класс, но реализует сколько угодно интерфейсов — и это осознанное решение: множественное наследование состояния даёт ромбовидную проблему, множественное наследование поведения — нет.

Начиная с Java 8 интерфейс умеет:

public interface Notifier {
    // 1. Абстрактный метод — собственно контракт.
    void send(String message);

    // 2. default — реализация по умолчанию. Позволяет добавить метод в интерфейс,
    //    не сломав существующие реализации. Именно так в Collection появился stream().
    default void sendAll(List<String> messages) {
        messages.forEach(this::send);   // переиспользуем абстрактный метод
    }

    // 3. static — фабрики и утилиты рядом с контрактом.
    static Notifier noop() { return message -> { }; }

    // 4. private — вынести общий код из default-методов (Java 9+).
    private static String trim(String s) { return s.strip(); }
}

Чего интерфейс по-прежнему не умеет: хранить состояние. Поля интерфейса — это неявно public static final константы, и складывать туда изменяемое состояние — классический антипаттерн «constant interface».

Когда два интерфейса дают конфликтующие default-методы, компилятор разрешает конфликт по чётким правилам:

Дополнительно: интерфейс не может дать default-реализацию для методов Object (equals, hashCode, toString) — компилятор это запрещает, потому что класс всегда побеждает и такая реализация была бы мёртвым кодом.

Отдельный вид интерфейсов — функциональные: ровно один абстрактный метод. Аннотация @FunctionalInterface не обязательна, но включает проверку компилятором. Такие интерфейсы можно реализовать лямбдой, и компилятор превратит её не в анонимный класс, а в invokedynamic + LambdaMetafactory — то есть класс будет сгенерирован в рантайме и, для лямбд без захвата контекста, переиспользован. Подробно — в Stream API и функциональном стиле.

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

Критерий Интерфейс Абстрактный класс
Состояние (поля) нет да
Множественное наследование да нет
Конструктор нет да, для инвариантов
Контроль над созданием нет да (protected-конструктор)
Эволюция API легко: default легко, но занимает единственный слот extends

Рабочее правило: контракт публикуйте интерфейсом, скелет реализации — абстрактным классом, и не смешивайте. В JDK этот приём виден невооружённым глазом: List — интерфейс, AbstractList — скелет, ArrayList — реализация. Такой «скелетный класс» позволяет получить и множественное наследование типа, и переиспользование кода.

Композиция вместо наследования

Наследование реализации нарушает инкапсуляцию: подкласс зависит от того, как суперкласс реализован внутри, а не только от его контракта. Канонический пример — попытка посчитать все добавленные элементы:

// СЛОМАНО: HashSet.addAll внутри вызывает add(), поэтому счётчик удваивается.
class CountingSet<E> extends HashSet<E> {
    private int added = 0;

    @Override public boolean add(E e) { added++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) {
        added += c.size();
        return super.addAll(c);   // а внутри снова add() → +1 за каждый элемент
    }
    public int added() { return added; }
}

Проблема не в коде, а в том, что мы зависим от недокументированной детали реализации. Завтра HashSet.addAll перепишут — и наш класс молча сломается. Композиция (здесь — паттерн «Декоратор») эту связь разрывает:

// РАБОТАЕТ: мы зависим только от интерфейса Set.
class CountingSet<E> implements Set<E> {
    private final Set<E> delegate;
    private int added = 0;

    CountingSet(Set<E> delegate) { this.delegate = delegate; }

    @Override public boolean add(E e) { added++; return delegate.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) {
        added += c.size();
        return delegate.addAll(c);
    }
    public int added() { return added; }
    // остальные методы Set делегируются так же — обычно генерируются IDE
}

Формулировка Джошуа Блоха (Effective Java, item 18): наследуйте только тогда, когда есть настоящее отношение «is-a» и суперкласс спроектирован и задокументирован под наследование. Если он не помечен final, но в javadoc нет раздела «Implementation Requirements» — считайте, что наследовать его нельзя. Подробнее про компромиссы — в Coupling and cohesion и структурных паттернах.

Records: данные как данные

Значительная часть классов в реальном приложении — просто «прозрачные носители данных»: DTO, ключи, события, координаты, результаты запросов. До Java 16 такой класс занимал 60 строк: поля, конструктор, геттеры, equals, hashCode, toString. Record (JEP 395, openjdk.org/jeps/395) делает это одной строкой.

public record Point(int x, int y) { }

Компилятор генерирует: приватные final-поля, канонический конструктор, аксессоры x() и y() (без префикса get), equals/hashCode по всем компонентам и toString вида Point[x=1, y=2]. Класс неявно final и неявно наследует java.lang.Record.

Record — это номинальный кортеж с семантикой значения: два record одного типа равны тогда и только тогда, когда равны все их компоненты.

Валидация, нормализация и защитное копирование

Компактный конструктор — форма без списка параметров и без присваиваний: присваивание полям компилятор добавит сам, в конце.

public record DateRange(LocalDate from, LocalDate to) {
    // Компактный конструктор: валидируем и нормализуем ПАРАМЕТРЫ,
    // поля будут присвоены автоматически после этого блока.
    public DateRange {
        Objects.requireNonNull(from, "from");
        Objects.requireNonNull(to, "to");
        if (to.isBefore(from)) {
            throw new IllegalArgumentException("to < from: " + from + ".." + to);
        }
    }

    // Дополнительные конструкторы обязаны делегировать каноническому.
    public DateRange(LocalDate single) { this(single, single); }

    // Производные методы писать можно и нужно — состояния они не добавляют.
    public long days() { return ChronoUnit.DAYS.between(from, to) + 1; }
}

Если компонент изменяемый (коллекция, массив, Date), record сам по себе иммутабельность не даёт — нужны две защиты:

public record Order(String id, List<String> items) {
    public Order {
        // 1. Защита на входе: копируем и делаем неизменяемым.
        items = List.copyOf(items);   // NPE при null-элементах — это тоже валидация
    }
    // 2. Аксессор переопределять не нужно: List.copyOf уже вернул immutable-список.
}

С массивом сложнее: record Data(byte[] payload) получит equals, который сравнивает массивы по ссылке, и hashCode от identity. Это почти всегда баг — либо не кладите массивы в record, либо переопределяйте equals/hashCode вручную.

Что record не умеет

  • Наследоваться от класса (он уже наследует Record) и быть наследуемым — неявно final.
  • Иметь дополнительные instance-поля (только static).
  • Иметь неявно изменяемое состояние — все компоненты final.
  • Иметь with-выражение как в C#. Копирование с изменением пишется вручную (new Order(id, newItems)) или через сгенерированный билдер; языковая поддержка обсуждается (openjdk.org/jeps/468), но пока не в языке.

Record может реализовывать интерфейсы, быть дженериком, объявляться локально внутри метода (удобно для промежуточных кортежей в стримах) и быть вложенным.

Для JPA-сущностей record не подходит: Hibernate требует конструктор без аргументов и изменяемые поля — об этом в Работе с данными. А вот в качестве DTO на границе, ключа кэша, доменного value-object и события — это ровно то, что нужно.

Sealed-типы: закрываем иерархию

sealed (JEP 409, openjdk.org/jeps/409) — обратная сторона медали. Обычный интерфейс говорит: «реализуй меня кто угодно». Sealed говорит: «реализовать меня могут ровно эти три типа, и компилятор это знает».

public sealed interface Shape permits Circle, Rectangle, Triangle { }

public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public record Triangle(double base, double height) implements Shape { }

Правила:

  • Каждый наследник обязан быть final, sealed или non-sealed. Третий вариант — явная «дыра»: этот подтип снова открыт для наследования.
  • Наследники должны жить в том же модуле (или в том же пакете, если модулей нет).
  • permits можно опустить, если все наследники находятся в том же файле.

Комбинация «sealed интерфейс + records-наследники» — это алгебраический тип данных (sum type) в Java: сумма вариантов, каждый из которых — произведение полей. То, что в функциональных языках делается через data/enum, теперь выражается и здесь. См. Функциональное программирование.

Ключевая выгода — не синтаксис, а то, что добавление четвёртого варианта ломает компиляцию всюду, где вы обрабатываете этот тип. Это ровно то поведение, которое хочется от доменной модели: забыть новый случай становится невозможно.

Pattern matching: разбор данных вместо цепочек instanceof

Sealed-типы и records раскрываются вместе с pattern matching. Три поколения возможностей, все уже в LTS-релизах:

// Java 16: pattern for instanceof — проверка и приведение одним движением.
if (event instanceof PaymentResult.Success s && s.amount().isPositive()) {
    audit(s.transactionId());
}

// Java 21: switch по типам + record patterns (деструктуризация).
static String describe(Shape shape) {
    return switch (shape) {
        // Деструктурируем компоненты record прямо в образце.
        case Circle(double r) when r > 100 -> "огромный круг r=" + r;
        case Circle(double r)              -> "круг r=" + r;
        case Rectangle(double w, double h) when w == h -> "квадрат " + w;
        case Rectangle(double w, double h) -> "прямоугольник " + w + "x" + h;
        case Triangle t                    -> "треугольник площадью " + area(t);
        // default НЕ НУЖЕН: иерархия sealed, компилятор проверил полноту
    };
}

Ожидаемый вывод для describe(new Rectangle(3, 3))квадрат 3.0, для describe(new Circle(200))огромный круг r=200.0.

Что важно знать про switch с образцами:

  • Порядок имеет значение. Компилятор запрещает «доминирование»: если case Circle c идёт раньше case Circle c when r > 100, будет ошибка компиляции — второй случай недостижим. Сначала более специфичные образцы.
  • null больше не молчаливая ошибка. Классический switch кидал NPE. Pattern-switch по умолчанию тоже кидает, но можно написать case null -> и обработать явно. Это лучше, чем внешний if (x == null).
  • Полнота проверяется компилятором только для sealed-иерархий и enum. Для открытого типа default обязателен.
  • Вложенные образцы работают на любую глубину: case Line(Point(var x1, var y1), Point p2) -> ....

Полный пример, который можно скомпилировать и запустить (Java 21+):

import java.util.List;

public class Shapes {
    sealed interface Shape permits Circle, Rect, Group { }
    record Circle(double r) implements Shape { }
    record Rect(double w, double h) implements Shape { }
    record Group(List<Shape> parts) implements Shape { }

    // Рекурсивный обход алгебраического типа: ни одного instanceof, ни одного каста.
    static double area(Shape s) {
        return switch (s) {
            case Circle(double r)          -> Math.PI * r * r;
            case Rect(double w, double h)  -> w * h;
            case Group(List<Shape> parts)  -> parts.stream().mapToDouble(Shapes::area).sum();
        };
    }

    public static void main(String[] args) {
        Shape scene = new Group(List.of(new Circle(1), new Rect(2, 3), new Group(List.of(new Rect(1, 1)))));
        System.out.printf("площадь = %.4f%n", area(scene));
        // площадь = 10.1416
    }
}

Обратите внимание, чего здесь нет: приведений типов, null-проверок, default-ветки и метода area() внутри каждого варианта. Логика операции собрана в одном месте.

Полиморфизм или pattern matching — как выбирать

Оба механизма решают одну задачу — выбор поведения по типу — но оптимизируются под разные направления роста. Это классическая «expression problem».

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

  • Новые типы приходят часто, операции стабильны (плагины, драйверы, стратегии) — интерфейс и виртуальные методы. Добавить реализацию можно, не трогая существующий код.
  • Набор типов стабилен, операции добавляются (AST, доменные события, результаты) — sealed + records + pattern matching. Добавить операцию можно одним новым switch, а компилятор проследит за полнотой.

И не превращайте это в догму: смешивать нормально. Общее поведение — методом на интерфейсе, специфичные внешние операции (сериализация, рендеринг, метрики) — через pattern matching снаружи.

Модификаторы доступа и границы модуля

Java даёт четыре уровня видимости, и самый недооценённый — package-private (отсутствие модификатора). Это единственный способ сказать «реализация видна внутри пакета, но не наружу», не поднимая тяжёлую артиллерию модулей.

Модификатор Свой класс Пакет Наследник Все
private да нет нет нет
(нет модификатора) да да нет нет
protected да да да нет
public да да да да

Заметьте, что protected шире package-private: он открывает доступ наследникам из чужих пакетов. Использовать protected стоит только осознанно — вы публикуете точку расширения и обязаны поддерживать её совместимость.

Начиная с Java 9 существует JPMS: module-info.java позволяет экспортировать конкретные пакеты и прятать всё остальное на уровне модуля, а не пакета.

module shop.orders {
    requires java.sql;
    exports shop.orders.api;          // публичный контракт
    // shop.orders.internal остаётся невидимым снаружи, даже будучи public
}

JPMS в приложениях используется реже, чем ожидалось (Spring Boot и fat-jar-подход живут без него), но в библиотеках и в самом JDK он работает — именно поэтому sun.misc.Unsafe и внутренности перестали быть доступны. Про сборку и организацию модулей подробнее в Архитектуре прод-приложений.

Вложенные классы: где прячется утечка памяти

Java различает четыре вида вложенных классов, и разница между первыми двумя регулярно стоит людям продакшн-инцидентов.

class Outer {
    private int state = 42;

    // 1. Статический вложенный класс — обычный класс, просто в чужом namespace.
    static class Node { int value; Node next; }

    // 2. Внутренний (inner) класс — хранит СКРЫТУЮ ссылку на экземпляр Outer.
    class Iter {
        int read() { return state; }   // может читать state именно поэтому
    }

    void run() {
        // 3. Локальный класс — виден только внутри метода.
        // 4. Анонимный класс — одноразовая реализация.
        Runnable r = new Runnable() {
            @Override public void run() {
                System.out.println(state + " / this = " + this);  // this — анонимный объект
            }
        };
        Runnable lambda = () -> System.out.println(state + " / this = " + this); // this — Outer
    }
}

Ловушка: если вы сохраните экземпляр Iter (или анонимного Runnable) в долгоживущей структуре — кэше, статическом списке, планировщике, — вместе с ним в памяти останется весь Outer со всеми его полями. Это одна из самых частых причин утечек в Java-приложениях. Правило: вложенный класс делайте static, пока не доказано обратное; если нужны данные внешнего объекта — передайте их явно.

Второе отличие: this внутри анонимного класса ссылается на сам анонимный объект, внутри лямбды — на объемлющий экземпляр. Лямбда не создаёт новую область именования — это не «анонимный класс покороче», а другой механизм.

Типичные грабли: сводка

  1. Вызов переопределяемого метода из конструктора — наследник видит незаполненные поля. Лечится final/private и -Xlint:this-escape.
  2. equals без hashCode (и наоборот) — объект «теряется» в HashMap. Генерируйте IDE или используйте record.
  3. equals(MyType other) вместо equals(Object o) — тихая перегрузка. @Override.
  4. Изменяемое состояние в ключе коллекции — объект не находится после мутации.
  5. Массив внутри recordequals сравнивает ссылки.
  6. Возврат внутренней коллекции наружуreturn items; вместо return List.copyOf(items); даёт вызывающему право менять ваше состояние.
  7. protected-поля — это публичный API, который вы больше никогда не поменяете. Используйте protected-методы, не поля.
  8. Нестатический вложенный класс в долгоживущей структуре — утечка объемлющего объекта.
  9. Optional в качестве поля или параметра — это тип возвращаемого значения, не универсальный контейнер. Подробнее в Идиоматике и обработке ошибок.
  10. Глубокие иерархии наследования — если у вас 4+ уровня extends, почти наверняка нужна композиция.
  11. clone() — сломанный по дизайну механизм (поверхностное копирование, Cloneable без методов). Используйте копирующий конструктор или статическую фабрику.
  12. finalize() — удалён из языка. Для освобождения ресурсов — try-with-resources и AutoCloseable, при крайней необходимости — Cleaner.

Честно про место Java в этой нише

Где объектная модель Java выигрывает:

  • Стабильность контрактов. Обратная совместимость на десятилетия: код 2005 года чаще всего компилируется и работает. Для больших долгоживущих систем это главная ценность, важнее любой синтаксической элегантности.
  • Инструменты. Рефакторинг «извлечь интерфейс», «поднять метод», «заменить наследование делегированием» в IDE работает надёжно именно из-за номинальной системы типов и явных иерархий.
  • Предсказуемость чтения. Читая незнакомый Java-код, вы почти всегда понимаете, где определено поведение. Это выигрыш в командах, где сменяются десятки людей.

Где проигрывает:

  • Церемонность. Даже с records и var Java многословнее C#, Kotlin и Scala. Нет свойств, нет перегрузки операторов, нет именованных и умолчальных параметров — отсюда телескопические конструкторы и билдеры.
  • Нет пользовательских value-типов. Каждый Money, Point, Id — объект в куче со своим заголовком. В C# это struct без аллокации. Valhalla должна это закрыть, но её ждут больше десяти лет.
  • Стирание дженериков. List<String> и List<Integer> — один класс в рантайме. Отсюда невозможность new T[], instanceof List<String> и перегрузки по параметру типа. Подробно — в Коллекциях и дженериках.
  • Наследование по умолчанию виртуальное. Вы обязаны явно помечать final то, что не рассчитано на переопределение, иначе фреймворки и наследники сделают это за вас.

Куда объектную модель Java тащить не стоит: в задачи, где доминирует преобразование данных без идентичности (там выразительнее Elixir, Clojure или чистые функции на Python), и в маленькие CLI-утилиты, где стоимость запуска JVM и церемонии перевешивает выгоду (хотя GraalVM native-image частично это лечит).

Мини-итог

  • Объект в Java — заголовок плюс поля в куче; поведение живёт в классе, а не в экземпляре. Отсюда цена мелких объектов и дешевизна методов.
  • Инкапсуляция — про инварианты, а не про геттеры. Валидируйте в конструкторе, делайте поля final, класс — final, пока не доказано обратное.
  • Все методы виртуальны по умолчанию; JIT делает мономорфные вызовы бесплатными, мегаморфные — нет. Не проектируйте под микрооптимизации, но знайте механизм.
  • Наследуйте только ради подтипизации и только у классов, спроектированных под наследование. Во всех остальных случаях — композиция и делегирование.
  • Интерфейс описывает контракт, default позволяет его эволюционировать, абстрактный класс даёт скелет реализации.
  • Record — это данные с равенством по значению; sealed — закрытый набор вариантов. Вместе с pattern matching они переводят Java из «всё через полиморфизм» в «выбираем механизм под направление роста».
  • Contract equals/hashCode, порядок инициализации и вложенные классы — три места, где сосредоточена большая часть багов объектной модели.

Источники

Что дальше

Коллекции и дженерики: устройство, выбор структуры, вариантность — разберём, как устроены ArrayList, HashMap и ArrayDeque изнутри, почему стирание типов сделало Java-дженерики такими, какие они есть, и как читать ? extends и ? super, не заглядывая каждый раз в справочник.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов