Undefining the C++ Pre-processor(取消C++中的预处理器)

背景

There are only two kinds of languages: the ones people complain about and the ones nobody uses (世界上只有两类编程语言:一类是被人们抱怨的,一类是没人使用的)
— Bjarne Stroustrup

我喜欢上面这句话,它能说明JavaScript 和 Haskell被人抱怨的原因。通过上面的分类方法,我们会发现预处理也是一类被人们经常使用的语言(因为人们经常抱怨它)。人们从来没有考虑将它从C或者C++中分离出来,如果这样做的话,我敢说它将会是在TIOBE榜上排名第一的语言。预处理器非常有用,并且无处不在。事实上,如果不使用预处理器,编写一些正式的跨平台的C++应用将会非常困难。

甲:预处理器很糟糕
乙:我知道,对的,预处理器是最糟糕的。嘿,你能合并我的提交吗?我添加了一些有用的宏。

我想很多人对上面的谈话都会很熟悉,如果不认真对待,20年后我们可能还会听到这样的对话。不幸的是,这种长久存在也许是预处理器唯一可认为的优点(redeeming quality)。换句话说,我的问题很简单,既不是什么理论的,哲学的,也不是什么理想的不着边的问题,是非常实际的问题。

我不在意有人用预处理器不做任何检查就进行标识符,关键字替换(有人认为这是非法的),也不在意预处理器尝试做到图灵完备而实际上却不能合适的处理逗号的问题。我甚至不在意includes和includes guards,我对#pragma也没意见。多数时候,你必须务实(pragmatic)冷静的对待现在的一切。

然而…

下面我将列举一种预处理器的使用场景,你可能会认为这种做法是我瞎编乱造的,但是,请耐心些。现在,假设你正在重构某个跨平台的应用的代码,你决定做一些不寻常的事情,比如,重命名某个函数。

那是不可能的。 从来没有,可能永远不会。

看下面的代码

#ifdef WINDOWS
  foo(43);
#else
  foo(42);
#endif

本质上说,编译器或者其它必须用到的成熟的编译器前端工具都不会分析全部代码。那些未被启用的部分从没有被编译,解析,被分词或者其它的任何形式的分析。

那些未被启用的部分可能不是有效的C++代码,下面的代码是可以编译通过的:

#if 0
#!/bin/bash
    g++ "$0" && ./a.out && rm ./a.out
    exit $?;
#else
#include <iostream>
int main() {
    std::cout << "Hello ?\n";
}
#endif

因此,如果编译器将未启用的部分考虑进来,它有可能都不能生产一个合法的抽象语法树(AST),更糟糕的是,预处理,就像它的名字所说的那样,是作为一个独立的状态进行的,预处理指令可能会插入任何两个C++的标记中间(表达式或者语句),像下面代码所示:

#if 0
  void
#else
  bool
#endif

#if 0
  &
#endif
#if 0
  bar(int
#else
  baz(long,
#endif
#if 0
  , std::vector<
#   if 0
      double
#   else
      int
#   endif
    >)
#else
  double)
#endif
;

其它让人同样关注的问题是编译器不可能知道哪些#ifdef 和#defines的组合可以形成一个有效的程序。

比如,Qt 提供了一系列的宏定义用来在编译期启用或者禁用某个特性。假如你不需要某个日历控件,你可以通过定义#QT_NO_CALENDAR_WIDGET,这可以生成一个相对小些的二进制文件。这不会起作用,事实上我怀疑这从来就没有起作用过。假设,在某些时候Qt有大约100个类似的编译期配置选项,考虑到程序可能采用的的配置选项的组合数,它相对于配置选项的个数以指数型的方式爆炸式的增长。如果有100个编译选项,程序可能采用的的配置选项的组合为 2 100 2^{100} 2100,也就是说可能会有 2 100 2^{100} 2100个程序需要测试,即使采用云端自动化测试,也是非常困难的。

所有未经测试的代码是坏代码。

你大概应该知道这个著名的断言。那么那些没有编译好的代码呢?

我认为将一些平台相关的代码放在一些平台相关的文件中会产生类似的问题。总的来说,编译器看到的代码应该是一个单独的自包含的真实的源码,而不是支离破碎的,或在最好的视图下也是不完整的。(Basically the code that the compiler see should be a single self contained source of truth, but instead the code is fragmented and the vision that you have of it is, as best, incomplete.)

既然认为预处理是有害的,那么我们该怎么对待它呢?

顺便说一下,不仅仅是预处理器有这个缺点,所有的现代的处理器都有这个问题。也许我们应该避免做任何类似的上面的预处理。

正确对待预处理指令

好吧,我们今天只谈谈该如何对待预处理指令:

  1. 优先使用常量而不是 #define

    这一条非常简单,然而我还是看到很多用宏定义的常量。总是使用static const 或者constexpr,而不是用define。如果你的程序的构建过程涉及到设置一系列的变量,比如版本号或者git的hash值,最好考虑生成一个源文件而不是用define来定义这些构建参数。

  2. 函数通常优于宏

    	#ifndef max
        #define max(a,b) ((a)>(b)?(a):(b))
        #endif
        #ifndef min
        #define min(a,b) ((a)<(b)?(a):(b))
        #endif
    

    上面的代码片段来自Win32的API,即使对于那些非常简单的只有一行代码的功能,你最好也用函数。
    如果你需要对函数的参数进行惰性运算( lazy evaluation),用lambda。下面是一个用宏解决问题的的反例,但是宏只是最初考虑的方案之一。
    http://foonathan.net/blog/2017/06/27/lazy-evaluation.html

  3. 抽出让人关切的跨平台的部分(Abstract away the portability concerns.)
    妥善的将平台相关的部分放到不同的文件、类或者函数,将会减少那些#indef出现在你的代码中。但是这并没有解决我上面提出的问题,当你在Linux平台工作时,你可能不太想重命名一个在windows上的符号或改变一个平台相关的符号。

  4. 限制构建程序的可选配置项的数目

    依赖真的是可选的吗?

    如果你有可选的依赖项,这些依赖项包含软件中的某些功能,最好使用插件系统或者将你的工程分成几个不同的,相互独立的构建组件和程序,而不是在缺少某些依赖时使用#ifdef来禁用这些代码路径。确保测试覆盖了包含或不包含这些依赖的所有程序。为了避免这些麻烦,可以考虑从不将依赖设置为可选的。

    某些代码真的只运行在release版本吗?

    避免Debug/Release版本有太多的代码路径。记住,未经编译测试的代码是坏代码。

    某个特性真的应该禁用吗?

    比起依赖,特性或功能(feature)在使用预处理方面应该更严格一些,特性从不应该通过预编译选项设置为可选的,应该通过运行时的标志或者插件系统来实现。

  5. 优先使用 pragma once 而不是 include guard

    目前,很少有编译器不支持#pragma once,使用#pragma once更容易少犯错误,更简单,程序编译起来也更快,和include guard告别吧!

  6. 尽量多写些代码,而不是宏

    这一条应视情况而变,多数情况下不值得因为有少数的几个C++标志重复了就将它们用宏来替换。在C++语言规则内写代码,不要试图过于聪明,容忍一点点重复,这样的代码可能是更可读的,更易于维护的,你的IDE会感谢你这样做:)

  7. 及时取消宏
    宏用过后尽可能快的用 #undef来取消。头文件中的每个宏都应该有文档说明。
    宏是没有范围的,用你的工程名大写作为宏名的前缀。
    如果你在使用一个第三方框架,比如Qt,它既有较长的宏名也有较短的( signal and QT_SIGNAL ),应该尽量使用较长的,特别是当它们可能作为你的API的一部分向外暴露时。不要提供短的宏名。宏名应该能够不和其它代码冲突,比如boost::signal or std::min等。

  8. 不要将ifdef块放在C++语句中间

    foo( 42,
    #if 0
      "42",
    #endif
     42.0
    );
    

    上面的代码有几个问题:难阅读,难维护,clang-format等工具也不好处理它,另外,有时它也可能是坏代码。
    可以像下面这样写成两个不同的语句:

    #if 0
    foo(42, "42", 42.0);
    #else
    foo(42, 42.0);
    #endif
    

    在某些情况下这样做可能比较困难,这也表明在这样的情况下你应该将你的代码切分为更多的函数或者最好抽出这些打算条件编译的代码为函数。

  9. 用 static_assert 而不是 #error
    当编译失败时用static_assert(false).

未来的预处理器

上面讲的那些建议是对任意版本的C++都适用的,如果你使用的是一个较新的编译器,有更多的方法来帮助你减少日常宏的使用。

  1. 优先使用modules而不是includes
    使用modules可以节省编译时间,也可以为宏划定范围,这样各个modules内部的宏就不会互相影响。在2018年初,除了GCC, MSVC和clang实现了module或者正在实现,在生产环境中还没有可以用的编译器支持modules。尽管大家都没有相关的经验,我们还是可以期望使用module可以让工具开发更容易,更方便的启用一些新特性,比如在符号缺失时自动包含相应modules,自动清除不需要的modules等。

  2. 优先使用if constexpr 而不是 #ifdef
    当未启用的代码路径组织良好时(没有引用未知符号时),if constexpr 相对于 #ifdef是个更好的选择,因为这样做的话,未启用的代码路径将依然是AST的一部分,可以被编译器或者其它静态分析工具或者代码重构工具检查。

  3. 优先使用std::source_location 而不是__LINE__ 或 FILE
    每个人都喜欢写一个自己的日志记录器。现在你可以写一个自己的logger,使用std::source_location而不是宏__LINE__ 或 __FILE__来做。

通向 macro-free 应用程序之路

一些编译器提供其它相对于宏更好的选择,但是现实点,你可能最终仍然不得不诉诸于预处理器。但是幸运的是,我们仍然有许多可以改善的地方:

  1. 将 -D 用编译器定义的变量替换
    -D 是最常用的方法来查询系统的编译环境:Debug/Release,或系统架构,或优化选项等。
    我们可以想象下利用一些列的常量,通过std::compiler来获取这些信息,像下面这样:

    if constexpr(std::compiler.is_debug_build()) {  }
    

    用同样的方式,我们可以想象有一些编译器外部的,声明在源码中的常量,但是它们在编译器中被定义或者已经定义的值被覆写的。这将比像constexpr x = SOME_DEFINE这样定义要好。如果有一种方式能够限制这些变量的取值,像下面这样:

    enum class OS {
        Linux,
        Windows,
        MacOsX
    };
    [[compilation_variable(OS::Linux, OS::Windows, OS::MacOsX)]]  extern constexpr int os;
    

    我希望能够给编译器更多的信息,让它能够知道这些各种各样的配置变量的作用,甚至编译器能够判断这些变量的哪些组合是合法的,这样我们可以更好的对源码建模,因此可以提供更好的工具和静态分析。

  2. 多用 attributes
    C++ 中的 attributes 非常不错,我们应该在代码中多使用。attributes以 [[attr]] 或 [[namespace::attr]] 的形式存在,[[deprecated]], [[fallthrough]],[[carries_dependency]]等都是C++ 11中开始支持的attributes。它可以通过一个常量参数来切换import/export(译者注:怎么做到的?)
    it could take a constexpr variable as argument to switch from import to export.

  3. 借鉴Rust的经验
    Rust社区只要有机会都会不遗余力的强力推销Rust语言中的优点。确实,Rust在不少事情做得非常好。编译器可配置是其中的一项:

    // The function is only included in the build when compiling for macOS
    #[cfg(target_os = "macos")]
    fn macos_only() {
      // ...
    }
    
    // This function is only included when either foo or bar is defined
    #[cfg(any(foo, bar))]
    fn needs_foo_or_bar() {
      // ...
    }
    

    通过使用一个 attribute 系统在编译单元中条件包含某个符号是一个非常有意思的想法。
    首先,它非常可读,并且也是自文档化(self documenting)的,其次,即使某个符号没有包含在构建单元中,我们也可以尝试着去解析它,更重要的是,唯一的声明为编译器提供了相关实体足够的信息以实现强大的工具,静态分析和重构。

    考虑下面的代码:

    [[static_if(std::compiler.arch() == "arm")]]
    void f() {}
    
    void foo() {
        if constexpr(std::compiler.arch() == "arm") {
            f();
        }
    }
    

    它有一个非常好的特性:它的组织非常好。由于编译器知道 f f f是一个有效的对象:一个函数名,编译器能够准确无误的解析函数体。(译者注:原文it can unambiguously parse the body of the discarded if constexpr statement, discarded这里什么作用)
    可以像下面这样对任何类型的C++声明使用同样的语法,编译器将能够理解它

    [[static_if(std::compiler.arch() == "arm")]]
    int x = /*...*/
    

    上面编译器将只能够解析等号左侧的,因为静态分析或工具不需要其它部分。

    [[static_if(std::compiler.is_debugbuild())]]
    class X {
    };
    

    静态分析只需要索引类名和公共成员。

    当然,在当前生效的代码路径下引用一个废弃的声明是非法的,但是如果配置正确,编译器将会确保这从不会发生。当然,这需要耗费一些计算资源,但这是值得的,你将得到强有力的保证:你的代码是合法的。这样,你在Linux上写的代码也将很难把你的windows build搞挂。

    然而,这也不是看上去那么简单。如果废弃的实体内部包含当前编译器不知道的语法,可能是某个编译器厂商提供的扩展或者一些新的C++特性,会发生什么呢?我认为解析基于尽力而为的策略,当某个解析失败时,编译器能够跳过当前的语句并且给出那块源码信息不能通过编译的警告信息,这是合理的。“我不能够重命名110行到130行间的Foo函数”这样的提示信息比“我重命名了一些Foo函数的使用,但可能不是全部,你最好手动浏览整个工程,真的,别烦编译器了,用grep解决吧”这样的提示信息要好得多。

  4. 尽可能用constexpr.

    也许我们需要一个constexpr类型的std::chrono::system_clock::now() 来代替宏 TIME, 我们也许同样需要一个编译期的随机数生成器 compile time Random Number Generator. 为什么不呢? 谁会在意每次的build是完全可重复的?

  5. 通过反射生存代码和符号

    元数据类(metaclasses)的提案是自 modules和concepts后最好的提案。特别的提案P0712 是一篇在许多方面都非常好的文章。

    它引入了非常多的基础设施,其中一个是 declname 关键字,它可以通过任意字母和数字的组合构建标识符。

    int declname(“foo”, 42) = 0; 创建变量 foo42 。考虑到使用宏最多的情况是通过字符串的拼接来生成新的标识符是, 这个特性确实非常有意思。希望编译器在这种情况下对于生成的符号有足够的信息来恰当的索引这些符号。

    不受好评的 X macro 在未来应该称为历史。

  6. 要摆脱对宏的依赖,我们需要一种新的类型的宏

    因为宏的本质是文本替换,它们的参数采用的是惰性求值。尽管我们可以使用lambda来模拟这种行为,但是这是非常麻烦的(cumbersome)。那么,我们还可以利用函数的惰性求值吗?

    这个问题我去年想过。

    我的想法是使用代码注入这个工具来创建某种新的“宏”,由于想不到更好的名字,我称之为“语法宏”。本质上,如果你为你的一段代码(一段你可以在某个时候在程序的某个地方注入的代码)起一个名字,允许这段代码有几个参数,这样你就为自己准备了一个宏。但是这个宏是在语义方面做的检查而不是像预处理器那样在源码的token方面做的检查。

    那这怎么工作呢,相关代码 如下 :

    constexpr {
        bool debug = /*...*/;
        log->(std::meta::expression<const char*> c,  std::meta::expression<>... args) {
                if(debug) {
                    -> { 
                       printf(->c, ->(args)...); 
                   };
             }
        }
    }
    
    void foo() {
        log->("Hello %", "World");  //expand to printf("Hello World")  only and only if debug is true
    }
    

    好吧,我们看看上面发生了什么:

    我们首先用 constexpr { } 创建了一个 constexpr 块。这是元数据类(metaclasses)的一部分。一个 constexpr 块是一个混合的语句块,在这个语句块里面所有的变量都必须是 constexpr,并且没有其它副作用。这个块的唯一作用是创建注入的片段,在编译时修改块实体的属性。(元数据类就是基于 constexpr 块的语法糖,其实我想说我们事实上不需要元数据类)

    在 constexpr 块内我们定义了一个宏log。注意到这个宏不是函数。它们将会展开为代码,它们不返回任何东西也不在栈上存在。log就是一个可以被限定的标志符,在同一个块内不能是任何其它实体的名字。“语法宏”和其它标志符一样遵循同样的名字查找规则。

    这里使用 -> 作为注入符。 -> 用来描述所有代码注入相关的操作,和 -> 本来的使用方法不冲突。在上面的例子中,log就是一个实现了代码注入的“语法宏”,我们将其定义为log->(){…} 。

    “语法宏”的内部本身是一个可以包含任意C++表达式的 constexpr 块,这个 constexpr 块可以在一个 constexpr 的语境中进行运算。

    “语法宏”可能包含0个,1个或多个用 -> {}表示的注入语句。一个注入语句创建一个代码片段,在宏被调用时立刻注入,也就是说,当“语法宏”展开的地方,相关的注入语句立刻注入。

    一个“语法宏”可能注入一个表达式或者0个或者多个语句。一个注入表达式的“语法宏”只能在需要这个表达式的地方展开。

    尽管“语法宏”没有任何类型,它有有编译器决定的某种属性。

    你可以传递任何参数到一个“语法宏”,就像你往一般的函数传递参数那样。参数在展开前就运算了,是一种强数据类型。

    然而,你也可以在表达式中传递反射。这假设我们能够对任意表达式取反射。作用于表达式e的反射的类型是 decltype(e)。

    在实现的时候,在上面的例子中,std::meta::expression<char*>是一个和作用于char *类型的表达式的反射对应的概念。

    宏的最后的一个魔法是宏展开前表达式将会隐式的转换为它们的反射。

    从根本上说,我们是在移动AST的各个节点,这和当前反射和代码注入是一致的。

    最后,当我们注入 print(->c, ->(args)…)时,注意 -> 标志。它将反射转换为最初可以求值的表达式。

    从上面的例子,log->(“Hello %”, “World”)看上去像一个常规的返回类型为空的函数调用,不同的是这里的 -> 表示这里有宏展开。

    最后,在计算之前标志符能够作为参数传递可能会减少对关键字的依赖,例如:

    std::reflexpr->(x) 在x被计算前可以展开为 __std_reflexpr_intrasics(x) 。

“语法宏”可以完全替代预处理器上的宏吗 ?

“语法宏”不可以完全替代预处理器上的宏,也不打算这么做。最主要的,是因为“语法宏”必须是合法的C++代码,它们将会在多个时候被审查(在定义时,展开前,展开时,展开后),它们也禁止 token soup。它们必须是合法的C++:注入合法的C++同时也要用合法的C++作为参数。

这意味着我们不能注入部分语句,操纵部分语句或者将任意语句作为参数。

它们确实解决了惰性求值和条件编译的问题。例如,你不能利用它们实现foreach,是因为 for(; ; )不是一个完整的语句,for(; ; );和for(; ; ) {}是,但是这没有什么作用。

有很多关于名字查找的问题。一个宏应该看到它展开的上下文吗? 参数应该感知它自己在一个宏内部吗?

我认为有局限是好事。如果你真的需要创造一些新的C++基础设施,也许目前的语言正好缺少这部分,在这种情况下,我建议你写一个提案。或者你需要一个代码生成器,或者只是一些更多的抽象,或者更多实际的代码而已。

这就是真实生活吗?

“语法宏”非常奇妙,并且它也绝对没有包含在目前已有的C++提案中,我确信这可能会是代码注入特性的一个有逻辑的演变。

它看起来有点像Rust宏,但是它又不支持任意语句。它看起来就是C++的一部分,而不是有单独语法的另一门语言。

结语

预处理器看起来当然是容易犯错的。但是你可以做很多事来使你更少的依赖它。C++社区也可以通过提供更好的宏的替代品来使宏逐渐变得不那么有用。

这可能要花数十年的时间,但是这将是值得的。不仅仅是因为宏从根本上来说是糟糕的,更因为工具目前是,将来也会更多的依赖语言本身,而不是糟糕的文本替换。

因为我们真的需要更好的工具,我们需要尽力减少我们对预处理器的依赖。

原文链接: https://hackernoon.com/undefining-the-c-pre-processor-c4eeb3d06e1f

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值