This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="01 | 重新认识C++:生命周期和编程范式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/9c/d3dd3640be80d4be65703babec7b0e9c.mp3"></audio>
你好我是Chrono。
今天是专栏的第一节正式课。我想既然你选择了这个专栏你就应该已经对C++有所了解了,而且有过一些开发经验,甚至还掌握了一两种其他的语言。
苏轼有诗云“不识庐山真面目只缘身在此山中。”学习C++很容易被纷繁复杂的语法细节所吸引、迷惑所以我决定从“生命周期”和“编程范式”这两个不太常见的角度来“剖析”一下C++站在一个更高的层次上审视这门“历久弥新”的编程语言帮你认清楚C++最本质的东西。
这样,今后在写程序的时候,你也会有全局观或者说是大局观,更能从整体上把握程序架构,而不会迷失在那些琐碎的细枝末节里。
现在我们先来了解下C++的生命周期。
## C++程序的生命周期
如果你学过一点软件工程的知识,就一定知道“瀑布模型”,它定义了软件或者是项目的生命周期——从需求分析开始,经过设计、开发、测试等阶段,直到最终交付给用户。
“瀑布模型”把软件的生命周期分成了多个阶段,每个阶段之间分工明确,相互独立,而且有严格的先后次序,是一个经典的开发模型。虽然它已经不再适合瞬息万变的互联网产业了,但仍然有许多值得借鉴和参考的地方。
那么说了半天“瀑布模型”跟C++程序有什么关系呢?
其实从软件工程的视角来看一个C++程序的生命周期也是“瀑布”形态的也可以划分出几个明确的阶段阶段之间顺序衔接使用类似的方法就可以更好地理解C++程序的运行机制,帮助我们写出更好的代码。
不过因为C++程序本身就已经处在“开发”阶段了所以不会有“需求分析”“设计”这样的写文档过程。所以一个C++程序从“诞生”到“消亡”,要经历这么几个阶段:**编码Coding、预处理Pre-processing、编译Compiling和运行Running**。
<img src="https://static001.geekbang.org/resource/image/b6/4c/b6696db53248d122cd57ddd9a8e52a4c.jpg" alt="" title="C++程序的四个阶段">
## C++程序的四个阶段
**编码**应该是你很熟悉的一个阶段了,这也是我们“明面”上的开发任务最集中的地方。
在这个阶段,我们的主要工作就是在编辑器里“敲代码”:定义变量,写语句,实现各种数据结构、函数和类。
**编码阶段是C++程序生命周期的起点也是最重要的阶段是后续阶段的基础直接决定了C++程序的“生存质量”**
显然,在编码阶段,我们必须要依据一些规范,不能“胡写一气”,**最基本的要求是遵循语言规范和设计文档,再高级一点的话,还有代码规范、注释规范、设计模式、编程惯用法,等等**。现在市面上绝大部分的资料都是在教授这个阶段的知识,在专栏后面,我也会重点讲一讲我在这方面的一些经验积累。
那么,编码阶段之后是什么呢?
可能对你来说稍微有点陌生,这个阶段叫**预处理**。
所谓的预处理,其实是相对于下一个阶段“编译”而言的,在编译之前,预先处理一下源代码,既有点像是编码,又有点像是编译,是一个中间阶段。
预处理是C/C++程序独有的阶段其他编程语言都没有这也算是C/C++语言的一个特色了。
在这个阶段发挥作用的是预处理器Pre-processor。它的输入是编码阶段产生的源码文件输出是经过“预处理”的源码文件。“预处理”的目的是文字替换用到的就是我们熟悉的各种预处理指令比如#include#define#if等,实现“**预处理编程**”。这部分内容,我后面还会展开讲。
不过,你要注意的是,它们都以符号“#”开头虽然是C++程序的一部分但严格来说不属于C++语言的范畴,因为它走的是预处理器。
在预处理之后C++程序就进入了**编译**阶段更准确地说应该是“编译”和“链接Linking”。简单起见我统一称之为“编译”。
在编译阶段C++程序——也就是经过预处理的源码——要经过编译器和链接器的“锤炼”生成可以在计算机上运行的二进制机器码。这里面的讲究是最多的也是最复杂的C++编译器要分词、语法解析、生成目标码,并尽可能地去优化。
在编译的过程中编译器还会根据C++语言规则检查程序的语法、语义是否正确发现错误就会产生“编译失败”。这就是最基本的C++“静态检查”。
在处理源码时由于编译器是依据C++语法检查各种类型、函数的定义,所以,在这个阶段,我们就能够以编译器为目标进行编程,有意识地控制编译器的行为。这里有个新名词,叫“**模板元编程**”。不过,“模板元编程”比较复杂,不太好理解,属于比较高级的用法,稍后我会再简单讲一下。
编译阶段之后有了可执行文件C++程序就可以跑起来了,进入**运行**阶段。这个时候“静态的程序”被载入内存由CPU逐条语句执行就形成了“动态的进程”。
运行阶段也是我们最熟悉的了。在这个阶段我们常做的是GDB调试、日志追踪、性能分析等然后收集动态的数据、调整设计思路再返回编码阶段重走这个“瀑布模型”实现“螺旋上升式”的开发。
<img src="https://static001.geekbang.org/resource/image/9c/df/9cb2036ae3dbda30a00d58bdd4834ddf.jpg" alt="">
好了梳理清楚了C++程序生命周期的四个阶段,你可以看到,这和软件工程里的“瀑布模型”很相似,这些阶段也是职责明确的,前一个阶段的输出作为后一个阶段的输入,而且每个阶段都有自己的工作特点,我们可以有针对性地去做编程开发。
还有别忘了软件工程里的“蝴蝶效应”“混沌理论”大概意思是一个Bug在越早的阶段发现并解决它的价值就越高一个Bug在越晚的阶段发现并解决它的成本就越高。
所以,依据这个生命周期模型,**我们应该在“编码”“预处理”“编译”这前面三个阶段多下功夫消灭Bug优化代码尽量不要让Bug在“运行”阶段才暴露出来**,也就是所谓的“把问题扼杀在萌芽期”。
## C++语言的编程范式
说完了C++程序的生命周期再来看看C++的编程范式Paradigm
什么是编程范式呢?
关于这个概念,没有特别权威的定义,我给一个比较通俗的解释:**“编程范式”是一种“方法论”,就是指导你编写代码的一些思路、规则、习惯、定式和常用语**。
编程范式和编程语言不同,有的范式只能用于少数特定的语言,有的范式却适用于大多数语言;有的语言可能只支持一种范式,有的语言却可能支持多种范式。
那么你一定知道或者听说过C++是一种**多范式**的编程语言。具体来说现代C++11/14以后支持“面向过程”“面向对象”“泛型”“模板元”“函数式”这五种主要的编程范式。
其中,**“面向过程”“面向对象”是基础,支撑着后三种范式**。我画了一个“五环图”,圆环重叠表示有的语言特性会同时应用于多种范式,可以帮你理解它们的关系。
<img src="https://static001.geekbang.org/resource/image/6e/87/6ef13308109b2d1795e43c5206c32687.jpg" alt="" title="C++编程范式的“五环图”">
接下来,我就和你详细说说这五种编程范式。
## C++语言的五种范式
**面向过程**是C++里最基本的一种编程范式。它的核心思想是“命令”,通常就是顺序执行的语句、子程序(函数),把任务分解成若干个步骤去执行,最终达成目标。
面向过程体现在C++中就是源自它的前身——C语言的那部分比如变量声明、表达式、分支/循环/跳转语句,等等。
**面向对象**是C++里另一个基本的编程范式。**它的核心思想是“抽象”和“封装”**,倡导的是把任务分解成一些高内聚低耦合的对象,这些对象互相通信协作来完成任务。它强调对象之间的关系和接口,而不是完成任务的具体步骤。
在C++里面向对象范式包括class、public、private、virtual、this等类相关的关键字还有构造函数、析构函数、友元函数等概念。
**泛型编程**是自STL标准模板库纳入到C++标准以后才逐渐流行起来的新范式,核心思想是“一切皆为类型”,或者说是“参数化类型”“类型擦除”,使用模板而不是继承的方式来复用代码,所以运行效率更高,代码也更简洁。
在C++里泛型的基础就是template关键字然后是庞大而复杂的标准库里面有各种泛型容器和算法比如vector、map、sort等等。
与“泛型编程”很类似的是**模板元编程**这个词听起来好像很新其实也有十多年的历史了不过相对于前三个范式来说确实“资历浅”。它的核心思想是“类型运算”操作的数据是编译时可见的“类型”所以也比较特殊代码只能由编译器执行而不能被运行时的CPU执行。
在讲编译阶段的时候我也说了模板元编程是一种高级、复杂的技术C++语言对它的支持也比较少更多的是以库的方式来使用比如type_traits、enable_if等。
最后一个**函数式**它几乎和“面向过程”一样古老但却直到近些年才走入主流编程界的视野。所谓的“函数式”并不是C++里写成函数的子程序,而是数学意义上、无副作用的函数,**核心思想是“一切皆可调用”,通过一系列连续或者嵌套的函数调用实现对数据的处理**。
函数式早在C++98时就有少量的尝试bind1st/bind2nd等函数对象但直到C++11引入了Lambda表达式它才真正获得了可与其他范式并驾齐驱的地位。
好了介绍完了这五种编程范式你可以看到它们基本覆盖了C++语言和标准库的各个成分,彼此之间虽然有重叠,但在理念、关键字、实现机制、运行阶段等方面的差异还是非常大的。
这就好像是五种秉性不同的“真气”在C++语言里必须要有相当“浑厚”的内力才能把它们压制、收服、炼化,否则的话,一旦运用不当,就很容易“精神分裂”“走火入魔”。
**说得具体一点,就是要认识、理解这些范式的优势和劣势,在程序里适当混用,取长补短才是“王道”**
说到这儿,你肯定很关心,该选择哪种编程范式呢?
拿我自己来说,我的出发点是“**尽量让周围的人都能看懂代码**”,所以常用的范式是“过程+对象+泛型”,再加上少量的“函数式”,慎用“模板元”。
对于你来说,我建议根据自己的实际工作需求来决定。
我个人觉得,**面向过程和面向对象是最基本的范式是C++的基础,无论如何都是必须要掌握的**,而后三种范式的学习难度就大一些。
如果是开发直接面对用户的普通应用Application那么你可以再研究一下“泛型”和“函数式”就基本可以解决90%的开发问题了如果是开发面向程序员的库Library那么你就有必要深入了解“泛型”和“模板元”优化库的接口和运行效率。
当然,还有一种情况:如果你愿意挑战“最强大脑”,那么,“模板元编程”就绝对是你的不二选择(笑)。
## 小结
今天是开篇第一课我带你从“生命周期”和“编程范式”这两个特别的角度深度“透视”了一下C++,做个简单小结:
1. C++程序的生命周期包括编码、预处理、编译、运行四个阶段,它们都有各自的特点;
1. 虽然我们只写了一个C++程序但里面的代码可能会运行在不同的阶段分别由预处理器、编译器和CPU执行
1. C++支持面向过程、面向对象、泛型、模板元、函数式共五种主要的编程范式;
1. 在C++里可以“无缝”混用多范式编程,但因为范式的差异比较大,必须小心谨慎,避免导致混乱。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你是怎么理解C++程序的生命周期和编程范式的?
1. 试着从程序的生命周期和编程范式的角度把C++和其他语言例如Java、Python做个比较说说C++的优点和缺点分别是什么。
欢迎你在留言区写下你的思考和答案,如果觉得对你有所帮助,也欢迎把今天的内容分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/4a/a4/4a40e5b8c618ab38945c1346ab3878a4.jpg" alt="">

View File

@@ -0,0 +1,232 @@
<audio id="audio" title="02 | 编码阶段能做什么秀出好的code style" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/d7/7edff57af7a2f188139c7783ba030fd7.mp3"></audio>
你好我是Chrono。
上节课我介绍了C++程序的生命周期和编程范式,今天我就接着展开来讲,看看在编码阶段我们能做哪些事情。
在编码阶段,我们的核心任务就是写出在预处理、编译和运行等不同阶段里执行的代码,还记得那句编程格言吗:
“**任何人都能写出机器能看懂的代码,但只有优秀的程序员才能写出人能看懂的代码。**”
所以我们在编码阶段的首要目标不是实现功能而是要写出清晰易读的代码也就是要有好的code style。
怎么样才能写出human readable的好代码呢
这就需要有一些明确的、经过实践验证的规则来指导,只要自觉遵守、合理运用这些规则,想把代码写“烂”都很难。
在此,我强烈推荐一份非常棒的[指南](http://openresty.org/cn/c-coding-style-guide.html)它来自OpenResty的作者章亦春代码风格参照的是顶级开源产品Nginx内容非常详细完善。
不过有一点要注意这份指南描述的是C语言但对于C++仍然有很好的指导意义,所以接下来我就以它为基础,加上我的工作体会,从**代码格式**、**标识符命名**和**注释**三个方面讲一下怎么“秀出好的code style”。
## 空格与空行
当我们拿到一份编码风格指南的时候,不论它是公司内部的还是外部的,通常第一感觉就是“头大”,几十个、上百个的条款罗列在一起,规则甚至细致到了标点符号,再配上干巴巴的说明和示例,不花个半天功夫是绝对看不完的。而且,最大的可能是半途而废,成了“从入门到放弃”。
我写了很多年代码也看过不少这样的文档我从中总结出了一条最基本、最关键的规则只要掌握了这条规则就可以把你code style的“颜值”起码提高80%。
这条“神奇”的规则是什么呢?
认真听,只有五个字:**留白的艺术**。
再多说一点,就是像“写诗”一样去写代码,**恰当地运用空格和空行**。不要为了“节省篇幅”和“紧凑”而把很多语句挤在一起,而是要多用空格分隔开变量与操作符,用空行分隔开代码块,保持适当的阅读节奏。
你可以看看下面的这个示例,这是我从某个实际的项目中摘出来的真实代码(当然,我隐去了一些敏感信息):
```
if(!value.contains(&quot;xxx&quot;)){
LOGIT(WARNING,&quot;value is incomplete.\n&quot;)
return;
}
char suffix[16]=&quot;xxx&quot;;
int data_len = 100;
if(!value.empty()&amp;&amp;value.contains(&quot;tom&quot;)){
const char* name=value.c_str();
for(int i=0;i&lt;MAX_LEN;i++){
... // do something
}
int count=0;
for(int i=0;i&lt;strlen(name);i++){
... // do something
}
}
```
这段代码真可谓是“高密度”密密麻麻一大堆看着“有如滔滔江水连绵不绝”读起来让人“窒息”code style非常糟糕。
应用“留白的艺术”,代码就变成了下面的样子:
```
if (!value.contains(&quot;xxx&quot;)) { // if后{前有空格
LOGIT(WARNING, &quot;value is incomplete.\n&quot;) // 逗号后有空格
return; // 逻辑联系紧密就不用加空行
}
// 新增空行分隔段落
char suffix[16] = &quot;xxx&quot;; // 等号两边有空格
int data_len = 100; // 逻辑联系紧密就不用加空行
// 新增空行分隔段落
if (!value.empty() &amp;&amp; value.contains(&quot;tom&quot;)) { // &amp;&amp;两边有空格
const char* name = value.c_str(); // 等号两边有空格
// 新增空行分隔段落
for(int i = 0; i &lt; MAX_LEN; i++){ // =;&lt;处有空格
... // do something
}
// 新增空行分隔段落
int count = 0; // 等号两边有空格
// 新增空行分隔段落
for(int i = 0; i &lt; strlen(name); i++){ // =;&lt;处有空格
... // do something
}
}
```
加上了适当的空格和空行后,代码就显得错落有致,舒缓得当,看着就像是莎翁的十四行诗,读起来不那么累,也更容易理清楚代码的逻辑。
我还有个私人观点:**好程序里的空白行至少要占到总行数的20%以上**。虽然比较“极端”,但也不失为一个可量化的指标,你可以在今后的实际工作中尝试一下。
## 起个好名字
有了好的代码格式,接下来我们要操心的就是里面的内容了,而其中一个很重要的部分就是为变量、函数、类、项目等起一个好听易懂的名字。
这里有一个广泛流传的笑话:“**缓存失效与命名是计算机科学的两大难题**。”把起名字与缓存失效(也有说是并发)相提并论,足见它有多么重要了,值得引起你的重视。
但其实命名这件事并不难,主要就在于平时的词汇和经验积累,知道在什么情况下用哪个单词最合适,千万不要偷懒用“谜之缩写”和汉语拼音(更有甚者,是汉语拼音的缩写)。由于现在搜索引擎、电子词典都很发达,只要你有足够认真的态度,在网上一搜,就能够找到合适的名字。
另外你还可以用一些已经在程序员之间形成了普遍共识的变量名比如用于循环的i/j/k、用于计数的count、表示指针的p/ptr、表示缓冲区的buf/buffer、表示变化量的delta、表示总和的sum……
关于命名的风格,我所知道的应用比较广的有三种。
第一种风格叫“匈牙利命名法”在早期的Windows上很流行使用前缀i/n/sz等来表示变量的类型比如iNum/szName。它把类型信息做了“硬编码”不适合代码重构和泛型编程所以目前基本上被淘汰了。
不过它里面有一种做法我还是比较欣赏的就是给成员变量加“m_”前缀member给全局变量加“g_”前缀global比如m_count、g_total这样一看就知道了变量的作用域在大型程序里还是挺有用的。
第二种风格叫“CamelCase”也就是“驼峰式命名法”在Java语言里非常流行主张单词首字母大写比如MyJobClass、tryToLock但这种风格在C++世界里的接受程度不是太高。
第三种风格叫“snake_case”用的是全小写单词之间用下划线连接。这是C和C++主要采用的命名方式看一下标准库里面的vector、unordered_set、shrink_to_fit都是这样。
那么,你该选用哪种命名风格呢?
我的建议是“取百家之长”,混用这几种中能够让名字辨识度最高的那些优点,就是四条规则:
1. 变量、函数名和名字空间用snake_case全局变量加“g_”前缀
1. 自定义类名用CamelCase成员函数用snake_case成员变量加“m_”前缀
1. 宏和常量应当全大写,单词之间用下划线连接;
1. 尽量不要用下划线作为变量的前缀或者后缀比如_local、name_很难识别。
下面我举几个例子,你一看就能明白了:
```
#define MAX_PATH_LEN 256 //常量,全大写
int g_sys_flag; // 全局变量加g_前缀
namespace linux_sys { // 名字空间,全小写
void get_rlimit_core(); // 函数,全小写
}
class FilePath final // 类名,首字母大写
{
public:
void set_path(const string&amp; str); // 函数,全小写
private:
string m_path; // 成员变量m_前缀
int m_level; // 成员变量m_前缀
};
```
命名另一个相关的问题是“名字的长度”,有人喜欢写得长,有人喜欢写得短,我觉得都可以,只要易读易写就行。
不过一个被普遍认可的原则是:**变量/函数的名字长度与它的作用域成正比**,也就是说,局部变量/函数名可以短一点,而全局变量/函数名应该长一点。
想一下,如果你辛辛苦苦起了一个包含四五个单词的长名字,却只能在短短十几行的循环体里使用,岂不是太浪费了?
## 用好注释
写出了有好名字的变量、函数和类还不够,要让其他人能“一眼”看懂代码,还需要加上注释。
“注释”在任何编程语言里都是一项非常重要的功能甚至在编程语言之外比如配置文件ini、yml、标记语言html、xml都有注释。一个突出的反例就是JSON没有注释功能让许多人都很不适应。
注释表面上的功能很简单,就是给代码配上额外的文字,起到注解、补充说明的作用。但就像是写文章一样,写些什么、写多写少、写成什么样子,都是大有讲究的。
你可能也有不少写注释的经验了,一般来说,注释可以用来阐述目的、用途、工作原理、注意事项等代码本身无法“自说明”的那些东西。
但要小心,注释必须要正确、清晰、有效,尽量言简意赅、点到为止,不要画蛇添足,更不能写出含糊、错误的注释。
比如说有这么一个模板函数get_value
```
template&lt;typename T&gt;
int get_value(const T&amp; v);
```
代码很简单,但可用的信息太少了,你就可以给它加上作者、功能说明、调用注意事项、可能的返回值,等等,这样看起来就会舒服得多:
```
// author : chrono
// date : 2020-xx-xx
// purpose : get inner counter value of generic T
// notice : T must have xxx member
// notice : return value maybe -1, means xxx, you should xxx
template&lt;typename T&gt;
int get_value(const T&amp; v);
```
你可能注意到了在注释里我都用的是英文因为英文ASCII或者说是UTF-8的“兼容性”最好不会由于操作系统、编码的问题变成无法阅读的乱码而且还能够锻炼自己的英语表达能力。
不过,用英文写注释的同时也对你提出了更高的要求,最基本的是**不要出现低级的语法、拼写错误**。别笑我就经常见到有人英文水平不佳或者是“敷衍了事”写出的都是“Chinglish”看了让人哭笑不得。
写注释最好也要有一些标准的格式比如用统一的“标签”来标记作者、参数说明什么的。这方面我觉得你可以参考Javadoc它算是一个不错的工程化实践。
对于C++来说也有一个类似的工具叫Doxgen用好它甚至可以直接从源码生成完整的API文档。不过我个人觉得Doxgen的格式有些过于“死板”很难严格执行是否采用就在于你自己了。
除了给代码、函数、类写注释,我还建议**最好在文件的开头写上本文件的注释**,里面有文件的版权声明、更新历史、功能描述,等等。
下面这个就是我比较常用的一个文件头注释,简单明了,你可以参考一下。
```
// Copyright (c) 2020 by Chrono
//
// file : xxx.cpp
// since : 2020-xx-xx
// desc : ...
```
另外注释还有一个很有用的功能就是todo作为功能的占位符提醒将来的代码维护者也许就是你比如
```
// TODO: change it to unordered_map
// XXX: fixme later
```
总的来说,要写好注释,你要时刻“换位思考”,设身处地去想别人怎么看你的代码,这样的话,上面的那些细则也就不难实施了。
## 小结
在编码阶段,拥有一个良好的编程习惯和态度是非常重要的(我见过太多对此漫不经心的“老”程序员)。今天,我只介绍了三个最基本的部分,再来“敲黑板”画个重点:
1. 用好空格和空行,多留白,让写代码就像写诗一样;
1. 给变量、函数、类起个好名字,你的代码就成功了一半;
1. 给变量、函数、类再加上注释,让代码自带文档,就成了“人能够看懂的代码”。
有了这个基础你还可以更进一步使用其他高级规则写出更好的代码比如函数体不能太长、入口参数不宜过多避免使用else/switch导致层次太深圈复杂度等等这些虽然也很有用但比较琐碎暂时就不细说了。
对了还有一招“必杀技”善用code review和你周围的同事互相审查代码可以迅速改善自己的code style。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你用过哪些好的code style你最喜欢今天介绍的哪几条
1. 注释在代码里通常的作用是“说明文档”,但它还有另外一个重要的用法,你知道吗?
欢迎你在留言区写下你的思考和答案,如果觉得对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/3a/41/3a5325510a8c10a318f82f9ac2696941.jpg" alt="">

View File

@@ -0,0 +1,297 @@
<audio id="audio" title="03 | 预处理阶段能做什么:宏定义和条件编译" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/03/d25824d6423a51ee9db36a18dae10e03.mp3"></audio>
你好我是Chrono。
上一次我讲了在编码阶段要有好的code style尽量写出“人能够看懂的代码”。今天我就继续讲讲编码后的预处理阶段看看这个阶段我们能做哪些事情。
## 预处理编程
其实只要写C/C++程序,就会用到预处理,只是大多数时候,你只用到了它的一点点功能。比如,在文件开头写上“#include <vector>”这样的语句,或者用“#define”定义一些常数。只是这些功能都太简单了,没有真正发挥出预处理器的本领,所以你可能几乎感觉不到它的存在。</vector>
预处理只能用很少的几个指令,也没有特别严谨的“语法”,但它仍然是一套完整自洽的语言体系,使用预处理也能够实现复杂的编程,解决一些特别的问题——虽然代码可能会显得有些“丑陋”“怪异”。
那么,“预处理编程”到底能干什么呢?
你一定要记住:**预处理阶段编程的操作目标是“源码”,用各种指令控制预处理器,把源码改造成另一种形式,就像是捏橡皮泥一样。**
把上面的这句话多读几遍,仔细揣摩体会一下,理解了之后,你再去用那些预处理指令就会有不一样的感觉了。
C++语言有近百个关键字,预处理指令只有十来个,实在是少得可怜。而且,常用的也就是#include#define#if,所以很容易掌握。不过,有几个小点我还是要特别说一下。
首先,预处理指令都以符号“#”开头这个你应该很熟悉了。但同时你也应该意识到虽然都在一个源文件里但它不属于C++语言它走的是预处理器不受C++语法规则的约束。
所以预处理编程也就不用太遵守C++代码的风格。一般来说预处理指令不应该受C++代码缩进层次的影响不管是在函数、类里还是在if、for等语句里永远是**顶格写**。
另外,单独的一个“#”也是一个预处理指令,叫“空指令”,可以当作特别的预处理空行。而“#”与后面的指令之间也可以有空格,从而实现缩进,方便排版。
下面是一个示例,#号都在行首而且if里面的define有缩进看起来还是比较清楚的。以后你在写预处理代码的时候可以参考这个格式。
```
# // 预处理空行
#if __linux__ // 预处理检查宏是否存在
# define HAS_LINUX 1 // 宏定义,有缩进
#endif // 预处理条件语句结束
# // 预处理空行
```
预处理程序也有它的特殊性暂时没有办法调试不过可以让GCC使用“-E”选项略过后面的编译链接只输出预处理后的源码比如
```
g++ test03.cpp -E -o a.cxx #输出预处理后的源码
```
多使用这种方式,对比一下源码前后的变化,你就可以更好地理解预处理的工作过程了。
这几个小点有些杂,不过你只要记住“**#开头、顶格写**”就行了。
## 包含文件(#include
先来说说最常用的预处理指令“#include”,它的作用是“**包含文件**”。注意,不是“包含头文件”,而是**可以包含任意的文件**。
也就是说,只要你愿意,使用“#include”可以把源码、普通文本,甚至是图片、音频、视频都引进来。当然,出现无法处理的错误就是另外一回事了。
```
#include &quot;a.out&quot; // 完全合法的预处理包含指令,你可以试试
```
可以看到,“#include”其实是非常“弱”的,不做什么检查,就是“死脑筋”把数据合并进源文件。
所以,在写头文件的时候,为了防止代码被重复包含,通常要加上“**Include Guard**”,也就是用“#ifndef/#define/#endif”来保护整个头文件,像下面这样:
```
#ifndef _XXX_H_INCLUDED_
#define _XXX_H_INCLUDED_
... // 头文件内容
#endif // _XXX_H_INCLUDED_
```
这个手法虽然比较“原始”但在目前来说C++11/14是唯一有效的方法而且也向下兼容C语言。所以我建议你在所有头文件里强制使用。
除了最常用的包含头文件,你还可以利用“#include”的特点玩些“小花样”,编写一些代码片段,存进“*.inc”文件里然后有选择地加载用得好的话可以实现“源码级别的抽象”。
比如说,有一个用于数值计算的大数组,里面有成百上千个数,放在文件里占了很多地方,特别“碍眼”:
```
static uint32_t calc_table[] = { // 非常大的一个数组,有几十行
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba,
0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
...
};
```
这个时候,你就可以把它单独摘出来,另存为一个“*.inc”文件然后再用“#include”替换原来的大批数字。这样就节省了大量的空间,让代码更加整洁。
```
static uint32_t calc_table[] = {
# include &quot;calc_values.inc&quot; // 非常大的一个数组,细节被隐藏
};
```
## 宏定义(#define/#undef
接下来要说的是预处理编程里最重要、最核心的指令“#define”,它用来定义一个源码级别的“**文本替换**”,也就是我们常说的“**宏定义**”。
#define”可谓“无所不能”在预处理阶段可以无视C++语法限制,替换任何文字,定义常量/变量实现函数功能为类型起别名typedef减少重复代码……
不过也正是因为它太灵活如果过于随意地去使用宏来写程序就有可能把正常的C++代码搞得“千疮百孔”,替换来替换去,都不知道真正有效的代码是什么样子了。
所以,**使用宏的时候一定要谨慎,时刻记着以简化代码、清晰易懂为目标,不要“滥用”,避免导致源码混乱不堪,降低可读性。**
下面,我就说几个注意事项,帮你用好宏定义。
首先因为宏的展开、替换发生在预处理阶段不涉及函数调用、参数传递、指针寻址没有任何运行期的效率损失所以对于一些调用频繁的小代码片段来说用宏来封装的效果比inline关键字要更好因为它真的是源码级别的无条件内联。
下面有几个示例摘自Nginx你可以作为参考
```
#define ngx_tolower(c) ((c &gt;= 'A' &amp;&amp; c &lt;= 'Z') ? (c | 0x20) : c)
#define ngx_toupper(c) ((c &gt;= 'a' &amp;&amp; c &lt;= 'z') ? (c &amp; ~0x20) : c)
#define ngx_memzero(buf, n) (void) memset(buf, 0, n)
```
其次,你要知道,**宏是没有作用域概念的,永远是全局生效**。所以,对于一些用来简化代码、起临时作用的宏,最好是用完后尽快用“#undef”取消定义,避免冲突的风险。像下面这样:
```
#define CUBE(a) (a) * (a) * (a) // 定义一个简单的求立方的宏
cout &lt;&lt; CUBE(10) &lt;&lt; endl; // 使用宏简化代码
cout &lt;&lt; CUBE(15) &lt;&lt; endl; // 使用宏简化代码
#undef CUBE // 使用完毕后立即取消定义
```
另一种做法是**宏定义前先检查**如果之前有定义就先undef然后再重新定义
```
#ifdef AUTH_PWD // 检查是否已经有宏定义
# undef AUTH_PWD // 取消宏定义
#endif // 宏定义检查结束
#define AUTH_PWD &quot;xxx&quot; // 重新宏定义
```
再次你可以适当使用宏来定义代码中的常量消除“魔术数字”“魔术字符串”magic number
虽然不少人认为定义常量更应该使用enum或者const但我觉得宏定义毕竟用法简单也是源码级的真正常量而且还是从C继承下来的传统用在头文件里还是有些优势的。
这种用法非常普遍,你可能也经常用,我就简单举两个例子吧:
```
#define MAX_BUF_LEN 65535
#define VERSION &quot;1.0.18&quot;
```
不过你要注意,关键是要“适当”,自己把握好分寸,不要把宏弄得“满天飞”。
除了上面说的三个如果你开动脑筋用好“文本替换”的功能也能发掘出许多新颖的用法。我有一个比较实际的例子用宏来代替直接定义名字空间namespace
```
#define BEGIN_NAMESPACE(x) namespace x {
#define END_NAMESPACE(x) }
BEGIN_NAMESPACE(my_own)
... // functions and classes
END_NAMESPACE(my_own)
```
这里我定义了两个宏BEGIN_NAMESPACE和END_NAMESPACE虽然只是简单的文本替换但它全大写的形式非常醒目可以很容易地识别出名字空间开始和结束的位置。
## 条件编译(#if/#else/#endif
利用“#define”定义出的各种宏,我们还可以在预处理阶段实现分支处理,通过判断宏的数值来产生不同的源码,改变源文件的形态,这就是“**条件编译**”。
条件编译有两个要点,一个是条件指令“#if”,另一个是后面的“判断依据”,也就是定义好的各种宏,而**这个“判断依据”是条件编译里最关键的部分**。
通常编译环境都会有一些预定义宏比如CPU支持的特殊指令集、操作系统/编译器/程序库的版本、语言特性等,使用它们就可以早于运行阶段,提前在预处理阶段做出各种优化,产生出最适合当前系统的源码。
你必须知道的一个宏是“**__cplusplus**”它标记了C++语言的版本号使用它能够判断当前是C还是C++是C++98还是C++11。你可以看下面这个例子。
```
#ifdef __cplusplus // 定义了这个宏就是在用C++编译
extern &quot;C&quot; { // 函数按照C的方式去处理
#endif
void a_c_function(int a);
#ifdef __cplusplus // 检查是否是C++编译
} // extern &quot;C&quot; 结束
#endif
#if __cplusplus &gt;= 201402 // 检查C++标准的版本号
cout &lt;&lt; &quot;c++14 or later&quot; &lt;&lt; endl; // 201402就是C++14
#elif __cplusplus &gt;= 201103 // 检查C++标准的版本号
cout &lt;&lt; &quot;c++11 or before&quot; &lt;&lt; endl; // 201103是C++11
#else // __cplusplus &lt; 201103 // 199711是C++98
# error &quot;c++ is too old&quot; // 太低则预处理报错
#endif // __cplusplus &gt;= 201402 // 预处理语句结束
```
除了“__cplusplus”C++里还有很多其他预定义的宏,像源文件信息的“**FILE**”“ **LINE**”“ **DATE**以及一些语言特性测试宏比如“__cpp_decltype” “__cpp_decltype_auto” “__cpp_lib_make_unique”等。
不过与优化更密切相关的底层系统信息在C++语言标准里没有定义但编译器通常都会提供比如GCC可以使用一条简单的命令查看
```
g++ -E -dM - &lt; /dev/null
#define __GNUC__ 5
#define __unix__ 1
#define __x86_64__ 1
#define __UINT64_MAX__ 0xffffffffffffffffUL
...
```
基于它们,你就可以更精细地根据具体的语言、编译器、系统特性来改变源码,有,就用新特性;没有,就采用变通实现:
```
#if defined(__cpp_decltype_auto) //检查是否支持decltype(auto)
cout &lt;&lt; &quot;decltype(auto) enable&quot; &lt;&lt; endl;
#else
cout &lt;&lt; &quot;decltype(auto) disable&quot; &lt;&lt; endl;
#endif //__cpp_decltype_auto
#if __GNUC__ &lt;= 4
cout &lt;&lt; &quot;gcc is too old&quot; &lt;&lt; endl;
#else // __GNUC__ &gt; 4
cout &lt;&lt; &quot;gcc is good enough&quot; &lt;&lt; endl;
#endif // __GNUC__ &lt;= 4
#if defined(__SSE4_2__) &amp;&amp; defined(__x86_64)
cout &lt;&lt; &quot;we can do more optimization&quot; &lt;&lt; endl;
#endif // defined(__SSE4_2__) &amp;&amp; defined(__x86_64)
```
除了这些内置宏你也可以用其他手段自己定义更多的宏来实现条件编译。比如Nginx就使用Shell脚本检测外部环境生成一个包含若干宏的源码配置文件再条件编译包含不同的头文件实现操作系统定制化
```
#if (NGX_FREEBSD)
# include &lt;ngx_freebsd.h&gt;
#elif (NGX_LINUX)
# include &lt;ngx_linux.h&gt;
#elif (NGX_SOLARIS)
# include &lt;ngx_solaris.h&gt;
#elif (NGX_DARWIN)
# include &lt;ngx_darwin.h&gt;
#endif
```
条件编译还有一个特殊的用法,那就是,使用“#if 1”“#if 0”来显式启用或者禁用大段代码要比“/* … */”的注释方式安全得多,也清楚得多,这也是我的一个“不传之秘”。
```
#if 0 // 0即禁用下面的代码1则是启用
... // 任意的代码
#endif // 预处理结束
#if 1 // 1启用代码用来强调下面代码的必要性
... // 任意的代码
#endif // 预处理结束
```
## 小结
今天我讲了预处理阶段现在你是否对我们通常写的程序有了新的认识呢它实际上是混合了预处理编程和C++编程的两种代码。
预处理编程由预处理器执行,使用#include#define#if等指令来实现文件包含、文本替换、条件编译,把编码阶段产生的源码改变为另外一种形式。适当使用的话,可以简化代码、优化性能,但如果是“炫技”式地过分使用,就会导致导致代码混乱,难以维护。
再简单小结一下今天的内容:
1. 预处理不属于C++语言,过多的预处理语句会扰乱正常的代码,除非必要,应当少用慎用;
1.#include”可以包含任意文件,所以可以写一些小的代码片段,再引进程序里;
1. 头文件应该加上“Include Guard”防止重复包含
1.#define”用于宏定义,非常灵活,但滥用文本替换可能会降低代码的可读性;
1. “条件编译”其实就是预处理编程里的分支语句,可以改变源码的形态,针对系统生成最合适的代码。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你认为宏的哪些用法可以用其他方式替代,哪些是不可替代的?
1. 你用过条件编译吗?分析一下它的优点和缺点。
欢迎你在留言区写下你的思考和答案,如果觉得对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/b8/11/b8b819b1331736ebc40664cd878f7511.jpg" alt="">

View File

@@ -0,0 +1,231 @@
<audio id="audio" title="04 | 编译阶段能做什么:属性和静态断言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/db/d43e25a507bc2644f8101f105e8470db.mp3"></audio>
你好我是Chrono。
前面我讲了C++程序生命周期里的“编码阶段”和“预处理阶段”,它们的工作主要还是“文本编辑”,生成的是**人类可识别的源码**source code。而“编译阶段”就不一样了它的目标是**生成计算机可识别的机器码**machine instruction code
今天,我就带你来看看在这个阶段能做些什么事情。
## 编译阶段编程
编译是预处理之后的阶段它的输入是经过预处理的C++源码,输出是**二进制可执行文件**(也可能是汇编文件、动态库或者静态库)。这个处理动作就是由编译器来执行的。
和预处理阶段一样在这里你也可以“面向编译器编程”用一些指令或者关键字让编译器按照你的想法去做一些事情。只不过这时你要面对的是庞大的C++语法,而不是简单的文本替换,难度可以说是高了好几个数量级。
编译阶段的特殊性在于它看到的都是C++语法实体比如typedef、using、template、struct/class这些关键字定义的类型而不是运行阶段的变量。所以这时的编程思维方式与平常大不相同。我们熟悉的是CPU、内存、Socket但要去理解编译器的运行机制、知道怎么把源码翻译成机器码这可能就有点“强人所难”了。
比如说,让编译器递归计算斐波那契数列,这已经算是一个比较容易理解的编译阶段数值计算用法了:
```
template&lt;int N&gt;
struct fib // 递归计算斐波那契数列
{
static const int value =
fib&lt;N - 1&gt;::value + fib&lt;N - 2&gt;::value;
};
template&lt;&gt;
struct fib&lt;0&gt; // 模板特化计算fib&lt;0&gt;
{
static const int value = 1;
};
template&lt;&gt;
struct fib&lt;1&gt; // 模板特化计算fib&lt;1&gt;
{
static const int value = 1;
};
// 调用后输出2358
cout &lt;&lt; fib&lt;2&gt;::value &lt;&lt; endl;
cout &lt;&lt; fib&lt;3&gt;::value &lt;&lt; endl;
cout &lt;&lt; fib&lt;4&gt;::value &lt;&lt; endl;
cout &lt;&lt; fib&lt;5&gt;::value &lt;&lt; endl;
```
对于编译器来说可以在一瞬间得到结果但你要搞清楚它的执行过程就得在大脑里把C++模板特化的过程走一遍。整个过程无法调试,完全要靠自己去推导,特别“累人”。(你也可以把编译器想象成是一种特殊的“虚拟机”,在上面跑的是只有编译器才能识别、处理的代码。)
简单的尚且如此,那些复杂的就更不用说了。所以,今天我就不去讲那些太过于“烧脑”的知识了,而是介绍两个比较容易理解的编译阶段技巧:属性和静态断言,让你能够立即用得上,效果也是“立竿见影”。
## 属性attribute
“预处理编程”[这一讲](https://time.geekbang.org/column/article/233711)提到的#include#define都是预处理指令,是用来控制预处理器的。那么问题就来了,有没有用来控制编译器的“编译指令”呢?
虽然编译器非常聪明但因为C++语言实在是太复杂了,偶尔它也会“自作聪明”或者“冒傻气”。如果有这么一个东西,让程序员来手动指示编译器这里该如何做、那里该如何做,就有可能会生成更高效的代码。
在C++11之前标准里没有规定这样的东西但GCC、VC等编译器发现这样做确实很有用于是就实现出了自己“编译指令”在GCC里是“__ attribute __在VC里是“__declspec”。不过因为它们不是标准所以名字显得有点“怪异”。
到了C++11标准委员会终于认识到了“编译指令”的好处于是就把“民间”用法升级为“官方版本”起了个正式的名字叫“**属性**”。你可以把它理解为给变量、函数、类等“贴”上一个编译阶段的“标签”,方便编译器识别处理。
“属性”没有新增关键字,而是用两对方括号的形式“[[…]]”方括号的中间就是属性标签看着是不是很像一张方方正正的便签条。所以它的用法很简单比GCC、VC的都要简洁很多。
我举个简单的例子,你看一下就明白了:
```
[[noreturn]] // 属性标签
int func(bool flag) // 函数绝不会返回任何值
{
throw std::runtime_error(&quot;XXX&quot;);
}
```
不过在C++11里只定义了两个属性“noreturn”和“carries_dependency”它们基本上没什么大用处。
C++14的情况略微好了点增加了一个比较实用的属性“deprecated”用来标记不推荐使用的变量、函数或者类也就是被“废弃”。
比如说你原来写了一个函数old_func(),后来觉得不够好,就另外重写了一个完全不同的新函数。但是,那个老函数已经发布出去被不少人用了,立即删除不太可能,该怎么办呢?
这个时候你就可以让“属性”发挥威力了。你可以给函数加上一个“deprecated”的编译期标签再加上一些说明文字
```
[[deprecated(&quot;deadline:2020-12-31&quot;)]] // C++14 or later
int old_func();
```
于是,任何用到这个函数的程序都会在编译时看到这个标签,报出一条警告:
```
warning: int old_func() is deprecated: deadline:2020-12-31 [-Wdeprecated-declarations]
```
当然,程序还是能够正常编译的,但这种强制的警告形式会“提醒”用户旧接口已经被废弃了,应该尽快迁移到新接口。显然,这种形式要比毫无约束力的文档或者注释要好得多。
目前的C++17和C++20又增加了五六个新属性比如fallthrough、likely但我觉得标准委员会的态度还是太“保守”了在实际的开发中这些真的是不够用。
好在“属性”也支持非标准扩展允许以类似名字空间的方式使用编译器自己的一些“非官方”属性比如GCC的属性都在“gnu::”里。下面我就列出几个比较有用的(全部属性可参考[GCC文档](https://gcc.gnu.org/onlinedocs/gcc/Attribute-Syntax.html))。
- deprecated与C++14相同但可以用在C++11里。
- unused用于变量、类型、函数等表示虽然暂时不用但最好保留着因为将来可能会用。
- constructor函数会在main()函数之前执行,效果有点像是全局对象的**构造**函数。
- destructor函数会在main()函数结束之后执行,有点像是全局对象的**析构**函数。
- always_inline要求编译器强制内联函数作用比inline关键字更强。
- hot标记“热点”函数要求编译器更积极地优化。
这几个属性的含义还是挺好理解的吧我拿“unused”来举个例子。
在没有这个属性的时候,如果有暂时用不到的变量,我们只能用“(void) **var**;”的方式假装用一下,来“骗”过编译器,属于“不得已而为之”的做法。
那么现在我们就可以用“unused”属性来清楚地告诉编译器这个变量我暂时不用请不要过度紧张不要发出警告来烦我
```
[[gnu::unused]] // 声明下面的变量暂不使用,不是错误
int nouse;
```
[GitHub仓库](https://github.com/chronolaw/cpp_study/blob/master/section1/compile.cpp)里的示例代码里还展示了其他属性的用法,你可以在课下参考。
## 静态断言static_assert
“属性”像是给编译器的一个“提示”“告知”,无法进行计算,还算不上是编程,而接下来要讲的“**静态断言**”,就有点编译阶段写程序的味道了。
你也许用过assert吧它用来断言一个表达式必定为真。比如说数字必须是正数指针必须非空、函数必须返回true
```
assert(i &gt; 0 &amp;&amp; &quot;i must be greater than zero&quot;);
assert(p != nullptr);
assert(!str.empty());
```
当程序也就是CPU运行到assert语句时就会计算表达式的值如果是false就会输出错误消息然后调用abort()终止程序的执行。
注意assert虽然是一个宏但在预处理阶段不生效而是在运行阶段才起作用所以又叫“**动态断言**”。
有了“动态断言”,那么相应的也就有“静态断言”,名字也很像,叫“**static_assert**”,不过它是一个专门的关键字,而不是宏。因为它只在编译时生效,运行阶段看不见,所以是“静态”的。
“静态断言”有什么用呢?
类比一下assert你就可以理解了。它是编译阶段里检测各种条件的“断言”编译器看到static_assert也会计算表达式的值如果值是false就会报错导致编译失败。
比如说,这节课刚开始时的斐波拉契数列计算函数,可以用静态断言来保证模板参数必须大于等于零:
```
template&lt;int N&gt;
struct fib
{
static_assert(N &gt;= 0, &quot;N &gt;= 0&quot;);
static const int value =
fib&lt;N - 1&gt;::value + fib&lt;N - 2&gt;::value;
};
```
再比如说要想保证我们的程序只在64位系统上运行可以用静态断言在编译阶段检查long的大小必须是8个字节当然你也可以换个思路用预处理编程来实现
```
static_assert(
sizeof(long) &gt;= 8, &quot;must run on x64&quot;);
static_assert(
sizeof(int) == 4, &quot;int must be 32bit&quot;);
```
这里你一定要注意static_assert运行在编译阶段只能看到编译时的常数和类型看不到运行时的变量、指针、内存数据等是“静态”的所以不要简单地把assert的习惯搬过来用。
比如,下面的代码想检查空指针,由于变量只能在运行阶段出现,而在编译阶段不存在,所以静态断言无法处理。
```
char* p = nullptr;
static_assert(p == nullptr, &quot;some error.&quot;); // 错误用法
```
说到这儿你大概对static_assert的“编译计算”有点感性认识了吧。在用“静态断言”的时候你就要在脑子里时刻“绷紧一根弦”把自己代入编译器的角色**像编译器那样去思考**,看看断言的表达式是不是能够在编译阶段算出结果。
不过这句话说起来容易做起来难,计算数字还好说,在泛型编程的时候,怎么检查模板类型呢?比如说,断言是整数而不是浮点数、断言是指针而不是引用、断言类型可拷贝可移动……
这些检查条件表面上看好像是“不言自明”的但要把它们用C++语言给精确地表述出来可就没那么简单了。所以想要更好地发挥静态断言的威力还要配合标准库里的“type_traits”它提供了对应这些概念的各种编译期“函数”。
```
// 假设T是一个模板参数即template&lt;typename T&gt;
static_assert(
is_integral&lt;T&gt;::value, &quot;int&quot;);
static_assert(
is_pointer&lt;T&gt;::value, &quot;ptr&quot;);
static_assert(
is_default_constructible&lt;T&gt;::value, &quot;constructible&quot;);
```
你可能看到了“static_assert”里的表达式样子很奇怪既有模板符号“&lt;&gt;”,又有作用域符号“::”,与运行阶段的普通表达式大相径庭,初次见到这样的代码一定会吓一跳。
这也是没有办法的事情。因为C++本来不是为编译阶段编程所设计的。受语言的限制,编译阶段编程就只能“魔改”那些传统的语法要素了:把类当成函数,把模板参数当成函数参数,把“::”当成return返回值。说起来倒是和“函数式编程”很神似只是它运行在编译阶段。
由于“type_traits”已经初步涉及模板元编程的领域不太好一下子解释清楚所以在这里我就不再深入介绍了你可以课后再看看这方面的其他资料或者是留言提问。
## 小结
好了今天我和你聊了C++程序在编译阶段能够做哪些事情。
编译阶段的“主角”是编译器它依据C++语法规则处理源码。在这个过程中,我们可以用一些手段来帮助编译器,让它听从我们的指挥,优化代码或者做静态检查,更好地为运行阶段服务。
但要当心毕竟只有编译器才能真正了解C++程序,所以我们还是要充分信任它,不要过分干预它的工作,更不要有意与它作对。
我们来小结一下今天的要点。
1. “属性”相当于编译阶段的“标签”,用来标记变量、函数或者类,让编译器发出或者不发出警告,还能够手工指定代码的优化方式。
1. 官方属性很少常用的只有“deprecated”。我们也可以使用非官方的属性需要加上名字空间限定。
1. static_assert是“静态断言”在编译阶段计算常数和类型如果断言失败就会导致编译错误。它也是迈向模板元编程的第一步。
1. 和运行阶段的“动态断言”一样static_assert可以在编译阶段定义各种前置条件充分利用C++静态类型语言的优势,让编译器执行各种检查,避免把隐患带到运行阶段。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 预处理阶段可以自定义宏,但编译阶段不能自定义属性标签,这是为什么呢?
1. 你觉得,怎么用“静态断言”,才能更好地改善代码质量?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/25/39/25232468a72b55a41bf7af90583ae239.jpg" alt="">

View File

@@ -0,0 +1,253 @@
<audio id="audio" title="05 | 面向对象编程:怎样才能写出一个“好”的类?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/8f/c5070fcfb479a08e9578eb1668b1d88f.mp3"></audio>
你好我是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画个类体系的示意图来辅助检查。如果继承深度超过三层就说明有点“过度设计”了需要考虑用组合关系替代继承关系或者改用模板和泛型。
<img src="https://static001.geekbang.org/resource/image/14/d5/145780476bd0beb148e5e130c2336ed5.jpg" alt="">
在设计类接口的时候,我们也要让类尽量简单、“短小精悍”,**只负责单一的功能**。
如果很多功能混在了一起出现了“万能类”“意大利面条类”有时候也叫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&amp;) = delete; // 禁止拷贝构造
DemoClass&amp; operator=(const DemoClass&amp;) = delete; // 禁止拷贝赋值
};
```
因为C++有隐式构造和隐式转型的规则,如果你的类里有单参数的构造函数,或者是转型操作符函数,为了防止意外的类型转换,保证安全,就要使用“**explicit**”将这些函数标记为“显式”。
```
class DemoClass final
{
public:
explicit DemoClass(const string_type&amp; 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&amp; s) : // 字符串参数构造函数
DemoDelegating(stoi(s)) // 转换成整数,再委托给第一个构造函数
{}
};
```
第二个是“**成员变量初始化**”In-class member initializer
如果你的类有很多成员变量,那么在写构造函数的时候就比较麻烦,必须写出一长串的名字来逐个初始化,不仅不美观,更危险的是,容易“手抖”,遗漏成员,造成未初始化的隐患。
而在C++11里你可以在类里声明变量的同时给它赋值实现初始化这样**不但简单清晰,也消除了隐患。**
```
class DemoInit final // 有很多成员变量的类
{
private:
int a = 0; // 整数成员,赋值初始化
string s = &quot;hello&quot;; // 字符串成员,赋值初始化
vector&lt;int&gt; 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&lt;int&gt;; // 集合类型别名
using vector_type = std::vector&lt;std::string&gt;;// 容器类型别名
private:
string_type m_name = &quot;tom&quot;; // 使用类型别名声明变量
uint32_type m_age = 23; // 使用类型别名声明变量
set_type m_books; // 使用类型别名声明变量
private:
kafka_conf_type m_conf; // 使用类型别名声明变量
};
```
类型别名不仅能够让代码规范整齐而且因为引入了这个“语法层面的宏定义”将来在维护时还可以随意改换成其他的类型。比如把字符串改成string_viewC++17里的字符串只读视图把集合类型改成unordered_set只要变动别名定义就行了原代码不需要做任何改动。
## 小结
今天我们谈了“面向对象编程”,这节课的内容也比较多,我划一下重点。
1. “面向对象编程”是一种设计思想,要点是“抽象”和“封装”,“继承”“多态”是衍生出的特性,不完全符合现实世界。
1. 在C++里应当少用继承和虚函数,降低对象的成本,绕过那些难懂易错的陷阱。
1. 使用特殊标识符“final”可以禁止类被继承简化类的层次关系。
1. 类有六大基本函数,对于重要的构造/析构函数,可以使用“= default”来显式要求编译器使用默认实现。
1. “委托构造”和“成员变量初始化”特性可以让创建对象的工作更加轻松。
1. 使用using或typedef可以为类型起别名既能够简化代码还能够适应将来的变化。
所谓“仁者见仁智者见智”,今天我讲的也只能算是我自己的经验、体会。到底要怎么用,你还是要看自己的实际情况,千万不要完全照搬。
## 课下作业
这次的课下作业时间,我给你留两个思考题:
1. 你对“面向对象编程”有哪些认识,是否赞同这节课的观点?(希望你大胆地说出来,如果意见有分歧,那也很正常,我们一起讨论。)
1. 你觉得应用这节课讲到的准则和技巧能否写出一个“好”的类,还缺什么吗?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/67/b3/6739782414607164bdbe20fca7fd5fb3.jpg" alt="">