记录一下OOP中的五个面向对象设计的原则;文章中的例子和理解借助ChatGPT3.5完成。
1. 单一职责原则 (Single Responsibility Principle, SRP):
一个类应该只负责一项职责。
不符合 SRP 原则的代码
class Animal {
constructor(name) {
this.name = name;
}
move() {
console.log(`${this.name} is moving`);
}
eat() {
console.log(`${this.name} is eating`);
}
}
// 符合 SRP 原则的代码
class Animal {
constructor(name) {
this.name = name;
}
move() {
console.log(`${this.name} is moving`);
}
}
class Food {
constructor(name) {
this.name = name;
}
eat() {
console.log(`Eating ${this.name}`);
}
}
一个类应该只负责一项职责从实现上来说就是一个类只提供一个实例化方法,一个具体类如果想要拥有move这个方法就需要继承Move类,如果想要拥有run这个方法就需要继承Run这个类。然而js本身【不支持多继承】,所以在js中实践SRP用【组合和混入】的方式:
class Engine {
start() {
console.log("Engine started");
}
}
class Car {
constructor(engine) {
// 这里如果写成this.engine = new Engine()
// 就不符合依赖反转的原则了
// 下面这句代码就是混入的具体过程
this.engine = engine;
}
start() {
this.engine.start();
console.log("Car started");
}
}
在js中实现混入常用的是Object.assign方法。
2. 开放封闭原则 (Open/Closed Principle, OCP):
软件实体(如类、模块、函数等)应该对扩展开放,对修改关闭。
假设我们正在编写一个图形绘制应用程序,它可以绘制各种形状,如圆形、矩形等。为了实现这个功能,我们创建了一个 Shape 类,并让每个具体的形状类都继承自 Shape 类。
class Shape {
draw() {
throw new Error("Not implemented");
}
}
class Circle extends Shape {
draw() {
console.log("Drawing a circle");
}
}
class Rectangle extends Shape {
draw() {
console.log("Drawing a rectangle");
}
}
在某个时刻,我们需要添加一个【新的形状类型】,例如三角形。按照 OCP 原则的要求,我们不应该直接修改 Shape 类或者其他已有的类,而是【通过扩展来实现】。
class Triangle extends Shape {
draw() {
console.log("Drawing a triangle");
}
}
简单来说就是,在写基类的时候,不应该在一开始就实现之后被子类覆写的方法,而是将其写成一个【抽象方法】,然后在子类中具体实现;如果不这样做就会出现在基类中的原有实现被子类篡改的情况;可以扩展基类没有实现的抽象方法但是不可以修改基类已经实现具体的方法。
3. 里氏替换原则 (Liskov Substitution Principle, LSP):
子类型必须能够替换掉它们的父类型。
不符合 LSP 原则的代码
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width) {
this.width = width;
this.height = width;
}
setHeight(height) {
this.height = height;
this.width = height;
}
}
如果使用 Square 类来代替 Rectangle 类,会导致错误的结果
let rect = new Square(5, 5);
rect.setWidth(10);
console.log(rect.area()); // 预期结果为 50,实际结果为 100
符合 LSP 原则的代码
class Shape {
area() {
throw new Error("Not implemented");
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(side) {
super();
this.side = side;
}
setSide(side) {
this.side = side;
}
area() {
return this.side ** 2;
}
}
有一个快速判断不符合里氏替换原则的方法就是看子类是否具有父类所有的属性和方法,也就是只能多不能少。在此基础之上再看子类是重写的方法是否能够实现父类同名方法的功能。简记为:只能多不能少,只能加不能减!
4. 接口隔离原则 (Interface Segregation Principle, ISP):
客户端不应该依赖它不需要的接口。应该【将大的接口拆分】成更小的、更具体的接口。
不符合 ISP 原则的代码
class Document {
open() {
console.log("Opening document");
}
save() {
console.log("Saving document");
}
print() {
console.log("Printing document");
}
}
符合 ISP 原则的代码
class PrintableDocument {
print() {
console.log("Printing document");
}
}
class SavableDocument {
save() {
console.log("Saving document");
}
}
class Document {
open() {
console.log("Opening document");
}
}
class MyDocument extends Document {
constructor() {
super();
// 这样写不符合以来反转原则
this.printable = new PrintableDocument();
this.savable = new SavableDocument();
}
doSomething() {
// 只需要调用需要的方法
this.printable.print();
this.savable.save();
}
}
-
看起来和【单一职责原则】比较像,实现接口隔离原则的【前提】就是单一职责原则,需要【粒度细】的接口,或者说一个接口只负责一个功能;
-
这样在具体类使用的时候,就可以【按需取用】,而不用担心取过来一个总接口然后其中大部分功能用不到的情况;
-
也就是说不能满足,存在A接口,能够同时实现B接口和C接口的功能的情况;
-
倘若具体类中既要实现B接口功能也要实现C接口功能,那么应该分别实现B和C而不应该存在一个A同时实现B和C接口功能。
5. 依赖反转原则 (Dependency Inversion Principle, DIP):
高层模块不应该依赖于底层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
不符合 DIP 原则的代码
class Database {
constructor() {
this.data = {};
}
setData(key, value) {
this.data[key] = value;
}
getData(key) {
return this.data[key];
}
}
class User {
constructor(name, email) {
this.name = name;
this.email = email;
this.db = new Database(); // 依赖具体实现,这里db的值不应该在User的构造函数中创建,而是应该通过参数传递进来
}
save() {
this.db.setData(this.email, { name: this.name, email: this.email });
}
load() {
let data = this.db.getData(this.email);
this.name = data.name;
this.email = data.email;
}
}
符合 DIP 原则的代码
class Database {
constructor() {
this.data = {};
}
setData(key, value) {
this.data[key] = value;
}
getData(key) {
return this.data[key];
}
}
class UserRepository {
constructor(db) {
this.db = db; // 依赖抽象接口,而非具体实现
}
save(user) {
this.db.setData(user.email, { name: user.name, email: user.email });
}
load(user) {
let data = this.db.getData(user.email);
user.name = data.name;
user.email = data.email;
}
}
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
save(userRepo) {
userRepo.save(this);
}
load(userRepo) {
userRepo.load(this);
}
}
let db = new Database();
let userRepo = new UserRepository(db);
let user = new User("John", "john@example.com");
user.save(userRepo);
总结:之前实现单一职责原则的时候,js由于其特殊性,采用的是混入的模式;混入的时候,其他对象成为新类的一个属性,这是没有问题的;问题在于这个属性的值是在新类的构造函数执行的时候被现场创建出来的,还是在constructor被调用的时候传进来的;这两种获得值方式的不同正是依赖反转原则的体现,如果是在构造函数中现场实例化出来的,那么新类和基类(或者说接口)耦合性强,以后需要修改的时候及必须进入新类中修改代码;而在新类中使用基类的方法也不能被称为是【接口】,因为此时这些方法是具体的;而如果属性的值是从外面传入的,在构建新类的时候无需关系这个值的具体内容,只需了解其能够提供什么样的方法(即接口),并且当基类对象被替换之后,对于新类来说是不可感知的,也就无需进入新类内部修改代码。
五大原则及特征小结:
-
单一职责原则 SRP:一个类应该只负责一项职责,只提供一个实例化方法,用【组合和混入】的方式实现;
-
开放封闭原则 OCP:实体对扩展开放,对修改关闭,可以扩展基类没有实现的抽象方法,但是不可以修改基类已经实现具体的方法;
-
里氏替换原则 LSP:子类型必须能够替换掉它们的父类型,只能多不能少,只能加不能减;
-
接口隔离原则 ISP:客户端不应该依赖它不需要的接口;
-
依赖反转原则 DIP:抽象不应该依赖于细节,细节应该依赖于抽象。