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;
};

// 调用后输出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)

“预处理编程”这一讲提到的#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文档)。

  • deprecated:与C++14相同,但可以用在C++11里。
  • unused:用于变量、类型、函数等,表示虽然暂时不用,但最好保留着,因为将来可能会用。
  • constructor:函数会在main()函数之前执行,效果有点像是全局对象的构造函数。
  • destructor:函数会在main()函数结束之后执行,有点像是全局对象的析构函数。
  • always_inline:要求编译器强制内联函数,作用比inline关键字更强。
  • hot:标记“热点”函数,要求编译器更积极地优化。

这几个属性的含义还是挺好理解的吧,我拿“unused”来举个例子。

在没有这个属性的时候,如果有暂时用不到的变量,我们只能用“(void) var;”的方式假装用一下,来“骗”过编译器,属于“不得已而为之”的做法。

那么现在,我们就可以用“unused”属性来清楚地告诉编译器:这个变量我暂时不用,请不要过度紧张,不要发出警告来烦我:

[[gnu::unused]]      // 声明下面的变量暂不使用,不是错误
int nouse;  

GitHub仓库里的示例代码里还展示了其他属性的用法,你可以在课下参考。

静态断言(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. 你觉得,怎么用“静态断言”,才能更好地改善代码质量?

欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。我们下节课见。

精选留言

  • Luca

    2020-05-14 10:44:46

    1. 因为属性标签都在编译器里内置,自定义的属性标签编译器无法识别。
    2. 静态断言可以作为编译期的一种约定,配合错误提示能够更快发现编译期的错误。
    作者回复

    very nice。

    2020-05-14 12:57:50

  • yelin

    2020-05-14 12:36:30

    斐布那契还可以这么玩,期待老师后面对于模版类的课程,我可能从来没都没学会过
    作者回复

    模板元编程比较复杂,属于屠龙之术,这次我先不讲,如果感兴趣的同学多可以以后单独开一个课程。

    2020-05-14 12:57:33

  • 逸清

    2020-05-14 07:32:50

    老师,自己C++基础知识还算了解,但代码写的太少,拿到一个需求无从下手,老师有没有比较好的方法或者适合练手的项目推荐?
    作者回复

    建议先学习一下标准库,了解里面的那些工具,现在开发很少有白手起家的了,用好工具,知道它们能解决哪些问题,写应用也就比较容易了。

    比如string/regex处理字符串、map/set集合、线程库等等,跟着课程逐步学吧。

    2020-05-14 08:53:48

  • eletarior

    2020-05-14 06:24:39

    看到老师的斐波那契数列实现,我还是挺惊讶的,代码虽都看得懂,但是从没想过这么写,我有两个问题想请教下: 1.按本节的主题,编译阶段能做什么,所以说后面的那几个斐波那契数列在编译器就有结果了吗?如果是这样的话,肯定是需要cpu压栈计算的,这和真实的运行期有哪些不同呢?2.模板编程在哪些场景下使用比较好?模板编程 和 编译 阶段 似乎关联更大些
    作者回复


    1.是的,这些代码都是模板类,自然会由编译器去解析处理,最后出来的也是编译期数值,也就是静态常量,省去了运行期的技术成本,运行期直接用就行。

    2.模板元编程和预处理编程有点像,由编译器来改变源码的形态,但它的规则更复杂,难以理解,你首先要了解泛型编程,之后才能尝试模板元编程。
    对于80%的C++程序员来说,我不建议尝试模板元编程,可以参考第1讲。

    2020-05-14 08:56:41

  • jxon-H

    2020-05-20 23:23:58

    第三次学习这节课的内容,感觉自己总算明白了罗老师的苦心。
    与一般的C++课不同,罗老师完全不讲语法要素这些百度一大把,而是从工作的原理和本质去剖析C++。
    我记得开课的第一讲,罗老师就这么说过,当时没啥体会,现在越发觉得这样的编排确实很高级。
    虽然对于我这种没怎么用过C++的人来说,接受所有信息有点吃力,但反而使我开阔视野,学习C++的时候,不会被限制语法语义的规则上,你还可以和预处理器、编译器打交道,让你的代码更好的让人和机器读懂。
    什么场景应该和预处理器沟通一下,什么时候和编译器沟通一下,这些都是高级的编程技巧。这些沟通也许是非必要的,但是掌握这些沟通技巧,在编程的时候将如鱼得水。
    对C++的钻研还不够深,功力不够,没发对老师的思考题发表有营养的见解,就这么表达一下自己的感受吧。
    作者回复

    有点过誉了,受之有愧。

    因为C++比较复杂,所以我划出了四个生命周期,方便特性的归类和理解,不然混在一起很容易把思路弄乱。

    C++需要在实践中学,要花的时间和精力还是挺多的,不过乐趣也自在其中。

    2020-05-21 08:52:55

  • EncodedStar

    2020-05-19 11:42:50

    老师可以在每讲开始讲讲上一讲提到的问题吗?很多疑惑~
    用“静态断言”,是不是在代码严格要求是32位系统或者64位系统的时候也比较有用呢?32位系统和64位系统本身有的类型所占字节数不同。
    作者回复


    1.课程都是预先录好的,所以不能及时回答,有问题写在留言里,我可以回复,还是希望自己思考得到答案。

    2.静态断言的用处很多,判断32/64只是个最简单的例子,只要能够在编译阶段计算出的结果就可以断言,不过这就需要对编译阶段有比较多的认识了。
    不用着急,慢慢学C++,了解了泛型后再看静态断言可能就会好懂一些。

    2020-05-19 12:46:32

  • tt

    2020-05-15 12:46:59

    受语言的限制,编译阶段编程就只能“魔改”那些传统的语法要素了:把类当成函数,把模板参数当成函数参数,把“::”当成 return 返回值。

    这个说法真形象,那些乱七八糟的语法一下就不面目可憎了。
    作者回复

    嗯,这也是我反复思考才得出的经验。

    2020-05-15 18:39:39

  • EncodedStar

    2020-05-18 22:58:05

    预处理可以自定义是直接将定义好的内容写到源码里,而标签不能自定义是因为编译器需要识别标签名
    作者回复

    good

    2020-05-19 07:34:58

  • 牙医

    2020-05-14 08:25:21

    模版元编程,劝退多少c++码农啊
    作者回复

    模板元编程比较复杂,不会也没关系,用面向对象+泛型也可以写得很开心。

    2020-05-14 08:51:45

  • Carlos

    2020-05-14 08:10:38

    不得不说这节课让我回忆起了自己刚学会 vim macros 的感觉: 原来是我的想象力限制了 vim... 现在我想说: 原来是我的想象力限制了 c++...🧠

    今天两个问题我都不是很懂, 希望老师指正.

    1. 预处理阶段就是简单的文字替换, 编译阶段的属性标签应该需要编译器对这个标签进行 "一系列" 的配合, 过于复杂, 自己写容易翻车.
    2. 要写简洁易懂的备注, 告诉别人为什么我要在这里终止编译对你进行提醒.
    作者回复


    1.回答沾点边。实际上是因为属性标签必须要由编译器解释,而自定义标签编译器是不认识的,所以只能等编译器开发者去加,而不能是自己加。

    2.说的比较好。
    静态断言是一种对编译环境的“前提”“假设”,要求在编译阶段必须如何如何,可以结合第1讲的生命周期,考虑一下应该如何发挥它的作用。

    2020-05-14 09:06:29

  • Tedeer

    2020-05-14 23:05:42

    老师,因为在做Android时,会做一些java层的反编译;很少做so库的反编译,我很好奇so反编译生成的代码还会有这些属性标签和断言吗?
    作者回复

    Android和Java不太熟,不是很了解。但我觉得,属性和断言都是源码级别的,如果反编译这些信息应该是看不见的。

    2020-05-15 05:52:49

  • 平风造雨

    2025-02-18 17:03:48

    template在编译期的这个过程,如何进行调试?假如说代码里关于template的实现比较复杂,想调试下这个过程,可行吗?
    作者回复

    比较难办,不过现在编译器都在改进,gcc比较难,llvm好像容易点。

    2025-02-20 10:54:20

  • 2024-02-16 21:37:01

    有本书《CppTemplateTutorial》详细介绍了c++的模板和元编程,bing就可以搜到
    作者回复

    这本书很老了,也是我的模板元编程入门书。

    2024-02-25 16:02:05

  • 布衣

    2022-10-30 17:27:41

    👌👌👌👌👌👌
    作者回复

    keep running

    2022-10-31 11:28:20

  • Geek_3a0eeb

    2022-10-09 12:39:04

    请问工程代码中会常用到断言吗?断言在win环境,vc的release是不能用的吧?在linux好像是debug和release都可以.
    作者回复

    assert可以当做是一种代码级别的注释,在调试开发的时候还是有用的,一般都是在debug版本生效。

    2022-10-10 06:37:23

  • 于小咸

    2021-09-24 07:45:45

    请问这样写斐波那契数列,会不会造成代码膨胀的问题呀?我理解对每一个数都会生成一个对应的函数
    作者回复

    这里的编译期元函数实际上是结构体struct,并不是真正的函数,而且运算都是在编译期完成的,最后留在二进制文件里的都是最终的计算结果。

    至于代码膨胀,我觉得现在不需要去特别关心,要注意80-20原则,可能我们在这里省了一点点,根本弥补不了其他地方的浪费。

    2021-09-24 15:51:28

  • Geek_358817

    2021-08-20 14:03:51

    assert做文档形式的 代码
    作者回复

    great

    2021-08-21 11:53:20

  • 承君此诺

    2020-09-28 11:05:48

    如何在编译期输出warning提示不是error。
    如我用宏开关屏蔽了多种方案细节,某方案由于未足够测试等原因,不建议但允许使用。这时用静态断言就不合适了。
    作者回复

    目前的静态断言只能是error,暂时没有编译期的warning。

    顺便再说一句,gcc好像可以用-W选项来控制如何输出告警,具体可以去查一下它的手册。

    2020-09-28 15:54:11

  • 宇天飞

    2020-08-31 23:06:39

    预处理阶段可以自定义宏,但编译阶段不能自定义属性标签,这是为什么呢?
    编译阶段是编译为二进制代码,跟编译器打交道,如果自定义属性编译器也无法理解
    预处理器是进行文本替换的

    你觉得,怎么用“静态断言”,才能更好地改善代码质量?
    比如对于平台的断言
    作者回复

    说的挺好,编译阶段的属性和静态断言还需要多在实际中使用才能理解它的好处。

    2020-09-01 08:56:44

  • Eason

    2020-06-01 18:19:53

    有一个问题:
    比如,在 libc-headers/fcntl.h 定义了 open,那么看到open函数是如何实现的呢?
    ```cpp
    /* Open FILE and return a new file descriptor for it, or -1 on error.
    OFLAG determines the type of access used. If O_CREAT or O_TMPFILE is set
    in OFLAG, the third argument is taken as a `mode_t', the mode of the
    created file.

    This function is a cancellation point and therefore not marked with
    __THROW. */
    #ifndef __USE_FILE_OFFSET64
    extern int open (const char *__file, int __oflag, ...) __nonnull ((1));
    #else
    # ifdef __REDIRECT
    extern int __REDIRECT (open, (const char *__file, int __oflag, ...), open64)
    __nonnull ((1));
    # else
    # define open open64
    # endif
    #endif
    #ifdef __USE_LARGEFILE64
    extern int open64 (const char *__file, int __oflag, ...) __nonnull ((1));
    #endif
    ```

    作者回复

    混合了宏定义和条件编译,实在是难以看懂。

    可以直接gdb,看open到底是什么。

    2020-06-02 09:05:35