MSVC 新预处理器概述

Visual Studio 2015 使用不符合 C++ 或 C99 标准的传统预处理器。 从 Visual Studio 2019 版本 16.5 开始,对 C++20 标准的新预处理器支持功能完整。 可以使用 /Zc:preprocessor 编译器开关获取这些更改。 从 Visual Studio 2017 版本 15.8 及更高版本开始,可使用 /experimental:preprocessor 编译器开关获得新预处理器的实验版本。 提供了有关在 Visual Studio 2017 和 Visual Studio 2019 中使用新预处理器的详细信息。 若要查看 Visual Studio 首选项的文档,请使用“版本”选择器控件。 它位于此页面上目录表的顶部。

我们正在更新 Microsoft C++ 预处理器以提高标准一致性,修复长期 bug,并更改一些官方未定义的行为。 我们还添加了新的诊断来针对宏定义中的错误发出警告。

从 Visual Studio 2019 版本 16.5 开始,对 C++20 标准的预处理器支持功能完整。 可以使用 /Zc:preprocessor 编译器开关获取这些更改。 从 Visual Studio 2017 版本 15.8 开始,早期版本中提供了新预处理器的实验版本。 可以通过使用 /experimental:preprocessor 编译器开关来启用该版本。 默认预处理器行为与以前的版本相同。

新的预定义宏

可以检测在编译时使用的预处理器。 检查预定义宏 _MSVC_TRADITIONAL 的值,判断是否正在使用传统预处理器。 此宏由支持它的编译器版本无条件设置,与调用哪个预处理器无关。 对于传统预处理器,其值为 1。 对于符合要求的预处理器,该值为 0。

#if !defined(_MSVC_TRADITIONAL) || _MSVC_TRADITIONAL
// Logic using the traditional preprocessor
#else
// Logic using cross-platform compatible preprocessor
#endif

新预处理器中的行为更改

新预处理器的初始工作侧重于使所有宏扩展都符合标准。 它允许将 MSVC 编译器与当前被传统行为阻止的库一起使用。 我们在实际项目中测试了更新后的预处理器。 以下是我们发现的一些更常见的中断性变更:

宏注释

传统的预处理器基于字符缓冲区,而不是预处理器标记。 它允许异常行为,例如以下预处理器注释技巧,该技巧在符合标准的预处理器下不起作用:

#if DISAPPEAR
#define DISAPPEARING_TYPE /##/
#else
#define DISAPPEARING_TYPE int
#endif

// myVal disappears when DISAPPEARING_TYPE is turned into a comment
DISAPPEARING_TYPE myVal;

符合标准的修复是在相应的 #ifdef/#endif 指令中声明 int myVal

#define MYVAL 1

#ifdef MYVAL
int myVal;
#endif

L#val

传统预处理器错误地将字符串前缀合并到字符串化运算符 (#) 运算符的结果中:

#define DEBUG_INFO(val) L"debug prefix:" L#val
//                                       ^
//                                       this prefix

const wchar_t *info = DEBUG_INFO(hello world);

在这种情况下,L 前缀是不必要的,因为相邻字符串字面量无论如何都在宏扩展之后合并。 向后兼容的修复是更改定义:

#define DEBUG_INFO(val) L"debug prefix:" #val
//                                       ^
//                                       no prefix

在将参数“字符串化”为宽字符串文字的便利宏中也发现了相同的问题:

 // The traditional preprocessor creates a single wide string literal token
#define STRING(str) L#str

可以通过多种方式修复此问题:

  • 使用 L""#str 的字符串串联来添加前缀。 相邻的字符串字面量在宏扩展后合并:

    #define STRING1(str) L""#str
    
  • 在使用附加宏扩展对 #str 进行字符串化后添加前缀

    #define WIDE(str) L##str
    #define STRING2(str) WIDE(#str)
    
  • 使用串联运算符 ## 合并标记。 未指定 ### 的操作顺序,但在这种情况下所有编译器似乎都在 ## 之前评估 # 运算符。

    #define STRING3(str) L## #str
    

无效警告 ##

标记粘贴运算符 (##) 未生成单个有效预处理标记时,则表明行为未定义。 传统预处理器无法合并标记且不会发出提示。 新预处理器与大多数其他编译器的行为匹配,并会发出诊断。

// The ## is unnecessary and does not result in a single preprocessing token.
#define ADD_STD(x) std::##x
// Declare a std::string
ADD_STD(string) s;

可变参数宏中的逗号省略

传统的 MSVC 预处理器始终会移除空 __VA_ARGS__ 替换内容前的逗号。 新的预处理器更严密地遵循其他常用跨平台编译器的行为。 要移除逗号,必须缺少可变参数(不仅仅为空),并且必须用 ## 运算符标记。 请考虑以下示例:

void func(int, int = 2, int = 3);
// This macro replacement list has a comma followed by __VA_ARGS__
#define FUNC(a, ...) func(a, __VA_ARGS__)
int main()
{
    // In the traditional preprocessor, the
    // following macro is replaced with:
    // func(10,20,30)
    FUNC(10, 20, 30);

    // A conforming preprocessor replaces the
    // following macro with: func(1, ), which
    // results in a syntax error.
    FUNC(1, );
}

在以下示例中,在对 FUNC2(1) 的调用中,要调用的宏中缺少可变参数。 在对 FUNC2(1, ) 的调用中,可变参数为空,但没有缺失(注意参数列表中的逗号)。

#define FUNC2(a, ...) func(a , ## __VA_ARGS__)
int main()
{
   // Expands to func(1)
   FUNC2(1);

   // Expands to func(1, )
   FUNC2(1, );
}

在即将推出的 C++20 标准中,已通过添加 __VA_OPT__ 解决了此问题。 从 Visual Studio 2019 版本 16.5 开始,提供对 __VA_OPT__ 的新预处理器支持。

C++20 可变参数宏扩展

新的预处理器支持 C++20 可变参数宏参数省略:

#define FUNC(a, ...) __VA_ARGS__ + a
int main()
  {
  int ret = FUNC(0);
  return ret;
  }

此代码不符合 C++20 标准之前的标准。 在 MSVC 中,新的预处理器将此 C++20 行为扩展到较低的语言标准模式(/std:c++14/std:c++17)。 此扩展与其他主要跨平台 C++ 编译器的行为匹配。

宏参数已“解压缩”

在传统的预处理器中,如果某个宏将它的一个参数转发给另一个依赖宏,那么该参数在插入时不会“解压缩”。 此优化通常不易察觉,但它可能导致异常行为:

// Create a string out of the first argument, and the rest of the arguments.
#define TWO_STRINGS( first, ... ) #first, #__VA_ARGS__
#define A( ... ) TWO_STRINGS(__VA_ARGS__)
const char* c[2] = { A(1, 2) };

// Conforming preprocessor results:
// const char c[2] = { "1", "2" };

// Traditional preprocessor results, all arguments are in the first string:
// const char c[2] = { "1, 2", };

展开 A() 时,传统的预处理器会将 __VA_ARGS__ 中打包的所有参数转发到 TWO_STRINGS 的第一个参数,从而将 TWO_STRINGS 的可变参数留空。 这导致 #first 的结果为“1, 2”而不仅仅是“1”。 如果严格按照相关步骤操作,你可能想知道传统预处理器扩展中 #__VA_ARGS__ 的结果发生了什么:如果可变参数为空,它应该导致一个空字符串字面量 ""。 一个单独的问题导致无法生成空字符串字面量标记。

重新扫描替换列表中的宏

替换宏后,将重新扫描生成的标记以查找要替换的其他宏标识符。 传统预处理器用于执行重新扫描的算法不符合要求,如以下基于实际代码的示例所示:

#define CAT(a,b) a ## b
#define ECHO(...) __VA_ARGS__
// IMPL1 and IMPL2 are implementation details
#define IMPL1(prefix,value) do_thing_one( prefix, value)
#define IMPL2(prefix,value) do_thing_two( prefix, value)

// MACRO chooses the expansion behavior based on the value passed to macro_switch
#define DO_THING(macro_switch, b) CAT(IMPL, macro_switch) ECHO(( "Hello", b))
DO_THING(1, "World");

// Traditional preprocessor:
// do_thing_one( "Hello", "World");
// Conforming preprocessor:
// IMPL1 ( "Hello","World");

虽然这个示例看起来是人为想象的,但我们已经在实际代码中看到了它。

要查看发生了什么,我们可以对以 DO_THING 开始的扩展进行细分:

  1. DO_THING(1, "World") 扩展为 CAT(IMPL, 1) ECHO(("Hello", "World"))
  2. CAT(IMPL, 1) 扩展为 IMPL ## 1,后者扩展为 IMPL1
  3. 现在,标记处于以下状态:IMPL1 ECHO(("Hello", "World"))
  4. 预处理器发现类似于函数的宏标识符 IMPL1。 由于它后面没有 (,因此不被视为类似函数的宏调用。
  5. 预处理器继续处理以下标记。 它发现调用了类似函数的宏 ECHOECHO(("Hello", "World"))(扩展为 ("Hello", "World")
  6. 不再考虑将 IMPL1 用于扩展,因此扩展的完整结果为:IMPL1("Hello", "World");

要将宏修改为在新预处理器和传统预处理器下行为方式相同,请添加另一个间接层:

#define CAT(a,b) a##b
#define ECHO(...) __VA_ARGS__
// IMPL1 and IMPL2 are macros implementation details
#define IMPL1(prefix,value) do_thing_one( prefix, value)
#define IMPL2(prefix,value) do_thing_two( prefix, value)
#define CALL(macroName, args) macroName args
#define DO_THING_FIXED(a,b) CALL( CAT(IMPL, a), ECHO(( "Hello",b)))
DO_THING_FIXED(1, "World");

// macro expands to:
// do_thing_one( "Hello", "World");

16.5 之前的功能不完整

从 Visual Studio 2019 版本 16.5 开始,新的预处理器已针对 C++20 提供了完整的功能。 在 Visual Studio 的早期版本中,新的预处理器大多功能完整,但一些预处理器指令逻辑仍然回退到传统行为。 以下是 16.5 之前的 Visual Studio 版本中不完整功能的部分列表:

  • _Pragma 支持
  • C++20 功能
  • Boost 阻塞 bug:预处理器常量表达式中的逻辑运算符未在版本 16.5 之前的新预处理器中完全实现。 在某些 #if 指令上,新的预处理器可以回退到传统的预处理器。 只有在扩展与传统预处理器不兼容的宏时,效果才明显。 生成 Boost 预处理器槽时,可能会发生这种情况。