mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
mod
This commit is contained in:
145
极客时间专栏/罗剑锋的C++实战笔记/概论/01 | 重新认识C++:生命周期和编程范式.md
Normal file
145
极客时间专栏/罗剑锋的C++实战笔记/概论/01 | 重新认识C++:生命周期和编程范式.md
Normal 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="">
|
||||
232
极客时间专栏/罗剑锋的C++实战笔记/概论/02 | 编码阶段能做什么:秀出好的code style.md
Normal file
232
极客时间专栏/罗剑锋的C++实战笔记/概论/02 | 编码阶段能做什么:秀出好的code style.md
Normal 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("xxx")){
|
||||
LOGIT(WARNING,"value is incomplete.\n")
|
||||
return;
|
||||
}
|
||||
char suffix[16]="xxx";
|
||||
int data_len = 100;
|
||||
if(!value.empty()&&value.contains("tom")){
|
||||
const char* name=value.c_str();
|
||||
for(int i=0;i<MAX_LEN;i++){
|
||||
... // do something
|
||||
}
|
||||
int count=0;
|
||||
for(int i=0;i<strlen(name);i++){
|
||||
... // do something
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码真可谓是“高密度”,密密麻麻一大堆,看着“有如滔滔江水,连绵不绝”,读起来让人“窒息”,code style非常糟糕。
|
||||
|
||||
应用“留白的艺术”,代码就变成了下面的样子:
|
||||
|
||||
```
|
||||
if (!value.contains("xxx")) { // if后{前有空格
|
||||
LOGIT(WARNING, "value is incomplete.\n") // 逗号后有空格
|
||||
return; // 逻辑联系紧密就不用加空行
|
||||
}
|
||||
// 新增空行分隔段落
|
||||
char suffix[16] = "xxx"; // 等号两边有空格
|
||||
int data_len = 100; // 逻辑联系紧密就不用加空行
|
||||
// 新增空行分隔段落
|
||||
if (!value.empty() && value.contains("tom")) { // &&两边有空格
|
||||
const char* name = value.c_str(); // 等号两边有空格
|
||||
// 新增空行分隔段落
|
||||
for(int i = 0; i < MAX_LEN; i++){ // =;<处有空格
|
||||
... // do something
|
||||
}
|
||||
// 新增空行分隔段落
|
||||
int count = 0; // 等号两边有空格
|
||||
// 新增空行分隔段落
|
||||
for(int i = 0; i < strlen(name); i++){ // =;<处有空格
|
||||
... // 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& str); // 函数,全小写
|
||||
private:
|
||||
string m_path; // 成员变量,m_前缀
|
||||
int m_level; // 成员变量,m_前缀
|
||||
};
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
命名另一个相关的问题是“名字的长度”,有人喜欢写得长,有人喜欢写得短,我觉得都可以,只要易读易写就行。
|
||||
|
||||
不过一个被普遍认可的原则是:**变量/函数的名字长度与它的作用域成正比**,也就是说,局部变量/函数名可以短一点,而全局变量/函数名应该长一点。
|
||||
|
||||
想一下,如果你辛辛苦苦起了一个包含四五个单词的长名字,却只能在短短十几行的循环体里使用,岂不是太浪费了?
|
||||
|
||||
## 用好注释
|
||||
|
||||
写出了有好名字的变量、函数和类还不够,要让其他人能“一眼”看懂代码,还需要加上注释。
|
||||
|
||||
“注释”在任何编程语言里都是一项非常重要的功能,甚至在编程语言之外,比如配置文件(ini、yml)、标记语言(html、xml),都有注释。一个突出的反例就是JSON,没有注释功能让许多人都很不适应。
|
||||
|
||||
注释表面上的功能很简单,就是给代码配上额外的文字,起到注解、补充说明的作用。但就像是写文章一样,写些什么、写多写少、写成什么样子,都是大有讲究的。
|
||||
|
||||
你可能也有不少写注释的经验了,一般来说,注释可以用来阐述目的、用途、工作原理、注意事项等代码本身无法“自说明”的那些东西。
|
||||
|
||||
但要小心,注释必须要正确、清晰、有效,尽量言简意赅、点到为止,不要画蛇添足,更不能写出含糊、错误的注释。
|
||||
|
||||
比如说,有这么一个模板函数get_value:
|
||||
|
||||
```
|
||||
template<typename T>
|
||||
int get_value(const T& 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<typename T>
|
||||
int get_value(const T& 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="">
|
||||
297
极客时间专栏/罗剑锋的C++实战笔记/概论/03 | 预处理阶段能做什么:宏定义和条件编译.md
Normal file
297
极客时间专栏/罗剑锋的C++实战笔记/概论/03 | 预处理阶段能做什么:宏定义和条件编译.md
Normal 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 "a.out" // 完全合法的预处理包含指令,你可以试试
|
||||
|
||||
```
|
||||
|
||||
可以看到,“#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 "calc_values.inc" // 非常大的一个数组,细节被隐藏
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 宏定义(#define/#undef)
|
||||
|
||||
接下来要说的是预处理编程里最重要、最核心的指令“#define”,它用来定义一个源码级别的“**文本替换**”,也就是我们常说的“**宏定义**”。
|
||||
|
||||
“#define”可谓“无所不能”,在预处理阶段可以无视C++语法限制,替换任何文字,定义常量/变量,实现函数功能,为类型起别名(typedef),减少重复代码……
|
||||
|
||||
不过,也正是因为它太灵活,如果过于随意地去使用宏来写程序,就有可能把正常的C++代码搞得“千疮百孔”,替换来替换去,都不知道真正有效的代码是什么样子了。
|
||||
|
||||
所以,**使用宏的时候一定要谨慎,时刻记着以简化代码、清晰易懂为目标,不要“滥用”,避免导致源码混乱不堪,降低可读性。**
|
||||
|
||||
下面,我就说几个注意事项,帮你用好宏定义。
|
||||
|
||||
首先,因为宏的展开、替换发生在预处理阶段,不涉及函数调用、参数传递、指针寻址,没有任何运行期的效率损失,所以对于一些调用频繁的小代码片段来说,用宏来封装的效果比inline关键字要更好,因为它真的是源码级别的无条件内联。
|
||||
|
||||
下面有几个示例,摘自Nginx,你可以作为参考:
|
||||
|
||||
```
|
||||
#define ngx_tolower(c) ((c >= 'A' && c <= 'Z') ? (c | 0x20) : c)
|
||||
#define ngx_toupper(c) ((c >= 'a' && c <= 'z') ? (c & ~0x20) : c)
|
||||
|
||||
#define ngx_memzero(buf, n) (void) memset(buf, 0, n)
|
||||
|
||||
```
|
||||
|
||||
其次,你要知道,**宏是没有作用域概念的,永远是全局生效**。所以,对于一些用来简化代码、起临时作用的宏,最好是用完后尽快用“#undef”取消定义,避免冲突的风险。像下面这样:
|
||||
|
||||
```
|
||||
#define CUBE(a) (a) * (a) * (a) // 定义一个简单的求立方的宏
|
||||
|
||||
cout << CUBE(10) << endl; // 使用宏简化代码
|
||||
cout << CUBE(15) << endl; // 使用宏简化代码
|
||||
|
||||
#undef CUBE // 使用完毕后立即取消定义
|
||||
|
||||
```
|
||||
|
||||
另一种做法是**宏定义前先检查**,如果之前有定义就先undef,然后再重新定义:
|
||||
|
||||
```
|
||||
#ifdef AUTH_PWD // 检查是否已经有宏定义
|
||||
# undef AUTH_PWD // 取消宏定义
|
||||
#endif // 宏定义检查结束
|
||||
#define AUTH_PWD "xxx" // 重新宏定义
|
||||
|
||||
```
|
||||
|
||||
再次,你可以适当使用宏来定义代码中的常量,消除“魔术数字”“魔术字符串”(magic number)。
|
||||
|
||||
虽然不少人认为,定义常量更应该使用enum或者const,但我觉得宏定义毕竟用法简单,也是源码级的真正常量,而且还是从C继承下来的传统,用在头文件里还是有些优势的。
|
||||
|
||||
这种用法非常普遍,你可能也经常用,我就简单举两个例子吧:
|
||||
|
||||
```
|
||||
#define MAX_BUF_LEN 65535
|
||||
#define VERSION "1.0.18"
|
||||
|
||||
```
|
||||
|
||||
不过你要注意,关键是要“适当”,自己把握好分寸,不要把宏弄得“满天飞”。
|
||||
|
||||
除了上面说的三个,如果你开动脑筋,用好“文本替换”的功能,也能发掘出许多新颖的用法。我有一个比较实际的例子,用宏来代替直接定义名字空间(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 "C" { // 函数按照C的方式去处理
|
||||
#endif
|
||||
void a_c_function(int a);
|
||||
#ifdef __cplusplus // 检查是否是C++编译
|
||||
} // extern "C" 结束
|
||||
#endif
|
||||
|
||||
#if __cplusplus >= 201402 // 检查C++标准的版本号
|
||||
cout << "c++14 or later" << endl; // 201402就是C++14
|
||||
#elif __cplusplus >= 201103 // 检查C++标准的版本号
|
||||
cout << "c++11 or before" << endl; // 201103是C++11
|
||||
#else // __cplusplus < 201103 // 199711是C++98
|
||||
# error "c++ is too old" // 太低则预处理报错
|
||||
#endif // __cplusplus >= 201402 // 预处理语句结束
|
||||
|
||||
```
|
||||
|
||||
除了“__cplusplus”,C++里还有很多其他预定义的宏,像源文件信息的“**FILE**”“ **LINE**”“ **DATE**”,以及一些语言特性测试宏,比如“__cpp_decltype” “__cpp_decltype_auto” “__cpp_lib_make_unique”等。
|
||||
|
||||
不过,与优化更密切相关的底层系统信息在C++语言标准里没有定义,但编译器通常都会提供,比如GCC可以使用一条简单的命令查看:
|
||||
|
||||
```
|
||||
g++ -E -dM - < /dev/null
|
||||
|
||||
#define __GNUC__ 5
|
||||
#define __unix__ 1
|
||||
#define __x86_64__ 1
|
||||
#define __UINT64_MAX__ 0xffffffffffffffffUL
|
||||
...
|
||||
|
||||
|
||||
```
|
||||
|
||||
基于它们,你就可以更精细地根据具体的语言、编译器、系统特性来改变源码,有,就用新特性;没有,就采用变通实现:
|
||||
|
||||
```
|
||||
#if defined(__cpp_decltype_auto) //检查是否支持decltype(auto)
|
||||
cout << "decltype(auto) enable" << endl;
|
||||
#else
|
||||
cout << "decltype(auto) disable" << endl;
|
||||
#endif //__cpp_decltype_auto
|
||||
|
||||
#if __GNUC__ <= 4
|
||||
cout << "gcc is too old" << endl;
|
||||
#else // __GNUC__ > 4
|
||||
cout << "gcc is good enough" << endl;
|
||||
#endif // __GNUC__ <= 4
|
||||
|
||||
#if defined(__SSE4_2__) && defined(__x86_64)
|
||||
cout << "we can do more optimization" << endl;
|
||||
#endif // defined(__SSE4_2__) && defined(__x86_64)
|
||||
|
||||
|
||||
```
|
||||
|
||||
除了这些内置宏,你也可以用其他手段自己定义更多的宏来实现条件编译。比如,Nginx就使用Shell脚本检测外部环境,生成一个包含若干宏的源码配置文件,再条件编译包含不同的头文件,实现操作系统定制化:
|
||||
|
||||
```
|
||||
#if (NGX_FREEBSD)
|
||||
# include <ngx_freebsd.h>
|
||||
|
||||
#elif (NGX_LINUX)
|
||||
# include <ngx_linux.h>
|
||||
|
||||
#elif (NGX_SOLARIS)
|
||||
# include <ngx_solaris.h>
|
||||
|
||||
#elif (NGX_DARWIN)
|
||||
# include <ngx_darwin.h>
|
||||
#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="">
|
||||
231
极客时间专栏/罗剑锋的C++实战笔记/概论/04 | 编译阶段能做什么:属性和静态断言.md
Normal file
231
极客时间专栏/罗剑锋的C++实战笔记/概论/04 | 编译阶段能做什么:属性和静态断言.md
Normal 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<int N>
|
||||
struct fib // 递归计算斐波那契数列
|
||||
{
|
||||
static const int value =
|
||||
fib<N - 1>::value + fib<N - 2>::value;
|
||||
};
|
||||
|
||||
template<>
|
||||
struct fib<0> // 模板特化计算fib<0>
|
||||
{
|
||||
static const int value = 1;
|
||||
};
|
||||
|
||||
template<>
|
||||
struct fib<1> // 模板特化计算fib<1>
|
||||
{
|
||||
static const int value = 1;
|
||||
};
|
||||
|
||||
// 调用后输出2,3,5,8
|
||||
cout << fib<2>::value << endl;
|
||||
cout << fib<3>::value << endl;
|
||||
cout << fib<4>::value << endl;
|
||||
cout << fib<5>::value << 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("XXX");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,在C++11里只定义了两个属性:“noreturn”和“carries_dependency”,它们基本上没什么大用处。
|
||||
|
||||
C++14的情况略微好了点,增加了一个比较实用的属性“deprecated”,用来标记不推荐使用的变量、函数或者类,也就是被“废弃”。
|
||||
|
||||
比如说,你原来写了一个函数old_func(),后来觉得不够好,就另外重写了一个完全不同的新函数。但是,那个老函数已经发布出去被不少人用了,立即删除不太可能,该怎么办呢?
|
||||
|
||||
这个时候,你就可以让“属性”发挥威力了。你可以给函数加上一个“deprecated”的编译期标签,再加上一些说明文字:
|
||||
|
||||
```
|
||||
[[deprecated("deadline:2020-12-31")]] // 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 > 0 && "i must be greater than zero");
|
||||
assert(p != nullptr);
|
||||
assert(!str.empty());
|
||||
|
||||
```
|
||||
|
||||
当程序(也就是CPU)运行到assert语句时,就会计算表达式的值,如果是false,就会输出错误消息,然后调用abort()终止程序的执行。
|
||||
|
||||
注意,assert虽然是一个宏,但在预处理阶段不生效,而是在运行阶段才起作用,所以又叫“**动态断言**”。
|
||||
|
||||
有了“动态断言”,那么相应的也就有“静态断言”,名字也很像,叫“**static_assert**”,不过它是一个专门的关键字,而不是宏。因为它只在编译时生效,运行阶段看不见,所以是“静态”的。
|
||||
|
||||
“静态断言”有什么用呢?
|
||||
|
||||
类比一下assert,你就可以理解了。它是编译阶段里检测各种条件的“断言”,编译器看到static_assert也会计算表达式的值,如果值是false,就会报错,导致编译失败。
|
||||
|
||||
比如说,这节课刚开始时的斐波拉契数列计算函数,可以用静态断言来保证模板参数必须大于等于零:
|
||||
|
||||
```
|
||||
template<int N>
|
||||
struct fib
|
||||
{
|
||||
static_assert(N >= 0, "N >= 0");
|
||||
|
||||
static const int value =
|
||||
fib<N - 1>::value + fib<N - 2>::value;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
再比如说,要想保证我们的程序只在64位系统上运行,可以用静态断言在编译阶段检查long的大小,必须是8个字节(当然,你也可以换个思路用预处理编程来实现)。
|
||||
|
||||
```
|
||||
static_assert(
|
||||
sizeof(long) >= 8, "must run on x64");
|
||||
|
||||
static_assert(
|
||||
sizeof(int) == 4, "int must be 32bit");
|
||||
|
||||
```
|
||||
|
||||
这里你一定要注意,static_assert运行在编译阶段,只能看到编译时的常数和类型,看不到运行时的变量、指针、内存数据等,是“静态”的,所以不要简单地把assert的习惯搬过来用。
|
||||
|
||||
比如,下面的代码想检查空指针,由于变量只能在运行阶段出现,而在编译阶段不存在,所以静态断言无法处理。
|
||||
|
||||
```
|
||||
char* p = nullptr;
|
||||
static_assert(p == nullptr, "some error."); // 错误用法
|
||||
|
||||
```
|
||||
|
||||
说到这儿,你大概对static_assert的“编译计算”有点感性认识了吧。在用“静态断言”的时候,你就要在脑子里时刻“绷紧一根弦”,把自己代入编译器的角色,**像编译器那样去思考**,看看断言的表达式是不是能够在编译阶段算出结果。
|
||||
|
||||
不过这句话说起来容易做起来难,计算数字还好说,在泛型编程的时候,怎么检查模板类型呢?比如说,断言是整数而不是浮点数、断言是指针而不是引用、断言类型可拷贝可移动……
|
||||
|
||||
这些检查条件表面上看好像是“不言自明”的,但要把它们用C++语言给精确地表述出来,可就没那么简单了。所以,想要更好地发挥静态断言的威力,还要配合标准库里的“type_traits”,它提供了对应这些概念的各种编译期“函数”。
|
||||
|
||||
```
|
||||
// 假设T是一个模板参数,即template<typename T>
|
||||
|
||||
static_assert(
|
||||
is_integral<T>::value, "int");
|
||||
|
||||
static_assert(
|
||||
is_pointer<T>::value, "ptr");
|
||||
|
||||
static_assert(
|
||||
is_default_constructible<T>::value, "constructible");
|
||||
|
||||
|
||||
```
|
||||
|
||||
你可能看到了,“static_assert”里的表达式样子很奇怪,既有模板符号“<>”,又有作用域符号“::”,与运行阶段的普通表达式大相径庭,初次见到这样的代码一定会吓一跳。
|
||||
|
||||
这也是没有办法的事情。因为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="">
|
||||
253
极客时间专栏/罗剑锋的C++实战笔记/概论/05 | 面向对象编程:怎样才能写出一个“好”的类?.md
Normal file
253
极客时间专栏/罗剑锋的C++实战笔记/概论/05 | 面向对象编程:怎样才能写出一个“好”的类?.md
Normal 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&) = 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_view(C++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="">
|
||||
Reference in New Issue
Block a user