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

ОСНОВЫ ЯЗЫКА: JAVA Фаза 2. Объектно-ориентированное программирование

Обязательно

ООП в Java: классы, наследование, интерфейсы, record и enum

Java

Классы и объекты, инкапсуляция, наследование, интерфейсы, полиморфизм, record и enum.

1. Класс задаёт форму, объект хранит своё состояние

1 / 5

Класс объявляет поля и методы, конструктор создаёт объект. Два объекта одного класса не делят значения полей.

  • Поле принадлежит объекту, static - классу целиком.
  • Конструктор называется как класс и не возвращает значение.
  • Метод работает с конкретным объектом через this.
class User userA.name = "Анна"свой объект userB.name = "Павел"другой объект static count - одно на класс

2. Инкапсуляция прячет способ менять состояние

2 / 5

Поля делают private, а изменения пропускают через методы с проверками. final запрещает повторное присваивание ссылки, но не делает сам объект неизменяемым.

  • public открывает API, private скрывает детали.
  • Набор геттеров без правил не заменяет инкапсуляцию.
  • Неизменяемый объект не меняет состояние после конструктора.
class Account private balance deposit(amount > 0) проверка баланс Снаружи нет прямого balance = -100

3. Наследование расширяет тип, полиморфизм выбирает метод объекта

3 / 5

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

  • super(...) вызывает конструктор родителя первым.
  • @Override проверяет сигнатуру переопределения.
  • Наследование применяют для отношения «является», не для копирования кода.
Person Employeeextends Person Person p = employee p.role() - Employee

4. Интерфейс задаёт контракт, абстрактный класс - общую основу

4 / 5

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

  • Java позволяет реализовать много интерфейсов.
  • Наследовать можно только один класс.
  • default-метод добавляет поведение в интерфейс с обратной совместимостью.
interface Operation Paymentimplements Refundimplements AbstractOperation - поля и общий аудит

5. record и enum убирают шаблонный код

5 / 5

record описывает неизменяемые данные и автоматически создаёт конструктор, акцессоры, equals, hashCode и toString. enum задаёт закрытый набор значений.

  • Запись подходит для DTO и значений, не для меняемой JPA-сущности.
  • Перечисление безопаснее строк и чисел.
  • equals сравнивает содержимое, == - ссылки.
record Point(int x,int y) equals + hashCodeуже сгенерированы enum Status NEW PAID FAILED Только перечисленные состояния

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 говорит нет».

Глубже: вложенные, внутренние и анонимные классырасширенное

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

Статический вложенный класс (static class) это обычный класс, просто записанный внутри другого ради группировки и доступа к его приватным членам. Экземпляр внешнего класса ему не нужен, создаётся он как new Outer.Inner(). Это выбор по умолчанию: так устроены Map.Entry, узлы HashMap, билдеры.

Внутренний класс (без static) привязан к экземпляру внешнего: у каждого его объекта есть скрытая ссылка Outer.this, через которую он читает поля внешнего объекта, не указывая, откуда они. Создаётся он только от существующего внешнего объекта: basket.new Report(). Цена скрытой ссылки важнее удобства: пока жив внутренний объект, жив и внешний. Слушатель, объявленный внутренним классом и положенный в долгоживущий список подписок, держит в памяти весь внешний объект со всеми его полями, и это одна из классических утечек. Правило простое: не нужны поля внешнего объекта, ставьте static.

Локальный класс объявляют прямо в теле метода, когда класс нужен в одном месте и у него несколько методов. Анонимный класс это тот же локальный, но без имени, объявленный и созданный одним выражением new Iterator<>() { ... }. Обе формы видят локальные переменные метода, но только фактически неизменяемые: компилятор копирует их внутрь объекта, и изменение после копирования было бы ложью, поэтому присваивание такой переменной не компилируется.

живой пример

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class NestedDemo {
    static class Money {                      // статический вложенный: просто класс внутри класса
        final long cents;
        Money(long cents) { this.cents = cents; }
    }

    private final List<Money> items = new ArrayList<>();

    class Report {                            // внутренний: держит ссылку на внешний объект
        long total() {
            long sum = 0;
            for (Money m : items) sum += m.cents;   // items внешнего объекта, без this
            return sum;
        }
    }

    Iterator<Money> iterator() {
        return new Iterator<>() {             // анонимный: один раз, тут же
            int i = 0;
            public boolean hasNext() { return i < items.size(); }
            public Money next() { return items.get(i++); }
        };
    }

    public static void main(String[] args) {
        NestedDemo basket = new NestedDemo();
        basket.items.add(new Money(1999));
        basket.items.add(new Money(501));
        NestedDemo.Report report = basket.new Report();
        System.out.println(report.total());
        for (Iterator<Money> it = basket.iterator(); it.hasNext(); ) System.out.print(it.next().cents + " ");
        System.out.println();
        Runnable r = () -> System.out.println(basket.items.size());   // лямбда вместо анонимного Runnable
        r.run();
    }
}
Запустить

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

Там, где у интерфейса один абстрактный метод, анонимный класс сегодня заменяет лямбда: короче и без лишнего объекта с собственным this. Анонимный класс остаётся, когда методов несколько, как у Iterator выше, или когда нужны поля с состоянием.

Глубже: копирование объектов: clone, конструктор копии и снимокрасширенное

«Скопировать объект» звучит просто, пока копия не начинает менять оригинал. Переменная хранит ссылку, поэтому Order copy = original копирует только ссылку: это один объект под двумя именами. Настоящая копия это новый объект с теми же значениями полей, и здесь развилка.

Поверхностная копия переносит значения полей как есть. Для примитивов и неизменяемых объектов (String, Integer, записи) этого достаточно. Но поле items типа List это ссылка, и в копии она указывает на тот же список: добавили позицию в копию, и она появилась в оригинале. Глубокая копия создаёт новые объекты и для вложенных полей, столько уровней, сколько нужно.

У Object для этого есть clone(), и устроен он неудобно: метод защищённый, класс обязан реализовать маркерный интерфейс Cloneable (без него super.clone() бросает CloneNotSupportedException), конструктор при клонировании не вызывается, а копия всегда поверхностная, вложенные коллекции копируют руками. Поэтому в современном коде clone() для своих классов почти не пишут: он остался удобным для массивов (array.clone()), а объекты копируют конструктором копии или статическим методом copyOf.

живой пример

import java.util.ArrayList;
import java.util.List;

public class CopyDemo {
    static final class Order implements Cloneable {
        String id;
        List<String> items;
        Order(String id, List<String> items) { this.id = id; this.items = items; }

        @Override protected Order clone() {
            try {
                Order copy = (Order) super.clone();   // поверхностная: поле items указывает на тот же список
                copy.items = new ArrayList<>(items);  // делаем копию глубокой руками
                return copy;
            } catch (CloneNotSupportedException e) {
                throw new AssertionError(e);
            }
        }
    }

    record Basket(String id, List<String> items) {
        Basket { items = List.copyOf(items); }       // снимок на входе: внутренний список не изменить снаружи
        Basket withItem(String item) {
            List<String> next = new ArrayList<>(items);
            next.add(item);
            return new Basket(id, next);
        }
    }

    public static void main(String[] args) {
        Order original = new Order("o-1", new ArrayList<>(List.of("book")));
        Order copy = original.clone();
        copy.items.add("pen");
        System.out.println(original.items + " " + copy.items);

        List<String> source = new ArrayList<>(List.of("book"));
        Basket basket = new Basket("b-1", source);
        source.add("pen");
        System.out.println(basket.items() + " " + basket.withItem("cup").items());
    }
}
Запустить

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

Запись Basket показывает подход, который снимает вопрос вообще: неизменяемый объект копировать не нужно, его можно только безопасно отдать. Компактный конструктор делает снимок входящего списка через List.copyOf, а изменение выражают методом withItem, который возвращает новую запись. Так устроены String и типы java.time.

Коротко

  • Класс — чертёж (поля + методы), объект — экземпляр по чертежу, создаётся через 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 проверяет полноту.
  • Вложенный класс по умолчанию static; внутренний без static держит скрытую ссылку на внешний объект и не даёт ему собраться; анонимный класс видит только неизменяемые локальные переменные, а с одним методом его заменяет лямбда.
  • copy = original копирует ссылку; clone() даёт поверхностную копию и требует Cloneable, вложенные коллекции копируют руками; свои классы копируют конструктором копии или copyOf, а неизменяемую запись копировать не нужно.

Что почитать дальше

  • Синтаксис и типы данных — переменные, примитивы, var и базовые конструкции языка.
  • Коллекции — List, Set, Map и как хранить группы объектов.
  • Дженерики — типобезопасные классы и методы, на которых построены коллекции.
  • Records и Optional — record подробнее, а вместе с ним sealed-классы и pattern matching.