Java — язык, в котором почти весь код живёт внутри классов. Поэтому объектно-ориентированное программирование (ООП) тут не «один из стилей», а основа, с которой вы работаете каждый день.
Переменная объявлена типом Animal, а метод выбирает тот объект, который в неё положили: Dog печатает «Гав», Cat — «Мяу», сам Animal — многоточие. Это и есть полиморфизм, ради которого выстроено всё остальное: классы, наследование, интерфейсы.
Зачем вообще ООП
Когда программа растёт, главная проблема — не «как написать логику», а «как не запутаться». ООП предлагает простую идею: сгруппировать данные и действия над ними в один объект и спрятать детали внутри.
Короткая формула: класс — это чертёж, объект — конкретный предмет по этому чертежу.
Классы и объекты
Класс описывает, какие у предмета есть данные (поля) и что он умеет (методы). Объект — конкретный экземпляр, созданный по классу через new.
живой пример
public class AccountDemo {
static class Account {
private long balance; // поле: данные объекта
void deposit(long amount) { // метод: действие
balance += amount;
}
long getBalance() {
return balance;
}
}
public static void main(String[] args) {
Account first = new Account(); // создали объект
Account second = new Account();
first.deposit(500);
System.out.println(first.getBalance()); // 500
System.out.println(second.getBalance()); // 0 — свой баланс
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Каждый объект хранит свои собственные значения полей: два Account — это два независимых баланса.
Конструкторы
Конструктор — особый метод, который вызывается при создании объекта. Он нужен, чтобы объект сразу появлялся в корректном состоянии, а не пустым.
живой пример
public class ConstructorDemo {
static class Account {
private final long id;
private long balance;
Account(long id, long balance) { // конструктор
this.id = id;
this.balance = balance;
}
String info() {
return "Account#" + id + ", баланс " + balance;
}
}
public static void main(String[] args) {
Account acc = new Account(1, 1000); // id и баланс заданы сразу
System.out.println(acc.info()); // Account#1, баланс 1000
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если конструктор не написать, Java добавит пустой конструктор без аргументов. Как только вы объявили свой — пустой больше не создаётся автоматически. Слово this тут означает «текущий объект» и помогает отличить поле от параметра с тем же именем.
Инкапсуляция и модификаторы доступа
Инкапсуляция — это сокрытие внутренностей объекта. Поля делают private, а доступ дают через методы. Так объект сам контролирует свои данные и не даёт привести себя в недопустимое состояние.
живой пример
public class EncapsulationDemo {
static class Account {
private long balance = 1000;
void withdraw(long amount) {
if (amount > balance) {
throw new IllegalArgumentException("Недостаточно средств");
}
balance -= amount; // снять больше, чем есть, нельзя
}
long getBalance() {
return balance;
}
}
public static void main(String[] args) {
Account acc = new Account();
acc.withdraw(300);
System.out.println(acc.getBalance()); // 700
try {
acc.withdraw(5000);
} catch (IllegalArgumentException e) {
System.out.println("Отказ: " + e.getMessage());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Будь balance открытым, любой код мог бы записать в него минус в обход проверки в withdraw. Модификатор доступа и отвечает на вопрос, кому поле или метод видны. private видит только свой класс, и для полей это выбор по умолчанию. Без модификатора видит весь пакет: так прячут вспомогательные классы, которым незачем торчать наружу. protected добавляет к пакету наследников. public видят все, и это обязательство: убрать публичное потом трудно, им уже пользуются.
Практическое правило: делайте всё максимально закрытым и открывайте ровно то, что действительно нужно снаружи.
Поле private лежит за методом: снаружи зовут withdraw, и он решает, менять balance или отказать, а прямая запись в поле снизу даже не компилируется.
static и final: что принадлежит классу, а что нельзя менять
Слово static стоит в каждом примере с первой статьи, и пора его объяснить. Обычное поле или метод принадлежит объекту: у каждого Account свой balance. Но бывает то, что общее для всех объектов класса или вовсе не связано ни с каким объектом: счётчик созданных счетов, курс валюты, вспомогательный метод, которому не нужно состояние. Такое помечают static: оно принадлежит классу, существует в одном экземпляре и доступно без new.
живой пример
public class StaticDemo {
static class Account {
static int created = 0;
static final int MAX_BALANCE = 1_000_000;
final long id;
long balance;
Account() {
created++;
id = created;
}
static boolean fits(long amount) {
return amount <= MAX_BALANCE;
}
}
public static void main(String[] args) {
new Account();
Account second = new Account();
System.out.println(Account.created + " " + second.id + " " + Account.fits(5_000_000));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Счётчик created один на класс и растёт при каждом создании, а id у каждого объекта свой. Статический метод fits вызывается через имя класса и не видит полей объекта: внутри него нет this, и обращение к balance там не скомпилируется. Отсюда и static у main: JVM запускает его до того, как создан хоть один объект. Правило простое: static для того, что не зависит от конкретного объекта, и это должно быть редкостью, потому что статическое состояние общее для всей программы и всех потоков.
final означает «нельзя переназначить». У локальной переменной и поля это запрет присвоить второй раз: id получает значение в конструкторе и больше не меняется, и поэтому все поля record тоже final. Оговорка та же, что у record: final запрещает менять ссылку, а не объект за ней, final List можно дополнять. У метода final запрещает переопределение в наследниках, у класса запрещает наследование, так защищены String и все обёртки. Пара static final это константа: одно значение на класс, известное заранее, по соглашению именуется заглавными буквами.
Наследование
Наследование (extends) позволяет одному классу взять поля и методы другого и добавить своё. Базовый класс называют родителем (суперклассом), производный — наследником (подклассом).
public class InheritanceDemo {
static class Animal {
String sound() {
return "...";
}
}
static class Dog extends Animal {
@Override
String sound() { // переопределяем поведение родителя
return "Гав";
}
}
public static void main(String[] args) {
System.out.println(new Animal().sound()); // ...
System.out.println(new Dog().sound()); // Гав
}
}
Явного родителя может и не быть — тогда класс всё равно наследуется от Object, общего предка всех классов в Java. Оттуда у любого объекта уже есть toString, equals и hashCode: те самые методы, которые record пишет за вас, а HashSet и HashMap используют, чтобы понять, одинаковые перед ними объекты или разные.
Аннотация @Override не обязательна, но желательна: она проверяет, что вы действительно переопределяете метод родителя, а не создаёте новый по ошибке (например, из-за опечатки в имени). Без неё такая опечатка ничего не сломает на сборке: у класса просто появится лишний метод, родительский останется как был, и программа молча начнёт делать не то — искать причину придётся в отладчике.
Наследник не пишет конструктор родителя заново, а вызывает его: super(...) первой строкой своего конструктора. Если этой строки нет, компилятор подставляет super() без аргументов сам, и это ломается, как только у родителя остался только конструктор с параметрами: тогда наследник обязан вызвать его явно. Порядок всегда один: сначала до конца отрабатывает конструктор родителя, потом тело наследника, поэтому поля родителя к моменту вашего кода уже заполнены. Тем же словом super зовут переопределённый метод родителя, когда нужно не заменить его, а дополнить.
живой пример
public class SuperDemo {
static class Animal {
final String name;
Animal(String name) {
this.name = name;
System.out.println("Animal готов: " + name);
}
String sound() {
return "...";
}
}
static class Dog extends Animal {
Dog(String name) {
super(name);
System.out.println("Dog готов: " + name);
}
@Override
String sound() {
return super.sound() + " Гав";
}
}
public static void main(String[] args) {
System.out.println(new Dog("Рекс").sound());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Сначала печатается строка из Animal, потом из Dog, и sound у собаки начинается с многоточия родителя.
Наследованием не стоит злоупотреблять: оно жёстко связывает классы. Наследник получает всё, что есть у родителя, включая то, что ему не нужно, и любая правка родителя проезжает по всем наследникам. Часто гибче не наследоваться, а держать другой объект внутри как поле и вызывать его: это называют композицией. Сравните два способа сделать счёт, который пишет операции в журнал:
живой пример
import java.util.ArrayList;
import java.util.List;
public class CompositionDemo {
static class Account {
long balance;
void deposit(long amount) {
balance += amount;
}
}
static class LoggedAccount {
private final Account account = new Account();
private final List<String> log = new ArrayList<>();
void deposit(long amount) {
account.deposit(amount);
log.add("+" + amount);
}
long balance() {
return account.balance;
}
}
public static void main(String[] args) {
LoggedAccount acc = new LoggedAccount();
acc.deposit(300);
acc.deposit(200);
System.out.println(acc.balance() + " " + acc.log);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
LoggedAccount extends Account дал бы то же самое короче, но тогда наружу торчали бы все методы Account, включая те, что появятся у него завтра и журнал обойдут. Композиция показывает наружу ровно то, что вы решили показать, а Account внутри можно подменить на другой класс, не трогая тех, кто пользуется LoggedAccount. Практическое правило: наследование, когда объект является частным случаем родителя и это видно снаружи (Dog это Animal); композиция, когда объект использует другой (счёт с журналом использует счёт).
Интерфейсы
Интерфейс описывает, что объект умеет, не говоря как. Это контракт: список методов, у которых по умолчанию нет тела. Класс обещает его выполнить через implements.
живой пример
public class NotifierDemo {
interface Notifier {
void send(String message); // только сигнатура, без реализации
}
static class EmailNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("Письмо: " + message);
}
}
public static void main(String[] args) {
Notifier notifier = new EmailNotifier();
notifier.send("заказ оплачен"); // Письмо: заказ оплачен
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Один класс может реализовать несколько интерфейсов — в отличие от наследования, где родитель только один. Это главный способ давать классам общие «способности», не выстраивая жёсткую иерархию.
default-методы
Интерфейс может дать методу готовую реализацию через default. Тогда все, кто реализует интерфейс, получают её бесплатно и при желании переопределяют.
public interface Notifier {
void send(String message);
default void sendAll(List<String> messages) { // готовая реализация
messages.forEach(this::send);
}
}
default придумали, чтобы можно было добавлять методы в существующие интерфейсы, не ломая код всех, кто их уже реализовал.
Кроме default, у интерфейса бывают static-методы (с Java 8): фабрики и вспомогательные функции, которые зовут через имя интерфейса, как Comparator.comparing(...) или List.of(...). И private-методы (с Java 9): общий кусок кода для нескольких default-методов, который наружу показывать незачем.
Есть и обратная сторона default. Класс может реализовать два интерфейса, и у обоих окажется default-метод с одной сигнатурой. Компилятор не станет угадывать, чей брать, и откажет: класс обязан переопределить метод сам, а внутри может позвать любую из реализаций через ИмяИнтерфейса.super.метод().
живой пример
public class DiamondDemo {
interface Email {
default String signature() {
return "с уважением";
}
}
interface Sms {
default String signature() {
return "";
}
}
static class Notifier implements Email, Sms {
@Override
public String signature() {
return Email.super.signature();
}
}
public static void main(String[] args) {
System.out.println(new Notifier().signature());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Уберите переопределение в Notifier, и код перестанет компилироваться с сообщением о конфликте. Так язык гарантирует, что множественное наследование поведения не создаёт неоднозначности: решение всегда за классом.
Абстрактные классы
Абстрактный класс (abstract) — нечто среднее между обычным классом и интерфейсом. Его нельзя создать через new, он служит общей заготовкой: часть методов реализована, часть оставлена наследникам.
живой пример
public class ShapeDemo {
abstract static class Shape {
abstract double area(); // нет тела — реализует наследник
String describe() { // общий код для всех фигур
return "Площадь: " + area();
}
}
static class Circle extends Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
return Math.PI * radius * radius;
}
}
public static void main(String[] args) {
System.out.println(new Circle(2).describe()); // Площадь: 12.56...
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Когда выбирать что: интерфейс — когда нужен только контракт и общие способности (наследовать можно много интерфейсов); абстрактный класс — когда у наследников есть общее состояние (поля) и общий готовый код.
Одна развилка вместо правила текстом: у каждой стрелки написан признак, по которому берут интерфейс, абстрактный класс, наследование или композицию.
Полиморфизм
Полиморфизм означает «много форм»: переменная типа родителя или интерфейса может ссылаться на объект любого наследника, и вызовется его собственная версия метода. Вызывающий код не знает и не должен знать, какой именно объект перед ним.
живой пример
import java.util.List;
public class PolymorphismDemo {
static class Animal {
String sound() {
return "...";
}
}
static class Dog extends Animal {
@Override
String sound() {
return "Гав";
}
}
static class Cat extends Animal {
@Override
String sound() {
return "Мяу";
}
}
public static void main(String[] args) {
List<Animal> animals = List.of(new Dog(), new Cat(), new Animal());
for (Animal a : animals) {
System.out.println(a.sound()); // Гав, Мяу, ...
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Это и есть сила ООП: допишите Fox extends Animal и положите его в список — цикл заработает с ним без единой правки. Какой метод вызвать, Java решает в момент выполнения по фактическому типу объекта.
record — краткий неизменяемый класс данных
Класс, который нужен только чтобы хранить набор значений, раньше требовал полей, конструктора, геттеров, equals, hashCode и toString руками. record Point(int x, int y) {} делает всё это одной строкой, а поля у него неизменяемые, поэтому он годится для передачи данных и для ключей в Map. Как record устроен, где его границы и что такое компактный конструктор, разобрано в статье про современную Java; здесь достаточно узнавать его в коде.
enum — перечисление
enum задаёт фиксированный набор именованных значений. Это надёжнее, чем хранить строки или числа: компилятор не даст использовать значение, которого нет в списке.
живой пример
public class EnumDemo {
enum Status { NEW, PAID, SHIPPED, CANCELLED }
public static void main(String[] args) {
Status s = Status.PAID;
if (s == Status.PAID) {
System.out.println("Заказ оплачен"); // Заказ оплачен
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
enum — это полноценный класс: ему можно добавить поля, конструктор и методы, чтобы привязать к каждому значению дополнительные данные.
public enum Planet {
EARTH(9.8), MARS(3.7); // у каждого значения своя гравитация
private final double gravity;
Planet(double gravity) { // конструктор enum
this.gravity = gravity;
}
public double gravity() {
return gravity;
}
}
У любого enum есть несколько готовых методов, без которых он редко обходится. values() возвращает массив всех констант в порядке объявления: так перебирают статусы для выпадающего списка или строят таблицу переходов. valueOf("PAID") превращает строку в константу и бросает IllegalArgumentException, если такой нет: это обычный способ разобрать статус из JSON или базы, и место, где ловят опечатки в данных. name() даёт строку обратно, ordinal() порядковый номер, и на него завязываться нельзя: вставили константу в середину, и все номера поехали. И switch по enum пишут без префикса класса, а для switch-выражения компилятор ещё и проверяет, что разобраны все константы:
живой пример
public class EnumMethodsDemo {
enum Status { NEW, PAID, SHIPPED, CANCELLED }
static String describe(Status s) {
return switch (s) {
case NEW -> "ждёт оплаты";
case PAID, SHIPPED -> "в работе";
case CANCELLED -> "отменён";
};
}
public static void main(String[] args) {
for (Status s : Status.values()) {
System.out.println(s.ordinal() + " " + s.name() + ": " + describe(s));
}
System.out.println(Status.valueOf("PAID") == Status.PAID);
try {
Status.valueOf("paid");
} catch (IllegalArgumentException e) {
System.out.println("нет такой константы: " + e.getMessage());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Добавите в Status пятую константу, и describe перестанет компилироваться, пока вы не решите, что она значит: это и есть главная польза enum перед строками. Коллекции с enum-ключами (EnumMap, EnumSet) разобраны в статье про коллекции.
Глубже: контракт Object: equals, hashCode и toStringрасширенное
Каждый класс наследует от Object три метода, и статья про коллекции показывает, что бывает, когда equals и hashCode не согласованы. Сам контракт стоит разобрать здесь, потому что он про классы, а не про коллекции.
equals по умолчанию сравнивает ссылки: два объекта равны, только если это один и тот же объект. Для сущностей с идентичностью это правильно, для объектов-значений (деньги, координаты, адрес) нет, и equals переопределяют. У переопределения есть контракт, и нарушение любого пункта даёт тихие ошибки в коллекциях: рефлексивность (a.equals(a)), симметричность (a.equals(b) тогда и только тогда, когда b.equals(a)), транзитивность, согласованность (повторные вызовы дают то же, пока объекты не менялись) и false для null. Симметричность ломается чаще всего при сравнении с подклассом: Point считает равным ColorPoint с теми же координатами, а ColorPoint нет. Поэтому в equals сравнивают класс через getClass() или проверяют instanceof и не переопределяют equals в подклассах, добавляющих поля.
hashCode обязан быть согласован: равные по equals объекты дают равный хеш. Обратное не требуется, неравные могут совпадать, это коллизия, о которой статья про HashMap. Считают хеш по тем же полям, что участвуют в equals, через Objects.hash(a, b) или вручную; переопределили equals и забыли hashCode, и объект не находится в HashSet. И хеш не должен зависеть от полей, которые меняются, пока объект лежит в коллекции.
toString по умолчанию даёт Point@1b6d3586, имя класса и хеш, и это первое, что видно в логе и в отладчике. Переопределяют, чтобы печаталось содержимое, без секретов и персональных данных, потому что toString попадает в журналы. Для объектов-значений всё три метода генерирует record, и это главная причина писать значения записями, о чём статья про современную Java; для сущностей equals и hashCode считают по идентификатору, а toString печатает идентификатор и тип.
живой пример
import java.util.Objects;
public class Money {
private final long cents;
private final String currency;
Money(long cents, String currency) { this.cents = cents; this.currency = currency; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money m = (Money) o;
return cents == m.cents && currency.equals(m.currency);
}
@Override public int hashCode() { return Objects.hash(cents, currency); }
@Override public String toString() { return cents / 100 + "." + cents % 100 + " " + currency; }
public static void main(String[] args) {
Money a = new Money(1999, "RUB"), b = new Money(1999, "RUB");
System.out.println(a.equals(b) + " " + (a == b) + " " + (a.hashCode() == b.hashCode()) + " " + a);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Проверка здесь короткая: библиотека EqualsVerifier в тестах прогоняет весь контракт за один вызов, и это дешевле, чем ловить нарушение в проде по «объект есть, а contains говорит нет».
Коротко
- Класс — чертёж (поля + методы), объект — экземпляр по чертежу, создаётся через
new; конструктор задаёт стартовое состояние. - Инкапсуляция: поля
private, доступ через методы; держите всё максимально закрытым. - Наследование (
extends) — взять и расширить другой класс; родитель один. Не злоупотребляйте, часто лучше композиция. - Интерфейс (
implements) — контракт «что умеет»; их можно реализовать много, естьdefault-методы с готовым телом. Абстрактный класс — заготовка с общим кодом и состоянием, нельзя создать напрямую. - Полиморфизм — один вызов работает с любым наследником; новые типы добавляются без правки старого кода. Общий предок у всех классов один —
Object, оттудаtoString,equalsиhashCode. - record — краткий неизменяемый класс данных (готовые геттеры,
equals,hashCode,toString); enum — фиксированный набор значений, при необходимости с полями и методами. equalsпо умолчанию сравнивает ссылки; переопределённый обязан быть рефлексивным, симметричным, транзитивным и согласованным сhashCodeпо тем же полям;toStringпечатает содержимое без секретов; для значений всё это даётrecord.staticпринадлежит классу, а не объекту (безthis),finalзапрещает переназначить поле, переопределить метод или наследовать класс; конструктор наследника начинается сsuper(...), родитель отрабатывает первым.- Композиция (объект внутри как поле) показывает наружу ровно то, что решили, и предпочтительна, когда объект использует другой, а не является им; два
defaultс одной сигнатурой класс обязан переопределить сам (Имя.super.метод()). - У enum есть
values(),valueOf(бросаетIllegalArgumentException),name()иordinal()(на него не завязываться);switch-выражение по enum проверяет полноту.
Что почитать дальше
- Синтаксис и типы данных — переменные, примитивы,
varи базовые конструкции языка. - Коллекции —
List,Set,Mapи как хранить группы объектов. - Дженерики — типобезопасные классы и методы, на которых построены коллекции.
- Records и Optional — record подробнее, а вместе с ним sealed-классы и pattern matching.