面向对象 SOLID 设计原则

编写可维护、可理解和可扩展的软件

软件由需求驱动,而需求会不断变化,随着时间的变化,系统的熵必然会不断增加,软件开发就是控制系统熵减的一个过程。那么,如何编写一个可维护、可理解和可扩展的软件?

本文旨在介绍一个有着 20 年历史的面向对象 SOLID 设计原则,可能会对你有所帮助。

# SOLID 简介

SOLID 是面向对象编程领域的五个设计原则,由它们的首字母缩写而来:

  • Single Responsibility Principle(单一功能原则)
  • Open/Closed Principle(开闭原则)
  • Liskov Substitution Principle(里氏替换原则)
  • Interface Segregation Principle(接口隔离原则)
  • Dependency Inversion Principle(依赖反转原则)

你大概率已经听过这些名词,假如它们出现在一本介绍编程的书里面,你大概率会忽略它们,因为它们看起来和你要做的编程工作没有关系。

假如你是一个有了一定经验的程序员,看到这些名词,并理解它们,你可能会非常兴奋。因为这正是你在寻找的东西。

# 通过例子学习 SOLID

# Single Responsibility Principle

单一功能原则,从字面意思上来讲,就是一个类只能有一个功能。但是假如真的这么做,无疑是过度设计了,所以我更愿意这样子理解:假如你有一个类,经常修改它并且总是出于不同的原因,那么你应该尝试把它拆分成不同的类。

这样做的好处是可以设计充分的单元测试,错误也更容易定位。

例如,有一个 Text 类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Text {
public:
  void Append(const std::string &text) { text_.append(text); }
  void DeltetLastCharacter() { text_.pop_back(); }
  void DeleteSubString(const std::string &substring) {
    text_.erase(text_.find(substring), substring.length());
  }

  void Print() { std::cout << text_; }
  void PrintLastCharacter() { std::cout << text_.back(); }
  void PrintLength() { std::cout << text_.length(); }

private:
  std::string text_;
};

它有 Append、Delete、Print 方法。根据 SRP 原则,我们可以认为 Append、Delete 都属于操作文本,而 Print 则属于输出文本到另外一个地方,借此可以把 Print 相关的函数放到一个新的 TextPrinter 类里面。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Text {
public:
  void Append(const std::string &text) { text_.append(text); }
  void DeltetLastCharacter() { text_.pop_back(); }
  void DeleteSubString(const std::string &substring) {
    text_.erase(text_.find(substring), substring.length());
  }
  std::string &GetText() { return text_; }

private:
  std::string text_;
};

class TextPrinter {
public:
  void Print() { std::cout << text_.GetText(); }
  void PrintLastCharacter() { std::cout << text_.GetText().back(); }
  void PrintLength() { std::cout << text_.GetText().length(); }

private:
  Text text_;
};

任何开发过软件的人都明白,这不是一条容易遵守的规则,因为比较极端的情况下,似乎我们得要为每一个功能都设计一个类,这是不现实的。

知道哪些类可以拆分很重要,这需要你动用领域驱动设计(DDD)的思想,充分了解具体的业务模型,以此来决定类的粒度。如果实在是觉得纠结,这里还有一些数学方法来衡量类的内聚性:Cohesion metrics,不再展开。

# Open/Closed Principle

开闭原则,开闭原则的全称是 Open for Extension, Closed for Modification。它的主要指导思想是一个类被设计完之后,就不应该再被修改了,而是基于抽象去做扩展。这听起来简直匪夷所思,天方夜谭,白日做梦。让我们看一个 Calculator 类的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
enum class CalculatorOperation { ADD, SUB };

class Calculator {
public:
  Calculator(float left, float right) : left_(left), right_(right) {}

  void Calculate(CalculatorOperation operation) {
    switch (operation) {
    case CalculatorOperation::ADD:
      result_ = left_ + result_;
      break;
    case CalculatorOperation::SUB:
      result_ = left_ - result_;
      break;
    default:
      break;
    }
  }

private:
  float left_;
  float right_;
  float result_;
};

这看上去没什么问题,但是假如之后有新的操作,比如乘法,除法,或者平方根,我们该如何处理?

你可能会说添加一个新的 case,但是这就违背了开闭原则,它修改了已经存在的类。我们看一个更符合开闭原则的实现方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class CalculatorOperation {
public:
  virtual float Operation(float left, float right) = 0;
};

class Addition : public CalculatorOperation {
public:
  float Operation(float left, float right) override { return left + right; }
};

class Subtraction : public CalculatorOperation {
public:
  float Operation(float left, float right) override { return left - right; }
};

class Calculator {
public:
  Calculator(float left, float right) : left_(left), right_(right) {}

  void Calculate(CalculatorOperation operation) {
    result_ = operation.Operation(left_, right_);
  }

private:
  float left_;
  float right_;
  float result_;
};

我们巧妙地把所有操作抽象成一个接口,然后让 Calculator 类依赖这个接口,这样就避免了修改 Calculator 类。

# Liskov Substitution Principle

里氏替换原则,它的规则很简单:子类型应该能替换父类型出现的位置且无需改动任何代码。

虽然看起来很简单,但是实际做起来非常有讲究,我们可以参考 Program Development in Java: Abstraction, Specification, and Object-Oriented Design,里面提出了一些建设性的建议。

  1. 子类重写父类方法,参数类型的范围可以扩大或相同。
  2. 子类重写父类方法,返回类型的范围只能缩小或者相同。
  3. 子类重写父类方法,不能抛出父类没有抛出的异常。
  4. 子类重写父类方法,方法运行前的条件要一致。
  5. 子类重写父类方法,方法运行后的条件要一致。
  6. 子类属性要满足父类属性的约束。
  7. 子类不能允许修改父类不曾修改的属性。

这是一个遵守 LSP 的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Rectangle {
public:
    Rectangle(int width, int height) : width_(width), height_(height) {}

    [[nodiscard]] int GetWidth() const { return width_; }

    [[nodiscard]] int GetHeight() const { return height_; }

private:
    int width_, height_;
};

class Square : public Rectangle {
public:
    explicit Square(int size) : Rectangle(size, size) {}
};

Square 的宽和高是一样的,它比 Rectangle 多一个约束条件,所以 Square 继承 Rectangle 是可行的,但是反过来就不对。

# Interface Segregation Principle

接口隔离原则,这个比较容易理解,即尽可能把接口设计得足够小。

假如我们有一个 Human 类:

1
2
3
4
5
6
class Human {
public:
  virtual void Eat() = 0;
  virtual void Run() = 0;
  virtual void Speak() = 0;
};

我们很容易想到,Eat、Run 不是 Human 特有的行为,Speak 看上去像是 Human 特有的行为,但是我们也许 Alien 也有这个行为。

所以我们应该拆分成更细粒度的接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Eatable {
public:
  virtual void Eat() = 0;
};

class Runable {
public:
  virtual void Run() = 0;
};

class Speakable {
public:
  virtual void Speak() = 0;
};

# Dependency Inversion Principle

依赖反转原则,在我们设计软件的时候,很容易基于想到,上层模块应该依赖底层模块,但是依赖反转原则想要告诉你,上层模块不应该依赖于底层模块,而是依赖于抽象。这种情况在后端编程的时候非常常见:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ILoginService {
public:
  virtual bool Login() = 0;
};

class UserService : public ILoginService {
public:
  bool Login() {}
};

class AdminService : public ILoginService {
public:
  bool Login() {}
};

class FileController {
public:
  void Upload() {
    bool access = loginService->Login();
    if (access) {
      // Do Upload
    }
  }

private:
  ILoginService *loginService;
};

在这里,FileController 类依赖了 ILoginService 接口,而不是直接使用 UserService 类,这样就避免了后续添加 AdminService 类后,FileController 类需要兼容 AdminService 类的逻辑。

# 总结

SOLID 是面向对象程序设计领域的五个原则的缩写,分别是:

  1. 单一功能原则,一个类只应该负责一个单一的功能
  2. 开闭原则,需求变动的时候,不应该修改类,而是扩展类
  3. 里氏替换原则,派生类要能完全替换基类出现的位置,且代码行为要保持一致
  4. 接口隔离原则,不同的领域要分成不同的接口,不能混合在一起
  5. 依赖反转原则,高层模块不应该依赖于底层模块,而是依赖于抽象接口

尝试在日常的代码编写中思考和应用这些原则。

# 参考资料

使用 Hugo 构建
主题 StackJimmy 设计