Ruby 物件導向設計實踐讀書筆記

Ruby 物件導向設計實踐-敏捷入門 (Practical Object-Oriented Design in Ruby: An Agile Primer)

Cindy Liu
15 min readMay 2, 2020

這篇文章有更新到我新的 Blog ,歡迎大家過去看看!!

這是一篇筆記,需要放在心中的概念。

當我念完這本書後,重新再看一次把重點整理起來,希望自己學到各種方法以及觀念後,可以更知道在什麼樣的場合有什麼樣的寫法,可以去思考如何寫可以更好,並實踐它。

這本書:https://amzn.to/3fmWVGq
這本書的範例程式碼:
https://github.com/skmetz/poodr
https://github.com/skmetz/poodr2

大綱:

物件導向的設計
設計具有單一職責的類別
管理依賴關係
建立靈活的介面
使用鴨子類型技巧降低成本
藉由繼承取得行為
使用模組共用角色行為
組合物件
設計節省成本的測試

物件導向的設計

物件導向設計與依賴關係管理相關

  • 不受管理的依賴關係很容易造成嚴重破壞,因為物件之間彼此了解太多
  • 設計的目的是使你日後仍然可以繼續設計

設計原則

SOLID

  • 單一職責(Single Responsibility Principle, SRB)
  • 開閉原則(Open-Closed Principle, OCP)
  • 里氏代替原則(Liskov Substitution Principle, LSP)
  • 介面隔離原則(Interface Segregation Principle, ISP)
  • 依賴倒置原則(Dependency Inversion Principle, DIP)

Don’t Repeat Yourself, DRY

Law of Demeter, LoD

若未進行設計 => 我可以增加這項功能,但這會把所有東西破壞。

設計具有單一職責的類別

程式碼應具備的特點(TRUE)

  • 透明性(Transparent)-程式碼的修改結果要顯而易見
  • 合理性(Reasonable)-修改的成本要跟修改後的效益成正比
  • 可用性(Usable)-既有程式碼在任何時候都要保持可用
  • 典範性(Examplary)-程式碼本身鼓勵為延續這些特點的修改

判斷方法

  • 嘗試用一句話描述類別(Class),若描述中出現表示不只做一件事情
  • 高聚合-這個類別所做的所有事情都與其目標非常相關

依賴行為而非資料

只負責單一事物的類別能夠將事物與應用程式的其他部分有所隔離

管理依賴關係

  • 低耦合
  • 依賴像膠水,類別和接觸到他的事物黏在一起,存在幾滴膠水是有必要的,但如果膠水太多,應用程式會凝結成堅固的一塊

依賴注入(dependency injection)

class 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(...))

隔離依賴

  • 隔離實例建立(當無法使用依賴注入時)
# 第一種方式         
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
  • 隔離外部訊息
class A
...
def a_method
x * b_method
end
# 將外部訊息 b_method 隔離出來 (似乎可以用委派)
def b_method
b.b_method
end
...
end

移除參數順序依賴

  • 使用 Hash
class A
attr_reader :x, :y, :z
def initialize(args)
@x = args[:x]
@y = args[:y]
@z = args[:z]
end
...
end
  • 明確定義預設值
  def 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
  • 隔離多重參數初始化操作
# 當 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

選擇依賴方向

告訴類別他們要依賴那些變化少於他們自身的事物

  • 有些類別更容易發生變化
  • 具體類別比抽象類別更容易發生變化
  • 修改具有許多依賴關係的類別會造成廣泛的影響

建立靈活的介面

定義介面

公共介面

  • 顯露出主要職責
  • 期望被其他物件呼叫
  • 不會隨便改變
  • 其他物件可以放心依賴它
  • 在測試裡被詳盡記錄

私有介面

  • 要處理實作細節
  • 不希望被傳送到其它物件
  • 可因任何原因變化
  • 其他物件不能放心依賴它
  • 可能不會在測試裡被引用

關鍵字(Ruby 裡的方法)

詢問傳送方想要什麼而非告訴接收者如何表現

表示物件之間彼此信任

Law of Demeter, LoD

  • 對物件之間的傳遞進行限制:禁止將一則訊息藉由第二個不同的物件轉發給第三個物件,即只能與你的鄰近對話只能使用一個小圓點
  • 小心使用委派(delegate) — Ruby 的 delegate.rbforwardable.rb,Rails 的 delegate 方法

使用鴨子類型技巧降低成本

  • 鴨子類型(duck typing)
  • 多態性(polymorphism):許多不同物件回應相同訊息的能力,duck typing 是實作多態性的方法之一
class 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

藉由繼承取得行為

classical inheritance

  • 繼承的核心是一種用於實作訊息自動委派的機制

如果程式碼中的傳送者可以說話,如果說出:我知道你是誰,因為我知道你會做什麼,這項知識是一種會增加修改成本的依賴關係

建立抽象父類別

class Father
end

class ChildrenA < Father
end

class ChildernB < Father
end

提升抽象行為

class 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

從具體分離出抽象

class 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)

class 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)

  • 將子類別的方法寫進父類別中,即使是不做事也要實作該方法,讓工程師知道繼承這個類別時一定要實作哪些方法
class Father 
...
# 只要在當下稍微用心一點,
# 建立出在失敗時帶有合理錯誤訊息的程式碼,
# 就能夠得到永久性的益處
def default_arg2
raise NotImplementedError,
"This #{self.class} cannot respond to:"
end
end

父子間的耦合管理

  • 緊密耦合的類別會黏再一起,並且可能無法單獨修改
class 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)訊息解耦

class 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
end
  • 使用 hook 方法可以讓繼承者不用強迫傳送 super,並且還能提供特殊化內容
class 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

使用模組共用角色行為

理解物件所扮演的角色,找出隱藏角色,建立程式碼,以便在多個扮演者之間共用行為,同時要最小化其中所產生的依賴關係

ruby 的模組(module)

  • 撰寫技巧與繼承相似,但模組更在乎的是像什麼,而繼承是是什麼
# 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

找尋方法的順序

抽象父類別裡的所有程式碼都應該適用於每個繼承他的類別,父類別不應該包含只適用於部分(而非全部)子類別的程式碼,這項限制也同樣應用在模組上:模組裡的程式碼必須也能夠一併適用於包含他的所有事物

里氏代替原則(Liskov Substitution Principle, LSP):子類型必須能夠代替他們的父類型

組合物件

  • composition
require '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
end
# 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 用於表現得像什麼的關係

思考角色最明確的方法是從外部,以角色扮演者的持有者作為觀點

  • 將組合用於含有什麼的關係

設計節省成本的測試

測試的意圖

  1. 找出錯誤

2. 提供文件

抱著假設自己將來會得健忘症一樣來撰寫測試

3. 延後設計決定

4. 支持抽象

除非程式碼有測試,否則會出現一層幾乎無法安全做出任何修改的設計抽象

5. 暴露出設計缺陷

  • 如果一項測試需要麻煩的設定,就表示程式碼期望過多的上下文
  • 如果測試某個物件會將一大堆的其他物件捲進來,這表示程式碼有著大量的依賴關係
  • 如果測試難以撰寫,那麼其他物件也將會發現這段程式碼難以重複使用

測試的內容

  • 所有事物只測試一次,並且要在適當的地方進行
  • 將每個物件當成一個黑盒子
  • 針對定義在公共介面的訊息撰寫測試

輸入訊息應該測試其傳回狀態,輸出的命令訊息(command)應該測試是否被傳送(行為測試),而輸出的查詢訊息(query)則不應該被測試。

測試的方法

  • 由外向內的 BDD
  • 由內向外的 TDD

不要測試沒有依賴關係的輸入訊息,而是刪除它:刪除未使用的程式碼能夠立即節省成本,保留未使用的程式碼比刪除之後再恢復他們所花費的成本更高

--

--