Appearance
Инкапсуляция
Первый столп ООП
В прошлой лекции мы познакомились с классами и объектами. Научились писывать класс и создавать экземпляры этого класса.
На первый взгляд может показаться, что класс - просто удобный способ объединить несколько переменных и функций.
Ну, так и есть. Это одна из задач класса. Но если мы остановимся на этом, то не увидим одну из важнейших задач ООП. Вопрос на повестке дня:
А что мешает любому другому коду взять и испортить состояние нашего объекта?
Давайте возьмём пример из задания прошлой лекции и рассмотрим одну проблему:
java
class BankAccount {
String owner;
double balance;
void deposit(double amount) {
balance += amount;
}
void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
}
}
}
Вроде всё нормально. Есть класс, который описывает банковский счёт, есть методы, которые описывают его поведение. Даже проверочка есть на то, чтобы нельзя было списать больше денег, чем есть на счету. Но:
java
void main() {
BankAccount account = new BankAccount();
account.balance = -1234567;
}
И вот начинается самое интересное.
Класс мы создали, проверку баланса сделали:
java
if (balance >= amount)
То есть класс пытается не допустить отрицательный баланс. Но на данный момент ничто не мешает нам напрямую установить отрицательное значение поля balance. И, по сути, от такой ситуации наша проверка не спасает, ведь к полю мы обращаемся в обход метода с проверкой.
Получается странная ситуация: мы написали метод withdraw(), который защищает объект от некорректного состояния, но одновременно с этим оставили возможность обойти этот метод.
Любой внешний код может просто проигнорировать этот метод.
java
account.balance = 1000000;
account.balance = -500000;
account.balance = 42;
Объект никак не может этому помешать.
И как в итоге сделать так, чтобы изменить баланс можно было только контролируемым способом?
Первый, самый простой вариант - питонистический. Что-то вроде:
Пожалуйста, очень вас просим, не изменяйте
balanceнапрямую.
Стоит ли говорить, что это тупой вариант?
Программа не должна зависеть от того, насколько внимательно каждый программист соблюдает устную договорённость.
Если какое-то правило действительно важно, желательно, чтобы его обеспечивала сама программа.
И для этого в Java существуют модификаторы доступа.
Модификаторы доступа
Модификаторы видимости - это ключевые слова, которые определяют уровень видимости и доступности классов, методов, полей и конструкторов. В Java существует четыре уровня доступа. Их всего четыре и с двумя из них мы уже знакомы. Пойдём от самого "закрытого" модификатора к самому "открытому".
private
Начнём с самого важного модификатора в контексте инкапсуляции - private. Начнём сразу с примера. Возьмём класс BankAccount:
java
class BankAccount {
String owner;
private double balance;
// ...
}
Добавили одно слово к полю balance и починили класс. private означает, что поле/метод/конструктор/класс/нужное подчеркнуть доступны только внутри того же класса. То есть, к полю balance мы можем обратиться только изнутри класса BankAccount:
java
class BankAccount {
String owner;
private double balance = 0;
void deposit(double amount) {
balance += amount;
}
}
// ...
void main() {
BankAccount account = new BankAccount();
account.deposit(1000); // теперь на балансе 1000 у.е. Всё работает нормально
account.balance = 9999; // Ошибка. Поле balance тут недоступно
IO.println(account.balance); // Тоже ошибка. Мы вообще не видим поле вне класса
}
Подводя итоги:
private (частный) - член класса (поле/метод/конструктор/...) доступен только в том же классе, где он был объявлен
package-private
Второй модификатор, с которым мы точно сталкивались. Не помните? Точно говорю, вы с ним работали. Иначе он называется пакетным или default и применяется он в том случае, если мы явно не указываем никакой другой модификатор.
java
class BankAccount {
String owner; // Мы явно не указали модификатор, поэтому по умолчанию package-private
double balance; // тоже package-private
}
Класс в примере выше тоже имеет модификатор доступа package-private.
Член класса с таким модификатором доступен всем классам в том же пакете. О том, что такое пакет, на этом курсе мы скорее всего вообще обсуждать не будем. Пока что воспринимайте пакет как папку. Крайне специфичный модификатор, целенаправленно используется редко.
protected
Доступен в том же пакете, плюс в любых подклассах, даже если они находятся в другом пакете. О наследовании позже.
public
Крайне распространенный модификатор. Самый широкий уровень доступа. Член доступен из любого места программы.
Сводная табличка
| Модификатор | Тот же класс | Тот же пакет (папка) | Подкласс (другой пакет) | Весь мир |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
default | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
Так а что такое инкапсуляция?
Теперь вернёмся к основному термину этой лекции.
Инкапсуляция - это объединение данных и методов, работающих с этими данными, внутри одного объекта и ограничение доступа к внутреннему состоянию объекта
Проще говоря - объект обязан защищать своё состояние от внешнего кода.
В нашем примере:
java
class BankAccount {
private double balance;
public void deposit(double amount) {
balance += amount;
}
}
Поле private double balance полностью скрыто от внешнего кода, а метод deposit(...) является способом взаимодействия с объектом. Внешнему коду нежелательно знать, как именно внутри хранится баланс. Он просто говорит account.deposit(100), а объект уже сам решает что и как при этом делать.
А что мешает написать плохой метод?
А теперь может возникнуть резонный вопрос: а что мешает нам сделать так:
java
public void setBalance(double balance) {
this.balance = balance;
}
Никаких проверок. Мы всё ещё можем сделать:
java
account.setBalance(-999);
Ииии... Мы вернулись к тому, с чего начали.
Делаем вывод, что одного private недостаточно.
Инкапсуляция - это не просто "ставим везде приват". Важно ещё и контролировать поведение, которое меняет состояние объекта.
Вот пример нормального кода:
java
public void deposit(double amount) {
if (amount <= 0) {
IO.println("Нельзя пополнить баланс на отрциательное количество");
return;
}
balance += amount;
}
public void withdraw(double amount) {
if (amount <= 0 || amount > balance) {
return;
}
balance -= amount;
}
Теперь внешний код не может изменить баланс как ему заблагорассудится. Он может только попросить объект выполнить какую-то операцию, но саму операцию и контроль над данными контролирует объект.
Интерфейс объекта
Здесь не путаем "интерфейс объекта" и ключевое слово Java interface.
Пока что говорим об интерфейсе в общем смысле.
Когда мы используем объект, нам необходимо знать, что он делает. А вот как он это делает - нам неважно.
Например:
java
account.deposit(1000);
account.withdraw(500);
Мы знаем эти операции. Что они делают, что они принимают аргументами. Но нам не нужно знать, как они реализованы внутри.
К примеру, deposit() может содержать просто balance += amount. А может что-то вроде
java
if (blocked) {
// ...
}
if (amount <= 0) {
// ...
}
balance += amount;
saveTransaction(...);
sendNotification(...);
// ...
Но, коду, который использует BankAccount, необязательно знать все эти детали. Достаточно "используем, когда хотим закинуть денег на счёт".
Это и есть одна из важных идей проектирования объектов:
наружу мы предоставляем необходимый интерфейс, а внутреннюю реализацию стараемся скрыть.
Getter
Но возникает проблема: состояние объекта мы скрыли с помощью private. А получить-то его как теперь?
java
IO.println(account.balance); // ошибка. Поля не видно
Для получения значений полей теперь нужно использовать методы:
java
public double getBalance() {
return balance;
}
Теперь:
java
IO.println(account.getBalance());
всё нормально.
Такие методы обычно называют getter или геттеры.
Имя геттера
Имя геттеров строится по схеме: get + ИмяПоля
Например: getBalance(), getName(), getSomethingElse() и так далее.
Setter
А что делать, если значение поля всё же должно изменяться извне?
По аналогии с геттерами, для этого мы используем сеттеры.
java
public void setName(String name) {
this.name = name;
}
И тут появляется важнейшее преимущество инкапсуляции: контроль данных с помощью сеттеров:
java
public void setBalance(double balance) {
if (balance < 0) {
return;
}
this.balance = balance;
}
Вроде изменяем поле напрямую, но при этом следим за тем, что внешний код нам передаёт.
Строится название сеттеров по аналогии с геттерами
Всегда ли нужны геттеры и сеттеры?
Важный момент. Иногда можно машинально написать и геттер, и сеттер. Но это далеко не всегда правильно. Представим:
java
class BankAccount {
private double balance;
public double getBalance() {
return balance;
}
}
Геттер есть, сеттер отсутствует. Почему? Потому что баланс мы можем узнать. Но давать возможность произвольно его менять - плохая идея. Меняться он должен исключительно с помощью предусмотренных операций withdraw() и deposit().
Наличие
privateполя не означает, что для него обязательно должны существовать getter и setter.
Нужно задавать только те операции, которые действительно должны быть доступны пользователю объекта.
А когда закрывать поля с помощью private?
Всегда.
Вообще всегда.
Есть исключения, но они настолько редкие и нишевые, что вы можете об этом не задумываться. Просто закрываем каждое поле.
Главная мысль лекции
ОБЪЕКТ ОТВЕЧАЕТ ЗА СВОИ ПРАВИЛА Он должен не просто хранить данные. Он должен определять, каким образом с этими данными можно работать.
Контрольные вопросы
- Что означает модификатор доступа
private? Чем полеprivateотличается от поляpublic? - Почему наличие
privateполя само по себе ещё не гарантирует корректность состояния объекта? - Что такое инкапсуляция? Какую проблему она решает?
- Почему такой класс можно считать плохо спроектированным?
java
class BankAccount {
public double balance;
}
- В чём разница между getter и setter? Обязательно ли для каждого
privateполя создавать оба метода? - Почему следующий вариант может быть лучше, чем
setBalance()?
java
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
- Что понимается под интерфейсом объекта в контексте этой лекции? Не путайте с ключевым словом
interface. - Что такое сокрытие реализации? Приведите пример ситуации, когда оно позволяет изменить класс, не изменяя код, который его использует.
- Почему с точки зрения ООП желательно, чтобы объект сам отвечал за соблюдение правил, связанных с его состоянием? Приведите пример такого правила для любого объекта.
Задания
Проблемный класс
java
class Student {
public String name;
public int age;
public double averageGrade;
}
Требования:
- сделать состояние объекта защищённым от прямого изменения;
- добавить необходимые методы для чтения данных;
- добавить методы для изменения данных;
- не допускать возраст меньше 0;
- не допускать оценку меньше 0 или больше 5.
Исправить BankAccount
Возьмите класс BankAccount из самого начала лекции.
Требования:
- владелец счёта задаётся при создании объекта;
- баланс изначально равен 0;
- можно пополнять счёт;
- нельзя пополнить счёт на отрицательную или нулевую сумму;
- можно снять деньги;
- нельзя снять больше денег, чем есть на счёте;
- баланс можно узнать, но нельзя установить напрямую.
Игровой персонаж
Спроектируйте класс Character.
Требования:
- здоровье нельзя изменить напрямую;
- здоровье не может стать меньше 0;
- здоровье не может стать больше максимального;
- персонаж может получать урон;
- персонаж может восстанавливать здоровье;
- текущее здоровье можно узнать.
Класс заказа
Дан класс:
java
class Order {
public double price; // стоимость заказа
public int items; // товары в заказе (по айди. Можете заменить на список)
public boolean paid; // оплачен ли заказ
public boolean cancelled; // отменен ли заказ
}
Представьте, что этот класс используется в большой интернет-магазинной системе. Найдите проблемы, связанные с инкапсуляцией, и предложите публичный интерфейс класса. Реализуйте методы:
- добавления товара в заказ;
- проведения оплаты;
- отмены заказа;
- необходимые геттеры и сеттеры.