← 上一章:方法與程式碼區塊(block) 下一章:使用套件(Gem)讓開發更有效率 →

類別(Class)與模組(Module)

Rails 不是一種程式語言,它是使用 Ruby 這個程式語言所開發出來的網頁開發框架(Web Framework)。

這個章節的目的並不是要詳細的介紹 Ruby 這個程式語言所有的功能,而是希望讓大家對 Ruby 有足夠的基本認識,之後大家在閱讀或撰寫 Rails 專案的時候,會比較知道 Rails 在寫些什麼。

類別(Class)

Ruby 是一款物件導向程式語言(Objecte-Oriented Programming, OOP),這是一款物件化的很徹底的程式語言,在 Ruby 的世界裡,幾乎所有的東西都是物件。但,到底什麼是「物件」?

什麼是物件?

物件(object) = 狀態(state) + 行為(behavior)

在現實生活中,路上跑的車子、天上飛的鳥,你我他,看得到、摸得到的都可通稱為之物件(Object)。物件會有狀態跟行為,例如我本人有是「黑色頭髮」、「黃色皮膚」、「年紀 18 歲(?)」等狀態,也有「吃飯」、「睡覺」、「走路」、「講話」等行為。

為了讓大家更容易學習程式設計,許多程式語言都有引進物件導向的概念,讓程式架構更容易組織、整理。而 Ruby 又是一款物件化相當徹底的程式語言,在 Ruby 的世界,你看得到的東西幾乎都是物件,數字 1、字串 “hello”,陣列、Hash 都是物件。

等等,為什麼說「幾乎」?

「在 Ruby 裡所有東西都是物件」,但其實也是有例外的,在上個章節介紹到的 Block 就不是物件,Block 本身沒辦法單獨的存活在 Ruby 的世界裡。

什麼是類別?

不知道大家有沒有在夜市看過或吃過雞蛋糕,有小貓、小狗等可愛動物造型,只要把調配好的麵粉糊倒進模具,蓋上蓋子,幾分鐘後就會有香噴噴又造型可愛的雞蛋糕可以吃了。

image
photo by Bryan Liu

那個烤盤模具,就是類別(Class)的概念。如果沒意外,一樣形狀的模具,放一樣的原料進去,做出來雞蛋糕的型狀應該都會長得一樣。而這個做出來的雞蛋糕,以物件導向程式設計的概念來說便稱之「實體(Instance)」。

定義類別

在 Ruby 要定義一個類別,使用的關鍵字是 class

class 類別的名字
  #...
end

如果我想定義一個小貓類別,順便在裡面先定義好一些方法,就可以這樣寫:

class Cat
  def eat(food)
    puts "#{food} 好好吃!!"
  end
end

其中,類別的名字規定必須是常數,也就是必須是大寫英文字母開頭。有了 Cat 類別之後,就可以用這個類別的 new 方法來產生實體:

kitty = Cat.new
kitty.eat "鮪魚罐頭"  #=> 印出「鮪魚罐頭 好好吃!!」

nancy = Cat.new
nancy.eat "小魚餅干"  #=> 印出「小魚餅干 好好吃!!」

在這裡我用 Cat 類別做了兩個不同的實體,分別叫做 kittynancy,這兩個物件因為都是用 Cat 類別做出來的,所以都有 eat 方法。

初始化

一樣形狀的烤盤,在一開始的時候放入不同的原料就可以做出不同口味的雞蛋糕。一樣的概念,在使用 new 方法製作實體的時候,也可以順便傳參數進去。

class Cat
  def initialize(name, gender)
    @name = name
    @gender = gender
  end

  def say_hello
    puts "hello, my name is #{@name}"
  end
end

kitty = Cat.new("kitty", "female")
kitty.say_hello    # => hello, my name is kitty

如果要透過 new 方法傳參數進來,在類別裡面必須有個名為 initialize 的方法來接收傳進來的參數。在 initialize 方法裡,常見的手法是會把參數傳進來給內部的實體變數(instance variable)。

實體變數(instance variable)

在 Ruby 裡的實體變數是有一個 @ 開頭的變數,顧名思義,是活在每個實體裡的變數,而且每個實體之間互不相影響。

前面這個範例來說,@name@gender 就是實體變數。

在 Rails 專案中,實體變數使用的頻率很高,最常用的地方應該是 Controller 與 View 之間的溝通,隨便舉一個 Controller 為例:

class PostsController < ApplicationController
  def index
    @posts = Post.all    # 取得所有的 Post 資料
  end
end

更多細詳內容將會於 MVC(Model, View, Controller)章節再做說明。

取用實體變數

Ruby 的實體變數沒辦法直接從外部取用,像這樣直接取用會發生錯誤訊息:

kitty = Cat.new("kitty", "female")
kitty.name = "nancy"     # 這會發生錯誤
puts kitty.name          # 這也會發生錯誤

因為 Ruby 並沒有「屬性」(property/attribute)這樣的設計,要取用實體變數,需要另外定義的方法才行:

class Cat
  def initialize(name, gender)
    @name = name
    @gender = gender
  end

  def say_hello
    puts "hello, my name is #{@name}"
  end

  def name
    @name
  end

  def name=(new_name)
    @name = new_name
  end
end

kitty = Cat.new("kitty", "female")
kitty.name = "nancy"
puts kitty.name        # => nancy

這裡定義的 name 以及 name= 方法(是的,你沒看錯,等號 = 也是方法的一部份)就是負責回傳及設定 @name 這個實體變數的。

也許你會想,每次要這樣取用或設定實體變數的值都要這麼麻煩嗎?還好,Ruby 有幫我們定義了三個方法來解決這件事,分別是 attr_readerattr_writer 以及 attr_accessor。這三個方法分別會做出「讀取」、「設定」以及「讀取 + 設定」的方法,所以原來的有點囉嗦的寫法就可使用 attr_accessor 改寫成這樣:

class Cat
  attr_accessor :name

  def initialize(name, gender)
    @name = name
    @gender = gender
  end

  def say_hello
    puts "hello, my name is #{@name}"
  end
end

實體方法與類別方法

依據方法作用的對象不同,有分實體方法(instance method)及類別方法(class method),舉個例子來說:

kitty = Cat.new("kitty", "female")
kitty.say_hello

這個 say_hello 是作用在 kitty 這個「實體」,所以稱這個 say_hello 為實體方法。如果是這樣:

class PostsController < ApplicationController
  def index
    @posts = Post.all    # 取得所有的 Post 資料
  end
end

這裡的 all 方法是直接作用在 Post 這個「類別」上,故稱之類別方法。在 Ruby 要定義類別方法有幾種寫法,其中一種比較簡單的,就是在前面加上 self

class Cat
  def self.all
    # ...
  end
end

這樣就可以直接用 Cat.all 的方式呼叫了。下面是另一種寫法:

class Cat
  class << self
    def all
      # ...
    end
  end
end

這樣的寫法跟前面的一樣,但這樣就不需要特別在方法前面加上 self。當一個類別裡面有很多類別方法的時候,我常會選擇這樣的寫法。

方法的存取控制

如果你曾經在別的程式語言寫過OOP,你也許對類別的方法存取限制不會太陌生。類別的方法存取限制常見的主要有三種:publicprotected 以及 private

這三種存取限制,在別的程式語言常聽到的解釋大概會是像這樣:

  • public: 就是所有的人都可以直接存取。
  • private: 是只有在類別內部才可以存取。
  • protected: 差不多是在這兩者之間,比 private 寬鬆一些,但又沒有 public 那麼自在,protected 在同一個類別內或是同一個 package,或是繼承它的子類別可以自由取用,但如果不是的話則不可存取。

但 Ruby 的設定在這方面跟其它程式語言不太一樣,不過讓我們先來看看怎麼使用。Ruby 的方法存取限制有兩種寫法,一種是寫在方法定義之前:

class Cat
  def eat
    puts "好吃!"
  end

  protected
  def sleeping
    puts "zzzzzzzzz..."
  end

  private
  def gossip
    puts "我跟你說,你不要跟別人說喔!"
  end
end

這裡定義了三個方法,分別是 public 的 eat 方法、protected 的 sleeping 方法,以及 private 的 gossip 方法。在 Ruby 的類別裡,方法如果沒有特別限制,預設就是 publilc,也就所有的類別都可以存取它。

這種把存取控制放在前面的寫法,在它設定之後的方法都會受影響,除非又遇到另一個存取控制的設定。以上面這個例子來說,eat 方法沒有特別限制,所以它是 public 方法(如果你想要特別加上 public 也可以,只是通常不會這麼做)。

Ruby 冷知識:
沒有特別限制的方法預設都是 public,但除了一個例外,就是負責初始化的 initialize 方法,它永遠是 private 的,只會被 new 方法呼叫。

另一種的方法存取限制是寫在方法定義之後:

class Cat
  def eat
    puts "好吃!"
  end

  def sleeping
    puts "zzzzzzzzz..."
  end

  def gossip
    puts "我跟你說,你不要跟別人說喔!"
  end

  protected :sleeping
  private :gossip
end

這兩種哪種方法比較好呢?都好,隨個人喜好。我個人喜好第一種,因為我習慣會先把 public 的方法放在類別的上半部,把 private 方法放在類別的最底下,所以使用第一種寫法對我來說寫起來比較順手。

Ruby 冷知識:
其實 publicprotected 以及 private 這三個在 Ruby 裡並不是關鍵字,它只是一般的方法而已。

前面為什麼會特別提到 Ruby 的方法存取限制跟其它的程式語言「類似」呢?雖然 Ruby 裡的確也有 public、protected 以及 private ,但事實上是不太一樣的,特別是 private 方法。我們先來看一小段的程式碼:

kitty = Cat.new
kitty.eat           # => "好吃!"
kitty.sleeping      # => NoMethodError
kitty.gossip        # => NoMethodError

kitty 是 Cat 類別產出的實體,而實體的 public 方法如所預期的印出結果,protected 跟 private 方法呼叫的時候產生 NoMethodError 例外,到這裡看起來都還跟其它程式語言的設計差不多。

再繼續往下說明之前,先讓我們看一下這段程式碼:

kitty.eat

這看起來非常普通,不就是「kitty 物件執行了 eat 方法」而已嗎?其實這個在 Ruby 裡,它被解讀成:

有一個 kitty 物件,對它發送了一個 eat 的「訊息」(message),而這個 kitty 就是訊息的「接收者」(receiver)。

這個概念是來自於一個非常古老的程式語言 Smalltalk。為什麼特別提這個?因為在 Ruby 裡所謂的 private 方法的使用規定很簡單,就只有一條:「不能明確的指出 receiver」。用白話文講,就是「在呼叫 private 方法的時候,前面不可以有小數點」。也就是因為這樣,在 Ruby 的 private 方法其實不只類別自己內部可以存取,它的子類別也可以,並沒有像其它程式語言一樣的繼承限制。

再讓我們看這個例子:

class Cat
  def say_hello
    self.gossip
  end

  private
  def gossip
    puts "我跟你說,你不要跟別人說喔!"
  end
end

kitty = Cat.new
kitty.say_hello        # NoMethodError

也許你有聽過 self 這個特別的變數,指的是「自己」,上面這個例子,say_hello 方法執行會發生錯誤。為什麼?因為你在呼叫 private 方法的時候加上了 self。就跟你說「在呼叫 Ruby 的 private 方法時,不能明確的指定 receiver」,不管你是不是 self,違反規定就無法使用 private 方法。

也許你會好奇,private 方法常見嗎?其實,我們很常用的 puts 方法,它其實就是 Object 這個類別的 private 方法之一(更正確的說,是 Kernel 模組 mixin 到 Object 類別裡的方法)。我們平常會這樣用:

puts "Hello Ruby"

你有注意到呼叫 puts 方法的時候,前面沒有小數點嗎?但如果你這樣做:

self.puts "Hello Ruby"

就會出現出 NoMethodError 了。

那 protected 方法呢?從外部來看,它跟 private 一樣,不能直接使用,但在類別內部,它的規定就沒那麼嚴格了,你要指定或不指定 receiver 都可以;至於public方法,就跟其它語言的定義差不多,就是隨便你用啦。

真的這麼 private?

不過,其實 Ruby 的 private 方法也不是真的那麼 private,轉個彎,一樣可以被外部呼叫:

kitty = Cat.new
kitty.gossip          # => NoMethodError
kitty.send(:gossip)   # => 我跟你說,你不要跟別人說喔!

咦?不是說呼叫 private 方法的時候不能有明確的接收者嗎?你仔細看,並沒有違反這個規定喔,這邊我是執行 send 方法,把 gossip 當做參數傳給它而已,所以不算違反規定。

僅供參考

這..這樣會不會太隨便了?如果連 private 方法都能被直接存取,那當初何必還要這樣設計呢?還是直接乾脆全部都 public 就好了?

我想這其實是 Ruby 當初設計的哲學之一,Ruby 把很大部份的權限都下放給程式設計師,讓開發者有最大的彈性空間可以運用(或惡搞),也就是這樣,跟別的程式語言比起來,在 Ruby 做 Metaprogramming 是相對的較容易的。不只在這裡,你應該還可以在很多地方看到這個 Ruby 的有趣的特性。

繼承(Inheritance)

到目前為止的範例都是只有單一類別,但在真實的世界裡其實是更複雜的,像是如果想要再加入一個小狗類別:

class Cat
  def eat(food)
    puts "#{food} 好好吃!!"
  end
end

class Dog
  def eat(food)
    puts "#{food} 好好吃!!"
  end
end

在這個例子,不管是 Cat 或 Dog 類別都有定義了 eat 方法,在物件導向的概念裡,如果這些類別的用途是同一型的(例如 Cat 跟 Dog 都是動物),通常會把相同功能的方法移到「上一層」的類別裡,然後再去繼承它:

class Animal
  def eat(food)
    puts "#{food} 好好吃!!"
  end
end

class Cat < Animal
end

class Dog < Animal
end

在這裡,我定義了一個 Animal 類別,然後讓 Cat 跟 Dog 都去繼承它,那個小於符號 < 就是「繼承」的意思。這樣一來,就算 Cat 跟 Dog 類別空空的什麼都沒寫,也一樣有 eat 方法可以用。雖然 Cat 跟 Dog 本身是兩個不同的類別,但我們可以說「Cat 是一種 Animal,Dog 也是一種 Animal」,利用這樣的設計,可以把程式碼整理得更漂亮,不會寫出一堆重複的程式碼。

開放類別(Open Class)

大家請先看一下這段程式碼:

class Cat
  def abc
    # ...
  end
end

class Cat
  def xyz
    # ...
  end
end

kitty = Cat.new
kitty.abc       # => 會發生什麼事?
kitty.xyz       # => 會發生什麼事?

一個不小心,在這裡定義了兩個名字都叫做 Cat 的類別,你可能會猜,後面寫的類別應該會蓋掉前面先寫的類別,所以 kitty.xyz 可正常運作,但 kitty.abc 會出錯。

但在 Ruby 裡,如果遇到兩個一樣名字的類別,其實並不會「覆蓋」,而是會進行「融合」,上面這兩個類別最後會變成這樣:

class Cat
  def abc
    # ...
  end

  def xyz
    # ...
  end
end

然後 abcxyz 兩個方法都可以正常執行。利用這個特性,可以做出有趣的效果:

class String
  def say_hello
    "hi, I am #{self}"
  end
end

puts "eddie".say_hello  # => hi, I am eddie
puts "kitty".say_hello  # => hi, I am kitty

在這裡,我先定義一個 String 類別,並且在裡面定義了 say_hello 方法,根據前面的規則,遇到「同名」的類別的話,方法會互相「融合」,所以在那之後所有的字串就都有 say_hello 方法可以用了。等等,那個 String 類別不是內建的類別嗎?是的,你沒看錯,在 Ruby 即使是內建的類別,也是可以幫它「加料」的,這個技巧稱之開放類別(Open Class)。

這是我個人很喜歡的功能,有些人可能會認為這樣的設計感覺很隨便,竟然連內建的類別都可以任意修改,但我想大家都是大人了,應該不會沒事亂 open 然後去惡搞自己或自己的同事吧。事實上,Rails 本身也正是利用這個特性,讓程式碼的可讀性變得更好,例如:

puts 3.days.ago    # => Wed, 21 Dec 2016 12:06:13 UTC +00:00
puts 10.megabyte   # => 10485760

這樣不是很酷嗎 :)

一加一不等於二?

還記得在前面有提到,1 + 2 其實是「1 這個數字物件,呼叫了 + 方法,並且把 2 這個數字物件當做參數傳給它」,事實上,open class 不僅能幫原來的類別加功能,連原來即有的行為都可以改掉:

class Integer
  def +(n)
    1000
  end
end

puts 1 + 2   #=> 得到 1000
puts 3 + 4   #=> 得到 1000

我在 Integer 類別裡重新定義了 + 方法,讓它總是回傳數字 1000,這樣一來,不管是 1 + 2 或是 3 + 4,答案都會是 1000。

這樣其實沒什麼用,不過可以再做一點事情,讓原本的 + 還是能算出它原本的答案,但又還可以做些額外的事:

class Integer
  alias :original_plus :+

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

puts 1 + 2
puts 3 + 4

在這裡我使用了 Ruby 內建的 alias 方法把原本的 + 方法加個別名 original_plus,然後再新定義的 + 方法裡,再呼叫它原本的算法。執行之後就會發現計算結果跟原本的 + 是一樣的,但會偷偷多印了 hey hey hey 字樣在畫面上。至於這個 hey hey hey 可以做什麼,就看大家的想像力了。

Ruby 是個設計上很特別也很彈性的程式語言,不僅很多東西都不是它看起來的樣子(例如 + 號竟然只是個方法),連內建的方法如果不喜歡都可以直接改掉(例如把 + 方法的結果改掉)。

模組(Module)

如果我有一個小貓類別,我想要這個小貓類別有飛行功能,你會怎麼做?

  1. 直接寫一個有飛行功能的小鳥類別,然後再叫小貓類別去繼承它?
  2. 直接把飛行功能寫在小貓類別裡?

「繼承」的概念是:

我只要讓小貓類別去繼承小鳥類別就好啦,反正小鳥會飛,所以繼承之後的小貓就會飛了!

但第 1 種做法的設計有點怪怪的,好好的貓不當,為什麼要去當鳥?為了想要有飛行功能就去當別人家的小孩。第 2 種做法看來似乎可行,但如果之後又有「我希望我的這個小狗類別也會飛!」的需求,那這樣又得在小狗類別裡寫一段飛行功能,飛行功能的程式碼沒辦法共用。

這時候,模組就可以派上用場了。

飛行模組

在 Ruby 定義模組,使用的是 module 這個關鍵字:

module Flyable
  def fly
    puts "I can fly!"
  end
end

寫起來的手感跟類別一樣,連模組名字的規定也跟類別一樣,必須是常數(也就是大字英文字母開頭)。模組定義好了之後,如果要把它拿來用,只要用 include 這個方法:

class Cat
  include Flyable
end

kitty = Cat.new
kitty.fly        # => I can fly!

把這個飛行模組掛上去,然後小貓就會飛了!之後如果小狗類別也想要有飛行功能的話,只要這樣一行:

class Dog
  include Flyable
end

小狗也會飛了。

類別跟模組寫起來好像?

如果你注意到,在寫類別或模組的時候,除了一個是用 class,另一個是用 module,其它幾乎沒什麼差別。事實上,在 Ruby 裡,「類別」的上層類別就是「模組」,開 IRB 實驗一下:

$ irb
>> Class.superclass
=> Module

既然類別跟模組之間是繼承關係,讓我們來看看這兩個類別之間的差別:

$ irb
>> Class.instance_methods - Module.instance_methods
=> [:new, :allocate, :superclass]

可以發現身為「後代」的 Class 類別,比 Module 類別多了 3 個方法,就是因為 Module 類別少了這 3 個方法,所以它跟 Class 最大的差別,就是:

  1. 模組沒辦法 new 一個新的實體出來。
  2. 模組沒辦法繼承別的模組。

除此之外,模組跟類別在本質上沒什麼太大的差別。

要用繼承還是要用模組?

如果你發現你要做的這個功能,它可能在很多不同體系的類別裡都會用得到,那你可以考慮把功能寫在模組裡,需要的時候再 include 進來即可。但如果你還是不知道到底類別跟模組有什麼差別,我再舉二個例子。

不知道大家有沒看過火影忍者這部漫畫,漫畫裡的主人公之一,宇智波佐助,因為他們家族血統的關係,他寫輪眼這個功能是天生就有的,你可以想像他這個功能算是從他的家族「繼承」來的。而佐助的老師,旗木卡卡西,他雖然也有寫輪眼功能,但他的寫輪眼並非繼承來的,事實上是他在年輕時候 include 了某位朋友的寫輪眼模組,所以才有這個效果。

另一個例子,海賊王漫畫裡,主角魯夫本來是普通人,但在偶然的機會下,他 include 了橡膠果實之後,他就有了橡膠人的能力了,並不是因為他老爸是橡膠人所以他才是橡膠人。

在 Rails 專案中,模組用在哪些地方?

在 Rails 專案中有不少地方有用到模組,常見主要是用在 View 的 Helper 以及 Model 跟 Controller 的 Concern,這待到後面的 Rails 章節再做詳細的介紹。

← 上一章:方法與程式碼區塊(block) 下一章:使用套件(Gem)讓開發更有效率 →

Comments