最近「台灣共識」很熱門,公司的粉專也分享了 Ruby 版的台灣共識。
我們在公司內部的群組大家其實討論了蠻久,如果只是單純的去實作跟其他語言一樣的內容,不就沒有意義了嗎?
我們之所以會選擇用 Ruby 來當作工作上的工具,就表示他有一些特別的地方吸引我們。
所以,上面用了哪些 Ruby 技巧讓我們一起來分析看看!
先來看一下原始的版本,這是一個可以實際執行的 Ruby 語法。
1def Consensus92(countries:, system:)
2 Module.new do
3 define_method 'definition' do
4 { countries: countries, system: system }
5 end
6
7 define_method 'build_consensus_with?' do |other|
8 return true if definition == other.definition
9 raise "This is not #{other} consensus"
10 end
11 end
12end
13
14class Taiwan
15 extend Consensus92(countries: 2, system: 2)
16end
17
18class China
19 extend Consensus92(countries: 1, system: 2)
20end
21
22China.build_consensus_with?(Taiwan)
include 與 extend
大多數時候我們都是對 include
比較熟悉,因為它可以把一些方法切割到一個 Module 裡面,然後在物件中呼叫。
我們先來看一下這段程式碼:
1module Extension
2 def echo
3 puts 'ECHO'
4 end
5end
6
7class A
8 include Extension
9end
10
11class B
12 extend Extension
13end
14
15p A.ancestors
16# => [A, Extension, Object, Kernel, BasicObject]
17A.new.echo
18p B.ancestors
19# => [B, Object, Kernel, BasicObject]
20B.echo
我們會發現在 B
上面的繼承上,是沒有 Extension
模組的,所以兩者的差異在哪邊呢?
因為我們希望是
China.build_consensus_with?(Taiwan)
而不是China.new.build_consensus_with?(Taiwan.new)
的寫法,才選擇用extend
線索一
調查了 Ruby 的文件會發現 include
屬於 Module
物件的行為,而 extend
則是屬於 Object
物件的行為。
簡單說就表示 include
只能作用在 Class
上,物件的實例是不行的,像是下面這樣:
1A.new.include Extension
但是 extend
是屬於 Object
的行為,所以原本我們預期是這樣
1B.new.extend Extension
但是同時 Ruby 的所有東西都是物件的一種,所以同理可以證明 Module
也是一種物件(而 Class
物件繼承於 Module
)所以下面的用法也會成立:
1B.extend Extension
線索二
根據 Ruby 文件提供的 extend
實作,大概是長這樣的,意外的很簡單。
1static VALUE
2rb_obj_extend(int argc, VALUE *argv, VALUE obj)
3{
4 int i;
5 ID id_extend_object, id_extended;
6
7 CONST_ID(id_extend_object, "extend_object");
8 CONST_ID(id_extended, "extended");
9
10 rb_check_arity(argc, 1, UNLIMITED_ARGUMENTS);
11 for (i = 0; i < argc; i++)
12 Check_Type(argv[i], T_MODULE);
13 while (argc--) {
14 rb_funcall(argv[argc], id_extend_object, 1, obj);
15 rb_funcall(argv[argc], id_extended, 1, obj);
16 }
17 return obj;
18}
裡面其實就只是把 extend
傳入的 Module 都帶入,並且呼叫 extended
和 extend_object
兩個方法。
經過簡單的測試,像下面這樣的修改就能阻止 extend
複製方法。
1module Extension
2 def self.extend_object(obj)
3 # Do Nothing
4 end
5
6 def echo
7 puts 'ECHO'
8 end
9end
10
11class B
12 extend Extension
13end
14
15B.echo
16# => undefine method
也就是說,在 extend
的行為下,我們會透過 extend_object
方法做某些處理後,才得以「複製」方法,而不是像 include
一樣把整個 Module 放入物件的繼承體系之中。
因為文章篇幅限制,我們先不去追
extend_object
的源頭。
Consensus92 的用法
首先,大家可能會有點疑惑為什麼可以用 extend Consensus92()
這樣的寫法,我們先釐清一下「方法」和「常數」的差異。
1def Consensus92; end
2p Consensus92()
3p Consensus92 # => uninitialized constant Consensus92
從上面這段程式碼我們可以發現,實際上「方法」和「常數」的命名空間是不同的,也就是說他們兩者並不衝突可以並存。而 Ruby 在這個情況下是透過有沒有 ()
來判斷到底是個方法,還是一個常數。
這邊省略 Ruby 的 Keyword Arguments 解釋,這部分雖然不常見但還是屬於日常使用的一部分。
那麼,為什麼 Consensus92()
的回傳結果可以被 extend
呢?
這個問題大家可能很快就猜到了,因為我們使用了「匿名模組」的技巧,雖然不確定是否真的有這個詞,不過大多數我們都用「匿名 XX」來稱呼一些沒有取名的定義,所以這邊也就這樣使用吧!
1def Consensus92
2 Module.new; end
3end
4
5class Taiwan
6 extend Consensus92
7end
因為不論 include
還是 extend
都只會確認對象是不是一個 Module 所以在這邊我們「即時」產生一個新的 Module 是符合 Ruby 在運作上的判定,也因此會被視為合法的行為。
所以實際上我們在 Taiwan
和 China
擴充的模組是不一樣的,這樣在程式的意義上,剛好也跟「九二共識沒有共識」的意思重疊在一起,畢竟從一開始「拓展(extend)」的共識就是不同的。
如果有在使用 Rails 的話,可能會注意到像是
Association_User_CollectionProxy
之類的類別名稱,其實就是運用這種技巧去動態產生的 Class 喔!
define_method 的理由
實際上,我們使用 Module.new do; end
和 module Extension; end
的效果是相同的,從下面的程式碼可以得到驗證:
1A = Module.new
2 def echo
3 puts 'ECHO'
4 end
5end
6
7class B
8 extend A
9end
10
11B.echo
既然這樣也會運作,那麼我們為什麼還需要用 define_method
呢?
這是因為我們希望達到類似 Closure 的技巧,看看下面這段程式就會注意到一個有趣的問題:
1def Consensus92(countries:)
2 Module.new
3 def definition
4 { countries: countries }
5 end
6 end
7end
8
9class Taiwan
10 extend Consensus(countries: 2)
11end
12
13Taiwan.definition
14# => undefine variable countries
為什麼會這樣,因為對 def
來說 countries
已經是屬於在執行階段的一部分,所以我們在呼叫 definition
的時候才會嘗試去尋找 countries
這個東西,但是他已經無法被找到。
但是 define_method
就不太一樣了!
1def Consensus92(countries:)
2 Module.new do
3 define_method 'definition' do
4 { countries: countries }
5 end
6 end
7end
8
9class Taiwan
10 extend Consensus92(countries: 2)
11end
實際上 define_method
在被呼叫的當下,會被轉成像這樣的樣子
1def definition
2 { countries: 2 }
3end
這是因為對於 define_method
所傳入的 Block 是用來定義方法的內容,但是因為我們是在呼叫一個方法,所以 countries
變數就被視為是處於 Consensus92
方法的環境下,而不是呼叫的當下。
稍微有點難懂,不過是不是很像 Closure 的感覺呢?
總結
這段程式碼其實算是有不少巧思在裡面,把程式碼換成中文讀起來意思也是很容易懂的。
1def 九二共識(國家:, 制度:)
2 Module.new do
3 define_method '定義' do
4 { 國家: 國家, 制度: 制度 }
5 end
6
7 define_method '建立共識?' do |定義|
8 return 是 如果 定義 == 對方.定義
9 raise "這不是#{對方}共識"
10 end
11 end
12end
13
14class 台灣
15 擴充 九二共識(國家: 2, 制度: 2)
16end
17
18class 中國
19 擴充 九二共識(國家: 1, 制度: 2)
20end
21
22中國.建立共識?(台灣)
23# => 錯誤「這不是台灣共識」
這也是 Ruby 在 DSL 表現優異上的原因之一,我們可以透過許多動態定義或者語法上的特殊技巧,製作出非常接近我們習慣的語言跟用法。
這篇文章提到關於 Ruby 類別上的應用,可以參考之前寫過的自由的 Ruby 類別來了解背後的機制。