Объектно-ориентированное программирование в Java
Если ООП для вас новое или вы учите 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 порядок объявления полей, методов и конструкторов внутри класса не влияет на компиляцию. Компилятор анализирует весь класс целиком. Однако для читаемости принято следовать соглашениям:
- Константы (
static final) - Поля экземпляра
- Конструкторы
- Методы
- Вложенные классы/интерфейсы
Также часто группируют члены по видимости — сначала
public, затемprotected, package-private,private.
Основы ООП в Java
Давайте рассмотрим пример с реализацией принципов ООП в Java:
Код ITЗагрузка примера кода…
Здесь мы видим реализацию концепций:
- Интерфейс (
IVoice) — содержит только сигнатуру метода. Описывает, что объект умеет делать, без реализации. - Абстрактный класс (
Animal) — реализует интерфейс, но помечен какabstract. Позволяет хранить общие данные (name), конструктор и абстрактный метод (move()). Экземпляр создать нельзя:new Animal()вызовет ошибку компиляции. - Наследование (
extends) —CatиDogполучают поля и конструкторAnimalчерезsuper(name). Каждый наследник обязан реализовать все абстрактные методы и методы интерфейса. - Полиморфизм — в
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Загрузка интерактивного демо…
Инкапсуляция — это принцип, направленный на достижение двух целей:
- Ограничение прямого доступа к внутреннему состоянию объекта, чтобы предотвратить его некорректное изменение извне.
- Гарантия согласованности состояния объекта, то есть поддержание инвариантов — условий, которые должны оставаться истинными на протяжении всего жизненного цикла объекта.
Управление доступом к членам класса критически важно для инкапсуляции.
Без инкапсуляции:
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 — ссылка на суперкласс.
- Вызов конструктора суперкласса:
public class Animal {
public Animal(String name) {
this.name = name;
}
}
public class Dog extends Animal {
public Dog(String name) {
super(name); // Обязательный вызов, если нет конструктора без аргументов
}
}
- Вызов переопределённого метода суперкласса:
public class Dog extends Animal {
@Override
public void makeSound() {
super.makeSound(); // Сначала звук животного
System.out.println("Гав!"); // Потом специфичный звук
}
}
Давайте посмотрим на основные шаблоны:
class <имя подкласса> extends <имя суперкласса>— базовый синтаксис одиночного наследования.class <имя класса> implements ...— шаблон множественного наследования поведения через интерфейсы (альтернатива наследованию классов).@Override ...— строгий шаблон переопределения метода. Аннотация обязательна для контроля соблюдения контракта на этапе компиляции.super(...)иsuper.— шаблоны явного обращения к конструктору и членам родительского класса. Критичны для цепочки инициализации.<тип> ... = new <имя подкласса>(...)— демонстрация полиморфизма: ссылка типа суперкласса указывает на объект подкласса.instanceofиisAssignableFrom— шаблоны безопасной проверки типов перед приведением (кастингом), чтобы избежатьClassCastException.final class/final method— шаблоны запрета наследования или переопределения (нарушение расширяемости ради безопасности/производительности).abstract class/abstract method— шаблоны определения неполного контракта, требующего реализации в наследниках.
Наследование решает две основные задачи:
- Повторное использование кода — избегание дублирования общей функциональности.
- Моделирование "является"-отношения (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). Подкласс может предоставить собственную реализацию метода, уже объявленного в суперклассе, при соблюдении следующих условий:
- Сигнатура метода (имя, список параметров, типы параметров) должна быть идентична.
- Возвращаемый тип должен быть либо таким же, либо ковариантным (подтипом исходного возвращаемого типа — например,
Object→String). - Уровень доступа не может быть более строгим (нельзя переопределить
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 реализованы два вида полиморфизма:
- Статический полиморфизм (перегрузка методов — overloading)
- Динамический полиморфизм (переопределение методов — 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Загрузка примера кода…
Разрешение перегрузки происходит на этапе компиляции. Компилятор выбирает наиболее подходящий метод, основываясь на:
- Точном совпадении типов аргументов.
- Автоматическом приведении (например,
int→long,float→double). - Упаковке и распаковке примитивов (
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 метод ломает наследников без реализации |
| Модификаторы членов | Фактически public | public, 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()
- Рефлексивность:
x.equals(x)должно бытьtrue. - Симметричность:
x.equals(y) ⇔ y.equals(x). - Транзитивность: если
x.equals(y)иy.equals(z), тоx.equals(z). - Согласованность: многократные вызовы
x.equals(y)должны давать один и тот же результат, пока ничего не изменилось. - Неравенство с
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 (Factory, Observer, Strategy и др.) и большой гид с примерами на Java.
Раздел design-patterns — полный маршрут.
S — Single Responsibility Principle (Принцип единственной ответственности)
Класс должен иметь одну, и только одну, причину для изменения. В контексте Java это означает:
- Разделение данных, поведения и представления.
- Избегание "божественных классов" (
UserService, который работает с БД, отправляет email, логирует, валидирует, конвертирует в JSON). - Использование композиции —
UserService→UserRepository,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(...):
- JVM выделяет память в heap.
- Поля инициализируются значениями по умолчанию (
0,null,false). - Выполняется цепочка конструкторов (
super()неявно или явно). - Возвращается ссылка в переменную.
Важно: конструктор подкласса обязан вызвать конструктор суперкласса (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 поле | Присвоить можно один раз (в конструкторе или при объявлении) |
| Переопределение сужает доступ | Ошибка компиляции (protected → private) |
| Интерфейс: методы | По умолчанию public abstract (или default) |
| Интерфейс: поля | Только public static final |
Вложенные классы:
| Тип | Создание | Доступ к внешнему объекту |
|---|---|---|
| Inner class | outer.new Inner() | Все поля внешнего (включая private) |
| Static nested | new Outer.Nested() | Только static члены внешнего |
| Local / anonymous | внутри метода | Захват effectively final переменных |
Типичные ошибки в Java-ООП
| Ошибка | Почему плохо | Что делать |
|---|---|---|
| Публичные поля | Нарушение инкапсуляции, хрупкий API | private + геттеры/сеттеры с валидацией |
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 matching | Verbose, риск ClassCastException | Java 16+: if (o instanceof Dog d) |
Мини-сравнение с другими языками
| Аспект | Java | C# | Python | C++ |
|---|---|---|---|---|
| Корень типов | Object | System.Object | object | нет |
| Наследование классов | одиночное | одиночное | множественное | множественное |
| Интерфейсы | implements, несколько | interface | abc / duck typing | чисто виртуальный ABC |
| Свойства | JavaBeans | get/set | @property | методы |
| Value types | record, примитивы | struct, record | нет | struct |
| Память | GC (JVM) | GC (.NET) | GC | RAII / умные указатели |
| Virtual по умолчанию | да (не-static) | нет (virtual явно) | N/A | нет (virtual явно) |
| Package visibility | да (default) | internal | нет | нет (есть friend) |
| Перегрузка | да | да | нет | да |
| Generics | type erasure | reified (value) | аннотации | шаблоны |
Учебные примеры ООП
Небольшие самодостаточные программы, которые показывают классы, объекты, инкапсуляцию, наследование и взаимодействие нескольких типов на одной предметной области.
Класс и объект
Чертёж класса Figure и конкретные объекты — круг и квадрат.
Код ITЗагрузка примера кода…
Банковский счёт
Инкапсуляция: скрытое поле баланса и методы deposit/withdraw.
Код ITЗагрузка примера кода…
Наследование
Родитель Animal и дочерние Cat и Dog с общим eat() и своим speak().
Код ITЗагрузка примера кода…
Смартфон
Состояние объекта: заряд батареи, звонки и подзарядка.
Код ITЗагрузка примера кода…
Студент
Список оценок, средний балл и проходной порог.
Код ITЗагрузка примера кода…
Корзина покупок
Взаимодействие Product, Cart и Order при оформлении заказа.
Код ITЗагрузка примера кода…
Автомобиль
Пробег, расход топлива и напоминание о техобслуживании.
Код ITЗагрузка примера кода…
Пользователь
Скрытый пароль, вход в систему и публикация сообщений.
Код ITЗагрузка примера кода…