Введение в протокольно-ориентированное программирование в Swift
Опубликовано: 2022-03-11Протокол — очень мощная функция языка программирования Swift.
Протоколы используются для определения «плана методов, свойств и других требований, которые подходят для конкретной задачи или части функциональности».
Swift проверяет наличие проблем с соответствием протоколу во время компиляции, позволяя разработчикам обнаруживать некоторые фатальные ошибки в коде еще до запуска программы. Протоколы позволяют разработчикам писать гибкий и расширяемый код на Swift без ущерба для выразительности языка.
Swift делает еще один шаг вперед в удобстве использования протоколов, предоставляя обходные пути для некоторых наиболее распространенных особенностей и ограничений интерфейсов, от которых страдают многие другие языки программирования.
В более ранних версиях Swift можно было расширять только классы, структуры и перечисления, как и во многих современных языках программирования. Однако, начиная со второй версии Swift, появилась возможность расширять и протоколы.
В этой статье рассматривается, как протоколы в Swift можно использовать для написания повторно используемого и поддерживаемого кода, и как изменения в большой кодовой базе, ориентированной на протоколы, могут быть объединены в одном месте с помощью расширений протокола.
Протоколы
Что такое протокол?
В своей простейшей форме протокол представляет собой интерфейс, описывающий некоторые свойства и методы. Любой тип, соответствующий протоколу, должен заполнять конкретные свойства, определенные в протоколе, соответствующими значениями и реализовывать необходимые методы. Например:
protocol Queue { var count: Int { get } mutating func push(_ element: Int) mutating func pop() -> Int }
Протокол Queue описывает очередь, содержащую целые элементы. Синтаксис довольно прост.
Внутри блока протокола, когда мы описываем свойство, мы должны указать, является ли свойство только доступным для { get }
или и доступным для получения и { get set }
. В нашем случае переменная Count (типа Int
) доступна только для получения.
Если протокол требует, чтобы свойство было доступным для получения и установки, это требование не может быть выполнено постоянным хранимым свойством или вычисляемым свойством, доступным только для чтения.
Если протокол требует, чтобы свойство было доступным только для получения, требование может быть удовлетворено любым типом свойства, и допустимо, чтобы свойство также было устанавливаемым, если это полезно для вашего собственного кода.
Для функций, определенных в протоколе, важно указать, будет ли функция изменять содержимое с помощью ключевого слова mutating
. Кроме этого, сигнатуры функции достаточно для определения.
Чтобы соответствовать протоколу, тип должен предоставлять все свойства экземпляра и реализовывать все методы, описанные в протоколе. Ниже, например, показана структура Container
, соответствующая нашему протоколу Queue
. Структура по существу хранит отправленные Int
в items
закрытого массива.
struct Container: Queue { private var items: [Int] = [] var count: Int { return items.count } mutating func push(_ element: Int) { items.append(element) } mutating func pop() -> Int { return items.removeFirst() } }
Однако наш текущий протокол Queue имеет существенный недостаток.
Только контейнеры, работающие с Int
, могут соответствовать этому протоколу.
Мы можем снять это ограничение, используя функцию «связанных типов». Связанные типы работают как дженерики. Чтобы продемонстрировать, давайте изменим протокол очереди, чтобы использовать связанные типы:
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Теперь протокол Queue позволяет хранить элементы любого типа.
При реализации структуры Container
компилятор определяет связанный тип из контекста (т. е. тип возвращаемого значения метода и типы параметров). Этот подход позволяет нам создать структуру Container
с универсальным типом элементов. Например:
class Container<Item>: Queue { private var items: [Item] = [] var count: Int { return items.count } func push(_ element: Item) { items.append(element) } func pop() -> Item { return items.removeFirst() } }
Использование протоколов во многих случаях упрощает написание кода.
Например, любой объект, представляющий ошибку, может соответствовать протоколу Error
(или LocalizedError
, если мы хотим предоставить локализованные описания).
Затем ту же логику обработки ошибок можно применить к любому из этих объектов ошибок во всем коде. Следовательно, вам не нужно использовать какой-либо конкретный объект (например, NSError в Objective-C) для представления ошибок, вы можете использовать любой тип, соответствующий протоколам Error
или LocalizedError
.
Вы даже можете расширить тип String, чтобы он соответствовал протоколу LocalizedError
и выдавал строки как ошибки.
extension String: LocalizedError { public var errorDescription: String? { Return NSLocalizedString(self, comment:””) } } throw “Unfortunately something went wrong” func handle(error: Error) { print(error.localizedDescription) }
Расширения протокола
Расширения протоколов основаны на удивительности протоколов. Они позволяют нам:
Обеспечьте реализацию методов протокола по умолчанию и значения свойств протокола по умолчанию, тем самым сделав их «необязательными». Типы, соответствующие протоколу, могут предоставлять свои собственные реализации или использовать по умолчанию.
Добавить реализацию дополнительных методов, не описанных в протоколе, и «украсить» любые типы, соответствующие протоколу, этими дополнительными методами. Эта функция позволяет нам добавлять определенные методы к нескольким типам, которые уже соответствуют протоколу, без необходимости изменять каждый тип по отдельности.
Реализация метода по умолчанию
Создадим еще один протокол:
protocol ErrorHandler { func handle(error: Error) }
Этот протокол описывает объекты, отвечающие за обработку ошибок, возникающих в приложении. Например:
struct Handler: ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Здесь мы просто печатаем локализованное описание ошибки. С расширением протокола мы можем сделать эту реализацию по умолчанию.
extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } }
Это делает метод handle
необязательным, предоставляя реализацию по умолчанию.
Возможность расширения существующего протокола с помощью поведения по умолчанию является довольно мощной, позволяя протоколам расти и расширяться, не беспокоясь о нарушении совместимости существующего кода.
Условные расширения
Таким образом, мы предоставили реализацию метода handle
по умолчанию, но вывод на консоль не очень полезен для конечного пользователя.
Мы, вероятно, предпочли бы показать им какое-то представление предупреждений с локализованным описанием в тех случаях, когда обработчик ошибок является контроллером представления. Для этого мы можем расширить протокол ErrorHandler
, но можем ограничить расширение, чтобы оно применялось только в определенных случаях (например, когда тип является контроллером представления).
Swift позволяет нам добавлять такие условия в расширения протокола, используя ключевое слово where
.
extension ErrorHandler where Self: UIViewController { func handle(error: Error) { let alert = UIAlertController(title: nil, message: error.localizedDescription, preferredStyle: .alert) let action = UIAlertAction(title: "OK", style: .cancel, handler: nil) alert.addAction(action) present(alert, animated: true, completion: nil) } }
Self (с большой буквы «S») в приведенном выше фрагменте кода относится к типу (структуре, классу или перечислению). Указав, что мы расширяем протокол только для типов, наследуемых от UIViewController
, мы можем использовать специальные методы UIViewController
(например, present(viewControllerToPresnt: animated: completion)
).

Теперь любые контроллеры представлений, соответствующие протоколу ErrorHandler
, имеют собственную реализацию по умолчанию метода handle
, который показывает представление предупреждений с локализованным описанием.
Неоднозначные реализации методов
Предположим, что есть два протокола, оба из которых имеют метод с одинаковой сигнатурой.
protocol P1 { func method() //some other methods } protocol P2 { func method() //some other methods }
Оба протокола имеют расширение с реализацией этого метода по умолчанию.
extension P1 { func method() { print("Method P1") } } extension P2 { func method() { print("Method P2") } }
Теперь предположим, что существует тип, соответствующий обоим протоколам.
struct S: P1, P2 { }
В этом случае у нас есть проблема с неоднозначной реализацией метода. Тип не указывает четко, какую реализацию метода следует использовать. В результате получаем ошибку компиляции. Чтобы исправить это, мы должны добавить реализацию метода к типу.
struct S: P1, P2 { func method() { print("Method S") } }
Многие объектно-ориентированные языки программирования страдают от ограничений, связанных с разрешением неоднозначных определений расширений. Swift справляется с этим довольно элегантно с помощью расширений протокола, позволяя программисту взять на себя управление там, где компилятор терпит неудачу.
Добавление новых методов
Давайте еще раз взглянем на протокол Queue
.
protocol Queue { associatedtype ItemType var count: Int { get } func push(_ element: ItemType) func pop() -> ItemType }
Каждый тип, соответствующий протоколу Queue
, имеет свойство экземпляра count
, которое определяет количество хранимых элементов. Это позволяет нам, среди прочего, сравнивать такие типы, чтобы решить, какой из них больше. Мы можем добавить этот метод через расширение протокола.
extension Queue { func compare<Q>(queue: Q) -> ComparisonResult where Q: Queue { if count < queue.count { return .orderedDescending } if count > queue.count { return .orderedAscending } return .orderedSame } }
Этот метод не описан в самом протоколе Queue
, поскольку он не связан с функциональностью очереди.
Таким образом, это не реализация метода протокола по умолчанию, а скорее новая реализация метода, которая «украшает» все типы, соответствующие протоколу Queue
. Без расширений протокола нам пришлось бы добавлять этот метод к каждому типу отдельно.
Расширения протокола и базовые классы
Расширения протокола могут показаться очень похожими на использование базового класса, но использование расширений протокола дает несколько преимуществ. К ним относятся, но не обязательно ограничиваются:
Поскольку классы, структуры и перечисления могут соответствовать более чем одному протоколу, они могут использовать реализацию нескольких протоколов по умолчанию. Это концептуально похоже на множественное наследование в других языках.
Протоколы могут быть приняты классами, структурами и перечислениями, тогда как базовые классы и наследование доступны только для классов.
Расширения стандартной библиотеки Swift
Помимо расширения собственных протоколов, вы можете расширять протоколы из стандартной библиотеки Swift. Например, если мы хотим найти средний размер набора очередей, мы можем сделать это, расширив стандартный протокол Collection
.
Структуры данных последовательности, предоставляемые стандартной библиотекой Swift, элементы которых можно просматривать и получать к ним доступ через индексированный индекс, обычно соответствуют протоколу Collection
. С помощью расширения протокола можно расширить все такие стандартные библиотечные структуры данных или выборочно расширить некоторые из них.
Примечание. Протокол, ранее известный как
CollectionType
в Swift 2.x, был переименован вCollection
в Swift 3.
extension Collection where Iterator.Element: Queue { func avgSize() -> Int { let size = map { $0.count }.reduce(0, +) return Int(round(Double(size) / Double(count.toIntMax()))) } }
Теперь мы можем вычислить средний размер любого набора очередей ( Array
, Set
и т.д.). Без расширений протокола нам пришлось бы добавлять этот метод к каждому типу коллекции отдельно.
В стандартной библиотеке Swift расширения протоколов используются для реализации, например, таких методов, как map
, filter
, reduce
и т. д.
extension Collection { public func map<T>(_ transform: (Self.Iterator.Element) throws -> T) rethrows -> [T] { } }
Расширения протокола и полиморфизм
Как я уже говорил ранее, расширения протокола позволяют нам добавлять реализации некоторых методов по умолчанию, а также добавлять новые реализации методов. Но в чем разница между этими двумя функциями? Вернемся к обработчику ошибок и выясним.
protocol ErrorHandler { func handle(error: Error) } extension ErrorHandler { func handle(error: Error) { print(error.localizedDescription) } } struct Handler: ErrorHandler { func handle(error: Error) { fatalError("Unexpected error occurred") } } enum ApplicationError: Error { case other } let handler: Handler = Handler() handler.handle(error: ApplicationError.other)
Результат — фатальная ошибка.
Теперь удалите объявление метода handle(error: Error)
из протокола.
protocol ErrorHandler { }
Результат тот же: фатальная ошибка.
Означает ли это, что нет никакой разницы между добавлением реализации метода протокола по умолчанию и добавлением новой реализации метода в протокол?
Нет! Разница существует, и вы можете увидеть ее, изменив тип handler
переменной с Handler
на ErrorHandler
.
let handler: ErrorHandler = Handler()
Теперь вывод на консоль такой: Операция не может быть завершена. (Ошибка ApplicationError 0.)
Но если мы вернем в протокол объявление метода handle(error: Error), результат изменится обратно на фатальную ошибку.
protocol ErrorHandler { func handle(error: Error) }
Давайте посмотрим на порядок того, что происходит в каждом случае.
Когда в протоколе существует объявление метода:
Протокол объявляет метод handle(error: Error)
и предоставляет реализацию по умолчанию. Метод переопределяется в реализации Handler
. Таким образом, правильная реализация метода вызывается во время выполнения, независимо от типа переменной.
Когда объявление метода не существует в протоколе:
Поскольку метод не объявлен в протоколе, тип не может его переопределить. Поэтому реализация вызываемого метода зависит от типа переменной.
Если переменная имеет тип Handler
, вызывается реализация метода из этого типа. В случае, если переменная имеет тип ErrorHandler
, вызывается реализация метода из расширения протокола.
Код, ориентированный на протокол: безопасный, но выразительный
В этой статье мы продемонстрировали некоторые возможности расширений протоколов в Swift.
В отличие от других языков программирования с интерфейсами, Swift не ограничивает протоколы ненужными ограничениями. Swift обходит общие особенности этих языков программирования, позволяя разработчику устранять неоднозначность по мере необходимости.
Благодаря протоколам и расширениям протоколов Swift код, который вы пишете, может быть таким же выразительным, как и большинство динамических языков программирования, и при этом оставаться типобезопасным во время компиляции. Это позволяет вам обеспечить повторное использование и ремонтопригодность вашего кода, а также с большей уверенностью вносить изменения в кодовую базу вашего приложения Swift.
Мы надеемся, что эта статья окажется для вас полезной, и приветствуем любые отзывы или дополнительные идеи.