Антон Рябов bio photo

Антон Рябов

Не люблю бриться и у меня умный взгляд.

Email Twitter Telegram Github PGP RSS

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

Обзор

От переводчика: Оригинал статьи

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

Во-первых, нам нужен класс, который будет включать наш собственный модуль CustomEnumerable, давайте определим его:

class ArrayWrapper

  include CustomEnumerable

  def initialize(*items)
    @items = items.flatten
  end

  def each(&block)
    @items.each(&block)
    self
  end

  def ==(other)
    @items == other
  end

end

Здесь не так много кода, инклудим CustomEnumerable (нашу собственную реализацию Enumerable) и пишем враппер для Array. Также реализован метод ==, который необязателен для функциональности Enumerable, но нужен нам чтобы легче использовать матчеры Rspec.

map

В документации про map написано:

Возвращает новый массив с результатами выполнения блока для каждого элемента в исходном массиве.

Итак, наш код должен вызывать переданный блок кода на каждом элементе коллекции и затем генерировать новый массив с результатом выполнения каждого вызова. Давайте реализуем это:

module CustomEnumerable

  def map(&block)
    result = []
    each do |element|
      result << block.call(element)
    end
    result
  end

end

Это будет шаблон почти для всех методов, которые мы создаем: создаем целевой массив, вызываем метод each и делаем нужную работу. Важно знать, что наша реализация ничего не знает о том где будет включена (included), ожидается только, что у объекта будет метод each.

Чтобы увидеть map в действии давайте умножим каждый элемент массива на 2:

it 'maps the numbers multiplying them by 2' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.map do |n|
    n * 2
  end

  expect(result).to eq([2, 4, 6, 8])
end

find

Вот что говорит документация о find:

Помещает каждую запись массива в блок. Возвращает первое вхождение для которого блок не false. Если ни один объект не подошел вызывается переменная ifnone, если она не задана возвращается nil.

find используется чтобы искать объекты в Enumerable совпадающие с блоком, переданным в метод, давайте реализуем его:

def find(ifnone = nil, &block)
  result = nil
  found = false
  each do |element|
    if block.call(element)
      result = element
      found = true
      break
    end
  end
  found ? result : ifnone && ifnone.call
end

Вначале мы устанавливаем переменные, в одну мы сохраним результат, если он будет, а другая нужна в качестве сигнала, если мы действительно найдем значение. Почему бы просто не использовать переменную result со значением nil если мы ничего не нашли? Потому что nil может быть тем самым значением, которое ищет пользователь!

Итак, нам действительно нужно знать нашли мы что-то (неважно что это) или нет до того как будем возвращать результат. И если мы ничего не нашли то вызываем результат ifnone, если ifnone - nil просто вернем его.

Есть много вариантов использования для find, например мы можем искать элемент массива:

it 'finds the item given a predicate' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.find do |element|
    element == 3
  end

  expect(result).to eq(3)
end

Мы можем изменить значение по-умолчанию если результат не найден:

it 'returns the ifnone value if no item is found' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.find(lambda {0}) do |element|
    element < 1
  end
  expect(result).to eq(0)
end

Это полезно если вы всегда хотите возвращать какое-то значение, даже если ничего не нашлось.

От переводчика: в случае выше, мы возвращаем ноль, если ничего не нашлось, вместо дефолтного nil.

Ну и в простых случаях всегда можно оставить дефолтное значение:

it "returns nil if it can't find anything" do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.find do |element|
    element == 10
  end
  expect(result).to be_nil
end

Все отлично, find возвращает первое совпадение в коллекции, но что если я хочу найти вернуть все значения внутри Enumerable удовлетворяющие критериям? Нам нужно использовать метод find_all!

find_all

Снова обратимся к документации:

Возвращает массив, содержащий все элементы из перечисления для которых переданный блок возвращает значение true

Итак, теперь у нас нет значений по-умолчанию, метод всегда возвращает массив всех объектов для которых выполняется переданный в блоке код (или пустой массив в случае когда совпадений нет), давайте сделаем это:

def find_all(&block)
  result = []
  each do |element|
    if block.call(element)
      result << element
    end
  end
  result
end

Поскольку find выходит сразу же как только найдется результат, мы не можем использовать его в данном случае, наш метод find_all должен быть написан с нуля. Мы создаем массив, проходим по нашему перечислению, проверяя каждый элемент и если элемент подходит, добавляем его в массив с результатами, по окончанию мы возвращаем коллекцию с объектами, которые совпадают.

Давайте посмотрим на несколько примеров:

it 'finds all the numbers that are greater than 2' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.find_all do |element|
    element > 2
  end
  expect(result).to eq([3,4])
end

it 'does not find anything' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.find_all do |element|
    element > 4
  end
  expect(result).to be_empty
end

Даже если совпадений не будет, код вернет массив (хоть и пустой), так что при использовании 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.

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

def reduce(accumulator, &block)
  each do |element|
    accumulator = block.call(accumulator, element)
  end
  accumulator
end

Итак, это довольно просто, мы вызываем блок с аккумлятором и элементом и следующий аккумулятор это производная вызова блока. Довольно простая реализация, но эта абстракция невероятно мощная и доступна во всех функциональных языках программирования для аггрегации (reduce в данном случае, это часть парадигмы map-reduce).

Давайте посмотрим на пример:

it 'sums all numbers' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.reduce(0) do |accumulator,element|
    accumulator + element
  end
  expect(result).to eq(10)
end

И в примере у нас простая reduce функция, которая производит сложение всех элементов. Также важно проверить случай, когда enum пустой, если это так, функция должна вернуть начальное значение:

it 'returns the accumulator if no value was provided' do
  items = ArrayWrapper.new
  result = items.reduce(50) do |accumulator,element|
   accumulator + element
  end
  expect(result).to eq(50)
end

Теперь, давайте добавим первый опциональный параметр, символ операции который применяется вместо блока.

def reduce(accumulator, operation = nil, &block)
  if operation && block
    raise ArgumentError, "you must provide either an operation symbol or a block, not both"
  end

  block = case operation
    when Symbol
      lambda { |acc,value| acc.send(operation, value) }
    when nil
      block
    else
      raise ArgumentError, "the operation provided must be a symbol"
  end

  each do |element|
    accumulator = block.call(accumulator, element)
  end
  accumulator
end

Фактически реализация особо не поменялась, мы добавили проверку которая исключает случаи передачи в метод лишнего, так как должен быть передан либо символ операции либо блок. Далее мы определяем блок, если в operation передан символ то используем его, если там nil то присваим блок в block иначе вызываем ошибку. Основная петля (loop - т.е. проход по элементам перечисления) по факту не изменилась.

Теперь посмотрим на использование:

it 'executes the operation provided' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.reduce(0, :+)
  expect(result).to eq(10)
end

Первое - базовое использование, вызов reduce с символом, который применяется к аккумулятору и каждому значению. Это тот же самый пример что и для нашей первой реализации reduce, но теперь используется меньше кода.

Теперь давайте посмотри на ошибочные варианты, во-первых передадим в метод и оператор и блок:

it "fails if both a symbol and a block are provided" do
  items = ArrayWrapper.new(1, 2, 3, 4)
  expect do
    items.reduce(0, :+) do |accumulator,element|
      accumulator + element
    end
  end.to raise_error(ArgumentError, "you must provide either an operation symbol or a block, not both")
end

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

it 'fails if the operation provided is not a symbol' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  expect do
    items.reduce(0, '+')
  end.to raise_error(ArgumentError, "the operation provided must be a symbol")
end

Не Symbol? Извини, не могу это использовать. </br> Теперь, последний шаг нашей реализации - параметр аккумулятор теперь опциональный. Если его нет, должен быть использован первый элемент коллекции. Теперь у нас есть 4 варианта использования reduce:

  • accumulator + блок кода
  • accumulator + operation
  • operation
  • без параметро + блок

Давайте сделаем тесты для двух случаев, которые мы пропустили ранее, вызов только operation:

it 'executes the operation provided without an initial value' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.reduce(:+)
  expect(result).to eq(10)
end

И вызов только блока:

it 'executes the block provided without an initial value' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  result = items.reduce do |accumulator,element|
    accumulator + element
  end
  expect(result).to eq(10)
end

Почему эти два теста?

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

Если мы попробуем запустить эти тесты:

Failures:

1) CustomEnumerable reduce executes the operation provided without an initial value
  Failure/Error: @items.each(&block)
  NoMethodError:
  undefined method `call' for nil:NilClass
  # ./lib/custom_enumerable.rb:47:in `block in reduce'
  # ./spec/custom_enumerable_spec.rb:12:in `each'
  # ./spec/custom_enumerable_spec.rb:12:in `each'
  # ./lib/custom_enumerable.rb:46:in `reduce'
  # ./spec/custom_enumerable_spec.rb:128:in `block (3 levels) in <top (required)>'

2) CustomEnumerable reduce executes the block provided without an initial value
  Failure/Error: result = items.reduce do |accumulator,element|
  ArgumentError:
  wrong number of arguments (0 for 1..2)
  # ./lib/custom_enumerable.rb:32:in `reduce'
  # ./spec/custom_enumerable_spec.rb:134:in `block (3 levels) in <top (required)>'

Как мы будем делать это? Большая часть кода будет заниматься жонглированием параметров. reduce был объявлен задолго до того как в ruby появились именованные параметры, нет магического способа определить аккумулятор это операция или нет, мы должны проверить это вручную.

Также, нам нужен способ получить первый элемент коллекции, иначе нам придется это делать в самом методе reduce. Давайте начнем с реализации метода first:

def first
  found = nil
  each do |element|
    found = element
    break
  end
  found
end

Использовать его очень просто:

it 'returns the first element inside a collection' do
  items = ArrayWrapper.new(1, 2, 3, 4)
  expect(items.first).to eq(1)
end

it 'returns nil if the collection is empty' do
  items = ArrayWrapper.new
  expect(items.first).to be_nil
end

Если вы спрашиваете себя - почему он использует break а не просто возвращает элемент из each, попробуйте поменять код на:

def first
  each do |element|
    return element
  end
end

Что сейчас произошло?

Вторая спека которая ожидает что вернется nil, когда коллекция пустая, провалилась. Почему? Потому что each возвращает саму коллекцию когда запускается, так как код ни разу не был выполнен (коллекция пустая!) each просто возвращает себя а не nil, как мы ожидаем. Вот почему нам нужно использовать return и break.

Теперь, когда метод first написан, давайте сделаем финальную реализацию reduce:

def reduce(accumulator = nil, operation = nil, &block)
  if accumulator.nil? && operation.nil? && block.nil?
    raise ArgumentError, "you must provide an operation or a block"
  end

  if operation && block
    raise ArgumentError, "you must provide either an operation symbol or a block, not both"
  end

  if operation.nil? && block.nil?
    operation = accumulator
    accumulator = nil
  end

  block = case operation
    when Symbol
      lambda { |acc, value| acc.send(operation, value) }
    when nil
      block
    else
    raise ArgumentError, "the operation provided must be a symbol"
  end

  if accumulator.nil?
    ignore_first = true
    accumulator = first
  end

  index = 0

  each do |element|
    unless ignore_first && index == 0
      accumulator = block.call(accumulator, element)
    end
    index += 1
  end
  accumulator
end

Учитывая, что мы не знаем как будут переданы параметры или структуру коллекции, мы не можем оптимизировать данный вызов (если только не задублируем код немного, например, добавив упорядочивание реализации если переда аккумулятор). Но, так как мы хотим, чтобы код работал во всех случаях, мы будем надеяться что классы, включающие данный модуль будут предоставлять реализацию с более ровными структурами.

Код начинается с проверки всех параметров, если параметры не переданы, выходим, нечего делать. Далее начинается проверка какую ситуацию мы решаем, вначале проверяется - если operation и block - nil, значит поле accumulator должно быть операцией и значит у нас нет аккумулятора.

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

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

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

reduce magic

Теперь, когда у нас есть реализация reduce, есть много методов, которые мы можем построить на его базе, например min и max:

def min
  reduce do |accumulator,element|
    accumulator > element ? element : accumulator
  end
end

def max
  reduce do |accumulator,element|
    accumulator < element ? element : accumulator
  end
end

Наш reduce уже обрабатывает вариант с пустым значением:

it 'produces nil if it is empty' do
  items = ArrayWrapper.new
  expect(items.max).to be_nil
end

И случай одиночного варианта:

it 'produces 1 as the max result' do
  items = ArrayWrapper.new(1)
  expect(items.max).to eq(1)
end

Нашим реализациям min и max не нужно заботиться об этом, все что нужно сделать это передать блок который делает сравнение и возвращает наибольший или наименьший элемент, всю остальную работу делает reduce. Мощно, не так ли?

Есть еще много методов Enumerable помимо reduce, такие как each_with_index, each_with_object, count, max_by, min_by и другие, попробуйте также реализовать их на Ruby.

Ну и все что было сделано в этом примере доступно на github.

#Ruby #Tutorial