為什麼 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 實戰課程
線上課程:五倍學院線上課程