设计原则
设计原则(Design Principles)指的是那些在软件开发中,特别是在设计软件组件时应该遵循的指导原则。这些原则旨在提高代码的可维护性、可扩展性和灵活性。
单一职责原则(SRP)
一个类(或者接口、方案)应该只做一件事,只有一个变化的原因。
单一职责原则的重点在于职责的划分,很多时候并不是一成不变的,需要根据实际情况而定。
我们在设计一个类的时候,可以先从粗粒度的类开始设计,等到业务发展到一定规模,如果发现这个粗粒度的类方法和属性太多,且经常修改的时候,我们就可以对这个类进行重构了,将这个类拆分成粒度更细的类。
开放封闭原则(OCP)
类应该是开放的,可以扩展,而不能修改(也就是说对扩展开放,对更改封闭;修改意味着更改现有类的代码,扩展意味着添加新功能)。
那么如何在不触及类的情况下添加新功能呢?它通常是在接口和抽象类的帮助下完成的。
例如,我们需要设计一个图形类库,里面有圆形、正方形等形状:
class Shape {
draw() {
// 绘制圆形、正方形等形状的代码
}
}
class Shape {
draw() {
// 绘制圆形、正方形等形状的代码
}
}
如果要添加新的形状,就需要修改这个类。我们可以将绘图方法定义为抽象方法,让子类去实现:
abstract class Shape {
abstract draw();
}
class Circle extends Shape {
draw() {
console.log('画圆');
}
}
class Square extends Shape {
draw() {
console.log('画正方形');
}
}
abstract class Shape {
abstract draw();
}
class Circle extends Shape {
draw() {
console.log('画圆');
}
}
class Square extends Shape {
draw() {
console.log('画正方形');
}
}
Liskov 替换原则(LSP)
里氏替换原则指出,子类应该可以替代其基类。
也就是说,所有父类能出现的地方,子类就可以出现,并且替换了也不会出现任何错误。
接口隔离原则(ISP)
原则表面客户端不应该强迫依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上。
例如,如果一个接口提供了很多方法,但是一个类只需要用到其中的几个方法,那么可以将这个接口拆分成多个更小的接口。
我们经常会给别人提供服务,而服务调用方可能有很多个。很多时候我们会提供一个统一的接口给不同的调用方,但有些时候调用方 A 只使用 1、2、3 三个方法,调用方 B 只使用 4、5 两个方法。接口隔离原则的意思是,你应该把 1、2、3 拆为为一个接口,4、5 拆为一个接口,这样接口之间就隔离开来了。
依赖倒置原则(DIP)
高层模块不应该依赖底层模块,两者都应该依赖其抽象。抽象不应该依赖细节,即接口或抽象类不依赖于实现类。细节应该依赖抽象,即实现类不应该依赖于接口或抽象类。
如果一个类依赖于另一个类的具体实现,那么当这个依赖的具体实现发生变化时,就需要修改原来的类。我们可以将这两个类都抽象为接口或基类,让它们之间只依赖于它们的共同点(即共同的接口或基类)。
class B {
doSomething() {
console.log('B');
}
}
class A {
constructor(b: B) {
this.b = b;
}
fun() {
return this.b.doSomething();
}
}
class B {
doSomething() {
console.log('B');
}
}
class A {
constructor(b: B) {
this.b = b;
}
fun() {
return this.b.doSomething();
}
}
如果要替换 B 的实现,就需要修改 A 类。我们可以将 B 抽象为接口:
interface BInterface {
doSomething(): void;
}
class B implements BInterface {
doSomething() {
console.log('B');
}
}
class C implements BInterface {
doSomething() {
console.log('C');
}
}
class A {
constructor(b: BInterface) {
this.b = b;
}
fun() {
return this.b.doSomething();
}
}
interface BInterface {
doSomething(): void;
}
class B implements BInterface {
doSomething() {
console.log('B');
}
}
class C implements BInterface {
doSomething() {
console.log('C');
}
}
class A {
constructor(b: BInterface) {
this.b = b;
}
fun() {
return this.b.doSomething();
}
}
这样,A 类就不需要依赖于 B 类的具体实现了。如果要替换 B 的实现,只需要将 A 构造函数的参数改为 C 即可。
迪米特法则(LoD)
迪米特法则也叫最少知识原则,就是说一个对象应该对其他对象有尽可能少的了解。
该原则要求对象只与其直接的关系对象进行交互,避免暴露过多的信息给外部对象,从而减少对象之间的依赖关系。旨在降低软件模块之间的耦合度,增强模块的独立性和可复用性。
在迪米特法则中,对于一个对象,其朋友包括以下几类:
- 当前对象本身(this);
- 以参数形式传入到当前对象方法中的对象;
- 当前对象的成员对象;
- 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
- 当前对象所创建的对象。
任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互。
在买房子这件事情上,中介者(中介公司)就是一个迪米特法则的典型应用。中介者充当了两个对象之间的中介,使得这两个对象不需要直接交互。如果中介者不介入,买家和卖家就需要直接交互,这样买家就知道了卖家的很多信息,违背了迪米特法则。
合成复用原则(CRP)
优先使用对象组合,而不是继承来达到复用的目的。
在面向对象的编程中,继承是用来表示类之间的“is-a”关系,而合成(或称为聚合)是用来表示类之间的“has-a”关系。如果一个类可以重用另一个类的代码,那么应该优先考虑使用合成而不是继承。
- 类继承通常为“白箱复用”,对象组合通常为“黑箱复用”;
- 继承在某种程度上破坏了封装性,子类父类耦合度高;
- 而对象组合只要求被组合的对象具有良好定义的接口,耦合度低。
例如,如果要设计一个图形编辑器,可以使用合成原则来实现:
interface Shape {
draw(): void;
}
class Rectangle implements Shape {
draw() {
console.log('绘制矩形');
}
}
class Circle implements Shape {
draw() {
console.log('绘制圆形');
}
}
class GraphicEditor {
shapes: Shape[] = [];
addShape(shape: Shape) {
this.shapes.push(shape);
}
drawAllShapes() {
for (const shape of this.shapes) {
shape.draw();
}
}
}
interface Shape {
draw(): void;
}
class Rectangle implements Shape {
draw() {
console.log('绘制矩形');
}
}
class Circle implements Shape {
draw() {
console.log('绘制圆形');
}
}
class GraphicEditor {
shapes: Shape[] = [];
addShape(shape: Shape) {
this.shapes.push(shape);
}
drawAllShapes() {
for (const shape of this.shapes) {
shape.draw();
}
}
}
这样,GraphicEditor
类就可以在不修改的情况下添加新的图形类了。
其它建议
高内聚松耦合
- 使用封装来创建对象之间的分界层,让设计者可以在分界层的一侧进行修改,而不会对另一侧产生不良的影响,从而实现层次间的松耦合
针对接口编程,而不是针对实现编程
- 不将变量类型声明为某个特定的具体类,而是声明为某个接口
- 客户程序无需获知对象的具体类型,只需要知道对象所具有的接口
- 减少系统中各部分的依赖关系,从而实现,“高内聚,松耦合”的类型设计方案