Инкапсуляция в Java: private-поля, геттеры, сеттеры и пример до и после

Инкапсуляция в Java: private-поля, геттеры, сеттеры и пример до и после Полезное

Инкапсуляция в Java — это принцип ООП, при котором класс хранит свое состояние в закрытых полях (private) и позволяет менять его только через свои методы, а те проверяют правила. Цель — чтобы объект нельзя было привести в неверное состояние снаружи: отрицательный баланс, пустое имя, переполненная группа.

Три слова часто смешивают, поэтому сразу разведу их:

Термин Что это Как выглядит в Java
Инкапсуляция Принцип: данные и правила работы с ними живут в одном классе, и правила нельзя обойти Поля + методы с проверками внутри класса
Сокрытие данных Средство инкапсуляции: внутренности не видны снаружи private-поля
Модификаторы доступа Механизм языка, которым сокрытие делают private, без модификатора, protected, public

Сокрытие без правил — еще не инкапсуляция: private-поле с сеттером, который принимает что угодно, защищает не больше, чем public-поле. Ниже — один класс «до» и «после», модификаторы доступа, геттеры и сеттеры, две типичные утечки и неизменяемые классы.

Все примеры скомпилированы и запущены на OpenJDK 25.0.4 (Temurin, Java 25 LTS) 24 сентября 2026 года, вывод и тексты ошибок взяты из прогона.

Пример до: открытые поля

Аналогия: банковский счет. Клиент не может зайти в хранилище и переписать цифру в ведомости, он просит кассира, а тот проверяет, хватает ли денег. Без инкапсуляции класс работает как хранилище без кассира.

class Account {
    String owner;
    long balance; // копейки

    Account(String owner, long balance) {
        this.owner = owner;
        this.balance = balance;
    }
}

public class Main {
    public static void main(String[] args) {
        Account acc = new Account("Анна", 10_000);
        acc.balance = acc.balance - 25_000; // списали больше, чем есть
        acc.owner = null;                    // и владельца потеряли
        System.out.println(acc.owner + ": " + acc.balance);
    }
}

Программа компилируется и печатает:

null: -15000

Ошибки нет, но объект сломан: баланс отрицательный, владельца нет. Любой код, у которого есть ссылка на acc, может сделать так же, и искать виноватого придется по всему проекту.

Пример после: private-поля и методы с проверками

import java.util.Objects;

class Account {
    private final String owner;
    private long balance; // в копейках, никогда не меньше 0

    Account(String owner, long initial) {
        this.owner = Objects.requireNonNull(owner, "owner");
        if (initial < 0) {
            throw new IllegalArgumentException("initial < 0: " + initial);
        }
        this.balance = initial;
    }

    public String getOwner() {
        return owner;
    }

    public long getBalance() {
        return balance;
    }

    public void deposit(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("amount <= 0: " + amount);
        }
        balance = Math.addExact(balance, amount);
    }

    public void withdraw(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("amount <= 0: " + amount);
        }
        if (amount > balance) {
            throw new IllegalStateException(
                "недостаточно средств: баланс " + balance + ", запрошено " + amount);
        }
        balance -= amount;
    }
}

public class Main {
    public static void main(String[] args) {
        Account acc = new Account("Анна", 10_000);
        acc.deposit(5_000);
        acc.withdraw(3_000);
        System.out.println(acc.getOwner() + ": " + acc.getBalance());

        try {
            acc.withdraw(25_000);
        } catch (IllegalStateException e) {
            System.out.println("Отказ: " + e.getMessage());
        }
        try {
            acc.deposit(-100);
        } catch (IllegalArgumentException e) {
            System.out.println("Отказ: " + e.getMessage());
        }
        System.out.println(acc.getOwner() + ": " + acc.getBalance());
    }
}

Вывод:

Анна: 12000
Отказ: недостаточно средств: баланс 12000, запрошено 25000
Отказ: amount <= 0: -100
Анна: 12000

Что изменилось по сравнению с версией «до»:

  • Поля private: снаружи их не видно, менять можно только через методы класса.
  • Правило «баланс не меньше нуля» записано в одном месте — в withdraw. Его невозможно забыть в другом конце проекта.
  • Неверные запросы отклоняются исключением, а состояние объекта остается прежним: после двух отказов баланс все те же 12000.
  • owner объявлен final и проверен в конструкторе: владелец не может стать null ни при создании, ни потом.
  • Math.addExact бросает ArithmeticException при переполнении long вместо тихого перехода в минус.

Сумма хранится в копейках целым long, а не в double: у двоичной дроби бывают ошибки округления. Для учебного примера этого достаточно, в реальных расчетах часто берут BigDecimal.

Что будет, если обратиться к private-полю

Если в main из примера выше перед строкой acc.deposit(5_000); вставить acc.balance = -1;, программа не соберется:

Main.java:45: error: balance has private access in Account
        acc.balance = -1;
           ^
1 error

Это главная практическая выгода: нарушение ловит компилятор, а не пользователь в продакшене. Исправление — пользоваться методами класса (acc.withdraw(...)) или, если нужной операции нет, добавить ее в класс вместе с проверкой.

Модификаторы доступа в Java

В Java четыре уровня доступа к полям, методам и конструкторам. Уровень без ключевого слова называют package-private (доступ на уровне пакета).

Модификатор Свой класс Тот же пакет Наследник в другом пакете Любой другой код
private да нет нет нет
без модификатора (package-private) да да нет нет
protected да да да, с оговоркой нет
public да да да да

Два места, где путаются чаще всего:

  • protected в Java шире, чем кажется. Он открывает член всему пакету, а не только наследникам. Наследник из другого пакета видит его через this или ссылку на свой тип, но не через ссылку на произвольный объект базового класса.
  • Отсутствие модификатора — не private. Поле без модификатора видно всем классам того же пакета.

Проверка на двух пакетах:

package shop.core;

public class Price {
    private long kopecks;          // только этот класс
    long cachedTotal;              // package-private: весь пакет shop.core
    protected String currency = "RUB"; // пакет shop.core + наследники
    public long rubles() { return kopecks / 100; } // всем
}
package shop.app;

import shop.core.Price;

public class App {
    public static void main(String[] args) {
        Price p = new Price();
        System.out.println(p.rubles());
        System.out.println(p.cachedTotal);
    }
}

Команда javac -d out shop/core/Price.java shop/app/App.java дает:

shop/app/App.java:9: error: cachedTotal is not public in Price; cannot be accessed from outside package
        System.out.println(p.cachedTotal);
                            ^
1 error

Для классов верхнего уровня допустимы только public и package-private. Модули Java (с Java 9) добавляют еще одну границу: public-класс из пакета, который модуль не экспортирует в module-info.java, недоступен другим модулям.

Практическое правило выбора: начинать с самого узкого уровня (private) и расширять, только когда появился реальный внешний пользователь.

Геттеры и сеттеры: когда они нужны, а когда нет

Геттер (getBalance()) возвращает значение поля, сеттер (setBalance(long)) его меняет. Имена по схеме getX/setX/isX — соглашение JavaBeans, на него опираются многие библиотеки и фреймворки.

Автоматически сгенерированная пара геттер + сеттер на каждое поле — не инкапсуляция. Сравните:

Вариант Можно сделать баланс отрицательным?
public long balance; да
private long balance; + setBalance(long v) { balance = v; } да
private long balance; + withdraw(long) с проверкой нет

Как решать:

  1. Спросить, какие операции нужны объекту по смыслу: «пополнить», «списать», «перевести», а не «установить баланс».
  2. Геттер дать, если значение действительно нужно снаружи.
  3. Сеттер — только для полей, которые можно менять по смыслу, и с проверкой внутри.
  4. Поле, которое не должно меняться после создания, объявить final и задавать в конструкторе.
  5. Те же проверки, что в методах, повторить в конструкторе, иначе объект можно создать сразу неверным.

Утечка через геттер: изменяемые коллекции

private защищает ссылку, но не объект, на который она указывает. Если геттер отдает внутренний List, внешний код меняет его напрямую:

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

class Course {
    private final List<String> students = new ArrayList<>();

    public void enroll(String name) {
        if (students.size() >= 2) {
            throw new IllegalStateException("мест нет");
        }
        students.add(name);
    }

    public List<String> getStudents() {
        return students; // отдаем ссылку на внутренний список
    }
}

public class Main {
    public static void main(String[] args) {
        Course c = new Course();
        c.enroll("Анна");
        c.enroll("Борис");
        c.getStudents().add("Вера"); // лимит обойден снаружи
        System.out.println(c.getStudents());
    }
}

Результат — три студента при лимите в два:

[Анна, Борис, Вера]

Исправление — возвращать неизменяемую копию: return List.copyOf(students);. Тогда вызов c.getStudents().add("Вера") бросает UnsupportedOperationException, и прогон с перехватом этого исключения печатает:

Снаружи не добавить: UnsupportedOperationException
[Анна, Борис]

Альтернатива — Collections.unmodifiableList(students): это представление без копирования, оно запрещает изменения снаружи, но показывает будущие изменения внутреннего списка. То же касается массивов и java.util.Date: их тоже отдают копией.

Неизменяемые классы и record

Самая сильная форма инкапсуляции — объект, который вообще нельзя изменить после создания. Классический рецепт: класс final, все поля private final, значения проверяются в конструкторе, сеттеров нет, изменяемые части копируются на входе и на выходе.

С Java 16 для этого есть record: компилятор сам создает private final-поля, конструктор, методы доступа (title(), а не getTitle()), equals, hashCode и toString. Проверки кладут в компактный конструктор:

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

record Group(String title, List<String> students) {
    Group {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title пустой");
        }
        students = List.copyOf(students); // защитная копия, неизменяемая
    }
}

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>(List.of("Анна", "Борис"));
        Group g = new Group("Java-1", names);

        names.add("Вера"); // меняем исходный список
        System.out.println(g);

        try {
            g.students().add("Глеб");
        } catch (UnsupportedOperationException e) {
            System.out.println("Изменить нельзя: " + e.getClass().getSimpleName());
        }
    }
}

Вывод:

Group[title=Java-1, students=[Анна, Борис]]
Изменить нельзя: UnsupportedOperationException

Без строки students = List.copyOf(students); record хранил бы ссылку на чужой изменяемый список, и «Вера» попала бы внутрь группы. record делает неизменяемыми только свои поля, а не объекты, на которые они ссылаются. Еще одна граница: List.copyOf бросает NullPointerException, если в списке есть null.

Инкапсуляция — не защита от взлома

Модификаторы доступа защищают от ошибок программиста, но не от злоумышленника с доступом к процессу. Через рефлексию private-поле собственного класса читается без препятствий:

import java.lang.reflect.Field;

class Secret {
    private int pin = 1234;
}

public class Main {
    public static void main(String[] args) throws Exception {
        Field f = Secret.class.getDeclaredField("pin");
        f.setAccessible(true);
        System.out.println("pin = " + f.getInt(new Secret()));

        Field v = String.class.getDeclaredField("value");
        v.setAccessible(true);
    }
}

Первая часть печатает pin = 1234. Вторая падает: внутренности JDK закрыты системой модулей (строгая инкапсуляция включена по умолчанию с Java 16, а с Java 17 ее больше нельзя ослабить флагом --illegal-access; остается только явный --add-opens):

Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @14dad5dc

Хеш после @ меняется от запуска к запуску. Вывод для практики: пароли, ключи и PIN-коды не становятся защищенными от того, что лежат в private-поле. Их защищают хранением вне кода, шифрованием и правами доступа.

Выводы

  • Инкапсуляция в Java = закрытое состояние (private) + методы, которые проверяют правила; одно без другого не работает.
  • Модификаторы: private < package-private < protected < public; protected открывает член еще и всему пакету.
  • Геттер и сеттер без проверок почти не отличаются от public-поля; лучше методы по смыслу предметной области.
  • Изменяемые коллекции отдают копией (List.copyOf), иначе private обходится через геттер.
  • Для неизменяемых объектов удобен record с проверками и защитными копиями в компактном конструкторе.

Где применяется / связь с практикой

Освойте тему на практике

Инкапсуляция встречается в каждом Java-проекте: сущности предметной области, DTO, классы конфигурации, публичные API библиотек. На собеседованиях джунов почти всегда просят объяснить разницу между protected и package-private и показать, чем плохой сеттер отличается от метода с проверкой. Если хочется разобрать ООП на Java последовательно, с задачами и ревью кода, посмотрите курс «Java разработчик. Базовый уровень». Попробовать формат можно на бесплатных открытых уроках Otus.

Как тот же принцип устроен в другом языке, где есть уровень internal и границы сборок, — в статье про инкапсуляцию и модификаторы доступа в C#.

FAQ

Чем инкапсуляция отличается от абстракции?
Абстракция решает, какие операции объект показывает миру (что он умеет), инкапсуляция — как спрятать реализацию и не дать обойти правила. На практике они работают вместе: интерфейс задает абстракцию, private-поля и проверки в классе обеспечивают инкапсуляцию.

Нужно ли писать геттеры вручную или можно использовать Lombok?
Lombok генерирует геттеры и сеттеры аннотациями, но решает только проблему шаблонного кода, а не проектирования: @Setter на поле с правилами так же опасен, как ручной сеттер без проверки. Для неизменяемых данных часто достаточно встроенного record.

Можно ли сделать private конструктор?
Да. Так делают классы с фабричными методами (у Optional конструктор закрыт, объект создают через Optional.of), синглтоны и утилитные классы вроде Math, которые не должны создаваться через new снаружи.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)