與你每天擦身而過的 Enumerator

在 Ruby/Rails 開發日常中總少不了對集合物件(例如 hash 與 array)做 iteration(迭代)操作,常見的包含 select, count, filter …等。例如當我們想從一個 array 中有條件式選取一些元素,可能會這樣寫:

arr = [1, 2, 3]

arr.map do |num|
  num * 2
end

#=> [2, 4, 6]

Ruby 的常態是使用 Internal Iterator 對每個元素做指定的處理。既然有 internal 不難猜想也會有 external,而這也是這次的主題: External Iteration 與 Enumerator

External Iteration

Internal 跟 External Iterator 的差異在於 Internal 由集合物件(常見的是 Array 或 Hash)控制,我們沒有掌控權;而 External 由外部物件所控制,意即,要怎麼 iterate 由你自己決定。

arr = [1, 2].to_enum
#=> #<Enumerator: [1, 2]:each>

arr.next
#=> 1

arr.next
#=> 2

arr.next
#=> StopIteration (iteration reached an end)

上面的例子一開始對 enumerable 物件呼叫 to_enum 後得到的是 Enumerator 物件(更多實例化 Enumerator 的方式後續會提到),接下來就可以使用 next 方法告訴這個 iterator 我要拿下一個元素,直到超出範圍時噴出 StopIteration

以下是一個實際應用的範例。假如在製作 rails 列表時,想要在每列 blog.title 前循環印出 icon-upicon-down icon、且不理會 @blogs 數量的增減,做法大概可以是這樣。icons 被指定使用 cycle 方式 iterate,所以當 next 到最後一個元素,下一次的 next 就會從頭再來過。

<% icons = %w[up down].to_enum(:cycle) %>

<table>
  <% @blogs.each do |blog| %>
    <tr>
      <td>
        <span class="icon-#{icons.next}"></span>
        <%= blog.title %>
      </td>
    </tr>
  <% end %>
</table>

Enumerator

第一次聽到 Enumerator 可能會有『蛤?那是什麼?從來沒看過』之類的陌生感。事實上,即使從來沒用過,我們跟 Enumerator 的距離可能也只有一個 returnenter 鍵。

這樣描述的原因是因為自己第一次接觸 Enumerator 是在某一次在寫一個 each block 時,寫到 each 時不小心按到 return,發現不僅沒噴錯還獲得了一個 #<Enumerator: ..略..> 物件,也是因為這樣引起好奇心。取得 Enumerator 物件的方式大致有這些:

由 constructor 建立

enum = Enumerator.new do |yielder|
  yielder << '發'
  yielder << '大'
  yielder << '財'
end

#=> #<Enumerator: #<Enumerator::Generator:0x00007fd993996ff0>:each>

使用 Enumerable (大多數)方法不帶 block 時

[9, 4, 8, 7].filter

#=> #<Enumerator: [9, 4, 8, 7]:filter>

對 Enuerable 物件呼叫 to_enum

[1, 9, 8, 7].to_enum #等同 to_enum(:each) 


#=> #<Enumerator: [1, 9, 8, 7]:each>

另外,操作 String 與 Integer 物件時,使用少數的方法時也會得到 Enumerator,例如:

# String

"GG".each_char
#=> #<Enumerator: "GG":each_char>

# Integer

5.times
#=> #<Enumerator: 5:times>

所以,什麼是 Enumerator?

或許用最簡單的解釋是:Enumerable 加上 External Iteration,而 Enumerator 與 Enumerable 的合作方式是前者負責產生、儲存資料,『需要時』再以後者的方法運用。以下面例子來說,enum物件儲存資料,等到呼叫 Enumerable 的 select 方法時,資料才一筆一筆 yield 進 block 做處理。

enum = [1,2,3,4].each
#=> #<Enumerator: [1, 2, 3, 4]:each>

enum.select { |x| x.even? }
#=> [2, 4]

除了先存起來之後『需要時』再用的操作彈性,另一個 Enumerator 特性是可以把物件依使用需求不斷 chain 起來。例如,如果我們想把吉大之溜重新排列之後、加上順序號碼印出:

'吉大之溜'.split('').reverse_each.with_index(1) do |word, index|
  puts "#{index}: #{word}"
end

#=> 1: 溜
#=> 2: 之
#=> 3: 大
#=> 4: 吉

# 如果在 block 開始前就 return,可以看到:
#<Enumerator: #<Enumerator: ["吉", "大", "之", "溜"]:reverse_each>:with_index(1)>

Lazy

假設有個情境需要我們從 1 開始遞增的正整數中找出前五個偶數,直覺可能會這樣寫:

infinity = 1..Float::INFINITY
infinity.select { |num| (num % 2).zero? }.first(5)

按下 return 後發現時間一直過去,始終沒有回傳 [2, 4, 6, 8, 10],開始懷疑是電腦壞了或懷疑人生。

很多回傳 array 物件的 Enumerable 方法,例如 collect, any?, detect,在底層,這些方法對集合物件呼叫 each 方法並做不同的操作後回傳 array;用另外一個角度想,當使用這些方法時,每個集合物件內的元素需要被“跑”過一遍才有辦法繼續處理。

上面的例子中,first 也是一樣的特性(eager)。first 對前一個接收者 select 呼叫 each 並想要找出前五筆資料,selectinfinity 呼叫並嘗試找出『所有』偶數。恩,到這裡就知道為什麼永遠等不到回傳值了。

infinity = 1..Float::INFINITY
infinity.lazy.select { |num| (num % 2).zero? }.first(5)
#=> [2, 4, 6, 8, 10]

要解決這類問題只需要多呼叫一個 lazy 方法,簡略的敘述原因是 method chain 最後一個 method first 反過頭來掌控了整個 iteration 的進行,當接收到5個值時,iteration 就會停止。如果有興趣深入瞭解,可以參考 Enumerator::Lazy