Enumerable в Руби является, безусловно, одним из самых лучших примеров как нужно делать модули. Он предоставляет большой набор методов, полезных для обработки структур данных и требуют от вас реализовать только один метод - each
. Так, для любого класса, который будет вести себя как коллекция и реализовывать метод each
, может быть использован Enumerable.
Обзор
От переводчика: Оригинал статьи
Хороший способ понять как Enumerable работает - реализовать его основные методы. Реализовывая каждый метод самостоятельно, мы лучше понимаем, что каждый из них делает и как можно построить такую функциональность, которая требует реализации только одного метода.
Во-первых, нам нужен класс, который будет включать наш собственный модуль CustomEnumerable
, давайте определим его:
Здесь не так много кода, инклудим CustomEnumerable
(нашу собственную реализацию Enumerable) и пишем враппер для Array. Также реализован метод ==
, который необязателен для функциональности Enumerable, но нужен нам чтобы легче использовать матчеры Rspec.
map
В документации про map
написано:
Возвращает новый массив с результатами выполнения блока для каждого элемента в исходном массиве.
Итак, наш код должен вызывать переданный блок кода на каждом элементе коллекции и затем генерировать новый массив с результатом выполнения каждого вызова. Давайте реализуем это:
Это будет шаблон почти для всех методов, которые мы создаем: создаем целевой массив, вызываем метод each
и делаем нужную работу. Важно знать, что наша реализация ничего не знает о том где будет включена (included), ожидается только, что у объекта будет метод each
.
Чтобы увидеть map
в действии давайте умножим каждый элемент массива на 2:
find
Вот что говорит документация о find
:
Помещает каждую запись массива в блок. Возвращает первое вхождение для которого блок не false. Если ни один объект не подошел вызывается переменная ifnone, если она не задана возвращается nil.
find
используется чтобы искать объекты в Enumerable совпадающие с блоком, переданным в метод, давайте реализуем его:
Вначале мы устанавливаем переменные, в одну мы сохраним результат, если он будет, а другая нужна в качестве сигнала, если мы действительно найдем значение. Почему бы просто не использовать переменную result
со значением nil
если мы ничего не нашли? Потому что nil
может быть тем самым значением, которое ищет пользователь!
Итак, нам действительно нужно знать нашли мы что-то (неважно что это) или нет до того как будем возвращать результат. И если мы ничего не нашли то вызываем результат ifnone
, если ifnone
- nil
просто вернем его.
Есть много вариантов использования для find
, например мы можем искать элемент массива:
Мы можем изменить значение по-умолчанию если результат не найден:
Это полезно если вы всегда хотите возвращать какое-то значение, даже если ничего не нашлось.
От переводчика: в случае выше, мы возвращаем ноль, если ничего не нашлось, вместо дефолтного nil.
Ну и в простых случаях всегда можно оставить дефолтное значение:
Все отлично, find
возвращает первое совпадение в коллекции, но что если я хочу найти вернуть все значения внутри Enumerable удовлетворяющие критериям? Нам нужно использовать метод find_all
!
find_all
Снова обратимся к документации:
Возвращает массив, содержащий все элементы из перечисления для которых переданный блок возвращает значение
true
Итак, теперь у нас нет значений по-умолчанию, метод всегда возвращает массив всех объектов для которых выполняется переданный в блоке код (или пустой массив в случае когда совпадений нет), давайте сделаем это:
Поскольку find
выходит сразу же как только найдется результат, мы не можем использовать его в данном случае, наш метод find_all
должен быть написан с нуля. Мы создаем массив, проходим по нашему перечислению, проверяя каждый элемент и если элемент подходит, добавляем его в массив с результатами, по окончанию мы возвращаем коллекцию с объектами, которые совпадают.
Давайте посмотрим на несколько примеров:
Даже если совпадений не будет, код вернет массив (хоть и пустой), так что при использовании find_all
нужно не забывать проверять что массив имеет объекты или нет (вместо проверки на nil, как это было сделано в find
).
reduce
reduce
или inject
(также известный как foldLeft
в других языках как OCaml или Scala) это метод который обрабатывает элементы enum применяя к ним блок, принимающий два параметра - аккумулятор (memo) и обрабатываемый элемент. На каждом шаге аккумулятору memo присваивается значение, возвращенное блоком. Первая форма позволяет присвоить аккумулятору некоторое исходное значение. Вторая форма в качестве исходного значения аккумулятора использует первый элемент коллекции (пропуская этот элемент при проходе). Хоть и звучит странно, это очень полезная функция.
Давайте посмотрим в документацию:
Комбинирует все элементы в enum, применяя бинарную операцию, переданную в виде блока или символа в метод.
If you specify a block, then for each element in enum the block is passed an accumulator value (memo) and the element. If you specify a symbol instead, then each element in the collection will be passed to the named method of memo. In either case, the result becomes the new value for memo. At the end of the iteration, the final value of memo is the return value for the method.
Если явно не указано начальное значение для memo, то первый элемент коллекции используется в качестве начального значения memo.
Таким образом, мы должны получить блок или символ и мы можем получить начальное значение, если оно не передано, то в качестве начального значения будет использоваться первый элемент. Эта реализация на самом будет немного сложнее, давайте начнем с простого случая когда мы передаем в метод и блок и начальное значение:
Итак, это довольно просто, мы вызываем блок с аккумлятором и элементом и следующий аккумулятор это производная вызова блока. Довольно простая реализация, но эта абстракция невероятно мощная и доступна во всех функциональных языках программирования для аггрегации (reduce в данном случае, это часть парадигмы map-reduce).
Давайте посмотрим на пример:
И в примере у нас простая reduce функция, которая производит сложение всех элементов. Также важно проверить случай, когда enum пустой, если это так, функция должна вернуть начальное значение:
Теперь, давайте добавим первый опциональный параметр, символ операции который применяется вместо блока.
Фактически реализация особо не поменялась, мы добавили проверку которая исключает случаи передачи в метод лишнего, так как должен быть передан либо символ операции либо блок. Далее мы определяем блок, если в operation
передан символ то используем его, если там nil
то присваим блок в block
иначе вызываем ошибку. Основная петля (loop - т.е. проход по элементам перечисления) по факту не изменилась.
Теперь посмотрим на использование:
Первое - базовое использование, вызов reduce
с символом, который применяется к аккумулятору и каждому значению. Это тот же самый пример что и для нашей первой реализации reduce
, но теперь используется меньше кода.
Теперь давайте посмотри на ошибочные варианты, во-первых передадим в метод и оператор и блок:
Когда переданы оба параметра, мы должны выдать ошибку, потому что непонятно что хочет пользователь. Тоже если то, что передано в operation
не является символом.
Не Symbol
? Извини, не могу это использовать.
</br>
Теперь, последний шаг нашей реализации - параметр аккумулятор теперь опциональный. Если его нет, должен быть использован первый элемент коллекции. Теперь у нас есть 4 варианта использования reduce
:
accumulator
+ блок кодаaccumulator
+operation
operation
- без параметро + блок
Давайте сделаем тесты для двух случаев, которые мы пропустили ранее, вызов только operation
:
И вызов только блока:
Почему эти два теста?
В общем-то они одинаковые, в обоих случаях нет аккумулятора, разница только в том, что в одном передается блок, но оба должны вытащить первый элемент коллекции и затем запустить reduce
.
Если мы попробуем запустить эти тесты:
Как мы будем делать это? Большая часть кода будет заниматься жонглированием параметров. reduce
был объявлен задолго до того как в ruby
появились именованные параметры, нет магического способа определить аккумулятор это операция или нет, мы должны проверить это вручную.
Также, нам нужен способ получить первый элемент коллекции, иначе нам придется это делать в самом методе reduce
. Давайте начнем с реализации метода first
:
Использовать его очень просто:
Если вы спрашиваете себя - почему он использует break
а не просто возвращает элемент из each
, попробуйте поменять код на:
Что сейчас произошло?
Вторая спека которая ожидает что вернется nil
, когда коллекция пустая, провалилась. Почему? Потому что each
возвращает саму коллекцию когда запускается, так как код ни разу не был выполнен (коллекция пустая!) each
просто возвращает себя а не nil
, как мы ожидаем. Вот почему нам нужно использовать return
и break
.
Теперь, когда метод first
написан, давайте сделаем финальную реализацию reduce
:
Учитывая, что мы не знаем как будут переданы параметры или структуру коллекции, мы не можем оптимизировать данный вызов (если только не задублируем код немного, например, добавив упорядочивание реализации если переда аккумулятор). Но, так как мы хотим, чтобы код работал во всех случаях, мы будем надеяться что классы, включающие данный модуль будут предоставлять реализацию с более ровными структурами.
Код начинается с проверки всех параметров, если параметры не переданы, выходим, нечего делать. Далее начинается проверка какую ситуацию мы решаем, вначале проверяется - если operation
и block
- nil
, значит поле accumulator
должно быть операцией и значит у нас нет аккумулятора.
Когда у нас есть параметр operation
мы достигаем другого куска, проверка аккумулятора. Если аккумулятор nil
, мы должны брать первый элемент коллекции, а также сказать методу игнорировать первую итерацию.
Наша новая петля each
теперь проверяет специальные переменные в случае пустого аккумулятора, так что мы можем безопасно обрабатывать коллекцию без дублирования значений. Это, кстати, неплохое место для оптимизации.
И это завершает реализацию метода reduce
, посмотрите можете ли вы найти более быстрое или качественное решение, безусловно есть варианты лучше моего.
reduce magic
Теперь, когда у нас есть реализация reduce
, есть много методов, которые мы можем построить на его базе, например min
и max
:
Наш reduce
уже обрабатывает вариант с пустым значением:
И случай одиночного варианта:
Нашим реализациям min
и max
не нужно заботиться об этом, все что нужно сделать это передать блок который делает сравнение и возвращает наибольший или наименьший элемент, всю остальную работу делает reduce
. Мощно, не так ли?
Есть еще много методов Enumerable
помимо reduce
, такие как each_with_index
, each_with_object
, count
, max_by, min_by
и другие, попробуйте также реализовать их на Ruby.
Ну и все что было сделано в этом примере доступно на github.