Перейти к основному содержимому

Объектно-ориентированное программирование в Java

Разработчику Архитектору
Сначала — общие понятия (раздел 4 "Код")

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

Ниже — как это устроено в Java.

Теория и синтаксис Java

Понятие ООПКак выражено в Java
АДТ, классclass, все объекты наследуют Object
Инкапсуляцияprivate / protected / public, геттеры и сеттеры
Наследованиеextends (один класс), implements (интерфейсы)
Полиморфизм подтипов@Override, ссылки на супертип
Ad hoc-полиморфизмперегрузка методов
Параметрический полиморфизмgenerics (List<T>)
Сообщениявызовы методов; рефлексия — отдельный механизм

Определения без привязки к языку — раздел 4-08-oop.

Кратко для новичка:

  • Класс — файл .java с полями и методами; new создаёт объект в куче JVM.
  • private — поле или метод недоступны снаружи класса; public — часть API.
  • Наследованиеextends (один родитель); @Override — явное переопределение.
  • Интерфейс — контракт методов; класс implements несколько интерфейсов.
  • Полиморфизм — ссылка типа Animal на объект Dog; JVM вызывает метод Dog.

КЛАСС Кот поля: имя, возраст метод мяукнуть() КОНЕЦ

объект barsik := новый Кот(имя="Барсик", возраст=3) barsik.мяукнуть()


Разбор:
- Блок показывает модель ООП на псевдокоде: сначала определяется шаблон (`КЛАСС`), потом создается экземпляр (`объект`).
- `поля — имя, возраст` описывают состояние объекта, которое хранится в памяти для каждого экземпляра отдельно.
- `метод мяукнуть()` описывает поведение, доступное объектам этого типа.
- Строка с `barsik := новый Кот(...)` эквивалентна созданию объекта через `new` в Java.
- Вызов `barsik.мяукнуть()` демонстрирует, что поведение вызывается у конкретного экземпляра, а не у класса в целом.

---

## Объектно-ориентированное программирование в Java

В Java классы не изменяемы в runtime. Нельзя добавить метод или поле в существующий класс после компиляции. Всё строго, предсказуемо, но менее гибко.

1. Всё строго типизировано. Тип переменной фиксирован, проверка на этапе компиляции.
2. Приватность обеспечивается языком. Модификатор `private` запрещает доступ извне (кроме [рефлексии](/encyclopedia/5-languages/5-03-java/299)); [инкапсуляция](/encyclopedia/4-code-dev/4-08-oop/3) здесь закреплена синтаксисом, а не только соглашением команды.
3. Статическая типизация + одиночное наследование классов. Класс наследует один родитель (`extends`), но реализует много интерфейсов (`implements`).

<ExternalPlayEmbed example="about/archi-styler-play" title="Наследование и implements на диаграмме" minHeight={480} playProps={{ defaultLanguage: 'java', defaultPattern: 'factory', namespace: 'com.example.oop', title: 'Наследование и implements на диаграмме', subtitle: 'Шаблон Factory Method: связи Inherits / Implements и превью Java' }} />

Что обязательно знать:


<ExternalCodeEmbed example="java/java-503-18-001" title="Объектно-ориентированное программирование в Java" minHeight={720} />



Разбор:
- `Warrior` инкапсулирует состояние (`name`) и общую для всех объектов статистику (`static int count`).
- Конструктор `Warrior(String name)` инициализирует объект, а `this.name` снимает конфликт имен параметра и поля.
- `Knight extends Warrior` показывает одиночное наследование классов в Java и повторное использование базовой логики.
- `super(name)` обязан вызываться в конструкторе наследника, чтобы корректно инициализировать часть родителя.
- `Attackable` задает контракт поведения, а `implements Attackable, Healable` демонстрирует множественную реализацию интерфейсов.

Модификаторы доступа - важнейшая тема:
- `private` только внутри класса;
- нет модификатора - внутри пакета;
- `protected` - пакет+наследники;
- `public` - везде.
- поля всегда `private`, доступ через геттеры/сеттеры.

`static` — общий член класса (не экземпляра):
```java
class MathUtils {
public static final double PI = 3.14159; // константа

public static int square(int x) { // статический метод
return x * x;
}
}

// Вызов без создания объекта:
MathUtils.square(5);

Разбор:

  • MathUtils играет роль утилитарного класса со статическими членами.
  • public static final double PI — константа класса, единая для всех вызовов.
  • square(int x) вызывается через имя класса, потому что static не привязан к экземпляру.
  • MathUtils.square(5) иллюстрирует паттерн "чистой функции" без хранения состояния объекта.
  • Такой стиль подходит для вычислений и вспомогательных операций, где не нужен new.

Пример класса в Java

Код ITЗагрузка примера кода…

Разбор:

  • Класс Unit объединяет характеристики персонажа (поля) и игровые действия (методы) в одной сущности.
  • getDamage() вычисляет урон на основе текущих статов и уровня, что демонстрирует инкапсуляцию формулы.
  • Метод attack(Unit target) принимает другой объект того же типа и изменяет его состояние (target.health).
  • В main создаются два независимых экземпляра (warrior, mage) с разными параметрами.
  • Последовательные вызовы attack показывают взаимодействие объектов и изменение состояния во времени.

Ключевое слово class определяет новый класс. Модификатор доступа public делает класс доступным из других частей программы. Имя класса начинается с заглавной буквы по соглашению об именовании в Java.

Поля класса объявляются внутри тела класса. Каждое поле имеет тип данных и имя. Тип String используется для текстовых значений, тип int для целых чисел. Поля без модификатора доступа имеют пакетную видимость.

Метод getDamage возвращает вычисляемое значение урона. Ключевое слово public делает метод доступным извне класса. Ключевое слово return возвращает результат вычисления вызывающему коду.

Метод attack принимает объект класса Unit в качестве параметра. Метод выводит сообщение о нанесённом уроне и изменяет значение здоровья цели. Метод не возвращает значение, поэтому его тип указан как void.

Метод main является точкой входа в программу. Модификатор static позволяет вызывать метод без создания экземпляра класса. Параметр String[] args принимает аргументы командной строки.

Оператор new выделяет память для нового объекта и вызывает конструктор класса. Конструктор по умолчанию инициализирует поля начальными значениями. После создания объекта можно изменять значения его полей через точечную нотацию.

При запуске программы выполняется метод main. В методе создаются два объекта, настраиваются их характеристики и вызываются методы атаки для демонстрации взаимодействия между объектами.


Объектно-ориентированное программирование

Объектно-ориентированное программирование (ООП) — это фундаментальная парадигма проектирования программного обеспечения, в основе которой лежит идея моделирования предметной области через взаимодействие объектов. В отличие от процедурного подхода, где основное внимание уделяется последовательности операций, ООП ставит во главу угла структуру данных и поведение, объединённые в единую сущность — объект.

Java — один из наиболее ярких представителей строго объектно-ориентированных языков. Практически всё в Java является объектом — исключения, потоки ввода-вывода, коллекции, пользовательские сущности — все они инстанцируются из классов, наследуют поведение, взаимодействуют через интерфейсы. Даже примитивные типы имеют свои объектные обёртки (Integer, Boolean, Character и др.), что упрощает унификацию подходов к работе с данными.

В языке Java реализованы четыре ключевых принципа ООП — инкапсуляция, наследование, полиморфизм и абстракция. Эти принципы не существуют изолированно — они взаимно усиливают друг друга, образуя систему, позволяющую строить масштабируемые, поддерживаемые и гибкие архитектуры. Ниже каждый из принципов рассматривается как концепция, как технический механизм в Java, и как инструмент проектирования.

Интерактивная схема — класс и объект (псевдокод, подходит для любого ООП-языка). Полный разбор принципов: ООП в разделе "Код и разработка".

Play ITЗагрузка интерактивного демо…


Класс

Как мы помним из основ ООП, класс — это шаблон (чертёж), описывающий структуру (какие данные может хранить), поведение (какие действия может выполнять) и способ создания (как инициализируется).

Класс не занимает память сам по себе — он всего лишь описание.

Класс — это ссылочный тип, наследуемый от java.lang.Object.

java.lang.Object — корень всей иерархии типов в Java. Все классы неявно наследуются от Object. Это означает, что любой объект в Java обладает базовым набором методов, определённых в этом классе.

Методы Object (типовой список на собеседовании):

МетодНазначение
equals(Object)Логическое равенство (по умолчанию — сравнение ссылок)
hashCode()Хеш для HashMap/HashSet; контракт с equals
toString()Строковое представление (по умолчанию — класс @ hash)
getClass()Runtime-класс объекта (Class<?>)
clone()Поверхностная копия; требует Cloneable, редко используется
finalize()Устарел (deprecated); не полагайтесь на него — есть GC
wait() / notify() / notifyAll()Координация потоков на мониторе объекта
  • toString() — переопределяют для читаемого вывода и логов.
  • equals() / hashCode() — переопределяют вместе для классов-значений и ключей коллекций.
  • getClass() — для рефлексии; в equals часто используют getClass() != obj.getClass() вместо instanceof, чтобы сохранить симметрию при наследовании.
Object obj = "Hello";
System.out.println(obj.getClass().getName()); // java.lang.String
System.out.println(obj.toString()); // Hello

Переопределение toString(), equals() и hashCode() является стандартной практикой при создании собственных классов данных.


Обычный класс

Обычный класс (Concrete Class) это базовая единица построения программы. Класс может быть создан по умолчанию (если не указаны модификаторы доступа abstract или static) и его экземпляры могут создаваться с помощью оператора new.

  • Создание экземпляров: Возможно (new MyClass()).
  • Наследование: Может наследовать другой класс (один родитель) и реализовывать несколько интерфейсов.
  • Методы: Содержит как абстрактные методы (только сигнатуру), так и конкретные (с телом). Если есть хотя бы один абстрактный метод, класс должен быть объявлен как abstract.
  • Конструкторы: Обязательны для создания объектов.
  • Статические члены: Могут содержать статические поля и методы, доступные без создания объекта.
  • Использование: Основной инструмент для инкапсуляции данных и поведения конкретных сущностей.

Выбирайте обычный класс, когда вы создаете конкретную сущность, которую нужно создавать множество раз, и она не должна быть абстракцией.


Абстрактный класс

Абстрактный класс (Abstract Class) это класс, который не может быть instantiated (не может быть создан напрямую через new). Он предназначен для определения общего шаблона (скелета) для подклассов.

  • Создание экземпляров: Невозможно. Создается только через наследование.
  • Наследование: Может наследовать только один класс (как и обычный класс). Может быть расширен несколькими классами.
  • Методы: Может содержать:
    • Конкретные методы (с телом).
    • Абстрактные методы (без тела, требуют реализации в потомках).
    • Статические и финальные методы.
  • Конструкторы: Существуют, но вызываются только при создании экземпляра класса-потомка.
  • Поля: Могут быть любого типа (public, protected, private, static, final).
  • Назначение: Использование, когда нужно разделить общую логику (код) и вариативную часть (абстрактные методы) у группы связанных объектов.

Выбирайте abstract class, если у вас есть группа тесно связанных классов, которые делятся общим состоянием (поля) и общей реализацией некоторых методов, но требуют обязательной реализации определенных действий. Также используйте, если нужна сложная иерархия наследования.


Статический класс

В Java понятие "статический класс" относится к вложенному статическому классу (Nested Static Class). Сам по себе корневой класс не может быть объявлен static, это применимо только к классам внутри других классов.

Синтаксис:

class Outer {
public static class Inner { }
}
  • Зависимость от внешнего класса: Отсутствует. Вложенный статический класс не имеет скрытой ссылки на экземпляр внешнего класса (Outer.this).
  • Доступ к членам: Может обращаться только к статическим членам внешнего класса. Для доступа к экземплярам внешнего класса требуется явное создание объекта new Outer().
  • Создание экземпляров: new Outer.Inner(). Не требует предварительного создания объекта внешнего класса.
  • Разница с обычным вложенным классом: Обычный вложенный класс (inner class) привязан к экземпляру внешнего класса и может получать доступ ко всем его полям (включая приватные). Статический вложенный класс работает изолированно, как отдельная сущность, просто находящаяся в пространстве имен внешнего класса.
  • Применение: Часто используется для реализации вспомогательных логических групп, хелперов или констант, которые логически связаны с внешним классом, но не зависят от его состояния.

Важное уточнение: В C# существует понятие static class (класс, который нельзя инстанцировать и содержит только статические члены). В Java такой конструкции нет. То, что в C# является static class, в Java реализуется либо как обычный класс со статическими методами (паттерн Utility class), либо как вложенный статический класс, если он нужен внутри другого класса.

Выбирайте статический вложенный класс, когда класс имеет смысл только в контексте внешнего класса, но не зависит от его экземпляра (например, узлы дерева внутри класса Дерева, или хелпер-классы).


Интерфейс

Тип, определяющий контракт (набор методов), который обязана реализовать реализующая его сущность. До версии Java 8 интерфейс мог содержать только абстрактные методы и константы. Начиная с Java 8, появились возможности для реализации методов по умолчанию.

  • Создание экземпляров: Невозможно напрямую. Реализуется классом или другим интерфейсом.
  • Наследование: Класс может реализовать несколько интерфейсов. Интерфейс может наследовать несколько других интерфейсов.
  • Методы:
    • public abstract: Обязательная реализация (по умолчанию публичные и абстрактные).
    • default: Имеет тело, предоставляет стандартную реализацию (добавлено в Java 8).
    • static: Имеет тело, вызывается через имя интерфейса (добавлено в Java 8).
    • private: Имеет тело, используется внутри интерфейса для переиспользования кода (добавлено в Java 9).
  • Поля: Все поля являются public static final (константами).
  • Конструкторы: Отсутствуют.
  • Назначение: Определение поведения, которое должно быть у разных, возможно несвязанных иерархически, классов. Используется для полиморфизма и разделения ответственности (SOLID принцип Interface Segregation).

Выбирайте interface, если вам нужно определить поведение, которое будет использоваться разными, несвязанными типами объектов (например, Runnable, Comparable). Это обеспечивает максимальную гибкость множественной реализации.


Сравнение классов

ХарактеристикаОбычный классАбстрактный классСтатический вложенный классИнтерфейс
ИнстанцированиеДа (new)НетДа (new Outer.Inner())Нет
Наследование (класс)Один родительОдин родительОдин родитель (внешний)Нет (реализует интерфейсы)
Реализация интерфейсовНесколькоНесколькоНесколькоНесколько
Абстрактные методыДопустимы (только в абстр. классе)ДопустимыДопустимы (если класс не final)Обязательны (кроме default/static)
Конкретные методыДаДаДаДа (default, static, private)
ПоляЛюбой модификаторЛюбой модификаторЛюбой модификаторТолько public static final
КонструкторыЕстьЕстьЕстьНет
Связь с родителемНет (для статического)Нет (для статического)Нет (скрытая ссылка отсутствует)Нет
Основная цельСоздание объектовШаблон + общий кодГруппировка в пространстве именКонтракты / Поведение

Объект

Объект (или экземпляр) — это конкретный представитель класса, созданный в памяти с помощью оператора new.

Синтаксис создания: Тип имяПеременной = new Конструктор()

Car myCar = new Car();

Здесь:

  • myCar — переменная-ссылка, хранящая адрес объекта в куче.
  • new Car() — выделение памяти и вызов конструктора.
myCar.setModel("Tesla");
myCar.setYear(2023);
myCar.startEngine(); // Двигатель запущен!

Класс определяет тип, а объект является экземпляром данного типа.


Конструктор

Конструктор – это специальный метод, который вызывается при создании экземпляра класса. Он используется для инициализации состояния объекта.

Пример класса с конструктором:

Код ITЗагрузка примера кода…

Можно иметь несколько конструкторов с разными параметрами — это называется перегрузкой конструкторов.


Атрибуты

Атрибуты – это свойства и поля, которые описывают состояние объекта.

String brand; // поле
int year;
private String model; // поле с ограниченным доступом

Поле

Поле — это переменная, объявленная внутри класса. Хранит состояние объекта.

public class Person {
public String name; // открытое поле
private int age; // приватное поле
}

Свойство (JavaBean convention)

В Java нет встроенного синтаксиса свойств, как в C#. Вместо этого используются методы доступа (геттеры и сеттеры), следующие соглашению JavaBeans.

  • Геттер — метод для чтения значения поля.
  • Сеттер — метод для записи значения поля, часто с валидацией.

Код ITЗагрузка примера кода…

Использование:

Person person = new Person();
person.setName("Alice");
System.out.println(person.getName());

Такой подход обеспечивает инкапсуляцию: внутреннее представление данных скрыто, а доступ контролируется логикой методов.


Метод

Метод — это функция, принадлежащая классу. Описывает, что объект умеет делать.

public void drive() {
System.out.println("Машина едет...");
}

public int calculateAge(int birthYear) {
return java.time.Year.now().getValue() - birthYear;
}

Методы могут принимать переменное количество аргументов (varargs):

public void printNames(String... names) {
for (String name : names) {
System.out.println(name);
}
}
// Вызов:
printNames("Alice", "Bob", "Charlie");
printNames(); // OK — пустой массив

this

Ключевое слово this — это ссылка на текущий объект.

public class Person {
private String name;

public Person(String name) {
this.name = name; // this.name — поле, name — параметр
}

public void printInfo() {
System.out.println("Имя: " + this.name);
}
}

this также используется для вызова другого конструктора того же класса:

public class Person {
private String name;
private int age;

public Person() {
this("Unknown", 0); // вызов другого конструктора
}

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

Вызов this() должен быть первой строкой в конструкторе.


Порядок объявления

В Java порядок объявления полей, методов и конструкторов внутри класса не влияет на компиляцию. Компилятор анализирует весь класс целиком. Однако для читаемости принято следовать соглашениям:

  1. Константы (static final)
  2. Поля экземпляра
  3. Конструкторы
  4. Методы
  5. Вложенные классы/интерфейсы Также часто группируют члены по видимости — сначала public, затем protected, package-private, private.

Основы ООП в Java

Давайте рассмотрим пример с реализацией принципов ООП в Java:

Код ITЗагрузка примера кода…

Здесь мы видим реализацию концепций:

  1. Интерфейс (IVoice) — содержит только сигнатуру метода. Описывает, что объект умеет делать, без реализации.
  2. Абстрактный класс (Animal) — реализует интерфейс, но помечен как abstract. Позволяет хранить общие данные (name), конструктор и абстрактный метод (move()). Экземпляр создать нельзя: new Animal() вызовет ошибку компиляции.
  3. Наследование (extends)Cat и Dog получают поля и конструктор Animal через super(name). Каждый наследник обязан реализовать все абстрактные методы и методы интерфейса.
  4. Полиморфизм — в main переменные объявлены как Animal, но хранят объекты Cat и Dog. Вызов методов определяется во время выполнения (dynamic dispatch), что позволяет работать с разными типами через общий интерфейс.

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

  • IVoice определяет обязанности для объектов;
  • Animal реализует интерфейс и выступает верхушкой иерархии;
  • Cat и Dog - наследники класса Animal;
  • Именно на основе Cat или Dog создаются объекты в памяти.

Когда текущий объект будет создан, у него появится свой набор данных. Это контекст.


Контекст текущего объекта

Контекст текущего объекта — это доступ к полям и методам конкретного объекта через ссылку this. Каждый объект хранит свои данные отдельно.

Код ITЗагрузка примера кода…


Внешний и вложенный класс

Экземпляр внешнего класса — когда вложенный класс создаётся внутри объекта внешнего класса и автоматически получает ссылку на него.

Код ITЗагрузка примера кода…

Всё это в комплексе позволяет выстраивать любые системы.


Наследование и вложенность

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

Код ITЗагрузка примера кода…


1. Наследование (extends и implements)

  • Абстрактный класс (Vehicle): Используется для передачи общего состояния (brand) и общей логики (startEngine). Класс может наследовать только один такой класс. Это связь "является" (Is-A): Машина является Транспортным средством.
  • Интерфейс (Flyable): Используется для добавления дополнительного поведения, которое может быть у несвязанных объектов. Класс может реализовать несколько интерфейсов. Это способность (Can-Do): Машина может летать (в данном примере).
  • Обычный класс (Car): Конечный продукт, объединяющий оба механизма.

2. Вложенность (Inner и Static Nested)

Это критически важное различие в контексте связности с внешним классом.

ХарактеристикаОбычный вложенный класс (InnerClass)Статический вложенный класс (StaticNestedClass)
СозданиеТребует экземпляр внешнего класса: outer.new Inner()Не требует: new Outer.StaticNested()
Доступ к даннымВидит все поля и методы внешнего класса (включая private).Видит только статические поля и методы внешнего класса.
Скрытая ссылкаИмеет скрытую ссылку на внешний объект (Outer.this).Ссылки на внешний объект нет.
ПамятьКаждый экземпмер внутреннего класса хранит ссылку на экземпляр внешнего, что увеличивает потребление памяти.Не хранит ссылку, ведет себя как отдельный класс в пространстве имен.
ИспользованиеКогда логика тесно связана с состоянием внешнего объекта (например, итератор внутри коллекции).Когда класс является вспомогательным, хелпером или константой, логически связанной с внешним классом, но независимой от его состояния.

3. Почему нельзя сделать static class на верхнем уровне?

В коде выше вы видите class Container, а внутри него static class. Если попытаться объявить корневой класс как static:

// ОШИБКА КОМПИЛЯЦИИ
static class TopLevel { ... }

Java не поддерживает статические классы на уровне пакета (top-level classes). Модификатор static применим только к членам другого класса (вложенным классам).

  • Если вам нужен класс, который содержит только статические методы (как Math или Collections), вы просто делаете конструктор private и все методы static в обычном классе. Это паттерн Utility Class.

Инкапсуляция

Интерактивная схема — инкапсуляция (псевдокод). Подробнее: Инкапсуляция.

Play ITЗагрузка интерактивного демо…

Инкапсуляция — это принцип, направленный на достижение двух целей:

  1. Ограничение прямого доступа к внутреннему состоянию объекта, чтобы предотвратить его некорректное изменение извне.
  2. Гарантия согласованности состояния объекта, то есть поддержание инвариантов — условий, которые должны оставаться истинными на протяжении всего жизненного цикла объекта.

Управление доступом к членам класса критически важно для инкапсуляции.

Без инкапсуляции:

public class BankAccount {
public double balance; // Прямо доступно!
}
...
var account = new BankAccount();
account.balance = -1000; // Ошибка логики!

С инкапсуляцией:

public class BankAccount {
private double balance;

public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}

public double getBalance() {
return balance;
}
}

В Java инкапсуляция реализуется на уровне класса с использованием модификаторов доступа:

  • private,
  • protected,
  • public,
  • package-private (отсутствие модификатора).

Шаблоны следующие:

<модификатор класса> class <имя класса>
<модификатор доступа> <тип> <имя поля>
<модификатор доступа> <имя класса>(<параметры>)
<модификатор доступа> <тип> get<ИмяСвойства>()
<модификатор доступа> void set<ИмяСвойства>(<тип> <параметр>)
<тип> <имя метода>() // package-private (модификатор отсутствует)

protected <тип> <имя метода>()
private <тип> <имя метода>()
public <тип> <имя метода>()

Соответствия спецификации Java:

  • package-private не имеет ключевого слова; видимость определяется на уровне пакета компиляции.
  • protected обеспечивает доступ внутри пакета + в классах-наследниках (в том числе за пределами пакета).
  • private ограничивает доступ рамками одного класса; внешнее взаимодействие реализуется через public/protected аксессоры и мутаторы.
  • Конвенция именования геттеров/сеттеров соответствует JavaBeans: get<ИмяСвойства>() / set<ИмяСвойства>(<тип> значение).
  • Валидация в сеттере является стандартным механизмом сохранения инвариантов объекта при инкапсуляции состояния.

private

private — доступ только внутри класса. Наиболее строгий уровень.

public class Logger {
private String logFile = "app.log";

private void writeToFile(String message) {
// логика записи
}

public void log(String message) {
writeToFile("[" + java.time.LocalDateTime.now() + "] " + message);
}
}

public

public — доступ отовсюду.

public class Calculator {
public int add(int a, int b) {
return a + b;
}
}

protected

protected — доступ внутри пакета и в наследниках (даже если они в другом пакете).

Код ITЗагрузка примера кода…


package-private (default)

Отсутствие модификатора означает доступ только в пределах текущего пакета. Используется для вспомогательных классов, не предназначенных для внешнего использования.

class DatabaseHelper {
String connectionString = "jdbc:mysql://localhost";
}

static

Ключевое слово static означает, что член принадлежит классу, а не экземпляру.


Статические поля

Хранят общее состояние для всех экземпляров.

public class Counter {
private static int totalCount = 0;
private int instanceId;

public Counter() {
instanceId = ++totalCount;
}

public static int getTotalCount() {
return totalCount;
}
}

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

Не требуют создания экземпляра. Не могут обращаться к нестатическим полям напрямую.

public class MathUtils {
public static double square(double x) {
return x * x;
}
}
double result = MathUtils.square(5);

Статические блоки инициализации

Выполняются один раз при загрузке класса.

Код ITЗагрузка примера кода…


Статические вложенные классы

Вложенный класс, помеченный static, не имеет ссылки на внешний класс.

public class Outer {
private static String staticField = "Static";

public static class StaticNested {
public void print() {
System.out.println(staticField); // Доступ к статическим полям внешнего класса
}
}
}

Наиболее строгий уровень — private, и именно он является рекомендованным по умолчанию для всех полей экземпляра. Дело в том, что открытое (public) поле превращает внутреннее состояние объекта в часть его публичного контракта. Любое изменение типа, семантики или логики такого поля становится потенциально ломающим изменением для всех клиентов класса.

Рассмотрим следующий пример:

Код ITЗагрузка примера кода…

Здесь поля accountNumber и balance объявлены как private, а доступ к ним контролируется через конструктор и методы. Обратите внимание: отсутствует сеттер для balance. Это не упущение — это осознанное проектировочное решение. Баланс не должен устанавливаться "произвольно" извне. Его изменение допустимо только через семантически осмысленные операции — deposit() и withdraw(), каждая из которых проверяет корректность и сохраняет инварианты (например, баланс не может стать отрицательным при снятии, если явно не разрешено овердрафт).

Метод getBalance() возвращает значение, а не ссылку на внутренний BigDecimal. Это важно, потому что BigDecimal хотя и неизменяем, но в случае изменяемых объектов (например, List, Date, StringBuilder) возврат ссылки на приватное поле может нарушить инкапсуляцию: внешний код получит возможность модифицировать внутреннее состояние напрямую. В таких случаях применяют защитное копирование (new ArrayList<>(internalList), date.clone() — осторожно, clone() проблематичен — или Instant вместо Date).

Инкапсуляция позволяет проводить рефакторинг внутренней реализации без изменения публичного API. Например, можно заменить BigDecimal на long, хранящий сумму в копейках, или добавить логирование всех операций — при условии, что сигнатуры публичных методов остаются неизменными, клиентский код продолжит работать.

Механическое добавление сеттеров ко всем полям превращает класс в анемичную модель — структуру данных без поведения, что противоречит духу ООП. Настоящая инкапсуляция проявляется тогда, когда объект отвечает за своё поведение и защищает свою целостность.


Наследование

Интерактивная схема — наследование (псевдокод). Подробнее: Наследование.

Play ITЗагрузка интерактивного демо…

Наследование в Java — это механизм, позволяющий одному классу (подклассу, или наследнику) унаследовать поля и методы другого класса (суперкласса, или родителя). Это достигается с помощью ключевого слова extends.

Таким образом, имея один класс:

class Parent {
public void parentMethod {
//Это метод родителя
}
}

...мы можем создать другой класс, на основе Parent. Новый класс будет называться подкласс, получит все свойства и методы родительского класса (суперкласса), что обеспечит повторное использование кода и упростит поддержку. Подкласс сможет расширять или переопределять поведение суперкласса, добавлять новые поля и методы. Но родитель в Java может быть только один:

class Child extends Parent {
@Override
public void parentMethod {
System.out.println("Мы переопределили метод родителя!");
}

public void childMethod {
System.out.println("А это метод наследника.");
}
}

super

Ключевое слово super — ссылка на суперкласс.

  1. Вызов конструктора суперкласса:
public class Animal {
public Animal(String name) {
this.name = name;
}
}

public class Dog extends Animal {
public Dog(String name) {
super(name); // Обязательный вызов, если нет конструктора без аргументов
}
}
  1. Вызов переопределённого метода суперкласса:
public class Dog extends Animal {
@Override
public void makeSound() {
super.makeSound(); // Сначала звук животного
System.out.println("Гав!"); // Потом специфичный звук
}
}

Давайте посмотрим на основные шаблоны:

  1. class <имя подкласса> extends <имя суперкласса> — базовый синтаксис одиночного наследования.
  2. class <имя класса> implements ... — шаблон множественного наследования поведения через интерфейсы (альтернатива наследованию классов).
  3. @Override ... — строгий шаблон переопределения метода. Аннотация обязательна для контроля соблюдения контракта на этапе компиляции.
  4. super(...) и super. — шаблоны явного обращения к конструктору и членам родительского класса. Критичны для цепочки инициализации.
  5. <тип> ... = new <имя подкласса>(...) — демонстрация полиморфизма: ссылка типа суперкласса указывает на объект подкласса.
  6. instanceof и isAssignableFrom — шаблоны безопасной проверки типов перед приведением (кастингом), чтобы избежать ClassCastException.
  7. final class / final method — шаблоны запрета наследования или переопределения (нарушение расширяемости ради безопасности/производительности).
  8. abstract class / abstract method — шаблоны определения неполного контракта, требующего реализации в наследниках.

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

  1. Повторное использование кода — избегание дублирования общей функциональности.
  2. Моделирование "является"-отношения (is-a relationship) — например, "собака является животным", "кредитный счёт является банковским счётом".

Важно понимать: наследование — это не инструмент для "доступа" к полям другого класса. Это контракт на расширение поведения. Подкласс не просто получает доступ к членам суперкласса — он обязуется соблюдать его контракт и может его уточнять, но не нарушать.


Правила наследования в Java

Java поддерживает одиночное наследование классов: класс может напрямую наследоваться только от одного суперкласса. Это ограничение введено сознательно — во избежание "алмазной проблемы" (diamond problem), характерной для языков с множественным наследованием классов, когда неясно, какую реализацию унаследованного метода следует использовать.

Код ITЗагрузка примера кода…

Здесь Dog наследует Animal. Попытка унаследовать ещё один класс приведёт к ошибке компиляции.

Однако множественное наследование поведения возможно через интерфейсы. Начиная с Java 8, интерфейсы могут содержать методы с реализацией по умолчанию (default), что позволяет классу "наследовать" поведение от нескольких источников.

Код ITЗагрузка примера кода…

Это демонстрирует множественное наследование поведения, но не состояния (поля в интерфейсах — либо public static final, либо отсутствуют).

Подкласс наследует все public и protected члены суперкласса (поля, методы, вложенные классы), а также package-private члены — но только если находится в том же пакете.

// Файл: animals/Animal.java
package animals;

public class Animal {
public String species = "Unknown";
protected int age = 0;
String habitat = "Wild"; // package-private
private String secret = "Only for me"; // не наследуется напрямую

public void introduce() {
System.out.println("I am a " + species);
}
}
// Файл: animals/Dog.java
package animals;

public class Dog extends Animal {
public void showDetails() {
System.out.println("Species: " + species); // OK — public
System.out.println("Age: " + age); // OK — protected
System.out.println("Habitat: " + habitat); // OK — package-private и тот же пакет
// System.out.println(secret); // Ошибка — private недоступен
}
}

Если бы Dog находился в другом пакете, доступ к habitat был бы запрещён.

Конструкторы не наследуются. При создании экземпляра подкласса неявно или явно вызывается конструктор суперкласса (через super()).

Код ITЗагрузка примера кода…

Если в суперклассе нет конструктора без параметров, подкласс обязан вызвать super(...) явно.

Если конструктор подкласса не вызывает super() явно, компилятор автоматически вставляет вызов super() без аргументов.

class Parent {
public Parent() {
System.out.println("Parent default constructor");
}
}

class Child extends Parent {
public Child() {
// Компилятор добавляет: super();
System.out.println("Child constructor");
}
}

Однако если в Parent нет конструктора без параметров, такой код не скомпилируется.


Переопределение методов и контракт Лисков

Центральный механизм наследования — переопределение (overriding). Подкласс может предоставить собственную реализацию метода, уже объявленного в суперклассе, при соблюдении следующих условий:

  • Сигнатура метода (имя, список параметров, типы параметров) должна быть идентична.
  • Возвращаемый тип должен быть либо таким же, либо ковариантным (подтипом исходного возвращаемого типа — например, ObjectString).
  • Уровень доступа не может быть более строгим (нельзя переопределить public метод как protected).
  • Исключения, объявленные в throws, не могут быть шире (можно выбрасывать подтипы, но не надтипы).

virtual, override, final

В Java все нестатические, не-private и не-final методы являются виртуальными по умолчанию.

  • @Override — аннотация, указывающая на намерение переопределить метод. Помогает избежать ошибок при изменении сигнатуры в родителе.
  • final — запрещает переопределение метода или наследование класса.
public final class ImmutableClass {
// От этого класса нельзя наследоваться
}

public class Parent {
public final void cannotOverride() {
// Этот метод нельзя переопределить
}
}

Корректное определение выглядит так:

class Animal {
public Object makeSound() {
return "Some generic sound";
}
}

class Dog extends Animal {
@Override
public String makeSound() { // ковариантный возвращаемый тип: String ⊂ Object
return "Woof!";
}
}

Здесь:

  • сигнатура makeSound() совпадает;
  • возвращаемый тип String — подтип Object;
  • уровень доступа остался public.

Нарушение сигнатуры — перегрузка, а не переопределение. Пример:

class Bird {
public void fly(int altitude) {
System.out.println("Flying at " + altitude + " meters");
}
}

class Eagle extends Bird {
public void fly(double altitude) { // другой тип параметра → перегрузка
System.out.println("Soaring at " + altitude + " meters");
}
}

Метод fly(double) не переопределяет fly(int), потому что сигнатуры различаются. Это перегрузка, а не переопределение.

Уменьшение уровня доступа — ошибка компиляции. Пример:

class Vehicle {
public void start() {
System.out.println("Engine started");
}
}

class Car extends Vehicle {
// ❌ ОШИБКА КОМПИЛЯЦИИ
protected void start() { // нельзя сузить доступ с public до protected
System.out.println("Car engine started");
}
}

Java запрещает делать переопределённый метод менее доступным, чем в суперклассе.

Расширение списка исключений — ошибка компиляции:

Код ITЗагрузка примера кода…

Переопределённый метод не может объявлять проверяемые исключения, которые не являются подтипами исключений из суперкласса.

Вот как нужно сужать исключения:

Код ITЗагрузка примера кода…

Это допустимо: FileNotFoundException — подкласс IOException.

Переопределение с тем же возвращаемым типом:

Код ITЗагрузка примера кода…

Сигнатура идентична, возвращаемый тип совпадает — всё корректно.

Аннотация @Override не обязательна, но настоятельно рекомендуется: она помогает компилятору проверить, действительно ли метод переопределяет родительский.

class Parent {
public void greet() {
System.out.println("Hello from Parent");
}
}

class Child extends Parent {
@Override
public void greet() {
System.out.println("Hello from Child");
}
}

Если по ошибке изменить имя или параметры, компилятор выдаст ошибку благодаря @Override.

Особое значение имеет принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP): объекты подкласса должны быть взаимозаменяемы с объектами суперкласса без изменения корректности программы. Это означает, что переопределённый метод не должен:

  • Ужесточать предусловия (например, требовать больше аргументов или более строгие проверки входных данных).
  • Ослаблять постусловия (например, возвращать меньше информации или в более узком формате).
  • Изменять семантику вызова (например, метод close() не должен начинать подключение к ресурсу).

Нарушение LSP приводит к хрупкому коду, где полиморфизм работает непредсказуемо. Пример анти-паттерна:

Код ITЗагрузка примера кода…

Клиентский код, рассчитывающий на поведение Rectangle, может сломаться:

Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
System.out.println(r.getArea()); // ожидаем 20, получаем 16

Таким образом, Square не должен наследоваться от Rectangle — несмотря на кажущееся "является"-отношение в реальном мире, в программной модели это нарушает контракт. Корректнее использовать композицию или вынести общее в абстрактный класс/интерфейс с другим именем (например, Shape с методом getArea()).


Конструкторы и цепочка инициализации

При создании объекта подкласса сначала вызывается конструктор суперкласса — неявно (super()) или явно (super(args)). Это гарантирует, что базовая часть объекта инициализирована до специфичной. Если суперкласс не имеет конструктора без параметров, подкласс обязан явно вызвать один из доступных конструкторов суперкласса в первой строке своего конструктора.

Код ITЗагрузка примера кода…

Невыполнение этого требования приведёт к ошибке компиляции.


Когда использовать наследование — и когда избегать

Наследование оправдано, когда:

  • Существует чёткое is-a отношение.
  • Подкласс действительно расширяет или специализирует поведение, не нарушая контракт.
  • Повторное использование кода действительно необходимо, и альтернативы (композиция, делегирование) привели бы к большему дублированию.

Во всех остальных случаях предпочтительна композиция ("has-a"): класс содержит экземпляр другого класса и делегирует ему часть работы. Композиция гибче — она не привязывает класс к иерархии, позволяет менять поведение во время выполнения, и не нарушает инкапсуляции так легко, как наследование.

Представим, что у нас есть разные типы двигателей (бензиновый, электрический), и автомобиль может использовать любой из них. Вместо того чтобы создавать иерархию Car extends GasEngine или ElectricCar extends ElectricEngine, мы используем композицию.

Код ITЗагрузка примера кода…

Вот как будет выглядеть использование:

public class Main {
public static void main(String[] args) {
Car gasCar = new Car(new GasEngine());
gasCar.start(); // Запуск бензинового двигателя

Car electricCar = new Car(new ElectricEngine());
electricCar.start(); // Запуск электродвигателя

// Можно даже поменять двигатель "на лету"
gasCar.setEngine(new ElectricEngine());
gasCar.start(); // Теперь запускается электродвигатель
}
}

Почему это лучше наследования?

  • Гибкость: поведение можно изменить без создания новых подклассов.
  • Инкапсуляция сохраняется: Car не зависит от внутренней реализации Engine.
  • Нет жёсткой привязки к иерархии — не нужно плодить классы вроде GasCar, HybridCar, ElectricSUV и т.д.
  • Легко тестируется: можно подставить мок-объект Engine в тестах.
  • Соблюдается принцип "Favor composition over inheritance" из SOLID.

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


Полиморфизм

Интерактивная схема — полиморфизм (псевдокод). Подробнее: Полиморфизм.

Play ITЗагрузка интерактивного демо…

Полиморфизм позволяет объектам разных классов обрабатываться как объекты одного типа, выполняя "свою" версию метода.

Полиморфизм — это способность сущности в программе принимать множество форм. В контексте ООП он проявляется в том, что один и тот же интерфейс (в широком смысле — сигнатура метода, ссылка на суперкласс или интерфейс) может использоваться для обращения к объектам разных типов, при этом конкретное поведение определяется типом фактического объекта, а не типом ссылки.

В Java реализованы два вида полиморфизма:

  1. Статический полиморфизм (перегрузка методов — overloading)
  2. Динамический полиморфизм (переопределение методов — overriding)

Эти два механизма часто путают, хотя они принципиально различаются уровнем связывания (binding), временем разрешения и целями применения.


Статический полиморфизм — перегрузка (overloading)

public class Printer {
public void print(int i) { System.out.println(i); }
public void print(String s) { System.out.println(s); }
public void print(double d) { System.out.println(d); }
}

Перегрузка — это наличие в одном классе нескольких методов с одинаковыми именами, но разными списками параметров (по количеству, типу или порядку). Возвращаемый тип при этом не участвует в разрешении перегрузки и не может служить единственным отличием.

Пример корректной перегрузки:

Код ITЗагрузка примера кода…

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

  • Точном совпадении типов аргументов.
  • Автоматическом приведении (например, intlong, floatdouble).
  • Упаковке и распаковке примитивов (int и Integer).
  • Вариадических аргументах (T...) — как крайний вариант.

Важно: при перегрузке не рассматривается фактический тип объекта во время выполнения. Решение принимается по объявленному типу переменных и литералов.

Object obj1 = "hello";
Object obj2 = "world";

Calculator calc = new Calculator();
// calc.add(obj1, obj2); ← ошибка компиляции: нет метода add(Object, Object)
// Даже если obj1 и obj2 — строки, компилятор видит только Object

Перегрузка — это удобство для API: позволяет использовать одно логическое имя для семантически схожих операций над разными типами. Однако чрезмерное увлечение перегрузкой (особенно с автоупаковкой и расширяющими приведениями) может привести к неочевидному выбору метода и ошибкам. Например:

public void process(Integer i) { System.out.println("Integer"); }
public void process(long l) { System.out.println("long"); }

process(5); // → "long", а не "Integer"!
// Потому что int → long (расширение) имеет приоритет над int → Integer (упаковка)

Такие ситуации требуют внимательного проектирования сигнатур и документирования.


Динамический полиморфизм

Динамический полиморфизм — сердцевина ООП. Он позволяет писать код, ориентированный на абстракции, а не на конкретные реализации. Это достигается за счёт:

  • Наследования (или реализации интерфейса).
  • Переопределения методов в подклассах.
  • Использования ссылок на суперкласс/интерфейс для работы с экземплярами подклассов.

Ключевой механизм — позднее (динамическое) связывание. Выбор конкретной реализации метода происходит во время выполнения, на основе фактического типа объекта, на который ссылается переменная.

Пример:

Animal animal = new Dog(); // ← ссылка типа Animal, объект типа Dog
animal.makeSound(); // → "Гав!"

С точки зрения компилятора: проверяется, что у типа Animal есть метод makeSound().
С точки зрения JVM: при вызове animal.makeSound() динамически определяется, что animal ссылается на экземпляр Dog, и вызывается переопределённая версия метода из Dog.

Этот механизм реализован через таблицу виртуальных методов (vtable), хранящуюся в каждом классе. При вызове нестатического (non-static), неприватного (non-private), незапечатанного (final) метода JVM обращается к vtable объекта и извлекает адрес нужной реализации.


Требования к переопределению (повторим для точности)

Для того чтобы метод в подклассе считался переопределением (а не просто методом с тем же именем), должны выполняться:

  • Совпадение имени и сигнатуры (включая generic-параметры после стирания — см. Type Erasure).
  • Совместимость возвращаемого типа (ковариантность допустима).
  • Уровень доступа не строже, чем у метода в суперклассе.
  • В throws — не могут указываться проверяемые (checked) исключения, не объявленные в суперклассе (можно подтипы, можно unchecked).

Аннотация @Override не обязательна, но крайне рекомендуется — она сообщает компилятору, что программист намеревался переопределить метод, и вызывает ошибку, если сигнатура не совпадает (например, опечатка в имени или несоответствие параметров). Это мощный инструмент защиты от регрессий при рефакторинге.


Полиморфизм и интерфейсы

Интерфейсы — наиболее чистый способ выразить полиморфизм. Класс может реализовать несколько интерфейсов, и ссылка на интерфейс может указывать на любой объект, реализующий его — независимо от иерархии наследования.

Код ITЗагрузка примера кода…

Здесь нет иерархии "круг — диаграмма", но есть общий контракт на поведение. Это позволяет строить гибкие, слабосвязанные системы, где зависимости направлены на абстракции, а не на реализации (принцип DIP — Dependency Inversion Principle из SOLID).


Ограничения полиморфизма в Java

  • static-методы не участвуют в полиморфизме — они связываются статически. Вызов Animal.staticMethod() всегда вызовет Animal.staticMethod(), даже если ссылка указывает на Dog.
  • private-методы не могут быть переопределены — они "невидимы" для подклассов. Если подкласс объявляет метод с тем же именем и сигнатурой, это новый метод, не связанный с родительским.
  • final-методы и final-классы запрещают переопределение — компилятор может выполнить раннее связывание (инлайнинг), что повышает производительность, но снижает гибкость.
  • Конструкторы не полиморфны — при создании new Dog() вызывается конструктор Dog, но внутри него — конструктор Animal. Однако в конструкторе Animal вызов this.makeSound() не вызовет переопределённую версию из Dog, если makeSound() не final или private — это приведёт к вызову переопределённого метода до полной инициализации объекта, что опасно. Такие вызовы стоит избегать.

Абстракция

Интерактивная схема — абстракция (псевдокод). Подробнее: Абстракция.

Play ITЗагрузка интерактивного демо…

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

В Java абстракция достигается двумя основными средствами:

  • Абстрактные классы (abstract class)
  • Интерфейсы (interface)

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


Абстрактные классы

Абстрактный класс — это класс, который не может быть инстанцирован напрямую и может содержать как реализованные, так и абстрактные методы (без тела, с ключевым словом abstract). Подклассы обязаны реализовать все абстрактные методы, если сами не объявлены abstract.

Код ITЗагрузка примера кода…

Здесь применён шаблон Шаблонный метод (Template Method): алгоритм generate() фиксирован, но его шаги делегированы подклассам. Абстрактный класс позволяет:

  • Делиться кодом реализации между подклассами (поля, конструкторы, вспомогательные методы).
  • Задавать частично реализованный шаблон поведения.
  • Обеспечивать инварианты: например, title инициализируется в конструкторе и не может быть null.

Когда использовать абстрактный класс:

  • Когда есть реальное повторное использование кода (не только сигнатур).
  • Когда подклассы действительно находятся в отношении is-a.
  • Когда нужен доступ к protected полям/методам в иерархии.
  • Когда требуется конструктор с параметрами для инициализации общего состояния.

abstract

Абстрактный класс не может быть инстанциирован. Может содержать абстрактные методы (без реализации), которые обязаны быть реализованы в подклассах.

Код ITЗагрузка примера кода…


Интерфейсы — контракты без реализации (и с ней — с Java 8+)

Изначально (до Java 8) интерфейс мог содержать только:

  • Объявления public abstract методов (ключевые слова можно опускать).
  • public static final константы.

С Java 8 появились:

  • default-методы — методы с реализацией по умолчанию, которые не обязаны переопределяться в реализующих классах.
  • static-методы — вспомогательные методы, привязанные к интерфейсу.

С Java 9 — private методы в интерфейсах (для рефакторинга дублирующегося кода внутри default-методов).

Пример:

public interface PaymentProcessor {
boolean processPayment(BigDecimal amount); // абстрактный метод

default boolean processRefund(BigDecimal amount) {
return processPayment(amount.negate());
}

static PaymentProcessor testProcessor() {
return amount -> amount.compareTo(BigDecimal.ZERO) > 0;
}
}

Другой пример - когда мы создаём рисуемый Drawable и непосредственно круг Circle:

public interface Drawable {
void draw();
}

Реализация:

public class Circle implements Drawable {
@Override
public void draw() {
System.out.println("Рисую круг");
}
}

Класс может реализовывать несколько интерфейсов.

public class SmartDevice implements Connectable, Updatable {
@Override
public void connect() { ... }

@Override
public void update() { ... }
}

Интерфейсы могут иметь методы с реализацией по умолчанию.

public interface Logger {
void log(String message);

default void logError(String message) {
log("ERROR: " + message);
}

static Logger createConsoleLogger() {
return System.out::println;
}
}

Это позволяет расширять интерфейсы без ломания существующих реализаций.

Интерфейсы предоставляют:

  • Множественную реализацию контрактов: класс может implements несколько интерфейсов.
  • Стабильный API: добавление default-метода в интерфейс не ломает существующие реализации (в отличие от добавления абстрактного метода в абстрактный класс).
  • Гибкость проектирования: зависимости можно строить на интерфейсах, а реализации подставлять через внедрение зависимостей (DI).

Однако интерфейсы:

  • Не могут содержать состояние экземпляра (только static final константы).
  • Не имеют конструкторов — инициализация должна происходить иначе.
  • default-методы не наследуются при конфликте имён (если два интерфейса имеют default-метод с одинаковой сигнатурой, реализующий класс обязан явно разрешить конфликт).

Выбор между интерфейсом и абстрактным классом

Общие определения, пример с птицами и умным домом — Абстракция в ООП. Ниже — как это устроено в Java.

КритерийИнтерфейсАбстрактный класс
Вопрос при проектированииЧто объект умеет (can-do)Чем объект является (is-a)
Ключевые словаimplements, можно несколькоextends, только один класс
Поля экземпляраНет; только static final константыprotected / private поля
Общий кодdefault и static методы (с Java 8)Любые методы, конструкторы
Расширение APIНовый default обычно не ломает старый кодНовый abstract метод ломает наследников без реализации
Модификаторы членовФактически publicpublic, protected, private
КонструкторНетЕсть

Типичная связка в библиотеках:

  • интерфейс Repositoryконтракт для клиентского кода;
  • абстрактный класс AbstractRepository — общая реализация, которую не копируют в каждом наследнике.

См. также зависимости и DI, интерфейсы в Java.

Код ITЗагрузка примера кода…

Такой подход даёт гибкость — клиенты зависят от Repository, реализации могут быть InMemory, Jdbc, Mongo — и при этом InMemory реализации получают готовый каркас бесплатно.


Эволюция абстракции — запечатанные классы (sealed classes) — Java 17+

Начиная с Java 17, появился механизм запечатанных классов — способ явно ограничить, какие классы могут наследоваться от данного. Это дополняет абстракцию — теперь можно выразить ограниченную иерархию (например, "тип выражения в DSL может быть только Literal, BinaryOp или FunctionCall").

Код ITЗагрузка примера кода…

Преимущества:

  • Компилятор может проверить исчерпаемость при использовании instanceof или switch (особенно с pattern matching).
  • Чёткое выражение замысла проектировщика: "другие реализации не предусмотрены".
  • Повышение безопасности: нельзя внедрить стороннюю реализацию в закрытую доменную модель.

Это дополнение — инструмент для случаев, когда иерархия по смыслу конечна и контролируема.


Объединение принципов

Четыре принципа ООП — взаимосвязанные слои архитектуры:

  • Абстракция задаёт границы модели — что важно, а что можно скрыть.
  • Инкапсуляция защищает внутреннее состояние и гарантирует, что абстракция остаётся согласованной.
  • Наследование (или реализация интерфейсов) позволяет строить иерархии абстракций и повторно использовать код.
  • Полиморфизм позволяет писать код, ориентированный на абстракции, а не на конкретики — повышая гибкость и тестируемость.

Пример синтеза:

Код ITЗагрузка примера кода…

Клиентский код:

Logger logger = new FileLogger(10, new PrintWriter("app.log"));
logger.log(LogLevel.INFO, "Система запущена"); // ← полиморфный вызов
// Можно легко заменить на DatabaseLogger, NetworkLogger — без изменения клиентского кода

Здесь:

  • Logger — абстракция.
  • BufferedLogger — инкапсулирует буфер и алгоритм сброса, предоставляет шаблон.
  • FileLogger — наследует и специализирует.
  • Клиент — зависит от абстракции, использует полиморфизм.

Это и есть сила ООП в Java — в возможности строить понятные, проверяемые, расширяемые модели реального мира.


Класс Object — фундаментальная база и её последствия

В Java любой класс, явно или неявно, наследуется от java.lang.Object. Это значит, что каждый объект обладает набором базовых методов, определённых в Object. От корректной реализации некоторых из них зависит логика приложения и корректность работы стандартных библиотек (HashMap, HashSet, ArrayList, потоки, сериализация и др.).


equals(Object obj) и hashCode() — обязательная пара

Метод equals() по умолчанию реализует сравнение по ссылке (==), что для большинства пользовательских классов не соответствует семантике равенства. При переопределении equals() обязательно должен быть переопределён и hashCode(), иначе нарушается фундаментальный контракт:

Если a.equals(b) == true, то a.hashCode() == b.hashCode() должно быть истинно.

Нарушение этого контракта приводит к тому, что объекты, которые логически равны, могут быть помещены в разные корзины хеш-таблицы и стать "невидимыми" для поиска.


Правила реализации equals()
  1. Рефлексивность: x.equals(x) должно быть true.
  2. Симметричность: x.equals(y) ⇔ y.equals(x).
  3. Транзитивность: если x.equals(y) и y.equals(z), то x.equals(z).
  4. Согласованность: многократные вызовы x.equals(y) должны давать один и тот же результат, пока ничего не изменилось.
  5. Неравенство с null: x.equals(null) должно быть false.

Типичная реализация — через последовательную проверку:

@Override
public boolean equals(Object obj) {
if (this == obj) return true; // оптимизация: сравнение ссылок
if (obj == null || getClass() != obj.getClass()) return false; // null и разные классы — не равны

Person other = (Person) obj;
return Objects.equals(name, other.name) &&
Objects.equals(birthDate, other.birthDate);
}

Важные замечания:

  • Использование getClass() != obj.getClass() (а не instanceof) гарантирует симметричность при наследовании. Если допустить подклассы через instanceof, возможна ситуация: person.equals(student) == true, но student.equals(person) == false, если Student добавляет новые поля в сравнение.
  • Objects.equals() безопасно обрабатывает null для полей.
  • Поля, используемые в equals(), должны быть неизменяемыми или изменяться крайне осторожно — иначе объект может "потеряться" в HashSet.

hashCode() — простота, скорость, стабильность

Хеш-код должен:

  • Быть одинаковым для объектов, для которых equals() возвращает true.
  • Иметь хорошее распределение (минимизировать коллизии).
  • Вычисляться быстро.
  • Не меняться при вызовах (если объект используется в хеш-коллекциях).

Рекомендуемый способ (начиная с Java 7):

@Override
public int hashCode() {
return Objects.hash(name, birthDate);
}

Для критичных по производительности сценариев можно использовать ручной расчёт (например, через 31 * result + field.hashCode()), но Objects.hash() — надёжный и читаемый выбор.


toString()

По умолчанию toString() возвращает что-то вроде com.example.Person@1f32e57. Это бесполезно. Переопределение toString() — это инвестиция в диагностику.

Хороший toString() должен:

  • Содержать имя класса и ключевые идентификаторы (например, id, name).
  • Не включать чувствительные данные (пароли, токены).
  • Не вызывать побочных эффектов (например, lazy-загрузку связанных сущностей).
  • Быть согласованным с equals() — поля, участвующие в равенстве, должны присутствовать.
@Override
public String toString() {
return "Person[id=" + id + ", name=" + name + "]";
}

Современный Java (14+) позволяет использовать text blocks и шаблонные строки (в preview-фичах), но даже простой конкатенации достаточно — главное, чтобы вывод был однозначно интерпретируем.


clone() — антипаттерн, которого следует избегать

Метод clone() в Object объявлен как protected native, а его контракт требует реализации интерфейса Cloneable — маркерного интерфейса без методов. Это приводит к:

  • Ненадёжной реализации (глубокое/поверхностное копирование — неочевидно).
  • Проблемам с наследованием (подкласс должен знать, как клонировать родительскую часть).
  • Нарушению инкапсуляции (доступ к protected clone() извне требует публичного обёрточного метода).

Рекомендация: не используйте clone(). Вместо этого:

  • Для неизменяемых объектов — клонирование не нужно.
  • Для изменяемых — реализуйте копирующий конструктор или фабричный метод:

Код ITЗагрузка примера кода…

Это явно, безопасно, типизировано и не требует Cloneable.


Записи (Records)

С Java 16 появился новый вид класса — record. Это спецификация неизменяемой структуры данных, где семантика равенства, хеш и строковое представление выводятся из описания состояния.

record — специальный тип класса для хранения неизменяемых данных. Автоматически генерирует конструктор, геттеры, equals, hashCode, toString.

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

Использование:

Point p = new Point(10, 20);
System.out.println(p.x()); // 10
System.out.println(p.y()); // 20

Records идеальны для DTO, ключей мап и простых структур данных.

Компилятор автоматически генерирует:

  • Приватное final поле для каждого компонента.
  • Публичный конструктор (с проверкой Objects.requireNonNull для ссылочных типов).
  • Геттеры с именами компонентов (x(), y()).
  • equals(), hashCode(), toString() — на основе компонентов.

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

Когда использовать record:

  • Для DTO, value objects, ключей, событий, параметров команд.
  • Когда класс состоит только из private final полей и "очевидных" методов (equals, hashCode, toString, геттеры).
  • Когда семантика структурного равенства (по содержимому) соответствует предметной области.

Когда не использовать:

  • Если нужна инкапсуляция внутреннего состояния (например, валидация в сеттере — но сеттеров в record нет).
  • Если требуется наследование или реализация интерфейсов с нестандартным поведением (хотя implements разрешён, но с осторожностью).
  • Если объект должен иметь изменяемое состояние — record по определению неизменяем.

Records — это специализированный инструмент для одного из классов проектировочных задач: передача неизменяемых данных. Их появление знаменует зрелость Java: язык теперь помогает выразить намерение проектировщика — семантику.


SOLID

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

Паттерны GoF после SOLID

Когда принципы уже понятны, переходите к именованным схемам из классов и интерфейсов — частые паттерны GoF (Factory, Observer, Strategy и др.) и большой гид с примерами на Java.

Раздел design-patterns — полный маршрут.


S — Single Responsibility Principle (Принцип единственной ответственности)

Класс должен иметь одну, и только одну, причину для изменения. В контексте Java это означает:

  • Разделение данных, поведения и представления.
  • Избегание "божественных классов" (UserService, который работает с БД, отправляет email, логирует, валидирует, конвертирует в JSON).
  • Использование композиции — UserServiceUserRepository, EmailSender, AuditLogger.

ООП-механизмы, поддерживающие SRP:

  • Интерфейсы — позволяют выделить отдельные роли (UserRepository, UserNotificationService).
  • Инкапсуляция — предотвращает "растекание" логики по полям.

O — Open/Closed Principle (Принцип открытости/закрытости)

Модули должны быть открыты для расширения, но закрыты для модификации. В Java это достигается через:

  • Абстракции (interface, abstract class).
  • Полиморфизм — добавление нового поведения через новый класс, а не изменение существующего.
  • Strategy, Template Method, Factory — паттерны, построенные на этом принципе.

Пример:

// Закрыт для модификации: интерфейс не меняется
public interface DiscountStrategy {
BigDecimal apply(BigDecimal price);
}

// Открыт для расширения: новые стратегии — без правки старого кода
public class TenPercentDiscount implements DiscountStrategy { ... }
public class VolumeDiscount implements DiscountStrategy { ... }

L — Liskov Substitution Principle (Принцип подстановки)

Уже обсуждался, но подчеркнём: это про сохранение контракта. Если подкласс ломает ожидания вызывающего кода — это ошибка проектирования, даже если компилятор молчит.

Инструменты контроля:

  • Unit-тесты для суперкласса должны проходить и для подклассов.
  • Анализ @Override-методов: не ужесточаются ли предусловия? не ослабляются ли постусловия?
  • Использование композиции вместо наследования при сомнениях.

I — Interface Segregation Principle (Принцип разделения интерфейсов)

Клиенты не должны зависеть от методов, которыми они не пользуются. В Java — это борьба с "толстыми" интерфейсами (UserService с 20 методами).

Решения:

  • Дробление интерфейсов по ролям — UserReader, UserWriter, UserNotifier.
  • Использование default-методов осторожно: они могут замаскировать нарушение ISP.
  • Предпочтение узкоспециализированных интерфейсов (Runnable, Callable, AutoCloseable) общим.

D — Dependency Inversion Principle (Принцип инверсии зависимостей)

Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

В Java это означает:

  • Зависимости объявляются через интерфейсы (private final UserRepository repository).
  • Конкретные реализации подставляются извне (через конструктор — dependency injection).
  • Фреймворки (Spring, Guice) — инструменты, а не цель; суть — в архитектуре.
public class OrderService {
private final PaymentProcessor paymentProcessor;

// Зависимость инвертирована: класс зависит от интерфейса, а не от PayPalProcessor
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
}

Типичные архитектурные ловушки и как их избегать

ЛовушкаПричинаРешение
Анемичная модельПоля public, все операции — в "сервисах": user.setName(...), userService.validate(user)Перенос поведения в объект: user.changeName(newName), user.validate(). Объект управляет своим состоянием.
Избыточное наследование"Для доступа к полям" или "потому что можно"Замена наследования композицией: Car содержит Engine, а не наследуется от Engine.
Неконтролируемая иерархияГлубокие цепочки наследования (A → B → C → D → E)Ограничение глубины (рекомендуется ≤ 3), использование интерфейсов и делегирования.
Нарушение инкапсуляции через сеттерыsetXxx() для всех полей — даже там, где логика изменения сложнаУдаление сеттеров. Предоставление методов с доменной семантикой: account.transferTo(other, amount).
Перегрузка equals() без hashCode()Забвение контрактаИспользование IDE-генерации или Objects.hash(); unit-тесты на контракт.
Избыточные интерфейсы "на будущее"interface IUserService extends UserService без причинПрименение интерфейсов только при наличии реальной необходимости (моки, множественная реализация, DI).

Пример простейшей игры на Java

Код ITЗагрузка примера кода…


Ещё один пример

Разберём ещё один пример - неплохой Space Invaders:

Код ITЗагрузка примера кода…


Пример - игра "Три в ряд"

Код ITЗагрузка примера кода…


Справочник по ООП в Java

Консолидированный блок для быстрой навигации по терминам, созданию объектов, модификаторам доступа и сравнению с другими языками. Дополняет разделы выше. Подробнее о record и sealed-классах. Общая теория — ООП в разделе "Код"; параллель по синтаксису — ООП в C#, ООП в C++.

Глоссарий — термин Java и понятие ООП

Термин JavaПонятие ООПКратко
classКласс (АДТ)Ссылочный тип; наследует Object
interfaceКонтракт поведенияМножественная реализация; с Java 8 — default
abstract classАбстрактный базовый классОбщий код + abstract-методы
recordОбъект-значение (Java 16+)Неизменяемый DTO с автогенерацией
enumИменованные константыТипобезопасные перечисления
поле (field)Состояние объектаОбычно private
getX() / setX()Свойство (JavaBeans)Контролируемый доступ
methodМетод / поведениеПринадлежит классу
конструкторИнициализацияИмя совпадает с классом, без возврата
staticЧлен классаОбщее состояние или утилиты
final (класс/метод)ЗапечатанностьНельзя наследовать / переопределить
@OverrideПереопределениеПолиморфизм подтипов
thisТекущий экземплярРазрешение имён, this()
superСуперклассsuper(), super.method()
вложенный классВнутренний типInner / static nested
примитив + обёрткаСкаляр и объектint / Integer для унификации

Создание экземпляра и запись в переменные

Переменная ссылочного типа хранит адрес объекта в куче (heap). Примитивы (int, boolean, …) из таблицы типов Java хранятся по значению.

// Ссылочный тип
Car myCar = new Car("Tesla", 2023);
Car alias = myCar; // две ссылки, один объект
alias.setYear(2024); // myCar.getYear() → 2024

// Примитив: копия значения
int a = 10;
int b = a;
b = 20; // a остаётся 10

// var (Java 10+)
var warrior = new Warrior("Артур");

// Полиморфная ссылка
Animal pet = new Dog();
pet.makeSound(); // динамическая диспетчеризация, вывод: Гав!

Порядок при new Car(...):

  1. JVM выделяет память в heap.
  2. Поля инициализируются значениями по умолчанию (0, null, false).
  3. Выполняется цепочка конструкторов (super() неявно или явно).
  4. Возвращается ссылка в переменную.

Важно: конструктор подкласса обязан вызвать конструктор суперкласса (super(...)) первой строкой, если у родителя нет no-arg конструктора.

class Dog extends Animal {
public Dog(String name) {
super(name); // обязательно, если Animal(String) единственный
}
}

Запись в массивы и коллекции: List<Car> хранит ссылки. Присваивание элемента не копирует объект:

List<Car> garage = new ArrayList<>();
garage.add(myCar);
garage.get(0).setYear(2025); // меняет тот же объект, что и myCar

record: компактное создание неизменяемых объектов:

record Point(int x, int y) {}
Point p = new Point(1, 2);
// p.x() — геттер; setters нет

Автоупаковка: Integer boxed = 42; — примитив оборачивается в объект при необходимости.


Доступность методов и полей (сводная таблица)

По умолчанию: поля и методы без модификатора — package-private (видимость в пределах пакета).

МодификаторКлассПоле / методНаследник (другой пакет)Другой пакет
privateтолько этот класснетнет
(нет модификатора)пакетпакетнет*нет
protectedкласс + наследники + пакетданет
publicвездевезде (если тип доступен)дада

* Наследник в другом пакете не видит package-private члены родителя; protected — видит.

Дополнительные правила:

СитуацияПоведение
public класс в файлеИмя файла = имя класса
static методНе обращается к нестатическим полям без экземпляра
final полеПрисвоить можно один раз (в конструкторе или при объявлении)
Переопределение сужает доступОшибка компиляции (protectedprivate)
Интерфейс: методыПо умолчанию public abstract (или default)
Интерфейс: поляТолько public static final

Вложенные классы:

ТипСозданиеДоступ к внешнему объекту
Inner classouter.new Inner()Все поля внешнего (включая private)
Static nestednew Outer.Nested()Только static члены внешнего
Local / anonymousвнутри методаЗахват effectively final переменных

Типичные ошибки в Java-ООП

ОшибкаПочему плохоЧто делать
Публичные поляНарушение инкапсуляции, хрупкий APIprivate + геттеры/сеттеры с валидацией
equals() без hashCode()Объекты пропадают из HashMap/HashSetПереопределить оба; Objects.hash()
Забыли @OverrideОпечатка → новый метод, не переопределениеВсегда ставить аннотацию
Вызов переопределяемого метода из конструктораthis ещё не полностью инициализированИзбегать; final поля после конструктора
Утечка this из конструктораВнешний код видит неготовый объектНе передавать this наружу до завершения init
Возврат ссылки на изменяемое private полеВнешний код меняет внутреннее состояниеЗащитная копия (List.copyOf, new ArrayList<>(...))
Механические сеттеры на всёАнемичная модельПоведение в объект: account.withdraw(amount)
static для сессионных данныхГлобальное состояние, проблемы в тестахDI, scoped beans (Spring)
Наследование ради переиспользования полейХрупкая иерархия, нарушение LSPКомпозиция
instanceof + cast без pattern matchingVerbose, риск ClassCastExceptionJava 16+: if (o instanceof Dog d)

Мини-сравнение с другими языками

АспектJavaC#PythonC++
Корень типовObjectSystem.Objectobjectнет
Наследование классоводиночноеодиночноемножественноемножественное
Интерфейсыimplements, несколькоinterfaceabc / duck typingчисто виртуальный ABC
СвойстваJavaBeansget/set@propertyметоды
Value typesrecord, примитивыstruct, recordнетstruct
ПамятьGC (JVM)GC (.NET)GCRAII / умные указатели
Virtual по умолчаниюда (не-static)нет (virtual явно)N/Aнет (virtual явно)
Package visibilityда (default)internalнетнет (есть friend)
Перегрузкададанетда
Genericstype erasurereified (value)аннотациишаблоны

Учебные примеры ООП

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

Класс и объект

Чертёж класса Figure и конкретные объекты — круг и квадрат.

Код ITЗагрузка примера кода…


Банковский счёт

Инкапсуляция: скрытое поле баланса и методы deposit/withdraw.

Код ITЗагрузка примера кода…


Наследование

Родитель Animal и дочерние Cat и Dog с общим eat() и своим speak().

Код ITЗагрузка примера кода…


Смартфон

Состояние объекта: заряд батареи, звонки и подзарядка.

Код ITЗагрузка примера кода…


Студент

Список оценок, средний балл и проходной порог.

Код ITЗагрузка примера кода…


Корзина покупок

Взаимодействие Product, Cart и Order при оформлении заказа.

Код ITЗагрузка примера кода…


Автомобиль

Пробег, расход топлива и напоминание о техобслуживании.

Код ITЗагрузка примера кода…


Пользователь

Скрытый пароль, вход в систему и публикация сообщений.

Код ITЗагрузка примера кода…