Ruby 物件導向設計實踐-敏捷入門

👀 14 min read 👀

大家好,我是 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 有很多場精彩的演講,大家可以去看看:

接下來是我在閱讀這本書每一章節的筆記,我另外在下面用註解的方式針對我的筆記做說明

參考程式碼如果是 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
      26
      class 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
    14
    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(...))
  • 隔離依賴
    • 隔離實例建立(當無法使用依賴注入時)
      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
      11
      class 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
      9
      class 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
      19
      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
    • 隔離多重參數初始化操作
      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
  • 選擇依賴方向
    • 告訴類別他們要依賴那些變化少於他們自身的事物
      • 有些類別更容易發生變化
      • 具體類別比抽象類別更容易發生變化
      • 修改具有許多依賴關係的類別會造成廣泛的影響

        這邊可以參考上面提到的 SOLID 的 依賴倒置原則(Dependency Inversion Principle, DIP)

建立靈活的介面

  • 定義介面
    • 公共介面
      • 顯露出主要職責
      • 期望被其他物件呼叫
      • 不會隨便改變
      • 其他物件可以放心依賴它
      • 在測試裡被詳盡記錄
    • 私有介面
      • 要處理實作細節
      • 不希望被傳送到其它物件
      • 可因任何原因變化
      • 其他物件不能放心依賴它
      • 可能不會在測試裡被引用
  • 關鍵字(Ruby 裡的方法)
  • 詢問傳送方想要什麼而非告訴接收者如何表現
    • 表示物件之間彼此信任
  • Law of Demeter, LoD
    • 對物件之間的傳遞進行限制:禁止將一則訊息藉由第二個不同的物件轉發給第三個物件,即只能與你的鄰近對話只能使用一個小圓點
    • 小心使用委派(delegate) - Ruby 的 delegate.rbforwardable.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
      19
      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
    • 繼承的核心是一種用於實作訊息自動委派的機制
  • 如果程式碼中的傳送者可以說話,如果說出:我知道你是誰,因為我知道你會做什麼,這項知識是一種會增加修改成本的依賴關係
  • 建立抽象父類別

    這裡會寫說抽象是因為實際上我們不會去 new 一個父類別出來使用,而是會針對不同的情況 new 出不一樣的子類別,所以我們說是將行為提升至抽象,而子類別我們就會說是具體的

    1
    2
    3
    4
    5
    6
    7
    8
    class 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
    16
    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
  • 從具體分離出抽象
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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)
    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
    30
    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)
    • 將子類別的方法寫進父類別中,即使是不做事也要實作該方法,讓工程師知道繼承這個類別時一定要實作哪些方法
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class 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
      19
      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)訊息解耦
      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
      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
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      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
    • 使用 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
  • 找尋方法的順序
  • 抽象父類別裡的所有程式碼都應該適用於每個繼承他的類別,父類別不應該包含只適用於部分(而非全部)子類別的程式碼,這項限制也同樣應用在模組上:模組裡的程式碼必須也能夠一併適用於包含他的所有事物
  • 里氏代替原則(Liskov Substitution Principle, LSP):子類型必須能夠代替他們的父類型

組合物件

  • composition
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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
    1
    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
  • 不要測試沒有依賴關係的輸入訊息,而是刪除它:刪除未使用的程式碼能夠立即節省成本,保留未使用的程式碼比刪除之後再恢復他們所花費的成本更高