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.

232 lines
13 KiB
Markdown

2 years ago
# 04 | 编译阶段能做什么:属性和静态断言
你好我是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;
};
// 调用后输出2358
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. “属性”相当于编译阶段的“标签”,用来标记变量、函数或者类,让编译器发出或者不发出警告,还能够手工指定代码的优化方式。
2. 官方属性很少常用的只有“deprecated”。我们也可以使用非官方的属性需要加上名字空间限定。
3. static\_assert是“静态断言”在编译阶段计算常数和类型如果断言失败就会导致编译错误。它也是迈向模板元编程的第一步。
4. 和运行阶段的“动态断言”一样static\_assert可以在编译阶段定义各种前置条件充分利用C++静态类型语言的优势,让编译器执行各种检查,避免把隐患带到运行阶段。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 预处理阶段可以自定义宏,但编译阶段不能自定义属性标签,这是为什么呢?
2. 你觉得,怎么用“静态断言”,才能更好地改善代码质量?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。我们下节课见。
![](https://static001.geekbang.org/resource/image/25/39/25232468a72b55a41bf7af90583ae239.jpg)