在軟體開發領域,這個以其多樣化和強烈持有觀點而聞名的領域,很少有實踐能夠像 SOLID 原則那樣達成共識,作為成為更好的軟體工程師的保證途徑。

Robert C. Martin 在 2000 年代初期正式製定的 5 條黃金法則極大地影響了軟體開發產業,並為更好的程式碼品質和決策過程製定了新標準,至今仍具有相關性。

堅實的原則

SOLID 原則是專門為支援 OOP(物件導向程式設計)範例而設計的。因此,本文是為希望提高開發技能並編寫更優雅、可維護和可擴展程式碼的 OOP 開發人員而設計的。

這裡使用的語言是 TypeScript,遵循常見的跨語言 OOP 概念。需要基本的 OOP 知識。


1. S = 單一職責原則(SRP)

單一職責原則 (SRP) 是五個 SOLID 原則之一,它規定每個類別應該只有一個職責,以保持有意義的關注點分離。

此模式是一種稱為「上帝物件」的常見反模式的解決方案,「上帝物件」只是指承擔太多職責的類別或物件,使其難以理解、測試和維護。

遵循 SRP 規則有助於使程式碼元件可重複使用、鬆散耦合且易於理解。讓我們探討這項原則,展示 SRP 違規情況和解決方案。

全域宣告

enum Color {
    BLUE = 'blue',
    GREEN = 'green',
    RED = 'red'
}

enum Size {
    SMALL = 'small',
    MEDIUM = 'medium',
    LARGE = 'large'
}

class Product {
    private _name: string;
    private _color: Color;
    private _size: Size;

    constructor (name: string, color: Color, size: Size) {
        this._name = name;
        this._color = color;
        this._size = size;
    }

    public get name(): string { return this._name; }
    public get color(): Color { return this._color; }
    public get size(): Size { return this._size; }
}

違反

在下面的程式碼中, ProductManager類別負責products 的建立和存儲,違反了單一職責原則。

class ProductManager {
    private _products: Product[] = [];

    createProduct (name: string, color: Color, size: Size): Product {
        return new Product(name, color, size);
    }

    storeProduct (product: Product): void {
        this._products.push(product);
    }

    getProducts (): Product[] {
        return this._products;
    }
}

const productManager: ProductManager = new ProductManager();

const product: Product = productManager.createProduct('Product 1', Color.BLUE, Size.LARGE);
productManager.storeProduct(product);

const allProducts: Product[] = productManager.getProducts();

解決

將產品建立和儲存的處理分離到兩個不同的類別可以減少ProductManager類別的職責數量。這種方法進一步模組化了程式碼並使其更易於維護。

class ProductManager {
    createProduct (name: string, color: Color, size: Size): Product {
        return new Product(name, color, size);
    }
}

class ProductStorage {
    private _products: Product[] = [];

    storeProduct (product: Product): void {
        this._products.push(product);
    }

    getProducts (): Product[] {
        return this._products;
    }
}

用法:

const productManager: ProductManager = new ProductManager();
const productStorage: ProductStorage = new ProductStorage();

const product: Product = productManager.createProduct("Product 1", Color.BLUE, Size.LARGE);

productStorage.storeProduct(product);
const allProducts: Product[] = productStorage.getProducts();

2. O = 開閉原理 (OCP)

“軟體實體應該對擴展開放,但對修改關閉”

開閉原則 (OCP) 是「寫一次,寫得夠好以便可擴展,然後就忘記它」。

這項原則的重要性取決於這樣一個事實:模組可能會根據新的需求不時發生變化。如果在模組編寫、測試並上傳到生產環境後出現新需求,則修改此模組通常是不好的做法,尤其是當其他模組依賴它時。為了防止這種情況,我們可以使用開閉原則。

全域宣告

enum Color {
    BLUE = 'blue',
    GREEN = 'green',
    RED = 'red'
}

enum Size {
    SMALL = 'small',
    MEDIUM = 'medium',
    LARGE = 'large'
}

class Product {
    private _name: string;
    private _color: Color;
    private _size: Size;

    constructor (name: string, color: Color, size: Size) {
        this._name = name;
        this._color = color;
        this._size = size;
    }

    public get name(): string { return this._name; }
    public get color(): Color { return this._color; }
    public get size(): Size { return this._size; }
}

class Inventory {
    private _products: Product[] = [];

    public add(product: Product): void {
        this._products.push(product);
    }

    addArray(products: Product[]) {
        for (const product of products) {
            this.add(product);
        }
    }

    public get products(): Product[] {
        return this._products;
    }
}

違反

讓我們描述一個實作產品過濾類別的場景。讓我們加入按顏色過濾產品的功能。

class ProductsFilter {
    byColor(inventory: Inventory, color: Color): Product[] {
        return inventory.products.filter(p => p.color === color);
    }
}

我們已經測試了此程式碼並將其部署到生產中。

幾天后,客戶請求一項新功能 - 也按大小過濾。然後我們修改該類別以支援新的要求。

現在違反了開閉原則!

class ProductsFilter {
    byColor(inventory: Inventory, color: Color): Product[] {
        return inventory.products.filter(p => p.color === color);
    }

    bySize(inventory: Inventory, size: Size): Product[] {
        return inventory.products.filter(p => p.size === size);
    }
}

解決

在不違反 OCP 的情況下實現過濾機制的正確方法應該使用「規範」類別。

abstract class Specification {
    public abstract isValid(product: Product): boolean;
}

class ColorSpecification extends Specification {
    private _color: Color;

    constructor (color) {
        super();
        this._color = color;
    }

    public isValid(product: Product): boolean {
        return product.color === this._color;
    }
}

class SizeSpecification extends Specification {
    private _size: Size;

    constructor (size) {
        super();
        this._size = size;
    }

    public isValid(product: Product): boolean {
        return product.size === this._size;
    }
}

// A robust mechanism to allow different combinations of specifications
class AndSpecification extends Specification {
    private _specifications: Specification[];

    // "...rest" operator, groups the arguments into an array
    constructor ((...specifications): Specification[]) {
        super();
        this._specifications = specifications;
    }

    public isValid (product: Product): boolean {
        return this._specifications.every(specification => specification.isValid(product));
    }
}

class ProductsFilter {
    public filter (inventory: Inventory, specification: Specification): Product[] {
        return inventory.products.filter(product => specification.isValid(product));
    }
}

用法:

const p1: Product = new Product('Apple', Color.GREEN, Size.LARGE);
const p2: Product = new Product('Pear', Color.GREEN, Size.LARGE);
const p3: Product = new Product('Grapes', Color.GREEN, Size.SMALL);
const p4: Product = new Product('Blueberries', Color.BLUE, Size.LARGE);
const p5: Product = new Product('Watermelon', Color.RED, Size.LARGE);

const inventory: Inventory = new Inventory();
inventory.addArray([p1, p2, p3, p4, p5]);

const greenColorSpec: ColorSpecification = new ColorSpecification(Color.GREEN);
const largeSizeSpec: SizeSpecification = new SizeSpecification(Size.LARGE);

const andSpec: AndSpecification = new AndSpecification(greenColorSpec, largeSizeSpec);
const productsFilter: ProductsFilter = new ProductsFilter();

const filteredProducts: Product[] = productsFilter.filter(inventory, andSpec); // All large green products

過濾機制現在是完全可擴展的。現有的類別不應該再被修改。

如果有新的過濾要求,我們只需建立一個新規範即可。或者如果需要更改規範組合,可以透過使用AndSpecification類別輕鬆完成。


3. L=里氏替換原理(LSP)

里氏替換原則(LSP)是軟體元件靈活性和穩健性的重要規則。它由 Barbara Liskov 提出,並成為 SOLID 原則的基本要素。

LSP 規定超類別的物件應該可以用子類別的物件替換,而不影響程式的正確性。換句話說,子類別應該擴展超類別的行為而不改變其原始功能。採用這種方法可以提高軟體元件的質量,確保可重複使用性並減少意外的副作用。

違反

下面的範例說明了違反里氏替換原則 (LSP) 的場景。當Rectangle物件被Square物件取代時,透過檢查程序的行為可以觀察到這種違規的跡象。

聲明:

class Rectangle {
    protected _width: number;
    protected _height: number;

    constructor (width: number, height: number) {
        this._width = width;
        this._height = height;
    }

    get width (): number { return this._width; }
    get height (): number { return this._height; }

    set width (width: number) { this._width = width; }
    set height (height: number) { this._height = height; }

    getArea (): number {
        return this._width * this._height;
    }
}

// A square is also rectangle
class Square extends Rectangle {
    get width (): number { return this._width; }
    get height (): number { return this._height; }

    set height (height: number) {
        this._height = this._width = height; // Changing both width & height
    }

    set width (width: number) {
        this._width = this._height = width; // Changing both width & height
    }
}

function increaseRectangleWidth(rectangle: Rectangle, byAmount: number) {
    rectangle.width += byAmount;
}

用法:

const rectangle: Rectangle = new Rectangle(5, 5);
const square: Square = new Square(5, 5);

console.log(rectangle.getArea()); // Expected: 25, Got: 25 (V)
console.log(square.getArea()); // Expected: 25, Got: 25 (V)

// LSP Violation Indication: Can't replace object 'rectangle' (superclass) with 'square' (subclass) since the results would be different.
increaseRectangleWidth(rectangle, 5);
increaseRectangleWidth(square, 5);

console.log(rectangle.getArea()); // Expected: 50, Got: 50 (V)

// LSP Violation, increaseRectangleWidth() changed both width and height of the square, unexpected behavior.
console.log(square.getArea()); //Expected: 50, Got: 100 (X)

解決

重構的程式碼現在遵循 LSP,確保超類別Shape的物件可以替換為子類別RectangleSquare的物件,而不會影響計算面積的正確性,也不會引入任何改變程式行為的不必要的副作用。

聲明:

abstract class Shape {
    public abstract getArea(): number;
}

class Rectangle extends Shape {
    private _width: number;
    private _height: number;

    constructor (width: number, height: number) {
        super();
        this._width = width;
        this._height = height;
    }

    getArea (): number { return this._width * this._height; }
}

class Square extends Shape {
    private _side: number;

    constructor (side: number) {
        super();
        this._side = side;
    }

    getArea (): number { return this._side * this._side; }
}

function displayArea (shape: Shape): void {
    console.log(shape.getArea());
}

用法:

const rectangle: Rectangle = new Rectangle(5, 10);
const square: Square = new Square(5);

// The rectangle's area is correctly calculated
displayArea(rectangle); // Expected: 50, Got: 50 (V)

// The square's area is correctly calculated
displayArea(square); // Expected: 25, Got: 25 (V)

4. I = 介面隔離原則 (ISP)

介面隔離原則 (ISP) 強調建立特定於客戶端的介面而不是一刀切的重要性。

這種方法根據客戶的需求集中類,消除了類別必須實現它實際上不使用或不需要的方法的情況。

透過應用介面隔離原則,軟體系統可以以更靈活、易於理解和易於重構的方式建構。讓我們來看一個例子。

違反

這裡違反了 ISP 規則,因為Robot必須實現完全沒有必要的eat()函數。

interface Worker {
    work(): void;
    eat(): void;
}

class Developer implements Worker {
    public work(): void {
        console.log('Coding..');
    }

    public eat(): void {
        console.log('Eating..');
    }
}

class Robot implements Worker {
    public work(): void {
        console.log('Building a car..');
    }

    // ISP Violation: Robot is forced to implement this function even when unnecessary
    public eat(): void {
        throw new Error('Cannot eat!');
    }
}

解決

下面的範例代表了我們之前遇到的問題的解決方案。現在,介面更加簡潔且更加特定於客戶端,允許客戶端類別僅實現與其相關的方法。

interface Workable {
    work(): void;
}

interface Eatable {
    eat(): void;
}

class Developer implements Workable, Eatable {
    public work(): void {
        console.log('Coding..');
    }

    public eat(): void {
        console.log('Eating...');
    }
}

class Robot implements Workable {
    public work(): void {
        console.log('Building a car..');
    }

    // No need to implement eat(), adhering ISP.
}

ISP 前後:

重構前後的介面隔離原則


5. D = 依賴倒置原理(DIP)

依賴倒置原則(DIP)是最終的SOLID原則,重點是透過使用抽象來減少低層模組(例如資料讀取/寫入)和高層模組(執行關鍵操作)之間的耦合。

DIP 對於設計能夠適應變化、模組化且易於更新的軟體至關重要。

DIP 的關鍵準則是:

  1. 高層模組不應該依賴低層模組。兩者都應該依賴抽象。這意味著應用程式的功能不應該依賴特定的實現,以便使系統更加靈活並且更容易更新或替換低階實現。

  2. 抽像不應該依賴細節。細節應該取決於抽象。這鼓勵設計專注於實際需要什麼操作,而不是如何實現這些操作。

違反

讓我們來看一個展示依賴倒置原則 (DIP) 違規的範例。

MessageProcessor (高階模組)緊密耦合並直接依賴FileLogger (低階模組),違反了原則,因為它不依賴抽象層,而是依賴特定的類別實作。

額外獎勵:這也違反了開閉原則(OCP)。如果我們想要更改日誌記錄機制以寫入資料庫而不是文件,我們將被迫直接修改MessageProcessor函數。

import fs from 'fs';

// Low Level Module
class FileLogger {
    logMessage(message: string): void {
        fs.writeFileSync('somefile.txt', message);
    }
}

// High Level Module
class MessageProcessor {
    // DIP Violation: This high-level module is is tightly coupled with the low-level module (FileLogger), making the system less flexible and harder to maintain or extend.
    private logger = new FileLogger();

    processMessage(message: string): void {
        this.logger.logMessage(message);
    }
}

解決

以下重構的程式碼表示為了遵守依賴倒置原則 (DIP) 所需進行的變更。與前面的範例相反,高階類別MessageProcessor持有特定低階類別FileLogger的私有屬性,現在它持有Logger類型的私有屬性(表示抽象層的介面)。

這種更好的方法減少了類別之間的依賴關係,從而使程式碼更具可擴展性和可維護性。

聲明:

import fs from 'fs';

// Abstraction Layer
interface Logger {
    logMessage(message: string): void;
}

// Low Level Module #1
class FileLogger implements Logger {
    logMessage(message: string): void {
        fs.writeFileSync('somefile.txt', message);
    }
}

// Low Level Module #2
class ConsoleLogger implements Logger {
    logMessage(message: string): void {
        console.log(message);
    }
}

// High Level Module
class MessageProcessor {
    // Resolved: The high level module is now loosely coupled with the low level logger modules.
    private _logger: Logger;

    constructor (logger: Logger) {
        this._logger = logger;
    }

    processMessage (message: string): void {
        this._logger.logMessage(message);
    }
}

用法:

const fileLogger = new FileLogger();
const consoleLogger = new ConsoleLogger();

// Now the logging mechanism can be easily replaced
const messageProcessor = new MessageProcessor(consoleLogger);
messageProcessor.processMessage('Hello');

DIP 之前和之後:

重構前後的依賴倒置原則

結論

透過遵循 SOLID 原則,開發人員在開發或維護任何規模的軟體系統時,可以避免常見的陷阱,例如緊密耦合、缺乏靈活性、程式碼可重複使用性差以及一般維護困難。掌握這些原則是成為更好的軟體工程師的又一步。


原文出處:https://dev.to/idanref/solid-the-5-golden-rules-to-level-up-your-coding-skills-2p82


共有 0 則留言