Протоколы и дженерики: 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: коробка с динамической диспетчеризацией, нужен для гетерогенных коллекций.