[Ruby] 如何理解 Ruby Block

所以說,我們來談談這個讓我一開始學 Ruby 時非常困惑的概念:blocks。當初開始學 Ruby 時,本想著一切都會非常輕鬆寫意,畢竟當時跟 C++(我的程式母語)處得還不錯,而 Ruby 開宗明義的哲學就是 “Make programmers happy.” 有 C++ 的背景加上這個語言是如此人性化,學起來絕對輕輕鬆鬆啊——結果 Block 就出現了。看著那些 do…end, yield, block.call 參數傳來傳去還有各式各樣的簡寫花式(一個 Ruby 讓人又愛又恨的特點),最讓我困惑的是「block 到底有什麼用?」直接用 function 不行嗎?為什麼要創造一個又不算是函式又不是變數的東西來混淆視聽?

但是 block 在 Ruby 中實在是太重要了,很多 Ruby 內建的方法也是利用了 block(像是 map),所以用久了也就漸漸習慣它的存在,只是心中的謎團一直沒有解開。

直到最近,在公司的程式碼裡面看到了一個 block 用法,頓悟終於來了:原來 block 是這樣用的!太聰明了!偉哉 Ruby(還有寫出這段程式碼的智者)!如果沒有 block,就很難如此簡潔地達成目的。發現這件事,簡直像是發現新大陸一樣,從此用一個完全不一樣的態度來看 block 了。

但是在介紹 block 的存在意義之前,我們先來複習一下 block 與它的兄弟姐妹 Proc 與 Lambda。

Ruby Block

Ruby Block 在其他語言中對應的概念是 closure或是匿名函式(lambda)。我們可以把 block 想像成「函式中的函式」。一般我們呼叫一個函式,傳進去的參數(parameter)是靜態的變數(variable),處理這個/些變數的邏輯全部由函式本身包辦。也就是說,我們輸入的只是資料,並不包含這個函式該怎麼處理這個資料。比如說今天我們去餐廳吃飯,有一個簡單的 order_from_menu 函式長這樣

def order_dish(dish)
# kitchen prepares the dish
...
  # serve
"Here comes your #{dish}!"
end

所以當我們呼叫 order_dish('蛋炒飯')'蛋炒飯' 就是我們傳進 order_dish 函式的參數。這個飯該怎麼炒、服務生要用什麼方式端過來,都不是我們的事。對這個函式的使用者而言,邏輯是這樣:

點菜(把參數傳進去) → 等菜(等函式裡的邏輯處理參數) → 上菜(得到回傳值)

所有使用這個函式的人都是得到一樣的處理邏輯,差別只在傳進的參數不同。

如果有 block 的話,就像是在這個函式的標準程序中,再插入自己想做的動作,比如說

# definition
def order_dish(dish)
# kitchen prepares the dish
...

# if we do pass in a block, then execute the content
if block_given?
yield
end
  # serve the dish
"Here comes your #{dish}!"
end
# usage
order_dish('蛋炒飯')           
=> "Here comes your 蛋炒飯!" # same result
order_dish('蛋炒飯'){ p 'Sing a song.' }    # pass in a block
=> "Sing a song."
=> "Here comes your 蛋炒飯!"

當我們一個 block 附帶在 method 的後面時,它就會在函式進行到 yield 時被執行。block_given? 是 ruby 內建的方法,幫助我們判斷是否有傳入 block。如果我們沒有傳入 block 卻執行 yield 的話就會出現錯誤 LocalJumpError: no block given (yield)

可以看到,上面這個例子在參數列完全沒有提示是否有 block,所以這是一個 “implicit block”。我們可以主動將 block 放在參數列,如下:

def order_dish(dish, &block)
...(some code)
  if block_given?
# now whenever we want to call the block, we can use
yield
# or
block.call
end
  ...(some code)
end

在 argument list 中加上 &block 這個函數的用法基本上是一樣的。我們依然可以選擇加上區塊或是不加區塊,有加區塊的話就可以用 yield 或是 block.call 來執行區塊內容。

什麼?你說為什麼&block 前面要加上一個 &?不加會怎麼樣?

這時候就要請 Proc 與 Lambda 登場了。

Ruby Proc

前面的 block 內容都是在我們呼叫函式的時候當場傳進去的,所以它沒有名字(直接用 yield 它就執行了),也沒有被寫進記憶體中。如果今天我們想要先將一個 block 準備好,需要的時候再直接將它傳進函式中可不可以?可以!這就是 Proc(Procedure)的功用。

Blocks are used for passing blocks of code to methods, and procs and lambda’s allow storing blocks of code in variables.

–擷取自 App Signal

基本上,Proc 就是把 block 內容存進變數裡。而且其實,如果你在上面的例子中用 block.class 看 block 的類別是什麼,你會看到 Proc。我們先來創一個 Proc看看:

sing_a_song = Proc.new { p 'Sing a song.' }

然後把它放進上面的例子中

order_dish('蛋炒飯', &sing_a_song)
=> "Sing a song."
=> "Here comes your 蛋炒飯!"

結果是一樣的,只是這次傳進的是一個預先定義好的區塊。

但是為什麼這裡又出現了 &?

& 的深意

在上面定義函式的例子 (def order_dish(dish, &block)) 中,我們使用 &block 的意思是如果今天有一個 block 的內容傳進來,那麼就把它放進名叫 block 的變數中在函式內部使用。如果沒有傳入 block 的內容,那麼這個變數就會是空的。我們當初用 order_dish('蛋炒飯') { 'Sing a song.' } 就是傳入了 block 的內容

block 的內容跟 block 本身是不一樣的。我們用 Proc.new 創立的 sing_a_song 是一個 Proc 實例,所以如果直接把 sing_a_song 傳進去當參數,會出現錯誤

order_dish('蛋炒飯', sing_a_song)   # no ampersand
=> ArgumentError: wrong number of arguments (given 2, expected 1)

因為它要的不是 Proc 本身,而是 Proc 的內容。所以,在呼叫的時候,我們在 Proc 實例之前加入 & 能夠讓程式碼幫我們將存在於 sing_a_song 裡面的 block 拿出來(也就是 {p 'Sing a song.'})。

聰明如我們當然馬上舉一反三,所以今天如果函式定義是 def order_dish(dish, block),那麼 block 就會是跟 dish 一個正常的參數,在傳 Proc 參數進去時自然也不用再加入 & 轉換了。

# definition
def order_dish (dish, block)
...
block.call # attention: can't use `yield` anymore
...
end
# usage
order_dish('蛋炒飯', sing_a_song)
=> "Sing a song."
=> "Here comes your 蛋炒飯!"

為何不能用 yield?因為之前的 block 是赤裸裸的貼在呼叫函式後面,yield 很好找人,現在它被塞進變數裡,變成 Proc 實體了,yield 抓不到了。

但這也代表一件事:我們可以傳超過一個 block 給函式!因為現在他們變成一般變數,所以我們想要放幾個 block 都可以。

# define some blocks
sing_a_song = Proc.new ('Sing a song.')
do_a_dance = Proc.new ('Do a dance.')
# define method
def order_dish(dish, blocks)
...
blocks.each do |block|
block.call
end
...
end
# usage
order_dish('蛋炒飯', [sing_a_song, do_a_dance])
=> "Sing a song."
=> "Do a dance."
=> "Here comes your 蛋炒飯!"

Ruby Lambda

lambda 其實跟 Proc 很相似,它叫做匿名函式,在其他語言中(比如說 Python)也有。先來看看如何使用:

a_lambda = lambda { 'lambda here' }

就是把原本 Proc.new 的地方換成了 lambda。

前面提過,block 其實是一個 Proc,那麼 lambda 呢?

block, Proc 與 lambda

我們來實際輸出比較一下

https://gist.github.com/jenny-codes/23fc0f97a9325581d74f12aff8580c44
result:
A block is a Proc
A block instance:
#<Proc:[email protected]_proc_lamnda.rb:11>
A proc is a Proc
A proc instance:
#<Proc:[email protected]_proc_lambda.rb:7>
A lambda is a Proc
A lambda instance:
#<Proc:[email protected]_proc_lambda.rb:8 (lambda)>

可以看到,lambda 其實也是個 Proc。不過在 lambda instance 中,我們可以發現有一個註解 (lambda) 來將 lambda 區別出來。有趣。

Proc 與 lambda 的差別

既然 Proc 與 lambda 都會創造一個 Proc 實體,那它們的差別在哪裡?

1. Return 的 scope

直接看例子

https://gist.github.com/jenny-codes/2d8c11eff334a9a54c281df277f08d12

執行的結果是

return from inside Proc
return from lambda function

Proc 裡面的 return 會結束整個 function,而 lambda 的 return 只會結束 lambda 本身。

2. 會不會檢查 Argument 個數

也是直接看例子

https://gist.github.com/jenny-codes/92c848dcc27cd3af334466a38bac66e7

結果:

a_proc outputs arg1 & arg2 &
Traceback (most recent call last):
2: from block.rb:40:in `<main>'
1: from block.rb:33:in `missing_arguments'
proc_lambda_argument.rb:37:in `block in <main>': wrong number of arguments (given 2, expected 3) (ArgumentError)

Proc 如果沒有收到足夠的參數,它就直接讓該物件變成 nil。lambda 遇到同樣的狀況就會報錯。

Ruby 中的 Proc 像是代碼片段(code snippet),所以 Proc 中的 return 就會讓整個方法 return;lambda 比較像是 function(匿名的),所以它 return 時就只會讓自己 return。

一些不同的寫法

以下列舉一些常見的寫法,存參。

# block
do...end # or
{}
# Proc
Proc.new {} # or
proc {}
# lambda
lambda {} # or
-> {}

所以說,那段讓你頓悟的程式碼呢?

終於(稍微)理解 block 的意義

單純知道一個概念該怎麼用,不代表你就知道為什麼、什麼時候要使用它。讓我頓悟的程式碼長這樣:

https://gist.github.com/jenny-codes/60847c96eac91a2bf13e59cc8d06564a
OUTPUT:
[Menu option] set: 蛋炒飯, side: 皮蛋豆腐, with_sauce: true
[Menu option] set: 蛋炒飯, side: 皮蛋豆腐, with_sauce: false
[Menu option] set: 蛋炒飯, side: 涼拌乾絲, with_sauce: true
[Menu option] set: 蛋炒飯, side: 涼拌乾絲, with_sauce: false
[Menu option] set: 蛋炒飯, side: 滷味拼盤, with_sauce: true
[Menu option] set: 蛋炒飯, side: 滷味拼盤, with_sauce: false
[Menu option] set: 紅蘿蔔蛋炒飯, side: 皮蛋豆腐, with_sauce: true
[Menu option] set: 紅蘿蔔蛋炒飯, side: 皮蛋豆腐, with_sauce: false
[Menu option] set: 紅蘿蔔蛋炒飯, side: 涼拌乾絲, with_sauce: true
[Menu option] set: 紅蘿蔔蛋炒飯, side: 涼拌乾絲, with_sauce: false
[Menu option] set: 紅蘿蔔蛋炒飯, side: 滷味拼盤, with_sauce: true
[Menu option] set: 紅蘿蔔蛋炒飯, side: 滷味拼盤, with_sauce: false

我有稍微修改了一下,但是概念基本上是一樣的。run_variations 這個 method 接受一系列的選項,將它們排列組合,重新放進一個 struct 裡面,並將這個 struct 傳入 block。於是在 block 中,我們可以便利地使用這些排列組合過後的值。

假設今天我要設計一個菜單,我決定要賣兩種套餐:蛋炒飯跟紅蘿蔔蛋炒飯,每種套餐可以選擇一種小菜,皮蛋豆腐、涼拌乾絲或是滷味拼盤,並且可以選擇要不要加醬。我想把所有可能的組合都印出來,上面就是我呼叫 run_variations 後的結果。

在這裡,block 的用法很像是前置作業,先行的函式定義放在主要的 method 定義裡面,而我們真正想要對 argument 做的事則是放在 block 裡。如果沒有 block 的話,就要先把 run_variations 產生的結果放在一個陣列裡,再迭代這個陣列。這樣當然行得通,但是就多耗費了一個轉換跟儲存的資源,並且也不能使用 combination.set這種有清楚架構的寫法。

block 就像是場地租借空間

延續餐廳的例子,block 就像是最近開始流行的場地租借空間,賣的不是食物,而是一個讓你聚會、辦活動的場地。它會提供一些基本的設備、小茶水點心,並且事後還幫你收拾,但你要做的事情是你自己決定的(自己傳進函式裡)。流程會像是

人進去(參數傳進去) → 店家準備好環境(先對參數做一些處理 etc) → 人在裡面做自己的活動(在函式中執行自己的邏輯,也就是 block)→ 店家收拾 → 人出來(函式結束)
def use_space(guests, &activity_block)
# environment setup
# (turn on the light, prepare seats for each guest etc.)
...
  # guests do their activity
activity_block.call
  # environment cleanup
...
end

大概是為了要讓讀者好理解(還有不要嚇壞讀者),參考書或是網路上解釋 block 的文件給的例子大都是「直接用 method 也可以解決」的情況,但是簡單的用法就無法彰顯 block 的威力,所以當時才讓我困惑這麼久。

既然現在知道 block 威力了,以後就更知道該在什麼時刻派他出場了!耶嘿!

References

RubyMonk - Interactive ruby tutorials to learn Ruby
rubymonk.com
Closures in Ruby: Blocks, Procs and Lambdas
blog.appsignal.com

http://rubyer.me/blog/917/

文章同步發表於 Medium


  • Find me at