Инкапсуляция
Автор: Орц Орцхоев, Введение: Дмитрий Прокопьев
Введение. Основная идея
Глава 1. Зачем нам нужна инкапсуляция?
Критика и принятие инкапсуляции
Что подразумевают под словом “Инкапсуляция”
Что плохого в том, чтобы оставлять данные публичными?
Инкапсуляция всегда в паре с Абстракцией
Не оставляйте данные открытыми
Остерегайтесь слишком жесткого сопряжения
Итоги
Глава 2. Примеры сокрытия данных
Пример защиты членов класса. Поля значимого типа
Защищены ли данные?
А теперь защищены?
Ну теперь-то данные точно защищены?
Примеры защиты членов класса. Поля ссылочного типа
Интерфейсы как помощник инкапсуляции
Решение в три шага:
Вернёмся к примеру с магазином
Введение
Вспомните, как вы сегодня включили свой компьютер. Помните, как подали напряжение на все компоненты устройства, как запустили BIOS и поочередно проверили работоспособность каждого компонента? Как решали, выдать сообщение об ошибке или пойти дальше? Проверили корректность структуры диска? Нашли загрузочные устройства? Вспомнили, где находится операционная система и как ее загрузить?
Нет, вы лишь нажали на кнопку, а все процессы, сложность которых можно изложить только на тысячах страниц текста, произошли автоматически. Почему вы можете пользоваться такой комплексной системой ничего не зная про то, как она работает? Представьте себе, что кнопки запуска нет и что чтобы включить компьютер вам нужно методично выполнить все низкоуровневые инструкции. Это все еще был бы ваш компьютер, устройство с огромным потенциалом, но пользоваться им стало бы невозможно. Но кто-то сумел спрятать под корпусом системного блока десятилетия работы и исследований и подать их вам в виде одной кнопки - кто-то инкапсулировал ваш компьютер.
Инкапсуляцию, как концепцию, можно описать как “искусство выдавать нечто сложное за что-то примитивное”. Она позволяет упростить работу, абстрагироваться от деталей реализации, спрятать от пользователя все, что ему знать необязательно - все ради удобства и эффективности. Звучит знакомо? Это же абстракция как принцип ООП, мы пришли к ней при первом упоминании инкапсуляции. Эти два принципа неразделимы, ведь инкапсуляция это способ достижения абстракции. Как абстрагироваться от деталей реализации сложной системы, фокусируясь на работе, которую она выполняет?
Согласно принципу инкапсуляции, мы должны взять все компоненты этой системы, поместить их в “капсулу” и разместить на “капсуле” наиболее простой интерфейс для ее пользователя. На примере с компьютером, мы берем процессор, элементы памяти, материнскую плату и т.д. и помещаем их в “капсулу” - в корпус системного блока. А затем, размещаем на корпусе простой интерфейс для пользователя - одну кнопку запуска. Гениально и просто.
Глава 1. Зачем нам нужна инкапсуляция?
Инкапсуляцию в этой статье мы будем обсуждать с точки зрения сокрытия информации. Это одна из самых конструктивных идей в мире разработки ПО, и в этой статье мы рассмотрим ее подробнее. Если не скрывать информацию — получится спагетти-код, в котором трудно разобраться.
Критика и принятие инкапсуляции
С сокрытием информации тесно связана идея «секретов» — аспектов проектирования и реализации, которые разработчик ПО решает скрыть в каком-то месте от остальной части программы.
Впервые сокрытие информации было представлено на суд общественности в 1972 г. Дэвидом Парнасом в статье «On the Criteria to Be Used in Decomposing Systems Into Modules (О критериях, используемых при декомпозиции систем на модули)».
А затем, в юбилейном 20-летнем издании книги «Мифический человеко-месяц» Фред Брукс пришел к выводу, что критика сокрытия информации была одной из ошибок, допущенных им в первом издании книги.
«Парнас был прав в отношении сокрытия информации, а я ошибался», — признал он. Он сообщил, что сокрытие информации — мощный метод избавления от повторной работы, и указал, что оно особенно эффективно в средах с высоким уровнем изменений. В контексте разработки ПО сокрытие информации оказывается особенно мощным принципом, так как все его аспекты и даже само название подчеркивают сокрытие сложности.
Что подразумевают под словом “Инкапсуляция”
Инкапсуляция — это принцип, который предполагает упаковку данных и методов, работающих с этими данными, в единый объект — класс. Основная идея инкапсуляции заключается в том, чтобы скрыть внутреннюю реализацию объекта от внешнего мира и предоставить доступ к его функциональности через установленные методы. Переводя с эльфийского: «Скрыть данные, и позволить их менять только через публичные методы».
Что плохого в том, чтобы оставлять данные публичными?
Начнём с рассмотрения типичной проблемы, которая возникает с публичными данными.
Допустим, вы пишете программу, каждый объект которой должен иметь уникальный идентификатор, хранящийся в переменной id
. Вместе с ней будет публично доступная переменная idCounter
.
При создании новых объектов вы можете, скажем, в конструкторе каждого объекта, просто выполнять команду id = ++idCounter
, что гарантирует уникальность идентификаторов, и требует абсолютно минимального кода при создании каждого объекта.
Разве это может привести к каким-нибудь неприятностям?
Может. Что, если вы захотите повторно задействовать идентификаторы уничтоженных объектов? Что, если для повышения защищенности программы вы захотите назначать идентификаторы в другом порядке? А если захотите включить в программу тест, проверяющий, не превысило ли число идентификаторов допустимый предел?
Если вы распространите команды id = ++idCounter
по всей программе, вам придется изменить каждую из них. Кроме того, этот подход небезопасен в многопоточной среде.
Способ генерации новых идентификаторов является тем аспектом проектирования, который следует скрыть. Применив команду ++idCounter
, вы раскроете сведения о том, что новый идентификатор создается просто путем увеличения переменной idCounter.
Если же вместо этого вы используете команды id = NewId()
, вы скроете информацию о способе создания новых идентификаторов. Сам метод NewId()
может состоять из строки return ( ++idCounter)
или ее эквивалента, однако, если вы позднее решите повторно использовать старые идентификаторы, вам придется изменить только метод NewId()
, но не десятки команд id = NewId()
.
Какими бы сложными ни были изменения метода NewId()
, они не повлияют ни на какую другую часть программы.
Допустим теперь, что вам понадобилось изменить тип идентификатора с целочисленного на строковый. Если по всей программе у вас разбросаны объявления вроде int id
, метод NewId()
не поможет.
В этом случае вам тоже придется просмотреть всю программу и внести десятки или сотни изменений.
Инкапсуляция всегда в паре с Абстракцией
Инкапсуляция является более строгой концепцией, чем абстракция. Абстракция помогает управлять сложностью, предоставляя модели, позволяющие игнорировать детали реализации.
Следующее описание и пример абстракции взяты из книги «Погружение в паттерны проектирования, Александр Швец (2018)»
«Когда вы пишете программу, используя ООП, вы представляете её части через объекты реального мира. Но объекты в программе не повторяют в точности их реальные аналоги, да и это редко когда нужно. Вместо этого, объекты программы всего лишь моделируют поведение реальных объектов, важных в том или ином контексте, а остальные свойства реального объекта игнорируют. Так, например, класс Самолёт будет актуален как для программы тренажёра пилотов, так и для программы бронирования авиабилетов, но в первом случае будут важны детали пилотирования самолёта, а во втором — лишь расположение и занятость мест внутри самолёта.»
С. Макконнелл в книге «Совершенный код» писал: «Инкапсуляция не позволяет узнать детали реализации, даже если вы этого захотите. Две этих концепции связаны: без инкапсуляции абстракция обычно разрушается. По своему опыту могу сказать, что вы или имеете и абстракцию, и инкапсуляцию, или не имеете ни того, ни другого. Промежуточных вариантов нет.»
Не оставляйте данные открытыми
Предоставление доступа к данным-членам (полям) нарушает инкапсуляцию и ограничивает контроль над абстракцией. Как указывает Артур Риэль, класс Point
(точка), который предоставляет доступ к данным:
нарушает инкапсуляцию, потому что клиентский код может свободно делать с данными Point
что угодно, при этом сам класс может даже не узнать об их изменении. В то же время класс Point
, включающий методы:
может инкапсулировать дополнительную логику. Вы не имеете понятия о том, реализованы ли данные как float
x
, y
и z
, хранит ли класс Point
эти элементы как double
, преобразуя их в float
, или же он хранит их на Луне и получает через спутник. Ранее мы уже разбирали пример с id
, где публичные данные нарушили инкапсуляцию, и узнали к каким проблемам это может привести — расширение программы станет трудоемким, и всюду придется вносить исправления, потому что наши данные торчат наружу. А с помощью метода, при необходимости, мы исправим всё разом, внеся изменения лишь в одном месте.
Важно! Это не значит, что все поля нужно покрывать геттерами и сеттерами. Это требуется в тех ситуациях, когда мы хотим ограничить спектр доступных операций или добавить дополнительную логику. А в случаях, когда все операции допустимы и дополнительная логика точно не потребуется, это может быть уместно. Например, публичные поля x
, y
и z
в структуре Vector3
(в Unity) уместно оставить публичными, так как любые их значения и изменения корректны для Vector3
.
Остерегайтесь слишком жесткого сопряжения
Сопряжение (coupling) характеризует силу связи между двумя классами. Как правило, чем сопряжение слабее, тем лучше. Из этого можно вывести несколько общих правил, выделим пока только те, которые будут понятны начинающим:
минимизируйте доступность классов и их членов (поля и методы);
делайте данные базового класса закрытыми, а не защищенными: это ослабляет сопряжение производных классов с базовым;
Итоги
Итак, мы узнали, что инкапсуляция помогает решить ряд проблем:
Сокрытие реализации: Ее мы уже рассмотрели в Главе 1. Инкапсуляция позволяет скрыть детали реализации объекта, что облегчает изменение внутренней структуры без воздействия на внешний код. Это способствует легкости сопровождения и расширения программного кода.
Защита данных: Ее мы рассмотрели в подпункте «Не оставляйте данные открытыми». Инкапсуляция позволяет установить правила доступа к данным, предотвращая напрямую изменение или чтение данных извне объекта. Это способствует обеспечению целостности данных и повышает безопасность программы.
Упрощение интерфейса: Здесь подразумеваются публичные методы, но не интерфейсы как тип в C#. Этот аспект инкапсуляции облегчает использование объекта другими частями программы, скрывая сложные детали его реализации. В пример можно привести сложные алгоритмы, которые скрыты под вызовом одного метода.
В следующей главе мы разберем «сокрытие данных» на примерах, чтобы вы знали как ее применять, и увидели конкретные кейсы.
Глава 2. Примеры сокрытия данных
Пример защиты членов класса. Поля значимого типа
Защищены ли данные?
Допустим у нас есть кошелёк Wallet
. Посмотрите на изображение и ответьте, защищены ли данные кошелька?
Нет, не защищены. Почему?
Публичное поле Money
поддерживает некоторые операции, которые не должны быть доступны пользователю. Например, присваивание нового значения. При работе с кошельком пользователю должны быть доступны только операции зачисления и снятия средств со счета.
Мы скрыли данные, установив модификатор private
, и действительно, компилятор кидает ошибку компиляции, когда мы пытаемся изменить их напрямую. Осталось добавить метод, чтобы изменять эти данные. Ведь кошелек должен позволять класть в него деньги, и доставать их оттуда.
Для полноты картины добавим исходные данные через конструктор new Wallet(100)
, иначе нечего будет снимать. В след за этим добавим сам метод снятия денег со счета Remove(int amount)
:
А теперь защищены?
Нет, всё ещё не защищены. Мы здесь лишь реализовали ту часть инкапсуляции, которая гласит: «Это упаковка данных и методов, работающих с этими данными, в единый объект».
Но захлопнув дверь, у нас распахнулись окна. Защита данных — это не только про их изменение через публичные методы, но и про сохранение их в валидном состоянии.
Валидное состояние — это состояние, при котором у нас денег либо нет = 0, либо они есть, и их количество положительное > 0. Отрицательного количества денег быть не может. Поэтому, если значение _money
станет отрицательным — наш класс ломается изнутри и становится по сути бесполезным.
На картинках видно, что исходное значение _money
равно 100. В методе Main()
мы вызываем метод Remove(1000)
, который хочет снять с кошелька 1000 единиц валюты. Всё, мы нарушили инвариант класса, денег в нём -900 — кошелёк сломан, и все дальнейшие взаимодействия с ним будут недействительны. Ведь класс будет работать в уже сломанном состоянии.
Давайте это исправим, защитим наши данные, чтобы при попытке снять деньги, мы убедились, что соблюдается инвариант класса (то есть деньги не уйдут в минус):
На изображении видно, что мы добавили условие, которое проверяет, превысило ли входное значение amount
наше количество денег _money
. Если превысило, значит результат снятия будет отрицательным. Нас такое не устраивает, и дальше с таким состоянием кошелька программа работать не должна. Наша задача жестко обозначить, что такая операция невозможна с нашим кошельком. Поэтому мы бросили ошибку: throw new InvalidOperationException()
. Это прервёт выполнение программы, и мы будем уверены, что с нашим классом никто не будет работать дальше в таком состоянии.
Ну теперь-то данные точно защищены?
Почти. Присмотритесь к конструктору Wallet
, можно ли через него сломать инвариант кошелька?
Решение оставляю на вас, ваша задача предотвратить получение в конструктор Wallet
отрицательного значения money
. А затем двигайтесь дальше, в следующей теме мы разберём, как защищать поля ссылочного типа!
Примеры защиты членов класса. Поля ссылочного типа
Мы разобрались с простой защитой данных значимого типа. Теперь вы готовы увидеть, как защищать класс, у которого есть поле имеет ссылочный тип. Для примера у нас будет магазин Shop
, с полем List<string> Products
. Ссылочный тип здесь — List
. Взгляните на изображения, и познакомьтесь с кодом:
С выводом всё в порядке, но есть подвох, и вы, вероятно уже догадались, что первая проблема в модификаторе доступа public
у поля List<string> Products
. Стоит отметить, что нам доступен список и для чтения, и для изменения.
Причем, раз это ссылочный тип — нам доступно и изменение ссылки на Products
, и изменение внутреннего состояния, то есть элементов списка. Сделать это можно как c помощью методов самого списка Products.Clear()
, так и с помощью индексатора Products[0]
.
Проблема здесь в том, что список можно менять снаружи класса. Так мы рискуем повредить инвариант класса Shop
, когда его данные внезапно кто-то испортит.
Давайте применим все способы, за исключением методов самого списка. Понятно, что раз их можно вызвать, то мы уже провалились в защите поля класса Shop
. Ниже продемонстрированы все варианты нарушить инвариант класса:
В консоли мы увидим результаты наших проделок. Элемент успешно изменён, ссылка на список тоже успешно изменена. Но как этого не допустить? Самый простой вариант, это «заприватить» наше поле:
Отлично! Теперь наше поле защищено, и ещё на этапе компиляции наша IDE(среда разработки) нас предупредит, что такие изменения не доступны. Часть работы сделана!
Сразу же рассмотрим ещё один кейс, при котором надо всё таки очень нужно получать список на чтение. Как его защитить в таком случае?
Первое, что приходим на ум — сделать из него свойство на чтение следующим образом:
Хорошо, теперь изменить ссылку извне ему нельзя. Но что ещё можно делать с ссылочным типом, у которого есть свои данные? Конечно же менять эти данные. Так и получится:
Изменение ссылки недоступно, зато всё внутреннее состояние списка полностью подвержено изменениям. Скоро мы решим и эту проблему тоже. Но сначала нам нужно раскрыть тему интерфейсов. К защите нашего магазина мы вернёмся по позже, а пока, приглашаю в следующую главу «Интерфейсы как помощник инкапсуляции».
Интерфейсы как помощник инкапсуляции
Интерфейс в C# это схема всех публичных элементов, которые должен реализовать класс (методы, свойства, события). Это название отражает суть идеи интерфейса, предоставить некоторый "пользовательский интерфейс", к которому можно "присоединить" любой класс, реализующий указанный в интерфейсе функционал.
Разберём сразу на примере. Взгляните на код на изображении, а именно на класс Warrior
. На примере этого класса мы и раскроем интерфейсов с точки зрения инкапсуляции.
Проблема заключается в том, что в метод Attack(Warrior warrior)
передаётся целый воин. Почему это проблема? Дело в том, что у Warrior
есть несколько публичных методов, и все их можно будет вызвать. К примеру, мы можем вызвать в методе Attack =>
warrior.RemoveHeal(100);
Правильно ли это, лечить врага? Нет. Правильно ли то, что мы в целом имеем доступ к излишнему функционалу другой сущности? Тоже нет. Так как же запретить вызывать что либо, кроме нужного метода RemoveDamage()
? Тут нам и поможет интерфейс. С помощью него мы ограничим доступный публичный функционал (доступный интерфейс). Смотрим реализацию на следующем изображении:
Решение в три шага:
Объявим интерфейс:
Реализуем интерфейс в классе
Warrior
. Реализация подразумевает описание методаTakeDamage()
какой-то логикой. В нашем случае она останется какой и была:Последним шагом будет заменить параметр
Warrior warrior
на тип интерфейсаIDamageable damageable
. Отлично, теперь мы увидим, что единственный доступный метод, существующий уWarrior
— этоTakeDamage()
. Отмечу, что мы именовали параметр метода какdamageable
, так как под интерфейсом может прийти любой класс, который реализует этот интерфейс. Это значит, что он не обязательно будетWarrior
, это вполне может быть и “ящик с припасами”, который мы можем ударить, и вообще что угодно.
Вот мы и разобрали интерфейсы относительно инкапсуляции. Защитили входной параметр, чтобы мы не получали лишние данные, и снизили вероятность ошибки нашими коллегами. Также мы и “расширили” возможные входные типы, но это уже другая тема.
Вернёмся к примеру с магазином
Для решения нам понадобится специальный интерфейс, который ограничит публичный функционал свойства Products
. Коллекции В C# реализуют интерфейс IEnumerable
, он предоставляет нам метод GetEnumerator()
. Этот метод вызывается в foreach
, чтобы доставать элементы из коллекции и взаимодействовать с ними. Всё, больше нам ничего не доступно. Смотрим на реализацию:
На строке public IEnumerable Products => _products;
при доступе к Products
он будет приведён к типу IEnumerable
, и будет вести себя как IEnumerable
, то есть иметь лишь метод GetEnumerator()
. Мы можем в этом убедиться, посмотрев на количество предлагаемых нам методов у свойства Products
:
Среди них есть и другие методы, такие как Equals
, GetType
, GetHashCode
, ToString
. Но о них не переживайте. Каждый тип данных в C# наследуется от типа object
, и также наследует и переопределяет его методы. Это как раз они и есть.
Сравните тип данных, который мы получаем, когда вызываем Products
, и тип данных, который мы получим, при вызове _products
. По этой причине, мы сделали тип свойства Products
— IEnumerable
, чтобы он возвращался наружу через этот "ограничитель".
Это базовые вещи в инкапсуляции, которые нужно знать. Теперь вы способны сами позаботиться о своих классах. Предлагаю вам пройти упражнения, чтобы закрепить полученные знания.
Last updated
Was this helpful?