為什麼 2.days.ago 在內建的 irb 會找不到這個方法?這不是 Ruby 語法嗎?

在公開演講或是校園推廣 Rails 的時候,我常會開 rails console 出來秀一下 Rails 快速開發的威力:

$ rails console
# 看,這樣寫就可以印出 2 天前的時間
>> 2.days.ago
=> Sun, 24 Apr 2016 00:56:51 UTC +00:00

# 這樣可以印出 10 Megabyte 是多大
>> 10.megabyte
=> 10485760

這真的很驚人,不只程式碼 2.days.ago 本身看起來好寫,而且就算連不懂程式語言的人看了也大概猜得出來是什麼意思,台下觀眾看到這裡,有的就會開始發出「喔喔喔」的讚嘆聲了。

但打開標準的 irb 來試試:

>> 2.days.ago
NoMethodError: undefined method `days' for 2:Fixnum
    from (irb):1
    from /Users/user/.rvm/rubies/ruby-2.3.0/bin/irb:11:in `<main>'
>> 10.megabyte
NoMethodError: undefined method `megabyte' for 10:Fixnum
    from (irb):2
    from /Users/user/.rvm/rubies/ruby-2.3.0/bin/irb:11:in `<main>'

一樣的語法在 Ruby 竟然不會動了!

原來,這些看起來很厲害的語法是 Rails 幫 Ruby 做的擴充功能,讓開發者在開發網站應用程式的時候可以很快的把功能完成。

怎麼做的?

先來看一段範例:

class Cat
  def sleep
    puts "ZZZZZZ"
  end
end

class Cat
  def eat
    puts "Yammy!"
  end
end

kitty = Cat.new
kitty.eat         # => Yammy!
kitty.sleep       # => ???

這裡我不小心定義了兩次 Cat 類別,如果這是你剛接觸 Ruby 不久,你也許會認為後面定義的類別會蓋掉前面的類別,所以執行 eat 方法沒問題,但執行 sleep 就會壞掉了。

但實際執行的時候發現其實兩個方法都可以正常運作! 所以,上面這段範例到底發生了什麼事?

事實上,當 Ruby 遇到兩個類別重複定義的時候,後面的定義並不會「覆蓋」掉前面的,反而比較像是「合併」。這個設計在 Ruby 裡面稱之「Open Class」,可以「打開」已經存在的類別,並再加料進去,甚至連內建的類別(像是數字、字串、陣列之類的)也可以。

我們來試試看內建的字串類別:

class String
  def say_hello
    "hello, #{self}"
  end
end

在這個定義之後,所有的字串就都會 say_hello

puts "kitty".say_hello  # => "hello, kitty"
puts "snoopy".say_hello  # => "hello, snoopy"
puts "くまモン".say_hello  # => "hello, くまモン"

這在其它程式語言是很少見的設計,特別是連內建的類別也可以這樣玩。讓我們再來玩一個!

puts 1 + 2    # => 3

1 加 2,不就等於 3 啊,這小學生都會!

但你知道其實這個簡單的加法可能跟你想的不太一樣,Ruby 的四則運算,事實上並不是一般的運算元(operator)。以上面這個 1 + 2 的例子來說,在 Ruby 裡實際上是:

數字物件 1 呼叫了 + 這個方法,並且把數字物件 2 傳進去當做參數

所以 1 + 2 事實上是長這樣:

puts 1.+(2)  # => 3

執行之後會得到一樣的結果。既然知道加法其實也是一個 Ruby 的 method 的話,那就可以來試著這樣玩:

class Fixnum
  def +(n)
    1000
  end
end

puts 1 + 2

這樣一來,不管是 1 + 2 或是 3 + 4,得到的結果都會是 1000。

不過這樣做有點風險,因為這樣等於是改寫了加法的行為,如果有其它方法有用到這個方法的話也會跟著得到不正確的結果。上面這個例子可以再改寫成:

class Fixnum
  alias :original_plus :+

  def +(n)
    puts "hey hey hey"
    original_plus(n)
  end
end

puts 1 + 2

執行上面這段範例可以發現,1 + 2 還是等於 3,但除此之外還會默默的輸出 hey hey hey 字樣,表示除了原來的加法之外,還可以做一些事。至於可以做什麼,這就靠大家自己發揮想像力了。

所以,Rails 是怎麼做的?

Rails 使用了 Open Class 的手法來幫 Ruby 加了不少功能,就以 2.days.ago 來說:

class Numeric
  # .. 略

  def days
    ActiveSupport::Duration.new(self * 24.hours, [[:days, self]])
  end
  alias :day :days

  # .. 略
end

這裡打開了 Numeric 類別(它是 Fixnum 的上層類別),幫它加上了 days 方法,而且還很貼心的幫 days 做了單數的 alias day,萬一只有 1 天也可以寫出 1.day.ago 這樣更像英文的語法。

實作的程式碼請看這裡

安全嗎?

這種「猴子補丁 (Monkey Patching)」的手法,有些人覺得很不嚴謹 (但我自己個人覺得還好),在 Ruby 2.0 之後推出了一個叫做 Refinement 的設計,可以稍微控制一下 Open Class 影響範圍。以前面那個 "kitty".hello 的範例,重新用 Refinement 改寫會長像這樣:

module MyHelloString
  refine String do
    def say_hello
      "hello, #{self}"
    end
  end
end

上面這段範例建立了一個叫 MyHelloString 的模組,在裡面使用 refine 方法在 String 類別上面進行「提煉」。但這並不會像之前 Open Class 的一樣寫完馬上有效果,要使用這個提煉過的方法,則是使用 using 方法:

using MyHelloString

puts "kitty".say_hello  # => "hello, kitty"

小結

各位看官會覺得 Ruby 的 Open Class 很隨便嗎? 我自己是還滿愛這樣的設計的 :)

Ruby 是一款非常有彈性的程式語言,但彈性本身也是雙面刃,你得知道學會怎麼運用這個彈性,而且你最好知道自己在做什麼。

工商服務

實體課程:Ruby on Rails 實戰課程
線上課程:五倍學院線上課程