如何設計出漂亮的 Ruby APIs [演講摘要]

註:這篇是好幾個月前應 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", "ihower@gmail.com")

其中 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 SelfThree 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,我們可以從他的設計經驗中學到很多。

參與討論

4 則留言

  1. 感謝分享與整理,收獲良多

    BTW, 關於method_missing的部份

    > 其中 find_by_name 和 find_by_email 就是這樣的方法。不過這個技巧不是萬能丹,它的執行效率並不好,所以只適合用在你沒辦法預先知道方法名稱的情況下。

    依我的理解,ActiveRecord::Base.method_missing的做法是:find_by_xxx第一次被呼叫到時,動態地用class_eval將該method定義出來,並且再呼叫一次。第二次呼叫同樣的method,會直接呼叫到先前定義的版本,而不會進到method_missing。除了第一次被呼叫到時要定義method外,對執行效率應該不會有影響。不知我的理解是否有誤

  2. BigCat:

    沒錯,AR 的 method_missing 會在第一次呼叫後定義 method。謝謝你的補充,讓我加強一下內文好了 XD

  3. 自動引用通知: Ruby元编程(三) | Mr.Cpp

發佈留言

發表迴響