與你每天擦身而過的 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-up
與 icon-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 的距離可能也只有一個 return
或 enter
鍵。
這樣描述的原因是因為自己第一次接觸 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
並想要找出前五筆資料,select
對 infinity
呼叫並嘗試找出『所有』偶數。恩,到這裡就知道為什麼永遠等不到回傳值了。
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。