Мобильная разработка. Swift: таинство протоколов

в 7:01, , рубрики: swift, Блог компании Acronis, Inc, мобильная разработка, разработка мобильных приложений

Сегодня мы продолжаем цикл публикаций на тему мобильной разработки под iOS. И если в прошлый раз речь шла о том, что нужно и не нужно спрашивать на собеседованиях, в этом материале мы коснемся тематики протоколов, которая имеет в Swift важное значение. Речь пойдет о том, как устроены протоколы, чем они отличаются друг от друга, и как сочетаются с интерфейсами Objective-C.

Мобильная разработка. Swift: таинство протоколов - 1

Как мы уже говорили ранее, новый язык Apple продолжает активно развиваться, и большинство его параметров и особенностей явно указаны в документации. Но кто читает документацию, когда код нужно написать здесь и сейчас? Поэтому давайте пройдемся по основным особенностям протоколов Swift прямо в нашем посте.

Для начала стоит оговориться, что протоколы Apple – это альтернативный термин для понятия «Интерфейс», которое применяется в других языках программирования. В Swift протоколы служат для того, чтобы обозначить шаблоны определенных структур (т.н. blueprint), с которыми можно будет работать на абстрактном уровне. Говоря простыми словами, протокол определяет ряд методов и переменных, которые в обязательном порядке должен наследовать определенный тип.

Далее в статье будут постепенно раскрываться моменты следующим образом: от простых и часто используемых к более сложным. В принципе, на собеседованиях можно давать вопросы в таком порядке, так как они определяют уровень компетенции соискателя — от уровня джунов до уровня сеньоров.

Для чего нужны протоколы в Swift?

Мобильные разработчики часто обходятся вообще без использования протоколов, но при этом теряется возможность работать с некоторыми сущностями абстрактно. Если выделить основные особенности протоколов в Swift, у нас получится следующие 7 пунктов:

  • Протоколы обеспечивают множественное наследование
  • Протоколы не могут хранить состояние
  • Протоколы могут быть унаследованы другими протоколами
  • Протоколы могут применяться к структурам (struct), классам(class) и перечислениям (enum), определяя функционал типов
  • Дженерик протоколы (Generic-protocol) позволяют задавать сложные зависимости между типами и протоколами во время их наследования
  • Протоколы не определяют «сильные» и «слабые» ссылки на переменные
  • В расширениях к протоколам можно описывать конкретные реализации методов, и вычисляемых переменных (computed values)
  • Классовые протоколы разрешают себя наследовать только классам

Как известно, все простые типы (string, int) в Swift являются структурами. В стандартной библиотеке Swift это, например, выглядит следующим образом:

public struct Int: FixedWidthInteger, SignedInteger {

При этом типы коллекций (сollection), а именно – array, set, dictionary – также можно упаковывать в протокол, потому что они тоже являются структурами. Например, словарь (Dictionary) определяется следующим образом

public struct Dictionary<Key, Value> where Key: Hashable {

Обычно в объектно-ориентированном программировании применяется понятие классов, и всем хорошо знаком механизм наследования методов и переменных родительского класса классом-потомком. При этом никто не запрещает ему содержать дополнительные методы и переменные.

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

Реализация протокола в Swift достаточно проста. Синтаксис подразумевает название, ряд методов и параметры (переменные), которые он будет содержать.

protocol Employee {
func work()
var hours: Int { get }
}

Кроме этого в Swift протоколы могут содержать не только наименования методов, но и их реализацию. Код метода в протоколе добавляются через расширения (extension). В документации можно найти много упоминаний расширений, но применительно к протоколам в расширениях можно размещать имя функции и тело функции.

extension Employee {
func work() {
print ("do my job")
}
} 

То же самое можно делать с переменными.

extension Employee {
var hours: Int {
return 8
}
}

Если мы где-то будем применять объект, связанный с протоколом, можно задать в нем переменную с фиксированным или передаваемым значением. Фактически переменная представляет собой небольшую функцию без входных параметров…или с возможностью прямого назначения параметра.

Расширение протокола в Swift позволяет реализовать тело переменной, и тогда она по факту будет представлять собой computed value – вычисляемый параметр с функциями get и set. То есть такая переменная не будет хранить никаких значений, а будет или играть роль функции или функций, или будет играть роль прокси для какой-то другой переменной.

Или если мы берем какой-то класс или структуру и реализуем протокол, то в нем можно использовать обычную переменную:

class Programmer {
var hours: Int = 24
}
extension Programmer: Employee { }

Стоит отметить, что переменные в определении протокола не могут быть weak. (weak – это вариант реализации переменой).

Есть и более интересные примеры: можно реализовать расширение массива и добавить туда функцию, связанную с типом данных массива. Например, если массив содержит целочисленные значения или имеет формат equitable (пригодные для сравнения), функция может, например, сравнивать все значения ячеек массива.

extension Array where Element: Equatable {
    var areAllElementsEqualToEachOther: Bool {
        if isEmpty {
            return false
        }
        var previousElement = self[0]
        for (index, element) in self.enumerated() where index > 0 {
            if element != previousElement {
                return false
            }
            previousElement = element
        }
        return true
    }
}
[1,1,1,1].areAllElementsEqualToEachOther

Небольшое замечание. Переменные и функции в протоколах могут быть статическими (static).

Использование @objc

Главное, что нужно знать в данном вопросе — это то, что @objc протоколы Swift видны в Objective-C коде. Строго говоря, для этого «волшебное слово» @objc и существует. Но всё остальное остаётся без изменений

@objc protocol Typable {
    @objc optional func test()
    func type()
}
extension Typable {
    func test() {
        print("Extension test")
    }
    func type() {
        print("Extension type")
    }
}

class Typewriter: Typable {
    func test() {
        print("test")
    }
    func type() {
        print("type")
    }
}

Стоит отметить, что в этом случае появляется возможность определять опциональные функции (@obj optional func), которые при желании можно не реализовывать, как для функции test() в предыдущем примере. Но условно опциональные функции можно реализовать и при помощи расширения протокола с пустой иплементацией.

protocol Dummy {
    func ohPlease()
}
extension Dummy {
    func ohPlease() { }
}

Наследование типами

Создавая класс, структуру или перечисление, мы можем обозначить наследование определенного протокола – в этом случае унаследованные параметры будут работать и для нашего класса, и для всех остальных классов, которые наследуют этот класс, даже если мы не имеем к ним доступа.

Кстати, в этом контексте появляется одна очень интересная задачка. Допустим у нас есть протокол. Есть некоторый класс. И класс реализует протокол, а в нем есть функция work(). Что будет, если у нас есть extension протокола, в котором также есть метод work(). Какой из них будет вызван при обращении к методу?

protocol Person {
     func work()
}

extension Person {
    func work() {
        print("Person")
    }
}
class Employee { }
extension Employee: Person {
    func work() {
        print("Employee")
    }
}

Запущен будет метод класса – таковы уж особенности диспетчеризации методов в Swift. И этот ответ дают многие соискатели. Но вот на вопрос, как сделать так, чтобы в коде был выполнен не метод класса, а метод протокола, знают ответ лишь немногие. Однако и для этой задачи есть решение – оно подразумевает удаление определения протокола из класса и вызов метода следующим образом:

protocol Person {
//     func work() //спрячем этот метод в определении протокола
}

extension Person {
    func work() {
        print("Person")
    }
}
class Employee { }
extension Employee: Person {
    func work() {
        print("Employee")
    }
}

Generic-протоколы

В Swift также есть дженерик-протоколы (generic protocol) с абстрактными ассоциативными типами (associated types), которые позволяют определять переменные типов. Такому протоколу можно присваивать дополнительные условия, которые накладываются на ассоциативные типы. Несколько подобных протоколов позволяют выстраивать сложные конструкции, необходимые для формирования архитектуры приложения.

Однако реализовать переменную в виде дженерик-протокола нельзя. Его можно только наследовать. Эти конструкции используются для того, чтобы создавать зависимости в классах. То есть мы можем описать некоторый абстрактный generic-класс, чтобы определить используемые в нем типы.

protocol Printer {
    associatedtype PrintableClass: Hashable
    func printSome(printable: PrintableClass)
}
extension Printer {
    func printSome(printable: PrintableClass) {
        print(printable.hashValue)
    }
}

class TheIntPrinter: Printer {
    typealias PrintableClass = Int
}

let intPrinter = TheIntPrinter()
intPrinter.printSome(printable: 0)

let intPrinterError: Printer = TheIntPrinter() // так нельзя

Следует помнить, что дженерик-протоколы отличаются высоким уровнем абстракции. Поэтому в самих приложениях они могут быть излишними. Но при этом дженерик-протоколы используются при программировании библиотек.

Классовые протоколы

В Swift существуют также class-bound (ограниченные классом) протоколы. Для их описания применяется два вида синтаксиса

protocol Employee: AnyObject { }

Или

protocol Employee: class { }

По словам разработчиков языка, использование этих синтаксисов равнозначно, но ключевое слово class используется только в этом месте, в отличие от AnyObject, который является протоколом.

Тем временем, как мы видим в ходе собеседований, люди часто не могут объяснить, что такое классовый протокол, и зачем он нужен. Его суть заключается в том, что мы получаем возможность использовать некоторый объект, которой был бы протоколом, и одновременно с этим функционировал бы как ссылочный тип. Пример:

protocol Handler: class {} 
class Presenter: Handler { weak var renderer: Renderer?  }
protocol Renderer {}
class View: Renderer { }

В чём тут соль?

В iOS используются управление памятью по методу automatic reference count, что подразумевает наличие сильных и слабых ссылок. И в некоторых случаях следует учитывать, какие именно — сильные (strong) или слабые (weak) – переменные используются в классах.

Проблема заключается в том, что при использовании некоторого протокола в качестве типа, при описании переменной (являющейся сильной ссылкой), может возникнуть циклические связи (retain cycle), приводящие к утечкам памяти, потому что объекты будут держаться везде сильными ссылками. Также неприятности могут возникнуть, если вы всё-таки решили писать код в соответствии с принципами SOLID.

protocol Handler {} 
class Presenter: Handler {
 var renderer: Renderer?  
}
protocol Renderer {} 
class View: Renderer { 
var handler: Handler? 
}

Чтобы не возникали такие ситуации, в Swift используют классовые протоколы, которые позволяют изначально задавать «слабые» переменные. Классовый протокол разрешает держать объект слабой ссылкой. Пример, где это часто стоит учитывать, называется делегат.

protocol TableDelegate: class {}
class Table {
    weak var tableDelegate: TableDelegate?
}

Другой пример, где стоит использовать классовые протоколы, – это явное указание, что объект передается по ссылке.

Множественное наследование и диспетчеризация методов

Как говорилось в начале статьи, протоколы можно множественно наследовать. То есть,

protocol Pet {
    func waitingForItsOwner()
}
protocol Sleeper {
    func sleepOnAChair()
}
class Kitty: Pet, Sleeper {
    func eat() {
        print("yammy")
    }
    func waitingForItsOwner() {
        print("looking at the door")
    }
    func sleepOnAChair() {
        print("dreams")
    }
}

Это полезно, но какие подводные камни здесь скрыты? Всё дело в том, что сложности, по крайней мере на первый взгляд, возникают из-за диспетчеризации методов (method dispatch). Говоря простыми словами, может быть непонятно, какой метод будет вызываться – родительский или из текущего типа.

Чуть выше, мы уже раскрыли тему того, как работает код, он вызывает метод класса. То есть, как ожидается.

protocol Pet {
    func waitingForItsOwner()
}
extension Pet {
    func waitingForItsOwner() {
        print("Pet is looking at the door")
    }
}
class Kitty: Pet {
    func waitingForItsOwner() {
        print("Kitty is looking at the door")
    }
}
let kitty: Pet = Kitty()
kitty.waitingForItsOwner()
// Output: Kitty is looking at the door

Но если попробовать убрать сигнатуру метода из определения протокола, то происходит «магия». Собственно говоря, это вопрос из собеседования: «как сделать, чтобы вызвалась функция из протокола?»

protocol Pet { }
extension Pet {
    func waitingForItsOwner() {
        print("Pet is looking at the door")
    }
}
class Kitty: Pet {
    func waitingForItsOwner() {
        print("Kitty is looking at the door")
    }
}
let kitty: Pet = Kitty()
kitty.waitingForItsOwner()
// Output: Pet is looking at the door

Но если использовать переменную не как протокол, а как класс, то всё будет нормально.

protocol Pet { }
extension Pet {
    func waitingForItsOwner() {
        print("Pet is looking at the door")
    }
}
class Kitty: Pet {
    func waitingForItsOwner() {
        print("Kitty is looking at the door")
    }
}
let kitty = Kitty()
kitty.waitingForItsOwner()
// Output: Kitty is looking at the door

Все дело в статической диспетчеризации методов при расширении протокола. И это надо учитывать. Причем тут множественное наследование? А вот при чем: если взять два протокола с реализованными функциями, такой код не сработает. Чтобы функция была выполнена, потребуется явно делать каст к нужному протоколу. Такой вот отголосок множественного наследования из C++.

protocol Pet {
    func waitingForItsOwner()
}
extension Pet {
    func yawn() {
print ("Pet yawns")
}
}

protocol Sleeper {
    func sleepOnAChair()
}
extension Sleeper {
    func yawn() {
print ("Sleeper yawns")
}
}
class Kitty: Pet, Sleeper {
    func eat() {
        print("yammy")
    }
    func waitingForItsOwner() {
        print("looking at the door")
    }
    func sleepOnAChair() {
        print("dreams")
    }
}
let kitty = Kitty()
kitty.yawn()

Схожая история будет, если наследовать один протокол другим, где есть функции, которые реализуются в расширениях. Компилятор не даст его собрать.

protocol Pet {
    func waitingForItsOwner()
}
extension Pet {
    func yawn() {
print ("Pet yawns")
}
}

protocol Cat {
    func walk()
}
extension Cat {
    func yawn() {
print ("Cat yawns")
}
}
class Kitty:Cat {
    func eat() {
        print("yammy")
    }
    func waitingForItsOwner() {
        print("looking at the door")
    }
    func sleepOnAChair() {
        print("dreams")
    }
}
let kitty = Kitty()

Два последних примера показывают, что полностью заменять протоколы классами не стоит. Можно запутаться в статической диспетчеризации.

Дженерики и протоколы

Можно сказать, что это — вопрос со звёздочкой, который вовсе и не надо спрашивать. Но кодеры любят сверхабстрактные конструкции, и конечно, парочка ненужных generic class обязательно должны быть в проекте (куда ж без этого). Но программист не был бы программистом, если бы не захотел всё это обернуть это в ещё одну абстракцию. И Swift, являясь хоть и молодым, но динамично развивающимся языком, даёт такую возможность, но в ограниченном варианте. (да, это уже не про мобилки).

Во-первых, полноценная проверка на возможное наследование есть только в Swift 4.2, то есть только с осени будет возможность это нормально использовать в проектах. На Swift 4.1 выдаётся сообщение, что возможность ещё не реализована.

protocol Property { }
protocol PropertyConnection { }

class SomeProperty { }
extension SomeProperty: Property { }
extension SomeProperty: PropertyConnection { }

protocol ViewConfigurator { }
protocol Connection { }

class Configurator<T> where T: Property {
    var property: T
    init(property: T) {
        self.property = property
    }
}
extension Configurator: ViewConfigurator { }
extension Configurator: Connection where T: PropertyConnection { }

[Configurator(property: SomeProperty()) as ViewConfigurator]
    .forEach { configurator in
    if let connection = configurator as? Connection {
        print(connection)
    }
}

Для Swift 4.1 выводится следующее:

warning: Swift runtime does not yet support dynamically querying conditional conformance ('__lldb_expr_1.Configurator<__lldb_expr_1.SomeProperty>': '__lldb_expr_1.Connection')

Тогда как в Swift 4.2  всё работает, как ожидается:

__lldb_expr_5.Configurator<__lldb_expr_5.SomeProperty>
connection

Также стоит заметить, что можно наследовать протокол только при одном типе связей. Если есть два типа связей, то наследование будет запрещено на уровне компилятора. Подробное объяснение того, что можно, а что нельзя, показано тут.

protocol ObjectConfigurator { }
protocol Property { }

class FirstProperty: Property { }
class SecondProperty: Property { }

class Configurator<T> where T: Property {
    var properties: T
    init(properties: T) {
        self.properties = properties
    }
}
extension Configurator: ObjectConfigurator where T == FirstProperty { } // тут будет ошибка:
 // Redundant conformance of 'Configurator<T>' to protocol 'ObjectConfigurator'

extension Configurator: ObjectConfigurator where T == SecondProperty { }

Но, не смотря на эти сложности, работа со связями в дженериках достаточно удобная.

Подводя итог

Протоколы были предусмотрены в Swift в текущем виде, чтобы сделать разработку более структурной и обеспечить более совершенные модели наследования, чем в том же Objective-C. Поэтому мы уверены, что использование протоколов является оправданным ходом, и обязательно спрашиваем у кандидатов в разработчики, что они знают об этих элементах языка Swift. В следующих постах мы коснемся тем диспетчеризации методов.

Автор: VyacheslavAcronis

Источник

Поделиться

* - обязательные к заполнению поля