Зачем нужен ещё один способ описать тип
Представьте розетку. Производителю чайника всё равно, какая марка холодильника воткнута в соседнюю розетку и как устроена проводка в стене — важно только одно: вилка подходит по форме и напряжению. Розетка не диктует, из чего сделан прибор и как он работает внутри, она лишь гарантирует набор контактов, к которым можно подключиться.
protocol в Swift — это такая же розетка, только для кода. Это список требований: какие свойства и методы обязаны быть у типа, — без единого слова о том, как именно они должны быть реализованы и есть ли у типа общий предок с другими типами.
Чем это отличается от наследования
В прошлый раз вы разбирали наследование классов: подкласс получает от родителя готовую реализацию и обязан быть с ним в одной иерархии. У этого способа есть жёсткие ограничения — у класса ровно один родитель, а структуры и перечисления наследоваться не умеют вообще.
Протокол свободен от обоих ограничений. Соответствовать (в терминологии Apple — conform) протоколу может struct, class или enum, и одновременно можно соответствовать сразу нескольким протоколам. Протокол ничего не даёт «в наследство» — реализацию каждый тип пишет сам, поэтому нет риска утащить за собой лишнее поведение родителя, которое хотелось бы просто пропустить.
Именно поэтому в Swift архитектуру принято строить вокруг протоколов, а не вокруг иерархий классов: протокол описывает «что тип умеет делать», а не «чей это потомок». Такое описание проще расширять и проще подставлять другую реализацию — например, для тестов.
Объявление протокола и требования к свойствам
Протокол объявляется ключевым словом protocol, а требование к свойству записывается без тела — только имя, тип и то, что от него нужно: чтение (get) или чтение и запись (get set).
protocol Named {
var displayName: String { get }
}
Здесь { get } означает: у соответствующего типа обязано быть свойство displayName, которое можно прочитать. Как оно устроено внутри — хранимое оно или вычисляемое, через let или через var — протоколу неважно, лишь бы читалось.
struct Person: Named {
let displayName: String
}
struct Robot: Named {
let serial: Int
var displayName: String {
return "Робот №\(serial)"
}
}
Person хранит имя напрямую в свойстве, а Robot вычисляет его на лету из serial — оба варианта одинаково законны, потому что оба дают читаемое свойство displayName. Если бы протокол требовал { get set }, то let displayName уже не подошёл бы: константу нельзя изменить, а протокол требует такую возможность.
Требования к методам
Метод в протоколе описывается так же, как обычная сигнатура функции, но тоже без тела:
protocol Shape {
func area() -> Double
}
struct Square: Shape {
let side: Double
func area() -> Double {
return side * side
}
}
Square соответствует Shape, потому что реализовал метод area() с точно такой же сигнатурой, как в требовании. Если реализовать метод с другим именем параметра или другим возвращаемым типом, компилятор не засчитает соответствие и укажет, чего не хватает.
mutating в требованиях протокола
Когда разбирали структуры, вы уже видели: метод структуры, который меняет собственные свойства, обязан быть помечен mutating. Это правило действует и внутри протокола — если у какого-то требования есть шанс, что структура захочет менять в нём self, протокол должен заранее объявить метод как mutating.
protocol Resettable {
mutating func reset()
}
struct Score: Resettable {
var value = 0
mutating func reset() {
value = 0
}
}
Классы это ограничение не касается: у класса реализация того же требования пишется без mutating, ведь свойства объекта можно менять и без этого слова — оно нужно только структурам и перечислениям, чтобы компилятор видел, где меняется значение, а где нет.
Протокол как тип
Самая полезная возможность: протокол можно использовать как обычный тип — для переменной, параметра функции или элемента массива. Тогда в одну переменную или коллекцию помещаются значения совершенно разных типов, у которых нет ничего общего, кроме соответствия протоколу.
protocol Greeter {
func greet() -> String
}
struct Cat: Greeter {
func greet() -> String { "Мяу" }
}
struct Dog: Greeter {
func greet() -> String { "Гав" }
}
let animals: [Greeter] = [Cat(), Dog()]
for animal in animals {
print(animal.greet())
}
Cat и Dog — никак не связанные между собой структуры, но массив [Greeter] спокойно хранит и ту, и другую, потому что обе умеют greet(). Внутри цикла компилятор знает только то, что гарантирует протокол, — вызвать greet() можно, а обратиться к чему-то специфичному только для Cat уже нельзя, для этого понадобилось бы приведение типов из прошлого урока.
Соответствие нескольким протоколам сразу
Через запятую в объявлении типа можно перечислить сколько угодно протоколов — в отличие от родителя-класса, которого может быть только один:
protocol Named {
var displayName: String { get }
}
protocol Greeter {
func greet() -> String
}
struct Assistant: Named, Greeter {
let displayName: String
func greet() -> String {
return "Привет, я \(displayName)"
}
}
Assistant одновременно и Named, и Greeter — реализовал требования обоих. Так протоколы собираются как отдельные способности, а не выстраиваются в одну жёсткую цепочку предков.
Частые ошибки
- Реализовать не все требования протокола. Компилятор сразу откажется собирать код и подробно перечислит, чего не хватает — читайте это сообщение, а не гадайте.
- Перепутать
{ get }и{ get set }: если протокол проситget set, свойство-реализация обязано быть изменяемым, константаletздесь не подойдёт. - Забыть
mutatingу метода структуры, если протокол объявил требование какmutating func. Без этого слова компилятор не даст менятьselfвнутри метода. - Пытаться положить в протокол готовую реализацию или хранимое свойство со значением по умолчанию прямо в объявлении протокола — протокол описывает только требования, реализация по умолчанию — это отдельная тема расширений.
- Ждать от протокола того же, что даёт наследование класса: общего хранимого состояния или вызова чего-то вроде
super. У протокола этого нет и не будет — если нужно расшарить готовый код между типами, для этого есть другие инструменты.
Резюме
- Протокол — список требований к свойствам и методам, без реализации и без общего хранилища.
- Соответствовать протоколу может структура, класс или перечисление, причём сразу нескольким протоколам одновременно.
- Свойство в протоколе описывается через
{ get }или{ get set }, метод — просто сигнатурой без тела. - Если метод протокола должен менять self в структуре, его помечают
mutatingуже в самом протоколе. - Протокол можно использовать как обычный тип — для переменных, параметров и коллекций — это и даёт полиморфизм без общей иерархии классов.
Где протоколы встретятся вам дальше
Самый заметный протокол в мире Apple — View. Каждый экран в SwiftUI это ваш собственный тип, который соответствует View и обязан предоставить свойство body. Ровно тот механизм, который вы только что разобрали: протокол задаёт требование, тип его выполняет.
Как из таких типов собирается настоящее приложение, разбирается в двух продолжениях этого курса: Приложения для iPhone и Apple Watch на SwiftUI — экран в кармане и на запястье, и Приложения для macOS на SwiftUI — окна, меню и боковые панели. Обоим нужен Mac с Xcode 27, а вот сами iPhone и часы покупать не придётся: в курсе про телефон хватает симуляторов.
Проверьте себя
4 вопроса
Профили сотрудников
Дан протокол Profile с требованиями var role: String { get } и func summary() -> String, а также две уже готовые структуры Developer и Manager, которые ему соответствуют.
Реализуйте функцию teamSummary(_ profiles: [Profile]) -> [String], которая принимает массив значений типа Profile (в нём могут вперемешку лежать и разработчики, и менеджеры) и возвращает массив строк — результат вызова summary() у каждого элемента, в том же порядке, в каком элементы шли во входном массиве.
Счётчик с протокольным требованием
Дан протокол Incrementable с требованиями var value: Int { get } и mutating func increment(by amount: Int), а также структура Counter, которая соответствует ему и хранит текущее значение в свойстве value.
Допишите тело метода increment(by:) у Counter так, чтобы он увеличивал value на переданную величину (величина может быть и отрицательной — тогда значение уменьшается).
Затем реализуйте функцию applyIncrements(_ counter: inout Counter, _ amounts: [Int]) -> Int, которая по очереди применяет к счётчику все приращения из массива amounts и возвращает итоговое значение value.