ООП в Java: классы, интерфейсы, наследование, records и sealed-типы
В Java нельзя написать программу вне класса — но это не значит, что вы автоматически пишете объектно-ориентированный код. Большая часть боли в старых Java-проектах идёт именно отсюда: класс использовали как мешок для полей, наследование — как способ переиспользовать три строки кода, а интерфейс — потому что «так принято».
Эта статья разбирает объектную модель Java не как список ключевых слов, а через два вопроса, на которые она отвечает:
- Как ограничить количество допустимых состояний программы? Это инкапсуляция, иммутабельность, records и sealed-иерархии.
- Как выбрать поведение, не зная заранее конкретный тип? Это наследование, интерфейсы, виртуальная диспетчеризация и pattern matching.
Параллельно будем смотреть, во что это превращается на уровне JVM: без понимания заголовка объекта и таблицы виртуальных методов разговор про «дорогое наследование» или «интерфейсы медленные» превращается в фольклор. Глубоко JVM разбирается в статье JVM изнутри, здесь берём ровно тот минимум, без которого объектная модель непонятна.
Предполагается, что базовый синтаксис и разница между примитивами и ссылками уже знакомы — если нет, начните с Основ Java.
Что такое объект на самом деле
Java-объект — это блок памяти в куче, состоящий из служебного заголовка и полей.
Переменная типа Order не содержит объект: она содержит ссылку (на 64-битной HotSpot
это, как правило, 32-битный сжатый указатель — compressed oop).
Три следствия, которые полностью определяют стиль кода на 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 даёт две вещи одновременно: наследование интерфейса (подтипизацию)
и наследование реализации (переиспользование кода). Первое полезно почти всегда,
второе — источник большинства проблем.
Механически всё выглядит так.
Важные выводы из этой схемы:
- Все нестатические, не-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 + "]"; }
}
Три практических предупреждения:
- Наследование ломает симметричность
equals. ЕслиPointсравнивается черезinstanceof, аColorPoint extends Pointдобавляет поле цвета, тоpoint.equals(colorPoint)вернётtrue, а обратное —false. Общий вывод Effective Java (item 10): расширить конкретный класс новым «значимым» полем, сохранив контракт, невозможно. Отсюда либоfinal-классы, либо композиция. - Изменяемые поля в ключе
HashMap— потерянные объекты. Положили объект, изменили поле, участвующее вhashCode— иmap.get(key)больше его не найдёт, потому что ищет в другой корзине. 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 внутри анонимного класса ссылается на сам анонимный объект,
внутри лямбды — на объемлющий экземпляр. Лямбда не создаёт новую область
именования — это не «анонимный класс покороче», а другой механизм.
Типичные грабли: сводка
- Вызов переопределяемого метода из конструктора — наследник видит незаполненные
поля. Лечится
final/privateи-Xlint:this-escape. equalsбезhashCode(и наоборот) — объект «теряется» вHashMap. Генерируйте IDE или используйте record.equals(MyType other)вместоequals(Object o)— тихая перегрузка.@Override.- Изменяемое состояние в ключе коллекции — объект не находится после мутации.
- Массив внутри record —
equalsсравнивает ссылки. - Возврат внутренней коллекции наружу —
return items;вместоreturn List.copyOf(items);даёт вызывающему право менять ваше состояние. protected-поля — это публичный API, который вы больше никогда не поменяете. Используйтеprotected-методы, не поля.- Нестатический вложенный класс в долгоживущей структуре — утечка объемлющего объекта.
Optionalв качестве поля или параметра — это тип возвращаемого значения, не универсальный контейнер. Подробнее в Идиоматике и обработке ошибок.- Глубокие иерархии наследования — если у вас 4+ уровня
extends, почти наверняка нужна композиция. clone()— сломанный по дизайну механизм (поверхностное копирование,Cloneableбез методов). Используйте копирующий конструктор или статическую фабрику.finalize()— удалён из языка. Для освобождения ресурсов —try-with-resourcesиAutoCloseable, при крайней необходимости —Cleaner.
Честно про место Java в этой нише
Где объектная модель Java выигрывает:
- Стабильность контрактов. Обратная совместимость на десятилетия: код 2005 года чаще всего компилируется и работает. Для больших долгоживущих систем это главная ценность, важнее любой синтаксической элегантности.
- Инструменты. Рефакторинг «извлечь интерфейс», «поднять метод», «заменить наследование делегированием» в IDE работает надёжно именно из-за номинальной системы типов и явных иерархий.
- Предсказуемость чтения. Читая незнакомый Java-код, вы почти всегда понимаете, где определено поведение. Это выигрыш в командах, где сменяются десятки людей.
Где проигрывает:
- Церемонность. Даже с records и
varJava многословнее 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, порядок инициализации и вложенные классы — три места, где сосредоточена большая часть багов объектной модели.
Источники
- The Java Language Specification, SE 21 — первоисточник по правилам наследования, разрешения перегрузок и sealed-типов.
- Java Tutorials: Classes and Objects — официальный вводный материал.
- Joshua Bloch, «Effective Java», 3rd ed. — главы 4 (классы и интерфейсы) и 3
(методы
Object) остаются лучшим текстом по теме: informit.com/store/effective-java-9780134685991. - JEP 395: Records, JEP 409: Sealed Classes, JEP 440: Record Patterns, JEP 441: Pattern Matching for switch.
- Brian Goetz, «Data Oriented Programming in Java» — как records и sealed меняют подход к моделированию.
- Java Object Layout (JOL) — посмотреть реальный размер и раскладку объектов.
- Aleksey Shipilëv, «Java Objects Inside Out» — подробнейший разбор того, из чего состоит объект на HotSpot.
Что дальше
Коллекции и дженерики: устройство, выбор структуры, вариантность —
разберём, как устроены ArrayList, HashMap и ArrayDeque изнутри, почему стирание
типов сделало Java-дженерики такими, какие они есть, и как читать ? extends и
? super, не заглядывая каждый раз в справочник.