設計模式–訪問者模式(Visitor Pattern)
- 這是 for Udemy Design Patterns in Python 課程 (講師是 Mr. Dmitri Nesteruk) 第 23 節主題為 Visitor 訪問者模式的筆記,由於這位 Dmitri 老師的課程對我而言,有時解說或過於簡潔,有時提供的範例或較為艱澀難懂,因此為了更好的學習,我另有收看對岸 圖靈星球論壇於 Youtube 頻道上發表的軟體設計模式系列的 Youtube 影片來輔助學習。
什麼是 Visitor Pattern?
- Visitor Pattern 是一種將操作與對象結構分離的設計模式, 它可以 在不改變集合結構的情況下, 為對象結構中的每個元素增加新的功能,它的核心思想是 將訪問者對象操作進行封裝(這裡只對對象操作的方法或函式),然後將它們作用回各個元素(Element)對象。
- 訪問者模式中的成員中包括一個 元素抽象介面(Element Interface – Abstract Class),此介面內含一個 Accept() 方法,用來接收 觀察者類;和多個繼承該介面的 元素類(Element Class),每個 元素類都繼承自這個元素介面,且實作了這個 accept() 方法,以用於接受 訪問者。
- 訪問者則包括一個 訪問者抽象介面,其內含一個 Visit() 抽象(靜態)方法,以及多個 訪問者類,每個 訪問者類 都繼承自 訪問者抽象介面,且實作了一個或多個訪問者的 visit() 方法, 以用於在各元素類增加一或多種的訪問功能。
為什麼需要 Visitor Pattern ?
- 來看下面這個小動畫的演示:
- 在這個小動畫中,有一個需求,我們要畫方及畫圓,傳統做法,我們會創建方和圓兩個類別物件,然後在類別中 Implement Drawing 畫圖(畫方和圓)方法,到要畫出方和圓的時候,就 create 出兩個類別的 Instance,然後調用目標類別中 implement 的 Drawing 方法來畫方和圓, 但這樣作法的缺點是,每次要新增一些功能,比方說我要增加計算周長或面積的方法,我就要進入到原先類別中去修改,添加需要的方法函式,但這樣其實就會破壞了 OOP in-out 的原則,增加類別壞掉的可能風險,此外,這些新增的功能,又得一筆筆的去改動到所有用到的類別中,非常費力,且可能略有疏漏就遺漏了某個類別修改。
- 而 Visitor 的方式是讓這些方法集中在觀察者身上, 與其一個個的在所有類別物件去 implement 需要的方法,還不如集中的在觀察者身上來 implement 所有要用到該方法的類別物件,這樣可以更方便程式碼的維護,也避免非必要的去修改到原始的類別物件。
Visitor Pattern Class Diagram
classDiagram
Visitor <|-- ConcreteVisitor1
Visitor <|-- ConcreteVisitor2
Element <|-- ConcreteElementA
Element <|-- ConcreteElementB
ConcreteElementA --> Visitor
ConcreteElementB --> Visitor
ObjectStructure --> Element
ObjectStructure --> Visitor
ConcreteVisitor1 ..> ConcreteElementA
ConcreteVisitor2 ..> ConcreteElementB
class Visitor {
+VisitConcreteElementA()
+VisitConcreteElementB()
}
class ConcreteVisitor1 {
+VisitConcreteElementA()
}
class ConcreteVisitor2 {
+VisitConcreteElementB()
}
class Element {
+Accept(Visitor)
}
class ConcreteElementA {
+Accept(Visitor)
+OperationA()
}
class ConcreteElementB {
+Accept(Visitor)
+OperationB()
}
class ObjectStructure {
-elements: Element[]
-visitors: Visitor[]
+Attach(Element)
+Detach(Element)
+Accept(Visitor)
}
- 訪問者模式包含四個角色:
- Element 元素對象類:表示被訪問的對象。
- Visitor 訪問者類:表示訪問元素的對象。
- Visitor Interface 訪問者抽象介面:表示訪問者必須實現的抽象介面(模型)。
- Element Interface 元素對象抽象介面:表示被訪問對象的抽象介面(模型)。
- 訪問者模式的流程如下:
- 創建訪問者抽象介面及元素對象的抽象介面。
- 將所有操作建立類別,並繼承訪問者抽象界面,且實作其訪問對象的方法。
- 建立所有的元素對象,並將其繼承元素對象的抽象類別,且實作其接收訪問者的方法。
- 對每個元素調用訪問者對應的方法。
- 推薦大家可以使用之前 Sky 版大推薦的 Python 執行視覺化 的網站來看 visitor pattern 的執行過程就會更清楚喔!!!
Visitor Pattern 重點歸納
- Visitor 模式將 操作 與 對象 結構分離。
- 訪問者模式 可以動態地添加新的操作: 允許在不改變對象結構的情況下,使得新操作可以不改變對象的類而增加。
- 適用於對象結構中元素的類型比較固定,而操作比較靈活的情況。
Visitor Pattern Python Example01
- 這段程式碼展示了如何使用訪問者模式來計算列表中所包含的對象中的所有數字的總和。
from abc import ABC, abstractmethod
class Element(ABC):
@abstractmethod
def accept(self, visitor: Visitor):
pass
class ConcreteElementA(Element):
def __init__(self, value: int):
self.value = value
def accept(self, visitor: Visitor):
visitor.visit(self)
class ConcreteElementB(Element):
def __init__(self, value: int):
self.value = value
def accept(self, visitor: Visitor):
visitor.visit(self)
class Visitor(ABC):
@abstractmethod
def visit(self, element: Element):
pass
class SumVisitor(Visitor):
def __init__(self):
self.sum = 0
def visit(self, element: Element):
self.sum += element.value
def main():
elements = [ConcreteElementA(1), ConcreteElementB(2), ConcreteElementA(3)]
visitor = SumVisitor()
for element in elements:
element.accept(visitor)
print(f'所有的元素對象的總和為: {visitor.sum}')
if __name__ == "__main__":
main()
# 執行結果:
所有的元素對象的總和為: 6
Visitor Pattern Python Example02
- 這段程式碼展示了如何使用 訪問者模式 來格式化一個文本文件。
class TextFile:
def __init__(self, content):
self.content = content
def accept(self, visitor):
visitor.visit(self)
class Formatter:
def visit(self, text_file):
print(f'格式化為: {text_file.content}')
class HtmlFormatter:
def visit(self, text_file):
from IPython.display import HTML
# print(text_file.content.replace("\n", "<br/>"))
print(HTML('\x1b[5;30;47m' + f'<div>{text_file.content.replace("\n", "")}</div>').data + '\x1b[0m')
def main():
text_file = TextFile("hello, world!")
formatter = Formatter()
text_file.accept(formatter)
html_formatter = HtmlFormatter()
text_file.accept(html_formatter)
if __name__ == "__main__":
main()
# 執行結果:
格式化為: hello, world!
格式化為: <div>hello, world!</div>
Visitor Pattern Python Example03
- 這段程式碼展示了如何使用訪問者模式來創建一個線上商店的購物車,依據購物對象商品,當要進行購買時,透過訪問者模式的購買(結帳)與折扣方法來進行糗購車的結帳作業。
from abc import ABC, abstractmethod
class Item(ABC):
@abstractmethod
def accept(self, visitor):
pass
class Book(Item):
def __init__(self, price):
self.price = price
def get_price(self):
return self.price
def accept(self, visitor):
visitor.visit_book(self)
class Electronics(Item):
def __init__(self, price):
self.price = price
def get_price(self):
return self.price
def accept(self, visitor):
visitor.visit_electronics(self)
class Visitor(ABC):
@abstractmethod
def visit_book(self, book):
pass
@abstractmethod
def visit_electronics(self, electronics):
pass
class RegularPriceVisitor(Visitor):
def __init__(self):
self.total_cost = 0
def visit_book(self, book):
self.total_cost += book.get_price()
def visit_electronics(self, electronics):
self.total_cost += electronics.get_price()
def get_total_cost(self):
return self.total_cost
class DiscountPriceVisitor(Visitor):
def __init__(self):
self.total_cost = 0
def visit_book(self, book):
self.total_cost += book.get_price() * 0.8 # 20% discount on books
def visit_electronics(self, electronics):
self.total_cost += electronics.get_price() * 0.9 # 10% discount on electronics
def get_total_cost(self):
return self.total_cost
def main():
items = [Book(50), Book(30), Electronics(100)]
regular_visitor = RegularPriceVisitor()
discount_visitor = DiscountPriceVisitor()
for item in items:
item.accept(regular_visitor)
item.accept(discount_visitor)
print("Regular total cost: ", regular_visitor.get_total_cost())
print("Discount total cost: ", discount_visitor.get_total_cost())
if __name__ == "__main__":
main()
# 執行結果為:
Regular total cost: 180
Discount total cost: 154.0
針對 Dmitri 老師這節範例的一些解析及補充
-
老師在這一節其實範例蠻精彩的,只是老實說,我覺得有點深,初看會看不懂,因此我放在這裡做個補充,基本上老師在這一節 visitor pattern 中,將 visitor pattern 分成三類:
intrusive visitor、reflective visitor 及 classic visitor ,其中 classic visitor 就是我上面 class diagram 及前面範例提到的標準典型的訪問者模式。以下是這三類訪問者的一些比較: -
在第一個範例中,程式碼展示了一種 intrusive visitor pattern 的實現。主要內容有:
- DoubleExpression、AdditionExpression 兩個 expression 類別,可組合出表示數學運算式的 expression tree 。
- print() 和 eval() 方法定義在每個 expression 類別中,用來輸出和計算運算式,
- 而主程式組合出一個運算式 1 + (2 + 3),並利用 print() 和 eval() 輸出和計算結果。
- 這是 intrusive 的 visitor pattern 實現,因為:
- print() 和 eval() 方法被定義在各個 expression 類別內, 打破了開放封閉原則 (OCP) 。
- 如果要新增操作(例如: 求 Derivative()),需要修改每一個 expression 類別,更可能需要新增操作而不是新增 expression 類型。
- 綜合以上分析,這個範例展示了 intrusive visitor 的缺點 - 違反 OCP 原則,不易擴充。要新增操作需要修改多個類別的程式碼。
-
在第二個範例中,程式碼展示了一種 reflective visitor pattern 的實現,主要內容有:
- Expression 為抽象類別,DoubleExpression、AdditionExpression 為具體子類別。
- ExpressionPrinter 類別實作了訪客類別,具有 print() 靜態方法。
- print() 方法則通過 isinstance() 來判斷是否為 expression 子類別,並依此來判斷是否要調用對應的打印邏輯。
- Expression 類別新增 print() 實例方法,調用 ExpressionPrinter 的 print(),於其中組合運算式並打印輸出。
- 這是 reflective 的 visitor pattern 實現,因為:
- print() 方法移出各個 expression 子類別,集中在訪客類別 ExpressionPrinter 中,通過 isinstance() 反射判斷具體 subclass 類型,以調用對應的打印邏輯,避免了 intrusive 需要修改子類別的問題,但這種實現仍然 違反開放封閉原則:
- 新增 expression 子類別時,仍需修改 ExpressionPrinter 的 print() 方法,新增對應的打印邏輯,需要做 M x N 次修改。
- print() 方法移出各個 expression 子類別,集中在訪客類別 ExpressionPrinter 中,通過 isinstance() 反射判斷具體 subclass 類型,以調用對應的打印邏輯,避免了 intrusive 需要修改子類別的問題,但這種實現仍然 違反開放封閉原則:
- 綜合以上分析,reflective visitor 模式改善了 intrusive 的問題,但 仍未完全解決 OCP 問題,擴充上仍有較大的負擔。
-
在第三個範例中,程式碼展示了一種 classic visitor pattern 的實現,主要內容有:
- DoubleExpression、AdditionExpression 兩個 expression 類別,與前面 reflective 範例基本相同。
- ExpressionPrinter 為訪客者類別,具有 visit() 方法。
- @visitor 裝飾器負責註冊 visitor 方法與具體類別的對應。
- accept() 方法在各 expression 類別中,用來調用訪客的對應 visit()。
- 主程式組合運算式,然後調用 ExpressionPrinter 進行訪問,這是 classic visitor pattern 的實現,它改善了 intrusive 和 reflective 範例的以下問題:
- 訪問者與元素類別完全解耦,可以各自獨立擴展,而不會相互影響;新增子類別時不需要修改任何類別;新增訪問者時也不需要修改任何元素類別。 上述優點都使其滿足開放封閉原則。
- 綜合以上分析,classic visitor 完全實現了 visitor 模式的效果,並解決了前兩類訪問者的相關問題,是三種訪問者中在程式碼實現上最完美的。
-
至於第四個範例中,程式碼主要是針對第三個範例的優化版本,其主要改進內容:
- 新增了 ExpressionEvaluator 訪問者類別,以用於計算運算式的值。
- ExpressionEvaluator 中的 visit() 方法使用 遞迴 調用的方式計算值。
- 原先的 ExpressionPrinter 是線性的遍歷方式,需要自行處理遞迴部分。
- 主程式中同時展示了打印和求值訪問者的使用。
- REFINED 版本相比原始版本的優點:
- 展示了訪問者模式的重要優勢 - 可新增訪問者,毫無影響。
- 計算值這種較複雜操作,調用次數和遞歸都由訪問者自身控制,程式結構更清晰。
- 凸顯了訪問者封裝算法的作用,訪問者可基於元素類別任意拓展新功能,不影響元素類別。
- 整體更加完整地展示了訪問者模式的效能。
- 綜合以上分析,該 REFINE 版本使得訪問者模式的優勢能夠更充分地體現出來,是一個比原始版本(第三個範例)更加優秀和完整的程式碼。
Visitor Pattern 的優缺點及 Summary
- 優點:
- 符合單一職責原則: 可以將邏輯分離到 Visitor 類中,使得對象結構的層次結構更加清晰。
- 使得增加新操作更容易: 當為不同的對象結構添加新的操作,而不需要修改對象結構本身。
- 可以跨越對象層次結構,訪問不同層次的元素對象,做出相應的操作。
- 將 操作 與 元素對象 進行結構分離, 提高了系統的可擴展性。
- 將 操作集中化,使得對象的某一個功能操作當有需要修改或變動時,只需修改該功能對應的訪問者,在不需要動到元素對象下,就能完成功能操作的邏輯更新。
- 缺點:
- 訪問者模式的實現比較複雜,增加了系統的複雜性。
- 訪問者模式破壞了封裝性,訪問者可以訪問到元素的私有成員。
- 當元素類數目增多,將導致維護成本增大。
- 適用場景:
- 當對象結構中需要在不想要變動對象結構本身而能添加新的操作的時候。例如: 文檔交換或編譯器
- 在需要跨越對象層次結構,訪問不同層次的元素對象,做出相應的操作的時候。
- 摘要:
- Visitor Pattern 可以用來解決 對象結構的層次結構比較複雜,需要在不同的元素上執行相同或相似操作的問題。 但是,它同時也伴隨著訪問者模式的中介,因而 增加了系統的複雜性,從而導致程式碼不易被理解。