Ruby 中的閉包-程式碼塊

lanzhiheng發表於2019-04-11

在許多程式語言中都會有閉包這個概念。今天主要來談談Ruby中的閉包,它在這門語言中地位如何,以什麼形式存在,主要用途有哪些?

閉包概要

維基百科裡對閉包地解釋如下

In programming languages, a closure, also lexical closure or function closure, is a technique for implementing lexically scoped name binding in a language with first-class functions. Operationally, a closure is a record storing a function together with an environment. The environment is a mapping associating each free variable of the function (variables that are used locally, but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created.
複製程式碼

看起來很複雜是吧?其實我也看不太懂,建議英文不好的人還是學我去看中文版。通俗來講,閉包就是一個函式,它可以跟外部作用域打交道,訪問並修改外部作用域的變數。在我們所熟悉的JavaScript這門語言中,所有的函式/方法都是閉包。

let a = 1;
function add_a() {
  return a += 1;
}

console.log(a)
console.log(add_a())
console.log(add_a())
console.log(add_a())
console.log(a)
複製程式碼

結果如下

1
2
3
4
4
複製程式碼

可見,函式add_a可以自由訪問外部作用域的變數a,並且能夠在函式呼叫過程中持續維護著變數的值。這種特性為函式的柯里化帶來了可能。

function add(a, b) {
 return a + b
}

console.log(add(1,2)) // => 3
複製程式碼

柯里化之後可得

function add(a) {
  return function(b) {
    return a + b
  }
}

console.log(add(1)(2)) // => 3
複製程式碼

得益於閉包的特性,上述兩個函式雖然呼叫方式不同,不過它們所完成的工作是等價的,我們可以藉此寫出許多有趣的程式碼。然而閉包如果使用不當,或許會不小心修改了不應該修改的外部變數,特別是這些外部變數被多個程式單元共享的時候,可能會引發意想不到的系統問題。Ruby在設計的時候也考慮到了這種問題,於是只要是通過def定義的方法,它都會建立一個封閉的詞法作用域,在該作用域內不可訪問外部的區域性變數,外部資訊只能夠通過引數的形式傳入到函式中。比如,下面這個程式碼片段是會報錯的

a = 100

def add_a
  a = a + 1
end

puts add_a

複製程式碼
Traceback (most recent call last):
	1: from a.rb:7:in `<main>'
a.rb:4:in `add_a': undefined local variable or method `a' for main:Object (NameError)
複製程式碼

然而,如果沒有閉包,Ruby這門語言所能夠提供的靈活性就很有限了。Matz也考慮到了這點,Ruby中並不是沒有閉包,它只是以另一種方式來展現--程式碼塊。程式碼塊也是Ruby超程式設計的重點內容,接下來我們以程式碼塊的形式來重新定義add_a方法。

a = 100

define_method :add_a do
  a = a + 1
end

puts add_a # => 101
puts add_a # => 102
puts add_a # => 103
複製程式碼

該例子中採用了define_method搭配程式碼塊來定義方法,使得我們可以在函式體的中訪問外部作用域的區域性變數a,使得該函式能夠達到我們預期的效果。一個方法的定義,是否要形成封閉的作用域,不同的語言可能會有不同的權衡,Ruby特地採用了程式碼塊來表示閉包,有別於一般的方法定義,為這門語言增添了不少色彩。

PS: 當然形如@xxxx的例項變數便不受這種封閉作用域的制約。因為例項變數本身就是例項上下文共享的。

回撥

在程式設計世界中,我們簡單地稱能夠作為某個函式的引數,並且能夠在該函式內部被呼叫的函式為回撥函式。許多人聽到回撥函式就會想到回撥地獄,然而個人覺得只要設計得當,並不是所有回撥都會淪為地獄。在Ruby中幾乎每一個通過def關鍵字定義地方法都預設接收一個程式碼塊來作為回撥函式,通常這個預設的回撥函式引數並不需要顯式宣告,考慮以下程式碼片段

def print_message(message)
  yield(message) if block_given?
  puts 'The End!!'
end

print_message('Hello World') do |message|
  puts "I will print the message #{message}"
end
複製程式碼

結果如下

I will print the message Hello World
The End!!
複製程式碼

好玩吧,我們可以在呼叫方法的時候,在末尾以程式碼塊的形式來定義回撥邏輯。在被呼叫的方法的內部,通過block_given?來判斷是否有程式碼塊傳入,如果有需要則通過關鍵字yield來執行對應的程式碼塊,並傳入相關的引數。這種以程式碼塊作為回撥的方式,為編碼帶來了一定的靈活性。然而或許在一些場景中這種隱式接收程式碼塊的方式並不是那麼直觀,我們也可以顯式地去宣告這個引數。

def print_message(message, &block)
  block.call(message) if block_given?
  puts "The End!!"
end

print_message('Hello World') do |message|
  puts "I will print the message #{message}"
end
複製程式碼

只是在這種場景中對應的引數&block會把我們傳入的程式碼塊轉換成Proc物件,於是在這個例子中需要通過Proc#call方法來執行對應的程式碼塊,而不再用yield關鍵字了。當然我們也可以直接往被呼叫的方法中傳入一個Proc的物件

callback = Proc.new do |message|
  puts "I will print the message #{message}"
end

def print_message(message, &block)
  block.call(message) if block_given?
  puts "The End!!"
end

print_message('Hello World', &callback)
複製程式碼

列印結果都是一樣的

I will print the message Hello World
The End!!
複製程式碼

以上兩種程式碼塊充當回撥的方式,是Ruby中編碼的常用手段。回撥邏輯始終作為“最後一個引數”傳入到被呼叫的方法中去,這或許也是一種約定優於配置的表現吧。

程式碼塊的“意義”

剛開始接觸Ruby的時候,我總覺得程式碼塊是一個反人類的設計,明明就是一個閉包,為何要設計得這麼異類。更奇怪的是,許多業內人士都覺得程式碼塊是Ruby最偉大的發明之一。後來接觸多了漸漸也就習慣了,程式碼塊的優雅配合上其閉包的特性,再加上上面所說的一些回撥的相關約定,為Ruby這門語言增色不少。程式碼塊有兩種表達方式{ .... }do ... end。一般對於單行的程式碼塊會採用第一種形式,對於多行的程式碼塊會採用第二種形式。在Ruby的開源世界中,程式碼塊幾乎無處不在,下面我們來看一些常見的案例

1. 容錯設計

Ruby承襲於Lisp,程式碼塊的執行會自動返回最後一條語句或者表示式的值,於是有些庫也考慮到了用程式碼塊來進行容錯處理。就拿Hash的例項來作做個例子,我們希望當Hash例項對應的鍵值對不存在的時候給它一個預設值,常見的做法是

> hash = {}
> value = hash['a'] ? 'default value' : hash['a']
 => "default value"
複製程式碼

熟悉JavaScript的人應該對上面這種程式碼不陌生,真所謂是囉嗦至極。為了使程式碼更加優雅我們可以採用Hash#fetch介面來取值,當Hash#fetch介面找不到對應的鍵值對的時候就會觸發異常

> hash = {}
> hash.fetch('a')
Traceback (most recent call last):
        3: from /Users/lan/.rvm/rubies/ruby-2.5.3/bin/irb:11:in `<main>'
        2: from (irb):12
        1: from (irb):12:in `fetch'
KeyError (key not found: "a")
複製程式碼

這個時候我們可以採用程式碼塊來做容錯,當找不到對應鍵的時候為取值操作提供一個預設值


> value = hash.fetch('a') { 'default value' }
 => "default value"
複製程式碼

相對於第一種方式第二種方式更加優雅,也更有Ruby味一些。雖說計算機世界是由0,1組成的,非此即彼。但是Ruby社群並不崇尚Python社群的絕對正確,每個人的偏向不同,我們可以選擇自己喜歡的方式去完成工作。

2. DSL

另外一個程式碼塊用得比較廣泛的地方應該就是DSL了,許多優秀的Ruby開源專案都會有相應的DSL。下面是Ruby模板渲染庫RABL的配置程式碼

Rabl.configure do |config|
  # Enabling cache_all_output will cause an orders cache entry to be used in all templates
  # matching orders.cache_key, which results in unexpected behavior on Spree api response.
  # For more about this option, see https://github.com/nesquena/rabl/issues/281#issuecomment-6780104
  config.cache_all_output = false
  config.cache_sources = !Rails.env.development?
  config.view_paths = [Rails.root.join('app/views/')]
end
複製程式碼

這是一個簡單的DSL,通過暴露模組內部的config例項,然後在呼叫者的上下文中去配置例項相關的屬性。這裡的程式碼塊其實也充當了回撥函式的角色,它讓我們的配置邏輯可以被統一規劃到一個區間當中,否則的話可能你得寫出類似這樣的配置程式碼

config = Rabl::ConfigureXXXX.new
config.cache_all_output = false
config.cache_sources = !Rails.env.development?
config.view_paths = [Rails.root.join('app/views/')]
複製程式碼

無論怎麼看都是DSL的方式比較優雅對吧?類似的DSL還有很多,這裡不一一舉例了,這些DSL如何去實現也不在本篇文章的討論範圍內。

3. 蹩腳的函式

程式碼塊可以被看成是一個“蹩腳”的函式,雖說一般情況下它可以作為某個方法的回撥,但是它不像JavaScript中的函式那樣可以獨立存在,它必須要依賴其他的機制。當我們要用程式碼塊去定義一個匿名函式時,需要搭配lambda關鍵字或者Proc類來實現

> c = lambda() {}
 => #<Proc:0x00007ff47b8546a8@(irb):6 (lambda)>
> c.class
 => Proc

> (lambda() { 'hello' }).call
 => "hello"
> (lambda() { 'hello' })[]
 => "hello"
> (Proc.new { 'hello' }).call
 => "hello"
> (Proc.new { 'hello' })[]
 => "hello"
複製程式碼

以上都是常用的定義匿名函式的方式,本質上它們都是Proc類的例項,需要顯式地利用Proc#call方法或者語法糖[]來呼叫它們。

尾聲

這篇文章簡單地介紹了一下閉包的概念,閉包跟一般封閉作用域的方法有何不同之處。區別於一般的方法,閉包在Ruby中以程式碼塊的形式出現,它在Ruby世界中幾乎無處不在,充當了一等公民。這種區分,不僅使我們的Ruby程式碼更加優雅,增添了可讀性,還使得我們的編碼過程更加簡單。

相關文章