/ruby-iterators

Итераторы в Ruby

Primary LanguageRuby

Итераторы в Ruby

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

1. Циклы Ruby

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

Например, для перебора элементов массива [1, 2, 3, 4, 5] можно воспользоваться циклом for:

for x in [1, 2, 3, 4, 5] do
  puts x
end
1
2
3
4
5

Цикл пробегает значения от начала массива до последнего элемента, помещая на каждой итерации текущее значение из массива в переменную x. Когда значения в цикле заканчиваются, цикл завершает работу.

Еще один оператор для выполнения циклов — это while. Например, следующий код выводит значения от 1 до 5:

i = 1
while i <= 5 do
  puts i
  i += 1
end
1
2
3
4
5

Цикл начинается с ключевого слова while, после которого размещено условие. Выражение с условием возвращает либо true (истина), либо false (ложь). Пока условие возвращает true, цикл выполняет выражения между ключевыми словами do и end. Как только условие возвращает false, а это происходит когда переменная i получает значение 6, он прекращает работу.

Кроме оператора while существует оператор until, который противоположен while, так как выполняет блок до тех пор, пока условие ложно:

i = 1
until i > 5 do
  puts i
  i += 1
end
1
2
3
4
5

2. Почему Ruby-исты не используют циклы?

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

Рассмотрим типичный итератор each:

[1, 2, 3, 4, 5].each do |i|
  puts i
end
1
2
3
4
5

Метод each является именно методом объекта [1, 2, 3, 4, 5], а не специальной конструкцией языка, чуть позже мы попробуем писать свои собственные методы-итераторы.

Конструкция между do и end называется блоком, и в отличие от одноименных конструкций в циклах имеет собственную область видимости, не может быть сокращена за счет удаление do (как в случае циклов), однако, может быть преобразована в краткую форму за счет использования фигурных скобок:

[1, 2, 3, 4, 5].each { |i| puts i }
1
2
3
4
5

К фигурным скобках обычно прибегают, когда блок состоит из одного выражения, в случае нескольких выражений используют полную форму блока, с использованием ключевых слов do и end.

Итератор each применим, не только для массивов, но и для хэшей:

{a: 'b', c: 'd'}.each { |key, value| puts "#{key}: #{value}" }
a: b
c: d

Здесь блок принимает вместо одного, два параметра, key под ключ (:a, :c), value — под значения ('b', 'd').

Итераторы необязательно обслуживают коллекции, например, итератор times применяется к числам позволяет выполнить цикл указанное количество раз:

5.times { |i| puts i }
0
1
2
3
4

Причем для вещественного числа метод times уже не сработает:

(5.0).times { |i| puts i }
NoMethodError: undefined method `times' for 5.0:Float

Для итерирования от одного числа к другому можно воспользоваться методом upto:

5.upto(10) { |i| puts i }
5
6
7
8
9
10

Метод downto позволяет наоборот пробегать числа с шагом минус один:

10.downto(5) { |i| puts i }
10
9
8
7
6
5

Главное знать, какой итератор можно применять с текущим объектом.

3. Как определить какой из итераторов можно использовать?

Как видно из предыдущего раздела итераторы можно применять не ко всем объектам. При поиске подходящего итератора в документации следует помнить, что Ruby является полностью объектно-ориентированным языком. Это упрощает работу с документацией. Так как любая практически любая конструкция языка, за исключением небольшого количества ключевых слов (тех операторов цикла for, while, until), является либо объектом, либо методом объекта.

Например даже обычное сложение

5 + 2
7

следует рассматривать как объектно-ориентированную операцию, заключающуюся в вызове метода с именем + в отношении объекта 5, с передачей методу аргумента 2

5.+(2)
7

Разумеется на практике отдается предпочтение более привычной арифметической форме записи 5 + 2, хотя в случае Ruby вторая форма записи более каноническая и более точно отражает, что стоит за реальным вызовом.

Почти каждый объект, с которым приходится иметь дело в Ruby, имеет метод methods, который возвращает список методов объекта. Например:

5.methods
[:%, :&, :*, :+, :-, :/, :<, :>, :^, :|, :~, :-@, :**, :<=>, :<<, :>>, :<=, :>=, :==, :===, :[],
 :inspect, :size, :succ, :to_s, :to_f, :div, :divmod, :fdiv, :modulo, :abs, :magnitude, :zero?,
 :odd?, :even?, :bit_length, :to_int, :to_i, :next, :upto, :chr, :ord, :integer?, :floor, :ceil,
 :round, :truncate, :downto, :times, :pred, :to_r, :numerator, :denominator, :rationalize, :gcd,
 :lcm, :gcdlcm, :+@, :eql?, :singleton_method_added, :coerce, :i, :remainder, :real?, :nonzero?,
 :step, :positive?, :negative?, :quo, :arg, :rectangular, :rect, :polar, :real, :imaginary, :imag,
 :abs2, :angle, :phase, :conjugate, :conj, :to_c, :between?, :iterator, :instance_of?, :public_send,
 :instance_variable_get, :instance_variable_set, :instance_variable_defined?,
 :remove_instance_variable, :private_methods, :kind_of?, :instance_variables, :tap, :is_a?, :extend,
 :define_singleton_method, :to_enum, :enum_for, :=~, :!~, :respond_to?, :freeze, :display, :send,
 :object_id, :method, :public_method, :singleton_method, :nil?, :hash, :class, :singleton_class,
 :clone, :dup, :itself, :taint, :tainted?, :untaint, :untrust, :trust, :untrusted?, :methods,
 :protected_methods, :frozen?, :public_methods, :singleton_methods, :!, :!=, :__send__, :equal?,
 :instance_eval, :instance_exec, :__id__]

Полученный массив можно сортировать

5.methods.sort
[:!, :!=, :!~, :%, :&, :*, :**, :+, :+@, :-, :-@, :/, :<, :<<, :<=, :<=>, :==, :===, :=~, :>, :>=,
 :>>, :[], :^, :__id__, :__send__, :abs, :abs2, :angle, :arg, :between?, :bit_length, :ceil, :chr,
 :class, :clone, :coerce, :conj, :conjugate, :define_singleton_method, :denominator, :display, :div,
 :divmod, :downto, :dup, :enum_for, :eql?, :equal?, :even?, :extend, :fdiv, :floor, :freeze, :frozen?,
 :gcd, :gcdlcm, :hash, :i, :imag, :imaginary, :inspect, :instance_eval, :instance_exec, :instance_of?,
 :instance_variable_defined?, :instance_variable_get, :instance_variable_set, :instance_variables,
 :integer?, :is_a?, :iterator, :itself, :kind_of?, :lcm, :magnitude, :method, :methods, :modulo,
 :negative?, :next, :nil?, :nonzero?, :numerator, :object_id, :odd?, :ord, :phase, :polar, :positive?,
 :pred, :private_methods, :protected_methods, :public_method, :public_methods, :public_send, :quo,
 :rationalize, :real, :real?, :rect, :rectangular, :remainder, :remove_instance_variable, :respond_to?,
 :round, :send, :singleton_class, :singleton_method, :singleton_method_added, :singleton_methods,
 :size, :step, :succ, :taint, :tainted?, :tap, :times, :to_c, :to_enum, :to_f, :to_i, :to_int, :to_r,
 :to_s, :truncate, :trust, :untaint, :untrust, :untrusted?, :upto, :zero?, :|, :~]

Или отфильтровать при помощи метода grep

5.methods.grep :downto
[:downto]

Метод grep допускает использование регулярных выражений, например, следующий вызов вернет список методов, начинающихся с символа d

5.methods.grep /^d.*/
[:div, :divmod, :downto, :denominator, :define_singleton_method, :display, :dup]

Кроме того, всегда можно запросить у текущего объекта его класс при помощи одноименного метода class:

5.class
Fixnum

Получив имя класса, в документации всегда можно уточнить список его методов.

4. Итераторы коллекций

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

Среди итераторов выделяют несколько основополагающих, знать которые должен любой Ruby-разработчик:

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

[1, 2, 3, 4, 5].each { |x| x + 1 }
[1, 2, 3, 4, 5]

[1, 2, 3, 4, 5].map { |x| x + 1 }
[2, 3, 4, 5, 6]

или вообще так

[1, 2, 3, 4, 5].map { |x| 1 }
[1, 1, 1, 1, 1]

У map есть синоним collect, однако рубисты в массе своей предпочитают более короткий в написании map. Это вообще общее правило, чем короче конструкция в написании тем она более популярна.

Таким образом map возвращает массив с тем же количеством элементов, что и исходный, но в качестве элементов которого выступает содержимое блоков.

Для того, чтобы отфильтровать содержимое массива используется пара итераторов select и reject. Блоки в этих итераторах должны возвращать true или false, select — возвращает те элементы коллекции, для которых блок возвращает true, а rejectfalse. Давай посмотрим это на примере уже рассмотренного выше примера, отбирая четные номера.

[1, 2, 3, 4, 5].select { |x| x.even? }
[2, 4]

Чтобы было понятнее, давайте, заменим select на map:

[1, 2, 3, 4, 5].map { |x| x.even? }
[false, true, false, true, false]

Т.е. select отбирает второй и четвертый элемент коллекции. Итератор reject, наоборот, выбирает false:

[1, 2, 3, 4, 5].reject { |x| x.even? }
[1, 3, 5]

Когда в блоке вызывается единственный метод, как в примере выше, можно воспользоваться сокращенной формой:

[1, 2, 3, 4, 5].reject(&:even?)
[1, 3, 5]

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

Итераторы можно комбинировать друг с другом, например, если требуется извлечь квадраты нечетных чисел, можно воспользоваться следующей комбинацией методов map и select:

[1, 2, 3, 4, 5].reject { |x| x.even? }.map { |x| x * x}
[1, 9, 25]

Еще один итератор, который обязательно нужно рассмотреть — это reduce, суть его это накопление результатов, по мере обхода коллекции. Например, перемножив элементы коллекции друг на друга можно получить факториал:

[1, 2, 3, 4, 5].reduce { |fact, x| fact * x }
120

Можем перепроверить, перемножив элементы коллекции без итератора

1 * 2 * 3 * 4 * 5
120

Итератор reduce, на первый взгляд может показаться магическим: непонятно как инициализируется переменная fact и как вообще она ведет себя в итераторе. Дело в том, что в такой форме переменная-аккумулятор fact инициализируется первым элементом коллекции. На каждой новой итерации, переменной присваивается результат вычисления блока. В качестве результата итератор возвращает значение переменной fact. Можно и явно инициировать переменную-аккумулятор, если передать итератору reduce аргумент:

[1, 2, 3, 4, 5].reduce(2) { |fact, x| fact * x }
240

В случае inject тоже есть сокращенные варианты и их тоже стоит иметь в виду, когда вы читаете код Ruby-проектов:

[1, 2, 3, 4, 5].reduce(:*)

Итератор tap предназначен для выполнения побочного действия в цепочке других итераторов. Для того чтобы узнать промежуточное значение, которое возвращает итератор reject в следующем примере, можно воспользоваться итератором tap:

[1, 2, 3, 4, 5].reject { |x| x.even? }.tap{ |x| p x }.map { |x| x * x}
[1, 3, 5]
=> [1, 9, 25]

5. Антипатерны

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

arr = []
[1, 2, 3, 4, 5].each do |x|
  arr << x * x
end
p arr
[1, 4, 9, 16, 25]

В случае когда пустой массив, заполняется внутри какого либо итератора, как правило, есть более подходящий итератор или комбинация итераторов, позволяющих сократить код, при помощи итератора map, select или reject:

arr = [1, 2, 3, 4, 5].map { |x| x * x }
p arr
[1, 4, 9, 16, 25]

Если внутри метода изменению подвергается хэш и в конце метода возникает явно вернуть переменную хэша

def change_hast(params)
  params[:page] = 1
  params
end

Как правило можно сократить код, воспользовавшись итератором tap, который всегда возвращает значение своего объекта

def change_hast(params)
  params.tap{ |p| p[:page] = 1 }
end

6. Итераторы изнутри: оператор yield и модуль Enumerable

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

def iterator
  yield 'hello'
  yield 'world'
end

В методе iterator оператор yield вызывается два раза. Это приводит к тому, что во-первых метод не работает без блока:

iterator
LocalJumpError: no block given (yield)

Во-вторых, если блок методу передается, он выполняется ровно два раза, по количеству вызовов оператора yield:

iterator { |word| puts word }
hello
world

Оператор yield может принимать переменное количество аргументов.

def iterator2
  yield 'hello', 'world'
end

Сколько аргументов передано yield, такое количество параметров должно быть в вызове блока:

iterator2 { |hello, word| puts “#{hello} #{word}” }
hello world

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

class Coll
  def initialize(coll = [])
   @arr = coll
  end

  def each
    for x in @arr do
      yield x
    end
  end
end

Обычно в языках программирования для описания свойств и поведения переменных используется тип. Класс выполняет схожую функцию, предоставляя код описания объектов. Метод initialize является конструктором и вызывается до всех других методов объекта, во время создания объекта методом new.

Единственная задача конструктора из примера, инициировать переменную объекта или как говорят инстанс-переменную @arr. Такие переменные — это переменные на уровне объекта, фактически это более короткий вариант self.arr. Сейчас не будем подробно останавливаться на объектно-ориентированной модели Ruby, так как язык полностью объектно-ориентированный и его объектно-ориентированные возможности мягко говоря богаты.

Помимо конструктора в классе определен метод each, который ведет себя как итератор, т.е. последовательно пробегает от первого элемента массива до последнего, передавая управление во внешний блок при помощи оператора yield. Рассмотрим как работает класс Coll, для этого создадим его объект.

obj = Coll.new([1, 2, 3, 4, 5])

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

obj.each { |x| puts x + 1 }
2
3
4
5
6

Воспользоваться итератором map уже не получится, так как он просто не объявлен в классе Coll.

obj.map { |x| x + 1 }
NoMethodError: undefined method `map' for #<Coll:0x007feaf383b1a8 @arr=[1, 2, 3, 4, 5]>

По идее нам нужно объявлять каждый итератор, который может потребоваться в дальнейшей работе. Если мы сейчас попробуем обратиться к map, то не сможем обнаружить его. К счастью, стандартная библиотека Ruby облегчает нам работу, достаточно реализовать один единственный метод each и подключить к классу модуль Enumerable и все базовые итераторы, map, select, reject и т.д. будут построены из метода each автоматически.

class Coll
  include Enumerable

  def initialize(coll = [])
     @arr = coll
  end

  def each
      for x in @arr do
      yield x
      end
  end
end
obj = Coll.new([1, 2, 3, 4, 5])
obj.map { |x| x + 1 }
[2, 3, 4, 5, 6]

Когда мы говорим о создании собственных итераторов, следует упомянуть метод block_given?, который проверяет передан ли текущему методу блок или нет. Это позволяет вместо завершении метода выбросом исключения совершить какое-то разумное действие. Например, вместо передачи управления в блок, просто вернуть коллекцию в виде массива. Вот здесь представлен метод block, который при помощи метода block_given? и оператора if позволяет обработать ситуацию передачи блока.

def block(arr = [])
  if block_given?
    arr.each { |x| yield x }
  else
    arr
  end
end

7. Реальный пример обход дерева каталога

Рассмотрим более сложный пример, пусть у нас имеется дерево с директориями и названиями файлов. Нам требуется рекурсивно обойти дерево, отобрав только файлы. Чтобы было интереснее пусть у нас сложилась ситуация, когда мы не можем воспользоваться стандартными средствами, классами Dir или Find и вообще дерево сформировано в виде массива, в котором элементы могут быть либо строками String, либо хэшами в которых ключи названия папок, а массивы — список файлов в папке или опять же подкаталоги.

tree = [
  'index.rb',
  {
    'src' => [
      'file01.rb',
      'file02.rb',
      'file03.rb'
    ]
  },
  {
    'doc' => [
      'file01.md',
      'file02.md',
      'file03.md',
      {
        'details' => [
          'index.md',
          'arch.md'
        ]
      }
    ]
  }
]

Обход дерева при помощи итератора each не очень интересен, так как он затрагивает только самый верхний уровень массива.

tree.each { |x| puts x }
index.rb
{"src"=>["file01.rb", "file02.rb", "file03.rb"]}
{"doc"=>["file01.md", "file02.md", "file03.md", {"details"=>["index.md", "arch.md"]}]}

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

tree.each do |x|
  puts 'Dir' if x.is_a? Hash
  puts 'File' if x.is_a? String
end

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

Сложность заключается в том, что при вызове оператора yield управление передается лишь непосредственно в точку вызова.

def walk(arr = [], &proc)
  arr.each do |el|
    proc.call(el) if el.is_a? String
    el.each { |_dir, files| walk(files, &proc) } if el.is_a? Hash
  end
end

Т.е. во второй строке each-блока, там где итератор walk вызывается для очередного списка файлов files необходимо вызывать блок. Для того, чтобы этого не делать, а передать блок наружу, пригодятся альтернативный синтаксис передачи блоков. Для этого последний параметр метода walk предваряется амперсандом &proc, в результате внутри функции блок становится именованным Proc-объект.

Во-первых его можно передать в другие методы или вглубь рекурсивного вызова, как в случае walk-итератора.

Во-вторых вместо оператора yield можно использовать proc-объект, вызвав у него метод call, т.е.

def walk(arr = [], &proc)
  arr.each do |el|
    proc.call(el) if el.is_a? String
    el.each { |_dir, files| walk(files, &proc) } if el.is_a? Hash
  end
end

Теперь можно разобрать дерево

walk(tree) { |file| puts file }
index.rb
file01.rb
file02.rb
file03.rb
file01.md
file02.md
file03.md
index.md
arch.md