iT邦幫忙

2023 iThome 鐵人賽

DAY 9
0

今天我們要透過一個工具來協助我們設計介面,讓所有物件都能依賴公共介面來傳遞訊息。

找出公共介面

實例演練

假設現在有一間自行車公司,提供兩種服務內容,公路和山地自行車旅程。顧客可以租用自行車,也可以自備,...以下省略旅程細節。

此時,某位顧客,為了選擇旅程,想看到富有彈性的旅程列表,這些旅程要有適當的難度,在特定日期可行,並且屆時還能夠租賃到自行車。

FastFeet的業務描述提供我們一些關於應用程式裡潛在類別的想法。我們的設計便會往 顧客 (Customer )、旅程(Trip )、路線(Route ) 、自行車(Bike )和技工(Mechanic ) 這幾個類別去思考,因為它們代表了這支應用程式(它同時具有資料和行為)裡的某些名詞,可以稱它們為 領域物件(domain objects)

使用順序圖

順序圖定義在統一模型化語言(Unified Modeling Language • UML )裡並且是UML所支援的眾多圖表之一。順序圖用於可視化不同對象(通常是類別或物件)之間的交互和消息傳遞。它顯示了消息的順序和交換,以及它們之間的時間順序。

第一階段:初步構想

展示了顧客Moe 和Trip類別,其中,Moe將suitable_trips訊息傳送給Trip,希望取得回應。
https://ithelp.ithome.com.tw/upload/images/20230909/20145409j84pTRzwb4.jpg
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)

這張圖有兩個物件,顧客MoeTrip,包含了一則訊息suitable_trips ,這則訊息需要三個參數:on_dateof_difficultyneed_bike。順序圖可以被解讀為:顧客Moe先將suitable_trips訊息傳送到Trip類別,接著,Trip類別處理這則訊息,在處理完成之後,傳回一個回應,在這張順序圖裡,Moe期望Trip類別能爲他找一個合適的旅程。

每個物件都使用兩個名稱相同的方框表示。其中,一個方框排列在另一個的上方,並藉由一條垂直線相連。水平線表示的是訊息。當有訊息傳送時,橫線會被貼上訊息的名稱。訊息線的結尾或起點帶有一個箭頭,箭頭會指向接收者。當某個物件忙於處理接收到的訊息時 ,它會成為活躍的並且其垂直線會被擴大為一個垂直的矩形。

看著順序圖不禁冒出 「應該讓Trip負責為每一個合適的旅程找出是否有適當的自行車可以使用嗎?」 或者更一般地問: 「這個接收者應該要負責回應該訊息嗎?」

順序圖可用於設計和定義公共介面,以明確物件之間的通訊方式,考慮基於訊息的設計觀點比基於類別的觀點更有助於創建靈活的應用程式。訊息傳遞是設計的起點,而物件的建立則是為了處理這些訊息。善用順序圖可以幫助我們探索不同的設計選擇,特別是當你需要決定哪個物件應該處理特定的訊息時。

第二階段:拆分工作

類別Trip不應該負責自行車相關事務,所以要有一個Bicycle類別來負責這件事。Trip可以負責suitable_trips ,而Bicycle負責suitable_bicycle
https://ithelp.ithome.com.tw/upload/images/20230909/20145409VVzx76sFHO.jpg
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)

把物件們擬人化,如果Moe與它們對話,那麼他現在便可以得到所要的答案。

在圖4.3中,他知道:
• 他想要一張旅程淸單
• 要有一個實作了suitable_trips訊息的物件。

在圖4.4中,他知道:
• 他想要一張旅程清單
• 要有一個實作了suitable_trips訊息的物件
• 找到合適的旅程就意味著找到合適的自行車
• 還要有另一個實作了suitable_bicycle訊息的物件。

圖4.4的問題在於Moe不僅知道自己想要的內容,他還知道其他物件應該如何協作才能提供這些內容。Customer類別掌握太多細節,並且吸收那些不屬於自己的職責,將自己綁定在某個可能發生變化的實作上。

第三階段:詢問「要什麼」,別告知「如何做」

Before
https://ithelp.ithome.com.tw/upload/images/20230909/20145409bLsU0LqgHj.jpg
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)

Trip知道Mechanic所做事情的許多細節:

  • Trip的公共介面包括了bicycles方法
  • Mechanic的公共介面包括clean_bicyclepump_tireslube_chaincheck_brakes方法
  • Trip期望持有一個可回應clean_bicyclepump_tireslube_chaincheck_brakes方法的物件。

Trip包含了這些知識並且告訴Mechanic如何做。如果Mechanic增加「新的自行車準備流程」,那麼Trip也必須要發生變化。假使Mechanic實作了一個方法(例如檢查自行車的維修工具),並將其作為Trip準備流程的一部分,這時必須修改Trip才能呼叫這個新方法,這個狀態足以顯示MechanicTrip之間正呈現高度耦合。

After
https://ithelp.ithome.com.tw/upload/images/20230909/201454095DEaTNquCk.jpg
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)

Trip要求Mechanic準備好每一輛自行車,但將那些實作細節留給了Mechanic

  • Trip的公共介面包括了bicycles方法
  • Mechanic的公共介面包含了prepare_bicycle方法
  • Trip期望持有一個可以回應prepare_bicycle的物件。

Trip現在已經將許多職責轉移給了MechanicTrip知道它想要每一輛自行車都會被準備好,並且它相信Mechanic能夠完成這項任務。由於知道「如何做」的職責已經轉移給了Mechanic,所以Trip總是會得到正確的行為。不管將來如何改進Mechanic都沒關係。

在圖4.5裡,Mechanic暴露了很多方法; 在圖4.6裡,它的公共介面只包含了一個prepare_bicycle方法。因為Mechanic的公共介面是穏定不變的,所以具有一個小的公共介面就意味著不會有太多的方法可以讓別人依賴。

第四階段:上下文獨立

物件的上下文是指物件在特定情境下的使用期望和依賴關係,一個物件的上下文可以影響它的可重複使用性和設計的複雜性。

簡單上下文的物件對其周圍環境的期望較少,容易重複使用和測試。它們通常對其他物件的身份和行為知之甚少;複雜上下文的物件對其周圍環境有較多的期望,需要特定的條件和相依物件才能正確運作。這種物件難以重複使用和測試。

物件的上下文可以影響其與其他物件的協作方式,使用依賴關係注入(Dependency Injection)等技巧可以幫助簡化物件的上下文,降低其對特定環境的依賴性。物件應該通過公共介面進行通訊,而不應直接依賴於其他物件的實現細節,這有助於隔離上下文並提高可重複使用性。

TripMechanic一無所知,但它仍然能夠與Mechanic進行協作, 進而將自行車準備好。Trip將自己想要的內容告知Mechanic,同時傳遞一個self參數,而Mechanic會立即冋呼Trip以取得需要準備的自行車清單。
https://ithelp.ithome.com.tw/upload/images/20230909/20145409oNl64ejwoQ.jpg
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)

  • Trip的公共介面包括bicycles
  • Mechanic的公共介面包括並可能包括prepare_bicycle
  • Trip期望持有一個可以回應prepare_trip的物件
  • Mechanic期望那個隨同prepare_trip一起傳遞的參數以回應bicycles

關於Mechanic如何準備旅程的所有知識,現在都已被隔離在Mechanic的內部,並且Trip的上下文也已被減少。

第五階段:使用訊息來發現物件

TripFinder擁有如何使某次旅程變得合適的所有知識。它知道這些規則其工作就是盡其所能去回應這則訊息。它提供了一個一致的公共介面,同時將那些混亂且多變 的內部實作細節隱藏起來。將這個方法移動到TripFinder之後,這個行為便可用於其他任何物件。

在未知的將來,或許會有其他的旅遊公司藉由某個Web服務來使用TripFinder,以便找出合適的旅程。現在,這種行為已經從Customer裡撷取出來,它可以被其他任何物件單獨地使用。
https://ithelp.ithome.com.tw/upload/images/20230909/20145409fqlzP6r4H2.jpg
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)

撰寫能展現介面的程式碼

以建立外顯示頁面為目標

每當你在建立類別時,請宣告它的介面。公共介面裡的方法應該滿足以下幾點。

  • 被明確標識。
  • 更多關於「做什麼」而非「怎麼做」。
  • 盡量確保這些名稱都是不會變化的。
  • 使用散列表做為選項參數。

Ruby 提供三種介面,public (公共的)、protected (受保護的)以及 private (私有的)。它們表示哪些方法是穩定的,哪些是不穩定的,再者,它們控制著方法的可見性(是否能夠被應用程式的其他部分看見)

  • private
    關鍵字private用於表示最不穩定的方法,其可見性也最具限制。私有方法必須呼叫隱含的接收者,或者換句話說,不能呼叫外顯的接收者。

  • protected
    protected表示的也是一種不穩定的方法,但是其可見性的限制稍有不 同。受保護的方法允許外顯接收者,只要接收者為self或同一個類別的實例, 或self的子類別,都可以。

  • public
    public表示的是穩定的方法。公共方法是隨處都可見的。

善用其他類別的公共介面

物件互動的基礎盡量建立在其他物件的公共介面上,當你必須依賴於某個類別私有介面時,便增加了可能會被迫進行修改的風險。一但私有介面為外部框架的一部分,那修改的風險又大幅提升,此時應該重新思考目前的設計是否能有更好的調整。

謹慎依賴私有介面

依賴私有介面會增加風險,善用上一章節的隔離技巧,隔離這種依賴關係,將風險降至最低。

最小上下文

在建構公共介面時,從其他類別取得的上下文務必保持最小化,也就是最小依賴。尤其要區分清楚「做什麼」與 「怎麼做」的區別。而一個好的公共方法允許傳送者取得所需的內容,卻無須瞭解類別是如何實作其行爲的。總之,一定要建立起公共介面。

迪米特法則(Law of Demeter)

我們在 Day3 有稍微提到迪米特法則的概念,這裡先幫大家複習一下:

迪米特法則又常稱為最少知識原則(Principle of least knowledge),也就是「各單元對其他單元所知應當有限:只瞭解與目前單元最相關之單元」。主要針對物件之間傳遞訊息的方法進行限制,禁止將一則訊息藉由第二個不同類型的物件轉發給第三個物件。

舉例1:School類別只與TeacherStudent類別的公共介面進行溝通,它不直接存取內部細節。

class School
  def initialize
    @teachers = []
    @students = []
  end

  def add_teacher(teacher)
    @teachers << teacher
  end

  def add_student(student)
    @students << student
  end

  def assign_teacher_to_student(teacher, student)
    teacher.teach(student)
  end
end

class Teacher
  def initialize(name)
    @name = name
  end

  def teach(student)
    puts "#{@name} is teaching #{student.name}."
  end
end

class Student
  def initialize(name)
    @name = name
  end

  def name
    @name
  end
end

school = School.new
teacher = Teacher.new("Mr. Smith")
student = Student.new("Alice")

school.add_teacher(teacher)
school.add_student(student)
school.assign_teacher_to_student(teacher, student)

舉例2:假設Tripdepart方法包含了下面幾行程式碼:(tire取得屬性,rotate取得行為)

customer.bicycle .wheel. tire 
customer.bicycle.wheel. rotate 
hash.keys.sort.join(’,')

每一行都是一條包含若干小圓點(英文句號)的訊息鏈。這些訊息鏈被俗稱為**「鐵路事故**」:每一個方法名稱都代表著了一節火車車廂,而那些小圓點就是它們的連接處。這些火車表明一個跡象,就是你可能違反了迪米特法則。

第 2 章指出程式碼應該具有透明性、合理性、可用性和典範性四項特點,先前所列舉的部份訊息鏈並不符合這些特點:

  • 如果wheel改變了tirerotate,那麼depart可能也會隨之變化。Tripwheel毫無關係,但修改wheel可能會強迫Trip也要進行修改。
  • tirerotate的修改可能會破壞depart,由於Trip既遙遠又看似是無關的,所以這項失敗完全在意料之外,這種程式碼很不透明。
  • Trip無法被重複使用,除非它能存取到帶有bicyclecustomer,而這個bicycle還要擁有wheeltire。它需要大量的上下文,並且不容易使用。
  • 這種訊息模式會被其他人複製,進而產生更多具有類似問題的程式碼。這種撰寫風格還會自我繁殖但它無法成為典範。

它是一條跨越「多個物件去取 得遠處行為的訊息鏈 這種撰寫風格的成本註定很高,而這類違規應該加以移除。

第三條訊息鏈(hash.keys.sort.join)完全合理。儘管它的小圓點數量很多,但它 可能沒有任何違反迪米特法則的地方。這並非是藉由統計「小圓點」個數來評判,而 是要檢查中間物件的類型:

hash.keys會傳回一個Enumerable
hash.keys.sort也會傳回一個Enumerable
hash.keys.sort.join會傳回一個String

按照這個邏輯,這只是很輕微的違反了迪米特法則。不過,如果你能說服自己接受hash.keys.sort.join實際上會傳冋一個包含多個StringEnumerable類型,並且所有的中間物件都具有相同的類型,那麼,就不會有違反迪米特法則的問題。如果你從這行程式碼裡移除這些小圓點,那麼你的成本可能會上升,而非下降。

一種常見的從程式碼裡移除「鐵路事故」的方法是使用委派來避開這些「小圓點」,在物件導向領域裡,委派(delegate) 一則訊息指的是將它傳遞到另一個物件,這常常是藉由包裝方法實作,這樣做可以封裝或隱藏物件之間的知識,避免直接存取其他物件的內部細節。

為什麼使用委派? 委派可用於降低類別之間的耦合性,使系統更具彈性和易於維護。它有助於隔離物件之間的依賴關係,並確保每個物件專注於自己的職責。

委派的實作方式: 不同的程式語言和框架提供了實現委派的方法。在Ruby中,你可以使用delegate.rb、forwardable.rb或Ruby on Rails框架提供的delegate方法。這些工具可以自動攔截傳送給一個物件的訊息,然後將它們傳送到其他物件。

注意事項: 儘管委派可以用來隱藏耦合性,但要小心不要過度使用。有時候,過多的委派可能導致程式碼變得難以理解,並且可能僅僅是掩蓋了問題,而不是解決了它們。在使用委派時,要謹慎考慮設計的整體架構和精神,而不僅僅是遵循字面上的法則。

委派是一種有用的技巧,可用於改進程式碼的設計,但它應該與其他設計原則一起使用,以確保系統的正常運行和可維護性。

結論

類別之間,應該要互相保持最低程度的了解,各自的工作盡可能封裝在自己的類別中,再用public的方式讓外部使用。讓訊息傳遞是發生在公共介面上,定義良好的公共介面是由穩定的方法所組成,這些方法會暴露出其底層類別的職責。並且能夠以最低成本帶來最大的益處。

參考資料:

  • Practical Object-Oriented Design in Ruby: An Agile Primer

上一篇
Day8 CH4 建立靈活的介面(上)
下一篇
Day10 CH5 使用鴨子類型技巧降低成本(上)
系列文
入坑 RoR 必讀 - Ruby 物件導向設計實踐30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言