Основы Swift / 17.2. Протоколы в качестве типов


Видео


Протоколы как типы
Протоколы сами по себе не реализуют никакой функциональности. Однако любой создаваемый Вами протокол становится полноправным типом для использования в Вашем коде.

Так как это тип, то Вы можете использовать протокол во многих местах, где разрешены другие типы, включая:
  • Тип параметра или возвращаемый тип в функции, методе или инициализаторе
  • Тип константы, переменной или свойства
  • В качестве элементов массива, словаря или другого контейнера
Так как протоколы - это типы, то начинайте их имена с заглавной буквы (как например FullyNamed или RandomNumberGenerator) для соответствия их имён типам в Swift (как например Int, String или Double).
@{17.2\1\1}
Здесь приведён пример использования протокола в качестве типа:
protocol RandomNumberGenerator {
    func random() -> Double
}

class LinearCongruentialGenerator: RandomNumberGenerator {
    var lastRandom = 42.0
    let m = 139968.0
    let a = 3877.0
    let c = 29573.0
    func random() -> Double {
        lastRandom = ((lastRandom * a + c).truncatingRemainder(dividingBy:m))
        return lastRandom / m
    }
}

class Dice {
    let sides: Int
    let generator: RandomNumberGenerator
    init(sides: Int, generator: RandomNumberGenerator) {
        self.sides = sides
        self.generator = generator
    }
    func roll() -> Int {
        return Int(generator.random() * Double(sides)) + 1
    }
}
Этот пример определяет новый класс с названием Dice, который представляет n-сторонний игральный кубик для использования в настольной игре. Объекты Dice имеют целочисленное свойство с названием sided, которое представляет, сколько сторон имеет кубик, а также свойства generator, который предоставляет генератор случайных чисел, который используется для создания значений бросков кубика.

Свойство generator имеет тип RandomNumberGenerator. Следовательно, Вы можете задать ему объект любого типа, который адаптирует RandomNumberGenerator протокол. Ничего другого не требуется для присваиваемого Вами объекта этому свойству, кроме экземпляра, адаптирующего RandomNumberGenerator протокол.

Dice также имеет инициализатор для установки изначального значения. Этот инициализатор имеет параметр с названием generator, который также имеет тип RandomNumberGenerator. Вы можете передать значение любого подходящего типа в этот параметр, когда Вы инициализируете объект Dice.

Dice предоставляет один метод объекта roll, который возвращаем целое число между 1 и числом граней кубика. Этот метод вызывает метод генератора random() для создания случайного числа между 0.0 и 1.0 и использует это случайное число для создания значения броска кубика внутри корректного интервала. Так как известно, что generator адаптирует RandomNumberGenerator, то гарантируется, что он будет иметь метод random() для вызова.
@{17.2\1\2}
Здесь представлен пример класса Dice, который может быть использован для создания шестигранного кубика с помощью объекта LinearCongruentialGenerator в качестве его генератора случайных чисел:
var d6 = Dice(sides: 6, generator: LinearCongruentialGenerator())
for _ in 1...5 {
    print("Random dice roll is \(d6.roll())")
}
// Random dice roll is 3
// Random dice roll is 5
// Random dice roll is 4
// Random dice roll is 5
// Random dice roll is 4
Делегация
Делегация - это паттерн, который позволяют классу или структуре вынести (или делегировать) часть своих действий объекту другого типа. Этот дизайн-паттерн реализуется определением протокола, который инкапсулирует делегируемые особенности, так что адаптирующий тип (известный как делегат) гарантированно предоставляет функциональность, которая была делегирована. Делегация может быть использована для ответа на определённое действие или получение данных из внешнего источника без необходимости знать подразумеваемый тип этого источника.
@{17.2\1\3}
Пример ниже определяет два протокола для использования с настольными играми на основе игральных кубиков:
protocol DiceGame {
    var dice: Dice { get }
    func play()
}
protocol DiceGameDelegate {
    func gameDidStart(_ game: DiceGame)
    func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int)
    func gameDidEnd(_ game: DiceGame)
}
DiceGame - это протокол, который может быть адаптирован любой игрой, использующей игральный кубик. Протокол DiceGameDelegate может быть адаптирован любым типом для отслеживания прогресса DiceGame.
@{17.2\1\4}
Здесь приведён пример версии игры Змеи и лестницы. Эта версия адаптирована для использования объекта Dice для бросания кубиков; адаптирована для протокола DiceGame; а также сообщает DiceGameDelegate о своём прогрессе:
class SnakesAndLadders: DiceGame {
    let finalSquare = 25
    let dice = Dice(sides: 6, generator: LinearCongruentialGenerator())
    var square = 0
    var board: [Int]
    init() {
        board = Array(repeating: 0, count: finalSquare + 1)
        board[03] = +08; board[06] = +11; board[09] = +09; board[10] = +02
        board[14] = -10; board[19] = -11; board[22] = -02; board[24] = -08
    }
    var delegate: DiceGameDelegate?
    func play() {
        square = 0
        delegate?.gameDidStart(self)
        gameLoop: while square != finalSquare {
            let diceRoll = dice.roll()
            delegate?.game(self, didStartNewTurnWithDiceRoll: diceRoll)
            switch square + diceRoll {
            case finalSquare:
                break gameLoop
            case let newSquare where newSquare > finalSquare:
                continue gameLoop
            default:
                square += diceRoll
                square += board[square]
            }
        }
        delegate?.gameDidEnd(self)
    }
}
Эта версия игры обёрнута в класс с названием SnakesAndLadders, который адаптирует протокол DiceGame. Он предоставляет получаемое свойство dic и метод play() для соответствия протоколу. (Свойство dice/ объявлено как константное свойства, так как ему не нужно меняться посыле инициализации, а протокол лишь требует от него быть получаемым.)

Игровое поле Змей и лестниц устанавливается через инициализатор класса init(). Вся логика игры содержится в методе протокола play, который использует требуемое протоколом свойство dice для предоставления значений броска кубика.

Заметьте, что свойство delegate определено как опциональное DiceGameDelegate, так как делегат не требуется для игры в игру. Так как оно имеет опциональный тип, то свойство delegate автоматически получает начальное значение nil. Затем игра имеет возможность задать свойство в подходящий делегат.

DiceGameDelegate предоставляет три метода для отслеживания прогресса игры. Эти три метода встроены в логику игры внутрь метода play() выше и вызываются при старте игры, начале нового хода или по окончании игры.

Так как свойство delegate является опционалом DiceGameDelegate, то метод play() использует опциональную цепочку каждый раз при его вызове над делегатом. Если свойство delegate занулено, то эти вызовы делегата проваливаются без ошибок. Если свойство delegate не равно nil, то методы делегата будут вызваны с передачей экземпляра SnakesAndLadders в качестве параметра.
@{17.2\1\5}
Следующий пример демонстрирует класс с названием DiceGameTracker, который адаптирует протокол DiceGameDelegate:
class DiceGameTracker: DiceGameDelegate {
    var numberOfTurns = 0
    func gameDidStart(_ game: DiceGame) {
        numberOfTurns = 0
        if game is SnakesAndLadders {
            print("Started a new game of Snakes and Ladders")
        }
        print("The game is using a \(game.dice.sides)-sided dice")
    }
    func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int) {
        numberOfTurns += 1
        print("Rolled a \(diceRoll)")
    }
    func gameDidEnd(_ game: DiceGame) {
        print("The game lasted for \(numberOfTurns) turns")
    }
}
DiceGameTracker реализует все методы, требуемые в DiceGameDelegate. Он использует эти методы для отслеживания количества сделанных в игре ходов. Он сбрасывает свойство numberOfTurns в нуль при старте новой игры, увенчивает его всякий раз при начале нового хода и выводит общее количество шагов по завершению игры.

Реализация gameDidStart(_:), показанная выше использует параметр game для вывода некоторой информации встутипетнльйо касательно игры, в которую будут играть. Параметр game имеет тип DiceGame, а не SnakesAndLadders, так что gameDidStart(_:) может получить доступ и использовать только те методы и свойства, которые реализуются как часть протокола DiceGame. Однако метод всё ещё способен использовать приведение типа для запроса типа подразумевающегося объекта. В этом примере он проверяет, является ли game в действительности объектом типа SnakesAndLadders, и выводит соответствующее сообщение, если это так.

Метод gameDidStart(_:) также получает доступ к свойству dicе/ у переданного параметра game. Так как известно, что game удовлетворяет протоколу DiceGame, то оно гарантированно будет иметь свойства dice, а значит метод gameDidStart(_:) способен получить доступ к и вывести свойство sides у кубика независимо от типа играемой игры.
@{17.2\1\6}
Здесь указано, как DiceGameTracker ведёт себя в действии:
let tracker = DiceGameTracker()
let game = SnakesAndLadders()
game.delegate = tracker
game.play()
// Started a new game of Snakes and Ladders
// The game is using a 6-sided dice
// Rolled a 3
// Rolled a 5
// Rolled a 4
// Rolled a 5
// The game lasted for 4 turns
Добавление соответствия протоколу с помощью расширения
Вы можете расширить существующий тип для адаптации и соответствия новому протоколу, даже если у Вас нету доступа к исходному коду существующего типа. Расширения могут добавлять новые свойство, методы и сабскрипты существующему типу, а значит они способны добавлять любые требования, которые нужны протоколу.

Существующие объекты типа автоматически адаптируют и соответствуют протоколу, когда соответствие добавляет типу объекта в расширении.
@[17.2\1\7}
Например, этот протокол с названием TextRepresentable может быть реализован любым типом, который можно представить как текст. Это может быть как описание его самого или же текстовая версия его текущего состояния:
protocol TextRepresentable {
    var textualDescription: String { get }
}
@{17.2\1\8}
Класс Dice может быть расширен для адаптации и соответствия TextRepresentable:
extension Dice: TextRepresentable {
    var textualDescription: String {
        return "A \(sides)-sided dice"
    }
}
Это расширение адаптирует новый протокол тем же способ, если бы он был бы представлен в оригинальной реализации Dice. Имя протокола идёт после имени типа, отделённое двоеточием, а реализация всех требований протокола предоставлена внутри фигурных скобок расширения.
@{17.2\1\9}
Любой объект типа Dice теперь может рассматриваться как TextRepresentable:
let d12 = Dice(sides: 12, generator: LinearCongruentialGenerator())
print(d12.textualDescription)
// Выведет "A 12-sided dice"
@{17.2\1\10}
Аналогично, класс игры SnakesAndLadders может быть расширен для адаптации и соответствия протоколу TextRepresentable:
extension SnakesAndLadders: TextRepresentable {
    var textualDescription: String {
        return "A game of Snakes and Ladders with \(finalSquare) squares"
    }
}
print(game.textualDescription)
// Выведет "A game of Snakes and Ladders with 25 squares"
Объявление адаптации протокола через расширение
Если тип уже соответствует всем требованиям протокола, но ещё не указывает, что он соответствует этому протоколу, то Вы можете сделать его адаптирующим с помощью пустого расширения:
@{17.2\1\11}
struct Hamster {
    var name: String
    var textualDescription: String {
        return "A hamster named \(name)"
    }
}
extension Hamster: TextRepresentable {}
@{17.2\1\12}
Объекты типа Hamster теперь могут использовать везде, где требуется тип TextRepresentable:
let simonTheHamster = Hamster(name: "Simon")
let somethingTextRepresentable: TextRepresentable = simonTheHamster
print(somethingTextRepresentable.textualDescription)
// Выведет "A hamster named Simon"
Типы не адаптируют протокол автоматически, просто удовлетворяя его требованиям. Они должны явно определять свою адаптивность всегда.
@{17.2\1\13}
Протокол может быть использован в качестве типа для хранения в коллекции, как массив или словарь. Этот пример создаёт массив элементов TextRepresentable:
let things: [TextRepresentable] = [game, d12, simonTheHamster]
@{17.2\1\14}
Теперь возможно проитерироваться через все элементы в массиве и вывести текстовое описание каждого элемента:
for thing in things {
    print(thing.textualDescription)
}
// A game of Snakes and Ladders with 25 squares
// A 12-sided dice
// A hamster named Simon
Заметьте, что константа thing имеет тип TextRepresentable. Она не имеет тип Dice, DiceGame или Hamster, даже если настоящий объект за кулисами имеет один из этих типов. Однако, так как их тип TextRepresentable, а известно, что всё, имеющее тип TextRepresentable, имеет свойство textualDescription, то является безопасным получить доступ к thing.textualDescription на всяком шаге цикла.
Наследование протоколов
Протокол может унаследовать один или более других протоколов и добавить несколько дополнительных требований к тем, что он наследует. Синтаксис для наследования протоколов тот же, что и для наследования классов, но с возможностью перечислить несколько наследуемых протоколов, разделённых запятой:
@{17.2\1\15}
protocol InheritingProtocol: SomeProtocol, AnotherProtocol {
    // protocol definition goes here
}
@{17.2\1\16}
Здесь приведён пример протокола, который наследует от протокола TextRepresentable выше:
protocol PrettyTextRepresentable: TextRepresentable {
    var prettyTextualDescription: String { get }
}
Этот пример определяет новый протокол PrettyTextRepresentable, который наследуется от TextRepresentable. Всё, что адаптирует PrettyTextRepresentable, должно так же удовлетворить требованиям, заданным в TextRepresentable, плюс дополнительным требованиям, введённым в PrettyTextRepresentable. В этом примере PrettyTextRepresentable добавляет единственное требование для предоставления получаемого свойства с названием prettyTextualDescription, которое возвращает String.
@{17.2\1\17}
Класс SnakesAndLadders может быть расширен для адаптации и соответствия PrettyTextRepresentable:
extension SnakesAndLadders: PrettyTextRepresentable {
    var prettyTextualDescription: String {
        var output = textualDescription + ":\n"
        for index in 1...finalSquare {
            switch board[index] {
            case let ladder where ladder > 0:
                output += "▲ "
            case let snake where snake < 0:
                output += "▼ "
            default:
                output += "○ "
            }
        }
        return output
    }
}
Расширение утверждает, что оно адаптирует протокол PrettyTextRepresentable и предоставляет реализацию свойства prettyTextualDescription для типа SnakesAndLadders. Все, что является PrettyTextRepresentable, также должно быть TextRepresentable, поэтому реализация prettyTextualDescription начинается с доступа к свойству textualDescription протокола TextRepresentable, чтобы начать выходную строку. Он добавляет двоеточие и разрыв строки и использует это как начало милого текстового представления. Затем он итерируется через массив границ доски и добавляет геометрическую фигуру для представления содержимого каждого квадрата:
  • Если значение квадрата больше 0, то это основание лестницы и представляется как ▲.
  • Если значение квадрата меньше 0, то это голова змеи и представляется через ▼.
  • В противном случае, значение клетки равно 0, что значит, что это пустая клетка, представляемая ○.
@{17.2\1\18}
Свойство prettyTextualDescription теперь может быть использовано для вывода приятного текстового описания любого объекта SnakesAndLadders:
print(game.prettyTextualDescription)
// A game of Snakes and Ladders with 25 squares:
// ○ ○ ▲ ○ ○ ▲ ○ ○ ▲ ▲ ○ ○ ○ ▼ ○ ○ ○ ○ ▼ ○ ○ ▼ ○ ▼ ○
@{17.2\2\1}
Вы можете ограничить адаптивность протоколов типами классов (а не структурами или перечислениями) добавлением протокола AnyObject к списку наследования протокола.
protocol SomeClassOnlyProtocol: AnyObject, SomeInheritedProtocol {
    // class-only protocol definition goes here
}
В примере выше SomeClassOnlyProtocol может быть адаптировано только типами-классами. Если Вы напишите определение структуры или перечисления, которое пытается адаптировать SomeClassOnlyProtocol, то будет выдана ошибка времени компиляции.

Используйте протокол только-для-класса, когда поведение, задаваемое требованиями протокола предполагает или требует того, чтобы подходящий тип имел ссылочную семантику вместо семантики значения.
Композиция протоколов
Может быть полезным потребовать от типа соответствовать множественным протоколам одновременно. Вы можете объединить множественные протоколы в одно требование с помощью композиции протоколов. Композиция протоколов ведёт себя так, как будто Вы определили временный локальный протокол, который объединяет требования воедино. Композиции протоколов не определяют никаких новых типов протоколов.

Композиции протоколов имеет форму SomeProtocol & AnotherProtocol.

Вы можете перечислить так много протоколов, сколько Вам нужно, разделив их амперсандами (&). В дополнение к списку его протоколов композиция протоколов также может содержать один тип класса, который позволяет Вам указать требуемый суперкласс.
@{17.2\2\2}
Здесь приведён пример того, как объединяются два протокола с названиями Named и Aged в одну композицию протокола в качестве требования параметра функции:
protocol Named {
    var name: String { get }
}
protocol Aged {
    var age: Int { get }
}
struct Person: Named, Aged {
    var name: String
    var age: Int
}
func wishHappyBirthday(to celebrator: Named & Aged) {
    print("Happy birthday, \(celebrator.name), you're \(celebrator.age)!")
}
let birthdayPerson = Person(name: "Malcolm", age: 21)
wishHappyBirthday(to: birthdayPerson)
// Prints "Happy birthday, Malcolm, you're 21!"
Пример определяет протокол с названием Named с одним требованием на получаемое свойство String с названием name. Он также определяет протокол с названием Aged с единственным требованием на получаемое свойства Int с названием age. Оба этих протокола адаптируются структурой с названием Person.

Пример также определяет функцию wishHappyBirthday(to:). Тип параметра сelebrator Named & Aged, что значит "любой тип, который удовлетворяет сразу обоим протоколам Named и Aged". Не важно, какой конкретный тип был передан в функцию, если он соответствует обоим требуемым протоколам.

Пример создаёт новый объект типа Person с названием birthdayPerson и передаёт этот новый объект в функцию wishHappyBirthday(to:). Так как Person удовлетворяет обоим протоколам, то это корректный вызов, а функция wishHappyBirthday(to:) способна вывести приветствие к его дню рождения.
@{17.2\2\3}
Здесь приведён пример того, как объединяется протокол Named из предыдущего примера с классом Location:
class Location {
    var latitude: Double
    var longitude: Double
    init(latitude: Double, longitude: Double) {
        self.latitude = latitude
        self.longitude = longitude
    }
}
class City: Location, Named {
    var name: String
    init(name: String, latitude: Double, longitude: Double) {
        self.name = name
        super.init(latitude: latitude, longitude: longitude)
    }
}
func beginConcert(in location: Location & Named) {
    print("Hello, \(location.name)!")
}

let seattle = City(name: "Seattle", latitude: 47.6, longitude: -122.3)
beginConcert(in: seattle)
// Prints "Hello, Seattle!"
Функция beginConcert(in:) принимает параметр типа Location & Named, что значит любой тип, являющийся подклассом Location и соответствующий протоколу Named. В этом случае City удовлетворяет обоих требованиям.

Если Вы попытаетесь передать birthdayPerson в функцию beginConcert(in:), то это будет некорректно, так как Person не является подклассом Location. Подобно этому, если Вы создадите подкласс Location, который не соответсвует протоколу Named, вызов beginConcert(in:) с объектом этого типа так же будет некорректным.