Содержание
Введение
Виды классов и их представление
Монолиты
Когда выбрать наследование, а когда композицию
Почему абстракции так важны
Наследовать не для повторного использования
Виртуальные функции
Свойства класса должны быть закрытыми
Вывод
Введение
В данной статье я хочу рассмотреть ключевые вопросы касательно проектирования классов на языке Swift и их особенности. Мы рассмотрим как это сделать правильно, как не допускать ошибки, избежать проблем и как правильно управлять зависимостями между объектами.
Виды классов и их представление
Существует большое количество различных видов классов и каждый вид служит для разных целей. При проектировании у вас всегда должно быть чёткое представление того, какую проблему должен решать новосозданный класс. Каждый вид следует определенным правилам. Разберем какие типы классов бывают.
Базовый класс. Такой вид классов должен представлять собой некий строительный блок для всей последующей иерархии классов. Базовый класс обладает следующими свойствами:
Обычно базовый класс представляет интерфейс посредством виртуальных функций, но язык Swift такой функциональностью не наделен, из-за этого разработчики часто приходят к ''костыльной" реализации данного механизма путем написания assertionFailure в методе, который необходимо переопределить в дочернем классе или же оставляют его пустым. В случае если разработчик создаст экземпляр такого класса и вызывает метод, то он получит ошибку о том, что этот метод должен быть переопределен.
Объекты такого класса представляют семантику ссылочного типа и создаются динамически в куче как часть объекта производного класса и используются посредством указателей.
В качестве примера я возьму реальный кейс из проекта. Построение грамотной навигации между модулями та еще задача, в данном случае нам на помощь приходит довольно известный паттерн Coordinator. Coordinator - компонент отвечающий за сборку модуля и навигацию между ними, он знает о всех передаваемых зависимостях и содержит логику показа нового экрана: модально, push в навигационном стеке или же custom transition. В данной реализации нам необходим базовый класс, так как базовые классы позволяют использовать stored properties, которые необходимы для удержания модуля в памяти.
class Coordinator {
weak var parentViewController: UIViewController?
weak var viewController: UIViewController?
init(parentViewController: UIViewController?) {
self.parentViewController = parentViewController
}
func start() {
assertionFailure("\(#function) must be overridden")
}
}
protocol HomeNavigationProtocol {
func navigateToAccount()
// ...
}
final class HomeCoordinator: Coordinator {
private let networkService: NetworkServiceProtocol
init(
networkService: NetworkServiceProtocl,
parentViewController: UIViewController?
) {
self.networkService = networkService
super.init(parentViewController: parentViewController)
}
override func start() {
let viewController = HomeViewController()
let presenter = HomePresenter(
coordinator: self,
view: viewController,
networkService: networkService
)
let navigationController = UINavigationController(rootViewController: viewController)
self.viewController = navigationController
}
}
extension HomeCoordinator: HomeNavigationProtocol {
func navigateToAccount() {
let coordinator = AccountCoordinator(parentViewController: viewController)
coordinator.start()
}
// ...
}
Классы стратегий. Шаблонные классы, являются фрагментами сменного поведения. Стратегия — это поведенческий паттерн, выносит набор алгоритмов в собственные классы и делает их взаимозаменимыми. Другие объекты содержат ссылку на объект-стратегию и делегируют ей работу. Во время выполнения, программа может подменить этот объект другим, если требуется иной способ решения задачи.
Кейс из реального проекта. Валидация данных обязательный процесс перед отправкой их на сервер, представим, что Presenter получает какой-то ввод от пользователя из View в метод didUserInputEnter(_ input: ) и валидирует данные используя алгоритм инкапсулируемый в классе стратегии, это может быть EmailValidator, а может NumericValidator. В зависимости от логики, Presenter может прямо в runtime подменить алгоритм валидации данных используя классы стратегии.
protocol Validator {
func validate(_ value: String) -> Bool
}
final class EmailValidator: Validator {
func validate(_ value: String) -> Bool {
//...
}
}
final class NumericValidator: Validator {
func validate(_ value: String) -> Bool {
//...
}
}
final class Presenter {
var validator: Validator?
func didUserInputEnter(_ input: String) {
if validator?.validate(input) == true {
// ...
} else {
// ...
}
}
}
Класс-значение. Такие классы моделируют встроенный тип и должны обладать следующими свойствами:
Имеют присваивание с семантикой значения.
Не имеют виртуальных функций.
Предназначены для использования в качестве конкретных классов.
При передаче в метод в качестве параметра не копируются, а передаются посредствам указателя, до какой-либо модификации.
Реализуют механизм copy on write (COW).
Array, Dictionary, Set... - это все классы-значения в стандартной библиотеке Foundation. Структура данный LinkedList один из примеров пользовательского класса-значения, который обладает всеми перечисленными выше свойствами.
class COWReference<T> {
var value: T
init(value: T) {
self.value = value
}
}
struct COWContainer<T> {
private(set) var reference: COWReference<T>
var value: T {
get {
reference.value
}
set {
if isKnownUniquelyReferenced(&reference) {
reference.value = newValue
} else {
reference = COWReference(value: newValue)
}
}
}
}
class Node<Value> {
var value: Value
var next: Node<Value>?
init(value: Value, next: Node<Value>? = nil) {
self.value = value
self.next = next
}
}
struct LinkedListBuffer<Value> {
private(set) var head: Node<Value>?
private(set) var tail: Node<Value>?
mutating func push(_ value: Value) {
head = Node(value: value, next: head)
if tail == nil {
tail = head
}
}
//...
}
struct LinkedList<Value> {
var buffer = COWContainer(reference: COWReference(value: LinkedListBuffer<Value>()))
mutating func push(_ value: Value) {
buffer.value.push(value)
}
//...
}
Класс-свойство (Utility-класс или Helper-класс). Такого рода классы представляют собой шаблон-контейнер, который несет информацию о типе и его функциональности. Класс-свойство обладает следующими характеристиками:
Содержит только статические поля и методы.
Не имеет модифицируемого состояния.
Не имеет виртуальных функций.
Объекты данного класса не создаются (конструкторы приватные).
Основная проблема таких классов в том, что они не обладают состояниями, это просто пространство имен, где сгруппированы методы, которые принимают на вход какие-то параметры, что-то с ними делают и возвращают данные. Например:
final class DateUtility {
private init() { }
static func currentDate() -> String {
//...
}
static func numberOfDaysInMonth() -> Int {
//...
}
static func firstDayOfWeek() -> Int {
//...
}
static func dateFor(_ type: DateForType) -> Date {
//...
}
}
Многие разработчики любят выносить утилитные методы в extensions к какому либо типу. Яркий тому пример класс Date:
extension Date {
static func localTimeString(_ timestamp: Double) -> String {
//...
}
static func isTwelveHourFormatSet() -> Bool {
//...
}
}
Монолиты
Я очень часто замечал одну и туже ошибку - огромные причудливые классы с кучей методов. Я могу согласиться с тем, что иметь все в одном месте очень удобно, однако подход при разработке небольших классов, которые легко комбинировать между собой, оказывается более успешен для систем любого размера и сложности.
Минимальный класс легче понять и проще использовать повторно.
Минимальный класс проще в употреблении. Монолитный класс часто должен использоваться как большое неделимое целое.
Монолитные классы снижают инкапсуляцию. Если класс имеет много функций-членов, которые не обязаны быть членами, но тем не менее являются таковыми (таким образом обеспечивается излишняя видимость закрытой реализации), то закрытые свойства класса становятся почти столь же плохими с точки зрения дизайна, как и открытые переменные.
Монолитные классы обычно являются результатом попыток предсказать и предоставить "полное" решение некоторой проблемы.
Монолитные классы сложнее сделать корректными и безопасными в связи с тем, что при их разработке зачастую нарушается принцип "Один объект - одна задача".
У нас есть датчик, который измеряет температуру в помещении с каким-то интервалом. Для взаимодействия с ним мы разработали класс DateTemperature, который имеет поля и методы необходимые для работы со временем и температурой. На следующий день к нам приходит наш член команды и хочет воспользоваться DateTemperature для работы со временем, но он замечает, что в нашей реализации слишком много лишнего и принимает решение написать свой собственный класс DateTime и перенести с DateTemperature все необходимые методы в него, аналогично с температурой. После всех манипуляций мы сдаем наши классы в тестирование и как это обычно бывает, там находят кучу багов. Вносим изменения в DateTemperature, однако кто вспомнит, что есть класс DateTime, который тоже нужно исправить и наоборот. Для решения этой проблемы мы должны были воспользоваться принципом SRP (Single responsibility principle) и изначально создать классы DateTime и Temperature. А если бы нам был нужен класс-фасад, то тогда бы мы могли создать DateTemperature, который бы содержал оба эти класса, а по средствам композиции делегировал бы им выполнение.
final class DateTime {
func current() -> String {
// ...
}
//...
}
final class Temperature {
func current() -> Double {
// ...
}
//...
}
final class DateTemperature {
private let dateTime = DateTime()
private let temperature = Temperature()
func report() -> String {
String(format: "date: %@; temperature: %f", dateTime.current(), temperature.current())
}
//...
}
Композиция
Используя безусловно мощнейший инструмент ООП - наследование, мы платим довольно большую цену, как с точки зрения производительности, так и с точки зрения отношения дружбы между объектами. Что не так с производительностью ? Вкратце, если попрыгать по указателям в глубину (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. Мы рассмотрели как проектировать классы правильно, как не допускать ошибки, избегать проблем и как правильно управлять зависимостями между объектами.
Комментарии (16)
t-nick
24.08.2021 22:30+3Для абстрактных (базовых) классов в Swift есть один хак:
protocol _MyAbstractProtocol { func foo() } class _MyAbstractClass { func bar() { if let base = self as? _MyAbstractProtocol { base.foo() } } } typealias MyAbstractClass = MyAbstractClass & _MyAbstractProtocol class MyConcreteClass: MyAbstractClass { func foo() { print("foo") } }
Данный способ позволяет уйти от
fatalError
для базовых методов обязательных к реализации в наследнике, с их контролем на этапе компиляции в виде бонуса.
t-nick
24.08.2021 22:34+1Класс-значение
Вы упустили, что данный вид классов в Swift на 99% заменяется структурами.
NLizogubov Автор
25.08.2021 12:31Я это не упустил, а опустил, так как тема о классах. Под капотом базовые структуры данных работают как ссылочный тип, это необходимо для оптимизации по ряду причин. Моя цель осветить на что обращать внимание при проектировании именно класс-значения. А в большинстве остальных кейсов выбор структуры будет проще и профитнее.
t-nick
25.08.2021 12:37Тогда нужно бы упомянуть, для чего нам классы-значения в принципе нужны в повседневной жизни и в чем их преимущество перед структурами.
t-nick
24.08.2021 22:42+1В статье много общих определений из ООП без единого примера на Swift. Из нее сложно почерпнуть что-либо полезное. Новичку она будет совсем непонятна, а для опытного ООП-шника в ней нет ничего нового.
t-nick
24.08.2021 22:46Класс-свойство
Тут вообще непонятно, что вы пытались сказать (как этот термин на английском пишется?), и как его реализовать на Swift.
NLizogubov Автор
25.08.2021 11:20Это Utility-классы или их еще любят называть Helper-классы. Такие классы очень удобны и их можно встретить в любом проекте. В качестве примера DateUtility.
final class DateUtility { static func currentDate() -> String { ... } static func numberOfDaysInMonth() -> Int { ... } static func firstDayOfWeek() -> Int { ... } static func dateFor(_ type: DateForType) -> Date { ... } }
Основная проблема таких классов в том, что они не обладают состояниями, это просто пространство имен, где сгруппированы методы, которые принимают на вход какие-то параметры что-то с ними делают и возвращают данные.
Еще часто замечаю, что утилитные методы любят прятать в extensions к какому либо типу.
t-nick
25.08.2021 11:41Для этих целей в Swift принято использовать enum'ы без case'ов, так как они не имеют инициализатора по-умолчанию и не позволяют наследование.
t-nick
25.08.2021 11:59Еще часто замечаю, что утилитные методы любят прятать в extensions к какому либо типу.
В Swift есть пространства имен на основе типов, их можно вкладывать один в другой, за исключением протоколов. Чтобы не засорять глобальное пространство имен, все типы-сателиты лучше определять вложенными в основной тип.
Gorthauer87
25.08.2021 09:58Насколько я помню, протоколы в Swift работают аналогично классам в Haskell или трейтам в Rust. Может не стоит натягивать сову на глобус, изображая базовые классы, а начать пользоваться протоколами ?
t-nick
25.08.2021 11:54+1Верно, для 90% задач протоколов с дефолтной имплементацией должно быть достаточно.
Иногда нужно создать базовый класс на основе библиотечного (например
UIViewController
) и тут мой пример сtypealias
может пригодиться.Похоже автор не разобрался до конца в Swift и пытается применить паттерны из C++, при том, что последний с трудом можно назвать ООП-языком.
t-nick
Как-то режет глаз. Хоть я давно и не читал русскоязычной литературы на данную тему, но предпочел бы "свойства" (от property) или "поля".
NLizogubov Автор
Тут +, поправлю