[Ruby]Ruby 中的 作用域、Block、Proc、Lambda

在 Ruby 中容易搞混(學不好),且面試經常會被問的問題:

「請說明 Block、Proc、Lambda 是什麼」
「Block 中的 do…end 與 花括號 { } 差異」
「請說明 Proc 與 Lambda 區別」
「Rails 的 scope 為什麼用 Lambda?」
「要怎麼把 Block 轉成 Proc、Lambda?」
「要怎麼把 Proc、Lambda 轉成 Block?」

上述常見問題,一次滿足!!

1. 作用域

  • 程序会在类定义、模块定义、方法定义时,关闭前一个作用域,同时打开一个新的作用域。
  • 每当程序进入(或离开)类定义、模块定义、方法时,就会发生作用域切换
  • 三个关键字 class、module、def 都对应一个作用域门
v1 = 1                # 顶级作用域
class A               # 作用域门:进入class
  v2 = 2
  local_variables     # => [:v2], 类定义时候就会执行
  def a               # 作用域门:进入def
    v3 = 3
    local_variables   # 方法调用时候才会执行
  end                 # 作用域门:离开def
  local_variables     # => [:v2]
end                   # 作用域门:离开def

obj = A.new
obj.a                 # => [:v3]
local_variables       # => [:v1, :obj]

2. Block (程式碼區塊)

什麼是 Block ?

  • Ruby 是一款相当彻底的「对象导向 OOP (Object-Oriented Programming)」的程式語言,絕大部分的東西都是对象,而 Block 是少數的例外。
list = [1, 3, 5, 7, 9, 10, 12]

list.map {
    
     |i| i * 2 }
# map 印出如下
2
6
10
14
18
20
24
 => [2, 6, 10, 14, 18, 20, 24]  # 回傳值
# map 印出如上


list.select {
    
     |j| p j.even? }
# select 印出如下
false
false
false
false
false
true
true
 => [10, 12]  # 回傳值
# select 印出如上


list.reduce {
    
     |x, y| p x + y }
# reduce 印出如下
4
9
16
25
35
47
 => 47  # 回傳值
# reduce 印出如上


list.each do |num|
  p num * 2
end
# each 印出如下
2
6
10
14
18
20
24
 => [1, 3, 5, 7, 9, 10, 12]  # 回傳值
 # each 印出如上


# 從上得知,map、select、reduce 與 each 的回傳值(return value)不同
# map、select、reduce 會回傳一個新陣列
# each 回傳 receiver

在 Ruby 中,花括號 {} 與 do…end 就是 Block,只有在调用一个方法时,才可以定义一个块。也就是要接在方法(method)後面,且無法單獨存活,否則會出錯。

{
    
     puts "無法單獨存活,會出錯" }  # SyntaxError


do
  puts "無法單獨存活,會出錯"    # SyntaxError
end

Block 無法單獨存活,會噴錯誤訊息。 Block 不是对象,不能單獨存在。

Block 只是附加在方法後面,等著被程式碼呼叫的一段程式碼。

Block是闭包

  • 闭包的意思也就是代码块可以获取局部绑定,并一直带着它们
  • 代码块可以把变量偷偷带出原来的作用域
v1 = 1
def a
 v1
end
p a => undefined local variable or method `v1' for main:Object
v1 = 1
def a 
  yield
end
p a {
    
     "局部变量v1从顶级作用域被带进了方法a的作用域,v1的值:#{
      
      v1}" } 
=> "局部变量v1从顶级作用域被带进了方法a的作用域,v1的值:1"

Block 中 花括號 {} 與 do…end 差異

一般來說,若可以一行寫完會使用 {},若遇上較複雜的判斷,需寫一行以上時,則會使用 do…end。

除此之外,還有別的差異嗎?
請看以下範例:

list = [1, 2, 3, 4, 5]

p list.map{
    
     |x| x * 2 }      # [2, 4, 6, 8, 10]

p list.map do |x| x * 2 end  # <Enumerator: [1, 2, 3, 4, 5]:map>

原來 花括號 {} 與 do…end 還有 優先順序 的不同

花括號 {} 優先順序大於 do…end

上述範例原形為

list = [1, 2, 3, 4, 5]

p(list.map{
    
     |x| x * 2 }        # [2, 4, 6, 8, 10]

p(list.map) do |x| x * 2 end   # <Enumerator: [1, 2, 3, 4, 5]:map>
# 因為優先順序較低,所以變成先跟 p 結合了,造成後面附掛的 Block 就不會被處理了

如何執行 Block 的內容

使用 yield 方法

如果想讓附掛的 Block 執行內容,可以使用 yield 方法,能暫時先把控制權交給 Block ,等 Block 執行結束後再把控制權交回來。

def hi_block
  puts "開始"
  yield               # 把控制權暫時讓給 Block
  puts "結束"
end

hi_block {
    
     puts "這裡是 Block" }
# 印出結果如下
開始
這裡是 Block
結束
 => nil
# 印出結果如上

傳參數給 Block

會發現不管在list.map { |i| i * 2 }list.each do |num| p num * 2 end的 Block 中,那個 |i| 和 |num| 是什麼?
Block 中包住 i 和 num 的 | 唸做 pipe,中間的 i 與 num 是匿名函數的參數,稱作 token,其實是 Block 範圍裡的區域變數,離開 Block 之後就會失效了。

list = [1, 3, 5, 7, 9, 10, 12]

list.map {
    
     |i| i * 2 }
# 變數 i 只有在 Block 裡有效,會產生 [2, 6, 10, 14, 18, 20, 24]

list.each do |num|
  p num * 2
end
# 變數 num 只有在 Block 裡有效,會依序印出 2、6、10、14、18、20、24


puts i    # 離開 Block 之後就失效,出現找不到變數的錯誤 (NameError)
puts num  # 離開 Block 之後就失效,出現找不到變數的錯誤 (NameError)

# 變數名稱自定義,會取有意義的名稱,讓人看到能知道是什麼,而不是取無意義的 i

所以, i 和 num 是怎麼來的?
事實上,它就只是你在使用 yield 方法把控制權轉讓給 Block 的時候,順便把值帶給 Block 而已。

def hi_block
  puts "開始"
  yield "媽,我在這"  # 也可寫 yield("媽,我在這")
  puts "結束"
end


hi_block {
    
     |x| puts "這裡是 Block,#{
      
      x}" }
# 印出結果如下
開始
這裡是 Block,媽,我在這
結束
 => nil
# 印出結果如上
  • yield 後面可以帶 1 個或以上的參數
# 範例1 (帶 1 個參數)
def hi_block
  puts "開始"
  yield 123         # 把控制權暫時讓給 Block,並且傳數字 123 給 Block
  puts "結束"
end

hi_block {
    
     |x|     # 這個 x 是來自 yield 方法
  puts "這裡是 Block,我收到了 #{
      
      x}"
}
# 印出結果如下
開始
這裡是 Block,我收到了 123
結束
 => nil
# 印出結果如上


# 範例2 (帶 2 個參數)
def tow_parm
  yield(123, "參數2")
end

tow_parm {
    
     |m, n|
  puts %Q(我說數字 #{
      
      m},你回#{
      
      n}~)
}
# 印出結果如下
我說數字 123,你回參數2~
 => nil
# 印出結果如上
  • yield 進階使用
    範例 1:
    第 7 行的 i 被 yield 出去找了第 11 行的 i, x 是實體變數 @v 用 each 方法印出陣列中的數字。
class Map 
  def initialize
    @v = [1, 2, 3, 4]
  end

  def each_print
    @v.each {
    
     |i| puts yield i } if block_given?
  end
end

i = "多看幾次就會理解了"
a_obj = Map.new
a_obj.each_print{
    
     |x| "#{
      
      i} #{
      
      x}" }

# 印出結果如下
多看幾次就會理解了 1
多看幾次就會理解了 2
多看幾次就會理解了 3
多看幾次就會理解了 4
 => [1, 2, 3, 4]
# 印出結果如上

範例 2:
第 4 行的 yield 將 counter 帶去 method 外面找 list 後面的 Block,因 first 預設為 1 ,得知 yield 後面的 counter 預設也為 1,成為外面 Block 中 |ary| 的參數,待 Block 執行完後再回到第 4 行繼續往下執行。

def list(array, first = 1)
  counter = first
  array.each do |item|
    puts "#{
      
      yield counter}. #{
      
      item}"
    counter = counter.next
  end
end


list(["a","b","c"]) {
    
     |ary| ary * 3 }
# 印出結果如下
3. a
6. b
9. c
 => ["a", "b", "c"]
# 印出結果如上

list(["a","b","c"], 100) {
    
     |ary| ary * 3 }
# 印出結果如下
300. a
303. b
306. c
 => ["a", "b", "c"]
# 印出結果如上

list(["Ruby", "Is", "Fun"], "A") {
    
     |ary| ary * 3}
# 印出結果如下
AAA. Ruby
BBB. Is
CCC. Fun
 => ["Ruby", "Is", "Fun"]
# 印出結果如上

Block 的回傳值

其實 yield 方法除了把控制權暫時的讓給後面的 Block 之外

Block 最後一行的執行結果也會自動變成 Block 的回傳值
所以可把 Block 當做判斷內容:

# 範例1
def dog
  puts "汪!!汪!!汪!!"
end

dog {
    
     puts "你看不見我~~" }                   # 汪!!汪!!汪!!
# 如果沒有 yield,寫在 Block 裡面的東西,是不會有反應的


# 範例2
def say_hello(list)
  result = []
  list.each do |i|
    result << i if yield(i)                   # 如果 yield 的回傳值是 true 的話...
  end
  result
end

p say_hello([*1..10]) {
    
     |x| x % 2 == 0 }      # [2, 4, 6, 8, 10]
p say_hello([*1..10]) {
    
     |x| x < 5 }           # [1, 2, 3, 4]
p say_hello([*1..10]) {
    
     |x| return x < 5 }    # 會產生 LocalJumpError 的錯誤
p say_hello([*1..10])                         # 會產生 LocalJumpError (no block given (yield)) 錯誤訊息

上述範例 say_hello 方法,會根據 Block 的設定條件,挑出符合條件的元素,需特別留意在 Block 裡加入 return 會造成 LocalJumpError 的錯誤,因為 Block 不是一個方法,它不知道你要 return 到哪裡去而造成錯誤。

Block 不是參數

Block 像寄生蟲一樣得依附或寄生在其他的方法或物件,但它不是參數,以下範例中, name 才是參數,但 Block 不是。

def say_hello(name)
  p name
end

say_hello("小菜") {
    
     puts "這是 Block"}   # "小菜"

上面這段程式碼執行後不會有任何錯誤,但 Block 裡面也不會被執行。

怎判斷有無 Block?

有一種狀況是方法裡有 yield,但是呼叫方法的時候卻沒有 Block 的話…

def say_hello
  yield
end

say_hello    # 會產生 LocalJumpError (no block given (yield)) 錯誤訊息

會出現LocalJumpError (no block given (yield))的錯誤訊息。

在這種狀況,要讓方法呼叫的時候也能正常執行

可以使用 Ruby 提供的一個判斷方法 block_given?

# 範例1
def hi_block
  if block_given?               # 判斷執行方法的後面有沒有跟 Block
    yield
  else
    "no block"
  end
  # 上面 5 行可簡寫成 block_given? ? yield : "no block"
end

hi_block                        # "no block"
hi_block {
    
     "hello" }            # "hello"
hi_block do "hello" end         # "hello"


# 範例2
def hello_world
  yield('小菜') if block_given? # 判斷執行方法的後面有沒有跟 Block
  # 也可寫 yield '小菜' if block_given?
end

p hello_world                   # nil
hello_world {
    
    |x| puts "#{
      
      x}" }  # 小菜


# 範例3
def say_hello(name)
  yield name if block_given?    # 判斷執行方法的後面有沒有跟 Block
end

say_hello(puts "hi")            # hi

Block 特性

總結上述所說特性

  1. 不是物件、不是參數
  2. 不能單獨存在,只是附加在方法後面,等著被程式碼呼叫的一段程式碼。
  3. Block 最後一行的執行結果也會自動變成 Block 的回傳值
  4. Block 內不能使用 return
  5. 不能賦值給其他物件

雖然 Block 不是物件,不能單獨存在
但 Ruby 有內建兩個方法使 Block 物件化且單獨存在: Proc 和 Lamda

Proc

Proc 是程序物件,可以將 Ruby 的程式碼保存起來,並且在需要的時候再執行它,或當作 Block 傳入其他函數。

Proc.new後面接一個 Block 就可以產生一個 Proc 的物件,物件化後就是一個參數,接著可以使用 call 方法來執行 Block 內的程式碼。

proc1 = Proc.new {
    
     puts "Block 被物件化囉" }   # 使用 Proc 類別可把 Block 物件化
# 也可以寫成
proc2 = Proc.new do
  puts "Block 被物件化囉"
end

proc1.call    # Block 被物件化囉
proc2.call    # Block 被物件化囉

Proc 中不能加入 return

  • return 不要寫在 Proc 的 Block 裡,否則程式碼執行到這段後就會停止(return 完後立即結束執行),程式碼不會繼續往下走。
def hi_proc
  p "strat"
    hi_proc = Proc.new {
    
     return "執行完這段就停止了" }
    hi_proc.call
  p "end"
end

p hi_proc
# 顯示結果如下
"strat"
"執行完這段就停止了"
 => "執行完這段就停止了"
# 顯示結果如上
    
# "end"不會印出,因為執行完第 3 行就停止了

Proc 可帶參數

# 範例1
hi_river = Proc.new {
    
     |name| puts "你好,#{
      
      name}"}
# 也可寫成 hi_river = proc { |name| puts "你好,#{name}" }

hi_river.call("小菜在這裡")    # 你好,小菜在這裡


# 範例2 (帶參數)
cal = Proc.new {
    
     |num| num * 5 }
# 也可寫成 cal = proc { |num| num * 5 }

cal.call(3)                   # 15


# 範例3 (帶參數)
def total_price(price)
  Proc.new {
    
     |num| num * price }
  # 也可寫成 proc { |num| num * price }
end

n1 = total_price(50)
n2 = total_price(30)

puts "n1 要 #{
      
      n1.call(2)} 元,而 n2 要 #{
      
      n2.call(5)} 元"
# n1 要 100 元,而 n2 要 150 元

Proc 呼叫方式

要執行一個 Proc 物件,除了 call 方法之外,還有以下幾種使用方法:

hi_river = Proc.new {
    
     |name| puts "你好,#{
      
      name}"}


hi_river.call("小菜在這裡")    # 使用 call 方法
hi_river.("小菜在這裡")        # 使用小括號(注意,方法後面有多一個小數點)
hi_river["小菜在這裡"]         # 使用中括號
hi_river === "小菜在這裡"      # 使用三個等號
hi_river.yield "小菜在這裡"    # 使用 yield 方法

# 上述 5 種方法皆印出
# 你好,小菜在這裡

Lambda

  • Block 除了能轉成 Proc,Block 也可以轉成 Lambda,與 Proc 有些微不同:

retrun 值
參數的判斷方式 (是否會檢查參數的數量正確性)

Proc、Lambda 怎麼分

p1 = Proc.new {
    
    |x| x + 1 }
p2 = proc {
    
    |x| x + 1 }    # Proc 的另種寫法
l1 = lambda {
    
    |x| x + 1 }
l2 = ->(x) {
    
     x + 1 }      # lambda 的另種寫法


puts "p1: #{
      
      p1.lambda?}, #{
      
      p1.class}" # p1: false, Proc
puts "p2: #{
      
      p2.lambda?}, #{
      
      p2.class}" # p2: false, Proc
puts "l1: #{
      
      l1.lambda?}, #{
      
      l1.class}" # l1: true, Proc
puts "l2: #{
      
      l2.lambda?}, #{
      
      l2.class}" # l2: true, Proc

現在我們知道

  • Proc 和 Lambda 一樣都是屬於 Proc 物件
    上面 p1、p2、l1、l2 都可以使用 call 方法來執行,其中我們可以用 lambda? 來判斷它是不是 Lambda,如果不是那它就是 Proc。

  • Lambda 裡可加入 return
    Lambda 與 Proc 的其中一個差異是 return 值不一樣


def hi_lambda
  p "strat"
    hi_lambda = lambda {
    
     return p "會繼續往下執行" }
    hi_lambda.call
  p "end"
end


p hi_lambda
# 顯示結果如下
"strat"
"會繼續往下執行"
"end"
"end"
 => "end"
# 顯示結果如上

一次比較 Proc 和 Lambda 的 return 值

def test_return(callable_object)
  callable_object.call * 5
end

la = lambda {
    
     return 10 }  # 也可寫成 la = ->{ return 10 }
pr = proc {
    
     return 10 }    # 也可寫成 pr = Proc.new { return 10 }


puts test_return(la) # 50
puts test_return(pr) # 顯示 LocalJumpError 錯誤訊息

Lambda 的 return 是從 Lambda return
Proc 則是從定義 Proc 的 scope return

講得很清楚,聽得很模糊嗎? 直接看 code 理解

def test_proc
  pr = Proc.new {
    
     return 10 }
  result = pr.call
  return result * 5
end

def test_lambda
  la = lambda {
    
     return 10 }
  result = la.call
  return result * 5
end

puts test_proc   # 10
puts test_lambda # 50

test_proc 在 pr.call 那行就 retrun 結束執行了,而 test_lambda 可以執行完方法中的每行程式。

Lambda 處理參數較嚴謹

  • Proc 處理參數較有彈性,而 Lambda 較嚴謹
pr = proc {
    
     |a, b| [a, b] }    # 也可寫成 pr = Proc.new{ |a, b| [a, b] }
la = lambda {
    
     |a, b| [a, b] }  # 也可寫成 la = ->(a, b){ [a, b] }

p pr.call(5, 6)     # [5, 6]
p pr.call           # [nil, nil]
p pr.call(5)        # [5, nil]
p pr.call(5, 6, 7)  # [5, 6]


p la.call(5, 6)     # [5, 6]
p la.call           # 顯示 ArgumentError 錯誤訊息
p la.call(5)        # 顯示 ArgumentError 錯誤訊息
p la.call(5, 6, 7)  # 顯示 ArgumentError 錯誤訊息

Proc 針對參數的數量不會進行檢查,不足補 nil ,過多會自動丟掉;Lambda 會要求參數數量正確才會執行,較嚴謹,否則會顯示 ArgumentError 錯誤訊息。

Rails 的 scope 為什麼用 Lambda?

假設我們寫會帶入參數的 scope

scope :product_price, -> (type) {
    
     where(price: type) }

以 Proc 來做的話,Prodct.product_price 沒帶參數時,SQL query 依舊能夠執行,不會噴錯,因為 Proc 會將沒帶入的參數值預設為 nil ,在 SQL query 等同於執行 where(price: nil),會出現你預料外的狀況,在 Debug 會比較不好找。

反而 Lambda 能夠確保參數的數量正確性,過多或太少皆會 error 告訴你不能這麼做,避免不必要的狀況。

這也就是為什麼 Rails 中的ActiveRecord model在使用 scope 時,會用 Lambda 進行傳遞,原因是相比 Proc 來說,更為謹慎。

反而 Lambda 表現更像是常見的匿名函數。

使用 & 符號將 Block 與 Proc、Lambda 轉換

Block 轉成 Proc、Lambda

在 Rails 當中,假如我們要從資料庫找出所有使用者的姓名,利用 map 的話,寫法如下:

# Block 轉 Proc 範例
names = User.all.map {
    
     |user| user[:name] }
# 組成一個全都是姓名的 Array

# 也可寫成
names = User.all.map(&:name)
# 將 Block 轉 Proc


# Block 轉 Proc 範例
pp = Proc.new {
    
     |x| puts x * 2 }
[1, 2, 3].each(&pp)
# 原形 [1, 2, 3].each{ |i| pp[i] }
# 印出結果如下
2
4
6
 => [1, 2, 3]
# 印出結果如上


# Block 轉 Lambda 範例            
lam = lambda {
    
     |x| puts x * 2 }
[1,2,3].each(&lam)
# 原形 [1, 2, 3].each{ |i| lam[i] }
# 印出結果如下
2
4
6
 => [1, 2, 3]
# 印出結果如上

那個奇怪的 & 符號代表帶入一個 Proc 或 lambda,將 Block 轉成 Proc 使用。

Proc 或 lambda 轉成 Block

剛才介紹 & 的其中一個用法,那就是在方法宣告同時,指定從 Block 轉成 Proc 或 Lambda,除此 & 還可以把 Proc 或 Lambda 轉成 Block:

hi_proc("Hahaha", &proc{
    
     |s| puts s} )

hi_lambda = (1..5).map &->(x){
    
     x*x }

當 Proc 或 Lambda 碰到 & 之後,會轉換成 Block,所以以上的示範意義與下相同:

hi_proc("Hahaha"){
    
     |s| puts s }

hi_lambda = (1..5).map {
    
     |x| x * x }

&block 放參數最後面

Block 無法得知被物件化(參數化)後的 Block,需在最後一個參數前面加上 & ,這東西只能有一個,且必須放在最後面,否則會出現 syntax error。

# 錯誤示範
def hi_block(&p, n)
  ...
end

def hi_block(n, &p1, &p2)
  ...
end


# 範例1
def hi_block1(str, &test01)
  "#{
      
      str} #{
      
      test01.call(18)}"
end

hi_block1("Hello") {
    
     |age| "I'm #{
      
      age} years old." }
# "Hello I'm 18 years old."


# 範例2
def temp_b1
  yield("參數1") # 小括號可省略
end

def temp_b2(&block)
  block.call("參數2")
end

block1 = Proc.new {
    
    |x| puts "這是 Proc #{
      
      x}"}
block2 = lambda {
    
    |x| puts "這是 lambda #{
      
      x}"}


temp_b1 {
    
     |x| puts "block0 #{
      
      x}" }  # block0 參數1
temp_b1(&block1)                    # 這是 Proc 參數1
temp_b2(&block2)                    # 這是 lambda 參數2

看完會發現 Ruby 中的 & 非常的神奇,背後做了很多事情,實際上它是生出一個 Proc 物件,雖然好用,但若不了解背後原理的話,會不知怎麼用、錯在哪。

有兩個以上 &block 該怎辦?

def two_block(n, p1, p2)
  p1[n]      # 等同於 p1.call(n)
  p2.call n  # 括號可省略
end

two_block('River', proc {
    
     |i| puts "#{
      
      i} 1" }, Proc.new {
    
     |i| puts "#{
      
      i} 2" } )
# 印出結果如下
River 1
River 2
 => nil
# 印出結果如上

建立一個 Proc 物件,並當參數傳入即可,但還是得在建立同時寫 Block 給 Proc.new 方法。乍看之下很冗長又不好看,當想同時傳入多個 Block 作為參數時,適用此技。

小結

這篇很燒腦,找蠻多資料參考,從一開始撰寫時不太清楚,到後來能解釋,過程中有感覺變強一些。

猜你喜欢

转载自blog.csdn.net/qq_41037744/article/details/120546422