設計模式七大原則(包含SOLID和迪米特法則、合成復用原則)

馬士兵教育cto 發佈 2023-12-03T04:46:19.148926+00:00

前言設計模式七大原則是用來指導面向對象設計的。其中的前5大,大家應該都比較熟悉了 SOLID的是有關面向對象設計的5個原則的首字母縮略詞。

前言

設計模式七大原則是用來指導面向對象設計的。其中的前5大,大家應該都比較熟悉了 SOLID的是有關面向對象設計的5個原則的首字母縮略詞。它包含

  1. Single-responsibility principle
  2. Open–closed principle
  3. Liskov substitution principle
  4. Interface segregation principle
  5. Dependency inversion principle 除了這些還有迪米特法則、合成復用原則。

遵循以上原則,有利於寫出易懂,靈活,可維護性高的代碼。下面來逐個介紹

Single-responsibility principle

單一職責原則:一個類只負責一件事,只有一個引起它變化的原因。 如果一個類型,負責多件事,那麼它的每個職責在修改時,都會修改這個類。而修改,就有可能帶來bug。 而且,如果其它的類只需要它的某個功能,但繼承了它,就會連那些不需要的功能一起繼承了,也可能帶來bug。

Open–closed principle

開閉原則:軟體實體(比如class)應該對擴展開放,對修改封閉 具體一點,它的意思是應該可以通過新增代碼來給類實現新的功能,而且儘量避免對原有代碼的修改。 這裡有一個例子

javascript複製代碼class Screen {
  drawShapes(shapes: Shape[]): void {
    for (const shape of shapes) {
      if (shape instanceof Circle) {
        drawCircle(shape);
      } else if (shape instanceof Rectangle) {
        drawRectangle(shape);
      } else {
        throw new Error("Unknown shape!");
      }
    }
  }
}

如果想給drawShapes新增畫三角形的功能,就得新增一個else if,違反了此原則。應該改成這樣:

php複製代碼class Screen {
  drawShapes(shapes: Shape[]): void {
    for (const shape of shapes) {
      shape.draw();
    }
  }
}

其中,每種shape的draw方法都由它自己實現。這樣避免了Screen和具體shape的耦合。新增shape時,不需要修改這個Screen::drawShapes里的代碼,它就能支持新的shape的繪製。

下面再舉一個例子 blinker.readthedocs.io/en/stable/ 這是python里的一個library。裡面有個signal的使用,就遵循OCP。 開發者可以把每種事件,抽象成一個signal。然後給這個signal添加回調函數。

scss複製代碼def log_on_event(event: Event):
    logger.info("Clicker at {}, {}".format(event.x, event.y))


def get_file(event: Event):
    requests.get(event.url)


on_click = signal("on_click")

on_click.connect(log_on_event)
on_click.connect(get_file)

on_click.send(Event(1, 2, "http://localhost:5000"))

如果想給on_click增加新的處理邏輯,只要新增on_click.connect(SOME_HANDLER)就行了。

Liskov substitution principle

里式替換原則:父類應當可以用子類替換。 也就是,父類能夠出現的地方,子類一定能夠出現。 這個原則的一個作用是來幫助開發者判斷一個繼承關係是否合理的。 這裡有一個著名的問題 Circle-ellipse problem。理論上,圓是橢圓的一種,當橢圓的長軸和短軸相等時,這個橢圓就成了圓。 但其實在面向對象設計中,圓繼承橢圓是不合適的。

ts複製代碼class Ellipse {
  constructor(public x: number, public y: number) {
  }
  setX(x: number) {
    this.x = x;
  }
  setY(y: number) {
    this.y = y;
  }
}

如果一個Circle類繼承了Ellipse,setX和setY,無論怎麼實現都是不合適的。原本接受Ellipse的代碼,如果換成了Circle,那麼代碼就可能出錯。

Interface segregation principle

接口隔離原則:不應強制客戶端依賴它們不需要的接口 這個原則有點像單一職責原則的「接口版本」。舉個例子

scss複製代碼interface ParkingLot {
  parkCar();
  doPayment(car: Car);
}

這個接口表示停車場,其中有兩個方法,一個停車,一個支付。 這裡的問題,有個停車場免費,所以沒有doPayment方法。 解決此問題的一個方法是拆成這兩個接口。

kotlin複製代碼interface ParkingLot {
  parkCar();
}

interface ParkingLotPayment {
  doPayment(car: Car);
}

免費停車場只需實現ParkingLot,付費停車場需要實現以上2個接口。

Dependency inversion principle

依賴倒置原則:類應該依賴接口或者抽象類,而不是具體類和函數。 再次考慮我在Open–closed principle中舉的Screen例子。其實,在這個例子中,我們是按照DIP來改造了Screen類。同時也讓這個類遵循了OCP。通過這個例子,我們可以發現,OCP和DIP有著緊密的聯繫。 DIP是一個實現OCP的方法,但不是唯一的方法。

Law of Demeteror (or principle of least knowledge)

迪米特法則,又稱最小知道原則,它的概念是一個對象應該對其他對象保持最少的了解。它是一種讓面向對象設計實現低耦合高內聚的方法。 舉個例子

ini複製代碼class Client {
  orderTakeout(foodService: FoodService, foodName: string) {
    const cook = foodService.getCook();
    const food = cook.cook(foodName);
    return food;
  }
}

這段代碼是沒有遵循LoD的。因為client不應該與cook交互。如果foodService獲取事務的邏輯不再是以上這個邏輯,那client也得改代碼。 不如改成以下形式

php複製代碼class Client {
  orderTakeout(foodService: FoodService, foodName: string) {
    const food = foodService.getFood();
    return food;
  }
}

這種形式中,Client類保持了對FoodSerivice的最小了解,職責變得簡單了。foodSerice獲取食物的邏輯無論怎樣更改,都不需要修改client的代碼。

Composite Reuse Principle (or composition over inheritance)

合成復用原則(又稱為組合優於繼承):使用組合來實現代碼復用和多態,而不是繼承。 繼承雖然很常用,但是也有一些問題。最近幾年流行的新語言,有些不支持繼承,比如go, rust。 這裡以繼承和組合兩種方式來實現同一個邏輯,來展示組合比繼承好在哪裡。 這個例子有點像我在接口隔離原則里提到的。

scala複製代碼abstract class IBird {
  eat() {
    console.log("Eat food");
  };
  fly(): void {
    console.log("Flying.");
  }
}

class Ostrich extends IBird {
  fly() {
    throw new Error("Not implemented");
  }
}

這種方法的問題,子類Ostrich繼承了它完全不需要的方法。這個問題,如果仍要使用繼承來解決,也是能解決的,但是比較麻煩

IFlyableBird

+fly()

IBird

+eat()

Pigeon

+sendMessage()

Ostrich

+run()

如果把鳥會不會跑、會不會叫,等問題考慮進來,那繼承關係更複雜了。假設鳥類擁有5種能力,這5種能力的組合高達2^5=32種,每一種都用1個類或者接口來表示就太麻煩了。

繼承一個問題在於:繼承層次過深、繼承關係過於複雜時會影響到代碼的可讀性和可維護性。

下面,把以上的邏輯使用組合來表示。 把吃食物、飛翔的能力,作為對象,讓各種鳥類引用這些對象,來表示它們擁有這些能力。

typescript複製代碼interface IFlyAbility {
  fly(): void;
}

interface IEatAbility {
  eat(): void;
}

class Pigeon {
  flyAbility: IFlyAbility;
  eatAbility: IEatAbility;
  fly() {
    this.flyAbility.fly();
  }
  eat() {
    this.eatAbility.eat();
  }
}

class Ostrich {
  eatAbility: IEatAbility;
  eat() {
    this.eatAbility.eat();
  }
}

以上代碼有以下優勢

  1. 繼承關係簡單。如果需要再增加一種能力,也只需要新增一個能力接口和一個能力實現即可。
  2. 靈活。類的繼承關係在編譯期就已經確定了,它的任意一種行為也是確定的。但組合關係中,一個對象可以隨便改變它引用的對象,這樣可以在運行時改變它的某個行為的實現。

總結

學習了以上幾種原則,我對低耦合高內聚有了更深刻的認識。 SIP體現了高內聚。 OCP,DIP,LoD體現了降低高層模塊與底層模塊的耦合。 組合優於繼承,體現了避免繼承關係帶來的耦合。

以上原則也不是完美的。例如在一個項目的實現方式(例如使用哪個資料庫)很少改動的情況下,遵循DIP會使代碼變得囉嗦。在明確一個類不會被繼承的情況下,而且項目較小的情況下,遵守SRP也會使代碼變得囉嗦。總之,應該靈活地去選擇和使用。

關鍵字: