Ruby 如何打造出自己的 DSL
前言
當 Ruby 的 gem 使用得夠多,不時會發現有許多既方便又神奇的語法,例如 RSpec
裡面提供了許多方法,如下方的語法範例:
1 2 3 4 5 6 7 8 9 10 |
|
其中有 context
、it
,而在 it 裡面又有像是 expect
等特殊的方法都是 RSpec 所預先提供著,而使用者可以在 RSpec 所提供的 範圍
裡,遵照 RSpe 的設計風格與規範
,使用了 特別的工具
方法,寫出愉快、精美又有效地能達成某些需求或目的的 script/code ,這就 Domain-specific language ( DSL ) 的一種實現。
其他常見的工具像是 Prawn
也是一種很好的 DSL
範例,在 Prawn::Document.generate
的 codeblock
裡,我們可以使用存取特定的參數 pdf
、以及使用許多預先提供的方法像是 draw_text
, image
, start_new_page
等等
1 2 3 4 |
|
但有沒有好奇過,這到底是如何實現的呢?為何 Ruby 這麼適合用來打造 DSL ? 接下來的章節我將會為大家做粗略的解說以及分享案例。
何謂 DSL
DSL 是 Domain-Specific Language 的縮寫,中文譯為「特定領域語言」,顧名思義就是說:
開發人員為了解決特定領域的問題所定義的專用語言。
任何人都可以開發自己的 DSL ,爽的話當然自己也可以立刻打造一個,但前提是它真的能滿足某些需求,以及方便令人使用,故我們可以在進一步做些定義:
- 滿足特定的需求 –> 存在的目的
- 提供有效的處理方式 –> 與其他可選方案的差異性
- 易於使用 –> 吸引他人使用的價值
- 有一定的規範或架構 –> 讓人有跡可尋、並遵守或拓展
Why Ruby?
因為 Ruby 寫起來很爽
通常具備動態能力的語言較容易實現 DSL ,而且如果還要能產生更讓人上手、並且自然、直覺地去使用的 DSL,那無疑 Ruby 兩者都能滿足。
但相較於其他動態語言 (暫不考慮靜態語言),我個人覺得 Ruby 最關鍵的地方於:
1. 呼叫方法時可省略 self
在 Ruby 裡,當不指定方法(訊息)的傳送對象,都會是默認當做傳送給 self
;所以我們不需要寫出像這樣的 code :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
而在 JavaScript 裡則必須地明確指定 this
,不然一般都會傳送給最頂層的物件 (像是 window),Python 也有類似的狀況。
因此 Ruby 此時的優勢是
帶來更簡潔的寫法、可讀性上也更佳、更直覺
2. 潔淨室的實現相當容易
而在講解潔淨室之前,我想先簡單介紹一下 Proc, codeblock。
Proc 與 codeblock
Ruby 裡的函式(方法)似乎不能當做第一級成員,至少它不能直接地被拿來傳遞給其他方法 (JavaScript, Python 都可以),但是取而代之的是,Proc 與 codeblock
Ruby 中同時存在著 Proc
與 lambda
,兩者的特性很像,除了對參數的接受容忍度、以及對 return
的處理行為不太一樣以外,我們大致可以把它視為相同的東西。
我們通常把它當作可傳遞
、可做方法呼叫
的物件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
以及最重要的特性:
proc 會產生閉包 ( closure ),會記住(帶著)創見時的語意環境 ( context )
1 2 3 4 5 6 7 8 9 10 |
|
在這個例子裡,create_proc
方法裡的 name
是地方變數,無法被外部存取,但是創見的 proc
確把它偷渡出來了
codeblock 也是一種 Ruby 用來傳遞一段程式碼的東西,但他不能直接被使用、存取,僅能透過 yield
來使用、以及 block_given?
判斷是否存在。
兩者關係:
就是可以藉由
&
來將 codeblock 與 proc 相互轉換。
所以我們通常使用兩者的時機就是,把一些彈性預留給外部去做定義,需要的時候在把這個來自外部的彈性帶進來執行。
就像 context
, it
都會傳遞一組 codeblock ,因為這就是整個方法的重點、要執行的主角,但在定義方法的時後還不知道,也不可能知道,但可以把這段執行主體交給外部決定( 如果常寫 JS 就把它想成是一個 callback )。
潔淨室的實現
我們已經知道 proc 、codeblock 會產生 closure ,就是會記住外部的語意環境,所以就是可以存取、使用存在於那個語意環境裡的變數、方法。
而潔淨室是一種我們專門用來執行 proc 或 codeblock 的物件
這樣看可能還是有些不懂,但其中一個關鍵字是 物件
,那麼我就用這個物件再搭配 instance_eval
方法一起看就會更懂了。
我們都知道 eval 家族得方法都很強大又有些邪惡,所以一般是少用的,但 Ruby 的超強大 meta programming 能力又很多仰賴於那些 eval 家族的方法像是 instance_eval
、 class_eval
,由於這兩個方法可以接受 code_block
所以相對地沒只能接受字串的 eval
方法這麼危險。
而潔淨室的關鍵正是要搭配 instance_eval
一起服用
一句化簡單介紹
instance_eval
,就是把給該方法的參數(通常是 codeblock)丟給該方法的接受物件去執行
1 2 3 4 5 6 |
|
因為該 closure 裡的 introduce
方法還未定義,所以其實帶走了,也是無法執行的;但我們可以利用 instance_eval
將該 closure 給某個有提供 introduce
的物件去執行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Foo 是一個具有 introduce
方法的類別,我們根據 Foo 建立出一個物件,並且對該物件呼叫 instance_eval 方法,再將透過 &
將 proc 物件轉換成 codeblock 當做 instance_eval 的參數去執行了
所以此時 Foo 建立的
物件
就是那個專門用來執行 > codeblock 的環境
,也就是潔淨室
。
而使用 instance_eval 的另一個目的就是,讓 codeblock 內隱含的 self
全都指向作為潔淨室的物件,不然照慣例 codeblock 是把當時環境的 self 給帶走的。
1 2 3 4 5 6 7 |
|
這就是為什麼我們可以在 RSpec 裡使用諸多預先提供好的方法像是 expect
等等,這都是某個 RSpec 的潔淨室物件
所提供的。
而 Prawn 的 codeblock 又可以在明確地存取 pdf
或是隱含地把 self
當做 pdf 物件去使用,這一切全看你所想定義的風格
1 2 3 4 5 6 7 8 |
|
以上兩個特性就是為何 Ruby 適合用來開發 DSL ,以及如何實現 DSL 的奧祕了(極度精簡版),剩下的就請大家去自行實踐或嘗試看看了。
範例
需求
有時候我們在呼叫一個方法時,會有一些前置方法去判斷是否符合條件,全部都通過才會執行方法本體,像是以下
1 2 3 4 5 6 |
|
但 for_match_condtion_a
、for_match_condtion_b?
這些方法其實只是作為某些規則去判斷而已,其他地方幾乎不會使用到,此時是否作為一個物件的其他方法就不是那麼必要的了,因此我們可以把這些抽象的規則邏輯給抽取出來成為一些 codeblock
使用起來很像是 Rails Model 裡常見的 callback 定義,而且我們期望在定義規則的 codeblock 可以直接存取實體方法
:
1 2 3 4 5 6 7 8 9 10 |
|
然後我們就可以藉由以下實體方法來做一些事:
rules_all_passed?
=> 判斷是否條件全部都通過failed_rule
=> 找出哪個條件失敗,如果都通過就回傳 nilmatch_rules
=> 回傳我們藉由add_rule
建立的那些規則
以上就是使用的需求以及情境,也可以當做一種很微小的 DSL 或是客製化類宏
實現範例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
|
結果可以自行試玩看看,或是有錯誤請幫忙 debug