Effective C++ - Prefer the Compiler to the Processor

这是 Scott Effective C++ 中的 Item 2。

Prefer const, enum and inline to #define

C/C++ 在生成可执行代码前,需要经过预处理编译汇编链接四个步骤(在 C++ 的标准中,更是将整个过程划分成 9 个阶段)。但无论怎样,预处理总是在编译阶段前。严格来说,编译器是看不到预处理究竟发生了什么的。这也就是为什么 Scott 说:"#define may be treated as if it’s not part of the language per se.",由于编译器不知道预处理过程发生了什么,所以预处理宏可以不被看作是语言的一部分。

我们假设一个例子:

#define PI 3.1415
int main() {
	char c = {PI};
	return 0;
}

预处理后,在编译器眼中的代码其实长这样:

int main() {
    char c = {3.1415};
    return 0;
}

因为宏在预处理阶段所做的仅仅就是展开宏,而且展开宏的符号并不会进入符号表中,所以在有 #define 的宏参与时,编译器有可能并不能提供多少有用的信息。我们也看到,经过预处理后的代码已经不含有符号 PI 了。

如果这个时候你进行编译,编译器会提示你:

clang++: warning: treating 'cpp-output' input as 'c++-cpp-output' when in C++ mode, this behavior is deprecated [-Wdeprecated]
test.cpp:4:15: error: type 'double' cannot be narrowed to 'char' in initializer list [-Wc++11-narrowing]
    4 |     char c = {3.1415};
      |               ^~~~~~
test.cpp:4:15: note: insert an explicit cast to silence this issue
    4 |     char c = {3.1415};
      |               ^~~~~~
      |               static_cast<char>( )
test.cpp:4:15: warning: implicit conversion from 'double' to 'char' changes value from 3.1415 to 3 [-Wliteral-conversion]
    4 |     char c = {3.1415};
      |              ~^~~~~~
1 warning and 1 error generated.

如果这是一个大项目,你就可能纳闷这个 3.1415 是哪里来的。如何让编译的提示保留符号名以便 debug 就是我们要讨论的。

Use const for Constants

因为 #define 所定义的符号都具有常量性,为了替换宏的方式, Scott 建议的方案就是使用 const。接着上面的例子,如果将宏替换成 const。如下:

const double Pi = 3.1415;
int main() {
    char c = {Pi};
    return 0;
}

作为语言层面的常量定义方式,编译器是肯定能看到 const 修饰的变量名的,所以编译器在进行编译时的报错信息中就会包含具体的符号名,Pi 也理所应当地会被放到符号表中。这是宏所做不到的。

但这里需要注意用 const 替换宏时候下面的这两种情况:

  1. 常量指针:虽然 #define 宏和指针扯不上什么联系,但如果宏是用来表示字符串常量(比如:#define NAME "CongZhi"),如果你想用 const 替代,你就不能简单地表示成 const char* Name = "CongZhi" 而是:

    const char* const Name = "CongZhi";
    

    这是因为我们不仅仅需要限定字符串内容的不可修改,同时也要限定指向字符串的指针不可修改(即 Name 始终指向字符串常量)。所以你需要写两次 const

    更安全一点的方式是引入 std::string ,同时具备类型安全,而且语义上也更加清晰。

    const std::string Name = "CongZhi"
    
  2. 类内静态常量成员#define 是没有作用域的,定义的宏在整个翻译单元内都有效,而 C++ 作为支撑封装和作用域的面向对象语言,很多时候我们想把常量限定在某个类的内部。此时,就需要使用 static const 来定义类专属的常量成员。但请注意,由于 C++ 实现和声明分离的特性,一般会要求你在类外的实现文件中才给出静态常量成员的定义。比方说:

    // test.hpp
    class Test {
    private:
    	static const float TestingNumber; // Declaration
    }
    // test.cpp
    const float Test::TestingNumber = 3.14; // Definition
    

    凡事都有例外,如果类内的整型静态常量成员只用于编译期常量,且你保证不去访问它,那么多数 C++ 编译器容许你在声明时给出初值,而在实现文件中提供无初值的定义,或直接缺省类外定义。如:

    // test.hpp
    class GamePlayer {
    private:
        static const int MaxLives = 3; // Constant declaration.
        int lives[MaxLives];
    };
    // test.cpp
    // const int GamePlayer::MaxLives; // Definition, no value given.
    

The enum Hack

有些老编译器可能不兼容 static const 声明时即给出初值,为了避免类外定义的麻烦,就有了 "the enum hack" 的 trick。如下:

	// test.hpp
	class GamePlayer {
	private:
		enum {MaxLives = 3};
	    int lives[MaxLives];
	};
	// test.cpp

实际上这个小 trick 相比较于 const 更像 #define 的行为,而 the enum hack 又比宏更安全。如 the enum hack 和宏一样没有类型、不会占用存储空间(无法取地址)、但拥有作用域且保留有符号。

Function-Like Macros

除了定义常量的宏,还有一些 function-like 的宏的常见用法。Scott 对于这些 function-like 的宏持批判态度。宏在预处理阶段就展开了,所以使用 function-like 的宏可以避免程序运行时的函数调用,减少开销,这也是宏最大的优点(在 C++11 引入常量表达式之前,宏确实是减小开销最有效的方法)。

但同样的,只要引入宏,在 C++ 里就会牵扯到污染命名空间的问题,而且展开后宏名并不会保留到符号表中,带来调试困难的问题。此外,function-like 的宏单是看看就让人头疼,还可能导致多次求值,在 Scott 的例子中:

// Compiled with g++ -std=c++23 test.cpp -o test

#include <print>

// call f with the maximum of a and b 
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

void f(int x) {
	std::println("f({})", x);
}

int main() {
	int a = 5, b = 0;
	CALL_WITH_MAX(++a, b); // a is incremented twice
	std::println("a: {}, b: {}", a, b);
	CALL_WITH_MAX(++a, b+10); // a is incremented once
	std::println("a: {}, b: {}", a, b);
}

在第一次宏展开时,因为 ++ab 大,所以会被 ++ 两次;第二次宏展开时,因为 ++a < b 所以只被 ++ 了一次。

为方便理解,宏的展开如下:

#include <print> // 这里就不展开了哈~

void f(int x) {
 std::println("f({})", x);
}

int main() {
 int a = 5, b = 0;
 f((++a) > (b) ? (++a) : (b));
 std::println("a: {}, b: {}", a, b);
 f((++a) > (b+10) ? (++a) : (b+10));
 std::println("a: {}, b: {}", a, b);
}

输出如下:

f(7)
a: 7, b: 0
f(10)
a: 8, b: 0
f(8)
a: 8, b: 0

这里,Scott 给出用内联函数代替的解决方案。相比于宏,函数的作用域明确,也会提供类型安全检查,还支持许多特性(模板、重载等)。比如上面的例子用内联函数替代就如下所示:

// Compiled with g++ -std=c++23 test.cpp -o test

#include <print>

template<typename T>
void f(const T& x) {
    std::println("f({})", x);
}

template<typename T>
inline void callWithMax(const T& a, const T& b) {
    f((a > b) ? a : b);
}

int main() {
    int a = 5, b = 0;

    callWithMax(++a, b);
    std::println("a: {}, b: {}", a, b);

    callWithMax(++a, b + 10);
    std::println("a: {}, b: {}", a, b);
}

虽然内联提供类似宏的函数调用优化语义(消除函数调用开销,避免栈帧创建销毁开销),但内联最主要还是用于解决链接时的多重定义问题。所以内联只是对编译器的一种弱提示 (weak hint),并不具有强制意义。即使你显式声明了 inline,编译器一般也并不会进行函数内联优化(除非可能发生符号冲突)。相关内容详见 Inline in C++

所以实际上内联可能并不能解决我们的问题,但 Scott 的出发点总归是好的——尽可能地用语言提供的机制代替预处理器技巧。那么如何真正解决我们的问题呢?更佳安全、且能够减少运行时的函数调用开销的方法何在?我们接着往下看。

Constant Expression Since C++11

在 C++11 之后,const 家族引入了新的关键字 constexpr, constevalconstinit。它们被称为常量表达式,用于表达编译器常量并约束初始化行为,用作函数上,就能给编译器一个强提醒去进行函数内联,避免调用开销。

在这里,我们接着本节中所举的例子,实例用常量表达式如何替代。常量表达式 (constexpr) 的使用和 const 几乎一样,但提供编译期化的语义,而 const 只表示值不可修改的语义,这里不赘述。

在第一个和第二个例子中,我们可直接将 const 替换成 constexpr

constexpr double Pi = 3.1415;
int main() {
    char c = {Pi};
    return 0;
}
	// test.hpp
	class GamePlayer {
	private:
	    static constexpr int MaxLives = 3; // Constant expression definition.
	    int lives[MaxLives];
	};
	// test.cpp

但是需要注意的是,使用 constexpr 在类内即可给出静态成员的定义,而不需要类外定义,而且任意类型都可初始化。实际上就类似于:

	// test.hpp
	class GamePlayer {
	private:
	    inline static const int MaxLives = 3; // Constant definition, C++17
	    int lives[MaxLives];
	};
	// test.cpp

对于函数例子,用 constexpr 替换也非常简单,只需要把 inline 改为 constexpr 即可。不过由于编译期优化的语义,函数的常量表达式需要保证所有的参数都是编译器可知的常量,之前例子显然不符合(++a, b + 10 还有 IO 调用):

不过 constepxr 对编译期的语义并不像 constevalconstinit 那样强制,所以即使参数不是常量,替换后函数还是可以正常运行,不过和加不加 constexpr 没什么两样罢了。

// Compiled with g++ -std=c++23 test.cpp -o test

#include <print>

template<typename T>
void f(const T& x) {
    std::println("f({})", x);
}

template<typename T>
constexpr void callWithMax(const T& a, const T& b) {
    f((a > b) ? a : b);
}

int main() {
    int a = 5, b = 0;

    callWithMax(++a, b);
    std::println("a: {}, b: {}", a, b);

    callWithMax(++a, b + 10);
    std::println("a: {}, b: {}", a, b);
}

有关常量表达式的详见: