蒼時弦也
蒼時弦也
資深軟體工程師
發表於

你看懂五倍紅寶石粉專上的 Ruby 版台灣共識了嗎?

最近「台灣共識」很熱門,公司的粉專也分享了 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 都帶入,並且呼叫 extendedextend_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 在運作上的判定,也因此會被視為合法的行為。

所以實際上我們在 TaiwanChina 擴充的模組是不一樣的,這樣在程式的意義上,剛好也跟「九二共識沒有共識」的意思重疊在一起,畢竟從一開始「拓展(extend)」的共識就是不同的。

如果有在使用 Rails 的話,可能會注意到像是 Association_User_CollectionProxy 之類的類別名稱,其實就是運用這種技巧去動態產生的 Class 喔!

define_method 的理由

實際上,我們使用 Module.new do; endmodule 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 類別來了解背後的機制。