Протоколы в Swift — это контракты, определяющие требования к типам. Узнайте, как они упрощают код и лежат в основе SwiftUI. Начните применять их прямо сейчас!
Протоколы позволяют определить, что умеет тип, не вдаваясь в его конкретную реализацию. Как только вы это осознаете, огромная часть Swift и SwiftUI станет понятной. Представьте, что вы создаете симулятор аниме-битв, и вам нужна функция, которая рассчитывает время, за которое боец доберется до поля боя. Легко — вы пишете её для Ninja:
func sendToMission(distance: Int, using fighter: Ninja) {
// код здесь
}
Но затем кто-то спрашивает: «А как насчет самурая?» Хорошо:
func sendToMission(distance: Int, using fighter: Samurai) {
// код здесь
}
Потом: «А пираты? Волшебники? Гигантские мехи?» Внезапно у вас шесть версий одной и той же функции, которые делают одно и то же, отличаясь только типом принимаемого параметра. И каждый раз, когда кто-то придумывает новый тип бойца, вам приходится писать еще одну версию. Есть способ лучше. 🍥
Протокол — это обещание. Он говорит: «Любой тип, который соответствует мне, должен иметь эти конкретные свойства и методы». Он ничего не реализует. Он не содержит кода за этими обещаниями. Это чисто контракт — список требований, которые типы могут согласиться выполнить.
Вот как выглядит протокол:
protocol Fighter {
var name: String { get }
var speed: Int { get set }
func estimateTravelTime(for distance: Int) -> Int
func travel(distance: Int)
}
Разберем по частям:
protocol Fighter — объявление нового протокола с именем Fighter. Как и все типы в Swift, имя начинается с заглавной буквы.var name: String { get } — каждый Fighter должен иметь свойство name, которое можно читать. Это может быть константа или вычисляемое свойство, главное — доступное для чтения.var speed: Int { get set } — каждый Fighter должен иметь свойство speed, которое можно читать и записывать. Это исключает константы.func estimateTravelTime(for:) и func travel(distance:) — каждый Fighter должен иметь эти два метода. Обратите внимание: без тел функций. Только сигнатуры.Вот и все. Протоколу не важно, как это реализовано. Он просто говорит: если хочешь называться Fighter, ты должен это иметь.
Теперь создадим несколько реальных бойцов. Синтаксис соответствия протоколу выглядит так же, как наследование от родительского класса — двоеточие после имени типа:
struct Ninja: Fighter {
let name = "Naruto"
var speed = 85
func estimateTravelTime(for distance: Int) -> Int {
distance / speed
}
func travel(distance: Int) {
print("\(name) использует Технику Уклонения, чтобы преодолеть \(distance) км!")
}
func shadowClone() {
print("Теневое клонирование!")
}
}
struct Samurai: Fighter {
let name = "Rurouni"
var speed = 60
func estimateTravelTime(for distance: Int) -> Int {
distance / speed
}
func travel(distance: Int) {
print("\(name) быстро скачет на лошади \(distance) км.")
}
func drawSword() {
print("Баттодзюцу!")
}
}
Обратите внимание на несколько моментов:
Fighter, реализуя все четыре обязательных члена. Если бы у одной из них не хватало хотя бы одного, Swift отказался бы компилировать код.Ninja есть дополнительный метод (shadowClone()), а у Samurai — (drawSword()). Это нормально — протокол определяет минимум, а не максимум.name — константа let в обоих случаях, что удовлетворяет { get }, так как константы доступны для чтения.speed — переменная var в обоих случаях, что удовлетворяет { get set }, так как переменные можно и читать, и записывать.Теперь все сходится. Поскольку Swift знает, что любой Fighter имеет estimateTravelTime() и travel(), мы можем написать функцию, принимающую Fighter вместо конкретного типа:
func sendToMission(distance: Int, using fighter: Fighter) {
if fighter.estimateTravelTime(for: distance) > 10 {
print("Это долгий путь. \(fighter.name) лучше подготовиться.")
} else {
fighter.travel(distance: distance)
}
}
Теперь эта единственная функция работает с любым типом, соответствующим Fighter:
let naruto = Ninja()
let rurouni = Samurai()
sendToMission(distance: 500, using: naruto)
sendToMission(distance: 500, using: rurouni)
Swift знает, что estimateTravelTime() и travel() существуют у обоих типов, потому что это гарантирует протокол. И внутри функции, когда travel() вызывается на Ninja, выводится версия ниндзя. Когда вызывается на Samurai — версия самурая. Один и тот же вызов функции, разное поведение, автоматически.
Становится еще мощнее. Вы можете поместить разные типы в один массив, если все они соответствуют одному протоколу:
func calculateAllTravelTimes(fighters: [Fighter], distance: Int) {
for fighter in fighters {
let time = fighter.estimateTravelTime(for: distance)
print("\(fighter.name) потребуется \(time) часов, чтобы преодолеть \(distance) км")
}
}
calculateAllTravelTimes(fighters: [naruto, rurouni], distance: 300)
Массив [Fighter] может содержать Ninja, Samurai или любой другой тип, соответствующий Fighter. Swift не заботится о конкретном типе в массиве — ему важно только, что все элементы выполнили обещания протокола.
Вы можете спросить: нельзя ли сделать то же самое с родительским классом? Иметь класс Fighter, от которого наследуются Ninja и Samurai? Можно, но у протоколов есть два больших преимущества:
struct Ninja: Fighter, Codable, CustomStringConvertible {
// должен удовлетворять всем трем протоколам
}
Вот почему разработчики Swift любят протоколы — они гибки так, как наследование не может.
Эти два аннотации легко упустить из виду, но их важно правильно понимать:
{ get } — свойство должно быть доступно для чтения. Можно использовать константу let, переменную var или вычисляемое свойство с геттером.{ get set } — свойство должно быть доступно для чтения и записи. Можно использовать только переменную var или вычисляемое свойство с геттером и сеттером. Константа let не подойдет.Если попытаться удовлетворить требование { get set } с помощью let, Swift сообщит об ошибке соответствия.
Протоколы позволяют писать код, который работает с любым типом, дающим правильные обещания, а не с одним конкретным типом. Это делает ваши функции более гибкими, массивы — более универсальными, а весь код — проще расширять без поломки существующего.
А в SwiftUI протоколы буквально повсюду. View — сам по себе протокол. Identifiable — протокол. Codable — протокол. Вы использовали их с первой статьи о SwiftUI — теперь вы точно знаете, что это такое и как они работают. 🌸
Прямо сейчас откройте свой проект и найдите функцию, которая принимает конкретный тип, но могла бы принимать протокол. Определите протокол с нужными свойствами и методами, заставьте тип соответствовать ему и замените параметр функции на этот протокол. Вы сразу почувствуете гибкость. А если вы еще не используете протоколы в SwiftUI — обратите внимание, что View — это протокол, и создайте свой первый кастомный вью-модификатор, используя протокол ViewModifier. Так вы закрепите знания на практике.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →