rails 讀書會 - POODR - Ch3 管理相依性
2015-08-07 08:54:24

物件導向能有效的解決問題是因為它反應了真實世界的模型,而就如同現實中的物件,它們之間不可避免的一定會產生一些互動。

class的method可以區分成三類:

  • class本身實作的method
  • class經由繼承得知的method
  • class呼叫其它class實作的method

設計良好的物件應該都要遵循single responsibility,因此很自然的必須互相合作來完成複雜的問題,合作就必須知道彼此,知道彼此就會產生相依性。

了解相依性

相依性的定義:一個物件相依於另一個物件表示當其中一個物件改變時,另一個物件可能會被強迫也跟著改變。

範例程式碼參數示意圖
範例程式碼參數示意圖
class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog       = cog
    @rim       = rim
    @tire      = tire
  end

  def gear_inches
    ratio * Wheel.new(rim, tire).diameter
  end

  def ratio
    chainring / cog.to_f
  end
# ...
end

class Wheel
  attr_reader :rim, :tire
  def initialize(rim, tire)
    @rim = rim
    @tire = tire
  end

  def diameter
    rim + (tire * 2)
  end
# ...
end

Gear.new(52, 11, 26, 1.5).gear_inches

一個物件有相依性,表示它知道:

  • 另一個物件的「存在」,也就是知道物件的名稱。
  • 另一個物件的「接受訊息的名稱」。
  • 另一個物件的「接受訊息」所需要的「參數」。
  • 另一個物件的「接受訊息」所需要的「參數」的「順序」。

以程式的講法,一個class有相依性,表示它知道:

  • 另一個class的「存在」,也就是知道class的名稱。
  • 另一個class的「method的名稱」。
  • 另一個class的「method」所需要的「參數」。
  • 另一個class的「method」所需要的「參數」的「順序」。

上面的範例大部分的相依性其實都沒有必要,不必要的相依性會讓程式碼顯的不合理。因為小幅度的修改可能會被迫造成程式其它許多地方的修改,牽一髮而動全身。而這部分的設計挑戰,就是要盡可能的讓相依性減少,一個類別應該只需要知道足夠做好事情的資訊就好。

物件之間的耦合 Coupling Between Objects (CBO)

耦合 = 相依性,相依性越高 = 耦合程度就越高 = 多個物件會被視做同一個個體而很難切割或重複利用

其它的相依性 Other Dependencies

  • 訊息鏈(message chaining):一個物件知道其它物件知道什麼,也就是知道其它物件傳遞的訊息。- Ch 4. 建立有彈性的界面 (Creating Flexible Interface)

user_phone = user.profile.contact.phone

  • 程式與測試之間的相依性:當程式在做重構或是修改時,其對應的測試被迫也要跟著修改。- Ch 9 設計有效率的測試 (Design Cost-Effective Tests)

撰寫低耦合的程式 Writing loosely Coupled Code

插入相依性 Inject Dependencies

class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog       = cog
    @rim       = rim
    @tire      = tire
  end

  def gear_inches
    ratio * Wheel.new(rim, tire).diameter
  end

  def y_pos
    Wheel.new(rim, tire).diameter / 2
  end

  def ratio
    chainring / cog.to_f
  end
end

Gear.new(52, 11, 26, 1.5).gear_inches

缺點:

  • 當修改Wheel class的名稱時,Gear的gear_inches與y_pos也必須跟著修改。
  • 輪子鋼圈大小(rim)與輪胎厚度(tire)只用在初始化Wheel的instance,Gear卻必須儲存這兩個變數,違反單一責任原則。

改進方式:

  • 將產生相依性的地方抽離出來,利用參數的方式插入到所需的class中。

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, wheel)
    @chainring = chainring
    @cog       = cog
    @wheel     = wheel
  end

  def gear_inches
    ratio * wheel.diameter
  end

  def y_pos
    wheel.diameter / 2
  end

  def ratio
    chainring / cog.to_f
  end
end

Gear.new(52, 11, Wheel.new(26, 1.5)).gear_inches

優點:

  • 上面的缺點都不見了。
  • 增加了彈性,Gear在計算gear_inches的時候,不限定一定要是Wheel的instance,而是任何有diameter這個method的class都可以傳入,這就是一個鴨子型態的運用。 - Ch 5 使用鴨子型態減少開發成本 (Reduce Cost with Duck Typing)

補充:

  • instance的初始化一直是經典的相依性問題,在design pattern中,甚至有一個專門的Factory pattern就是為了要處理這類的問題。

隔離相依性 Isolate Dependencies

假如很不幸的我們沒辦法更改Gear初始化的參數,一定要傳入chaining, cog, rim, tire這些值,換句話說在Gear中無法避免Wheel instance的建立,那要怎麼修改比較好?

改進方式:

  • 將instance的建立儲存在變數中,在其它地方改採用存取變數的方式來取代直接建立instance。

class Gear
  attr_reader :chainring, :cog, :rim, :tire, :wheel
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog       = cog
    @wheel     = Wheel.new(rim, tire)
  end

  def gear_inches
    ratio * wheel.diameter
  end

  def y_pos
    wheel.diameter / 2
  end

  def ratio
    chainring / cog.to_f
  end
end

Gear.new(52, 11, 26, 1.5).gear_inches

優點:

  • 雖然無法完全除去Wheel的相依性,但減少了改變時需要修改的程度。例如當Wheel改名稱的時候,相較之前的版本必須每個Wheel.new的地方都要改,這個版本的Gear只需要改一個地方(第6行)。
  • Wheel與Gear的gear_inches/y_pos method有做適當的隔離,gear_inches/y_pos只需要知道變數wheel,而不用知道Wheel這個class。

進階版本:

class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog       = cog
    @rim       = rim
    @tire      = tire
  end

  def gear_inches
    ratio * wheel.diameter
  end

  def y_pos
    wheel.diameter / 2
  end

  def ratio
    chainring / cog.to_f
  end

  def wheel
    @wheel ||= Wheel.new(rim, tire)
  end
end

Gear.new(52, 11, 26, 1.5).gear_inches

優點:

  • 定義的wheel method讓Wheel instance建立時機點延後到呼叫gear_inches/y_pos時執行,也就是所謂的lazy loading的技巧,等到真的用到才做初始化。

隔離外部訊息 Isolate Vulnerable External Messages

缺點:

  • 上面的例子還有一個問題,gear_inches/y_pos仍知道wheel有diameter這個method,一旦diameter的名稱改了,gear_inches/y_pos也必須跟著改。

改進方式:

  • 將呼叫其它class method的部分隔離放在另一個method裡。

class Gear
  # ...
  def gear_inches
    ratio * diameter
  end

  def y_pos
    diameter / 2
  end

  def diameter
    wheel.diameter
  end
  # ...
end

優點:

  • gear_inches/y_pos現在與wheel的diameter做適當的切割,當wheel.diameter修改時,只需要改Gear的diameter即可。

消除參數順序的相依性 Managing Argument-Order Dependencies

使用Hash做為初始化的參數 Use Hashes for Initialization Arguments

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, wheel)
    @chainring = chainring
    @cog       = cog
    @wheel     = wheel
  end
  # ...
end

Gear.new(52, 11, Wheel.new(26, 1.5)).gear_inches

缺點:

  • 在初始化Gear時,必須明確依照順序傳入chainring, cog, wheel這三個參數。假如之後需要更改Gear initialize傳入的參數,任何初始化Gear的程式都必須跟著改變。

改善方式:

  • 利用傳入hash的方式取代傳入一連串的參數。

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    @chainring = args[:chainring]
    @cog       = args[:cog]
    @wheel     = args[:wheel]
  end
  # ...
end

Gear.new(
  chainring: 52,
  cog: 11,
  wheel: Wheel.new(26, 1.5)).gear_inches

優點:

  • 上面的缺點都不見了。

明確地定義預設值 Explicitly Define Defaults

有三種方式:

使用 ll

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    @chainring = args[:chainring] || 40
    @cog       = args[:cog] || 18
    @wheel     = args[:wheel]
  end
  # ...
end

缺點:

  • 使用 || 的話,你無法傳入一個false的boolean值,它會被預設值蓋掉。

使用Hash的fetch

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    @chainring = args.fetch(:chainring, 40)
    @cog       = args.fetch(:cog, 18)
    @wheel     = args[:wheel]
  end
  # ...
end

優點:

  • 上面的缺點不見了。

使用Hash的merge

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    args = defaults.merge(args)
    @chainring = args[:chainring]
    @cog       = args[:cog]
    @wheel     = args[:wheel]
  end
  # ...
  def defaults
    { chaining: 40, cog: 18 }
  end
  # ...
end

優點:

  • 同樣沒有 || 的問題。
  • 預設值都集中定義在defaults中,方便管理與取用。

使用hash當參數的缺點

缺點:

  • 程式的可讀性降低,從method的參數列來看,只知道有一個args的參數,但看不出來要放什麼東西進args。
  • 失去原本參數列檢查必要參數的功能。如果使用原本的參數列,當必要參數沒給或是參數的數量不對,就會出現ArgumentError: wrong number of arguments (x for y)。但改成hash之後,如果必要參數沒給,出錯的地方會變成在使用參數的時候出現,增加debug的困難。

改善方式:

  • 使用Ruby 2.0 的 Key Arguments

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring: 40, cog: 18, wheel:)
    @chainring = chainring
    @cog   = cog
    @wheel = wheel
  end 
  # ...
end 

Gear.new(
  chainring: 52, 
  cog: 11, 
  wheel: Wheel.new(26, 1.5)).gear_inches

優點:

  • 上面的缺點不見了。

隔離多參數的初始化 Isolate Multiparameter Initialization

事情總是沒有想像的美好,如果Gear無法更改它的參數列(例如Gear是一個SomeFramework的class,我們無法更動它),但我們希望之後可以使用hash參數的好處,那要怎麼修改比較好?

module SomeFramework
  class Gear
    attr_reader :chainring, :cog, :wheel
    def initialize(chainring, cog, wheel)
      @chainring = chainring
      @cog       = cog
      @wheel     = wheel
    end
    # ...
  end
end

修改方式:

  • 使用wrapper(factory)當跳板。

module GearWrapper
  def self.gear(args)
    SomeFramework::Gear.new(
      args[:chainring]
      args[:cog]
      args[:wheel])
  end
end

GearWrapper.gear(
  chainring: 52, 
  cog: 11,
  wheel: Wheel.new(26, 1.5)).gear_inches

補充:

  • 這裡不得不提到design pattern中的proxy pattern,簡單來說就是一個轉接頭的概念。

管理相依性的方向 Managing Dependency Direction

逆轉相依性的方向 Reversing Dependencies

相依是具有向相性的,以上面的例子而言,改了Wheel會使Gear也跟著改,但改了Gear並不會影響Wheel,也就是Gear是相依於Wheel。我們可以重新撰寫程式碼讓相依的方向完全顛倒,下面就是一個例子。

class Gear
  attr_reader :chainring, :cog
  def initialize(chainring, cog)
    @chainring = chainring
    @cog = cog
  end

  def gear_inches(diameter)
    ratio * diameter
  end

  def ratio
    chainring / cog.to_f
  end
  # ...
end

class Wheel
  attr_reader :rim, :tire, :gear
  def initialize(rim, tire, chainring, cog)
    @rim = rim
    @tire = tire
    @gear = Gear.new(chainring, cog)
  end

  def diameter
    rim + (tire * 2)
  end

  def gear_inches
    gear.gear_inches(diameter)
  end

  def y_pos
    diameter / 2
  end
  # ...
end

Wheel.new(26, 1.5, 52, 11).gear_inches

即然我們可以改變相依性的方向,挑選相依於哪一個class就顯得特別重要。

挑選相依性的方向 Choosing Dependency Direction

原則:相依於一個比你還少變動的東西。

程式碼有下面三個事實:

  • 有些class相較於其它的class是比較容易產生變動的。
  • 實體的(Concrete) class相較於抽象的(Abstract) class是比較容易產生變動的。
  • 變動一個擁有許多相依性的class會造成擴散性的災難。

了解變動的程度 Understanding Likelihood of Change

ruby原生的class變動的程度 < framework的class變動的程度 < 你自己寫的class變動的程度

辨識實體性與抽象性 Recognizing Concretions and Abstractions

抽象性:不關聯於一個特定的instance。

一個Wheel.new所建立的instance v.s. 一個只需要respond_to diameter的@wheel變數

避免擁有大量相依性的類別 Avoiding Dependent-Laden Classes

如果很多class都相依於某個class,則當這個class變動的時候,就是痛苦的開始。

找出重要的相依性 Finding the Dependencies That Matter

相依程度高(許多class相依於它)AD
低(很少class相依於它)BC
少(不常變動)多(很常變動)
變動頻率

  • A (Abstract Zone):很少變動,但很多class相依於它。(Interface)
  • B (Neutral Zone):很少變動,而且變動的時候不會有什麼影響。
  • C (Neutral Zone):很常變動,但變動的時候不會有什麼影響。
  • D (Danger Zone):很常變動,而且一旦變動會造成災難。

規劃良好的程式碼,它的class會分佈在A, B, C這三個區域之中。而D則是在寫程式時應該要盡量避免。