Статьи

Реализация отложенных перечислимых в Ruby

Этот пост является выдержкой из Книги Рубиновых Закрытий , написанной самим Бенджамином. Если вам понравился этот пост (и он вам понравится), расскажите нам, что вам понравилось в конце, чтобы участвовать в розыгрыше для бесплатной копии! Теперь … читайте дальше …

rubyclosures

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

Что именно лениво ? Руби пытается расслабиться? Быть ленивым относится к стилю оценки . Чтобы позволить этому впитаться, рассмотрите противоположность ленивой оценки: нетерпеливая оценка.

На самом деле не о чем говорить о стремлении к оценке, так как это стандартный способ работы Ruby. Но иногда стремление — это плохо. Например, что вы думаете, это оценивает:

irb> 1.upto(Float::INFINITY).map { |x| x * x }.take(10) 

Вы можете ожидать, что результат будет [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] . К сожалению, интерпретатор Ruby не знает, когда остановиться. Проблема здесь 1.upto(Float::INFINITY) . 1.upto(Float::INFINITY) представляет бесконечную последовательность. Как это выглядит в консоли?

 irb> 1.upto(Float::INFINITY) => #<Enumerator: 1:upto(Infinity)> 

Неудивительно, что счетчик возвращается. Давайте попробуем вытолкнуть значения из перечислителя, используя Enumerator#to_a :

 irb> 1.upto(5).to_a => [1, 2, 3, 4, 5] 

Бесконечность не предел!:

 irb> 1.upto(Float::INFINITY).to_a 

Теперь вы не должны удивляться, что это приведет к бесконечному циклу. Обратите внимание, что Enumerator#to_a полезен для « Enumerator#to_a » значений из перечислителя. Фактически, Enumerator#to_a имеет псевдоним Enumerator#force . Это полезно, как вы увидите позже, когда вы хотите знать все значения, созданные перечислителем.

Приводя ленивых!

Как мы можем получить значения из бесконечного списка тогда? Представляем Enumerable#lazy :

 irb> 1.upto(Float::INFINITY).lazy.map { |x| x * x } => #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator: 1:upto(Infinity)>>:map> 

Похоже, что перечислитель 1.upto(Float::INFINITY) стал ленивым, обернув его с помощью Enumerator::Lazy . Этот ленивый перечислитель сам оборачивает ленивую версию карты. Давайте попробуем самое первое выражение еще раз, но на этот раз с Enumerable#lazy :

 irb> 1.upto(Float::INFINITY).lazy.map { |x| x * x }.take(10) => #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator: 1:upto(Infinity)>>:map>:take(10)> 

Ах, Enumerable#take также взят! Как мы получаем все значения? Enumerable#to_a на помощь:

 irb> 1.upto(Float::INFINITY).lazy.map { |x| x * x }.take(10).to_a => [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] 

Как работает эта магия? Мы собираемся выяснить. Мы реализуем нашу собственную версию ленивых перечислимых, хотя и минимальных версий. По пути вы также узнаете интересные аспекты перечислителей Ruby.

Строительство скелета

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

Итак, Enumerator является родителем Lazy . В коде:

 class Lazy < Enumerator end 

Вместо того, чтобы снова открывать классы Ruby (я получаю непроизвольные подергивания), для нашего небольшого упражнения мы собираемся придумать другое имя. Быстрое путешествие к тезаурусу приводит к чему-то похожему на Lazy . Представляем, Lax !

 class Lax < Enumerator end 

Внешняя VS Внутренняя Итерация

Что нас наследует от Enumerator ? Чтобы ответить на этот вопрос, нам нужно освежить в памяти, что такое Enumerator. Из документов:

Класс, который допускает как внутреннюю, так и внешнюю итерацию.

В чем разница между двумя разновидностями итерации? Ключ к тому, кто контролирует итерацию.

Для внутренней итерации, это объект Array (или любой Enumerable ), который управляет итерацией. Фактически, именно так вы обычно взаимодействуете с Enumerable s.

Внешняя итерация, с другой стороны, управляется другим объектом, обернутым вокруг Enumerable .

Зачем вам в первую очередь внешние итераторы? Ну, иногда вы не хотите перебирать все элементы за один раз. Вы хотите, чтобы можно было сказать перечисляемому: «Дайте мне ровно одну сейчас, а когда мне понадобится следующая, я спрошу снова». Внешние итераторы дают вам такую ​​возможность.

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

Я упоминал, что Enumerator оборачивает Enumerable . Мы можем видеть это в коде:

 irb> e = Enumerator.new([1,2,3]) irb> e.next => 1 irb> e.next => 2 irb> e.next => 3 irb> e.next StopIteration: iteration reached an end from (irb):7:in `next' 

Когда мы оборачиваем массив с помощью перечислителя, мы можем несколько раз вызвать Enumerator#next для получения следующего значения. Когда больше не осталось значений, StopIteration исключение StopIteration .

Обратите внимание, что Ruby жалуется на использование Object#to_enum или использование его с блоком. Давайте использовать блок, потому что это более интересно:

 e = Enumerator.new do |yielder| [1,2,3].each do |val| yielder << val end end 

Давайте посмотрим код в действии в irb :

 irb(main):008:0> e = Enumerator.new do |yielder| irb(main):009:1* [1,2,3].each do |val| irb(main):010:2* yielder << val irb(main):011:2> end irb(main):012:1> end => #<Enumerator: #<Enumerator::Generator:0x007fb9798e0668>:each> 

Как обычно, мы можем вызвать Enumerator#next :

 irb(main):013:0> e.next => 1 irb(main):014:0> e.next => 2 irb(main):015:0> e.next => 3 irb(main):016:0> e.next StopIteration: iteration reached an end from (irb):16:in `next' from (irb):16 from /Users/benjamintan/.rbenv/versions/2.2.0/bin/irb:11:in `<main>' 

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

 e = Enumerator.new do |yielder| [1,2,3].each do |val| yielder << val end end 
  1. Что это за объект yielder который передается в блок?
  2. Что делает yielder do?
  3. Наиболее важно, как возможно, что простое обертывание Enumerable позволяет извлекать элементы один за другим?

Мы можем ответить на первые два вопроса. Объект yielder передается в блок при создании объекта Enumerator с использованием блока.

Цель объекта урожайности состоит в том, чтобы сохранить инструкции для следующего урожая . Не ценность, а инструкция . Это то, что yielder does. The yielder does. The is aliased to the метода yield , но он не имеет ничего общего с ключевым словом yield . Да, это сбивает с толку.

Когда вызывается Enumerator#next , он возвращает следующее значение и возвращает. Это говорит о том, что yielder объект должен помнить некоторую форму состояния. Как это достигается? Возвращаемое значение из предыдущего листинга кода дает нам подсказку:

 #<Enumerator: #<Enumerator::Generator:0x007fb9798e0668>:each> 

Это говорит нам о том, что объект Enumerator содержит другой объект с именем Enumerator ::Generator .

Генераторы, Волокна, О Боже!

Давайте сделаем обход и исследуем Enumerator::Generator . Генераторы могут конвертировать внутренний итератор, такой как [1,2,3] , во внешний. Это секретный соус, который позволяет получать элементы по одному.

Вот как работают генераторы:

  1. Во-первых, он вычисляет некоторый результат
  2. Этот результат передается вызывающей стороне
  3. Кроме того, он также сохраняет состояние вычислений, чтобы вызывающий мог возобновить это вычисление для генерации следующего результата.

Одним из способов сделать это является использование малоизвестной конструкции Ruby, называемой волокном . Класс Fiber идеально подходит для преобразования внутреннего итератора во внешний.

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

Волокно создается с помощью Fiber.new вместе с блоком, который представляет вычисление. Давайте посмотрим на этот пример:

 f = Fiber.new do x = 0 loop do Fiber.yield x x += 1 end end 

Мы создаем волокно и даем ему блок вычислений. Этот блок содержит цикл, который не заканчивается. Что это за Fiber.yield ? Это секретный соус! Мы вернемся к этому через секунду.

Когда вы создаете волокно, как в приведенном выше примере, блок не выполняется сразу. Итак, как вы выполняете блок? С Fiber#resume . Заметим:

 irb> f = Fiber.new do irb* x = 0 irb> loop do irb* Fiber.yield x irb> x += 1 irb> end irb> end => #<Fiber:0x007fb979023e58> irb> f.resume => 0 irb> f.resume => 1 irb> f.resume => 2 

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

Код выполняет Fiber.yield x при Fiber.yield x Fiber#resume , значение возвращается вызывающей стороне, а управление возвращается обратно вызывающей стороне. До тех пор, пока в следующий раз не будет Fiber#resume , затем x увеличивается, цикл переходит к другому раунду, снова выполняет Fiber.yield x и возвращает управление вызывающей стороне.

Реализация Lax , наш собственный Enumerator::Lazy

Теперь, когда мы понимаем, как работают генераторы, давайте вернемся к реализации Enumerator::Lax . Нам не нужно напрямую использовать Enumerator::Generator , так как об этом позаботится объект yielder .

Давайте подумаем о том, как клиент будет использовать наш код. Фактически, мы постараемся максимально имитировать реальную реализацию. Вот пример:

 1.upto(Float::INFINITY).lax .map { |x| x*x } .map { |x| x+1 } .take(5) .to_a 

Это должно нас достать [2, 5, 10, 17, 26] .

Здесь начинается ловкость рук. Когда вызывается lax , возвращается экземпляр перечислителя Lax . Когда вызывается map , этот метод вызывается для экземпляра Lax , а не для map определенной в перечислимом.

Чтобы включить что-то вроде 1.upto(Float::INFINITY).lax и [1,2,3].lax и вернуть новый экземпляр Lax , мы должны добавить новый метод в модуль Enumerable :

 module Enumerable def lax Lax.new(self) end end class Lax < Enumerator end 

self — фактическое перечисляемое. Он передается в качестве аргумента конструктору Lax . Если вы запустите код сейчас, вы не получите никаких ошибок, но вы все равно получите бесконечный цикл. Это потому, что все, что мы сделали, это просто обеспечили дополнительный уровень косвенности, не делая ничего интересного.

Мы знаем, что нам нужно заполнить yielder , поэтому давайте сделаем это:

 class Lax < Enumerator def initialize(receiver) super() do |yielder| receiver.each do |val| yielder << val end end end end 

Даже с этим крошечным фрагментом кода мы уже можем перебирать бесконечную коллекцию, одну за другой:

 irb> e = 1.upto(Float::INFINITY).lax => #<Lax: #<Enumerator::Generator:0x007f8324155c30>:each> irb> e.next => 1 irb> e.next => 2 irb> e.next => 3 

Реализация Ленивая map

Теперь нам нужно реализовать цепочку методов, например, таких как map и take . Поскольку Enumerable::Lax возвращает экземпляр Lax , это означает, что нам нужно определить Lax версии map и take . Каждый вызов map и take по очереди возвращают еще один экземпляр Lax .

Как будет выглядеть метод карты в Lax ? Для начинающих:

 def map(&block) end 

Мы также знаем, что нам нужно вернуть новый экземпляр Lax . На этот раз self будет другим экземпляром Lax .

 def map(&block) Lax.new(self) end 

То, что мы действительно хотим, это заполнить объект урожай с элементами, которые модифицируются методом map . Это означает, что мы как-то должны передать две вещи в экземпляр Lax на map : объект- yielder и элемент для отображения. Мы можем передать их через аргумент блока следующим образом:

 def map(&block) Lax.new(self) do |yielder, val| yielder << block.call(val) end end 

Что это делает? Похоже, что блок map вызывается с помощью val , и этот элемент затем передается в урожай. Это не совсем точно, хотя. Это больше похоже на инструкции о том, как обрабатывать сопоставленное значение, хранящееся в получателе.

Поскольку Lax может получить блок, нам нужно изменить конструктор для обработки этого случая:

 class Lax < Enumerator def initialize(receiver) super() do |yielder| receiver.each do |val| if block_given? yield(yielder, val) else yielder << val end end end end end 

Когда предоставляется блок, как в случае с версией map Lax , мы получаем блок, передавая объект урожайности и элемент val .

Это работает? Давай выясним!

 p 1.upto(Float::INFINITY).lax .map { |x| x*x } .map { |x| x+1 } .first(5) 

Enumerable#first(5) возвращает первые 5 элементов перечислимого, что дает вам [2, 5, 10, 17, 26] .

Реализация Ленивый take

Теперь, когда мы увидели, как реализован map , давайте попробуем реализовать take . Как следует из его названия, Enumerable#take(n) возвращает первые n элементов из Enum. Ленивая версия вернет экземпляр Lax завернутый в Enumerable#take .

 class Lax < Enumerator # initialize and map goes here def take(n) taken = 0 Lax.new(self) do |yielder, val| if taken < n yielder << val taken += 1 else raise StopIteration end end end end 

Логика для take должна быть достаточно легкой для понимания. Здесь интересно то, как take сигналы об окончании итерации — она ​​вызывает исключение StopIteration .

Не сердитесь, потому что именно так это реализует Ruby. Мы можем обработать это исключение внутри конструктора и просто ничего не делать, когда обнаруживаем исключение StopIteration :

 class Lax < Enumerator def initialize(receiver) super() do |yielder| begin receiver.each do |val| if block_given? yield(yielder, val) else yielder << val end end rescue StopIteration end end end # map and take goes here ... end 

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

 module Enumerable def lax Lax.new(self) end end class Lax < Enumerator def initialize(receiver) super() do |yielder| begin receiver.each do |val| if block_given? yield(yielder, val) else yielder << val end end rescue StopIteration end end end def map(&block) Lax.new(self) do |yielder, val| yielder << block.call(val) end end def take(n) taken = 0 Lax.new(self) do |yielder, val| if taken < n yielder << val taken += 1 else raise StopIteration end end end end 

С этим, давайте попробуем код:

 p 1.upto(Float::INFINITY).lax .map { |x| x*x } .map { |x| x+1 } .take(10) .to_a 

Если вы получили [2, 5, 10, 17, 26] , то это сработало!

Резюме

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

До написания этой главы я никогда не сталкивался с Enumerator::Yielder , Enumerator::Generator и Fiber . К счастью, реализация Enumerable в Rubinius очень помогла. Узнав, как поведение Fiber.yield стало для меня моментом «АГА!».