跳到主要内容

面向对象设计原则

· 阅读需 9 分钟
amass
一个正在躺平的板砖人

今天在腾讯会议面试了一家叫 致趣科技 的公司。当然,面试无非就是先自我介绍以前自己做什么,负责做什么,然后按照简历写着自己会啥会啥为中心提问各种问题,最后就是围绕着自己简历写着的做过的项目进行项目技术和细节提问。

不过进两次面试,都问到了 你知道面向对象的设计原则么?、你知道什么是SOLID原则么?呃,我都老实说我答不上来,可能知道这么个意思,但是不知道如何表述。现在自己想者,虽然有着五年的工作经验,不过嘛,面试,不问这些问些啥呢。

我想,可能设计模式也不会落下。

SOLID 指代面向对象编程和面向对象设计的五个基本原则:单一职责(Single Responsiblity Principle)、开闭原则(Open Close Principle)、里氏替换(Liskov Substitution Principle)、接口隔离(Interface Segregation Principle)以及依赖反转(Dependency Inversion Principle)。

这些原则各自的定位和它们之间的关系如下图:

概括地讲:单一职责是所有设计原则的基础,开闭原则是设计的终极目标。里氏替换原则强调的是子类替换父类后程序运行时的正确性,它用来帮助实现开闭原则。而接口隔离原则用来帮助实现里氏替换原则,同时它也体现了单一职责。依赖倒置原则是过程式设计与面向对象设计的分水岭,同时它也被用来指导接口隔离原则。

开闭原则

软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。

如果一个类/函数对修改是封闭的,那么怎么扩展这个类的功能?这恰好是开闭原则要解决的问题,这个原则尝试去做到:新增功能时,已有的业务代码可以几乎完全保持不变。因为已有的类和函数都没有变更,因此我们才能有信心判断这次变更不会影响其他的功能,这样的设计才是健壮的。

应用单一职责只需要梳理和归纳,而应用开闭原则的关键则在于抽象。我们用一段代码示例来解释应用开闭原则的过程:

class Shape {
public:
virtual ~Shape() {
}
};
class Square : public Shape {};
class Rectangle : public Shape {};
class Circle : public Shape {};

void drawSquare(const Square *shape) {
// 具体实现 ...
}
void drawRectangle(const Rectangle *shape) {
// 具体实现 ...
}
void drawCircle(const Circle *shape) {
// 具体实现 ...
}

void drawAll(const std::list<Shape *> &shapes) {
for (auto shape : shapes) {
if (auto square = dynamic_cast<Square *>(shape); square != nullptr) {
drawSquare(square);
} else if (auto rectangle = dynamic_cast<Rectangle *>(shape); rectangle != nullptr) {
drawRectangle(rectangle);
} else if (auto circle = dynamic_cast<Circle *>(shape); circle != nullptr) {
drawCircle(circle);
}
}
}

上面的代码有几个问题:

  • 脆弱:如果需要新增一种 Shape,我们必须继续更改 drawAll() 函数,并继续新增条件语句。
  • 牢固:drawAll() 依赖于 Rectangle 和 Circle,无法拿出来复用。可复用和可测试很多情况下都是同义词,比如为了测试 drawAll() 我们需要把所有 Shape 代码都准备好,具体的 Shape 存在问题也会导致 drawAll 的单元测试失败,而且很难 mock 掉 drawAll() 的依赖。

这时我们需要对 drawAll() 的功能进行“抽象”。比如我们发现 drawAll() 其实并不关心 shape 的类型是 Circle 还是 Rectangle,只需要它能够 draw。因此我们让 drawAll、Circle、Rectangle 都依赖一个能够 draw 的 Shape 接口即可,解除 drawAll 对具体的 Circle 和 Rectangle 类型的依赖。重构后得到这样的代码:

class Shape {
public:
virtual void draw() = 0;
virtual ~Shape() {
}
};

class Square : public Shape {
public:
void draw() final {
// 具体实现 ...
}
};

class Rectangle : public Shape {
public:
void draw() final {
// 具体实现 ...
}
};
class Circle : public Shape {
public:
void draw() final {
// 具体实现 ...
}
};

void drawAll(const std::list<Shape *> &shapes) {
for (auto shape : shapes) {
shape->draw();
}
}

注意应用开闭原则进行重构对上述代码产生的影响:

  • 因为我们从 drawAll() 里干掉了条件分支,这份代码不再脆弱。
  • 也不再牢固,因为 drawAll 不再依赖任何具体的类,它的单元测试和复用也更容易。
  • 也不再僵化,因为需要新增类型时我们只需要新增一个 Star 类,不需要对 drawAll() 引入变更。

里氏替换原则

所有基类出现的地方都可以用派生类替换而不会程序产生错误。子类可以扩展父类的功能,但不能改变父类原有的功能。

class Bird {
public:
virtual float flySpeed() = 0;
float fly(float distance) { // 返回飞行distance所需要的时间
return distance / flySpeed();
}
};

class Swallow : public Bird {
public:
float flySpeed() final {
return 120;
}
};

class Phoenix : public Bird {
public:
float flySpeed() final {
return 240;
}
};

我们定义了一个类叫 Bird,这个接口有两个方法,然后我们有一个燕子类,一个凤凰类。可以看到,Bird 的四个方法用 Swallow 类和 Phoenix 类来代替是没有问题的。

class Chicken  : public Bird {
public:
float flySpeed() final {
return 240;
}
};

但是用 Chicken 类来代替当调用到 fly 方法的时候就会抛出异常。这个就不符合 Liskov 原则,因为作为 Bird 类的子类的 Chicken 类没有做到替换父类 Bird 类而不影响程序运行。解决方法是拆分Bird类的功能,因为家禽是不会飞的。

总结

  • 单一职责原则是 SOLID 所有原则的基础和解决问题的思路。
  • 开闭原则是直接保障代码质量的原则,用来解决设计的脆弱性、僵化、难以阅读、难以复用等问题,应用开闭原则的关键在于如何“抽象”。
  • 里氏替换原则通过确保子类和父类是 "is a" 的关系,来帮助实现开闭原则。该原则的使用中,引申出面向对象角度的 "is a" 是关于行为的,以及模型的正确性不是内在的,而是由它的客户程序来体现。
  • 接口隔离原则提供了一种方案,在不违反里氏替换原则的情况下,如何实现开闭原则。同时接口隔离的思想本身也体现了单一职责原则。
  • 依赖倒置原则是过程式设计与面向对象设计的分水岭,通过适当的抽象,让高层模块和底层模块同样地可复用和可测试。同时它也被用来指导接口隔离原则。