Зачем нужно ООП
Объектно-ориентированное программирование — способ моделировать систему через объекты. Каждый объект объединяет состояние (данные) и поведение (методы, которые с этим состоянием работают).
В процедурном стиле данные и функции живут порознь: по мере роста кода всё труднее уследить, кто и как меняет данные. ООП собирает данные и операции над ними в один тип с чёткой границей — снаружи виден только контракт, а не внутренности.
Главная цель — управлять сложностью: прятать детали за понятным интерфейсом и собирать большое из небольших заменяемых частей. Четыре принципа ниже — это инструменты для этой цели, а не пункты для заучивания.
Инкапсуляция: класс охраняет свои инварианты
Инкапсуляция — сокрытие внутреннего состояния за публичными методами так, чтобы класс сам отвечал за свою корректность. Внешний код не трогает поля напрямую, а просит объект выполнить операцию.
Суть не в том, чтобы «сделать поля private и приклеить геттеры/сеттеры». Суть — в инвариантах: правилах, которые всегда должны быть истинны (баланс не отрицателен, список отсортирован). Методы — это место, где правила проверяются.
Поэтому бездумные сеттеры на каждое поле ломают инкапсуляцию не хуже public-полей: они снова позволяют менять состояние в обход проверок. Хорошая инкапсуляция выставляет наружу осмысленные операции (deposit), а не присваивания (setBalance).
class Account {
private long balance; // состояние под защитой
public void deposit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
balance += amount; // инвариант «balance >= 0» под контролем
}
public long balance() { return balance; } // только чтение, без сеттера
}
Абстракция: что показать, а что спрятать
Абстракция — выделение существенного: какие операции тип предоставляет наружу, без подробностей, как они устроены внутри. В Java её выражают интерфейсы и абстрактные классы — это контракт, на который опираются клиенты.
Частая путаница: абстракция отвечает на вопрос «что делает тип» (контракт), а инкапсуляция — «как он защищает своё состояние» (сокрытие). Они дополняют друг друга, но это не одно и то же.
Чем уже публичный контракт, тем свободнее менять реализацию, не ломая тех, кто им пользуется. Хорошая абстракция показывает минимум.
Наследование: расширение через «is-a»
Наследование (extends) задаёт отношение «является»: подтип — это разновидность базового типа. Он получает поля и методы родителя, а часть поведения переопределяет через @Override; к версии родителя обращаются через super.
Наследование переиспользует код, но его цена — жёсткая связь. Проблема «хрупкого базового класса» (fragile base class): изменение базового класса может незаметно сломать наследников, потому что они зависят от его внутреннего поведения, а не только от контракта.
Поэтому наследоваться стоит, лишь когда отношение действительно «is-a» и контракт базового типа стабилен.
Полиморфизм: один интерфейс — много реализаций
Полиморфизм позволяет работать с разными реализациями через общий тип, а конкретный метод выбирается во время выполнения по фактическому классу объекта. Это динамическая диспетчеризация (late binding).
Не путайте два механизма: переопределение (overriding) — это и есть рантайм-полиморфизм; перегрузка (overloading) — выбор метода по типам аргументов на этапе компиляции и к полиморфизму подтипов отношения не имеет.
Полиморфизм — двигатель расширяемости: добавляя новую реализацию интерфейса, вы не трогаете код, который с ним работает. Это и есть принцип open/closed — открыт для расширения, закрыт для изменения.
interface Shape { double area(); }
class Circle implements Shape {
private final double r;
Circle(double r) { this.r = r; }
public double area() { return Math.PI * r * r; }
}
class Square implements Shape {
private final double a;
Square(double a) { this.a = a; }
public double area() { return a * a; }
}
// area() выбирает реализацию по фактическому типу объекта:
double total = 0;
for (Shape s : shapes) total += s.area();
Композиция вместо наследования
Композиция — отношение «имеет» (has-a): объект делегирует работу другим объектам, которые держит внутри, вместо того чтобы наследоваться от них.
Композиция гибче: зависимость можно подменить (в том числе в рантайме и в тестах), нет хрупкой связи с базовым классом и нет риска унаследовать лишнее. Отсюда правило «composition over inheritance».
Классический пример: Stack не должен наследовать ArrayList (иначе наружу протекут add/remove по индексу, ломающие LIFO). Лучше держать список внутри и выставить только push/pop.
// плохо: class Stack<T> extends ArrayList<T> — наследуем лишнее API
class Stack<T> {
private final List<T> items = new ArrayList<>(); // has-a
public void push(T x) { items.add(x); }
public T pop() { return items.remove(items.size() - 1); }
}
Частые заблуждения
Считать, что private-поля с геттерами/сеттерами — это и есть инкапсуляция. Инкапсуляция про инварианты, а не про шаблонные аксессоры.
Сводить полиморфизм к перегрузке методов вместо переопределения.
Путать абстракцию (контракт типа) и инкапсуляцию (сокрытие состояния).
Наследоваться ради переиспользования кода там, где отношения «is-a» на самом деле нет.
Как отвечать на собеседовании
Назовите четыре принципа и дайте по одному короткому примеру: инкапсуляция (поле под проверяющим методом), наследование (extends + @Override), полиморфизм (вызов переопределённого метода по ссылке базового типа), абстракция (интерфейс как контракт).
Сильный ответ добавляет связку: инкапсуляция бережёт инварианты, абстракция задаёт контракт, полиморфизм даёт расширяемость (open/closed), а наследование стоит предпочитать композиции. Это показывает, что принципы для вас — инструменты, а не заученный список.