su.Zero
back to main sitelogo and back to blog home
 
 

嗯,最近寫了太多 Android 的程式,但是我還是對 Ruby 比較有愛阿… XD 所以決定整理一下之前的東西,來寫一篇簡單的文章。 相信有稍微找過 Ruby 這個程式語言特色的人一定聽過人家說 Ruby 可以弄出很不錯的 DSL syntax 來。又或是你用過 Sinatra, Camping 等等 micro-webframework,肯定會被那個漂亮的 DSL Syntax 所吸引。老實說… 我也不例外 XD 我在用過 Camping 之後就已經覺得這個東西實在太有趣了,而用過 Sinatra 之後更是覺得太誇張了。當寫一個 Web 應用程式只要寫成這樣,難道你不會想要研究背後的實作嗎?

1
2
3
4
5
6
7
8
9
10
require 'rubygems'
require 'sinatra'
 
get '/' do
  "Hello World!"
end
 
post '/say' do
  "Hello, #{params[:name]}"
end

不過說來慚愧,Sinatra 的程式碼我到現在也還摸的不是很透… 不過在上網找了一些資料閱讀之後,發現寫一個簡單的 DSL parser 並不是這麼的難。在一間公司內部通常都會有一些 scriptable 的工具包裝一些常用的功能,而工具實際使用時又會搭配一隻程式產生 script 餵給他。這時候問題就出現了,平常在做這件事情的時候總是需要用上許多字串處理的東西。有時候是要換行,也可能是要檢查使用者輸入的資料。但是,當一支程式充滿了字串處理的時候,災難就出現了。常常一不小心就搞爛了整個程式,這時候如果能有一個包裝的很好的 library 不就能幫你節省了很多解 bug 的時間嗎?假設你的公司有一套產生帳單的工具,他吃的是這種 script:

1
2
3
4
5
6
7
8
9
beginbill my_lovely_customer@rich.people.net
  header "MyService Company\nUser ID: RichMan\nDate: 2009-01-01"
  print "Customer Support            10 x  3 =   30"
  print "Extra Material Shipping Fee  1 x  5 =    5"
  print "SUPER hosting (12 months)   90 x 12 = 1080"
  print " Discount for prepaid 12m    1 x 80 =  -80"
  print
  print "Total                                1035"
endbill

幻想程式碼

在實際進行之前,我們可以先幻想一下「如果是你,你希望用什麼樣的程式碼來產生一份帳單?」這個問題。總是要先有夢想來追求比較有成就感 :P 以我來說,我可能會希望是這樣的方式:

1
2
3
4
5
6
cust = Customer.get_any_one_customer
send_bill(:to => cust) do
  product "Customer Support", {:price => 10, :qty => 3}
  product "Extra Material Shipping Fee", {:price => 1, :qty => 5}
  subscription "SUPER hosting", {:months => 12, :price => 90, :discount => 80}
end

如果可以變成這樣的話,就可以大幅度的減少出錯的機率了。因為只要底層 library 是正確的,你的程式也正確,那程式就不會出錯了。而這樣的程式碼對一個程式設計師來說很容易閱讀,自然人為錯誤的機率就能大大降低,更可以節省日後的維護成本。

準備 Context

在開始實作之前,我們必須先準備一個 Context。所謂的 Context 就是在執行時期保持資料狀態的一個物件,我們可以利用一個 Context 在 send_bill 的 block (do…end那段) 執行過程之中,保存每一個指令跑下來的結果(以這個例子來說,就是每個產品、價格或是服務等等的紀錄)。這樣最後就可以輸出整個帳單。所以,我們可以寫出這樣的程式碼:

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
require 'stringio'
 
class BillContext
  def initialize(cust)
    @bill_output = ""
    @io = StringIO.new(@bill_output)
    @io.puts "MyService Company\nUser ID: #{cust.uid}\nDate: #{Time.new.strftime("%Y-%m-%d")}\n\n"
 
    @total = 0
  end
 
  def to_s
    @io.puts
    @io.printf("Total %35d", @total)
    @bill_output
  end
 
  def product(name, options)
    price = options[:price]
    qty = options[:qty]
    @io.printf("%-27s %2d x %2d = %4d\n", name, price, qty, price * qty)
    @total += price * qty
  end
 
  def subscription(name, options)
    price = options[:price]
    months = options[:months]
    discount = options[:discount]
    name = "#{name} (#{months} months)"
    @io.printf("%-27s %2d x %2d = %4d\n", name, price, months, price * months)
    @io.printf(" Discount for prepaid %2dm    1 x %2d = %4d\n", months, discount, -discount) if discount
 
    @total += price * months
    @total -= discount if discount
  end
end
 
class Customer
  attr_accessor :uid
 
  def initialize(uid)
    @uid = uid
  end
end
 
b=BillContext.new(Customer.new("RichMan"))
b.product "Customer Support", {:price => 10, :qty => 3}
b.product "Extra Material Shipping Fee", {:price => 1, :qty => 5}
b.subscription "SUPER hosting", {:months => 12, :price => 90, :discount => 80}
puts b.to_s

其中的 Customer class 可以用來產生 Mock Object,方便等下程式可以測試使用。再下面的程式碼則是使用看看目前這個 BillContext。如果直接執行,就會輸出如同我們上面最古早 Script 寫法類似的輸出結果。所以,我們簡單的這樣準備一個 BillContext 後,基本準備就大功告成了。那麼我們還缺少什麼?第一個,那個 b 變數實在很礙眼… 第二個,我們還希望這個程式可以自動幫我把帳單 email 出去。所以,我們接下來一項一項完成。首先,我們得先完成 send_bill 這個函式。

發送帳單

send_bill 函式由上面幻想程式碼中看來,似乎接受一個 Hash 變數並且需要一個 block,在 block 裡面則是處理實際帳單內容的程式。我們可以依據上面的想法先寫出這樣的程式碼:

1
2
3
4
5
6
7
module BillGate
  def send_bill(opt, &block)
    b = BillContext.new(opt[:to])
    yield
    puts b.to_s
  end
end

(關於這個 moudle 的名字…我絕對沒有在說誰…) 但是問題來了,yield 出來的東西怎麼存取到 b 呢? 我們可以利用 binding 來解決。所謂的 binding 在 ruby 基本上是一個透明的物件,他其中會帶著你目前這個區域(scope)中的變數資料。比如說,我們可以透過 eval 函式來使用。

1
2
3
4
5
6
def func(b)
  eval "main_var", b
end
 
main_var = "Hello"
puts func(binding)

當你在傳入 binding 作為 func 參數的同時,Ruby 會產生一個 Binding 物件包含了 main_var 的內容。因此當你在不同 scope 中 eval main_var 的時候就可以取得在外面的 main_var 內容。不過,這似乎不完全是我們要的對吧?

自己,self?

在知道了 Ruby 可以做到執行的變數"範圍"的變換之後,我們可以想想。平常在程式中呼叫函式, Ruby 到底是去哪裡搜尋這些函式的? 答案就是 self 變數。 self 變數會隨著你執行的地方不同而更替。在最外層的時候,self 是 main。在物件的方法中,self 就是那個物件。所以如果我們希望可以在 block 中使用剛剛在 Context 中定義的函式,像是: product, subscription 等等,我們必須把 block 執行過程中的 self 換成 BillContext 的實體物件。

仔細想想,我們是不是已經有一個了?是的,那個我們急欲幹掉的 b 變數就是一個 BillContext 的實體物件。搭配上 Ruby 非常貼心的 instance_eval 方法,我們可以將 block 丟到物件實體中去執行。

說了這麼多東西,其實說破了很簡單,就是一句程式碼:

1
b.instance_eval &block

Module, or not in Module?

把上面學過的東西總結之後,我們可以弄出下段程式碼:(當然下面要貼上我們最愛的幻想程式碼,試試看能不能跑 :P)

1
2
3
4
5
6
7
8
9
10
11
12
13
module BillGate
  def self.send_bill(opt, &block)
    b = BillContext.new(opt[:to])
    b.instance_eval &block
    puts b.to_s
  end
end
 
BillGate.send_bill(:to => Customer.new("RichMan")) do
  product "Customer Support", {:price => 10, :qty => 3}
  product "Extra Material Shipping Fee", {:price => 1, :qty => 5}
  subscription "SUPER hosting", {:months => 12, :price => 90, :discount => 80}
end

執行結果,太好了,跟我們預想的完全一樣。但是程式似乎離完美還差了一點點。因為我們得用 BillGate.send_bill 而不是 send_bill。為了做到這個,我們可以將 self. 拿掉,讓這個 method 變成一個 instance method。不過,由於 Module 本身無法實體化,你必須找地方寫上 include BillGate 才算完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module BillGate
  def send_bill(opt, &block)
    b = BillContext.new(opt[:to])
    b.instance_eval &block
    puts b.to_s
  end
end
include BillGate
 
send_bill(:to => Customer.new("RichMan")) do
  product "Customer Support", {:price => 10, :qty => 3}
  product "Extra Material Shipping Fee", {:price => 1, :qty => 5}
  subscription "SUPER hosting", {:months => 12, :price => 90, :discount => 80}
end

總算完成了,這樣我們就可以使用我們夢想中的程式碼來跑帳單了!不過為什麼這的小節要故意拆成兩部分呢?主要是想說明一下,當然你可以直接把 send_bill 放在 main 當中,這樣似乎更簡單。可是卻會遇到如果你的程式碼開始複雜起來,有了 class,你又不能在 class 之中使用 send_bill 了。因此把它包成 Module 在需要的時候引入,一方面只要在需要的時候使用 include BillGate 把它引入進來。一方面也可以避開,如果 main 中 send_bill 函式又被重複定義,怎麼辦?這種問題,可以順便做 namespacing,可以說是一舉數得阿 :P

你說我忘記做 email 了? 嘿,這就是回家作業啦! 想想看,什麼時候 block 會執行完呢? 提示一下,BillContext#to_s 這個方法可以轉換成純文字帳單。取得之後,你是不是就可以透過任何喜歡的方式 email 出去了呢?

後話

筆者自己也還在學習 meta-programming ,有許多的觀念也模模糊胡的。這篇文章是我試圖將我前陣子所看的東西,嘗試的東西轉換成一篇簡單的心得文,讓其他人可以花上少一些時間學習這些部分。如果我文中有什麼錯誤的地方,還請路過的訪客幫忙指正,謝謝。 或是您有什麼更好的作法,也請您不吝與大家分享。 :)

 

留言 Comments

 
© 2009 All Rights Reserved. | Powered by WordPress