Развитие способностей коллекций в Swift (на примере функции suffix)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
Автор статьи: Павел Плотников

iOS-разработчик в BestDoctor. Преподаватель в OTUS

Протоколы иерархии Sequence/Collection имеют одно из самых важных значений в Swift, начиная со встроенности в язык (например, конструкция цикла for in) и заканчивая популярными функциями высшего порядка mapreduce и т.п. Часто разработчики путаются в особенностях, не осознавая возможности, предоставляемые отдельными протоколами иерархии коллекций. Давайте попробуем разобраться с этими особенностями на примере одной функции, которая по разному реализуется в этих протоколах — функция suffix. Функция suffix возвращает массив из заданного количества элементов взятых с конца последовательности. Мы рассмотрим работу этой функции на основе реализации по умолчанию у протоколов SequenceCollectionBidirectionalCollection и RandomAccessCollection. Все эти протоколы последовательно наследуются друг от друга и на каждом уровне наследования добавляются новые возможности начиная с примитивного последовательного доступа и заканчивая прямым индексным доступом за время O(1).

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

Протокол Sequence

Базовый протокол всей иерархии коллекционных протоколов. Основная возможность протокола — способность перебирать элементы последовательности один за другим посредством итератора, который вы получаете через вызов функции makeIterator. Именно таким образом работает цикл for in, когда под капотом вызывается функция makeIterator и затем в цикле на полученном итераторе вызывается next(). Количество элементов в последовательности может быть ограниченным или неограниченным. Итерирование может быть деструктивным (при повторной итерации элементов может не быть в последовательности) или не деструктивным. Из-за этих особенностей у протокола нет свойства count, но для оптимизационных целей есть свойство underestimatedCount, которое при реализации функций по умолчанию позволяет зарезервировать память заранее, чтобы уменьшить вероятность ее выделения динамически в процессе работы.

Однако, такой простой принцип — перебор по одному элементов последовательности позволяет реализовать большую часть всеми известных функций. Именно на протоколе Sequence определены такие функции как mapprefixsuffixcontainsfirstminmaxdropsorted, и т.д. Для всех этих функций протокол имеет реализацию по умолчанию. Эти реализации могут быть далеко не оптимальными, но и требовать большего от протокола который реализует только перебор мы не можем. Мы привыкли получать мгновенный доступ, например, к последнему элементу в наших привычных массивах (которые являются дальними наследниками протокола Sequence), но в протоколе Sequence, вам придется последовательно перебрать все элементы, чтобы получить последний элемент. Простота этого протокола ограничивается только перебором и единственное что вы должны сделать, если хотите реализовать протокол — реализовать итератор.

Поскольку мы имеем только возможность итерации элементов, то чтобы добраться до последних n элементов (которые должна вернуть функция suffix(k)), нам нужно перебрать все элементы и складывать все элементы в структуру RingBuffer, которая будет хранить все последние пройденные элементы. Первый элемент последовательности сохранится в нулевой ячейке массива RingBuffer, второй во первой, k-ый элемент в k-1-ую ячейку RingBuffer, k+1 элемент мы опять кладем в нулевую ячейку и так далее.  В итоге, когда мы переберем все элементы последовательности в нашем RingBuffer будут хранится k последних элементов и нам останется только правильно склеить эти k элементов из массива RingBuffer. Вся эта работа, чтобы вернуть k элементов будет стоить O(n), — нам нужно в цикле пройти по всем элементам последовательности. Дороговато!

Реализацию функции suffix для Sequence можно найти здесь.

Протокол Collection

Collection — следующий протокол в иерархии, который наследуется напрямую от Sequence. К простому перебору элементов у этого протокола появляются новые способности. Во-первых декларируется недеструктивный доступ к элементам (перебор), во-вторых обеспечивается доступ по индексам в том-же порядке что и перебор. Кроме того, добавляются основные функции для работы с индексами — получение индекса на основе предыдущего index(after:), стартовый startIndex и последний endIndex (особенность которого заключается в том, что если хотите получить последний элемент, то вам нужно обратится по предыдущему индексу от endIndex, возможности subscript на endIndex нет) индексы коллекции.

Несмотря на то, что мы индексы часто воспринимаем просто как числа, в протоколе это не декларируется и требуемые индексы нужно отдельно вычислять. Мы не можем получить сразу пятый элемент коллекции, нам сначала нужно вычислить пятый индекс коллекции, а это тоже может быть не простым делом. Сначала нам нужно получить 0-ой индекс, потом на основе первого, получить 1-ый индекс и так далее. В итоге чтобы получить k-ый индекс коллекции нам нужно проделать последовательно k-операций. Еще раз повторю — у нас нет мгновенного доступа к k-ому элементу, у нас есть мгновенный доступ к элементу который находится по k-ому индексу, но чтобы этот индекс получить, нам все равно нужно проделать много работы — перебрать все предыдущие индексы начиная со стартового индекса.

Более того, получить следующий индекс мы можем только на основе предыдущего, но мы не можем получить индекс k-1 на основе индекса k (т.е. в обратно порядке). Работа по получению индексов (а это как вы уже поняли одна из основных функциональностей для этого протокола и последующих) работает в одном направлении — с начала в конец. Чтобы получить индекс последнего элемента с конца (который предшествует endIndex), нам нужно перебрать со стартового индекса n-1 индексов.

Протокол Collection дал нам возможность обращаться по индексам, и кажется это то что нам нужно, чтобы вернуть suffix(k) — достаточно просто обратится по индексам к последним k элементам — что-то типа collection[startSuffix..<endIndex], но дьявол как раз кроется в том, что чтобы получить индексы этих последних элементов — нам все равно нужно перебрать все индексы начиная со startIndex, последовательно вызывая index(after:). Да, в исходниках реализации вы увидите вызов index(:offsetBy:), но внутри этой функции все равно последовательно вызываются index(after:) один за другим и начиная со startIndex, пока мы не доберемся до n-k-го индекса. Получается итоговая дефолтная реализация suffix у Collection опять получается со сложностью O(n), но она уже основана не на переборе самих элементов, а на вычислении последовательно индексов с начала до конца, что выглядит уже не так тяжело.

Реализацию функции suffix для Collection можно найти здесь.

Протокол BidirectionalCollection

Если предыдущий протокол Collection заявлял возможность вычисления индексов в одном направлении, то протокол BidirectionalCollection, который наследуется от Collection, как вы уже наверное поняли из названия  добавляет простую возможность — функция получения индексов в обратном направлении — с конца в начало (старая возможность получения с начала в конец конечно же остается, как и все фишки Sequence перебора элементов). Таким образом, у нас появилась функция index(before:) и по сути это единственное из новшеств, что дает этот протокол. Такая простая возможность дает нам достаточно быстро получить последний элемент last — он находится по индексу index(before: endIndex). Кроме того, теперь мы достаточно легко можем получить reversed — перевернутую в обратном порядке последовательность.

Давайте теперь посмотрим что это значит для нашей функции suffix(k). Итак, чтобы получить последние k элементов, мы также должны вычислить их индексы последовательно с конца вызывая index(before:). Эти последовательные вызовы скрыты в вызове функции index(endIndex, offsetBy:-k) и затем получить эти элементы по вычисленным индексам с помощью обращения по subscript (с использование квадратных скобок, — например collection[startSuffix..<endIndex]). Но, как мы понимаем, в данном случае нам уже нужно выполнить k операций, а не n (равное количеству элементов в коллекции). Получаем вычислительную сложность этого подхода — O(k).

Реализацию функции suffix для BidirectionalCollection можно найти здесь.

Протокол RandomAccessCollection

Последний протокол в нашей статье (но протоколов последовательностей в Swift гораздо больше, мы лишь остановились на основных), который никаких новых функций не привносит в наш мир, но дает нам иную полезность ("это другое"). Смысл этого протокола — в объявлении, что основные функции работы с индексами index(_:offsetBy:) и distance(from:to:) должны работать за время O(1), т.е. протокол говорит, что теперь никаких переборов индексов — все индексы должны вычисляться сразу за O(1). Если мы хотим получить 5-ый индекс коллекции RandomAccessCollection, то мы просто вызываем index(startIndex,offsetBy:5) и мгновенно без перебора под капотом получаем этот индекс. Именно так работают массивы, когда мы хотим получить 5-ый элемент массива, мы просто передаем в subscript число 5 и получаем нужный элемент. Именно этот протокол реализуют привычные нам массивы Array.

Что касается функции suffix, то вы не найдете отдельной реализации этой функции и будет использоваться предыдущая реализация из BidirectionalCollection, основанная на вычислении индекса стартового элемента суффикса начиная с конца index(endIndex, offsetBy:-k). Но, как мы уже говорили, эта функция теперь не перебирает индексы один за другим, а за время O(1) может определить нужный индекс. Упрощенно говоря, чтобы вычислить n-k-ый индекс в массиве, нам нужно сделать одну операцию вычисления "n-k", которая выполняется, как мы знаем, моментально за O(1).

Заключение

Как мы увидели базовые последовательности развиваются постепенно от банального перебора элементов в Sequence (что уже дает великое множество известных нам операций), далее в Collection добавляется возможность работы с индексами с некоторыми ограничениями (нужно вычислять индексы, притом только в одном направлении). После Collection идет BidirectionalCollection, который добавляет дополнительную "ачивку" перебора индексов в обратном порядке. А завершает нашу базовую иерархию RandomAccessCollection, который не дает новых функций, но налагает ограничения на функции работы с индексами, которые должны быть реализованы за время O(1). Именно эту коллекцию реализуют известные нам обычные массивы в Swift.


Статью подготовил преподаватель OTUS Павел Плотников в преддверии старта курса «iOS Developer. Professional».

Всех желающих приглашаем на бесплатное demo-занятие «Пишем выразительный код на Swift 5.x». В версиях 5.0, 5.1, 5.2, 5.3, 5.4, 5.5 языка программирования Swift появилось много нововведений, позволяющих программировать более эффективно. На этом занятии рассмотрим на практических примерах самые важные из них и обзорно все оставшиеся.

Источник: https://habr.com/ru/company/otus/blog/648431/


Интересные статьи

Интересные статьи

CSS функция image-set поддерживается в браузерах на основе Chromium с 2012 года, а в Safari начиная с версии 6. Недавно поддержка появилась в Firefox 88. Давайте углубимс...
Хочу поделиться опытом автоматизации экспорта заказов из Aliexpress в несколько CRM. Приведенные примеры написаны на PHP, но библиотеки для работы с Aliexpress есть и для...
Надпись “Поддержка Flash Player будет прекращена в декабре 2020 года” в браузере способна вызвать различные эмоции у пользователей, начиная от облегчения, что де уязвимости решены, до осо...
В цифровой обработке сигналов оконные функции широко используются для ограничения сигнала во времени и их названия хорошо известны всем, кто так или иначе сталкивался с дискретным пре...
Некоторое время назад мне довелось пройти больше десятка собеседований на позицию php-программиста (битрикс). К удивлению, требования в различных организациях отличаются совсем незначительно и...