Pull to refresh

Protocol-Oriented Programming

Reading time17 min
Views34K
На WWDC 2015 Apple объявила, что Swift — первый протокол-ориентированный язык программирования (видео сессии «Protocol-Oriented Programming in Swift»).

На этой сессии и ряде других (Swift in Practice, Protocol and Value Oriented Programming in UIKit Apps) Apple демонстрирует хорошие примеры использования протоколов, однако не даёт формального определения, что же такое Protocol-Oriented Programming.

В интернете множество статей о Protocol-Oriented Programming (POP), которые демонстрируют примеры использования протоколов, но и в них я не нашёл ясного определения POP.

Я попытался проанализировать примеры использования протоколов и сформировать принципы, которых стоит придерживаться, чтобы код можно было назвать протокол-ориентированным.

Посмотрев примеры кода, демонстрирующего POP, можно определить, что в POP ключевую роль играют следующие средства языка: protocol, extensions и constraints.
Давайте разберём, какие возможности они нам дают.

Protocol


Использование протокола можно разделить на несколько сценариев:

Протокол как тип


Аналогичен понятию интерфейс из ООП и контракту из контрактного программирования. Служит для описания функциональности объекта. Может использоваться в качестве типа свойства, в качестве типа результата функции, типа элемента гетерогенной коллекции. Из-за ограничений языка, протоколы имеющие associated types или Self-requirements не могут использоваться в качестве типов.

Протокол как шаблон типа


Аналогичен понятию концепт из обобщённого программирования.

Так же служит для описания функциональности объекта, но в отличие от «протокол как тип», используется как требование к типу в обобщённых функциях. Может содержать associated types.
associated types — вспомогательные типы, имеющие некоторое отношение к моделирующему концепцию типу (определение с wikipedia).
Чёткой грани, в каком случае использовать протокол как тип, а в каком — как ограничение на тип, нет, более того — иногда требуется использовать протокол в обоих сценариях. Можно попытаться выделить случаи использования:

  • классы, которые предоставляют функциональность для более высоких слоёв приложения и передаются классам-потребителям как зависимости — это сервисы, репозитории, api-клиенты, пользовательские настройки, и прочее.

    В этом случае удобнее использовать протокол как тип — его можно будет зарегистрировать в IOC контейнере, а без его использования — не потребуется в каждой функции, где используется этот сервис, добавлять тип-параметр.
  • протокол с описанием математических операций, например сравнение, сложение, конкатенация и подобные вещи. В этом случае удобно воспользоваться Self-requirement (когда в функции или свойстве протокола используется псевдотип Self), чтобы избежать опасного приведения и использования разных типов, когда операция допускает параметры только одного типа (и Int и String в Swift соответствуют протоколу Equatable, но если попытаться проверить их на равенство между собой, компилятор выдаст ошибку, поскольку оператор сравнения требует, чтобы параметры были одного типа). Поэтому в этом случае протокол используется как шаблон типа.
  • иногда требуется сохранить в приватном свойстве протокол имеющий associated types, но в этом случае мы не можем использовать протокол как тип. Есть разные способы решения этой проблемы, например создание аналогичного протокола, в котором использование associated types будет заменено на конкретные типы; использование приёма type erasure — в этом случае associated types переедут в generic параметры типа Any[YourProtocolName]. Ещё варианты — сохранять не сам экземпляр, а его функции. Либо захватить экземпляр в замыкание, которое сохранить в свойство.

Протокол как Trait


Trait (типаж) — сущность, предоставляющая набор реализованной функциональности. Служит набором строительных блоков для классов/структур/enum-ов.

Описание концепции traits можно посмотреть здесь.

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

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

В swift эта концепция реализуется благодаря protocols и protocol extensions. Чтобы «подключить» нужные функции определённые для протокола, нужно добавить создаваемому типу соответствие этому протоколу — отпадает необходимость создания базового класса для наследования функциональности.

Какими свойствами обладает trait и аналогия с протоколами:

  • trait предоставляет набор методов, реализующих поведение. — Методы добавленные с помощью protocol extensions;
  • trait требует набор методов, которые служат параметрами для обеспечения поведения. — Методы, содержащиеся в самом протоколе (Protocol requirements);
  • traits не устанавливают переменных для хранения состояния. Методы, предоставляемые trait, не имеют прямого доступа к полям класса. — Методы расширения не могут добавить типу stored property. Protocol не может добавить требование, каким должно быть свойство — computed или stored, таким образом методы расширения не имеют прямого доступа к данным — он осуществляется через аксессоры свойств;
  • классы и traits могут быть составлены из других traits. Конфликты методов должны быть явно разрешены. — Классам может быть добавлено соответствие протоколам, а протоколы поддерживают наследование другим протоколам. Конфликты можно разрешать, например, с помощью приведения к определённому типу:

    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")
        }
    }
    

  • добавление trait не влияет на семантику класса — нет различий между тем, используются методы из traits или методы, определённые прямо в классе. — Верно для протоколов — посмотрев на код, мы не можем определить, где определён метод — в protocol extension или типе, соответствущем протоколу;
  • композиция trait не влияет на семантику trait — составной trait эквивалентен «плоскому» trait, содержащему те же методы. — Использование протокола Foo с методом foo(), который унаследован от протоколов Bar с методом bar() и Baz с методом baz() не отличается от использования протокола, с этими 3 методами: foo(), bar(), baz().

Как мы видим, протоколы полностью соответствуют концепции traits, описанной задолго до появления Swift.

Протокол как маркер


Используется как «атрибут» типа, в этом случае протокол не содержит каких-либо методов. В качестве примера можно привести NSFetchRequestResult из CoreData. Им помечены NSNumber, NSDictionary, NSManagedObject, NSManagedObjectID. Протокол в данном случае описывает не функциональность классов, а то, что CoreData поддерживает эти классы как тип результата запроса. Если указать непомеченный протоколом NSFetchRequestResult тип в качестве результата, то на этапе сборки вы получите ошибку.

Проверку на наличие протокола-маркера можно использовать и для ветвления логики:

if object is HighPrioritized { ... }

Extensions


Extension — средство языка позволяющее добавить функциональность к существующему типу или протоколу.

С помощью extensions мы можем сделать:

  • добавить метод в протокол — этот метод будет доступен (в пределах области видимости) для использования как внутри типов, которые соответствуют этому протоколу, так и для их потребителей;
  • добавить классу/структуре/enum-у соответствие протоколу. При этом нам не нужен доступ к коду этих типов, они могут содержаться и в сторонней библиотеке. Эта возможность называется Retroactive modeling.

    Мы не можем сделать, чтобы протокол соответствовал другому протоколу. Если бы это было возможно то, имея протокол P1, который соответствует протоколу P2, все типы, соответствующие протоколу P1 стали бы соответствовать автоматически и протоколу P2. В качестве обхода этой проблемы мы можем воспользоваться следующим приёмом: написать extension для протокола P1, в котором написать реализации методов протокола P2, после чего мы можем добавлять соответствие P2 типам, соответствующим P1, без реализаций методов. Эта идея хорошо демонстрируется примером с презентации POP — 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 {}
    

  • написать дефолтную реализацию метода протокола. Если тип содержит реализацию этого метода — то будет использована она, вместо дефолтной.

Constraints


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

Где используются:

  • ограничения на типы параметров в определении обобщенной функции. Пример: функция produce принимает аргумент типа, который соответствует протоколу Factory, ассоциативный тип Product которого должен быть Cola:

    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 { ... }
    

  • ограничение на associated type в определении протокола. Пример — ассоциативный тип Identifier обязан соответствовать протоколу Codable:

    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
    }
    

  • ограничение на доступность методов расширения. Перепишем примеры функций с Animal и Factory на методы расширения:

    extension Animal where Self: Flying  {
        func fly() { ... }
    }
    
    extension Factory where Product == Cola {
        func produce() { ... }
    }
    

  • определение условного соответствия протоколу (Conditional Conformance). Пример: если тип элементов массива соответствует протоколу ObjectWithMass, то и сам массив будет соответствовать этому протоколу, а в качестве массы он будет возвращать сумму масс элементов:

    protocol ObjectWithMass {
        var mass: Double { get }
    }
    
    extension Array: ObjectWithMass where Element: ObjectWithMass {
        var mass: Double { 
            return map { $0.mass }.reduce(0, +)
        }
    }
    


Поскольку констрейнты на associatedtypes можно указывать и на самом протоколе и для методов, в которые передаётся протокол, и для protocol extensions, возникает вопрос, куда добавлять констрейнты. Несколько рекомендаций:

  1. Если протокол специфичен для приложения и будет иметь одну реализацию — стоит рассмотреть возможность использовать конкретные типы вместо ассоциированных.
  2. Если протокол специфичен для приложения и будет иметь несколько реализаций (с учётом фейковых для тестов) — удобнее поместить их в сам протокол, чтобы не дублировать в места использования этого протокола.
  3. Если есть планы на переиспользование протокола — протокол должен содержать только те констрейнты, без которых существование протокола не имеет смысла и на которых построена основная логика. Все остальные констрейнты следует рассматривать как описание частных случаев и помещать их на методы и расширения.

Принципы


Лучший материал по POP — сессия «Protocol-Oriented Programming in Swift» проведённая Dave Abrahams. Настоятельно рекомендую её к просмотру. Большая часть принципов сформировано благодаря примерам из неё.

  1. «Don't start with a class. Start with a protocol.». Это утверждение Dave Abrahams с вышеупомянутой сессии. Можно трактовать 2 способами:

    • начинайте не с реализации, а с описания контракта (описание функциональности, которую объект будет обязан предоставить потребителям)
    • описывайте переиспользуемую логику в протоколах, а не классах. Используйте протокол как единицу переиспользования кода, а класс — как место для уникальной логики. По другому можно описать этот принцип — encapsulate what varies.
      Хорошим аналогом может стать паттерн «Шаблонный метод». Его идея — отделить общий алгоритм от деталей реализации. Базовый класс содержит общий алгоритм, а дочерние переопределяют определённые шаги алгоритма. В POP общий алгоритм будет содержаться в protocol extension, protocol будет определять шаги алгоритма и используемые типы, а реализация шагов — в классе.
  2. Композиция через расширения. Многие слышали фразу «предпочитайте композицию наследованию». В ООП, когда от объекта требуется разный набор функциональности (полиморфное поведение), эту функциональность можно либо разбить на части и организовать иерархию классов, где каждый класс наследует функциональность от предка и добавляет свою, либо разбить на несвязанные иерархией классы, экземпляры которых использовать в связующем классе. Используя возможность добавить соответствие протоколу через расширение мы можем использовать композицию не прибегая к созданию вспомогательных классов. Таким способом зачастую пользуются, когда добавляют viewController-у соответствие различным делегатам. Преимущество перед добавлением соответствия протоколам в самом классе — лучше организованный код:

    extension MyTableViewController: UITableViewDelegate {
        // реализация методов из UITableViewDelegate
    }
    
    extension MyTableViewController: UITableViewDataSource {
        // реализация методов из UITableViewDataSource
    }
    
    extension MyTableViewController: UITextFieldDelegate {
        // реализация методов из UITextFieldDelegate
    }
    

  3. Вместо наследования используйте протоколы. Dave Abrahams сравнил протоколы с суперклассами, поскольку они позволяют достичь подобия множественного наследования.
    Если ваш класс содержит много логики, стоит попытаться разбить его на отдельные наборы функциональности, которые вынести в протоколы.

    Разумеется, в случае использования сторонних фреймворков, как Cocoa, наследования не избежать.
  4. Используйте Retroactive modeling.

    Интересный пример с всё той же сессии «Protocol-Oriented Programming in Swift». Вместо того, чтобы написать класс реализующий протокол Renderer для отрисовки с помощью CoreGraphics, классу CGContext через extension добавляется соответствие этому протоколу. Перед добавлением нового класса, реализующего протокол, стоит задуматься, есть ли тип (класс/структура/enum), который можно адаптировать к соответствию протокола?
  5. Включайте в протоколы методы, которые можно переопределить (Requirements create customization points).

    Если появилась необходимость переопределить общий метод, определённый в protocol extension, для конкретного класса, то перенесите сигнатуру этого метода в protocol requirements. Другие классы не придётся править, т.к. продолжат использовать метод из расширения. Различие будет в формулировке — теперь это «default implementation method» вместо «extension method».

Отличия POP от OOP


Абстракция


В ООП роль абстрактного типа данных играет класс. В POP — протокол.
Преимущества протокола как абстракции, по утверждению Apple (слайд: «A Better Abstraction Mechanism»):

  • Supports value types (and classes)
  • Supports static type relationships (and dynamic dispatch)
  • Non-monolithic
  • Supports retroactive modeling
  • Doesn’t impose instance data on models
  • Doesn’t impose initialization burdens on models
  • Makes clear what to implement

Перевод
  • Поддержка value-типов (и классов)
  • Поддержка статических отношений типов (и dynamic dispatch)
  • Немонолитный
  • Поддержка retroactive modeling
  • Не навязывает данные объекта (поля базового класса)
  • Не обременяет инициализацией (базового класса)
  • Даёт понять, что реализовывать


Инкапсуляция


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

Полиморфизм


POP/swift поддерживает 2 вида полиморфизма:

  • полиморфизм подтипов. Он же используется в ООП:

    func process(service: ServiceType) { ... }
    

  • параметрический полиморфизм. Используется в обобщенном программировании.

    func process<Service: ServiceType>(service: Service) { ... }
    

    Набор функций принимаемого типа и его associated types определяется по ограничениям. Мы можем не накладывать ограничения, но в этом случае параметр будет аналогичен типу Any:

    func foo<T>(value: T) { ... }
    


В случае с полиморфизмом подтипов, нам неизвестен конкретный тип, который передаётся в функцию — нахождение реализации методов этого типа будет осуществляться во время выполнения (Dynamic dispatch). При использовании параметрического полиморфизма — тип параметра известен во время компиляции, соответственно и его методы (Static dispatch). За счёт того, что на этапе сборки известны используемые типы, компилятор имеет возможность лучше оптимизировать код — в первую очередь, за счёт использования подстановки (inline) функций.

Наследование


Наследование в ООП служит для заимствования функциональности от родительского класса.
В POP получение нужной функциональности происходит за счёт добавления соответствий протоколам, которые предоставляют функции через extensions. При этом мы не ограничены классами, имеем возможность расширять за счёт протоколов структуры и enum-ы.

Протоколы могут наследоваться от других протоколов — это означает добавление к собственным требованиям требований от родительских протоколов.

Посмотрим, как можно использовать POP на практике.

Пример 1


Первый пример — модернизированная версия SegueHandler, представленная на WWDC 2015 — Session 411 Swift in Practice.

Представим, что у нас есть 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 можно переиспользовать.

Пример 2


Рассмотрим типовую задачу на отображение списка элементов в UITableView.
В качестве исходных данных имеем модель Cat и TestCatRepository, который соответствует протоколу CatRepository:

struct Cat {
    var name: String
    var photo: UIImage?
}

protocol CatRepository {
    func getCats() -> [Cat]
}

В проект добавлены классы контроллера таблицы и ячейки: CatListTableViewController, CatTableViewCell.

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

  • у нас должна быть возможность задать количество элементов в секции;
  • должна быть возможность указать типы для индекса секции и для индекса элемента — они могут быть любыми — числом, enum-ом, кортежом, никаких требований мы на них не накладываем;
  • тип ячейки — на него тоже никаких требований не накладываем, может быть просто TableViewCell, может быть более сложным типом для получения ячейки определённого типа, если в таблице используются разные типы ячеек (разные Cell Identifier)
  • возможность обработать запрос на обновление ячейки — самым простым способом будет присвоить обработчик в виде функции с 2 параметрами — ячейка и её индекс.

С учётом составленных требований запишем наш протокол:

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)

Полный код примера можно найти здесь.

В нашем примере логика разбита на много маленьких независимых частей, которые работают как единое целое.

Поскольку большая часть логики находится в extensions, а не в классах, с первого взгляда не ясно, какой класс имеет какую ответственность. Соответственно, возникает вопрос: к какой архитектуре отнести данный пример? Используемая функциональность находится в расширении ListViewType. Классу CatListTableViewController доступна эта логика, поскольку он соответствует этому протоколу. Потребители CatListTableViewController считают, что это его функция:

catListTableView.setup(repository: repository)

Поэтому роль CatListTableViewController — Controller из MVC. Соответственно архитектура приложения — MVC, хоть и с непривычной организацией кода.

Заключение


Protocol-Oriented Programming опирается на Generic Programming и концепцию Traits.
Использование POP повышает переиспользование кода, лучше структурирует код, уменьшает дублирование кода, избегает сложности с иерархией наследования классов, делает код более связным.

Источники:

  1. WWDC 2015 «Protocol-Oriented Programming in Swift»
  2. WWDC 2015 «Swift in Practice»
  3. WWDC 2016 «Protocol and Value Oriented Programming in UIKit Apps»
  4. type erasure
  5. Traits: Composable Units of Behaviour
Tags:
Hubs:
Total votes 9: ↑9 and ↓0+9
Comments5

Articles