基于 TypeScript 理解程序設計的 SOLID 原則
大家好,我是 ConardLi,今天我們來基于 TypeScript 回顧學習下程序設計中的 SOLID 原則。
說到 SOLID 原則,可能寫過代碼的同學們應該都聽過吧,這是程序設計領域最常用到的設計原則。SOLID 由 羅伯特·C·馬丁 在 21 世紀早期引入,指代了面向對象編程和面向對象設計的五個基本原則, SOLID 其實是以下五個單詞的縮寫:
- Single Responsibility Principle:單一職責原則
- Open Closed Principle:開閉原則
- Liskov Substitution Principle:里氏替換原則
- Interface Segregation Principle:接口隔離原則
- Dependency Inversion Principle:依賴倒置原則
TypeScript 的出現讓我們可以用面向對象的思想編寫出更簡潔的 JavaScript 代碼,在下面的文章中,我們將用 TypeScript 編寫一些示例來分別解釋下這些原則。
單一職責原則(SRP)
核心思想:類的職責應該單一,不要承擔過多的職責。
我們先看看下面這段代碼,我們為 Book 創建了一個類,但是類中卻承擔了多個職責,比如把書保存為一個文件:
class Book {
public title: string;
public author: string;
public description: string;
public pages: number;
// constructor and other methods
public saveToFile(): void {
// some fs.write method to save book to file
}
}
遵循單一職責原則,我們應該創建兩個類,分別負責不同的事情:
class Book {
public title: string;
public author: string;
public description: string;
public pages: number;
// constructor and other methods
}
class Persistence {
public saveToFile(book: Book): void {
// some fs.write method to save book to file
}
}
好處:降低類的復雜度、提高可讀性、可維護性、擴展性、最大限度的減少潛在的副作用。
開閉原則(OCP)
核心思想:類應該對擴展開放,但對修改關閉。簡單理解就是當別人要修改軟件功能的時候,不能讓他修改我們原有代碼,盡量讓他在原有的基礎上做擴展。
先看看下面這段寫的不太好的代碼,我們單獨封裝了一個 AreaCalculator 類來負責計算 Rectangle 和 Circle 類的面積。想象一下,如果我們后續要再添加一個形狀,我們要創建一個新的類,同時我們也要去修改 AreaCalculator 來計算新類的面積,這違反了開閉原則。
class Rectangle {
public width: number;
public height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
}
class Circle {
public radius: number;
constructor(radius: number) {
this.radius = radius;
}
}
class AreaCalculator {
public calculateRectangleArea(rectangle: Rectangle): number {
return rectangle.width * rectangle.height;
}
public calculateCircleArea(circle: Circle): number {
return Math.PI * (circle.radius * circle.radius);
}
}
為了遵循開閉原則,我們只需要添加一個名為 Shape 的接口,每個形狀類(矩形、圓形等)都可以通過實現它來依賴該接口。通過這種方式,我們可以將 AreaCalculator 類簡化為一個帶有參數的函數,每當我們創建一個新的形狀類,都必須實現這個函數,這樣就不需要修改原有的類了:
interface Shape {
calculateArea(): number;
}
class Rectangle implements Shape {
public width: number;
public height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
public calculateArea(): number {
return this.width * this.height;
}
}
class Circle implements Shape {
public radius: number;
constructor(radius: number) {
this.radius = radius;
}
public calculateArea(): number {
return Math.PI * (this.radius * this.radius);
}
}
class AreaCalculator {
public calculateArea(shape: Shape): number {
return shape.calculateArea();
}
}
里氏替換原則(LSP)
核心思想:在使用基類的的地方可以任意使用其子類,能保證子類完美替換基類。簡單理解就是所有父類能出現的地方,子類就可以出現,并且替換了也不會出現任何錯誤。
我們必須要求子類的所有相同方法,都必須遵循父類的約定,否則當父類替換為子類時就會出錯。
先來看看下面這段代碼,Square 類擴展了 Rectangle 類。但是這個擴展沒有任何意義,因為我們通過覆蓋寬度和高度屬性來改變了原有的邏輯。
class Rectangle {
public width: number;
public height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
public calculateArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
public _width: number;
public _height: number;
constructor(width: number, height: number) {
super(width, height);
this._width = width;
this._height = height;
}
}
遵循里氏替換原則,我們不需要覆蓋基類的屬性,而是直接刪除掉 Square 類并,將它的邏輯帶到 Rectangle 類,而且也不改變其用途。
class Rectangle {
public width: number;
public height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
public calculateArea(): number {
return this.width * this.height;
}
public isSquare(): boolean {
return this.width === this.height;
}
}
好處:增強程序的健壯性,即使增加了子類,原有的子類還可以繼續運行。
接口隔離原則(ISP)
核心思想:類間的依賴關系應該建立在最小的接口上。簡單理解就是接口的內容一定要盡可能地小,能有多小就多小。我們要為各個類建立專用的接口,而不要試圖去建立一個很龐大的接口供所有依賴它的類去調用。
看看下面的代碼,我們有一個名為 Troll 的類,它實現了一個名為 Character 的接口,但是 Troll 既不會游泳也不會說話,所以它似乎不太適合實現我們的接口:
interface Character {
shoot(): void;
swim(): void;
talk(): void;
dance(): void;
}
class Troll implements Character {
public shoot(): void {
// some method
}
public swim(): void {
// a troll can't swim
}
public talk(): void {
// a troll can't talk
}
public dance(): void {
// some method
}
}
遵循接口隔離原則,我們刪除 Character 接口并將它的功能拆分為四個接口,然后我們的 Troll 類只需要依賴于我們實際需要的這些接口。
interface Talker {
talk(): void;
}
interface Shooter {
shoot(): void;
}
interface Swimmer {
swim(): void;
}
interface Dancer {
dance(): void;
}
class Troll implements Shooter, Dancer {
public shoot(): void {
// some method
}
public dance(): void {
// some method
}
}
依賴倒置原則(DIP)
核心思想:依賴一個抽象的服務接口,而不是去依賴一個具體的服務執行者,從依賴具體實現轉向到依賴抽象接口,倒置過來。
看看下面這段代碼,我們有一個 SoftwareProject 類,它初始化了 FrontendDeveloper 和 BackendDeveloper 類:
class FrontendDeveloper {
public writeHtmlCode(): void {
// some method
}
}
class BackendDeveloper {
public writeTypeScriptCode(): void {
// some method
}
}
class SoftwareProject {
public frontendDeveloper: FrontendDeveloper;
public backendDeveloper: BackendDeveloper;
constructor() {
this.frontendDeveloper = new FrontendDeveloper();
this.backendDeveloper = new BackendDeveloper();
}
public createProject(): void {
this.frontendDeveloper.writeHtmlCode();
this.backendDeveloper.writeTypeScriptCode();
}
}
遵循依賴倒置原則,我們創建一個 Developer 接口,由于 FrontendDeveloper 和 BackendDeveloper 是相似的類,它們都依賴于 Developer 接口。
我們不需要在 SoftwareProject 類中以單一方式初始化 FrontendDeveloper 和 BackendDeveloper,而是將它們作為一個列表來遍歷它們,分別調用每個 develop() 方法。
interface Developer {
develop(): void;
}
class FrontendDeveloper implements Developer {
public develop(): void {
this.writeHtmlCode();
}
private writeHtmlCode(): void {
// some method
}
}
class BackendDeveloper implements Developer {
public develop(): void {
this.writeTypeScriptCode();
}
private writeTypeScriptCode(): void {
// some method
}
}
class SoftwareProject {
public developers: Developer[];
public createProject(): void {
this.developers.forEach((developer: Developer) => {
developer.develop();
});
}
}
好處:實現模塊間的松耦合,更利于多模塊并行開發。