$ sudo teach IT

Зачем нужен ещё один способ описать тип

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

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.