Инкапсуляция в Java — это принцип ООП, при котором класс хранит свое состояние в закрытых полях (private) и позволяет менять его только через свои методы, а те проверяют правила. Цель — чтобы объект нельзя было привести в неверное состояние снаружи: отрицательный баланс, пустое имя, переполненная группа.
Содержание
- Пример до: открытые поля
- Пример после: private-поля и методы с проверками
- Что будет, если обратиться к private-полю
- Модификаторы доступа в Java
- Геттеры и сеттеры: когда они нужны, а когда нет
- Утечка через геттер: изменяемые коллекции
- Неизменяемые классы и record
- Инкапсуляция — не защита от взлома
- Выводы
- Где применяется / связь с практикой
- FAQ
Три слова часто смешивают, поэтому сразу разведу их:
| Термин | Что это | Как выглядит в 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) с проверкой |
нет |
Как решать:
- Спросить, какие операции нужны объекту по смыслу: «пополнить», «списать», «перевести», а не «установить баланс».
- Геттер дать, если значение действительно нужно снаружи.
- Сеттер — только для полей, которые можно менять по смыслу, и с проверкой внутри.
- Поле, которое не должно меняться после создания, объявить
finalи задавать в конструкторе. - Те же проверки, что в методах, повторить в конструкторе, иначе объект можно создать сразу неверным.
Утечка через геттер: изменяемые коллекции
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 снаружи.



