註:這篇是好幾個月前應 InfoQ China 的邀稿所寫,不過看起來是沒有派上用場 (大概是程式碼太多了)。對我來說,寫好的稿子沒公開出來太浪費了,所以就貼出來吧。
本文摘錄了我去年在 RubyConf China 2010 中的演講內容,包含了 Ruby APIs 的十個設計技巧以及範例程式,同時也介紹了 Ruby 的物件模組及元編程(Meta-programming)。完整的範例程式請搭配 投影片 服用。
首先,讓我們簡單定義一下什麼是漂亮的 APIs:
* 閱讀性:API 容易理解
* 編寫效率:API 容易使用
* 擴展性:API 容易擴充
1.Argument Processing
Ruby 使用了 Symbols 和 Hash 來達到虛擬關鍵字參數(Pseudo-Keyword Arguments)。這種技巧被廣泛應用在 Ruby 的函式庫和 Rails 中,增加了閱讀性,也很容易使用。
def blah(options)
puts options[:foo]
puts options[:bar]
end
blah(:foo => "test", :bar => "test")
Ruby 也可以將參數列當成陣列使用:
def sum(*args)
puts args[0]
puts args[1]
puts args[2]
puts args[3]
end
sum(1,2,3)
如此就可以設計出不固定參數列、十分彈性的 API。類似於 C++ 的 function overloading。在 Rails 中也十分常見這樣的 API 設計,例如 link_to 就支援了兩種用法:
# USAGE-1 without block
<% link_to 'Posts list', posts_path, :class => 'posts' %>
# USAGE-2 with block
<% link_to posts_path, :class => 'posts' do %>
Posts list
<% end %>
搭配虛擬關鍵字參數使用的話,可以參考 ActiveSupport#extract_options! 這個小技巧取出 Hash 值。
2. Code Blocks
程式區塊(Block)是 Ruby 最重要的特色,除了拿來做迭代(Iteration)之外,也可以包裝前後置處理(pre- and Post-processing),一個最基本的例子就是開檔了,一般程序式的寫法如下:
f = File.open("myfile.txt", 'w')
f.write("Lorem ipsum dolor sit amet")
f.write("Lorem ipsum dolor sit amet")
f.close
使用 Block 之後,我們可以將 f.close 包裝起來,不需要明確呼叫。只要程式區塊結束,Ruby 就會自動關檔。程式一來因為縮排變得有結構,二來也確定檔案一定會關閉(不然就語法錯誤了)
# using block
File.open("myfile.txt", 'w') do |f|
f.write("Lorem ipsum dolor sit amet")
f.write("Lorem ipsum dolor sit amet")
end
另一個程式區塊的技法,是用來當做回呼(Dynamic Callbacks)。在 Ruby 中,程式區塊也是物件,於是我們可以將程式區塊如透過”註冊”的方式先儲存下來,之後再依照需求找出來執行。例如在 Sinatra 程式中:
get '/posts' do
#.. show something ..
end
post '/posts' do
#.. create something ..
end
我們”註冊”了兩個回呼:一是當瀏覽器送出 GET ‘/posts’ 時,會執行 show something 的程式區塊,二是 POST ‘/posts’ 時。
3. Module
模組(Module)是 Ruby 用來解決多重繼承問題的設計。其中有一招 Dual interface 值得一提:
module Logger
extend self
def log(message)
$stdout.puts "#{message} at #{Time.now}"
end
end
Logger.log("test") # as Logger’s class method
class MyClass
include Logger
end
MyClass.new.log("test") # as MyClass’s instance method
Ruby 的 extend 作用是將模組混入(mix-in)進單件類別(singleton class),於是 log 這個方法除了可以像一般的模組被混入 MyClass 中使用,也可以直接用 Logger.log 呼叫。
要將 Ruby 模組的混入成類別方法(class method),也有一些常見的 pattern 模式,可以將模組設計可以同時混入實例方法(instance method)和類別方法,請參閱投影片範例。這在撰寫 Rails plugin 時非常常用。
4. method_missing?
Ruby 的 Missing 方法是當你呼叫一個不存在的方法時,Ruby 仍然有辦法處理。它會改呼叫 method_missing 這個方法,並把這個不存在的方法名稱傳進去當做參數。這個技巧在 Rails 的 ActiveRecord 中拿來使用:
class Person < ActiveRecord::Base
end
p1 = Person.find_by_name("ihower")
p2 = Person.find_by_name_and_email("ihower", "[email protected]")
其中 find_by_name 和 find_by_email 就是這樣的方法。不過這個技巧不是萬能丹,它的執行效率並不好,所以只適合用在你沒辦法預先知道方法名稱的情況下。不過也不是沒有補救之道,如果同樣的方法還會繼續呼叫到,你可以在 method_missing 之中用 define_method 或 class_eval 動態定義此方法,那麼下次呼叫就不會進來 method_missing,進而獲得效能的改善。事實上,ActiveRecord::Base 的 method_missing 就是這麼做的。(感謝 BigCat 留言提醒我有此補救之道)
另一個 Missing 方法的絕妙 API 設計,是拿來構建 XML 文件:
builder = Builder::XmlMarkup.new(:target=>STDOUT, :indent=>2)
builder.person do |b|
b.name("Jim")
b.phone("555-1234")
b.address("Taipei, Taiwan")
end
# <person>
# <name>Jim</name>
# <phone>555-1234</phone>
# <address>Taipei, Taiwan</address>
# </person>
搭配了區塊功能,就能用 Ruby 語法來寫 XML,非常厲害。
5. const_missing
除了 method_missing,Ruby 也有 const_missing。顧名思義就是找不到此常數時,會呼叫一個叫做 const_missing 的方法。現實中的例子有 Rails 的 ActiveSupport::Dependencies,它幫助我們不需要先載入所有類別檔案,而是當 Rails 碰到一個還不認識的常數時,它會自動根據慣例,找到該檔案載入。
我們也可以利用這個技巧,針對特定的常數規則來處理。例如以下的程式會自動將 U 開頭的常數,自動轉譯成 Unicode 碼:
class Module
original_c_m = instance_method(:const_missing)
define_method(:const_missing) do |name|
if name.to_s =~ /^U([0-9a-fA-F]{4})$/
[$1.to_i(16)].pack("U*")
else
original_c_m.bind(self).call(name)
end
end
end
puts U0123 # ģ
puts U9999 # 香
6. Methods chaining
方法串接是一個很常見的 API 設計,透過將方法的回傳值設成 self,我們就可以串接起來。例如:
[1,1,2,3,3,4,5].uniq!.reject!{ |i| i%2 == 0 }.reverse
# 5,3,1
7. Core extension
Ruby 的類別是開放的,可以隨時打開它新增一點程式或是修改。即使是核心類別如 Fixnum 或是 Object(這是所有類別的父類別) 都一樣。例如 Rails 就定義了一些時間方法在 Fixnum 裡:
class Fixnum
def hours
self * 3600 # 一小時有多少秒
end
alias hour hours
end
Time.now + 14.hours
Ruby 的物件模型與元編程(Meta-programming)
在 Ruby 中,所有東西都是物件。甚至包括類別(class)本身也是物件。這個類別物件(class object)是一個叫做 Class 的類別所實例出來的物件。而所有的物件(當然也包括類別物件),都有一個 metaclass (又叫做 singleton, eigenclass, ghost class, virtual class 等名字)。定義在 metaclass 裡的方法,只有該物件能夠使用,也就是 singleton method (單件方法),只有該物件才有的方法。
了解什麼是 metaclass 是 Ruby 元編程的一個重要前提知識。Ruby 元編程最常用的用途,就是因應需求可以動態地定義方法,例如在 Rails ActiveRecord 中常見的 Class Macro 應用。
要能隨心所欲動態定義方法的關鍵重點,就是 variable scope (變數的作用域) 了。例如以下我們透過 class_eval 和 define_method 幫 String 定義了一個 say 方法,注意到整個 variable scope 都是通透的,沒有建立新的 scope:
name = "say"
var = "it’s awesome"
String.class_eval do
define_method(name) do
puts var
end
end
"ihower".say # it’s awesome
class_eval 可以讓我們改變 method definition 區域(又叫做 current class)。除了本投影片,建議可以閱讀 Metaprogramming in Ruby: It’s Allhe Self 和 Three implicit contexts in Ruby 這兩篇文章深入了解 self 和 current class。
8. Class Macro (Ruby’s declarative style)
Class Macro 是 Ruby Meta-programming 非常重要的一個應用,例如在 Rails ActiveRecord 中:
class User < ActiveRecord::Base
validates_presence_of :login
validates_length_of :login, :within => 3..40
validates_presence_of :email
belongs_to :group
has_many :posts
end
那要如何實作自己的 Class Macro 呢? 以常見的 Memorize 為例:
class Account
def calculate
@calculate ||= begin
sleep 10 # expensive calculation
5
end
end
end
a = Account.new
a.caculate # need waiting 10s to get 5
a.caculate # 5
我們希望改寫成
class Account
def calculate
sleep 2 # expensive calculation
5
end
memoize :calculate
end
寫法如下:
class Class
def memoize(name)
original_method = "_original_#{name}"
alias_method :"#{original_method}", name
define_method name do
cache = instance_variable_get("@#{name}")
if cache
return cache
else
result = send(original_method) # Dynamic Dispatches
instance_variable_set("@#{name}", result)
return result
end
end
end
end
這裡有幾個小重點:第一,因為 scope 不變的關係,我們需要 instance_variable_get 和 instance_variable_set 來拿到物件變數。第二,我們需要能夠保留舊方法已待稍後呼叫,這又有兩種作法:第一種比較常見,如同這個例子使用 alias_method。第二種則是使用 method binding,如 const_missing 中的範例。
9. instance_eval
用 DSL 的術語來說,instance_eval 可以幫助我們在 code block 中建立 implicit context (隱性的語境)。例如以下是一個 Rack 的建構方式,我們在 code block 中直接呼叫 use 和 run:
Rack::Builder.new do
use Some::Middleware, param
use Some::Other::Middleware
run Application
end
那就如何自己寫呢? 結合 instance_eval 和 block variable 即可:
class Foo
attr_accessor :a,:b
def initialize(&block)
instance_eval &block
end
def use(name)
# do some setup
end
end
bar = Foo.new do
self.a = 1
self.b = 2
use "blah"
use "blahblah"
end
10. Class.new
我們在 Ruby 物件模型有提到,Ruby 的類也是一個物件。所以我們也可以用產生物件的方式來產生一個匿名的類,這樣的方式就不會產生新的 scope。例如:
var = "it’s awesome"
klass = Class.new do
puts var
define_method :my_method do
puts var
end
end
puts klass.new.my_method
# it’s awesome
# it’s awesome
結論
這裡我想另外講兩個故事,第一個是 José Valim 在 Euruko 2010 上的演講 DSL or NoDSL。在他的演講之中,提到要不要把 API 設計成 DSL 其實並不是重點,重點是如何設計出一個簡單好用的 API,這才是我們的目標。例如他自己曾經設計一個 ContactForm,原本的設計大量使用 Class Macro 實作,雖然第一眼看起來很漂亮,但是一旦需要擴充時,反而變得難以維護。
另一個故事則是 Rails 2 到 Rails 3 有不少的 API 改變,例如 Routes 幾乎重新設計過了,大量採用 implicit context 來降低程式的參數列長度。ActiveRecord 新的 API 也大量採用了 method chaining。ActionMailer 寄信也把動態方法移除,改成回傳 Mail 物件。這些 API 的改變可以說是一個 Ruby 世代的變革,對於 API 如何設計有了新的想法。畢竟 Rails 是 Ruby 開源社群中最大的 codebase,我們可以從他的設計經驗中學到很多。
感謝分享與整理,收獲良多
BTW, 關於method_missing的部份
> 其中 find_by_name 和 find_by_email 就是這樣的方法。不過這個技巧不是萬能丹,它的執行效率並不好,所以只適合用在你沒辦法預先知道方法名稱的情況下。
依我的理解,ActiveRecord::Base.method_missing的做法是:find_by_xxx第一次被呼叫到時,動態地用class_eval將該method定義出來,並且再呼叫一次。第二次呼叫同樣的method,會直接呼叫到先前定義的版本,而不會進到method_missing。除了第一次被呼叫到時要定義method外,對執行效率應該不會有影響。不知我的理解是否有誤
BigCat:
沒錯,AR 的 method_missing 會在第一次呼叫後定義 method。謝謝你的補充,讓我加強一下內文好了 XD