Этот пост является выдержкой из Книги Рубиновых Закрытий , написанной самим Бенджамином. Если вам понравился этот пост (и он вам понравится), расскажите нам, что вам понравилось в конце, чтобы участвовать в розыгрыше для бесплатной копии! Теперь … читайте дальше …
Я всегда был очарован ленивым перечислением 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
- Что это за объект
yielder
который передается в блок? - Что делает
yielder do?
- Наиболее важно, как возможно, что простое обертывание
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]
, во внешний. Это секретный соус, который позволяет получать элементы по одному.
Вот как работают генераторы:
- Во-первых, он вычисляет некоторый результат
- Этот результат передается вызывающей стороне
- Кроме того, он также сохраняет состояние вычислений, чтобы вызывающий мог возобновить это вычисление для генерации следующего результата.
Одним из способов сделать это является использование малоизвестной конструкции 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
стало для меня моментом «АГА!».