设计原则 miniWiki

简介

缩写 全称 中译名
SRP Single Resposibility Principle 单一责任原则
OCP Open–Closed Principle 开放封闭原则
LSP Liskov Substitution Principle 里氏替换原则
ISP Interface Segregation Principle 接口分离原则
DIP Dependency Inversion Principle 依赖倒置原则

这五条原则的首字母恰好构成英文单词 SOLID,因此在英文中常被称为 the SOLID principles。 在面向对象设计 (Object Oriented Design, OOD) 中,它们是指导类 (class) 设计的基本原则。如果将的含义推广为一组耦合的函数及数据,则这些原则可以被用在更一般的模块 (module) 设计上。

SRP: Single Resposibility Principle

SRP 的原始定义为:

A class should have one, and only one, reason to change.

其中 reason to change 是指被称作 actora group of people who require a change,因此 SRP 可以重新表述为:

A module should be responsible to one, and only one, actor.

违反 SRP 的类通常会有以下问题:

  • 某个 actor 的修改影响其他 actor 的行为。
  • 不同 actor 的修改在合并时发生冲突。

解决方案通常为:

  • 将数据与函数分离,将函数按 actor 拆分为若干相互独立的小类。
  • 如果上述拆分造成类的数量过多,可以用 Facade 模式创建一个接口。

OCP: Open–Closed Principle

该原则是 Bertrand Meyer (1988) 提出的:

A software artifact should be open for extension, but closed for modification.

这里的 software artifact 可以是类 (class)模块 (module)组件 (component)

遵循 OCP 的架构设计,通常会将系统引起变化原因划分为若干组件,并使组件之间的依赖符合 DIP,从而实现高层业务逻辑不受低层实现细节变化的影响。

LSP: Liskov Substitution Principle

Subtypes must be substitutable for their base types.

该原则源自于 Barbara Liskov [1988] 对子类 (subtype) 所作的定义:

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

这里的 object 在 C++ 中应当理解为指针 (pointer)引用 (reference)

语言实现机制

在 C++、Java、Python 等主流面向对象语言中,LSP 主要是通过虚函数 (virtual function) 机制来实现的。

假设需要这样一个几何库:

  • 所有几何类型都是 Shape 的派生类。
  • 所有几何类型都要实现一个返回当前几何对象面积的 GetArea() 成员。
  • 某个应用需要将 GetArea() 成员封装为一个非成员接口函数,符合 LSP 的设计应当允许以下的简单实现:
inline double GetArea(const Shape& shape) {
  return shape.GetArea();
}

下面给出三种设计方案:

原始设计 ⚠️

不了解虚函数的新手可能会给出如下设计:

struct Shape {
  double GetArea() const {
    return -1;
  }
};
struct Point : public Shape {
  double x;
  double y;
  Point(double a, double b)
      : x(a), y(b) {
  }
  double GetArea() const {
    return 0;
  }
};
class LineSegment : public Shape {
  Point head_;
  Point tail_;
 public:
  LineSegment(const Point& head, const Point& tail)
      : head_(head), tail_(tail) {
  }
  double GetArea() const {
    return 0;
  }
};
class Circle : public Shape {
  Point center_;
  double radius_;
 public:
  Circle(const Point& point, double radius)
      : center_(point), radius_(radius) {
  }
  double GetArea() const {
    return 3.1415926 * radius_ * radius_;
  }
};

该设计违反了 LSP:将派生类对象传递给接收基类引用的非成员接口函数,实际调用的总是基类的 GetArea() 成员,得到的返回值总是 -1,从而无法通过下面的单元测试:

#include <cassert>
int main() {
  auto p = Point(0, 0);
  auto q = Point(1, 0);
  assert(p.GetArea() == GetArea(p));
  auto ls = LineSegment(p, q);
  assert(ls.GetArea() == GetArea(ls));
  auto c = Circle(p, 2);
  assert(c.GetArea() == GetArea(c));
}

基于 RTTI 的设计 ⚠️

如果不使用虚函数机制,往往会引入运行期类型识别 (Run-Time Type Identification, RTTI) 或其他类似的机制。 一种实现方式:在基类中定义一个枚举成员 type,在派生类的构造函数中对其进行初始化,用于表示当前几何对象的类型。

struct Shape {
  enum class Type { Shape, Point, LineSegment, Circle };
  const Type type;
  Shape(Type t)
      : type(t) {
  }
  double GetArea() const {
    return -1;
  }
};
struct Point : public Shape {
  double x;
  double y;
  Point(double a, double b)
      : Shape(Shape::Type::Point), x(a), y(b) {
  }
  double GetArea() const {
    return 0;
  }
};
class LineSegment : public Shape {
  Point head_;
  Point tail_;
 public:
  LineSegment(const Point& head, const Point& tail)
      : Shape(Shape::Type::LineSegment), head_(head), tail_(tail) {
  }
  double GetArea() const {
    return 1;
  }
};
class Circle : public Shape {
  Point center_;
  double radius_;
 public:
  Circle(const Point& point, double radius)
      : Shape(Shape::Type::Circle), center_(point), radius_(radius) {
  }
  double GetArea() const {
    return 3.1415926 * radius_ * radius_;
  }
};

在非成员接口函数的实现中,利用对象的动态类型信息,将任务转发到相应的成员函数:

inline double GetArea(const Shape& shape) {
  switch (shape.type) {
    case Shape::Type::Point:
      return static_cast<const Point&>(shape).GetArea();
    case Shape::Type::LineSegment:
      return static_cast<const LineSegment&>(shape).GetArea();
    case Shape::Type::Circle:
      return static_cast<const Circle&>(shape).GetArea();
    default:
      return shape.GetArea();
  }
}

该设计存在以下缺陷:

  • 浪费资源:type 在每个对象中都要占据存储空间,对于需要生成大量几何对象的应用,这样的开销是不可忽视的。
  • 违反 OCP:引入新的派生类(例如 Rectangle)会迫使基类重新定义其枚举类型成员,并迫使非成员接口函数引入新的 case
  • 违反 DIP:非成员接口函数依赖于所有具体的派生类。

基于虚函数的设计

如果将基类的 GetArea() 成员声明为虚函数,那么非成员接口函数中的 shape.GetArea() 将会在运行期 (run-time) 自动转发到相应派生类的 GetArea() 成员,从而有效地解决上述问题:

struct Shape {
  virtual ~Shape() = default;
  virtual double GetArea() const {
    return -1;
  }
};
struct Point : public Shape {
  double x;
  double y;
  Point(double a, double b) : x(a), y(b) {
  }
  double GetArea() const override {
    return 0;
  }
};
class LineSegment : public Shape {
  Point head_;
  Point tail_;
 public:
  LineSegment(const Point& head, const Point& tail)
      : head_(head), tail_(tail) {
  }
  double GetArea() const override {
    return 1;
  }
};
class Circle : public Shape {
  Point center_;
  double radius_;
 public:
  Circle(const Point& point, double radius)
      : center_(point), radius_(radius) {
  }
  double GetArea() const {
    return 3.1415926 * radius_ * radius_;
  }
};

正方形 v. 长方形 ⚠️

派生机制不应过度使用,例如以下著名案例:

  • 在几何意义上,所有的 Square 都是 Rectangle
  • 在程序设计中,将 Square 定义为 Rectangle 的派生类是一种糟糕的设计。
#include <cassert>
struct Point {
  double x;
  double y;
  Point(double a, double b) : x(a), y(b) {
  }
};
class Rectangle {
  Point left_bottom_;
  double height_;
  double width_;
 public:
  Rectangle(Point& point, double height, double width)
      : left_bottom_(point), height_(height), width_(width) {
  }
  virtual ~Rectangle() = default;
  virtual void SetHeight(double height) {
    height_ = height;
  }
  virtual void SetWidth(double width) {
    width_ = width;
  }
  double GetArea() const {
    return height_ * width_;
  }
};
class Square : public Rectangle {
 public:
  Square(Point& point, double length)
      : Rectangle(point, length, length) {
  }
  virtual void SetHeight(double length) {
    Rectangle::SetHeight(length);
    Rectangle::SetWidth(length);
  }
  virtual void SetWidth(double length) {
    Rectangle::SetHeight(length);
    Rectangle::SetWidth(length);
  }
};
void Test(Rectangle& r, double height, double width) {
  r.SetHeight(height);
  r.SetWidth(width);
  assert(r.GetArea() == height * width);
}
int main() {
  auto p = Point(0, 0);
  auto r = Rectangle(p, 3, 5);
  Test(r, 3, 5);  // passed
  auto s = Square(p, 4);
  Test(s, 3, 5);  // failed
}

该设计存在以下缺陷:

  • 浪费资源:Squareheight_ 总是等于 width_,不需要像 Rectangle 那样存储为两个独立成员。
  • 违反 LSP:所有 Rectangle 都应当能通过单元测试 Test(Rectangle&, int, int),但当 height != widthSquare 却无法通过该测试。因此,从行为上看,a Square is NOT a Rectangle

ISP: Interface Segregation Principle

Clients should not be forced to depend on methods that they do not use.

DIP: Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

倒置的含义

依赖倒置首先是指源代码依赖关系(通常表现为 #includeimport 语句)的倒置。

源代码依赖关系的倒置通常也意味着接口所有权的倒置: 接口代表一种服务,其所有权应当归属于服务的使用者(高层策略模块)而非提供者(底层实现模块)。

下面的示例体现了源代码依赖关系接口所有权的双重倒置:

  • 在高层模块 Application 中,高层具体类 TaskScheduleraddTask(), popTask() 方法用到了高层接口 PriorityQueue 所提供的 push(), top(), pop() 服务;而实现这些服务所用到的数据结构及算法,并不需要暴露给 TaskScheduler
  • 在中层模块 Algorithm 中,中层具体类 BinaryHeap 借助于中层接口 Vector 所提供的 at() 服务,给出了高层接口 PriorityQueue 的一种实现;而由中层接口 Vector 隐式提供的 resize() 服务,并不需要暴露给 BinaryHeap
  • 在底层模块 DataStructure 中,底层具体类 DynamicArray 借助于更底层的(通常由操作系统提供的)动态内存管理服务,给出了中层接口 Vector 的一种实现。

语言实现机制

接口可以显式地出现在源代码中,例如:

  • 在 Java 中,接口通常表现为 interfaceabstract class
  • 在 C++ 中,接口可以表现为含有纯虚函数class
  • 在 C++20 中,接口可以表现为 concept
  • 在 Python 3.4+ 中,接口可以表现为借助于标准库模块 abc 定义的抽象类

接口也可以仅仅作为一种抽象的概念,例如:

  • 在 C++ 中,接口可以表现为对模板类型形参的隐式约束,凡是满足该约束的类型都可以被视作是该接口的一个实现。
  • 在 Python 等动态语言中,接口可以表现为对函数形参类型的隐式约束,凡是满足该约束的类型都可以被视作是该接口的一个实现。

面向对象设计 v. 面向对象语言

使用面向对象语言 (C++/Java/Python) 进行编程不等于面向对象编程。 一段程序是否是面向对象的,取决于程序中的依赖关系是否是倒置的,而与所使用的编程语言无关。 这种依赖关系的倒置是通过多态 (polymorphism) 来实现的,多态既可以是静态的(编译期绑定),也可以是动态的(运行期绑定)。 面向对象语言通过一定的语法机制,让多态变得更容易、更简洁、更安全。