SOLID: OOP的五大原則(譯)

S.O.L.I.D 是 面向對象設計 (OOD) 的 5 個準則的首字母縮寫 ,這些準則是由 Robert C. Martin 提出的,他更爲人所熟知的名字是 Uncle Bob。

這些準則使得開發出易擴展、可維護的軟件變得更容易。也使得代碼更精簡、易於重構。同樣也是敏捷開發和自適應軟件開發的一部分。

備註: 這不是一篇簡單的介紹 “歡迎來到 _S.O.L.I.D” 的文章,這篇文章想要闡明 S.O.L.I.D** 是什麼。

注:本文代碼部分用typescript進行重寫

S.O.L.I.D 意思是:
擴展出來的首字母縮略詞看起來可能很複雜,實際上它們很容易理解。

S - 單一功能原則
O - 開閉原則
L - 里氏替換原則
I - 接口隔離原則
D - 依賴反轉原則

接下來讓我們看看每個原則,來了解爲什麼 S.O.L.I.D 可以幫助我們成爲更好的開發人員。

單一職責原則

縮寫是 S.R.P ,該原則內容是:

一個類有且只能有一個因素使其改變,意思是一個類只應該有單一職責.

例如,假設我們有一些圖形,並且想要計算這些圖形的總面積.是的,這很簡單對不對?

class Circle {
  public radius: number;
  constructor(radius: number) {
    this.radius = radius;
  }
}

class Square {
  public length: number;
  constructor(length: number) {
    this.length = length;
  }
}

首先,我們創建圖形類,該類的構造方法初始化必要的參數.接下來,創建 AreaCalculator 類,然後編寫計算指定圖形總面積的邏輯代碼.

class AreaCalculator {

    shapes: any[];

    constructor(shapes = []) {
        this.shapes = shapes;
    }

    public sum() {
        // logic to sum the areas
    }

    public output() {
        return `Sum of the areas of provided shapes:,${this.sum()}`;
    }
}

AreaCalculator 使用方法,我們只需簡單的實例化這個類,並且傳遞一個圖形數組,在頁面底部展示輸出內容.

const = shapes = [
    new Circle(2),
    new Square(5),
    new Square(6)
]

const areas = new AreaCalculator(shapes);

console.log(areas.output());

輸出方法的問題在於,AreaCalculator 處理了數據輸出邏輯.因此,假如用戶希望將數據以 json 或者其他格式輸出呢?

所有邏輯都由 AreaCalculator 類處理,這恰恰違反了單一職責原則 (SRP); AreaCalculator 類應該只負責計算圖形的總面積,它不應該關心用戶是想要 json 還是 HTML 格式數據。

因此,要解決這個問題,可以創建一個 SumCalculatorOutputter 類,並使用它來處理所需的顯示邏輯,以處理所有圖形的總面積該如何顯示。

SumCalculatorOutputter 類的工作方式如下:

const $shapes = array(
    new Circle(2),
    new Square(5),
    new Square(6)
);

const $areas = new AreaCalculator($shapes);
const output = new SumCalculatorOutputter($areas);

console.log(output.JSON());
console.log(output.HAML());
console.log(output.HTML());
console.log(output.JADE());

現在,無論你想向用戶輸出什麼格式數據,都由 SumCalculatorOutputter 類處理。

開閉原則

對象和實體應該對擴展開放,但是對修改關閉.

簡單的說就是,一個類應該不用修改其自身就能很容易擴展其功能.讓我們看一下 AreaCalculator 類,特別是 sum 方法.

    public sum() {
      let areaSum = 0;
      this.shapes.forEach(shape => {
            if(shape instanceof Square) {
              areaSum += Math.pow(shape.length, 2);
            } else if(shape instanceof Circle) {
                areaSum += Math.PI * Math.pow(shape.radius, 2);
            }
        });

        return areaSum;
    }

如果我們想用 sum 方法能計算更多圖形的面積,我們就不得不添加更多的 if/else blocks ,然而這違背了開閉原則.

讓這個 sum 方法變得更好的方式是將計算每個形狀面積的代碼邏輯移出 sum 方法,將其放進各個形狀類中:

class Square {
  public length: number;
  constructor(length: number) {
    this.length = length;
  }
  public area() {
    return this.length ** 2;
  }
}

相同的操作應該被用來處理 Circle 類,在類中添加一個 area 方法。 現在,計算任何形狀面積之和應該像下邊這樣簡單:

    public sum() {
      return this.shapes.reduce((prev, next) => prev + next.area(), 0);
    }

接下來我們可以創建另一個形狀類並在計算總和時傳遞它而不破壞我們的代碼。 然而現在又出現了另一個問題,我們怎麼能知道傳入 AreaCalculator 的對象實際上是一個形狀,或者形狀對象中有一個 area 方法?

接口編碼是實踐 S.O.L.I.D 的一部分,例如下面的例子中我們創建一個接口類,每個形狀類都會實現這個接口類:

interface ShapeInterface {
    area(): number;
}

class Circle implements ShapeInterface{
  public radius: number;
  constructor(radius: number) {
    this.radius = radius;
  }
  area() {
    return Math.PI * this.radius ** 2
  }
}

在我們的 AreaCalculator 的 sum 方法中,我們可以檢查提供的形狀類的實例是否是 ShapeInterface 的實現,否則我們就拋出一個異常:

class AreaCalculator {

    shapes: ShapeInterface[];
    constructor(shapes: ShapeInterface[]  = []) {
        this.shapes = shapes;
    }

    public sum() {
      return this.shapes.reduce((prev, next) => prev + next.area(), 0);
    }

    public output() {
        return `Sum of the areas of provided shapes:,${this.sum()}`;
    }
}
const ac = new AreaCalculator(['square']) // error Type 'string' is not assignable to type 'ShapeInterface'.

這時,當我們傳入沒有實現ShapeInterface的數組時,就會報錯。

里氏替換原則

如果對每一個類型爲 T1 的對象 o1,都有類型爲 T2 的對象 o2,使得以 T1 定義的所有程序 P 在所有的對象 o1 都代換成 o2 時,程序 P 的行爲沒有發生變化,那麼類型 T2 是類型 T1 的子類型。

這句定義的意思是說:每個子類或者衍生類可以毫無問題地替代基類 / 父類。

依然使用 AreaCalculator 類,假設我們有一個 VolumeCalculator 類,這個類繼承了 AreaCalculator 類:

class VolumeCalculator extends AreaCalculator {
    constructor(shapes = []) {
      super(shapes);
    }

    public sum() {
        // logic to calculate the volumes and then return and array of output
        const result = 200; // 假如算出來是200
        return [result]; // typescript會提示我們子類要和父類返回類型要一致
    }
}
// SumCalculatorOutputter 類:

class SumCalculatorOutputter {
    protected calculator;

    constructor($calculator: AreaCalculator ) {
        this.calculator = $calculator;
    }

    public JSON() {
      const data = {
        sum: this.calculator.sum()
      }

        return JSON.stringify(data);
    }

    public HTML() {
        return `<p>Sum of the areas of provided shapes: ${this.calculator.sum()}</p>`;
    }
}

如果我們運行像這樣一個例子:

$areas = new AreaCalulator($shapes);
$volumes = new VolumeCalculator($solidShapes);

$output = new SumCalculatorOutputter($areas);
$output2 = new SumCalculatorOutputter($volumes);

程序不會出問題, 但當我們使用 $output2 對象調用 HTML 方法時 ,我們接收到一個 E_NOTICE 錯誤,提示我們 數組被當做字符串使用的錯誤。

爲了修復這個問題,只需讓子類的sum方法也返回一個數字:

public function sum() {
    // logic to calculate the volumes and then return and array of output
	const result = 200;
    return result;
}

而不是讓 VolumeCalculator 類的 sum 方法返回數組。

result 是一個浮點數、雙精度浮點數或者整型。

接口隔離原則

使用方(client)不應該依賴強制實現不使用的接口,或不應該依賴不使用的方法。

繼續使用上面的 shapes 例子,已知擁有一個實心塊,如果我們需要計算形狀的體積,我們可以在 ShapeInterface 中添加一個方法:

interface ShapeInterface {
    area(): number;
    volume(): number;
}

任何形狀創建的時候必須實現 volume 方法,但是【平面】是沒有體積的,實現這個接口會強制的讓【平面】類去實現一個自己用不到的方法。

ISP 原則不允許這麼去做,所以我們應該創建另外一個擁有 volume 方法的 SolidShapeInterface 接口去代替這種方式,這樣類似立方體的實心體就可以實現這個接口了:

interface ShapeInterface {
    area(): number;
}

interface SolidShapeInterface {
    volume(): number;
}

class Cuboid implements ShapeInterface, SolidShapeInterface {
    public area() {
        //計算長方體的表面積
    }

    public volume() {
        // 計算長方體的體積
    }
}

這是一個更好的方式,但是要注意提示類型時不要僅僅提示一個 ShapeInterface 或 SolidShapeInterface。
你能創建其它的接口,比如 ManageShapeInterface , 並在平面和立方體的類上實現它,這樣你能很容易的看到有一個用於管理形狀的 api。例:

interface ManageShapeInterface {
    calculate(): number;
}

class Square implements ShapeInterface, ManageShapeInterface {
    public function area() { /Do stuff here/ }

    public function calculate() {
        return this.area();
    }
}

class Cuboid implements ShapeInterface, SolidShapeInterface, ManageShapeInterface {
    public function area() { /Do stuff here/ }
    public function volume() { /Do stuff here/ }

    public function calculate() {
        return this.area() + this.volume();
    }
}

現在在 AreaCalculator 類中,我們可以很容易地用 calculate 替換對 area 方法的調用,並檢查對象是否是 ManageShapeInterface 的實例,而不是 ShapeInterface 。

依賴倒置原則

最後,但絕不是最不重要的:

實體必須依賴抽象而不是具體的實現.即高等級模塊不應該依賴低等級模塊,他們都應該依賴抽象.

這也許聽起來讓人頭大,但是它很容易理解.這個原則能夠很好的解耦,舉個例子似乎是解釋這個原則最好的方法:

class MySQLConnection {
 ...
}

class PasswordReminder {
    private dbConnection: MySQLConnection;

    constructor(dbConnection: MySQLConnection) {
        this.dbConnection = dbConnection;
    }
}

首先 MySQLConnection 是低等級模塊,然而 PasswordReminder 是高等級模塊,但是根據 S.O.L.I.D. 中 D 的解釋:依賴於抽象而不依賴與實現, 上面的代碼段違背了這一原則,因爲 PasswordReminder 類被強制依賴於 MySQLConnection 類.

稍後,如果你希望修改數據庫驅動,你也不得不修改 PasswordReminder 類,因此就違背了 Open-close principle.

此 PasswordReminder 類不應該關注你的應用使用了什麼數據庫,爲了進一步解決這個問題,我們「面向接口寫代碼」,由於高等級和低等級模塊都應該依賴於抽象,我們可以創建一個接口:

interface DBConnectionInterface {
    connect(): void;
}

這個接口有一個連接數據庫的方法,MySQLConnection 類實現該接口,在 PasswordReminder 的構造方法中不要直接將類型約束設置爲 MySQLConnection 類,而是設置爲接口類,這樣無論你的應用使用什麼類型的數據庫,PasswordReminder 類都能毫無問題地連接數據庫,且不違背 開閉原則.

class MySQLConnection implements DBConnectionInterface {
    public connect() {
        return "Database connection";
    }
}

class PasswordReminder {
    private dbConnection: DBConnectionInterface;

    constructor(dbConnection: DBConnectionInterface) {
        this.dbConnection = dbConnection;
    }
}

Copy
從上面一小段代碼,你現在能看出高等級和低等級模塊都依賴於抽象 DBConnectionInterface 了。

總結

說實話,S.O.L.I.D 一開始似乎很難掌握,但只要不斷地使用和遵守其原則,它將成爲你的一部分,使你的代碼易被擴展、修改,測試,即使重構也不容易出現問題。

原文:https://www.digitalocean.com/community/conceptual_articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design
參考:https://learnku.com/php/t/28922

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章