Протоколы и дженерики: protocol-oriented programming

Protocol-oriented programming — про контракты и композицию вместо иерархии классов; на собеседовании его проверяют тремя вопросами подряд.

Протокол — контракт: что тип умеет. Protocol extension добавляет к контракту готовое поведение, поэтому переиспользование кода не требует общего предка.

Как звучит вопрос

«Что такое protocol-oriented programming и чем он лучше наследования?», «Зачем нужен associatedtype?», «Чем some отличается от any

Что на самом деле проверяют

Понимаете ли вы систему типов Swift глубже уровня «интерфейс как в Java». Ключевые маркеры: знание статической диспетчеризации в extension, умение объяснить, почему протокол с associatedtype нельзя просто положить в массив, и различие между opaque type и existential.

Композиция вместо иерархии

protocol Keyed { var id: String { get } }
protocol Cacheable { var ttl: TimeInterval { get } }

extension Cacheable {
    var ttl: TimeInterval { 300 }        // реализация по умолчанию
}

struct Article: Keyed, Cacheable {
    let id: String
}

func store(_ item: Keyed & Cacheable) {
    print(item.id, item.ttl)
}

Структура получила два контракта и бесплатную реализацию ttl, не наследуясь ни от кого. Иерархия классов такого не умеет: там пришлось бы выбрать одного предка и тащить его состояние.

Классическая ловушка: диспетчеризация в extension

protocol Greeter {
    func hello() -> String              // объявлен в протоколе
}

extension Greeter {
    func hello() -> String { "hello (default)" }
    func bye() -> String { "bye (default)" }   // только в extension!
}

struct Ru: Greeter {
    func hello() -> String { "привет" }
    func bye() -> String { "пока" }
}

let direct = Ru()
let boxed: Greeter = Ru()
print(direct.hello(), boxed.hello())
print(direct.bye(), boxed.bye())

Вывод:

привет привет
пока bye (default)

Метод hello() есть в требованиях протокола — он попадает в witness table и диспетчеризуется динамически. А bye() объявлен только в extension, поэтому вызов резолвится статически по типу переменной. Итог: через переменную типа Greeter вызывается версия из extension, хотя фактический тип её переопределяет. Лечится тривиально — добавить bye() в тело протокола.

associatedtype и generic constraint

protocol Repository {
    associatedtype Item
    func all() -> [Item]
}

struct UserRepo: Repository {
    func all() -> [User] { [] }         // Item выведен как User
}

// let repos: [Repository] = []   // до Swift 5.6: "can only be used as a generic constraint"
let repos: [any Repository] = []  // компилируется, но Item неизвестен снаружи

// 1) ограничение дженерика
func printAll<R: Repository>(_ repo: R) where R.Item: CustomStringConvertible {
    repo.all().forEach { print($0.description) }
}

// 2) стирание типа — как AnyPublisher в Combine
struct AnyRepository<Item>: Repository {
    private let _all: () -> [Item]

    init<R: Repository>(_ base: R) where R.Item == Item {
        _all = base.all
    }

    func all() -> [Item] { _all() }
}

Причина ограничения в том, что «протокол с дырой» — это не один тип, а семейство типов: у UserRepo.Item и OrderRepo.Item разные размеры и разное поведение, поэтому сложить их в один массив без обёртки нельзя. Type erasure прячет конкретный тип за фасадом с замыканиями — ровно так устроен AnyPublisher в Combine.

some против any

func makeCircle() -> some Shape {        // opaque type: один конкретный тип
    Circle(radius: 1)
}

func makeShape(round: Bool) -> any Shape {  // existential: тип решается в рантайме
    round ? Circle(radius: 1) : Square(side: 2)
}

some Shape — «конкретный тип, который знает компилятор, но не знает вызывающий». Он один и тот же на всех путях выполнения, диспетчеризация статическая, boxing отсутствует, обобщённая информация (например, Element коллекции) сохраняется. Поэтому вернуть из такой функции то Circle, то Square нельзя — это ошибка компиляции.

any Shape — существующий тип-коробка: значение лежит в контейнере, вызовы идут через witness table, есть накладные расходы и возможен heap-аллокейшн для крупных значений. Зато в массив [any Shape] можно сложить что угодно, лишь бы соответствовало протоколу. Правило по умолчанию: some в параметрах и возвращаемых значениях, any — только когда гетерогенность действительно нужна.

Условные расширения

extension Collection where Element: Numeric {
    func total() -> Element { reduce(.zero, +) }
}

extension Array where Element == String {
    var joinedByComma: String { joined(separator: ", ") }
}

where позволяет добавить метод только тем типам, где он осмыслен: total() появится у [Int], но не у [String]. Это и есть «прокачка стандартной библиотеки» — то, ради чего в Swift затевали POP.

Типичные ошибки кандидатов

  • Описывать протокол как «интерфейс из Java», не упоминая extension с реализацией по умолчанию.
  • Не знать, что метод только из extension диспетчеризуется статически, и удивляться «неправильному» вызову.
  • Считать, что any Protocol и some Protocol — синонимы или просто «новый синтаксис».
  • Объяснять type erasure как «приведение к Any».
  • Не мочь назвать причину, по которой протокол с associatedtype нельзя использовать как обычный тип.
  • Пихать протокол везде: контракт с одной реализацией часто лишний слой.

Как ответить кратко

POP — это когда поведение собирается из протоколов и их extension, а не из иерархии классов: тип принимает несколько контрактов и получает реализации по умолчанию, без единственного предка. Важная ловушка: метод, объявленный только в extension, диспетчеризуется статически по типу переменной — чтобы работал полиморфизм, требование нужно объявить в самом протоколе. Протокол с associatedtype — это семейство типов, поэтому его берут либо как ограничение дженерика, либо через type erasure, либо как any. some — opaque type: один конкретный тип, известный компилятору, статическая диспетчеризация. any — existential: коробка с динамической диспетчеризацией, нужен для гетерогенных коллекций.

Проверьте себя
1. Метод объявлен только в protocol extension, а конкретный тип его переопределяет. Какая реализация вызовется через переменную, объявленную с типом протокола?
AРеализация конкретного типа: диспетчеризация всегда динамическая
BРеализация из extension: вызов резолвится статически по типу переменной
CОшибка компиляции: переопределять методы extension запрещено
DЗависит от того, class это или struct
2. Почему протокол с associatedtype нельзя было использовать как обычный тип переменной до появления any?
AПотому что associatedtype запрещает наследование протоколов
BПотому что такой протокол описывает семейство типов, и конкретный Item неизвестен
CПотому что associatedtype работает только с классами
DПотому что компилятор не умеет выводить типы у generic-структур
3. Чем some Shape отличается от any Shape в возвращаемом типе функции?
Asome — это один конкретный тип, известный компилятору, а any — коробка с динамической диспетчеризацией
BНичем, some это просто новый синтаксис для any
Csome разрешает возвращать разные типы на разных ветках, а any — нет
Dany работает быстрее, потому что не требует witness table