面向对象 SOLID 设计原则

面向对象 SOLID 设计原则

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

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

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

Single Responsibility Principle(单一功能原则)Open/Closed Principle(开闭原则)Liskov Substitution Principle(里氏替换原则)Interface Segregation Principle(接口隔离原则)Dependency Inversion Principle(依赖反转原则)你大概率已经听过这些名词,假如它们出现在一本介绍编程的书里面,你大概率会忽略它们,因为它们看起来和你要做的编程工作没有关系。

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

通过例子学习 SOLIDSingle 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,里面提出了一些建设性的建议。

子类重写父类方法,参数类型的范围可以扩大或相同。子类重写父类方法,返回类型的范围只能缩小或者相同。子类重写父类方法,不能抛出父类没有抛出的异常。子类重写父类方法,方法运行前的条件要一致。子类重写父类方法,方法运行后的条件要一致。子类属性要满足父类属性的约束。子类不能允许修改父类不曾修改的属性。这是一个遵守 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 是面向对象程序设计领域的五个原则的缩写,分别是:

单一功能原则,一个类只应该负责一个单一的功能开闭原则,需求变动的时候,不应该修改类,而是扩展类里氏替换原则,派生类要能完全替换基类出现的位置,且代码行为要保持一致接口隔离原则,不同的领域要分成不同的接口,不能混合在一起依赖反转原则,高层模块不应该依赖于底层模块,而是依赖于抽象接口尝试在日常的代码编写中思考和应用这些原则。

参考资料A Solid Guide to SOLID PrinciplesSingle Responsibility Principle in JavaOpen/Closed Principle in JavaLiskov Substitution Principle in Java