15. 什麽是物件導向?
所謂的物件(Object)就是指一個帶有狀態和方法的容器,例如以下是 JavaScript 中的自定義物件:
var my_object = {
name: "ihower",
move: function(){
console.log( this.name + " is moving" )
}
}
my_object.move()
my_object
有兩個屬性,一個是 name
一個是 move
,而 move
其實是一個匿名函式。這個物件有自己的狀態(name
是 ihower),有自己的方法(move)。我們把相關的數據和方法,一起包進物件裡面。
my_object.move()
可以看成是朝物件 my_object
發送一個 move
訊息。接收者 my_object
接到一個 move
訊息。
物件導向(Object-Oriented)程式設計指的就是以物件為基礎的編程方式,整個軟體就是一群物件之間的互動。
為什麽要用物件導向?
在上一章節中,算法和資料結構強調的是正確性和高效率。但是在物件導向要追求的是軟體的擴充性、維護性、修改彈性、可讀性、可測性,是一種將代碼適當安排組織的一種設計方式。
Ruby 是一個非常物件導向思維的程式語言,在 Ruby 中其實每一個數據其實都是物件。
Class-based 基於類別的物件導向設計
主流程式語言中,只有 JavaScript 可以像剛剛一樣直接創造一個自定義物件,其他程式語言包括 Ruby,都是要先定義類別(Class-based)才能創造物件。所謂的類別(Class)就是去定義了某一種類型的物件所擁有的屬性和方法。類別是抽象的事物,而不是其所描述的特定物件。以下的 Ruby 代碼先定義了 Person
類,然後再用 new
方法創造出兩個物件 p1
和 p2
:
class Person
attr_accessor :name
def move
puts "Hello, #{@name}"
end
end
p1 = Person.new
p1.name = "ihower"
p1.move # 輸出 Hello, ihower
p2 = Person.new
p2.name = "John"
p2.move # 輸出 Hello, John
你可以想像類別(Class)就是物件(Object)的模版,這些物件都有 name
屬性和一個 move
方法。
名詞釋疑:除了 Object (物件)的講法,這些由類產生出來的 Object,又叫做 Instance (實例)
16. Ruby 語法說明
讓我們完整解說一下 Ruby 中類別的語法:
class Person
def initialize(name)
@name = name
end
def say(word)
puts "#{word}, #{@name}"
end
end
p1 = Person.new("ihower")
p2 = Person.new("ihover")
p1.say("Hello") # 輸出 Hello, ihower
p2.say("Hello") # 輸出 Hello, ihover
- 類別一定是大寫開頭,也是一種常數
initialize
是物件的建構方法,當調用new
的時候的會把參數傳進這裡@
開頭的變數,也就是範例中的@name
叫做物件變數(instance variable)。這個是物件的內部狀態。def
會定義物件的方法
class Person
@@name = “ihower”
def self.say
puts @@name
end
end
Person.say # 輸出 ihower
- 兩個
@@
開頭的變數,也就是@@name
叫做類別變數(class variable),這個是屬於類別的 - 用
self.def
開頭定義的方法,也就是def self.say
叫做類別方法。用Person.say
就會調用。
另外,物件變數(@開頭)和類變數(@@開頭),都是封裝在類別內部,類別外無法存取。都需透過定義方法才可以存取到。
例如:
class Person
def initialize(name)
@name = name
end
end
p = Person.new('ihower')
p.name
# NoMethodError 會出錯
p.name='peny'
# NoMethodError 會出錯
需要定義存取的方法,一個讀、一個寫:
class Person
def initialize(name)
@name = name
end
def name
@name
end
def name=(name)
@name = name
end
end
p = Person.new('ihower')
p.name
=> "ihower"
p.name = "peny"
=> "peny"
attr_* 用法
由於定義存取方法很常見,Ruby 提供了 attr_accessor
、attr_reader
、attr_writer
等類別方法幫我們定義:
class Person
attr_accessor :name
attr_reader: foo
attr_writer: bar
end
其中 attr_accessor :name
等同於剛剛我們自定義的存取方法。attr_reader
只定義讀、attr_writer
只定義寫。
public/protected/private 方法
物件方法還有分 public(公開)、protected 和 private 不同,預設是 public。
class MyClass
def public_method
private_method # 調用 private 方法
self.protected_method # 調用 protected_method 方法,self. 可加可不加
end
private # 以下定義的都是 private 方法
def private_method
end
protected # 以下定義的都是 protected 方法
def protected_method
end
end
m = MyClass.new
m.public_method
m.private_method
# NoMethodError: private method `private_method' called 會出錯
m.protected_method
# NoMethodError: protected method `protected_method' called 會出錯
你不能對物件 m
調用 privated_method 或 protected_method。這兩個方法只能在內部調用。
那 private 和 protected 有什麽差別? protected 允許調用同一類的物件。private 則嚴格限制在內部調用。
為什麽物件導向語言會設計這個功能呢?這是因為我們希望管控有哪些 public 方法。這些 public 是公開的 API 會給程序員調用的,如果有任何修改都會影響到軟體的其他地方要一起修改。但是 private 但 protected 方法就只會影響這個物件的內部而已。
17. 特性一: 封裝(encapsulation)
物件導向的其中一個特色就是封裝,調用者不需要關心內部結構,只需根據公開接口進行操作。程式只依賴物件的公開接口,而不依賴內部結構。這樣內部的結構可以根據架構需求而修改,而不會影響到其他程式。
我們看一個沒有封裝的例子,分數相加:
# 設計處理分數的相加,假設分子是 x、分母是 y
def add_rational_numerator(x1, y1, x2, y2)
x1*y2 + x2*y1
end
def add_rational_denominator(x1, y1, x2, y2)
y1*y2
end
# 2/3 + 3/4
x1 = 2
y1 = 3
x2 = 3
y2 = 4
answer_x = add_rational_numerator(x1, y1, x2, y2)
answer_y = add_rational_denominator(x1, y1, x2, y2)
puts "#{answer_x}/#{answer_y}"
分子、分母、算分母的方法、算分子的方法,都是分開的。
如果改用物件導向來寫,首先定義分數的類:
class MyRational
attr_accessor :x, :y
def initialize(x, y)
@x, @y = x, y
end
def add(target)
MyRational.new(@x*target.y + @y*target.x, @y*target.y)
end
end
# 2/3 + 3/4
a = MyRational.new(2,3)
b = MyRational.new(3,4)
c = a.add(b)
puts "#{c.x}/#{c.y}"
於是有關分數的數據和方法,都被一起封裝到物件裡面去了。這樣就很清楚它們是相關的。
18. 特性二: 繼承(inheritance)
繼承可以讓父類的定義都複製到子類,在 Ruby 中用 class Child < Parent
符號表示:
class Vehicle
def move
puts "vehicle move"
end
end
class Car < Vehicle
def foo
puts "car foo"
end
end
class Bike < Vehicle
# overwrite
def move
puts "special bike move"
end
def foo
puts "bike foo"
end
end
car = Car.new
bike = Bike.new
car.move()
bike.move()
car.foo()
bike.foo()
Vehicle
類定義了 move
方法,而 Car
跟 Bike
都繼承自 Vehicle
,因此他們也都有 move
方法。但是在 Bike
中你也可以復寫掉 move
方法。
在 Rails 中也很常見繼承,打開任一個 Model 檔案,都是繼承自 ApplicationRecord
。再打開會發現 ApplicationRecord
繼承自 ActiveRecord::Base
。後者是 Rails 框架的核心類,我們之所以可以調用 .save
、.find
、.where
等等方法就是在 ActiveRecord::Base
中定義的。。
打開任一個 Controller 檔案,都是繼承自 ApplicationController
,然後 ApplicationController
又是繼承自 ActionController::Base
,我們之所以可以調用 before_action
、render
、redirect_to
等等方法就是在 ActionController::Base
中定義的。
透過繼承,我們寫的 Model 和 Controller 都是基於 Rails 已經寫好的功能進行擴充而已。
多重繼承
上述的寫法只能單一繼承,也就是只能有一個父類。如果有多個父類要繼承,在 Ruby 中會用到 module:
module Ownership
def show_owner
puts "#{self.class} show_owner called"
end
end
class Vehicle
def move
puts "move"
end
end
# Car has two parents: Car and Ownership
class Car < Vehicle
include Ownership
end
class House
include Ownership
end
car = Car.new
house = House.new
car.show_owner()
house.show_owner()
其中 module Ownership
會用 include
的語法 mix-in(混入)到 Car
裡面。
那 class 跟 module 有什麽不一樣?你不能實例化 module,也就是不能對 module 調用
new
方法來產生物件。
module 命名空間
順道一提 module 的另一個用途,就是拿來做命名空間,也就是讓常數的命名長一點避免撞名:
module A
class B
end
end
這個被 module A 包起來的 class B,如果要使用它要用 A::B
。
如果 module A 已經定義過了,則可以這樣寫:
class A::C
end
假如在 module A 裡面定義了一個跟最外層撞名的類別,這時候如果要拿外層的類別,需要加上 ::
符號:
class Person
end
module A
class Person
def foo
Person # 這個會是指 A::Person
::Person # 前面要加 :: 表示要拿最外層的 Person
end
end
end
最後,可以用 module 來定義模塊方法:
module MyUtil
def self.foobar
puts "foobar"
end
end
MyUtil.foobar
# 輸出 foobar
Ruby 的 Math API 就是長這種形式。
19. 特性三: 多型(polymorphism)
多型的意思是可以把很多不一樣的東西,當作同一種東西來處理。例如箱子有很多種,打開的實作方式各有不同(有的有鎖、有的沒鎖),但是這些箱子都有提供「打開」這個接口可以操作。下命令的人只需要知道呼叫這個指令即可。
先來示範一個不是物件導向的設計:
box1 = { :name => "Box1", :type => "locked" }
box2 = { :name => "Box2", :type => "unlocked"}
box3 = { :name => "Box3", :type => "seal" }
def open_box(box)
if box[:type] == "locked"
puts "Open locked"
elsif box[:type] == "unlocked"
puts "Open unlocked"
elsif box[:type] == "seal"
puts "Open Seal"
end
end
arr = [box1, box2, box3]
arr.each do |x|
open_box(x)
end
上述的代碼中,有一個很厲害的 open_box
方法,在裡面會判斷不同的箱子調用不同的輸出。這樣設計的缺點是不好擴充,維護性低。因為無論是新增不同的箱子,或是修改某一個箱子打開的行為,都得修改同一個方法,複雜度全部集中在 open_box
之中。
讓我們改用物件導向來寫:
class Box
attr_accessor :name
def initialize(name)
@name = name
end
def open
puts "Open default box"
end
end
class LockedBox < Box
def open
puts "Open locked"
end
end
class UnlockedBox < Box
def open
puts "Open Unlocked"
end
end
class SealBox < Box
def open
puts "Open seal"
end
end
box1 = LockedBox.new("Box1")
box2 = UnlockedBox.new("Box2")
box3 = SealBox.new("Box3")
arr = [box1, box2, box3]
arr.each do |x|
x.open()
end
代碼看起來好像變多了,但是擴充性和維護性比較好。因為如果要新增不同的箱子,只需要新增類別即可,不需要改到本來的代碼。沒有多型的話,單一函數就會充滿根據數據類型的判斷的 if-else,變得難以擴充。這種多型的特型讓我們不需要擔心確切的數據類型,只要接口一致(都有 open 方法)就可以操作
鴨子型別 Duck Typing
在動態語言中,不同物件只要方法的接口一樣,就可以有多型的特型,這又叫做 Duck Typing:當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麽這只鳥就可以被稱為鴨子。
名詞釋疑:方法的「接口(Interface)」指的是方法的名稱和參數,方法的「實作(Implement)」指的是方法內實際要做的代碼。上述的 LockedBox、UnlockedBox 和 SealBox 都有一樣的 open 接口,但是子類別中定義了不同的實作。
剛剛的 Box 例子是透過繼承來達成多型,因為所有繼承自 Box 的子類別,都一定有 open 方法,自然是多型的。
20. 物件導向設計
到目前為止我們只是基本瞭解定義類別的語法,以及認識了物件導向的三個特性。但是面對一個複雜的軟體好像還是不知道怎麽去設計要有哪些類別?
別擔心,作為應用程式開發的初學者,我們絕大部分需要的類別設計,都由應用程式框架,也就是 Rails 提供了,只要照著框架的規範寫程式即可,一開始不太需要自己設計全新的類別。隨著大型專案的需求,才會逐步需要這方面的知識進行更好的程式架構調整。
這種不依賴框架,自己定義的物件,又叫作 Plain-Old Ruby Object
物件導向設計又是一門學問,有興趣的學員,可以朝以下參考資源搜尋:
- 物件導向設計實踐指南: Ruby 語言描述 人民郵電
- SOLID OO 設計原則
- Design Patterns 設計模式,最有名的即 GoF patterns。設計模式針對了特定的情境,提供設計解法(通常是如何設計你的類別),並且「命名」這些模式讓程序員可以方便溝通和當作命名的元素。另一方面也是提供一種可以臨摹的設計範例。常見的設計模式包括 Factory, Adapter, Composite, Decorator, Iterator, Observer 等等
例如以下是一個 Strategy Pattern 範例:情境是我們想要設計一個通用的數據輸出功能,並且允許我們隨時可以抽換不同的輸出方式,例如 XML 或 JSON。
class AwesomeFormatter
attr_accessor :data, :formatter
def initialize(data, formatter)
self.data = data
self.formatter = formatter
end
def output
puts self.formatter.output( self.data )
end
end
class XMLFormatter
def output(data)
"<data>#{data}</data>"
end
end
class JSONFormatter
def output(data)
"{ data: #{data} }"
end
end
formatter = AwesomeFormatter.new( "ihower", XMLFormatter.new )
formatter.output() # 輸出 XML 格式
formatter.formatter = JSONFormatter.new # 動態更換不同的輸出策略
formatter.output() # 輸出 JSON 格式
在建構 AwesomeFormatter.new
時,第二個參數就是不同的策略,其中 formatter
屬性就代表不同的輸出方式。
透過這個 Strategy Pattern,我們可以隨時抽換不同的輸出方式。
練習作業
請實作一個 class 是汽車,屬性有名稱 name
和型號 modal
,都是字串。以及出場年份 year
是日期。三個屬性都有存取方法。
接著實作一個方法回傳車子的年紀、一個方法是輸出格式化的字串是 “出場年份 型號 名稱”。