iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

1
自我挑戰組

Metaprogramming Ruby and Rails系列 第 33

Ruby 學習筆記簿:Metaprogramming Workshop - The Legacy System

本篇要分享的是此書(在第三章)我蠻喜歡的範例之一,作者以說故事的方式講解本章節所介紹的題目,假設的情境是新進員工被會計部門賦予一項任務,目標是找出花費大於美金$99元的電腦配件。

我做了此範例可使用的 DS(搭配CSV檔),這樣修改起來比較有感覺。大家如有有興趣可以在我的 GitHub The Legacy System Demo下載。

主角所面臨的舊系統中存在著許多重複及冗長的程式碼,達成任務的過程就是以本章學習到的技巧來重構更簡潔、易維護的系統。

還未修改的程式碼如下:

class DS
  def initialize                           # connect to data source...
  def get_cpu_info(workstation_id)         # ...
  def get_cpu_price(workstation_id)        # ...
  def get_mouse_info(workstation_id)       # ...
  def get_mouse_price(workstation_id)      # ...
  def get_keyboard_info(workstation_id)    # ...
  def get_keyboard_price(workstation_id)   # ...
  def get_display_info(workstation_id)     # ...
  def get_display_price(workstation_id)    # ...
  # ...and so on

當建立新的 DS 實例物件時,DS#initializ 方法就會連接到資料系統裡。如需要詢問特定電腦的配件資訊,可以將該電腦ID帶入到對應的實例方法中,就可以得到相關訊息。

ds = DS.new
ds.get_cpu_info(42)      # => "2.9 Ghz quad-core"
ds.get_cpu_price(42)     # => 120
ds.get_mouse_info(42)    # => "Wireless Touch"
ds.get_mouse_price(42)   # => 60

#  這代表了電腦ID 42的:
#  CPU 是 "2.9 Ghz quad-core", 價格是美元 $120
#  滑鼠 是 "Wireless Touch", 價格是美元 $60...(這滑鼠也太貴了吧)

這位新進員工馬上就寫了初步的解決方案如下:

class Computer
  def initialize(computer_id, data_source)
      @id = computer_id
      @data_source = data_source
  end

  def mouse
    info = @data_source.get_mouse_info(@id)
    price = @data_source.get_mouse_price(@id)
    result = "Mouse: #{info} ($#{price})"
    return "* #{result}" if price >= 100 result
  end

  def cpu
    info = @data_source.get_cpu_info(@id)
    price = @data_source.get_cpu_price(@id)
    result =   "Cpu: #{info} ($#{price})"
    return "* #{result}" if price >= 100 result
  end

  def keyboard
    info = @data_source.get_keyboard_info(@id)
    price = @data_source.get_keyboard_price(@id)
    result = "Keyboard: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end

  # ...
end

不久,他就發現自己的程式碼存在了重複編碼的情形,於是請教了資深的同事幫忙。這位同事建議兩個方案來解決問題:(1) Dynamic Methods、 (2) Method Missing

方案1:Dynamic Methods

Step 1: Adding Dynamic Dispatches

首先注意到在 Computer 內的每個方法都有類似的程式碼,先把它抓出來再想辦法修改。

  info = @data_source.get_mouse_info(@id)
  price = @data_source.get_mouse_price(@id)
  result = "Mouse: #{info} ($#{price})"
  return "* #{result}" if price >= 100 result

還記得 send 方法嗎? 我們可以讓方法名稱變成只是字串變數"get_#{name}_info",經由#{name}變數的改變,便可以用 send() 來『 動態呼叫 』原本重複的方法。

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def component(name)
    info = @data_source.send "get_#{name}_info", @id
    price = @data_source.send "get_#{name}_price", @id
    result = "#{name.capitalize}: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end

  def mouse
    component :mouse
  end

  def cpu
    component :cpu
  end

  def keyboard
    component :keyboard
  end
end

Step 2: Generating Methods Dynamically

接者我們利用 define_method 可 『 動態定義 』方法的特性,把 mouse()、cpu()、及 keyboard() 再做簡化。作者建立 define_component 的類別方法,並將定義動態方法的程式碼放在裡面。然後以呼叫 define_component() 來建立實例方法,最終的輸出都是相同的。

這裡我提供另一個寫法,其實也可直接將方法名稱放在同ㄧ個集合中,再用 each do 轉出來就好。

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  #----------------- 以集合(Array)方式來做 ---------------------
  titles = ['cpu', 'mouse', 'keyboard']

  titles.each do |title|
      define_method(title) do
      info = @data_source.send("get_#{title}_info", "#{@id}")
      price = @data_source.send("get_#{title}_price", "#{@id}")
      result = "#{title.capitalize}: #{info} ($#{price})"
      return "* #{result}" if price >= 100
      result
    end
  end

  #----------------------- 書中示範的方式:---------------------
  def self.define_component(name)
    define_method(name) do
      info = @data_source.send "get_#{name}_info", @id
      price = @data_source.send "get_#{name}_price", @id
      result = "#{name.capitalize}: #{info} ($#{price})"
      return "* #{result}" if price >= 100
      result
    end
  end

  define_component :mouse
  define_component :cpu
  define_component :keyboard
  #-----------------------------------------------------------
end

Step 3: Sprinkling the Code with Introspection

修改到目前為止,程式碼重複的情況其實幾乎沒有了。不過你還是可以利用 Regular Expression 讓上步驟最後三行程式碼都省略掉。 注意在 initialize() 多了ㄧ行使用正規表示法的程式碼,當 block 依附在 Enumerable#grep()方法時,這個 block 將會被與 grep 裡的 regular expression 做對比,而符合的結果會被儲存在**$1**全域變數裡。

換句話說,如果在 data_source 中有 get_cup_info() 以及 get_mouse_info() Computer.define_component(cpu)Computer.define_component(mouse) 就會被呼叫和執行。

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
    data_source.methods.grep(/^get_(.*)_info$/) { Computer.define_component $1 }
  end

  def self.define_component(name)
    define_method(name) do
      info = @data_source.send "get_#{name}_info", @id
      price = @data_source.send "get_#{name}_price", @id
      result = "#{name.capitalize}: #{info} ($#{price})"
      return "* #{result}" if price >= 100
      result
    end
  end
end

方案2:Methods Missing

使用 Methods Missing 就簡單許多了。如果有看過之前 Chapter 3:Methods - Part III 的文章,應該就不需要多作解釋了。


class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def method_missing(name)
    super if !@data_source.respond_to?("get_#{name}_info")
    info = @data_source.send("get_#{name}_info", @id)
    price = @data_source.send("get_#{name}_price", @id)
    result = "#{name.capitalize}: #{info} ($#{price})"

    return "* #{result}" if price >= 100
    result
  end
end

如果有任何問題,都歡迎留言詢問!


上一篇
Ruby 學習筆記簿:Metaprogramming Quizzes
系列文
Metaprogramming Ruby and Rails33

尚未有邦友留言

立即登入留言