C++ 复习教程第六章(设计可重用代码)
第6章 —— 设计可重用代码
在程序中,重用库和其他代码是一项重要的设计策略。然而,这只是重用策略的一半,另外一半是设计并编写在程序中的可重用代码。你可能己经发现,设计良好的库和设计不当的库之间存在显著差别。设计良好的库用起来很舒服,而设计糟糕的库会让人觉得非常难受,以至于放弃使用,自己编写代码。无论是编写供其他程序员使用的库,还是仅仅设计某个类层次结构,在设计代码时都应该考虑重用。你永远不知道后续项目什么时候会用到相似的功能段。
第 4 章介绍了重用的设计主题,并阐述了如何通过在设计中整合库和其他代码来应用这个主题。本章讨论重用的另一方面:设计可重用代码。这一内容建立在第 5 章介绍的面向对象设计原则基础之上,并引入了新的策略和指导方针。
6.1重用哲学
应该设计自己和其他程序员都可以重用的代码。这条规则不仅是用于专供其他程序员使用的库或框架,还适用于程序中用到的类、子系统和组件。
牢记格言:
- 编写一次,经常使用;
- 尽量避免代码重复;
- DRY(Don’t Repeat Yourself);
原因如下:
- 代码不大可能只在一个程序中使用,代码总会被重用;
- 重用设计可节省时间和金钱,减少过多重复性工作;
- 团队中的其他程序员必须能够使用你编写的代码,因为大部分工程都不可能独自完成,因此,重用设计可以称为协作编程;
- 缺乏重用性会导致代码重复,代码重复会导致维护困难,因为一旦发现 bug 那么就必须在所有地方都进行改变,这很容易出错;
- 你自己是主要受益人,因为经验丰富的程序员永远不会扔掉代码,随着时间推移,他们会创建个人工具库,你永远不会事先知道要在什么时候使用到类似的代码;
警告:
公司员工设计或编写代码时,通常拥有知识产权的时公司而不是员工本人。当员工终止劳动合同时,保留设计或代码的副本通常是违法的;
6.2如何设计可重用代码
可重用代码目标:
- 代码必须通用,太过特定的组件很难被重用;
- 代码应当易于使用,而不需要花费大量时间理解他们的接口或功能;
将库“递交”给客户的方法也很重要。可以源代码形式递交,这样客户只需要将源代码整合到他们的项目中。另一种选择是递交一个静态库,客户可以将该库连接到他们的应用程序中,也可以给 Windows 客户递交一个动态链接库(DLL),给 Linux 客户递交一个共享对象(.so)。这些递交方式会对编写库的方式施加额外限定;
注意:
本章用术语 “客户”代表使用接口的程序员。不要将客户与使用程序的 “用户”混淆。本章还使用了短语“客户代码”,这代表使用接口的代码。
重中之重:
重用代码的本质是——抽象;
抽象将代码分为接口和实现,因此设计可重用代码会关注这两个关键领域;实现代码,是重用的实现;只要理解代码接口,即可使用;
2.1使用抽象
抽象的优点:
通过分离实现和接口,首先,使得客户只需要明白接口的用途就可以使用代码;而程序员在维护时,不需要修改接口,因此不需要征求客户的同意,只需改变代码本身就可以;如果使用动态链接库(DLL),甚至不需要重新生成可执行文件;
总之,获得好处的是因为作为库的编写者,可在接口明确指定希望的交互方式和支持方式的功能;接口与实现的明确分离可以杜绝用户以不希望的方式使用库,而这些方式可能导致意料之外的行为和 bug。
当在设计界面时,不要向客户公开实现细节;
有时为将某个接口返回的信息传递给其他接口,库要求客户代码保存这些信息。这一信息有时叫作句柄(handle), 经常用来跟踪某些特定的实例,这些实例调用时的状态需要被记住。如果库的设计需要句柄,不要公开句柄的内部情况。可将句柄放入某个不透明类,程序员不能“直接访问这个类的内部数据成员,也不能通过公有的获取器或设置器来访问”。
这种设计决策有以下目的和优势:
- 封装内部实现: 将句柄放入一个不透明类中,允许库在内部更改实现细节而不会影响客户代码。客户代码只需要与句柄进行交互,而不需要了解其内部结构。
- 隐藏实现细节: 隐藏句柄的内部情况可以防止客户代码直接访问或修改句柄的状态。这种信息隐藏有助于维护库的一致性和稳定性,因为客户代码无法非法地操作句柄。
- 提高安全性: 不透明类的使用可以提高安全性,防止客户代码意外地破坏库内部的状态。通过限制对内部成员的访问,可以减少潜在的错误和不当操作。
不要要求客户代码改变句柄内部的变量。一个不良设计的示例是,一个库为了启用错误日志,要求设置某个结构的特定成员,而这个结构所在的句柄本来应该是不透明的
2.2构建理想的重用代码
避免组合不相干的概念或者逻辑上独立的概念,当设计组件时,应该关注单个任务或一组任务,即“高聚合”, 也称为 SRP(Single Responsibility Principle,单一责任原则)。 不要将无关概念组合在一起,例如随机数生成器和 XML 解析器。
即使设计的代码并不是专门用来重用的,也应该记住这一策略。整个程序本身很少会被重用,但是程序的片段或子系统可直接组合到其他应用程序中,也可以在稍作变动后用于大致相同的环境。因此,设计程序时,应将逻辑独立的功能放到不同的组件中,以便在其他程序中使用。其中的每个组件都应当有明确定义的责任。
这个编程策略模拟了现实中可互换的独立部分的设计原则。例如,可编写一个 Car 类,在其中放入引擎的所有属性和行为。但引擎是独立组件,未与小汽车的其他部分绑定。可将引擎从一辆小汽车卸下,安装在另一辆小汽车中。合理的设计是添加一个 Engine 类,其中包含与引擎相关的所有功能。此后,Car 实例将包含 Engine实例。
将程序分为逻辑子系统 ,将子系统设计为可单独重用的分立组件,即“低耦合”。 每个子系统都应该遵循抽象原则。将每个子系统当作微型库,必须为其提供稳定的、便于使用的接口。即使你是使用这个微型库的唯一程序员,设计良好的接口和从逻辑上分离不同功能的实现也是有益的。
用类层次结构分离逻辑概念,除了将程序分为逻辑子系统以外,在类级别上应该避免将无关概念组合在一起。 总结:组织内部高聚合,组件之间低耦合;
用聚合分离逻辑概念。第 5 章讨论的聚合(Aggregation)模拟了“有一个”关系:为完成特定功能,对象会包含其他对象。当不适合使用继承方法时,可以使用聚合分离没有关系的功能或者有关系但独立的功能。
例如,假定要编写一个 Family 类来存储家庭成员。显然,树数据结构是存储这些信息的理想结构。不应该把树数据结构的代码整合到 Family 类中,而是应该编写一个单独的 Tree 类。然后 Family 类可以包含并使用 Tree实例。用面向对象的术语来说,Family 有一个 Tree。通过这种方法,可以在其他程序中方便地重用树数据结构。
消除用户界面的依赖性
如果是一个操作数据的库,就需要将数据操作与用户界面分离开来。这意味着对于这些类型的库,不应该假定哪种类型的用户界面会使用库,因此不应该使用 cout、cerr、cin、stdout、stderr 或 stdin, 因为如果在图形用户界面的环境下使用库,这些概念将没有意义。例如,基于 Windows GUI 的应用程序通常不会有任何形式的控制台 I/O。即使库只用于基于 GUI 的程序,也不应该向最终用户弹出任何类型的消息窗口或者其他类型的提示,这是客户代码需要做的事情。这种类型的依赖不仅降低了重用性,还阻止了客户代码正确响应错误以及在后台自动处理错误。
以下是一些实现这一目标的常见策略:
- 使用回调函数: 允许客户代码提供回调函数,这些函数用于处理特定事件或错误。例如,库可以定义一些回调函数,如处理错误、进度更新或异步操作完成等。客户代码负责实现这些回调函数,以便在适当的时候进行响应。
- 定义接口或抽象类: 将用户界面相关的操作定义为接口或抽象类,使得客户代码可以根据自己的需要实现这些接口。这种方式使得库不依赖于具体的用户界面实现,而只依赖于接口。
- 使用事件机制: 如果库支持事件,客户代码可以注册感兴趣的事件处理器。这样,库可以触发事件,而客户代码则负责处理这些事件,例如更新界面或处理错误。
- 提供配置选项: 允许客户代码通过配置选项来自定义库的行为,而不是在库中直接使用特定的用户界面元素。例如,客户代码可以通过设置配置选项来指定日志输出的目标,而不是直接使用 cout 或者其他特定的输出流。
- 避免直接的用户界面代码: 在库中避免直接调用与用户界面相关的库或函数。将这些调用封装在特定的接口或者类中,使得用户界面的具体实现对于库来说是可替换的。
- 提供适配器模式: 如果必须在库中处理用户界面相关的逻辑,可以考虑使用适配器模式。通过定义一个接口,允许不同的用户界面实现提供适配器,以便库可以与不同的用户界面进行交互。
第 4 章介绍的 Model-View-Controner(MVC)范型是一种将数据存储和数据显示分开的著名设计模式。在这种范型中,模型可放在库中,客户代码可提供视图和控制器。
2.对泛型数据结构和算法使用模板:
C++模板的概念允许以类型或类的形式创建泛型结构。例如,假定为整型数组编写了代码。如果以后要使用 double 数组,就需要重写并复制所有代码。模板的概念将类型变成一个需要指定的参数,这样就可以创建一个适用于任何类型的代码体。模板允许编写适用于任何类型的数据结构和算法。
最简单的示例是std::vector类,这个类是C++标准库的一部分。为创建整型的 vector, 可编写 std::vector
;,为创建 double 类型的 vector, 可编写 std::vector 。模板编程通常功能强大,但非常复杂。幸运的是,可以创建简单的、使用类型作为参数的模板用例。第 12 和 22 章讲述编写自定义模板的技巧,本节讨论模板的一些重要设计特征。 只要有可能,就应该设计泛型(而不是局限于某个特定程序的)数据结构和算法。不要编写只存储 book 对象的平衡二叉树结构,要使用泛型,这样就可以存储任何类型的对象。通过这种方法,可将其用于书店、音乐商店、操作系统或需要平衡二叉树的任何地方。这个策略是标准库的基础。标准库提供可用于任何类型的泛型数据结构和算法。
模板优于其他泛型程序设计的原因:
- void* (用于不指定类型的指针),类型不安全,,欸有类型检测,无论删除还是转换都存在一定风险;
- 为特定类编写数据结构,通过多态性(统一操作作用于不同对象,行为不同),这个类的所有子类都可以存储在这个结构中。Java 将这种方法发挥到极致,指定所有的类都从 Object 类直接或间接派生。早期 Java 版本的容器存储 Object, 因此可以存储任何类型的对象。然而,这种方法也不是真正类型安全的。从容器中删除某个对象时,必须记得其真实的类型,并向下转换(down-cast)为合适的类型。向下转换意味着转换为类层次结构中更具体的类,即沿着类层次结构“向下”转换。
模板并不是完美的:
- 首先,语法较为迷惑;
- 其次,模板要求相同类型的数据节后,在一个结构中只能存储相同的数据类型。这就是说,一个模板创建出的数据结构,只能用于存储同种类型。这种限制是由模板的类型安全性质决定的。从 C++17开始,可以采用一种标准方式来绕过这种“相同类型”限制。可编写数据结构来存储 std::variant 或 std::any 对象。std::any 对象可存储任意类型的值,std::variant对象可存储所选类型的值。第 20 章将详细介绍这两种对象以及它们的变体。
模板与继承的区别:
- 如果打算为不同的类型提供相同的功能,则使用模板。例如,如果要编写一个适用于任何类型的泛型排序算法,应该使用模板。如果要创建一个可以存储任何类型的容器,应该使用模板。关键的概念在于模板化的结构或算法会以相同方式处理所有类型。但是,如有必要,可给特定的类型特殊化模板,以区别对待这些类型。模板特殊化参见第 12 章
- 当需要提供相关类型的不同行为时,应该使用继承。例如,如果要提供两个不同但类似的容器,例如队列和优先队列,应该使用继承;
- 把二者结合起来。可以编写一个模板基类,此后从中派生一个模板化的类。(这也就叫做抽象,抽象出来这个,然后给出接口)第 12 章将详细讲述模板语法;
提供适当的检测和安全措施:
第一种方法是按契约设计,这表示函数或类的文档是契约,详细描述客户代码的作用以及函数或类的作用。按契约设计有三个重要方面:前置条件(precondition)、后置条件(postcondition)和不变量(invariant)。前置条件列出为调用函数或方法,客户代码必须满足的条件。后置条件列出完成执行后,函数或方法必须满足的条件。最后,不变量列出在函数或方法执行期间,必须一直满足的条件;
这种方法常用于标准库。例如,std::vector 定义了一个契约,以使用数组记号获取 vector 中的某个元素。契约指定,vector 不进行边界检查,这是客户代码的责任。也就是说,使用数组记号从 vector 获取元素的前置条件对于给定索引是有效的。这样可提高客户代码的性能,因为客户代码知道其索引在指定范围内。vector 还定义了 at()方法,用来获取进行边界检查的特定元素。所以客户代码可以选择是使用不带边界检查的数组记号,还是使用带有边界检查的 at()方法。
第二种方法是以尽可能安全的方式设计函数和类。这个指导方针的最主要特征就是在代码中执行错误检测。例如,如果随机数生成器要求一个处于指定范围的种子,不要相信用户一定会正确地传递一个有效的种子。应该检测传递过来的值,如果无效,就拒绝调用。前面讨论的 at() 方法是另一个考虑安全的示例。如果用户提供了无效索引,该方法将抛出异常。
有些技巧和语言特征有助于编写安全代码,有助于在程序中加入检测和安全措施。首先,可返回错误代码或特定的值(例如 fhlse 或 nullptr), 或者抛出异常,以提醒客户代码发生了错误,第 14 章将详细讲述异常。****其次,为编写安全代码,可使用智能指针管理动态分配的内存等资源。从概念上说,智能指针是指向动态分配的资源的指针,当超出作用域时会自动释放资源。第 1 章讨论过智能指针。
扩展性:
设计的类应当具有扩展性,可通过从这些类派生其他类来扩展它们。不过,设计好的类应当不再修改;也就是说,其行为应当是可扩展的,而不必修改其实现。这称为开放/关闭原则(Open/Closed Principle, OCP)。
例如,假设开始实现绘图应用程序。第一个版本只支持绘制正方形,设计中包含两个类:Square() 和 Renderer0 前者包含正方形的定义,如边长;后者负责绘制正方形。得到的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Square
{
// Details not important for this example.
};
class Renderer
{
public:
void render(const vector<Square>& squares);
};
void Renderer::render(const vector<Square>& squares)
{
for (auto& square : squares)
{
// Render this square object.
}
}接下来,添加对绘制圆的支持,因此创建 Circle 类:
1
2
3
4
class Circle
{
// Details not important for this example.
};下面给出一种愚蠢的 render() 解决方案:
1
2
3
4
5
6
7
8
9
10
11
12
void Renderer::render(const vector<Square>& squares,
const vector<Circle>& circles)
{
for (auto& square : squares)
{
// Render this square object.
}
for (auto& circle : circles)
{
// Render this circle object.
}
}此时的设计应当使用继承,虽然稍微超前,但是仍然给出,只需要理解其中的精妙,只需要知道 Square 从 Shape 类中派生即可:
1
class Square : public Shape {};
下面使用继承语法的设计:
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
class Shape
{
public:
virtual void render() = 0;
};
class Square : public Shape
{
public:
virtual void render() override { /* Render square */ }
// Other members not important for this example.
};
class Circle : public Shape
{
public:
virtual void render() override { /* Render circle */ }
// Other members not important for this example.
};
class Renderer
{
public:
void render(const vector<shared_ptr<Shape>>& objects);
};
void Renderer::render(const vector<shared_ptr<Shape>>& objects)
{
for (auto& object : objects)
{
object->render();
}
}
/*看到这里没有这个多态,实在是优美,利用抽象把多态给搞出来,再利用抽象把将有特点的类从基类中派生出来,注意到没有 auto* 的使用,这是不是特别引人入胜,一下子把很多需要说的东西给解决了,而且是类型安全的,而且使用 const & 的常量引用方式,只能说结构实在是清晰*/
// 因为后面介绍了 DIP 这种比较高级的方式,下面给出这种写法(看后面,已经觉得这种方式不是很好了,下面有鲁棒的):
#include <iostream>
// 渲染器接口
class Renderer
{
public:
virtual void renderShape() = 0;
};
// 具体的方形渲染器
class SquareRenderer : public Renderer
{
public:
virtual void renderShape() override
{
std::cout << "Rendering square." << std::endl;
// 具体的方形渲染逻辑
}
};
// 具体的圆形渲染器
class CircleRenderer : public Renderer
{
public:
virtual void renderShape() override
{
std::cout << "Rendering circle." << std::endl;
// 具体的圆形渲染逻辑
}
};
class Shape
{
public:
// 通过渲染器接口实现依赖反转
Shape(Renderer* renderer) : renderer(renderer) {}
// 渲染形状的方法
void render()
{
renderer->renderShape();
}
private:
Renderer* renderer;
};
int main()
{
// 创建具体的渲染器实例
SquareRenderer squareRenderer;
CircleRenderer circleRenderer;
// 创建具体的形状实例,并传入相应的渲染器
Shape square(&squareRenderer);
Shape circle(&circleRenderer);
// 渲染形状
square.render(); // 输出:Rendering square.
circle.render(); // 输出:Rendering circle.
return 0;
}
/*这样看上去是不是更 NB 了?这样使得依赖注入了*/
/*我想强调的是:依赖注入(DIP)实现的是对功能的多态化处理,将类与类的功能分开,是向实现功能的特定类进行派生类注入,然后实现同一操作的多态化。所以,给出下面更好的方法。*/
// 理论角度上,下面这种方式也是可行的:
#include <iostream>
// 渲染器接口
class Shape
{
public:
virtual void renderShape() = 0;
};
// 具体的方形渲染器
class Square : public Shape
{
public:
virtual void renderShape() override
{
std::cout << "Rendering square." << std::endl;
// 具体的方形渲染逻辑
}
};
// 具体的圆形渲染器
class Circle : public Shape
{
public:
virtual void renderShape() override
{
std::cout << "Rendering circle." << std::endl;
// 具体的圆形渲染逻辑
}
};
class Render
{
public:
// 通过渲染器接口实现依赖反转
Render(Shape* shape) : shape(shape) {}
// 渲染形状的方法
void render()
{
shape->renderShape();
}
private:
Shape* shape;
};
/*这里我们看到了两种依赖方式:
1.Shape 类依赖于 Renderer 接口(派生类为渲染器):
优点:Shape 类可以直接依赖于渲染器接口,更加直观。添加新的形状时,只需创建一个新的实现了 Renderer 接口的类,而不需要修改 Shape 类。
缺点:Shape 类需要知道渲染器的存在,这可能在一些情况下被视为 Shape 类的责任过重,不够单一。
2.Render 类依赖于 Shape 接口(派生类为图形):
优点:在这种情况下,Shape 类并不关心具体的渲染器是什么,只关心渲染的形状。这种设计更加符合单一责任原则,因为每个类都有一个清晰的责任。
缺点:可能存在大量的渲染器类,每个都需要实现 renderShape() 方法。这种情况下,添加新的形状可能需要创建一个新的渲染器类。
在实际应用中,选择哪种方法通常取决于具体的系统需求和设计目标。
如果系统中存在多种形状和多种渲染器,并且它们的变化原因不同,那么第一种方法可能更为合适。
如果系统中形状的变化和渲染器的变化相对独立,那么第二种方法可能更为直观和灵活。
嗯,事实上,第二种更符合最上面没有 DIP 化的形式,但是明显第一种也是可行的。虽然,我也认为第一种是极其不合适的,因为它作为 Shape 类太过“随便”了!仿佛是个图形都可以触发,这显得很轻浮,可以到处沾花惹草,也就是换成人话说,耦合性太强,责任太强。真的不如后面的第二个向 Render 中注入 Shape 的方式;
*/现在,设想一下,我们要添加一个新形状类型,那么只需要对基类 Shape 进行派生就可以了,而不需要对 render() 方法进行修改,这完全是多态的体现,这是一种动态的多态,也叫运行时多态。(PS: 另外一种静态多态,多被体现为函数的复写)。因此我们可以说,多态是拓展是开放的,对修改是关闭的。
我来解释一下,什么叫做 OPC:
开放:开放对新派生类的扩充,对其属性和行为的规范;
关闭:关闭对行为(可重用代码)的修改,利用抽象派生实现;
2.3设计有用的接口
没有优美的接口,实现再美好,都是扯淡。
良好接口有利于重用。
创新从实现上入手,不要对接口又什么创新想法,那只会变得很蠢;
回到 C++, 开发的接口应该遵循 C++程序员熟悉的标准。例如,C++程序员希望构造函数初始化对象,析构函数清理对象。当设计类时,应该遵循这个标准。如果要求程序员调用initialize。方法初始化对象,调用 cleanup。方法清理对象,而不是将这些功能放在构造函数和析构函数中,就会让所有用户感到迷惑。因为这个类与其他 C++类的行为不同,程序员需要花费更长的时间学习如何使用这个类,并可能由于忘记调用initialize。或 cleanup。方法而出错。
注意:
设计从使用者角度进行考虑;
运算符重载可以作为一种帮助为对象开发易于使用的接口的语言特性;但不要使用过度,那样很笨拙,而且反人性,之后会详细讨论;
不要省略必需的功能:
该策略分为两部分:
- 尽量包括用户所有的可能行为,虽然不可能完美,但是代码本身就没有完美,只有优雅;
- 在实现中尽量包含多的功能,不要要求客户端代码指定在实现中已经知道的信息(例如,库需要一个临时文件,不要让库的客户指定路径,他们应当不必担心库使用什么文件,应该用其他方法决定合适的临时文件路径);
走向极端:
部分程序员认为需要完美,比如曾经的我,但是现在我们认识到,优雅比完美更好。上面两个策略任何一个走向极端都是糟糕的事情,那会使得接口混乱至极;
从本质上说,设计简洁接口的思想看似简单,但是那是主观的规则,实践起来其实相当困难;这个规则基本上是主观的:由你决定什么是必需的,什么不是。当然,当这个判断出错时,客户一定会通知你。
提供文档和注释:
这不是当前的重点,我一带而过,这部分单独学习;
无论接口多么便于使用,都应该提供使用文档。如果不告诉程序员如何使用,不能期望他们会正确使用库。应该将库或代码称为供其他程序员使用的产品。产品应该带有说明其正确用法的文档。
提供接口文档有两种方法:接口自身内部的注释和外部的文档。应该尽量提供这两种文档。大多数公开的API 只提供外部文档:许多标准 UNIX 和 Windows 头文件中都缺少注释。在 UNIX 中,文档形式通常是名为man pages 的在线手册。在 Windows 中,集成开发环境通常附带文档。
虽然多数 API 和库都取消了接口本身的注释,但我们认为,这种形式的文档才是最重要的。绝不应该给出一个只包含代码的“裸”头文件。即使注释与外部文档完全相同,具有友好注释的头文件也比只有代码的头文件看上去舒服,即使最优秀的程序员也希望经常看到书面语言。
有些程序员使用工具将注释自动转换为文档,第 3 章详细讨论了这一技术。(但我还是没学)
无论提供注释、外部文档还是二者都提供,文档都应该描述库的行为而不是实现。行为包括输入、输出、错误条件和处理、预定用法和性能保障。例如,描述生成单个随机数的调用的文档应该说明这个调用不需要参数,返回一个预先指定范围的整数,还应该列出当出现问题时可能抛出的所有异常。文档不应该详细解释实际生成数字的线性同余算法,在接口注释中提供太多的实现细节可能是接口开发中最常见的错误。适用于库维护者(而不是客户)的注释会破坏接口和实现的良好分离,许多开发人员都见到过这种情况。当然,内部的实现也应该有文档记录,只是不要把它作为接口的一部分公开。第 3 章详细讨论了如何在代码中恰当地使用注释。
设计通用接口:
提供执行相同功能的多种方法:
例如:std::vector 提供了两种方法来访问特定索引处的元素。可使用at()方法,该方法执行边界检查;也可使用 operator口方法,该方法不执行边界检查。如果知道索引是有效的,那么使用 operator口方法更合适,这样可省去使用 at()方法时的边界检查开销;
注意,这一策略应该当作接口设计中“整洁”规则的例外。有些情况下这个例外是恰当的,但大多数情况下应该遵循“整洁”规则;
提供定制:
为增强接口的灵活性,可提供定制。定制可以很简单,如允许用户打开或关闭错误日志。定制的基本前提是向每个客户提供相同的基本功能,但给予用户稍加调整的能力。
通过函数指针和模板参数,可提供更强的定制。例如,可允许客户设置自己的错误处理例程。
标准库将定制策略发挥到极致,允许客户为容器指定自己的内存分配器。如果要使用这些特性,就必须编写一个遵循标准库指导方针和符合接口要求的内存分配器对象。标准库中的每个容器都将分配器作为模板参数,第 21 章将详细讲述。
协调通用性和使用性:
通用意味着复杂,使用意味着简洁,统一这两者即可得到想要的优雅接口;
太通用,太模板化,不利于使用;太简介,不一定能够完全满足需求;
但它们并不是互斥的;
提供多个接口:
为在提供足够功能的同时,使用这种方法,提供多个独立的接口,这样可以使得复杂度极大降低。也就是说,实现虽然差不多,但是,使用不同接口,进行功能定制;这称为接口隔离原则(Interface Segregation Principle, ISP)。例如,编写的通用网络库可以具有两个独立的方向:一个为游戏提供网络接口,另一个为超文本传输协议(HTTP, 一种网络浏览协议)提供网络接口。
让常用功能易于使用:
当提供通用接口时,某些功能的使用频率会高于其他功能。应该让常用功能易于使用,同时仍提供高级功能选项。例如,多语言设置,给定一种语言作为默认语言,其余仍可设置使用;
2.4 SOLID 原则
常使用易记的首字母缩写词 SOLID 来指代面向对象设计的基本原则。表 6 汇总了 SOLID 原则。其中的大多数原则都在本章讨论过;对于本章未讨论的原则,则指明相关的章号。
原则如下:
S:内部高聚合,之间低耦合;
O:多态+继承实现对象开放,多态+复用实现功能修改关闭;
L:用于区分“有一个”和“是一个”,意思是说,当选择进行“是一个”的派生时,派生类的行为应当与其基类保持一致,如果程序能够保持一致,那么认为应该继续认为选择“是一个”的关系(因为关系为“是一个”,所以当进行使用时,保持前后行为的一致性,功能没有特殊性的前提下,这样的行为不会引起歧义,而且这样做还可以使得多态性得以增强,因为行为是一致的,因为如果一旦复写方法,那么就有可能存在定义上的偏差,这种偏差随着积累可能会很突兀,而且继承之下,潜在的基类属性也会一并继承,这种渗透事实上很糟糕);如果不能保证的话,也就是派生类的行为与基类不一致时,应该舍弃“是一个”的派生关系,而转为“有一个”的包含关系(因为要包含对行为的修改,用有一个部分再进行修改的方式去写逻辑上更清晰。这种凡是则更加灵活,不会出现复写方法时的逻辑混乱问题);
I:接口兼顾通用、实用,通过独立接口实现接口特异化,高效;
D:高层模块不应该依赖于底层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
- 高层模块(抽象)定义接口,低层模块实现接口。
- 具体实现依赖于抽象,而不是抽象依赖于具体实现。
- 使用接口或抽象类来定义高层模块的行为,而不是依赖于具体的实现类。
示例:
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>
// 定义抽象接口
class Report {
public:
virtual std::string generate() = 0;
virtual ~Report() {} // 增加虚析构函数以正确释放资源
};
// 具体实现类
class PDFReport : public Report {
public:
std::string generate() override {
// 生成 PDF 报告的具体实现
return "PDF Report";
}
};
class HTMLReport : public Report {
public:
std::string generate() override {
// 生成 HTML 报告的具体实现
return "HTML Report";
}
};
// 高层模块使用抽象接口
class ReportGenerator {
private:
Report* report;
public:
// 通过构造函数注入依赖
ReportGenerator(Report* report) : report(report) {}
~ReportGenerator() {
delete report; // 释放资源
}
void generateReport() {
// 生成报告
std::string result = report->generate();
std::cout << "Generated Report: " << result << std::endl;
}
};
// 在使用时,可以通过传入不同的 Report 实现来生成不同类型的报告
int main() {
Report* pdfReport = new PDFReport();
Report* htmlReport = new HTMLReport();
ReportGenerator pdfReportGenerator(pdfReport);
pdfReportGenerator.generateReport();
ReportGenerator htmlReportGenerator(htmlReport);
htmlReportGenerator.generateReport();
return 0;
}
/*和上面的图形类似,这是在说,我的每个派生类要有一个抽象的拥有共同属性的基类,然后,每个积累包含有自己函数名相同但是实现内容不同的函数(多态的实现),然后对于高一级的应用,因为虽然是想对派生类进行操作,但是考虑到复用和不依赖关系,则使用注入基类的方式,来实现多态的实现*/优点如下:
- 松耦合: 依赖注入通过在对象的构造函数、方法参数或者属性中注入依赖,降低了组件之间的耦合度。这使得各个组件可以更独立地开发、测试和维护,而不容易受到彼此的变化影响。
- 可测试性: 通过依赖注入,可以轻松地替换实际依赖的实现,使用模拟对象或者测试替身。这样,在单元测试中,我们可以注入模拟对象,更方便地对组件进行隔离测试,而不受到真实依赖的影响。
- 可维护性: 依赖注入使得代码的结构更清晰,依赖关系更明确。这有助于理解和维护代码,因为每个组件的依赖都是显式的,而不是隐藏在组件内部。
- 灵活性: 通过依赖注入,可以在运行时动态地替换依赖的实现。这为系统提供了更大的灵活性,使得在不修改现有代码的情况下,可以更容易地切换或升级依赖的版本或实现。
- 可扩展性: 依赖注入使系统更容易扩展,因为新增的组件可以通过依赖注入的方式接入系统,而不需要修改现有代码。
6.3本章小结
SOLID!!!
第三部分 —— 专业的 C++ 编码方法
-