В этом руководстве мы представим вам подход к разработке приложений, называемый «протокольно-ориентированным программированием», который стал практически основным в Swift. Это действительно то, что вам необходимо уяснить при изучении Swift!
В этом руководстве вы:
- поймёте разницу между объектно-ориентированным и протокольно-ориентированным программированием;
- разберётесь со стандартной реализаций протоколов;
- научитесь расширять функционал стандартной библиотеки Swift;
- узнаете, как расширять протоколы при помощи дженериков.
Начинаем
Представьте, что вы разрабатываете игру — гонки. Ваши игроки могут гонять на машинах, мотоциклах и на самолётах. И даже летать на птицах, это же ведь игра, верно? Основное здесь то, что есть дофига всяких «штук», на которых можно гонять, летать и т.п.
Обычный подход при разработке подобных приложений состоит в объектно-ориентированном программировании. В этом случае мы заключаем всю логику в неких базовых классах, от которых в дальнейшем наследуемся. Базовые классы, таким образом, должны содержать внутри логику «вести» и «пилотировать».
Мы начинаем разработку с создания классов для каждого средства передвижения. «Птиц» отложим пока на потом, к ним мы вернёмся чуть позже.
Мы видим, что Car и Motorcycle весьма похожи по функционалу, так что мы создаём базовый класс MotorVehicle. Car и Motorcycle будут наследоваться из MotorVehicle. Таким же образом мы создаем базовый класс Aircraft, от которого создадим класс Plane.
Вы думаете, что всё прекрасно, но — бац! — действие вашей игры происходит в XXX столетии, и некоторые машины уже могут летать.
Итак, у нас случился затык. У Swift нет множественного наследования. Каким образом ваши летающие автомобили смогут наследоваться и от MotorVehicle и от Aircraft? Создавать еще один базовый класс, в котором соединятся обе функциональности? Скорее всего, нет, так как нет ясного и простого способа добиться этого.
И что же спасёт нашу игру в этой ужасной ситуации? На помощь спешит протокольно-ориентированное программирование!
Что такого в протокольно-ориентированном программировании?
Протоколы позволяют группировать похожие методы, функции и свойства применительно к классам, структурам и перечислениям. При этом только классы позволяют использовать наследование от базового класса.
Преимущество протоколов в Swift состоит в том, что объект может соответствовать нескольким протоколам.
Ваш код при при использовании такого метода становиться более модульным. Думайте о протоколах как о строительных блоках функционала. Когда вы добавляете новую функциональность объекту, делая его соответствующим некоему протоколу, вы не делаете совершенно новый объект «с нуля», это было бы слишком долго. Вместо этого, вы добавляете разные строительные блоки до тех пор, пока объект не будет готов.
Переход от базового класса к протоколам решит нашу проблему. С протоколами мы можем создать класс FlyingCar, который соответствует и MotorVehicle и Aircraft. Миленько, да?
Займёмся кодом
Запускаем Xcode, создаём playground, сохраняем как SwiftProtocols.playground, добавляем этот код:
protocol Bird {
var name: String { get }
var canFly: Bool { get }
}
protocol Flyable {
var airspeedVelocity: Double { get }
}
Скомпилируем при помощи Command-Shift-Return, чтобы быть уверенным, что все в порядке.
Здесь мы определяем простой протокол Bird, со свойствами name и canFly. Затем определяем протокол Flyable со свойством airspeedVelocity.
В «допротокольную эпоху» разработчик начал бы с класса Flyable в качестве базового, а затем, используя наследование, определил бы Bird и всё прочее, что может летать.
Но в протокольно-ориентированном программировании всё начинается с протокола. Эта техника позволяет нам инкапсулировать набросок функционала без базового класса.
Как вы сейчас увидите, это делает процесс проектирования типов гораздо гибче.
Определяем тип, соответствующий протоколу
Добавьте этот код внизу playground:
struct FlappyBird: Bird, Flyable {
let name: String
let flappyAmplitude: Double
let flappyFrequency: Double
let canFly = true
var airspeedVelocity: Double {
3 * flappyFrequency * flappyAmplitude
}
}
Этот код определяет новую структуру FlappyBird, которая соответствует и протоколу Bird и протоколу Flyable. Её свойство airspeedVelocity — произведение flappyFrequency and flappyAmplitude. Свойство canFly возвращает true.
Теперь добавьте определения еще двух структур:
struct Penguin: Bird {
let name: String
let canFly = false
}
struct SwiftBird: Bird, Flyable {
var name: String { "Swift \(version)" }
let canFly = true
let version: Double
private var speedFactor = 1000.0
init(version: Double) {
self.version = version
}
// Swift is FASTER with each version!
var airspeedVelocity: Double {
version * speedFactor
}
}
Penguin это птица, но не может летать. Хорошо, что мы не пользуемся наследованием и не сделали всех птиц Flyable!
При использовании протоколов вы определяете компоненты функционала и делаете все подходящие объекты соответствующими протоколу
Затем мы определяем SwiftBird, но в нашей игре есть несколько разных её версий. Чем больше номер версии, тем больше её airspeedVelocity, которая определена как вычисляемое свойство.
Однако, здесь есть некоторая избыточность. Каждый тип Bird должен определить явно определить свойство canFly, хотя у нас есть определение протокола Flyable. Похоже, что нам нужен способ определить реализацию методов протокола по умолчанию. Что ж, для этого существуют расширения протоколов (protocol extensions).
Расширяем протокол поведением по умолчанию
Расширения протокола позволяют задать поведение протокола по умолчанию. Напишите этот код сразу за определением протокола Bird:
extension Bird {
// Flyable birds can fly!
var canFly: Bool { self is Flyable }
}
Этот код определяет расширение протокола Bird. В этом расширении определяется, что свойство canFly вернёт true в случае, когда тип соответствует протоколу Flyable. Другими словам, всякой Flyable-птице больше не нужно явно задавать canFly.
Теперь удалим let canFly = ... из определений FlappyBird, Penguin и SwiftBird. Скомпилируйте код и убедитесь, что всё в порядке.
Займёмся перечислениями
Перечисления в Swift могут соответствовать протоколам. Добавьте следующее определение перечисления:
enum UnladenSwallow: Bird, Flyable {
case african
case european
case unknown
var name: String {
switch self {
case .african:
return "African"
case .european:
return "European"
case .unknown:
return "What do you mean? African or European?"
}
}
var airspeedVelocity: Double {
switch self {
case .african:
return 10.0
case .european:
return 9.9
case .unknown:
fatalError("You are thrown from the bridge of death!")
}
}
}
Определяя соответствующие свойства, UnladenSwallow соответствует двум протоколам — Bird и Flyable. Такими образом, реализуется определение по умолчанию для canFly.
Переопределяем поведение по умолчанию
Наш тип UnladenSwallow, соответствуя протоколу Bird, автоматически получил реализацию для canFly. Нам, однако, нужно, чтобы UnladenSwallow.unknown возвращала false для canFly.
Добавьте внизу следующий код:
extension UnladenSwallow {
var canFly: Bool {
self != .unknown
}
}
Теперь только .african и .european возвратят true для canFly. Проверьте! Добавьте следующий код внизу нашего playground:
UnladenSwallow.unknown.canFly // false
UnladenSwallow.african.canFly // true
Penguin(name: "King Penguin").canFly // false
Скомпилируйте playground и проверьте полученные значения с указанными в комментариях выше.
Таким образом мы переопределяем свойства и методы почти так же, как используя виртуальные методы в объектно-ориентированном программировании.
Расширяем протокол
Вы также можете сделать свой собственный протокол соответствующим другому протоколу из стандартной библиотеки Swift и определить поведение по умолчанию. Замените объявление протокола Bird следующим кодом:
protocol Bird: CustomStringConvertible {
var name: String { get }
var canFly: Bool { get }
}
extension CustomStringConvertible where Self: Bird {
var description: String {
canFly ? "I can fly" : "Guess I'll just sit here :["
}
}
Соответствие протоколу CustomStringConvertible означает, что у вашего типа должно быть свойство description. Вместо того, чтобы добавлять это свойство в типе Bird и во всех производных от него, мы определяем расширение протокола CustomStringConvertible, которое будет ассоциировано только с типом Bird.
Наберите UnladenSwallow.african внизу playground. Скомпилируйте и вы увидите “I can fly”.
Протоколы в стандартных библиотеках Swift
Как видите, протоколы — эффективный способ расширять и настраивать типы. В стандартной библиотеке Swift это их свойство также широко применяется.
Добавьте этот код в playground:
let numbers = [10, 20, 30, 40, 50, 60]
let slice = numbers[1...3]
let reversedSlice = slice.reversed()
let answer = reversedSlice.map { $0 * 10 }
print(answer)
Вы наверняка знаете, что выведет этот код, но, возможно, удивитесь использованным здесь типам.
Например, slice — не Array, а ArraySlice. Это специальная «обёртка», которая обеспечивает эффективный способ работы с частями массива. Соответственно, reversedSlice — это ReversedCollection<ArraySlice>.
К счастью, функция map определена как расширение к протоколу Sequence, которому соответствуют все типы-коллекции. Это позволяет нам применять функцию map как к Array, так и к ReversedCollection и не замечать разницы. Скоро вы воспользуетесь этим полезным приёмом.
На старт
Пока что мы определили несколько типов, соответствующих протоколу Bird. Сейчас же мы добавим нечто совсем другое:
class Motorcycle {
init(name: String) {
self.name = name
speed = 200.0
}
var name: String
var speed: Double
}
У этого типа нет ничего общего с птицами и полётами. Мы хотим устроить гонку мотоциклистов с пингвинами. Пора выводить эту странную компашку на старт.
Соединяем всё вместе
Чтобы как-то объединить столь разных гонщиков, нам нужен общий протокол для гонок. Мы сможем все это сделать, даже не трогая все созданные нами до этого типы, при помощи замечательной вещи, которая называется ретроактивное моделирование. Просто добавьте это в playground:
// 1
protocol Racer {
var speed: Double { get } // speed is the only thing racers care about
}
// 2
extension FlappyBird: Racer {
var speed: Double {
airspeedVelocity
}
}
extension SwiftBird: Racer {
var speed: Double {
airspeedVelocity
}
}
extension Penguin: Racer {
var speed: Double {
42 // full waddle speed
}
}
extension UnladenSwallow: Racer {
var speed: Double {
canFly ? airspeedVelocity : 0.0
}
}
extension Motorcycle: Racer {}
// 3
let racers: [Racer] =
[UnladenSwallow.african,
UnladenSwallow.european,
UnladenSwallow.unknown,
Penguin(name: "King Penguin"),
SwiftBird(version: 5.1),
FlappyBird(name: "Felipe", flappyAmplitude: 3.0, flappyFrequency: 20.0),
Motorcycle(name: "Giacomo")]
Вот что мы тут делаем: сначала определяем протокол Racer. Это всё то, что может участвовать в гонках. Затем мы приводим все наши созданные до этого типы к протоколу Racer. И, наконец, мы создаём Array, который содержит в себе экземпляры каждого нашего типа.
Скомпилируйте playground, чтобы все было в порядке.
Максимальная скорость
Напишем функцию для определения максимальной скорости гонщиков. Добавьте этот код в конце playground:
func topSpeed(of racers: [Racer]) -> Double {
racers.max(by: { $0.speed < $1.speed })?.speed ?? 0.0
}
topSpeed(of: racers) // 5100
Здесь мы используем функцию max чтобы найти гонщика с максимальной скоростью и возвращаем её. Если массив пуст, то возвращается 0.0.
Делаем функцию более обобщенной
Предположим, что массив Racers достаточно велик, а нам нужно найти максимальную скорость не во всем массиве, а в какой-то его части. Решение состоит в том, чтобы изменить topSpeed(of:) таким образом, чтобы она принимала в качестве аргумента не конкретно массив, а всё, что соответствует протоколу Sequence.
Заменим нашу реализацию topSpeed(of:) следующим образом:
// 1
func topSpeed<RacersType: Sequence>(of racers: RacersType) -> Double
/*2*/ where RacersType.Iterator.Element == Racer {
// 3
racers.max(by: { $0.speed < $1.speed })?.speed ?? 0.0
}
- RacersType — это generic-тип аргумента нашей функции. Он может быть любым, соответствующим протоколу Sequence.
- where определяет, что содержимое Sequence должно соответствовать протоколу Racer.
- Тело самой функции осталось без изменений.
Проверим, добавив это в конце нашего playground:
topSpeed(of: racers[1...3]) // 42
Теперь наша функция работает с любым типом, отвечающим протоколу Sequence, в том числе и с ArraySlice.
Делаем функцию более «свифтовой»
По секрету: можно сделать ещё лучше. Добавим это в самом низу:
extension Sequence where Iterator.Element == Racer {
func topSpeed() -> Double {
self.max(by: { $0.speed < $1.speed })?.speed ?? 0.0
}
}
racers.topSpeed() // 5100
racers[1...3].topSpeed() // 42
А вот теперь мы расширили сам протокол Sequence функцией topSpeed(). Она применима только в случае, когда Sequence содержит тип Racer.
Компараторы протоколов
Другая особенность протоколов Swift — это то, как вы определяете операторы равенства объектов или их сравнения. Напишем следующее:
protocol Score {
var value: Int { get }
}
struct RacingScore: Score {
let value: Int
}
Имея протокол Score можно писать код, который обращается со всеми элементами этого типа одним образом. Но если завести вполне определенный тип, такой как RacingScore, то вы не спутаете его с другими производными от протокола Score.
Мы хотим, чтобы очки (scores) можно было сравнивать, чтобы понять, у кого максимальный результат. До Swift 3 разработчикам было нужно писать глобальные функции для определения оператора к протоколу. Теперь же мы можем определить эти статические методы в самой модели. Сделаем это, заменив определения Score и RacingScore следующим образом:
protocol Score: Comparable {
var value: Int { get }
}
struct RacingScore: Score {
let value: Int
static func <(lhs: RacingScore, rhs: RacingScore) -> Bool {
lhs.value < rhs.value
}
}
Мы заключили всю логику для RacingScore в одном месте. Протокол Comparable требует опеределить реализацию только для функции «меньше, чем». Все остальные функции сравнения будут реализованы автоматически, на основании созданной нами реализации функции «меньше, чем».
Протестируем:
RacingScore(value: 150) >= RacingScore(value: 130) // true
Вносим изменения в объект
До сих пор каждый пример демонстрировал, как добавить функционал. Но что, если мы хотим сделать протокол, который что-то изменяет в объекте? Это можно сделать при помощи mutating методов в нашем протоколе.
Добавьте новый протокол:
protocol Cheat {
mutating func boost(_ power: Double)
}
Здесь мы определяем протокол, который дает нам возможность жульничать (cheat). Каким образом? Произвольно изменяя содержимое boost.
Теперь создадим расширение SwiftBird, которое соответствует протоколу Cheat:
extension SwiftBird: Cheat {
mutating func boost(_ power: Double) {
speedFactor += power
}
}
Здесь мы реализуем функцию boost(_:), увеличивая speedFactor на передаваемую величину. Ключевое слово mutating даёт структуре понять, что одно из её значений будет изменено этой функцией.
Проверим!
var swiftBird = SwiftBird(version: 5.0)
swiftBird.boost(3.0)
swiftBird.airspeedVelocity // 5015
swiftBird.boost(3.0)
swiftBird.airspeedVelocity // 5030
Заключение
Здесь вы можете загрузить полный исходный код playground.
Вы узнали о возможностях протокольно-ориентированного программирования, создавая простые протоколы и увеличивая их возможности их при помощи расширений. Используя реализацию по умолчанию, вы даете протоколам соответсвующее «поведение». Почти как с базовыми классами, но только лучше, так как все это применимо также к структурам и перечислениям.
Вы также увидели, что расширение протоколов применимо и к базовым протоколам Swift.
Здесь вы найдёте официальное руководство по протоколам.
Вы можете также посмотреть прекрасную лекцию на WWDC, посвященную протокольно-ориентированному программированию.
Как и с любой парадигмой программирования, есть опасность увлечься и начать использовать протоколы налево и направо. Здесь интересная заметка о том, что стоит опасаться решений в стиле «серебряная пуля».
Shiny2
По факту какой-то чувак запили статью про своё обучение. Протоколы по факту хорошо разложены в видосах Дейва Абрамса с ввдц и так же рекомендую заглянуть в его твиттер.