返回文章列表

物件導向設計原則與Python實踐

本文深入探討物件導向設計的 SOLID 原則,包含單一職責、開放封閉、里氏替換、介面隔離和依賴反轉,並搭配 Python

軟體設計 Python

SOLID 原則是物件導向程式設計的核心理念,能有效提升程式碼品質和可維護性。本文著重探討開放封閉原則、里氏替換原則和介面隔離原則,並以 Python 程式碼示範如何實作。同時,我們也將探討建立型設計模式,特別是工廠模式,說明如何利用工廠方法和抽象工廠來管理物件的建立,以及這些方法如何提升程式碼的彈性、降低耦合度,並改善效能。透過實際案例和圖表,讀者能更深入理解這些原則和模式的應用價值,並學習如何在 Python 專案中實踐。

物件導向設計原則:SOLID

物件導向設計是軟體開發中的重要概念,而SOLID原則是其中最為關鍵的指導方針。SOLID是五個設計原則的首字母縮寫,分別是單一職責原則(Single Responsibility Principle, SRP)、開放封閉原則(Open/Closed Principle, OCP)、里氏替換原則(Liskov Substitution Principle, LSP)、介面隔離原則(Interface Segregation Principle, ISP)和依賴倒置原則(Dependency Inversion Principle, DIP)。本文將重點探討OCP、LSP和ISP這三個原則,並透過實際程式碼範例進行詳細解析。

開放封閉原則(OCP)

開放封閉原則強調軟體實體(類別、模組、函式等)應該對擴充套件開放,對修改封閉。換言之,當需求變更或新增功能時,我們應該能夠在不修改現有程式碼的情況下進行擴充。

OCP範例:幾何形狀面積計算

以下是一個遵循OCP的設計範例,用於計算不同幾何形狀的面積:

from abc import ABC, abstractmethod
import math

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return math.pi * (self.radius ** 2)

def calculate_area(shape: Shape) -> float:
    return shape.area()

if __name__ == "__main__":
    rect = Rectangle(12, 8)
    rect_area = calculate_area(rect)
    print(f"Rectangle area: {rect_area}")

    circ = Circle(6.5)
    circ_area = calculate_area(circ)
    print(f"Circle area: {circ_area:.2f}")

程式碼解析:

此範例中,我們定義了一個抽象基礎類別Shape,並讓RectangleCircle類別繼承自它。calculate_area函式能夠接受任何Shape類別的例項,並計算其面積。當我們需要新增其他形狀時,只需建立新的類別繼承Shape並實作area方法,而無需修改現有的calculate_area函式。這正是OCP的體現。

里氏替換原則(LSP)

里氏替換原則指出,子類別應該能夠替換其基礎類別,並且程式的行為保持不變。也就是說,任何使用基礎類別的地方都應該能夠使用子類別的例項,而不會導致程式錯誤或行為異常。

LSP範例:鳥的移動行為

以下是一個遵循LSP的設計範例,用於模擬不同鳥類別的移動行為:

class Bird:
    def move(self):
        print("I'm moving")

class FlyingBird(Bird):
    def move(self):
        print("I'm flying")

class FlightlessBird(Bird):
    def move(self):
        print("I'm walking")

def make_bird_move(bird: Bird):
    bird.move()

if __name__ == "__main__":
    generic_bird = Bird()
    eagle = FlyingBird()
    penguin = FlightlessBird()

    make_bird_move(generic_bird)
    make_bird_move(eagle)
    make_bird_move(penguin)

程式碼解析:

在此範例中,我們定義了一個基礎類別Bird和兩個子類別FlyingBirdFlightlessBirdmake_bird_move函式能夠接受任何Bird類別的例項,並呼叫其move方法。透過這種設計,我們可以在不修改make_bird_move函式的情況下,新增不同型別的鳥類別,並且保持程式的正確性。

介面隔離原則(ISP)

介面隔離原則建議設計更小、更專注的介面,而不是大而全的介面。這意味著類別不應該被迫實作它們不需要的介面方法。

ISP範例:印表機功能

以下是一個遵循ISP的設計範例,用於模擬不同印表機的功能:

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print_document(self):
        pass

class Scanner(ABC):
    @abstractmethod
    def scan_document(self):
        pass

class FaxMachine(ABC):
    @abstractmethod
    def fax_document(self):
        pass

class SimplePrinter(Printer):
    def print_document(self):
        print("Printing")

class AllInOnePrinter(Printer, Scanner, FaxMachine):
    def print_document(self):
        print("Printing")

    def scan_document(self):
        print("Scanning")

    def fax_document(self):
        print("Faxing")

if __name__ == "__main__":
    simple_printer = SimplePrinter()
    simple_printer.print_document()

    all_in_one_printer = AllInOnePrinter()
    all_in_one_printer.print_document()
    all_in_one_printer.scan_document()
    all_in_one_printer.fax_document()

程式碼解析:

在此範例中,我們定義了三個獨立的介面:PrinterScannerFaxMachineSimplePrinter類別僅實作了Printer介面,而AllInOnePrinter類別則同時實作了這三個介面。這種設計使得每個類別只實作了它們需要的功能,避免了不必要的介面實作。

圖表翻譯:

此圖示展示了SOLID原則中的OCP、LSP和ISP三個原則之間的關係。OCP強調對擴充套件開放和對修改封閉,LSP關注子類別替換基礎類別的能力,而ISP則提倡介面隔離。這些原則共同構成了物件導向設計的基礎,幫助開發者建立更具彈性和可維護性的軟體系統。透過遵循這些原則,我們能夠在軟體開發過程中保持程式碼的清晰性和可擴充性。

SOLID 設計原則在 Python 中的應用

SOLID 是五個重要的設計原則首字母縮寫,分別代表單一職責原則(Single Responsibility Principle)、開閉原則(Open/Closed Principle)、里氏替換原則(Liskov Substitution Principle)、介面隔離原則(Interface Segregation Principle)及依賴反轉原則(Dependency Inversion Principle)。這些原則對於撰寫可維護、可擴充套件且穩健的 Python 程式碼至關重要。

介面隔離原則(ISP)

介面隔離原則主張客戶端不應被迫實作它不需要的介面。換言之,應該設計多個特定的介面,而不是一個龐大且通用的介面。

範例:印表機介面設計

首先,我們定義幾個不同的介面,分別代表印表機、掃描器和傳真機的功能:

from typing import Protocol

class Printer(Protocol):
    def print_document(self) -> None:
        ...

class Scanner(Protocol):
    def scan_document(self) -> None:
        ...

class Fax(Protocol):
    def fax_document(self) -> None:
        ...

接著,我們實作一個多功能印表機類別 AllInOnePrinter,它同時實作了印表機、掃描器和傳真機的介面:

class AllInOnePrinter:
    def print_document(self) -> None:
        print("列印檔案")

    def scan_document(self) -> None:
        print("掃描檔案")

    def fax_document(self) -> None:
        print("傳送傳真")

同時,我們也定義一個簡單的印表機類別 SimplePrinter,它只實作印表機介面:

class SimplePrinter:
    def print_document(self) -> None:
        print("簡單列印")

為了進一步展示介面隔離原則的優勢,我們定義一個函式 do_the_print,它接受任何實作了 Printer 介面的物件:

def do_the_print(printer: Printer) -> None:
    printer.print_document()

測試程式碼

if __name__ == "__main__":
    all_in_one = AllInOnePrinter()
    all_in_one.scan_document()
    all_in_one.fax_document()
    do_the_print(all_in_one)
    
    simple = SimplePrinter()
    do_the_print(simple)

輸出結果:

掃描檔案
傳送傳真
列印檔案
簡單列印

Plantuml 介面隔離原則示意圖

圖表翻譯:

此圖示展示了介面隔離原則的應用。客戶端可以根據需要選擇性地依賴不同的介面,如 PrinterScannerFaxAllInOnePrinter 類別實作了所有這些介面,而 SimplePrinter 只實作了 Printer 介面。這種設計使得客戶端可以靈活地使用不同的類別,而不必依賴於它們不需要的功能。

依賴反轉原則(DIP)

依賴反轉原則主張高層模組不應依賴於低層模組,而應共同依賴於抽象介面。透過這種方式,可以降低模組之間的耦合度,提高系統的可維護性和可擴充套件性。

範例:通知系統設計

首先,我們定義一個 MessageSender 介面:

from typing import Protocol

class MessageSender(Protocol):
    def send(self, message: str) -> None:
        ...

接著,我們實作一個 Email 類別來實作 MessageSender 介面:

class Email:
    def send(self, message: str) -> None:
        print(f"傳送電子郵件:{message}")

然後,我們定義一個 Notification 類別,它依賴於 MessageSender 介面:

class Notification:
    def __init__(self, sender: MessageSender) -> None:
        self.sender = sender

    def send(self, message: str) -> None:
        self.sender.send(message)

測試程式碼

if __name__ == "__main__":
    email = Email()
    notif = Notification(sender=email)
    notif.send(message="這是一條訊息。")

輸出結果:

傳送電子郵件:這是一條訊息。

Plantuml 依賴反轉原則示意圖

圖表翻譯:

此圖示展示了依賴反轉原則的應用。Notification 類別依賴於 MessageSender 介面,而不是直接依賴於 Email 類別。Email 類別實作了 MessageSender 介面,從而使得 Notification 可以使用任何實作了該介面的類別。這種設計降低了類別之間的耦合度,提高了系統的靈活性。

建立設計模式

設計模式是經過多次實際應用驗證的有效程式設計解決方案。這些模式在程式設計師之間廣泛分享,並隨著時間的不斷改進而演變。這個主題因《設計模式:可複用物件導向軟體的基礎》一書而廣受關注,該書由四位知名專家共同撰寫。

設計模式系統地命名、闡述並解釋了物件導向系統中常見設計問題的通用解決方案。它描述了問題、解決方案、適用時機以及後果。同時,它還提供了實作提示和範例。解決方案是一種通用的物件和類別安排,用於解決特定問題,並可根據特定情境進行客製化和實作。

在物件導向程式設計(OOP)中,根據所解決的問題型別和所提供的解決方案型別,設計模式被劃分為多個類別。四人幫的書中介紹了23種設計模式,將其分為三類別:建立模式、結構模式和行為模式。

建立設計模式

建立設計模式是本章將要探討的第一類別設計模式。這些模式處理物件建立的不同導向,其目標是為直接物件建立(在Python中發生在__init__()函式內)不方便的情況提供更好的替代方案。

本章將涵蓋以下主要主題:

  • 工廠模式
  • 建構模式
  • 原型模式
  • 單例模式
  • 物件池模式

透過本章的學習,您將對建立設計模式有深入的瞭解,包括它們在Python中的實用性以及如何有效地使用它們。

技術需求

請參閱第一章中介紹的技術需求。

工廠模式

我們將從四人幫書中的第一個建立設計模式開始:工廠設計模式。在工廠設計模式中,客戶端(即客戶端程式碼)請求一個物件,而不知道該物件的來源(即使用哪個類別來生成它)。工廠背後的理念是簡化物件建立過程。透過集中式函式建立物件,比直接使用類別例項化更易於追蹤和管理物件的建立。工廠模式透過集中管理物件建立,降低了應用程式的維護複雜度。

工廠通常有兩種形式:工廠方法和抽象工廠。工廠方法是一個方法(或對於Python開發者來說是一個函式),根據輸入引數傳回不同的物件。抽象工廠是一組工廠方法,用於建立一組相關的物件。

工廠方法

工廠方法根據一個單一的函式,用於處理物件建立任務。我們執行該函式,並傳遞一個引數,該引數提供了我們想要的物件的資訊。結果,所需的物件被建立。

有趣的是,當使用工廠方法時,我們不需要知道結果物件是如何實作的,也不需要知道它來自哪裡。

實務範例

在現實生活中,我們可以在塑膠玩具製造的背景下找到工廠方法模式的應用。用於製造塑膠玩具的材料是相同的,但透過使用正確的塑膠模具,可以生產出不同的玩具(不同的形狀或外觀)。這就像有一個工廠方法,其中輸入是我們想要的玩具的名稱(例如,鴨子或汽車),輸出(在模製之後)是所請求的塑膠玩具。

在軟體領域,Django網頁框架使用工廠方法模式來建立不同種類別的欄位(例如CharFieldEmailField等)。它們的部分行為可以透過屬性(如max_lengthrequired)進行客製化。

工廠方法的使用場景

如果您發現自己無法追蹤透過直接類別例項化建立的物件,那麼您應該考慮使用工廠方法模式。工廠方法將物件建立集中起來,使得追蹤物件變得更加容易。值得注意的是,可以建立多個工廠方法,這是常見的做法。每個工廠方法在邏輯上將建立具有相似性的物件分組。例如,一個工廠方法可能負責連線到不同的資料函式庫(MySQL、SQLite);另一個工廠方法可能負責建立您請求的幾何物件(圓形、三角形)等等。

工廠方法在您想要將物件建立與物件使用分離的情況下也很有用。我們在建立物件時並不依賴於特定的類別;我們只需提供有關我們想要的物件的部分資訊。這使得在不更改使用工廠方法的程式碼的情況下,引入對工廠方法的變更變得容易。

另一個值得一提的使用場景與提高應用程式的效能和記憶體使用有關。工廠方法可以透過重複使用現有的物件來改善效能和記憶體使用。當我們使用直接類別例項化建立物件時,每次建立新物件時都會分配額外的記憶體(除非類別內部使用快取,但通常情況下並非如此)。我們可以在下面的程式碼(ch03/factory/id.py)中看到這種情況的實際表現,該程式碼建立了相同類別MyClass的兩個例項,並使用id()函式比較它們的記憶體位址。位址也會在輸出中列印出來,以便我們檢查。記憶體位址不同的事實意味著建立了兩個不同的物件。程式碼如下:

class MyClass:
    pass

if __name__ == "__main__":
    a = MyClass()
    b = MyClass()
    print(id(a) == id(b))
    print(id(a))
    print(id(b))

在我的電腦上執行程式碼(ch03/factory/id.py)會產生以下輸出:

False
4330224656
4331646704

圖表說明

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title 物件導向設計原則與Python實踐

package "物件導向程式設計" {
    package "核心概念" {
        component [類別 Class] as class
        component [物件 Object] as object
        component [屬性 Attribute] as attr
        component [方法 Method] as method
    }

    package "三大特性" {
        component [封裝
Encapsulation] as encap
        component [繼承
Inheritance] as inherit
        component [多型
Polymorphism] as poly
    }

    package "設計原則" {
        component [SOLID] as solid
        component [DRY] as dry
        component [KISS] as kiss
    }
}

class --> object : 實例化
object --> attr : 資料
object --> method : 行為
class --> encap : 隱藏內部
class --> inherit : 擴展功能
inherit --> poly : 覆寫方法
solid --> dry : 設計模式

note right of solid
  S: 單一職責
  O: 開放封閉
  L: 里氏替換
  I: 介面隔離
  D: 依賴反轉
end note

@enduml

圖表翻譯:

此圖示展示了物件建立的兩種不同方法:直接例項化和工廠方法。直接例項化導致多個不同的物件被建立,從而增加了記憶體的使用,並且難以追蹤這些物件。相比之下,工廠方法透過集中管理物件建立,不僅改善了效能和記憶體使用,也使得物件的管理和維護變得更加容易。這個圖表清晰地展示了工廠方法在物件建立和管理上的優勢。