SOLID 原則:編寫可擴展且可維護的代碼

本文翻譯自國外論壇 medium,原文地址:https://forreya.medium.com/the-solid-principles-writing-scalable-maintainable-code-13040ada3bca


有沒有人告訴過你,你寫的是 “糟糕的代碼” ?

如果你寫過,其實也沒什麼好羞愧的。在學習的過程中,我們都會編寫有缺陷的代碼。但是好消息是對於 “糟糕的代碼” 進行改進是相當簡單的,但前提是你願意改。

改進代碼的最佳方法之一是學習一些編程設計原則。我們可以將編程原則視爲成爲一名更好的程序員的進階指南或者可以說這是代碼的原始哲學。現在我將介紹五個基本原則,它們將被涵蓋縮寫在 SOLID 單詞下。

我將在示例中使用 Python,但這些概念可以輕鬆轉移到其他語言(例如 Java)。

  1. SOLID 第一個單詞 “S” 代表單一職責 =========================

單一職責

這個原則告訴我們:

將我們的代碼分解成模塊,每個模塊有一個職責。

讓我們看一下這個 Person 類,它會執行和 Person 類不相關的任務,例如發送電子郵件和計算稅金。

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age

    def send_email(self, message):
        # 用於向此人發送電子郵件的代碼
        print(f"Sending email to {self.name}: {message}")

    def calculate_tax(self):
        # 爲此人計算稅費的代碼
        tax = self.age * 100
        print(f"{self.name}'s tax: {tax}")

根據單一職責原則,我們應該將 Person 類拆分爲幾個更小的類,以避免違反該原則。

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

class EmailSender:
    def send_email(person, message):
        # 向此人發送電子郵件的代碼
        print(f"Sending email to {person.name}: {message}")

class TaxCalculator:
    def calculate_tax(person):
        # 爲該人計算稅費的代碼
        tax = person.age * 100
        print(f"{person.name}'s tax: {tax}")

雖然代碼量變得更多,但現在我們可以更容易地識別代碼的每個部分試圖完成的任務,可以更乾淨地測試代碼,並在其他地方重用代碼的一部分(而不需要擔心不相關的方法)。

  1. 第二個單詞 “O” 代表開閉原則 ===================

開閉原則

這一原則建議我們設計的模塊遵循:

將來添加新功能而無需直接修改我們現有的代碼。

一旦模塊被使用,它基本上就被鎖定了,這減少了任何新添加破壞代碼的機會。

由於其自相矛盾的性質,這是 5 個原則中最難完全掌握的原則之一,所以讓我們看一個例子:

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

    def calculate_area(self):
        if self.shape_type == "rectangle":
            # 計算並返回矩形的面積
        elif self.shape_type == "triangle":
            # 計算並返回三角形的面積

在上面的示例中,Shape 類直接在其 calculate_area() 方法中處理不同的形狀類型。這違反了開閉原則,因爲我們正在修改現有代碼而不是擴展它。

這種設計是有問題的,因爲隨着添加更多形狀類型,calculate_area() 方法變得更加複雜且難以維護。它違反了職責分離的原則,並使代碼的靈活性和可擴展性降低。讓我們看一下解決這個問題的一種方法。

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

    def calculate_area(self):
        pass

class Rectangle(Shape):
    def calculate_area(self):
        # 爲矩形實現calculate_area()方法

class Triangle(Shape):
    def calculate_area(self):
        # 實現三角形的calculate_area()方法

在上面的例子中,我們定義了基類 Shape,它的唯一目的是讓更具體的形狀類繼承它的屬性。例如,Triangle 類擴展爲 calculate_area() 方法來計算並返回三角形的面積。

通過遵循開閉原則,我們可以在不修改現有 Shape 類的情況下添加新形狀。這使我們能夠擴展代碼的功能,而無需更改其核心實現。

  1. 第三個單詞 “L” 代表里氏替換原則(LSP) ==========================

里氏替換原則

這個原則告訴我們以下內容:

子類應該能夠與父類互換使用,而不會破壞程序的功能。

這到底是什麼意思呢?讓我們考慮一個帶有名爲 start_Engine() 方法的 Vehicle (車輛)類。

class Vehicle:
    def start_engine(self):
        pass

class Car(Vehicle):
    def start_engine(self):
        # 啓動汽車發動機
        print("Car engine started.")

class Motorcycle(Vehicle):
    def start_engine(self):
        # 啓動摩托車發動機
        print("Motorcycle engine started.")

根據里氏替換原則Vehicle 的任何子類也應該能夠毫無問題地啓動發動機。

但是,如果我們添加了 Bicycle(自行車)類。顯然我們將無法再啓動發動機,因爲自行車沒有發動機。下面演示瞭解決此問題的錯誤方法。

class Bicycle(Vehicle):
    def ride(self):
        # 騎自行車
        print("Riding the bike.")

    def start_engine(self):
         # 引發錯誤
        raise NotImplementedError("Bicycle does not have an engine.")

爲了正確遵守 LSP,我們可以採取兩條路線。我們來看看第一個。

解決方案 1Bicycle 成爲自己的類(無繼承),以確保所有 Vehicle 子類的行爲與其超類一致。

class Vehicle:
    def start_engine(self):
        pass

class Car(Vehicle):
    def start_engine(self):
        # Start the car engine
        print("Car engine started.")

class Motorcycle(Vehicle):
    def start_engine(self):
        # Start the motorcycle engine
        print("Motorcycle engine started.")

class Bicycle():
    def ride(self):
        # Rides the bike
        print("Riding the bike.")

解決方案 2:將父類 Vehicle 分爲兩部分,一種用於帶發動機的車輛,另一種用於後者。然後所有子類都可以與其父類互換使用,而不會改變預期行爲或引入異常。

class VehicleWithEngines:
    def start_engine(self):
        pass

class VehicleWithoutEngines:
    def ride(self):
        pass

class Car(VehicleWithEngines):
    def start_engine(self):
        # 啓動汽車發動機
        print("Car engine started.")

class Motorcycle(VehicleWithEngines):
    def start_engine(self):
        # 啓動摩托車發動機
        print("Motorcycle engine started.")

class Bicycle(VehicleWithoutEngines):
    def ride(self):
        # 騎自行車
        print("Riding the bike.")
  1. 第四個單詞 “I” 代表接口隔離原則 =====================

接口隔離原則

這個原則指出,我們的模塊不應該被迫擔心它們不使用的功能。解釋如下:

特定於客戶端的接口比通用接口更好。這意味着類不應該被迫依賴於它們不使用的接口。相反,他們應該依賴更小、更具體的接口。

假設我們有一個 Animal 接口,其中包含 walk()swim()Fly() 等方法。

class Animal:
    def walk(self):
        pass

    def swim(self):
        pass

    def fly(self):
        pass

這裏有個問題,並不是所有的 Animal 都能完成所有這些動作。

例如:狗不會游泳或飛翔,因此這兩種從 Animal 接口繼承的方法都是多餘的。

class Dog(Animal):
    # 狗只能走路
    def walk(self):
        print("Dog is walking.")

class Fish(Animal):
    # 魚只會游泳
    def swim(self):
        print("Fish is swimming.")

class Bird(Animal):
    # 鳥不會游泳
    def walk(self):
        print("Bird is walking.")

    def fly(self):
        print("Bird is flying.")

我們需要將 Animal 接口分解爲更小、更具體的子類別,然後我們可以使用這些子類別來組成每種動物所需的一組精確功能。

class Walkable:
    def walk(self):
        pass

class Swimmable:
    def swim(self):
        pass

class Flyable:
    def fly(self):
        pass

class Dog(Walkable):
    def walk(self):
        print("Dog is walking.")

class Fish(Swimmable):
    def swim(self):
        print("Fish is swimming.")

class Bird(Walkable, Flyable):
    def walk(self):
        print("Bird is walking.")

    def fly(self):
        print("Bird is flying.")

通過這樣做,我們實現了一種設計,其中類只依賴它們需要的接口,減少了不必要的依賴。這在測試時變得特別有用,因爲它允許我們僅模擬每個模塊所需的功能。

  1. 第五個單詞 “D” 代表依賴倒置原則 =====================

依賴倒置原則

這個解釋起來非常簡單,它指出:

高層模塊不應該直接依賴於低層模塊。相反,兩者都應該依賴於抽象(接口或抽象類)

讓我們再來看一個例子。假設我們有一個 ReportGenerator 類,它可以自然地生成報告。要執行此操作,需要首先從數據庫中獲取數據。

class SQLDatabase:
    def fetch_data(self):
        # 從 SQL 數據庫獲取數據
        print("Fetching data from SQL database...")

class ReportGenerator:
    def __init__(self, database: SQLDatabase):
        self.database = database

    def generate_report(self):
        data = self.database.fetch_data()
        # 使用獲取的數據生成報告
        print("Generating report...")

在此示例中,ReportGenerator 類直接依賴於具體的 SQLDatabase 類。

目前這工作正常,但如果我們想切換到不同的數據庫(例如 MongoDB)怎麼辦?這種緊密耦合使得在不修改 ReportGenerator 類的情況下更換數據庫實現變得困難。

爲了遵守依賴倒置原則,我們將引入 SQLDatabaseMongoDatabase 類都可以依賴的抽象(或接口)。

class Database():
    def fetch_data(self):
        pass

class SQLDatabase(Database):
    def fetch_data(self):
        # 從 SQL 數據庫獲取數據
        print("Fetching data from SQL database...")

class MongoDatabase(Database):
    def fetch_data(self):
        # 從 Mongo 數據庫獲取數據
        print("Fetching data from Mongo database...")

請注意 ReportGenerator 類現在還通過其構造函數依賴於新的數據庫接口。

class ReportGenerator:
    def __init__(self, database: Database):
        self.database = database

    def generate_report(self):
        data = self.database.fetch_data()
        # 使用獲取的數據生成報告
        print("Generating report...")

高級模塊(ReportGenerator)現在不直接依賴於低級模塊(SQLDatabaseMongoDatabase)。相反,它們都依賴於接口(數據庫)。

依賴倒置意味着我們的模塊不需要知道它們正在獲得什麼實現 — 只需要知道它們將接收某些輸入並返回某些輸出。

個人思考

SOLID

現在我在網上看到很多關於 SOLID 設計原則以及它們是否經受住時間考驗的討論。在這個多範式編程、雲計算和機器學習的現代世界中,SOLID 仍然有意義嗎?

就我個人而言,我相信 SOLID 原則永遠是好的代碼設計的基礎。有時在使用小型應用程序時,這些原則的好處可能並不明顯,但一旦開始處理較大規模的項目,代碼質量的差異就值得我們努力學習它們。SOLID 所提倡的模塊化仍然使這些原則成爲現代軟件體系結構的基礎,我個人認爲這種情況短期內不會改變。


博主總結

這裏博主在對 SOLID 原則做一個總結輸出。

SOLID 原則是一組編程設計原則,旨在提高軟件的可擴展性、可維護性和質量。它們分別是:

通過遵循這些原則,我們可以編寫出更加清晰、靈活和可複用的代碼,降低耦合度和代碼腐化的風險,提高代碼的可測試性和可讀性。當然,這些原則並不是鐵律,而是指導性的建議,我們需要根據具體的場景和需求來靈活地運用它們。希望本文能夠對你有所幫助和啓發。😎

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9TgOxeZ_9YBh7NfwUOb50w