You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

254 lines
15 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 05 | 面向对象编程:怎样才能写出一个“好”的类?
你好我是Chrono。
如果按照前几节课的惯例今天应该是讲运行阶段的。但是运行阶段跟前面的编码、预处理和编译阶段不同它是动态的、实时的内外部环境非常复杂CPU、内存、磁盘、信号、网络套接字……各种资源交织在一起可谓千变万化正如我在[第1节课](https://time.geekbang.org/column/article/231454)里所说,每一个阶段的差异都非常大)。
解决这个阶段面临的问题已经不是编程技术了更多的是要依靠各种调试、分析、日志工具比如GDB、Valgrind、Systemtap等。
所以我觉得把这些运行阶段的工具、技巧放在课程前面不是太合适咱们还是往后延一延等把C++的核心知识点都学完了,再来看它比较好。
那么,今天要和你聊哪些内容呢?
我想了想还是讲讲“面向对象编程”Object Oriented Programming吧。毕竟它是C++诞生之初“安身立命”的看家本领也是C++的核心编程范式。
不管我们是否喜欢“面向对象”早就已经成为了编程界的共识和主流。C++、Java、Python等流行的语言无一不支持面向对象编程而像Pascal、BASIC、PHP那样早期面向过程的语言在发展过程中也都增加了对它的支持新出的Go、Swift、Rust就更不用说了。
毫无疑问,**掌握“面向对象编程”是现在程序员的基本素养**。但落到实际开发时,每个人对它的理解程度却有深有浅,应用的水平也有高有低,有的人设计出的类精致灵活,而有的人设计出来的却是粗糙笨重。
细想起来,“面向对象”里面可以研究的地方实在是太多了。那么,到底“面向对象”的精髓是什么?怎样才能用好它?怎样才能写出一个称得上是“好”的类呢?
所以今天我就从设计思想、实现原则和编码准则这几个角度谈谈我对它的体会心得以及在C++里应用的一些经验技巧帮你写出更高效、更安全、更灵活的类。在第19、20课我还会具体讲解到时候你可以参考下。
## 设计思想
首先要说的是,虽然很多语言都内建语法支持面向对象编程,但它本质上是一种设计思想、方法,与语言细节无关,要点是**抽象**Abstraction和**封装**Encapsulation
掌握了这种代码之外的思考方式,就可以“高屋建瓴”,站在更高的维度上去设计程序,不会被语言、语法所限制。
所以即使是像C这样“纯”面向过程的编程语言也能够应用面向对象的思想以struct实现抽象和封装得到良好的程序结构。
面向对象编程的基本出发点是“对现实世界的模拟”,把问题中的实体抽象出来,封装为程序里的类和对象,这样就在计算机里为现实问题建立了一个“虚拟模型”。
然后以这个模型为基础不断演化,继续抽象对象之间的关系和通信,再用更多的对象去描述、模拟……直到最后,就形成了一个由许多互相联系的对象构成的系统。
把这个系统设计出来、用代码实现出来,就是“面向对象编程”了。
不过,因为现实世界非常复杂,“面向对象编程”作为一种工程方法,是不可能完美模拟的,纯粹的面向对象也有一些缺陷,其中最明显的就是“继承”。
“继承”的本意是重用代码表述类型的从属关系Is-A但它却不能与现实完全对应所以用起来就会出现很多意外情况。
比如那个著名的长方形的例子。Rectangle表示长方形Square继承Rectangle表示正方形。现在问题就来了这个关系在数学中是正确的但表示为代码却不太正确。长方形可以用成员函数单独变更长宽但正方形却不行长和宽必须同时变更。
还有那个同样著名的鸟类的例子。基类Bird有个Fly方法所有的鸟类都应该继承它。但企鹅、鸵鸟这样的鸟类却不会飞实现它们就必须改写Fly方法。
各种编程语言为此都加上了一些“补丁”像C++就有“多态”“虚函数”“重载”,虽然解决了“继承”的问题,但也使代码复杂化了,一定程度上扭曲了“面向对象”的本意。
## 实现原则
说了些“高大上”的理论是不是有点犯迷糊没关系下面我就在C++里细化一下。
就像我刚才说的,“面向对象编程”的关键点是“抽象”和“封装”,而“继承”“多态”并不是核心,只能算是附加品。
所以,我建议你在设计类的时候**尽量少用继承和虚函数**。
特别的,如果完全没有继承关系,就可以让对象不必承受“父辈的重担”(父类成员、虚表等额外开销),轻装前行,更小更快。没有隐含的重用代码也会降低耦合度,让类更独立,更容易理解。
还有把“继承”切割出去之后可以避免去记忆、实施那一大堆难懂的相关规则比如public/protected/private继承方式的区别、多重继承、纯虚接口类、虚析构函数还可以绕过动态转型、对象切片、函数重载等很多危险的陷阱减少冗余代码提高代码的健壮性。
如果非要用继承不可,那么我觉得一定要**控制继承的层次**用UML画个类体系的示意图来辅助检查。如果继承深度超过三层就说明有点“过度设计”了需要考虑用组合关系替代继承关系或者改用模板和泛型。
![](https://static001.geekbang.org/resource/image/14/d5/145780476bd0beb148e5e130c2336ed5.jpg)
在设计类接口的时候,我们也要让类尽量简单、“短小精悍”,**只负责单一的功能**。
如果很多功能混在了一起出现了“万能类”“意大利面条类”有时候也叫God Class就要应用设计模式、重构等知识把大类拆分成多个各负其责的小类。
我还看到过很多人有一种不好的习惯,就是喜欢在类内部定义一些嵌套类,美其名曰“高内聚”。但恰恰相反,这些内部类反而与上级类形成了强耦合关系,也是另一种形式的“万能类”。
其实,这本来是名字空间该做的事情,用类来实现就有点“越权”了。正确的做法应该是,**定义一个新的名字空间,把内部类都“提”到外面,降低原来类的耦合度和复杂度**。
## 编码准则
有了这些实现原则,下面我再来讲几个编码时的细节,从安全和性能方面帮你改善类的代码。
C++11新增了一个特殊的标识符“**final**”(注意,它不是关键字),把它用于类定义,就可以显式地禁用继承,防止其他人有意或者无意地产生派生类。无论是对人还是对编译器,效果都非常好,我建议你一定要积极使用。
```
class DemoClass final // 禁止任何人继承我
{ ... };
```
在必须使用继承的场合,建议你**只使用public继承避免使用virtual、protected**因为它们会让父类与子类的关系变得难以捉摸带来很多麻烦。当到达继承体系底层时也要及时使用“final”终止继承关系。
```
class Interface // 接口类定义没有final可以被继承
{ ... };
class Implement final : // 实现类final禁止再被继承
public Interface // 只用public继承
{ ... };
```
C++里类的四大函数你一定知道吧它们是构造函数、析构函数、拷贝构造函数、拷贝赋值函数。C++11因为引入了右值Rvalue和转移Move又多出了两大函数**转移构造函数**和**转移赋值函数**。所以在现代C++里,一个类总是会有六大基本函数:**三个构造**、**两个赋值**、**一个析构**。
好在C++编译器会自动为我们生成这些函数的默认实现,省去我们重复编写的时间和精力。但我建议,对于比较重要的构造函数和析构函数,应该用“**\= default**”的形式,明确地告诉编译器(和代码阅读者):“应该实现这个函数,但我不想自己写。”这样编译器就得到了明确的指示,可以做更好的优化。
```
class DemoClass final
{
public:
DemoClass() = default; // 明确告诉编译器,使用默认实现
~DemoClass() = default; // 明确告诉编译器,使用默认实现
};
```
这种“= default”是C++11新增的专门用于六大基本函数的用法相似的还有一种“**\= delete**”的形式。它表示**明确地禁用某个函数形式**,而且不限于构造/析构,可以用于任何函数(成员函数、自由函数)。
比如说如果你想要禁止对象拷贝就可以用这种语法显式地把拷贝构造和拷贝赋值“delete”掉让外界无法调用。
```
class DemoClass final
{
public:
DemoClass(const DemoClass&) = delete; // 禁止拷贝构造
DemoClass& operator=(const DemoClass&) = delete; // 禁止拷贝赋值
};
```
因为C++有隐式构造和隐式转型的规则,如果你的类里有单参数的构造函数,或者是转型操作符函数,为了防止意外的类型转换,保证安全,就要使用“**explicit**”将这些函数标记为“显式”。
```
class DemoClass final
{
public:
explicit DemoClass(const string_type& str) // 显式单参构造函数
{ ... }
explicit operator bool() // 显式转型为bool
{ ... }
};
```
## 常用技巧
C++11里还有很多能够让类更优雅的新特性这里我从“投入产出比”的角度出发挑出了三个我最喜欢的特性给你介绍一下让你不用花太多力气就能很好地改善代码质量。
第一个是“**委托构造**”delegating constructor
如果你的类有多个不同形式的构造函数为了初始化成员肯定会有大量的重复代码。为了避免重复常见的做法是把公共的部分提取出来放到一个init()函数里,然后构造函数再去调用。这种方法虽然可行,但**效率和可读性较差**毕竟init()不是真正的构造函数。
在C++11里你就可以使用“委托构造”的新特性一个构造函数直接调用另一个构造函数把构造工作“委托”出去既简单又高效。
```
class DemoDelegating final
{
private:
int a; // 成员变量
public:
DemoDelegating(int x) : a(x) // 基本的构造函数
{}
DemoDelegating() : // 无参数的构造函数
DemoDelegating(0) // 给出默认值,委托给第一个构造函数
{}
DemoDelegating(const string& s) : // 字符串参数构造函数
DemoDelegating(stoi(s)) // 转换成整数,再委托给第一个构造函数
{}
};
```
第二个是“**成员变量初始化**”In-class member initializer
如果你的类有很多成员变量,那么在写构造函数的时候就比较麻烦,必须写出一长串的名字来逐个初始化,不仅不美观,更危险的是,容易“手抖”,遗漏成员,造成未初始化的隐患。
而在C++11里你可以在类里声明变量的同时给它赋值实现初始化这样**不但简单清晰,也消除了隐患。**
```
class DemoInit final // 有很多成员变量的类
{
private:
int a = 0; // 整数成员,赋值初始化
string s = "hello"; // 字符串成员,赋值初始化
vector<int> v{1, 2, 3}; // 容器成员,使用花括号的初始化列表
public:
DemoInit() = default; // 默认构造函数
~DemoInit() = default; // 默认析构函数
public:
DemoInit(int x) : a(x) {} // 可以单独初始化成员,其他用默认值
};
```
第三个是“**类型别名**”Type Alias
C++11扩展了关键字using的用法增加了typedef的能力可以定义类型别名。它的格式与typedef正好相反别名在左边原名在右边是标准的赋值形式所以易写易读。
```
using uint_t = unsigned int; // using别名
typedef unsigned int uint_t // 等价的typedef
```
在写类的时候我们经常会用到很多外部类型比如标准库里的string、vector还有其他的第三方库和自定义类型。这些名字通常都很长特别是带上名字空间、模板参数书写起来很不方便这个时候我们就可以在类里面用using给它们起别名不仅简化了名字同时还能增强可读性。
```
class DemoClass final
{
public:
using this_type = DemoClass; // 给自己也起个别名
using kafka_conf_type = KafkaConfig; // 外部类起别名
public:
using string_type = std::string; // 字符串类型别名
using uint32_type = uint32_t; // 整数类型别名
using set_type = std::set<int>; // 集合类型别名
using vector_type = std::vector<std::string>;// 容器类型别名
private:
string_type m_name = "tom"; // 使用类型别名声明变量
uint32_type m_age = 23; // 使用类型别名声明变量
set_type m_books; // 使用类型别名声明变量
private:
kafka_conf_type m_conf; // 使用类型别名声明变量
};
```
类型别名不仅能够让代码规范整齐而且因为引入了这个“语法层面的宏定义”将来在维护时还可以随意改换成其他的类型。比如把字符串改成string\_viewC++17里的字符串只读视图把集合类型改成unordered\_set只要变动别名定义就行了原代码不需要做任何改动。
## 小结
今天我们谈了“面向对象编程”,这节课的内容也比较多,我划一下重点。
1. “面向对象编程”是一种设计思想,要点是“抽象”和“封装”,“继承”“多态”是衍生出的特性,不完全符合现实世界。
2. 在C++里应当少用继承和虚函数,降低对象的成本,绕过那些难懂易错的陷阱。
3. 使用特殊标识符“final”可以禁止类被继承简化类的层次关系。
4. 类有六大基本函数,对于重要的构造/析构函数,可以使用“= default”来显式要求编译器使用默认实现。
5. “委托构造”和“成员变量初始化”特性可以让创建对象的工作更加轻松。
6. 使用using或typedef可以为类型起别名既能够简化代码还能够适应将来的变化。
所谓“仁者见仁智者见智”,今天我讲的也只能算是我自己的经验、体会。到底要怎么用,你还是要看自己的实际情况,千万不要完全照搬。
## 课下作业
这次的课下作业时间,我给你留两个思考题:
1. 你对“面向对象编程”有哪些认识,是否赞同这节课的观点?(希望你大胆地说出来,如果意见有分歧,那也很正常,我们一起讨论。)
2. 你觉得应用这节课讲到的准则和技巧能否写出一个“好”的类,还缺什么吗?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
![](https://static001.geekbang.org/resource/image/67/b3/6739782414607164bdbe20fca7fd5fb3.jpg)