Ruby 物件導向設計實踐-敏捷入門
大家好,我是 Cindy,最近在整理 medium 舊的文章 Ruby 物件導向設計實踐讀書筆記,想把這篇也放在這裡,但覺得需要重新整理一下,就跟過了一陣子再看我寫的程式碼一樣,所以這篇文章就此誕生?
首先跟大家說一下,Ruby 物件導向設計實踐-敏捷入門 (Practical Object-Oriented Design in Ruby: An Agile Primer) 是一本書,中文版:Ruby 物件導向設計實踐-敏捷入門,作者是 Sandi Metz 致力推行物件導向程式設計的實踐,出了兩本書 Practical Object-Oriented Design (POODR) 和 99 Bottles of OOP,都是關於物件導向設計要如何實踐,而且最近發現 Sandi Metz 有很多場精彩的演講,大家可以去看看:
- GORUCO 2009 - SOLID Object-Oriented Design by Sandi Metz
- RailsConf 2014 - All the Little Things by Sandi Metz
- RailsConf 2015 - Nothing is Something
- RailsConf 2016 - Get a Whiff of This by Sandi Metz
- hafentalks #7 - Sandi Metz: “Go Ahead, Make a Mess”
接下來是我在閱讀這本書每一章節的筆記,我另外在下面用註解的方式針對我的筆記做說明
參考程式碼如果是 A 這種沒意義的命名請不要認真覺得要這樣寫唷,只是懶得想例子而已,記得要做有意義的命名。
大綱
物件導向的設計
物件導向設計與依賴關係管理相關
- 不受管理的依賴關係很容易造成嚴重破壞,因為物件之間彼此了解太多
依賴關係其實就是如果某個物件的修改會影響到另一個物件,我們就可以說這兩個物件具有依賴關係
- 不受管理的依賴關係很容易造成嚴重破壞,因為物件之間彼此了解太多
設計的目的是使你日後仍然可以繼續設計
設計原則
SOLID
單一職責(Single Responsibility Principle, SRP)
簡單說就是一個物件只做一件事情
開閉原則(Open-Closed Principle, OCP)
在設計已經完整的前提下只能增加程式碼,不能改既有的程式碼
里氏代替原則(Liskov Substitution Principle, LSP)
若是使用繼承,子類別實作的行為必須要與父類別或是介面所定義的行為一致,並且子類別要能夠完全取代掉父類別
介面隔離原則(Interface Segregation Principle, ISP)
No client should be forced to depend on methods it does not use.
依賴倒置原則(Dependency Inversion Principle, DIP)
簡單講是物件之間的依賴關係的處理,可參考這篇文章,雖然範例不是 Ruby,但我覺得說明的蠻有趣的,如果說看完文章想轉成 Ruby 的話最後結果類似下面這段程式碼,其中
stuffer
是抽象介面,即實際上並不存在stuffer
的類別
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class People
def eat(stuffer)
stuffer.new.stuff
end
end
class Hamburger
def stuff
p '咔拉雞腿滿福堡 好棒棒'
end
end
class Spaghetti
def stuff
p '大蒜辣椒麵 :D'
end
end
People.new.eat(Hamburger)
People.new.eat(Spaghetti)Don’t Repeat Yourself, DRY
Law of Demeter, LoD
若未進行設計 => 我可以增加這項功能,但這會把所有東西破壞。
設計具有單一職責的類別
- 程式碼應具備的特點(TRUE)
- 透明性(Transparent)-程式碼的修改結果要顯而易見
- 合理性(Reasonable)-修改的成本要跟修改後的效益成正比
- 可用性(Usable)-既有程式碼在任何時候都要保持可用
- 典範性(Examplary)-程式碼本身鼓勵為延續這些特點的修改
- 判斷方法
- 嘗試用一句話描述類別(Class),若描述中出現和、或表示不只做一件事情
- 高聚合-這個類別所做的所有事情都與其目標非常相關
- 依賴行為而非資料
資料庫的相關書籍也有提到說應用程式跟資料應該要分離,在寫程式的時候不應該依賴資料內容才對,否則會有應用程式與資料黏在一起的感覺啊,會進入越來越難寫的窘境
- 只負責單一事物的類別能夠將事物與應用程式的其他部分有所隔離
管理依賴關係
- 低耦合
- 依賴像膠水,類別和接觸到他的事物黏在一起,存在幾滴膠水是有必要的,但如果膠水太多,應用程式會凝結成堅固的一塊
- 依賴注入(dependency injection)
用這樣的方式其實就表示說當 A 和 B 必須具有依賴關係的時候,寧願讓依賴是 B 從外面丟進去 A 裡面,也不要是包在 A 裡面不容易察覺的地方
1
2
3
4
5
6
7
8
9
10
11
12
13
14class A
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def a_method
x * y.b_method
end
...
end
# B 從外面丟進去 A 裡面
A.new(x, B.new(...)) - 隔離依賴
- 隔離實例建立(當無法使用依賴注入時)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29# 第一種方式
class A
attr_reader :x
def initialize(x)
@x = x
end
def a_method
x * b.b_method
end
def b
@b ||= B.new(...)
end
...
end
# 第二種方式
class A
attr_reader :x
def initialize(x)
@x = x
@b ||= B.new(...)
end
def a_method
x * @b.b_method
end
...
end - 隔離外部訊息
1
2
3
4
5
6
7
8
9
10
11class A
...
def a_method
x * b_method
end
# 將外部訊息 b_method 隔離出來(似乎可以用委派)
def b_method
b.b_method
end
...
end
- 隔離實例建立(當無法使用依賴注入時)
- 移除參數順序依賴
- 使用 Hash
1
2
3
4
5
6
7
8
9class A
attr_reader :x, :y, :z
def initialize(args)
@x = args[:x]
@y = args[:y]
@z = args[:z]
end
...
end - 明確定義預設值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19def initialize(args)
@x = args[:x] || 5
@y = args[:y] || 10
# 如果是 boolean 下面這樣寫會有問題,全部都變成 true
@z = args[:z] || true
# 可以改使用 fetch 來寫
@z = args.fetch(:z, true)
end
# 使用 merge
def initialize(args)
args = defaults.merge(args)
@x = args[:x]
...
end
def defaults
{x: 5, y: 10}
end - 隔離多重參數初始化操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22# 當 A 是外部介面的一部分時
# 例如某個框架的東西,對我來說是不能修改的部分
module SomeFramework
class A
attr_reader :x, :y, :z
def initialize(x, y, z)
@x = x
@y = y
@z = z
end
...
end
end
# 將外部介面包裝起來
# 為某個特定類別建立實例,可以稱之為 factory,
# 當被迫無法修改外部介面時可以使用的技巧
module AWrapper
def self.a(args)
SomeFramework::A.new(args[:x], args[:y], arg[:z])
end
end
- 使用 Hash
- 選擇依賴方向
- 告訴類別他們要依賴那些變化少於他們自身的事物
- 有些類別更容易發生變化
- 具體類別比抽象類別更容易發生變化
- 修改具有許多依賴關係的類別會造成廣泛的影響
這邊可以參考上面提到的 SOLID 的 依賴倒置原則(Dependency Inversion Principle, DIP)
- 告訴類別他們要依賴那些變化少於他們自身的事物
建立靈活的介面
- 定義介面
- 公共介面
- 顯露出主要職責
- 期望被其他物件呼叫
- 不會隨便改變
- 其他物件可以放心依賴它
- 在測試裡被詳盡記錄
- 私有介面
- 要處理實作細節
- 不希望被傳送到其它物件
- 可因任何原因變化
- 其他物件不能放心依賴它
- 可能不會在測試裡被引用
- 公共介面
- 關鍵字(Ruby 裡的方法)
- public
- protected
- private
- 可參考資料:Public, Protected and Private Method in Ruby
- 詢問傳送方想要什麼而非告訴接收者如何表現
- 表示物件之間彼此信任
- Law of Demeter, LoD
- 對物件之間的傳遞進行限制:禁止將一則訊息藉由第二個不同的物件轉發給第三個物件,即只能與你的鄰近對話或只能使用一個小圓點
- 小心使用委派(delegate) - Ruby 的
delegate.rb
、forwardable.rb
,Rails 的delegate
方法
使用鴨子類型技巧降低成本
- 鴨子類型(duck typing)
- 多態性(polymorphism):許多不同物件回應相同訊息的能力,duck typing 是實作多態性的方法之一
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class A
def prepare(preparers)
preparers.each do |preparer|
preparer.prepare_something(self)
end
end
end
# 特定的 type
class B
def prepare_something(a)
...
end
end
# 特定的 type
class C
def prepare_something(a)
...
end
end
- 多態性(polymorphism):許多不同物件回應相同訊息的能力,duck typing 是實作多態性的方法之一
藉由繼承取得行為
- classical inheritance
- 繼承的核心是一種用於實作訊息自動委派的機制
- 如果程式碼中的傳送者可以說話,如果說出:我知道你是誰,因為我知道你會做什麼,這項知識是一種會增加修改成本的依賴關係
- 建立抽象父類別
這裡會寫說抽象是因為實際上我們不會去 new 一個父類別出來使用,而是會針對不同的情況 new 出不一樣的子類別,所以我們說是將行為提升至抽象,而子類別我們就會說是具體的
1
2
3
4
5
6
7
8class Father
end
class ChildrenA < Father
end
class ChildernB < Father
end - 提升抽象行為
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Father
attr_reader :share
def initialize(**args)
@share = arg[:share]
end
end
class ChildrenA < Father
attr_reader :specific
def initialize(**args)
@specific = arg[:specific]
super(args) # 子類別現在"必須"傳送 super
end
end - 從具體分離出抽象
1
2
3
4
5
6
7
8
9
10
11class Father
attr_reader :share, :method_arg1, :method_arg2
def initialize(**args)
@share = arg[:share]
# 本來兩個子類別方法中共用的參數
# 從具體的子類別中分離出來
@method_arg1 = arg[:method_arg1]
@method_arg2 = arg[:method_arg2]
end
end - 使用範本方法模式(template method pattern)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30class Father
attr_reader :share, :method_arg1, :method_arg2
def initialize(**args)
@share = arg[:share]
@method_arg1 = arg[:method_arg1] || default_arg1
@method_arg2 = arg[:method_arg2] || default_arg2
end
# 共同的預設值
def default_arg1
'10-arg1'
end
end
class ChildrenA < Father
...
# 子類別的預設值
def default_arg2
'23'
end
end
class ChildernB < Father
...
# 子類別的預設值
def default_arg2
'2.1'
end
end - 實作所有的範本方法(template method)
- 將子類別的方法寫進父類別中,即使是不做事也要實作該方法,讓工程師知道繼承這個類別時一定要實作哪些方法
1
2
3
4
5
6
7
8
9
10class Father
...
# 只要在當下稍微用心一點,
# 建立出在失敗時帶有合理錯誤訊息的程式碼,
# 就能夠得到永久性的益處
def default_arg2
raise NotImplementedError,
"This #{self.class} cannot respond to:"
end
end
- 將子類別的方法寫進父類別中,即使是不做事也要實作該方法,讓工程師知道繼承這個類別時一定要實作哪些方法
- 父子間的耦合管理
- 緊密耦合的類別會黏再一起,並且可能無法單獨修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class ChildrenA < Father
attr_reader :specific
def initialize(**args)
@specific = arg[:specific]
super(args) # 子類別現在"必須"傳送 super
# 這個 super 造成父子之間的耦合
# 強迫子類別知道如何與其抽象父類別互動
# 將演算法的知識下放到子類別裡
# 導致程式碼在多個子類別中重複
# 並且需要所有子類別在完全相同的地方傳送 super
end
# 父類別有一樣的方法
# 同上形成耦合
def other
super.merge(hash)
end
end - 使用鉤子(hook)訊息解耦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27class Father
attr_reader :share, :method_arg1, :method_arg2
def initialize(**args)
@share = arg[:share]
@method_arg1 = arg[:method_arg1] || default_arg1
@method_arg2 = arg[:method_arg2] || default_arg2
# 提供子類別使用
post_initialize(args)
end
# 實作方法,但不做事
def post_initialize(args)
nil
end
end
class ChildrenA < Father
...
# 子類別可選擇性覆蓋這個方法
# 子類別不再控制初始化
# 將特殊化提供給更大型的抽象演算法
def post_initialize
@specific = arg[:specific]
end
end1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Father
...
def other
{a: 'a', b: 'b'}.merge(local_other)
end
# 用於子類別覆蓋的 hook
def local_other
{}
end
end
class ChildrenA < Father
...
# 不用強迫子類別知道父類別實作了 other 的方法
def local_other
{c: 'c'}
end
end - 使用 hook 方法可以讓繼承者不用強迫傳送 super,並且還能提供特殊化內容
- 緊密耦合的類別會黏再一起,並且可能無法單獨修改
使用模組共用角色行為
- 理解物件所扮演的角色,找出隱藏角色,建立程式碼,以便在多個扮演者之間共用行為,同時要最小化其中所產生的依賴關係
- ruby 的模組(module)
- 撰寫技巧與繼承相似,但模組更在乎的是像什麼,而繼承是是什麼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21# https://github.com/skmetz/poodr2/blob/master/7_10.rb
module Schedulable
attr_writer :schedule
def schedule
@schedule ||= Schedule.new
end
def schedulable?(starting, ending)
!scheduled?(starting - lead_days, ending)
end
def scheduled?(starting, ending)
schedule.scheduled?(self, starting, ending)
end
# 包含者可以加以覆蓋
def lead_days
0
end
end
- 撰寫技巧與繼承相似,但模組更在乎的是像什麼,而繼承是是什麼
- 找尋方法的順序
- 單例類別(Singleton class 只在這個 instance 所定義的方法)->模組(extend instance 的 module 所定義的方法)->類別->類別包含的模組->父類別->父類別包含的模組->Object …
- Ruby 的繼承鍊 (1) - 如何實踐物件導向
- Ruby 的繼承鍊 (2) - Module 的 include、prepend 和 extend
- 抽象父類別裡的所有程式碼都應該適用於每個繼承他的類別,父類別不應該包含只適用於部分(而非全部)子類別的程式碼,這項限制也同樣應用在模組上:模組裡的程式碼必須也能夠一併適用於包含他的所有事物
- 里氏代替原則(Liskov Substitution Principle, LSP):子類型必須能夠代替他們的父類型
組合物件
- composition
1
2
3
4
5
6
7
8
9
10
11
12
13
14require 'forwardable'
class Parts
extend Forwardable
def_delegators :@parts, :size, :each
include Enumerable
def initialize(parts)
@parts = parts
end
def spares
select {|part| part.needs_spare}
end
end1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19# Struct 接收的是按順序排列的初始化參數,
# 而 OpenStruct 在初始化時則是接收一個 Hash
require 'ostruct'
module PartsFactory
def self.build(config, parts_class = Parts)
parts_class.new(
config.collect {|part_config|
create_part(part_config)})
end
def self.create_part(part_config)
OpenStruct.new(
name: part_config[0],
description: part_config[1],
needs_spare: part_config.fetch(2, true))
end
end
# https://github.com/skmetz/poodr/blob/master/chapter_8.rb#L422 - 組合允許物件之間的結構獨立性,其代價是需要明確進行訊息委派
- 如果問題可以使用組合技巧解決,應該盡可能使用組合,如果無法明確保證繼承是一種更好的解決方案,要用組合,因為組合的依賴關係比繼承少許多
- 選擇關係:
- 將繼承用於是什麼的關係
- 將 duck typing 用於表現得像什麼的關係
- 思考角色最明確的方法是從外部,以角色扮演者的持有者作為觀點
- 將組合用於含有什麼的關係
設計節省成本的測試
- 測試的意圖
- 找出錯誤
- 提供文件
- 抱著假設自己將來會得健忘症一樣來撰寫測試
- 延後設計決定
- 支持抽象
- 除非程式碼有測試,否則會出現一層幾乎無法安全做出任何修改的設計抽象
- 暴露出設計缺陷
- 如果一項測試需要麻煩的設定,就表示程式碼期望過多的上下文
- 如果測試某個物件會將一大堆的其他物件捲進來,這表示程式碼有著大量的依賴關係
- 如果測試難以撰寫,那麼其他物件也將會發現這段程式碼難以重複使用
- 測試的內容
- 所有事物只測試一次,並且要在適當的地方進行
- 將每個物件當成一個黑盒子
- 針對定義在公共介面的訊息撰寫測試
- 輸入訊息應該測試其傳回狀態,輸出的命令訊息(command)應該測試是否被傳送(行為測試),而輸出的查詢訊息(query)則不應該被測試。
- 測試的方法
- 由外向內的 BDD
- 由內向外的 TDD
- 不要測試沒有依賴關係的輸入訊息,而是刪除它:刪除未使用的程式碼能夠立即節省成本,保留未使用的程式碼比刪除之後再恢復他們所花費的成本更高