软件由需求驱动,而需求会不断变化,随着时间的变化,系统的熵必然会不断增加,软件开发就是控制系统熵减的一个过程。那么,如何编写一个可维护、可理解和可扩展的软件?
本文旨在介绍一个有着 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,里面提出了一些建设性的建议。
- 子类重写父类方法,参数类型的范围可以扩大或相同。
- 子类重写父类方法,返回类型的范围只能缩小或者相同。
- 子类重写父类方法,不能抛出父类没有抛出的异常。
- 子类重写父类方法,方法运行前的条件要一致。
- 子类重写父类方法,方法运行后的条件要一致。
- 子类属性要满足父类属性的约束。
- 子类不能允许修改父类不曾修改的属性。
这是一个遵守 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 是面向对象程序设计领域的五个原则的缩写,分别是:
- 单一功能原则,一个类只应该负责一个单一的功能
- 开闭原则,需求变动的时候,不应该修改类,而是扩展类
- 里氏替换原则,派生类要能完全替换基类出现的位置,且代码行为要保持一致
- 接口隔离原则,不同的领域要分成不同的接口,不能混合在一起
- 依赖反转原则,高层模块不应该依赖于底层模块,而是依赖于抽象接口
尝试在日常的代码编写中思考和应用这些原则。
参考资料