Один из моих любимых синтаксических сахаров

Одна из моих любимых особенностей Swift заключается в том, что почти все функции компилятора построены на основе реальных классов и протоколов языка.

Это означает, что если вы видите собственный тип, который имеет особое магическое свойство (например, декларативный синтаксис SwiftUI), вполне вероятно, что вы сможете воспроизвести это поведение в своих пользовательских типах.

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

Возможно, вы уже слышали о семействе протоколов Sequence, и здесь мы увидим, как компилятор использует их в качестве строительных блоков для for циклов.

for i in 0..<10 {
    print("Wait, this is not a hardcoded language thing?")
}

В большинстве языков возможность повторения простых типов данных, таких как целые числа, в операторах for часто жестко встроена в компилятор. Это не отличалось от первых нескольких версий Swift, где стандартом было использование старых циклов в стиле C.

// Swift 1.2
for var i = 0; i < 10; i++ {
    print(i)
}

Циклы стиля for in пришли вместе с типом Range, и выпуск Swift 3 официально удалил старые циклы стиля C из языка и заставил всех использовать for in циклы, к которым мы все привыкли.

Теперь for циклы в Swift основаны на фактических протоколах, которые вы можете унаследовать, чтобы добавить for in возможностей к всему, которое вы создаете, что может быть представлено как таковое.

Как и ожидалось, это означает, что возможность использовать диапазоны в качестве параметра цикла не потому, что диапазоны обрабатываются по-разному в компиляторе, а потому, что диапазоны соответствуют определенным общедоступным протоколам.

IteratorProtocol

Основой того, как итерация в целом работает в Swift, является тип IteratorProtocol. Это очень простой протокол, который принимает только два компонента: associatedtype, который представляет, что выполняется итерация, и свойство next, которое возвращает «следующий» элемент в итерации (или nil, если он завершен).

Базовым примером чего-то, что может быть реализовано как IteratorProtocol, является таймер обратного отсчета, который продолжает уменьшать значение до тех пор, пока оно не достигнет нуля:

struct CountdownIterator: IteratorProtocol {
    typealias Element = Int
    var count: Int
    mutating func next() -> Int? {
        if count == 0 {
            return nil
        } else {
            self.count -= 1
            return count
        }
    }
    init(count: Int) {
        self.count = count
    }
}

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

var iterator = CountdownIterator(count: 3)
iterator.next() // 2
iterator.next() // 1
iterator.next() // 0
iterator.next() // nil
iterator.next() // nil

Итераторы называются деструктивными, потому что вы не можете получить доступ к определенному значению более одного раза - вам нужно идти от начала до конца линейно, и вы можете сделать это только один раз. Если вам снова нужно получить доступ к значению, вам нужно создать еще один итератор и повторить процесс.

Последовательность

Что касается итераций, протокол Sequence - это то, с чем мы взаимодействуем больше всего.

Короче говоря, Sequence - это просто высокоуровневая абстракция типа IteratorProtocol, которая обеспечивает дополнительную функциональность поверх обычного процесса итерации.

Хотя Swift позволяет вам создавать Sequences, которые также IteratorProtocols, существование этого протокола позволяет отделить определение самого типа от логики, которая обрабатывает его итерацию.

Чтобы использовать его, все, что вам нужно сделать, это создать тип makeIterator(), который возвращает IteratorProtocol:

struct Countdown: Sequence {
    typealias Iterator = CountdownIterator
    let count: Int
    init(count: Int) {
        self.count = count
    }
    func makeIterator() -> CountdownIterator {
        return CountdownIterator(count: count)
    }
}

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

Sequence содержит предварительные определения для множества часто используемых алгоритмов на основе последовательностей, включая map, filter и reduce!

Хотя IteratorProtocol - это тип, определяющий, как происходит итерация, Sequence - это тип, который предоставляет дополнительные свойства и методы, которые делают эти итерации полезными в первую очередь:

let countdown = Countdown(count: 3)
countdown.map { $0 * $0 }
countdown.allSatisfy { $0.isMultiple(of: 2) }
countdown.max()
countdown.sorted()
countdown.forEach { print($0) }

Как и в IteratorProtocol, мы можем создать итератор и линейно извлекать из него значения. Вот как работают все эти свойства и методы. Например, вот как мы могли бы реализовать клон map():

extension Sequence {
    func map<T>(_ transform: (Iterator.Element) throws -> T) rethrows -> [T] {
        var iterator = makeIterator()
        var result = [T]()
        while let next = iterator.next() {
            result.append(try transform(next))
        }
        return result
    }
}

Но это еще не все - величайшее свойство Sequences и причина, по которой была задумана эта статья, заключается в том, что Sequences глубоко привязаны к компилятору Swift в форме for циклов.

Как упоминалось ранее, причина, по которой вы можете использовать диапазоны в циклах, заключается не в том, что диапазоны являются особенными, а просто в том, что диапазоны равны Sequences. Поскольку в нашем примере тип Countdown - это Sequence, его также можно использовать внутри цикла for!

for duration in Countdown(count: 3) {
    print(duration)
}

Как и следовало ожидать, это будет работать с любым типом, соответствующим Sequence.

Как циклы работают внутри Swift

Интересно, что такое поведение не ново для iOS - старожилы могут помнить, что вы могли разблокировать for-each циклы стиля в Objective-C аналогичным образом, сделав объекты наследованными от NSFastEnumerator:

for (id object in objectThatConformsToFastEnumerator) {
    NSLog(@"%@", object);
}

Но, в отличие от того времени, со Swift мы действительно можем видеть, что происходит под капотом.

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

swiftc -emit-sil forLoopTest.swift

Если я запущу эту команду с примером forLoopTest.swift файла, содержащего for цикл для массива, такого как приведенный ниже, мы увидим такие вещи вокруг области, где должен был быть определен цикл:

let array = [1,2,3]
for num in array {
    print(num)
}
// function_ref Collection<>.makeIterator()
%40 = function_ref ...
// function_ref IndexingIterator.next()
%40 = function_ref ...
switch_enum %43 : $Optional<Int>, case #some!enumelt.1: bb3, case #none!enumelt: bb2

Это показывает нам, что for циклов на самом деле не существует, это просто форма синтаксического сахара, позволяющая вам легко итерировать IteratorProtocol без необходимости извлекать итератор!

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

В этом случае волшебство происходит после того, как компилятор проверяет тип цикла: Если мы проанализируем код из средства проверки типов, мы увидим, что, если цикл представляет действительный код, который повторяет Sequence, он вводит новое свойство называется ${loopName}$generator.

Это свойство выбирает итератор последовательности, затем выбирает тип Element итератора и затем, наконец, перемещает наше исходное замыкание в новый цикл, который повторяет свойство итератора next().

Например, если мы скомпилируем предыдущий пример цикла, окончательный результат будет чем-то сопоставимым (но не совсем) с этим:

let array = [1,2,3]
var $num$generator = array.makeIterator()
while let num = $num$generator.next() { 
    print(num)
}

Компилятор Swift использует префикс $ для внутренних свойств, синтезируемых компилятором. Поскольку ему необходимо ввести реальный код, он использует символ, который невозможно использовать в обычном коде, чтобы гарантировать, что у нас не будет проблем с интервалом между именами.

Интересно, что вы можете взаимодействовать с некоторыми из этих внутренних свойств как часть новой функции оболочки свойств.

А как насчет коллекций?

Изучая больше о типах коллекций Swift, IteratorProtocol, Sequence и Collection часто упоминаются вместе, и вы могли заметить, что сгенерированный SIL для нашего примера упоминает Collections, поскольку он использует массив.

Если синтаксический сахар связан только с Sequences, как работают коллекции?

Можно сказать, что Collection - это просто более навороченная версия Sequence. Фактически, все Collections тоже Sequences:

public protocol Collection: Sequence { ... }

В отличие от обычного Sequences, Collections позволяет вам обращаться к элементам несколько раз, перебирать их в обратном порядке (в случае BidirectionalCollections) и даже обращаться к определенным элементам напрямую через индексы (в случае RandomAccessCollections, например Array<>).

В отличие от Sequences, Collections не нужно предоставлять IteratorProtocol. По умолчанию Swift предоставляет IndexingIterator структуру: IteratorProtocol, которая принимает коллекцию и просто просматривает индексы, определенные как часть протокола Collection.

Поскольку все Collections являются Sequences, им также может быть полезно иметь синтаксические сахарные петли.

Если вы хотите видеть больше подобной информации о Swift и iOS, подпишитесь на меня в моем Twitter (@rockthebruno) и дайте мне знать обо всех отзывах, предложениях и исправлениях, которыми вы хотите поделиться.

использованная литература

Исходный код Swift