Каждый объект в системе имеет определенный контракт взаимодействия. Контракт выражается в операциях, которые можно выполнить над объектом:
- Что нужно предоставить ему для корректной работы;
- Что в состоянии объекта может изменяться в процессе работы;
- Что он нам готов предоставить в результате.
Одна из задач проектирования кода – это построение прозрачных контрактов и абстракций. Если контракт понятен, лёгок в использовании и не привносит неожиданностей, то такой контракт хороший.
Абстракция – это, в каком-то смысле, и есть контракт. Мы абстрагируемся от несущественных деталей реализации, и нас также ограничивают от неправильных действий над объектом. Абстракция также накладывает определенный контракт на реализацию. Но в некоторых ситуациях эти слова не взаимозаменяемые: так, при реализации контракта, мы будем писать конкретные строки кода, которые и будут его выражать. Назвать абстракцией этот код не получится.
Описывая класс, мы уже описываем формальный контракт о том, что объект этого типа будет иметь определённые члены. Но также в классы мы сразу записываем реализацию. В других главах этого курса мы познакомимся с построением абстракций без или с частичной реализацией.
В C# уже на этапе проектирования типа мы можем воспользоваться рядом средств, чтобы описать контракт будущего объекта:
- Поля
- Функциональные члены
- Конструкторы
Благодаря этому мы можем указать данные, над которыми мы властвуем, закрыть их от прямого вмешательства. Сделать простые и понятные методы, которые будут по запросу работать с этими данными, и определить конструкторы, которые не позволяют с самого начала существовать в системе объекту с некорректным состоянием.
Состояние – это совокупность значений всех полей объекта.
Если какое-то значение меняется, то меняется и состояние объекта. Наша задача построить объект так, чтобы его состояние не становилось некорректным. Некорректное состояние – это состояние, работа с которым сопряжена с ошибками и багами.
Первое, что нам нужно сделать, это определить: какую ответственность на себя берёт данный тип?
В нашем случае у нас есть задача:
В нашей игре есть автомат. Автомат заряжен патронами разных типов:
- Бронебойный
- Трасирующий
- Обычный
Пока патроны отличаются лишь текстом, который выводится при стрельбе. У автомата есть очередь таких патронов, автомат также можно перезарядить. Перезарядка – это либо установка новой очереди, либо восстановление предыдущей.
Сейчас наша задача построить такой тип, который делал ровно то, что мы хотим, и нельзя было бы сделать того, что мы не ожидаем. На первый взгляд простая задача, не так ли?
Давайте возьмём такое решение:
class Gun
{
public int CurrentBullet;
public List<string> Bullets;
public void Shot()
{
if (CurrentBullet >= Bullets.Count)
return;
Console.WriteLine(Bullets[CurrentBullet]);
CurrentBullet++;
}
public void Reload(List<string> bullets = null)
{
if (bullets == null)
Bullets = bullets;
CurrentBullet = 0;
}
}
Как вы думаете, какие операции мы можем совершать над объектом данного типа?
Даже, на первый взгляд, можно сказать о следующих операциях:
- Вызывать метод Shot (фактически стрелять этим оружием)
- Вызывать метод Reload (фактически перезаряжать оружие)
Это мы поняли, взглянув на публичные методы оружия. Но ограничивается ли спектр наших операций этим? Нет, ещё мы можем делать такие непреднамеренные операции:
- Произвольное изменение текущего патрона в очереди. Ожидает ли данный тип, что снаружи это значение можно изменить как угодно?
- Возможно заменить обойму без сбрасывания указателя на текущий патрон.
И это не всё. Пока что я придержу все карты. Согласитесь, что если мы загрузим обойму через Reload на 10 выстрелов, а потом сделаем 5 выстрелов, то ничего страшного не произойдёт. Но что будет, если мы самостоятельно, без Reload, поставим в поле Bullets обойму, скажем, на 3 патрона, и попробуем сделать выстрел? Внезапно оружие не будет стрелять. А что, если перед этим мы произвели всего два выстрела? Оно выстрелит, но один раз. Не совсем очевидное поведение для того, кто этот код использует не правда ли? Но мы же сами разрешили менять это поле пользователю этим типом, соответственно нет ничего странного в том, что люди пробуют это делать.
Как мы можем защитится? Для начала нам нужно сделать поля private. Это позволяет закрыть доступ к ним вне типа. Добились ли мы защиты внутреннего состояния? Оно стало лучше, но всё ещё есть кое-какие проблемы. Они не критичны в данном типе, но могут вызывать проблемы в других ситуациях.
Обратите внимание на метод Reload. Что он принимает? List<T>. А этот тип ссылочный. Это значит, если нам дадут очередь для стрельбы, то ссылку на эту очередь будет иметь тот, кто нам её дал. И, конечно же, он сможет её произвольно изменять, как ему это хочется. Если бы у нас был чувствительный код к этому, то мы бы получили множество багов.
Что нужно делать в данном случае? Ограничивать абстракцию. Конечно же, нам хватило бы тут и обычного массива. List<T> добавляет операции записи и удаления, и мы, по сути, сами сказали: “дайте нам ссылку на что-то, что без нашего ведома может изменяться, а именно пополняться новыми элементами или стремительно сокращаться”.
Если подвести итог, то инкапсуляция – это защита внутреннего состояния объекта от непреднамеренного воздействия. Эта защита может достигаться множеством путей: правильной декларацией типов, использованием модификаторов доступов и многим другим. В нашем случае, мы рассмотрели первые два.