← назад к разделу

Java — язык, в котором почти весь код живёт внутри классов. Поэтому объектно-ориентированное программирование (ООП) тут не «один из стилей», а основа, с которой вы работаете каждый день.

for (Animal a : animals) System.out.println(a.sound()); три объекта одна переменная что напечатано Dogsound() → «Гав» Catsound() → «Мяу» Animalsound() → «...» Animal a Dogsound() → «Гав»= new Dog()Гав Catsound() → «Мяу»= new Cat()Мяу Animalsound() → «...»= new Animal()... строка вызова не меняется — меняется объект за переменной

Переменная объявлена типом 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 видят все, и это обязательство: убрать публичное потом трудно, им уже пользуются.

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

внешний код withdraw(amount) balance -= amount хватает баланса исключение не хватает acc.balance = -100 ошибка компиляции

Поле 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...
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Когда выбирать что: интерфейс — когда нужен только контракт и общие способности (наследовать можно много интерфейсов); абстрактный класс — когда у наследников есть общее состояние (поля) и общий готовый код.

Как связать классы интерфейс нужен только контракт абстрактный класс есть общие поля и код extends это частный случай композиция один использует другой

Одна развилка вместо правила текстом: у каждой стрелки написан признак, по которому берут интерфейс, абстрактный класс, наследование или композицию.

Полиморфизм

Полиморфизм означает «много форм»: переменная типа родителя или интерфейса может ссылаться на объект любого наследника, и вызовется его собственная версия метода. Вызывающий код не знает и не должен знать, какой именно объект перед ним.

живой пример

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.