Chapter 3: Methods - Part II

” Dynamic method is the technique of defining a method at runtime “― Metaprogramming Ruby ―

Defining Methods Dynamically

可先從 Ruby documentation 找到 define_method 的用法:

define_method(symbol, method)
define_method(symbol) { block }

以書中的範例來說:define_method 方法只需要提供

  1. 方法名稱
  2. block 裡放要執行程式碼就足夠了。

直接來看一段程式碼:

class MyClass
  define_method :my_method do |my_arg|
    my_arg * 3
  end
end

obj = MyClass.new
obj.my_method(2) # => 6

注意 define_method 是在 MyClass 類別內執行的,所以 my_method() 定義為 MyClass 的實例方法。 這種在 runtime 中定義的方法就被稱為是 Dynamic Method。

Def…end VS. Define_method

嚴格說來,define_method 與我們所熟悉的 def…end 的差別其實不大,最重要的不同是 define_method 允許在 runtime 的環境下,讓你決定方法的名稱。

從以下例子看到 define_method 的好用之處在哪。

class Store
  def trade_gold(arg, weight)
    "Taipei branch has traded #{arg} for #{weight} kg today"
  end

  def trade_silver(arg, weight)
    "Taipei branch has traded #{arg} for #{weight} kg today"
  end

  def trade_platinum(arg, weight)
    "Taipei branch has traded #{arg} for #{weight} kg today"
  end
end

tw_branch = Store.new

tw_branch.trade_gold('gold', 10)
# => "Taipei branch has traded gold for 10 kg so far today"

tw_branch.trade_silver('silver', 22)
# => "Taipei branch has traded silver for 22 kg so far today"

tw_branch.trade_platinum('platinum', 5)
# => "Taipei branch has traded platinum for 5 kg so far today"

有注意到 Store 類別裡的三個方法都蠻相似的,且回傳的內容重複性也蠻高的。 雖然我們也可用傳統的 def…end 來編碼,但要真正地精簡程式碼,可把方法的名稱在同ㄧ個陣列裡,再以 『動態方法』來定義:

class Store
  ["gold", "silver", "platinum"].each do |metal|
    define_method("trade_#{metal}") do |arg|
      "Taipei branch has traded #{metal} for #{arg} kg so far today
    end
  end
end
tw_branch = Store.new

tw_branch.trade_gold(10)
# => "Taipei branch has traded gold for 10 kg so far today"

tw_branch.trade_silver(22)
# => "Taipei branch has traded silver for 22 kg so far today"

tw_branch.trade_platinum(5)
# => "Taipei branch has traded platinum for 5 kg so far today"

可以看出我們並沒有改變回傳值,而是將方法名稱放進陣列,不但減少了重複的程式碼,也讓陣列裡的名稱及對應的方法更有彈性。

如此一來,當需要新增、刪除或是修改方法時,只要控制陣列內的元素即可。

Dynamic Methods

“Where you learn how to call and define methods dynamically, and you remove and duplicated code”― Metaprogramming Ruby ―

我們ㄧ般常見的呼叫方法是用逗點+方法名稱:

class Game
  def refresh(time)
  "refreshing in #{time} second" 
  end
end

game = Game.new
game.refresh(10)   # => "refreshing in 10 second"

不過還有另ㄧ種寫法可以呼叫 refresh(), 就是 Object 提供的send() 方法:

game.send(:refresh, 10)   # => "refreshing in 10 second"

Ruby Documentation 可以看到 send() 方法的第一個引數是方法的名稱,而且此名稱必須是以符號(Symbol) 或是 字串(String)來表示,其他需要的引數也都可傳遞。

Dynamic Dispatch

或許你會問既然用逗點+方法名稱或是send()都可以有一樣的效果,為何 Ruby 還要提供 send() 呢?

最大的不同是當使用 send()時,方法名稱就變成為一般的引數而已,因此可以等到條件判斷的結果出來後,再選擇是否要呼叫方法。這樣編碼的技巧就稱為: Dynamic Dispatch

” Dynamic dispatch is the technique that let you wait until the very last moment to decide which method to call, while the code is running”― Metaprogramming Ruby ―

以下的例子可以更清楚地說明 dynamic dispatch:

我們建立了一個 Game object 並將遊戲設定都存放在屬性裡。

game = Game.new
game.time = 23
game.score = 0
game.rank = 0
game.memory_size = 1800

而每ㄧ個實例方法( Game#time ) 都會有對應的類別方法( Game.time )回傳屬性的初始值。

Game.time = 60           # 初始值為 60

我們需要有ㄧ個 refresh() 方法幫助我們重新整理遊戲的設定值。這個 refresh() 方法會以 hash 當引數 (key 是屬性的名稱,value 是屬性的值)。

game.refresh(:time => 60, :score => 0)
game.time   # => 60 
game.score  # => 0

Game#refresh 方法會需要跑過每一個屬性(例如:self.time),並且初始化屬性的設定值(例如:Game.time),最後再檢查 hash 引數內的同樣的屬性是否有新的值,來決定要不要更新屬性。

def refresh(options={})

 defaults[:time] = Game.time
 self.time = options[:time] if options[:time]

 defaults[:score] = Game.score
 self.score = options[:score] if options[:score] 
.... ㄧ直重複編碼直到完成其餘的屬性

end

不難猜想如果未來屬性數量持續增加的話,此方法不僅有維護上的問題,程式碼區塊也將倍數成長。要解決以上問題,我們可以使用 Dynamic dispatch 技巧,只需要幾行的程式碼就可以設定好所有的值。

class Game
  attr_accessor :time, :score, :limit, :rank, :memory_size

  def initialize(time=60, score=0, limit=0, rank=0, memory_size=0)
    @time = time
    @score = score
    @limit = limit
    @rank = rank
    @memory_size = memory_size
  end

  def refresh( options={} )
    defaults = {}
    attributes = [ :time, :score, :limit, :rank, :memory_size ]

    attributes.each do |attribute|
      # 第一次的send()
      defaults[attribute] = self.send(attribute)  
    end

    defaults.merge!(options).each do |key, value|
      # 第二次的send()
      send("#{key}=", value) if respond_to?("#{key}=")
    end
  end
end

game = Game.new
game.refresh({:limit=> 100, :memory_size => 99}) 
# => {:time=>60, :score=>0, :limit=>100, :rank=>0, :memory_size=>99}

在上面的程式碼內,第一次的 send() 方法是用來將屬性的初始值,放進 defaults[attribute] 裡,接著再與 options hash 合併,最後使用 dynamic dispatch 技巧以 send 方法呼叫 attribute accessors ( 例如:time= )。

補充:

  • respond_to? 方法是用來檢查 Game#time= 方法是否存在?因此在 options hash 只要是沒有跟現有屬性相配的 key 都會被忽略掉。
  • Standard Library API: merge!