- PVSM.RU - https://www.pvsm.ru -
На WWDC 2015 Apple объявила, что Swift — первый протокол-ориентированный язык программирования (видео сессии «Protocol-Oriented Programming in Swift» [1]).
На этой сессии и ряде других (Swift in Practice [2], Protocol and Value Oriented Programming in UIKit Apps [3]) Apple демонстрирует хорошие примеры использования протоколов, однако не даёт формального определения, что же такое Protocol-Oriented Programming.
В интернете множество статей о Protocol-Oriented Programming (POP), которые демонстрируют примеры использования протоколов, но и в них я не нашёл ясного определения POP.
Я попытался проанализировать примеры использования протоколов и сформировать принципы, которых стоит придерживаться, чтобы код можно было назвать протокол-ориентированным.
Посмотрев примеры кода, демонстрирующего POP, можно определить, что в POP ключевую роль играют следующие средства языка: protocol, extensions и constraints.
Давайте разберём, какие возможности они нам дают.
Использование протокола можно разделить на несколько сценариев:
Аналогичен понятию интерфейс [4] из ООП и контракту из контрактного программирования [5]. Служит для описания функциональности объекта. Может использоваться в качестве типа свойства, в качестве типа результата функции, типа элемента гетерогенной коллекции. Из-за ограничений языка, протоколы имеющие associated types или Self-requirements не могут использоваться в качестве типов.
Аналогичен понятию концепт [6] из обобщённого программирования [7].
Так же служит для описания функциональности объекта, но в отличие от «протокол как тип», используется как требование к типу в обобщённых функциях. Может содержать associated types.
associated types — вспомогательные типы, имеющие некоторое отношение к моделирующему концепцию типу (определение с wikipedia [7]).
Чёткой грани, в каком случае использовать протокол как тип, а в каком — как ограничение на тип, нет, более того — иногда требуется использовать протокол в обоих сценариях. Можно попытаться выделить случаи использования:
В этом случае удобнее использовать протокол как тип — его можно будет зарегистрировать в IOC контейнере, а без его использования — не потребуется в каждой функции, где используется этот сервис, добавлять тип-параметр.
Trait (типаж) — сущность, предоставляющая набор реализованной функциональности. Служит набором строительных блоков для классов/структур/enum-ов.
Описание концепции traits можно посмотреть здесь [9].
Эта концепция разработана для замены наследования. В ООП, одна из ролей классов — единица переиспользуемого кода. Само переиспользование осуществляется через наследование.
Одновременно с этим класс используется для создания экземпляров, поэтому его функциональность должна быть законченной. Эти две роли класса зачастую конфликтуют. Плюс к этому каждый класс имеет определённое место в иерархии классов, а единица переиспользования кода может применяться в произвольном месте. В качестве решения предлагается использовать в роли единиц переиспользования кода более легковесные сущности — traits, а классам будет отведена роль связующего элемента для задействования логики, унаследованной от traits.
В swift эта концепция реализуется благодаря protocols и protocol extensions. Чтобы «подключить» нужные функции определённые для протокола, нужно добавить создаваемому типу соответствие этому протоколу — отпадает необходимость создания базового класса для наследования функциональности.
Какими свойствами обладает trait и аналогия с протоколами:
protocol Protocol1 { }
protocol Protocol2 { }
protocol ComposedProtocol: Protocol1, Protocol2 { }
extension Protocol1 {
func doWork() { print("Protocol1 method") }
}
extension Protocol2 {
func doWork() { print("Protocol1 method") }
}
extension ComposedProtocol {
func combinedWork() {
(self as Protocol1).doWork()
(self as Protocol2).doWork()
print("ComposedProtocol method")
}
}
Как мы видим, протоколы полностью соответствуют концепции traits, описанной задолго до появления Swift.
Используется как «атрибут» типа, в этом случае протокол не содержит каких-либо методов. В качестве примера можно привести NSFetchRequestResult из CoreData. Им помечены NSNumber, NSDictionary, NSManagedObject, NSManagedObjectID. Протокол в данном случае описывает не функциональность классов, а то, что CoreData поддерживает эти классы как тип результата запроса. Если указать непомеченный протоколом NSFetchRequestResult тип в качестве результата, то на этапе сборки вы получите ошибку.
Проверку на наличие протокола-маркера можно использовать и для ветвления логики:
if object is HighPrioritized { ... }
Extension — средство языка позволяющее добавить функциональность к существующему типу или протоколу.
С помощью extensions мы можем сделать:
Мы не можем сделать, чтобы протокол соответствовал другому протоколу. Если бы это было возможно то, имея протокол P1, который соответствует протоколу P2, все типы, соответствующие протоколу P1 стали бы соответствовать автоматически и протоколу P2. В качестве обхода этой проблемы мы можем воспользоваться следующим приёмом: написать extension для протокола P1, в котором написать реализации методов протокола P2, после чего мы можем добавлять соответствие P2 типам, соответствующим P1, без реализаций методов. Эта идея хорошо демонстрируется примером с презентации POP [10] — Retroactive adoptation:
protocol Ordered {
func precedes(other: Self) -> Bool
}
extension Comparable {
// Если нежелательно, чтобы метод расширения был доступен для всех Comparable, можно добавить ограничение:
// extension Ordered where Self: Comparable
// либо
// extension Comparable where Self: Ordered
func precedes(other: Self) -> Bool { return self < other }
}
extension Int : Ordered {}
extension String : Ordered {}
Ограничения на тип. Поддерживаются следующие: соответствует протоколу, наследуется от класса, имеет тип. Ограничения используются для определения набора методов, которые есть у обобщённого типа. Если передать в качестве аргумента неудовлетворяющий ограничениям тип, компилятор выдаст ошибку.
Где используются:
func produce<F: Factory>(factory: F) where F.Product == Cola
Другой пример: аргумент должен соответствовать одновременно 2 протоколам: Animal и Flying:
// разные варианты записи одной и той же функции:
func fly<T>(f: T) where T: Flying, T: Animal { ... }
func fly<T: Flying & Animal>(f: T) { ... }
func fly<T: Animal>(f: T) where T: Flying { ... }
func fly<T>(f: T) where T: Flying & Animal { ... }
protocol Order {
associatedtype Identifier: Codable
}
Мы можем делать констрейнты на associatedtype associatedtype-а:
protocol GenericProtocol {
associatedtype Value: RawRepresentable where Value.RawValue == Int
func getValue() -> Value
}
// запись констрейнтов можно перенести на уровень протокола. В фунциональном плане различий не будет:
protocol GenericProtocol where Value.RawValue == Int {
associatedtype Value: RawRepresentable
func getValue() -> Value
}
protocol GenericProtocol where Value: RawRepresentable, Value.RawValue == Int {
associatedtype Value
func getValue() -> Value
}
extension Animal where Self: Flying {
func fly() { ... }
}
extension Factory where Product == Cola {
func produce() { ... }
}
protocol ObjectWithMass {
var mass: Double { get }
}
extension Array: ObjectWithMass where Element: ObjectWithMass {
var mass: Double {
return map { $0.mass }.reduce(0, +)
}
}
Поскольку констрейнты на associatedtypes можно указывать и на самом протоколе и для методов, в которые передаётся протокол, и для protocol extensions, возникает вопрос, куда добавлять констрейнты. Несколько рекомендаций:
Лучший материал по POP — сессия «Protocol-Oriented Programming in Swift» [1] проведённая Dave Abrahams. Настоятельно рекомендую её к просмотру. Большая часть принципов сформировано благодаря примерам из неё.
extension MyTableViewController: UITableViewDelegate {
// реализация методов из UITableViewDelegate
}
extension MyTableViewController: UITableViewDataSource {
// реализация методов из UITableViewDataSource
}
extension MyTableViewController: UITextFieldDelegate {
// реализация методов из UITextFieldDelegate
}
Разумеется, в случае использования сторонних фреймворков, как Cocoa, наследования не избежать.
Интересный пример с всё той же сессии «Protocol-Oriented Programming in Swift». Вместо того, чтобы написать класс реализующий протокол Renderer для отрисовки с помощью CoreGraphics, классу CGContext через extension добавляется соответствие этому протоколу. Перед добавлением нового класса, реализующего протокол, стоит задуматься, есть ли тип (класс/структура/enum), который можно адаптировать к соответствию протокола?
Если появилась необходимость переопределить общий метод, определённый в protocol extension, для конкретного класса, то перенесите сигнатуру этого метода в protocol requirements. Другие классы не придётся править, т.к. продолжат использовать метод из расширения. Различие будет в формулировке — теперь это «default implementation method» вместо «extension method».
В ООП роль абстрактного типа данных играет класс. В POP — протокол.
Преимущества протокола как абстракции, по утверждению Apple (слайд: «A Better Abstraction Mechanism» [15]):
— свойство системы, позволяющее объединить данные и методы, работающие с ними, в классе.
Протокол не может содержать сами данные, он может содержать только требования на свойства, которые эти данные бы предоставляли. Как и в ООП, необходимые данные должны быть включены в класс/структуру, но функции могут быть определены как в классе, так и в extensions.
POP/swift поддерживает 2 вида полиморфизма:
func process(service: ServiceType) { ... }
func process<Service: ServiceType>(service: Service) { ... }
Набор функций принимаемого типа и его associated types определяется по ограничениям. Мы можем не накладывать ограничения, но в этом случае параметр будет аналогичен типу Any:
func foo<T>(value: T) { ... }
В случае с полиморфизмом подтипов, нам неизвестен конкретный тип, который передаётся в функцию — нахождение реализации методов этого типа будет осуществляться во время выполнения (Dynamic dispatch [16]). При использовании параметрического полиморфизма — тип параметра известен во время компиляции, соответственно и его методы (Static dispatch [17]). За счёт того, что на этапе сборки известны используемые типы, компилятор имеет возможность лучше оптимизировать код — в первую очередь, за счёт использования подстановки (inline) функций.
Наследование в ООП служит для заимствования функциональности от родительского класса.
В POP получение нужной функциональности происходит за счёт добавления соответствий протоколам, которые предоставляют функции через extensions. При этом мы не ограничены классами, имеем возможность расширять за счёт протоколов структуры и enum-ы.
Протоколы могут наследоваться от других протоколов — это означает добавление к собственным требованиям требований от родительских протоколов.
Посмотрим, как можно использовать POP на практике.
Первый пример — модернизированная версия SegueHandler, представленная на WWDC 2015 — Session 411 Swift in Practice [18].
Представим, что у нас есть RootViewController и нам нужно сделать обработку переходов к DetailViewController и AboutViewController. Типовая реализация prepare(for:sender:):
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segue.identifier {
case "DetailViewController":
guard let vc = segue.destination as? DetailViewController
else { fatalError("Invalid destination view controller type.") }
// configure vc
case "AboutViewController":
guard let vc = segue.destination as? AboutViewController
else { fatalError("Invalid destination view controller type.") }
// configure vc
default:
fatalError("Invalid segue identifier.")
}
}
Мы знаем, что у нас может быть только 2 перехода — с id DetailViewController и AboutViewController с одноимёнными классами контроллеров, однако нам приходится делать проверку на неизвестный seque.identifier и приведение типов segue.destination.
Попробуем улучшить код этого метода. Начнём с описания возможных переходов — для этого отлично подойдёт enum:
enum SegueDestination {
case detail(DetailViewController)
case about(AboutViewController)
}
(Примечание: SegueDestination объявлен внутри RootViewController)
Наша цель — написать универсальный вспомогательный метод для обработки переходов. Для этого определим протокол SegueHandlerType с ассоциированным типом, описывающим переход. Требование к ассоциированному типу — он должен предоставлять failable initializer, возвращающий nil в случае невалидного сочетания segue id и типа контроллера:
protocol SegueHandlerType {
associatedtype SegueDestination: SegueDestinationType
}
protocol SegueDestinationType {
init?(segueId: String, controller: UIViewController)
}
Протокол определён, теперь добавим для него метод segueDestination(forSegue:) возвращающий экземпляр перехода:
extension SegueHandlerType {
func segueDestination(forSegue segue: UIStoryboardSegue) -> SegueDestination {
guard let id = segue.identifier else { fatalError("segue id should not be nil") }
guard let destination = SegueDestination(segueId: id, controller: segue.destination)
else { fatalError("Wrong segue Id or destination controller type") }
return destination
}
}
Сделаем, чтобы RootViewController реализовывал SegueHandlerType (вынесем его в отдельный файл, чтобы этот тривиальный код реже попадался на глаза):
// file RootViewController+SegueHandler.swift
extension RootViewController.SegueDestination: SegueDestinationType {
init?(segueId: String, controller: UIViewController) {
switch (segueId, controller) {
case ("DetailViewController", let vc as DetailViewController):
self = .detail(vc)
case ("AboutViewController", let vc as AboutViewController):
self = .about(vc)
default:
return nil
}
}
}
extension RootViewController: SegueHandlerType { }
Хочу обратить внимание, что associatedtype в SegueHandlerType и enum в RootViewController имеют одинаковое имя, поэтому реализация SegueHandlerType для RootViewController вышла пустой. В случае отличающихся имен и в случае, если бы наш enum был бы определён не внутри RootViewController, нам бы потребовалось указать ассоциированный с протоколом тип с помощью typealias:
extension RootViewController: SegueHandlerType {
typealias SegueDestination = RootControllerSegueDestination
}
Финальная часть примера — теперь мы может отрефакторить prepare(for:sender:):
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segueDestination(forSegue: segue) {
case .detail(let vc):
// configure vc
case .about(let vc):
// configure vc
}
}
Код стал гораздо чище, не так ли?
Конечно, в итоге кода стало больше — но нам удалось разделить основную логику (ту, что скрывается за комментариями "// configure vc") и вспомогательный код. Плюсы — код стало легче читать, а вспомогательный SegueHandlerType можно переиспользовать.
Рассмотрим типовую задачу на отображение списка элементов в UITableView.
В качестве исходных данных имеем модель Cat и TestCatRepository, который соответствует протоколу CatRepository:
struct Cat {
var name: String
var photo: UIImage?
}
protocol CatRepository {
func getCats() -> [Cat]
}
В проект добавлены классы контроллера таблицы и ячейки: CatListTableViewController, CatTableViewCell.
Попробуем описать обобщённый протокол списка. Представим, что у нас есть планы по добавлению в проект других таблиц, которые, в том числе могут содержать несколько секций. Требования к протоколу:
С учётом составленных требований запишем наш протокол:
protocol ListViewType: class {
associatedtype CellView
associatedtype SectionIndex
associatedtype ItemIndex
func refresh(section: SectionIndex, count: Int)
var updateItemCallback: (CellView, ItemIndex) -> () { get set }
}
Давайте опишем требования к ячейке для показа информации о коте:
protocol CatCellType {
func setName(_: String)
func setImage(_: UIImage?)
}
Добавим соответствие этому протоколу классу CatTableViewCell.
Наш основной протокол, ListViewType, должен быть добавлен CatListTableViewController-у. Мы используем только один тип ячеек — CatTableViewCell, поэтому в качестве associatedtype CellView используем его. В таблице только одна секция и количество элементов заранее неизвестно — в качестве SectionIndex и ItemIndex используем Void и Int, соответственно.
Полная реализация CatListTableViewController:
class CatListTableViewController: UITableViewController, ListViewType {
var itemsCount = 0
var updateItemCallback: (CatTableViewCell, Int) -> () = { _, _ in }
func refresh(section: Void, count: Int) {
itemsCount = count
tableView.reloadData()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return itemsCount
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CatCell", for: indexPath) as! CatTableViewCell
updateItemCallback(cell, indexPath.row)
return cell
}
}
Сейчас наша цель — связать CatRepository и ListViewType. Однако, не хочется связывать алгоритм с конкретной моделью Cat. Для этого выделим обобщенные протоколы, где тип модели вынесен в associatedtype:
protocol RepositoryType {
associatedtype Model
func getItems() -> [Model]
}
protocol ConfigurableViewType {
associatedtype Model
func configure(using model: Model)
}
Добавим соответствие новым протоколам:
extension CatRepository {
func getItems() -> [Cat] {
return getCats()
}
}
extension TestCatRepository: RepositoryType { }
extension CatCellType where Self: ConfigurableViewType {
func configure(using model: Cat) {
setName(model.name)
setImage(model.photo)
}
}
extension CatTableViewCell: ConfigurableViewType { }
Всё готово, чтобы реализовать метод отображения объектов, предоставляемых RepositoryType, в списке ListViewType. Алгоритм не будет поддерживать несколько секций, а в качестве индекса использует Int. Добавим ограничения на extension:
extension ListViewType where SectionIndex == (), ItemIndex == Int { ... }
Наш CatListTableViewController соответствует этим ограничениям.
Но это не все ограничения — ListViewType.CellView должен быть ConfigurableViewType, а его тип Model должен быть RepositoryType.Model:
func setup<Repository: RepositoryType>(repository: Repository)
where CellView: ConfigurableViewType, CellView.Model == Repository.Model { ... }
И этим ограничениям соответствует наш класс.
Полный код расширения:
extension ListViewType where SectionIndex == (), ItemIndex == Int {
func setup<Repository: RepositoryType>(repository: Repository)
where CellView: ConfigurableViewType, CellView.Model == Repository.Model {
let items = repository.getItems()
refresh(section: (), count: items.count)
updateItemCallback = { cell, index in
let item = items[index]
cell.configure(using: item)
}
}
}
Основная логика готова, используем эту функцию в AppDelegate:
let catListTableView = window!.rootViewController as! CatListTableViewController
let repository = TestCatRepository()
catListTableView.setup(repository: repository)
Полный код примера можно найти здесь [19].
В нашем примере логика разбита на много маленьких независимых частей, которые работают как единое целое.
Поскольку большая часть логики находится в extensions, а не в классах, с первого взгляда не ясно, какой класс имеет какую ответственность. Соответственно, возникает вопрос: к какой архитектуре отнести данный пример? Используемая функциональность находится в расширении ListViewType. Классу CatListTableViewController доступна эта логика, поскольку он соответствует этому протоколу. Потребители CatListTableViewController считают, что это его функция:
catListTableView.setup(repository: repository)
Поэтому роль CatListTableViewController — Controller из MVC. Соответственно архитектура приложения — MVC, хоть и с непривычной организацией кода.
Protocol-Oriented Programming опирается на Generic Programming и концепцию Traits.
Использование POP повышает переиспользование кода, лучше структурирует код, уменьшает дублирование кода, избегает сложности с иерархией наследования классов, делает код более связным.
Источники:
Автор: Gotyanov
Источник [20]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/razrabotka-pod-ios/280395
Ссылки в тексте:
[1] видео сессии «Protocol-Oriented Programming in Swift»: https://developer.apple.com/videos/play/wwdc2015/408/
[2] Swift in Practice: https://developer.apple.com/videos/play/wwdc2015/411/
[3] Protocol and Value Oriented Programming in UIKit Apps: https://developer.apple.com/videos/play/wwdc2016/419/
[4] интерфейс: https://ru.wikipedia.org/wiki/Интерфейс_(объектно-ориентированное_программирование)
[5] контрактного программирования: https://ru.wikipedia.org/wiki/Контрактное_программирование
[6] концепт: https://en.wikipedia.org/wiki/Concept_(generic_programming)
[7] обобщённого программирования: https://ru.wikipedia.org/wiki/Обобщённое_программирование
[8] type erasure: http://robnapier.net/erasure
[9] здесь: http://scg.unibe.ch/archive/papers/Scha03aTraits.pdf
[10] презентации POP: https://developer.apple.com/videos/play/wwdc2015/408/?time=1995
[11] Conditional Conformance: https://swift.org/blog/conditional-conformance/
[12] «Шаблонный метод»: https://ru.wikipedia.org/wiki/Шаблонный_метод_(шаблон_проектирования)
[13] «предпочитайте композицию наследованию»: https://en.wikipedia.org/wiki/Composition_over_inheritance
[14] Requirements create customization points: https://developer.apple.com/videos/play/wwdc2015/408/?time=1754
[15] «A Better Abstraction Mechanism»: https://developer.apple.com/videos/play/wwdc2015/408/?time=803
[16] Dynamic dispatch: https://en.wikipedia.org/wiki/Dynamic_dispatch
[17] Static dispatch: https://en.wikipedia.org/wiki/Static_dispatch
[18] WWDC 2015 — Session 411 Swift in Practice: https://developer.apple.com/videos/play/wwdc2015/411/?time=1609
[19] здесь: https://github.com/Gotyanov/POP_CatList
[20] Источник: https://habr.com/post/358804/?utm_campaign=358804
Нажмите здесь для печати.