Продвинутые перечисления с Ruby

в 17:48, , рубрики: blocks, enumerations, enumerators, lambdas, ruby, ruby on rails, перечисления, перечислители

Перечисления (enumeration) по определению это — «действие упоминания некоторого количества чего-либо один за одним». В программировании, вместо упоминания, мы выбираем любое действие, которое хотим произвести, будь то простой вывод на монитор или выполнение некоторого рода выборки и/или преобразования над элементом.

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

Как работают перечисления в Ruby

В этом посте, я дам Вам краткий обзор о том, что делают блоки (block) и yield'ы. Блоки в Ruby, в которых мы заинтересованы, является кусками кода, определенными внутри методов или proc/lambda. Вы можете думать о yield, как о текущем блоке кода, куда вставляется другой блок кода откуда-то еще. Позвольте мне продемонстрировать.

def my_printer
  puts "Hello World!"
end

def thrice
  3.times do
    yield
  end
end

thrice &method(:my_printer)
# Hello World!
# Hello World!
# Hello World!

thrice { puts "Ruby" }
# Ruby
# Ruby
# Ruby

Методы принимают две формы блоков для команды yield: proc'ы или блоки. Метод method трансформирует определение метода в proc, который затем может быть передан внутрь как блок, как в примере my_printer выше.

Выше на том месте, где написана команда yield, равнозначно, как если бы код передающийся как блок, был бы на месте yield. Так что в первом случае представьте вызов yield, замененным на puts «Hello World!» и второй yield замененным на puts «Ruby».

yield может также работать как простой перечислитель. Вы можете передать любое значение внутрь как параметр к block/proc добавляя их после yield.

def simple_enum
  yield 4
  yield 3
  yield 2
  yield 1
  yield 0
end

simple_enum do |value|
  puts value
end
# 4
# 3
# 2
# 1
# 0

Требования к минимальному перечислителю

Стандартный способ создания перечислителя в Ruby это — each, выдает/йилдит (yields) значения. С учетом этого, вы можете объявить метод each на любом Ruby объекте и получить все преимущества более чем 50 методов для обработки и исполнения коллекций от модуля Enumerable. Просто добавьте include Enumerable внутрь объекта, который имеет валидный метод each, и вы можете полностью использовать все эти методы (имеется в виду методы модуля Enumerable).

Перечислители не ограничены простым коллекциями типа Array (массивы), они могут работать с любыми коллекциями, которые имеют в себе объявленный метод each (и обычно будет иметь модуль Enumerable в своих «прародителях/предках»).

Array.ancestors
# => [Array, Enumerable, Object, Kernel, BasicObject]
Hash.ancestors
# => [Hash, Enumerable, Object, Kernel, BasicObject]
Hash.method_defined? :each
# => true
require "set"
Set.ancestors
# => [Set, Enumerable, Object, Kernel, BasicObject]

«Ленивые» и не «ленивые» перечислители

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

Представьте линию сборки, где люди собирают пиццу, где каждый человек ответственен только за один шаг в готовке/трансформации пиццы. Первый человек бросает тесто правильной формы, следующий добавляет соус, следующий сыр, по человеку на каждую добавку (колбаски, перец, помидоры), еще один кладет все в печь, и последний человек доставляет готовую пиццу до Вас. В этом примере, «ленивая» версия сборки на Ruby — это иметь любое количество заказов на пиццу, но все оставшиеся будут ждать пока первая пицца пройдет через все стадии/шаги обработки, прежде чем продолжить делать следующую пиццу.

Если вы не используете «ленивый» перечислитель, тогда каждый шаг/стадия могла бы ждать пока на всей коллекции не отработает один шаг за единицу времени. Для примера, если у Вас есть 20 заказов на пиццу, человек, который бросает тесто должен будет сделать их 20 перед тем как к кому-то из них смогут добавить соус, следующим человеком на линии. И каждый шаг в очереди ожидает в похожей манере. Теперь, чем больше коллекцию Вам необходимо обработать, тем более смехотворным кажется заставлять ждать оставшихся сборочную линию.

Более жизненный пример: обработка списка писем, которую необходимо отправить всем пользователям. Если в коде ошибка и весь список обрабатывается не «лениво», тогда вполне вероятно, что никто не получит имейлы. В случае «ленивого» исполнения, потенциально, вы бы отправили письма большинству пользователей, перед тем как, скажем некорректный почтовый адрес вызвал бы проблему/ошибку. Если запись об отправке содержит статус успешности отправки, то проще отследить на какой записи (где) произошла ошибка.

Создание «ленивого» перечислителя в Ruby также просто как вызов lazy на объекте с включенным в него модулем Enumerable или вызов to_enum.lazy на объекте, с объявленным внутри него методом each.

class Thing
  def each
    yield "winning"
    yield "not winning"
  end
end

a = Thing.new.to_enum.lazy

Thing.include Enumerable
b = Thing.new.lazy

a.next
# => "winning"
b.next
# => "winning"

Вызов to_enum возвращает объект, который является как Enumerator так и Enumerable, и который будут иметь доступ ко всем их методам.

Важно обратить внимание, какие методы перечислителя будут «потреблять» всю коллекцию за раз, а какие будет «потреблять» (выполнять) её — «лениво». Для примера, метод partition потребляет всю коллекцию за раз, так что она не приемлема для бесконечных коллекций. Лучший выбор для ленивого выполнения, могли бы быть такие методы как chunk или select.

x = (0..Flot::INFINITY)

y = x.chunk(&:even?)
# => #<Enumerator::Lazy: #<Enumerator: #<Enumerator::Generator:0x0055eb840be350>:each>>
y.next
# => [true, [0]]
y.next
# => [false, [1]]
y.next
#=> [true, [2]]

z = x.lazy.select(&:even?)
# => #<Enumerator::Lazy: #<Enumerator::Lazy: 0..Infinity>:select>
z.next
# => 0
z.next
# => 2
z.next
# => 4

В случае использования select с бесконечным коллекциями, вы должны в начале вызывать метод lazy от предотвращения потребления всей коллекции целиком методом select, И принудительного завершения программы из-за бесконечного её выполнения.

Создание «ленивого» перечислителя

В Ruby есть класс Enumerator::Lazy, который позволяет писать Вам собственные методы перечислителя как take в Ruby.

(0..Float::INFINITY).take(2)
# => [0, 1]

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

def divisible_by?(num)
  ->input{ (input % num).zero? }
end

def fizzbuzz_from(value)
  Enumerator::Lazy.new(value..Float::INFINITY) do |yielder, val|
    yielder << case val
    when divisible_by?(15)
      "FizzBuzz"
    when divisible_by?(3)
      "Fizz"
    when divisible_by?(5)
      "Buzz"
    else
      val
    end
  end end

x = fizzbuzz_from(7)
# => #<Enumerator::Lazy: 7..Infinity:each>

9.times { puts x.next }
# 7
# 8
# Fizz
# Buzz
# 11
# Fizz
# 13
# 14
# FizzBuzz

С помощью Enumerator::Lazy, неважно, что вы подаете на yielder — будет значение возвращаемое на каждом шаге в последовательности. Перечислитель следит за текущим прогрессом, когда вы используете next. Но когда вы вызываете each после нескольких вызовов next, он начнет с самого начала коллекции.

Параметр, который вы передаете в Enumerator::Lazy.new — это коллекция через который мы пройдем перечислителем. Если вы написали этот метод для Enumerable или совместимого объекта, вы можете просто передать self как параметр. val будет единственным значением, производимым в единицу времени методом коллекции each, а yielder будет единственным точкой входа для любого блока кода, который вы хотите передать, как если бы это было с each.

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

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

require "prime"
x = (0..34).lazy.select(&Prime.method(:prime?))
x.next
# => 2
x.next
# => 3
x.next
# => 5
x.next
# => 7
x.next
# => 11

После метода select выше, вы можете добавить другие методы к обработке данных. Эти методы будут иметь дело только с ограниченным набором данных внутри простых чисел, а не со всеми простыми числами.

Группировка

Один прекрасный способ обработки данных для разбиения их на колонки — это использовать group_by, чтобы сконвертировать результат в ассоциативный массив групп. После этого, просто вытяните результаты, как если бы вы были заинтересованы во всех результатах.

[0,1,2,3,4,5,6,7,8].group_by.with_index {|_,index| index % 3 }.values
# => [[0, 3, 6], [1, 4, 7], [2, 5, 8]]

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

0    3    6
1    4    7
2    5    8

Вызов group_by выше передает и значение, и индекс внутрь блока. Мы используем нижнее подчеркивание для значения из массива, чтобы обозначить, что мы не заинтересованы в этом значении, а только в индексе. Что в результате мы получим, так это ассоциативный массив с ключами 0, 1 и 2 указывающими на каждую группу значений, которую мы сгруппировали. Так как мы не должны беспокоиться о ключах, мы вызываем values на этом ассоциативном массиве, чтобы получить массив массивов и далее отобразить как нам надо.

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

threes = (0..2).cycle
[0,1,2,3,4,5,6,7,8].slice_when { threes.next == 2 }.to_a
# => [[0, 1, 2], [3, 4, 5], [6, 7, 8]]

Перечислитель threes просто проходит бесконечно от 0 до 2, в «ленивой» манере. В итоге мы получим следующий вывод:

0    1    2
3    4    5
6    7    8

В Ruby также есть метод transpose, который переворачивает результаты выше от одного вида к другому.

x = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
x = x.transpose
# => [[0, 3, 6], [1, 4, 7], [2, 5, 8]]
x = x.transpose
# => [[0, 1, 2], [3, 4, 5], [6, 7, 8]]

«Сворачивание»

Давайте посмотрим на способы компоновки коллекций в результат. В других языках, это обычно делается через метод fold. В Ruby это долго делалось при помощи reduce и inject. Более свежее решение, и предпочтительный способ делать это с помощью each_with_object. Основная идея — это обработка одной коллекции в другую, выступающей как результат.

Суммирование целых чисел также просто как:

[1,2,3].reduce(:+)
# => 6

[1,2,3].inject(:+)
# => 6

class AddStore
  def add(num)
    @value = @value.to_i + num
  end

  def inspect
    @value
  end
end

[1,2,3].each_with_object(AddStore.new) {|val, memo| memo.add(val) }
# => 6

# As of Ruby 2.4
[1,2,3].sum
# => 6

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

Эти методы могут быть лучше продемонстрированы в работе, если брать данные из одной коллекции и класть их в другую коллекцию. Заметьте, что inject и reduce это одни и те же псевдонимы методов в Ruby и должны возвращать такое значение в конце блока, для того чтобы бы на основе него продолжать строить перечислитель.

each_with_object не нуждается чтобы бы последний кусок блока возвращал элемент, на котором он будет дальше строить перечислитель.

collection = [:a, 2, :p, :p, 6, 7, :l, :e]

collection.reduce("") { |memo, value|
  memo << value.to_s if value.is_a? Symbol
  memo # Note the return value needs to be the object/collection we're building
}
# => "apple"

collection.each_with_object("") { |value, memo|
  memo << value.to_s if value.is_a? Symbol
}
# => "apple"

Структуры

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

class Pair < Struct.new(:first, :second)
  def same?;    inject(:eql?)  end
  def add;      inject(:+)     end
  def subtract; inject(:-)     end
  def multiply; inject(:*)     end
  def divide;   inject(:/)     end

  def swap!
    members.zip(entries.reverse) {|a,b| self[a] = b}
  end

end

x = Pair.new(23, 42)
x.same?
# => false

x.first
# => 23

x.swap!

x.first
# => 42

x.multiply
# => 966

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

Заросли данных — это когда две или более переменных всегда используются в группе, но при этом они бы не имели никакого смысла если бы использовались по отдельности. Эта группа переменных должна быть сгруппирована в объект/класс.

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

Подытожим

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

Если производительность важна, тогда измерьте производительность отдельных реализаций и убедитесь, что установили фильтры и ограничения/лимиты в процесс обработки, как раннее, если конечно на то, есть возможность. Рассмотрите ограничение ваших входных данных в маленькие куски, используя метод readline на файлах, нежели read или readlines или используйте LIMIT number в SQL.

«Ленивая» итерация может оказать огромную помощь в разделение задач на различные потоки или фоновые задания обработки. Концепция «ленивых» итераций на самом деле не имеет недостатков, так что вы можете выбирать их для потребления любой коллекции в любом месте. Она предлагает гибкость, и некоторые языки, как Rust с итераторами, взяли за стандарт быть «лениво» реализованными (implemented lazily).

Возможности бесконечны, когда мы сталкиваемся с тем как манипулировать и управлять данными. И это забавный процесс, изучить и создать каждый способ манипулирования наборами данных в программировании. Ruby имеет хорошо документированные примеры для каждого из своих enumerable методов, так что он помогает учиться на основе них. Я поддерживаю Вас экспериментировать и открывать многие новые вещи, которые помогут сделать процесс программирования более приятным и приносящим удовольствие.

Автор: houk

Источник

Поделиться

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