今天我們要透過一個工具來協助我們設計介面,讓所有物件都能依賴公共介面來傳遞訊息。
假設現在有一間自行車公司,提供兩種服務內容,公路和山地自行車旅程。顧客可以租用自行車,也可以自備,...以下省略旅程細節。
此時,某位顧客,為了選擇旅程,想看到富有彈性的旅程列表,這些旅程要有適當的難度,在特定日期可行,並且屆時還能夠租賃到自行車。
FastFeet的業務描述提供我們一些關於應用程式裡潛在類別的想法。我們的設計便會往 顧客 (Customer )、旅程(Trip )、路線(Route ) 、自行車(Bike )和技工(Mechanic ) 這幾個類別去思考,因為它們代表了這支應用程式(它同時具有資料和行為)裡的某些名詞,可以稱它們為 領域物件(domain objects)。
順序圖定義在統一模型化語言(Unified Modeling Language • UML )裡並且是UML所支援的眾多圖表之一。順序圖用於可視化不同對象(通常是類別或物件)之間的交互和消息傳遞。它顯示了消息的順序和交換,以及它們之間的時間順序。
第一階段:初步構想
展示了顧客Moe 和Trip類別,其中,Moe將suitable_trips訊息傳送給Trip,希望取得回應。
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)
這張圖有兩個物件,顧客Moe
和Trip
,包含了一則訊息suitable_trips
,這則訊息需要三個參數:on_date
、 of_difficulty
和need_bike
。順序圖可以被解讀為:顧客Moe
先將suitable_trips
訊息傳送到Trip
類別,接著,Trip
類別處理這則訊息,在處理完成之後,傳回一個回應,在這張順序圖裡,Moe
期望Trip
類別能爲他找一個合適的旅程。
每個物件都使用兩個名稱相同的方框表示。其中,一個方框排列在另一個的上方,並藉由一條垂直線相連。水平線表示的是訊息。當有訊息傳送時,橫線會被貼上訊息的名稱。訊息線的結尾或起點帶有一個箭頭,箭頭會指向接收者。當某個物件忙於處理接收到的訊息時 ,它會成為活躍的並且其垂直線會被擴大為一個垂直的矩形。
看著順序圖不禁冒出 「應該讓Trip負責為每一個合適的旅程找出是否有適當的自行車可以使用嗎?」 或者更一般地問: 「這個接收者應該要負責回應該訊息嗎?」
順序圖可用於設計和定義公共介面,以明確物件之間的通訊方式,考慮基於訊息的設計觀點比基於類別的觀點更有助於創建靈活的應用程式。訊息傳遞是設計的起點,而物件的建立則是為了處理這些訊息。善用順序圖可以幫助我們探索不同的設計選擇,特別是當你需要決定哪個物件應該處理特定的訊息時。
第二階段:拆分工作
類別Trip
不應該負責自行車相關事務,所以要有一個Bicycle
類別來負責這件事。Trip
可以負責suitable_trips
,而Bicycle
負責suitable_bicycle
。
(圖片拍攝自書籍: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
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)
Trip
知道Mechanic
所做事情的許多細節:
Trip
的公共介面包括了bicycles
方法Mechanic
的公共介面包括clean_bicycle
、pump_tires
、lube_chain
和check_brakes
方法Trip
期望持有一個可回應clean_bicycle
、pump_tires
、lube_chain
和check_brakes
方法的物件。Trip
包含了這些知識並且告訴Mechanic
如何做。如果Mechanic
增加「新的自行車準備流程」,那麼Trip
也必須要發生變化。假使Mechanic
實作了一個方法(例如檢查自行車的維修工具),並將其作為Trip
準備流程的一部分,這時必須修改Trip
才能呼叫這個新方法,這個狀態足以顯示Mechanic
和Trip
之間正呈現高度耦合。
After
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)
Trip
要求Mechanic
準備好每一輛自行車,但將那些實作細節留給了Mechanic
Trip
的公共介面包括了bicycles
方法Mechanic
的公共介面包含了prepare_bicycle
方法Trip
期望持有一個可以回應prepare_bicycle
的物件。Trip
現在已經將許多職責轉移給了Mechanic
。Trip
知道它想要每一輛自行車都會被準備好,並且它相信Mechanic
能夠完成這項任務。由於知道「如何做」的職責已經轉移給了Mechanic
,所以Trip
總是會得到正確的行為。不管將來如何改進Mechanic
都沒關係。
在圖4.5裡,Mechanic
暴露了很多方法; 在圖4.6裡,它的公共介面只包含了一個prepare_bicycle
方法。因為Mechanic
的公共介面是穏定不變的,所以具有一個小的公共介面就意味著不會有太多的方法可以讓別人依賴。
第四階段:上下文獨立
物件的上下文是指物件在特定情境下的使用期望和依賴關係,一個物件的上下文可以影響它的可重複使用性和設計的複雜性。
簡單上下文的物件對其周圍環境的期望較少,容易重複使用和測試。它們通常對其他物件的身份和行為知之甚少;複雜上下文的物件對其周圍環境有較多的期望,需要特定的條件和相依物件才能正確運作。這種物件難以重複使用和測試。
物件的上下文可以影響其與其他物件的協作方式,使用依賴關係注入(Dependency Injection)等技巧可以幫助簡化物件的上下文,降低其對特定環境的依賴性。物件應該通過公共介面進行通訊,而不應直接依賴於其他物件的實現細節,這有助於隔離上下文並提高可重複使用性。
Trip
對Mechanic
一無所知,但它仍然能夠與Mechanic
進行協作, 進而將自行車準備好。Trip
將自己想要的內容告知Mechanic
,同時傳遞一個self
參數,而Mechanic
會立即冋呼Trip
以取得需要準備的自行車清單。
(圖片拍攝自書籍: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
裡撷取出來,它可以被其他任何物件單獨地使用。
(圖片拍攝自書籍:Practical Object-Oriented Design in Ruby: An Agile Primer)
每當你在建立類別時,請宣告它的介面。公共介面裡的方法應該滿足以下幾點。
Ruby 提供三種介面,public (公共的)、protected (受保護的)以及 private (私有的)。它們表示哪些方法是穩定的,哪些是不穩定的,再者,它們控制著方法的可見性(是否能夠被應用程式的其他部分看見)
private
關鍵字private用於表示最不穩定的方法,其可見性也最具限制。私有方法必須呼叫隱含的接收者,或者換句話說,不能呼叫外顯的接收者。
protected
protected表示的也是一種不穩定的方法,但是其可見性的限制稍有不 同。受保護的方法允許外顯接收者,只要接收者為self
或同一個類別的實例, 或self
的子類別,都可以。
public
public表示的是穩定的方法。公共方法是隨處都可見的。
物件互動的基礎盡量建立在其他物件的公共介面上,當你必須依賴於某個類別私有介面時,便增加了可能會被迫進行修改的風險。一但私有介面為外部框架的一部分,那修改的風險又大幅提升,此時應該重新思考目前的設計是否能有更好的調整。
依賴私有介面會增加風險,善用上一章節的隔離技巧,隔離這種依賴關係,將風險降至最低。
在建構公共介面時,從其他類別取得的上下文務必保持最小化,也就是最小依賴。尤其要區分清楚「做什麼」與 「怎麼做」的區別。而一個好的公共方法允許傳送者取得所需的內容,卻無須瞭解類別是如何實作其行爲的。總之,一定要建立起公共介面。
我們在 Day3 有稍微提到迪米特法則的概念,這裡先幫大家複習一下:
迪米特法則又常稱為最少知識原則(Principle of least knowledge),也就是「各單元對其他單元所知應當有限:只瞭解與目前單元最相關之單元」。主要針對物件之間傳遞訊息的方法進行限制,禁止將一則訊息藉由第二個不同類型的物件轉發給第三個物件。
舉例1:School
類別只與Teacher
和Student
類別的公共介面進行溝通,它不直接存取內部細節。
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:假設Trip
的depart
方法包含了下面幾行程式碼:(tire
取得屬性,rotate
取得行為)
customer.bicycle .wheel. tire
customer.bicycle.wheel. rotate
hash.keys.sort.join(’,')
每一行都是一條包含若干小圓點(英文句號)的訊息鏈。這些訊息鏈被俗稱為**「鐵路事故**」:每一個方法名稱都代表著了一節火車車廂,而那些小圓點就是它們的連接處。這些火車表明一個跡象,就是你可能違反了迪米特法則。
第 2 章指出程式碼應該具有透明性、合理性、可用性和典範性四項特點,先前所列舉的部份訊息鏈並不符合這些特點:
wheel
改變了tire
或rotate
,那麼depart
可能也會隨之變化。Trip
與wheel
毫無關係,但修改wheel
可能會強迫Trip
也要進行修改。tire
或rotate
的修改可能會破壞depart
,由於Trip
既遙遠又看似是無關的,所以這項失敗完全在意料之外,這種程式碼很不透明。Trip
無法被重複使用,除非它能存取到帶有bicycle
的customer
,而這個bicycle
還要擁有wheel
和tire
。它需要大量的上下文,並且不容易使用。它是一條跨越「多個物件去取 得遠處行為的訊息鏈 這種撰寫風格的成本註定很高,而這類違規應該加以移除。
第三條訊息鏈(hash.keys.sort.join
)完全合理。儘管它的小圓點數量很多,但它 可能沒有任何違反迪米特法則的地方。這並非是藉由統計「小圓點」個數來評判,而 是要檢查中間物件的類型:
hash.keys會傳回一個Enumerable
hash.keys.sort也會傳回一個Enumerable
hash.keys.sort.join會傳回一個String
按照這個邏輯,這只是很輕微的違反了迪米特法則。不過,如果你能說服自己接受hash.keys.sort.join
實際上會傳冋一個包含多個String
的Enumerable
類型,並且所有的中間物件都具有相同的類型,那麼,就不會有違反迪米特法則的問題。如果你從這行程式碼裡移除這些小圓點,那麼你的成本可能會上升,而非下降。
一種常見的從程式碼裡移除「鐵路事故」的方法是使用委派來避開這些「小圓點」,在物件導向領域裡,委派(delegate) 一則訊息指的是將它傳遞到另一個物件,這常常是藉由包裝方法實作,這樣做可以封裝或隱藏物件之間的知識,避免直接存取其他物件的內部細節。
為什麼使用委派? 委派可用於降低類別之間的耦合性,使系統更具彈性和易於維護。它有助於隔離物件之間的依賴關係,並確保每個物件專注於自己的職責。
委派的實作方式: 不同的程式語言和框架提供了實現委派的方法。在Ruby中,你可以使用delegate.rb、forwardable.rb或Ruby on Rails框架提供的delegate
方法。這些工具可以自動攔截傳送給一個物件的訊息,然後將它們傳送到其他物件。
注意事項: 儘管委派可以用來隱藏耦合性,但要小心不要過度使用。有時候,過多的委派可能導致程式碼變得難以理解,並且可能僅僅是掩蓋了問題,而不是解決了它們。在使用委派時,要謹慎考慮設計的整體架構和精神,而不僅僅是遵循字面上的法則。
委派是一種有用的技巧,可用於改進程式碼的設計,但它應該與其他設計原則一起使用,以確保系統的正常運行和可維護性。
類別之間,應該要互相保持最低程度的了解,各自的工作盡可能封裝在自己的類別中,再用public的方式讓外部使用。讓訊息傳遞是發生在公共介面上,定義良好的公共介面是由穩定的方法所組成,這些方法會暴露出其底層類別的職責。並且能夠以最低成本帶來最大的益處。
參考資料: