Проектирование классов на Swift

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Содержание

  1. Введение

  2. Виды классов и их представление

  3. Монолиты

  4. Когда выбрать наследование, а когда композицию

  5. Почему абстракции так важны

  6. Наследовать не для повторного использования

  7. Виртуальные функции

  8. Данные-члены должны быть закрытыми

  9. Вывод

Введение

В данной статье я хочу рассмотреть ключевые вопросы касательно проектирования классов на языке Swift и их особенности. Мы рассмотрим как это сделать правильно, как не допускать ошибки, избежать проблем и как правильно управлять зависимостями между объектами.

Виды классов и их представление

Существует большое количество различных видов классов и каждый вид служит для разных целей. При проектировании у вас всегда должно быть чёткое представление того, какую проблему должен решать ново созданный класс. Каждый вид следует определенным правилам.

Базовый класс. Такой вид классов должен представлять собой некий строительный блок для всей последующей иерархии классов. Базовый класс обладает следующими свойствами:

  • Обычно базовый класс представляет интерфейс посредством виртуальных функций, но язык Swift такой функциональностью не наделен, из-за этого разработчики часто приходят к ''костыльной" реализации данного механизма путем написания fatalError в методе, который необходимо переопределить в дочернем классе или же оставляют его пустым. В случае если разработчик создаст экземпляр такого класса и вызывает метод, то он получит ошибку о том, что этот метод должен быть переопределен.

  • Объекты такого класса представляют семантику ссылочного типа и создаются динамически в куче как часть объекта производного класса и используются посредством указателей.

Классы стратегий. Шаблонные классы, являются фрагментами сменного поведения. Такого рода классы очень часто можно встретить при работе с ViewModels, где есть базовая ViewModel, например, для работы с UITableViewCell, которая имеет определенный интерфейс, заданный с помощью виртуальных функций, таким образом, дочерние ViewModels реализующие уже конкретные ячейки, переопределяя методы базовой ViewModel, выступают в роле частного алгоритма, то бишь стратегии поведения. Классы стратегий обладают следующими свойствами:

  • Могут иметь состояния.

  • Могут иметь виртуальные функции и свойства, а могут и нет.

  • Объекты данного класса не создаются, а выступают в качестве базового класса.

Класс-значение. Такие классы моделируют встроенный тип и должны обладать следующими свойствами:

  • Имеют копирующий конструктор и присваивание с семантикой значения.

  • Не имеют виртуальных функций.

  • Предназначены для использования в качестве конкретных классов, а не в качестве базовых.

  • Размещаться в стеке.

  • При передаче в метод в качестве параметра не копируются, а передаются посредствам указателя, до какой-либо модификации.

  • Реализуют механизм copy on write.

Класс-свойство. Так как язык Swift не обладает возможность создавать пользовательские пространствами имён, то такого рода классы представляют собой шаблон-контейнер, который несет информацию о типе и его функциональности. Класс-свойство обладает следующими характеристиками:

  • Содержит только статические поля и методы.

  • Не имеет модифицируемого состояния.

  • Не имеет виртуальных функций.

  • Объекты данного класса не создаются (конструкторы приватные).

Монолиты

Декомпозируй! Я очень часто замечал одну и туже ошибку - огромные причудливые классы с кучей методов. Я могу согласиться с тем, что иметь все в одном месте очень удобно, однако подход при разработке небольших классов, которые легко комбинировать между собой, оказывается более успешен для систем любого размера и сложности.

  • Минимальный класс легче понять и проще использовать повторно.

  • Минимальный класс проще в употреблении. Монолитный класс часто должен использоваться как большое неделимое целое. Например, у нас есть датчик, который измеряет температуру в помещении с каким-то интервалом. Для взаимодействия с ним мы разработали класс DateTemperature, который имеет поля и методы необходимые для работы со временем и температурой. На следующий день к нам приходит наш член команды и хочет воспользоваться DateTemperature для работы со временем, но он замечает, что в нашей реализации слишком много лишнего и принимает решение написать свой собственный класс DateTime и перенести с DateTemperature все необходимые методы в него, аналогично с температурой. После всех манипуляций мы сдаем наши классы в тестирование и как это обычно бывает, там находят кучу багов. Вносим изменения в DateTemperature, однако кто вспомнит, что есть класс DateTime, который тоже нужно исправить и наоборот. Для решения этой проблемы мы должны были воспользоваться принципом SRP (Single responsibility principle) и изначально создать классы DateTime и Temperature. А если бы нам был нужен класс-фасад, то тогда бы мы могли создать DateTemperature, который бы содержал оба эти класса по средствам агрегации и делегировал бы им выполнение.

  • Монолитные классы снижают инкапсуляцию. Если класс имеет много функций-членов, которые не обязаны быть членами, но тем не менее являются таковыми (таким образом обеспечивается излишняя видимость закрытой реализации), то закрытые члены-данные класса становятся почти столь же плохими с точки зрения дизайна, как и открытые переменные.

  • Монолитные классы обычно являются результатом попыток предсказать и предоставить "полное" решение некоторой проблемы.

  • Монолитные классы сложнее сделать корректными и безопасными в связи с тем, что при их разработке зачастую нарушается принцип "Один объект - одна задача".

Композиция

Используя безусловно мощнейший инструмент ООП - наследование, мы платим довольно большую цену, как с точно зрения производительности, так и с точки зрения отношения дружбы между объектами. Что не так с производительностью ? Вкратце, если попрыгать по указателям в глубину (Memory Dumping), то мы получим очень сложный граф объектов, который создает Swift при работе со ссылочными типами. Это происходит из-за связи с Objective-C, ссылочный тип несет в себе методанные для указателей и счетчик ссылок для ARC, а так же стоит учесть динамическую диспетчеризацию методов, что тоже не бесплатно. Что касаемо связей, то наследование создает сильную связь, а такого рода связи следует избегать везде, где только можно. Таким образом, следует предпочитать композицию наследованию, кроме случаев, когда вы точно знаете, что делаете и какие преимущества дает наследование в вашем проекте.

Одно из самых главных правил в разработке программного обеспечения - снижение связности компонентов друг с другом. Известно, что наследование является практически самым сильным взаимоотношением. Если вы можете выразить отношения классов с использованием только лишь композиции, следует использовать этот способ. Композиция имеет важные преимущества над наследованием:

  • Большая гибкость. Сокрытые члены-данные находятся под полным контролем.

  • Времени компиляции. Хранение объекта посредством указателя, а не в виде непосредственного члена или базового класса позволяет также снизить зависимости, поскольку объявление указателя на объект не требует полного определения класса этого объекта. Наследование, напротив, всегда требует видимости полного определения базового класса.

  • Непредсказуемое поведение. Достаточно забыть вызвать super и уже даже этот тонкий момент с трудом поддастся отладке, не говоря уже о глубокой цепочке наследования, где взаимосвязь вызова методов будет довольно сложно уследить.

  • Переиспользуемость. Не все классы проектируются с учетом того, что они будут выступать в роли базовых. Но большинство классов вполне могут быть членами.

  • Безопасность.

  • Хрупкость. Наследование приводит к дополнительным усложнениям, таким как сокрытие имен и другим, возникающим при внесении изменений в базовый класс.

  • Легкая тестируемость.

Это все слабые аргументы против наследования. Наследование предоставляет очень большие возможности, чего только стоит заменимость отношения на и/или, возможность перекрытия методов и т.д. Но я рекомендую всегда задумываться за что вы платите. Если вы можете обойтись без наследования, вам незачем мириться с его недостатками. Давайте выделим моменты, где наследование действительно необходимо:

  • Управляемый полиморфизм, заменимость.

  • Перекрытие методов.

Почему абстракции так важны

На мой взгляд протоколы в Swift наделены рядом довольно удобных фишек: extensions, optional методы и т.д., но все эти инструменты, нарушают понятия абстракции. В первую очередь, абстракция помогает нам сосредоточиться на проблемах правильного абстрагирования, не вдаваясь в детали реализации или тем более управления состояниями, что дает нам Swift через его extensions. В моем понимании протокол должен представляет собой подобие абстрактного класса, а не базового, составленного полностью из (чисто) виртуальных функций и не обладающий состояниями, членами-данными, не иметь реализаций функций-членов. Их реализация в протоколах усложняет дизайн всей иерархии.

Предпочитайте определять правильный абстрактные протоколы и выполнять наследование от них, следуя принципу DIP (Dependency inversion principle). Корнями всей иерархий должны быть абстрактные классы или протокол, в то время как конкретные классы в этой роли выступать не должны. Абстрактные базовые классы или протоколы должны беспокоиться об определении функциональности, но не о ее реализации. Правильная абстракция нам дает:

  • Надежность. Менее стабильные части системы (конкретные реализации) зависят от более стабильных частей (абстракций).

  • Гибкость. Если абстракции корректно смоделированы, то при появлении новых требований легко разработать новые реализации.

  • Модульность. Дизайн, опирающийся на абстракции, обладает хорошей модульностью благодаря простоте зависимостей.

Наследование не для повторного использования

ООП как парадигма известна программистам уже многие годы, однако часто можно заметить, что цель наследования понимается неверно, и во многих случаях применение наследования оказывается неверным. Применять наследование для того, чтобы повторно использовать код находящийся в базовом классе воистину плохая идея. В первую очередь, наследование необходимо для того, чтобы быть повторно использованным существующим кодом, который полиморфно использует объекты базового класса.

Используя наследование всегда стоит помнить о LSP (Liskov substitution principle), данным принцип говорит нам как правильно моделировать отношения между базовым классом и его наследниками, а именно: "является", "работает как", "используется как". Все условия базового класса должны быть выполнены, все перекрытые методы не должны требовать и обещать больше или меньше, чем их базовые версии.

Отношение "является" очень часто понимается неверно, общеизвестный пример с квадратом и прямоугольником, где квадрат "является" прямоугольником, но с точки зрения поведения квадрат не является прямоугольником. Вот почему вместо "является" стоит предпочитать говорить "работает как" для того, чтобы такое описание воспринималось максимально правильно.

Цель наследования - заменимость. Цель наследования не в том, чтобы производный класс мог повторно использовать код базового класса для того, чтобы с его помощью реализовать свою функциональность. Такое отношение "реализован посредством" должно быть смоделировано при помощи композиции. Однако классы стратегий являются исключением, они добавляют новое поведение путем наследования, но это не является неверным употреблением наследования для моделирования отношения "реализован посредством".

Виртуальные функции

Большинство из нас на собственном опыте выучило правило, что члены класса должны быть приватными, если только мы не хотим специально обеспечить доступ к ним с внешней стороны. Это просто правило хорошего тона обычной инкапсуляции. В частности, в объектно-ориентированных иерархиях, внесение изменений в которые обходится достаточно дорого, предпочтительна полная абстракция: лучше делать открытые функции невиртуальными, а виртуальные функции - закрытыми или защищенными.

Как было описано в разделе о классификации классов, язык Swift обладает рядом ограничений для построения базовых классов, нам мешает отсутствие виртуальных методов и в некоторых случаях модификатора доступа protected. На данный момент обеспечить защищённый доступ к методу или свойству пока нет, но для построения открытой невиртуальной функции можно использовать ключевое слово final для предотвращения переопределения, тем самым можно будет четко разделить виртуальные функции, которые можно переопределить и просто открытые методы базового класса. Виртуальная функция решает две различные параллельные задачи:

  • Определение интерфейса. Будучи открытой, такая функция является непосредственной частью интерфейса класса, предоставленного внешнему миру.

  • Определение деталей реализации. Будучи виртуальной, функция предоставляет производному классу возможность заменить базовую реализацию этой функции (если таковая имеется), в чем и состоит цель настройки.

В связи с существенным различием целей этих двух задач, совершенно очевидно, что у функции недостаточно хорошее разделение зоны ответственности. Путем разделения открытых функций от виртуальных мы достигаем следующих значительных преимуществ:

  • Естественный вид. Когда мы разделяем открытый интерфейс от интерфейса настройки, каждый из них может легко приобрести тот вид, который для него наиболее естественен, не пытаясь найти компромисс, который заставит их выглядеть идентично. Зачастую эти два интерфейса требуют различного количества функций и/или различных параметров; например, внешняя вызывающая функция может выполнить вызов одной открытой функции Process, которая выполняет логическую единицу работы, в то время как разработчик данного класса может предпочесть перекрыть только некоторые части этой работы, что естественным образом моделируется путем независимо перекрываемых виртуальных функций (например, DoProcessPhase1, DoProcessPhase2), так что производному классу нет необходимости перекрывать их все.

  • Устойчивость. Мы можем позже добавить некоторую проверку условий, разделить выполнение работы на большее количество шагов или вовсе переделать ее, реализовать более полное разделение интерфейса или внести иные изменения в базовый класс, и все это никак не повлияет на код, использующий данный класс или наследующий его. Заметим, что ситуация окажется существенно сложнее, если начать с открытых виртуальных функций и позже изменять их, что неизбежно приведет к изменениям либо в коде, который использует данный класс, либо в наследующем его.

Данные-члены должны быть закрытыми

Сокрытие информации - ключ к качественной разработке программного обеспечения.

Все данные-члены должны быть закрыты. Закрытые данные - сохраняют непротиворечивое внутреннее состояние класса, в том числе при возможных вносимых изменениях.

Наличие открытых данных означает, что состояние вашего класса может изменяться непредсказуемо. Открытые данные-члены даже хуже простейших функций для получения и установки значений.

Использование функции для получения и установки значений равносильны тому, что закрыть свою квартиру на замок и оставить ключ в замке. Такие функции явно нарушают инкапсуляцию, путем доступа к данным из вне, однако это все же более безопасный вариант, поскольку он хотя бы может обеспечить устойчивость кода к возможным внесенным изменениям путем проверок. Также иногда классы обязаны предоставить доступ ко внутренним данным по причинам, связанным с совместимостью, например, для интерфейса со старым кодом или при использовании других систем.

Защищенные данные обладают всеми недостатками открытых данных, в качестве примера, можно создать производный класс и использовать его для доступа к данным. Читать и модифицировать защищенные данные так же легко, как и открытые.

Вывод

В данной статье я описал ключевые вопросы с пояснением касательно проектирования классов на языке Swift. Мы рассмотрели как проектировать классы правильно, как не допускать ошибки, избегать проблем и как правильно управлять зависимостями между объектами.

Источник: https://habr.com/ru/post/574482/


Интересные статьи

Интересные статьи

Все больше и больше аналитиков, проджектов и продактов приходят в айти из маркетинга, консалтинга, продаж и других нетехнических индустрий, да и вообще без опыта работы. ...
Думаю, многие разработчики сталкивались с задачей, когда нужно разбить проект на модули. В этой статье нет информации о том, как решать циклические зависимости или выделя...
Много всякого сыпется в мой ящик, в том числе и от Битрикса (справедливости ради стоит отметить, что я когда-то регистрировался на их сайте). Но вот мне надоели эти письма и я решил отписатьс...
Но если для интернет-магазина, разработанного 3–4 года назад «современные» ошибки вполне простительны потому что перед разработчиками «в те далекие времена» не стояло таких задач, то в магазинах, сдел...
Если у вас есть интернет-магазин и вы принимаете платежи через Интернет, то с 01 июля 2017 года у вас есть онлайн-касса.