tags:
- Cpp
Effective C++ - Prefer the Compiler to the Processor
这是 Scott Effective C++ 中的 Item 2。
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 就是我们要讨论的。
const
for Constants因为 #define
所定义的符号都具有常量性,为了替换宏的方式, Scott 建议的方案就是使用 const
。接着上面的例子,如果将宏替换成 const
。如下:
const double Pi = 3.1415;
int main() {
char c = {Pi};
return 0;
}
作为语言层面的常量定义方式,编译器是肯定能看到 const
修饰的变量名的,所以编译器在进行编译时的报错信息中就会包含具体的符号名,Pi
也理所应当地会被放到符号表中。这是宏所做不到的。
但这里需要注意用 const
替换宏时候下面的这两种情况:
常量指针:虽然 #define
宏和指针扯不上什么联系,但如果宏是用来表示字符串常量(比如:#define NAME "CongZhi"
),如果你想用 const
替代,你就不能简单地表示成 const char* Name = "CongZhi"
而是:
const char* const Name = "CongZhi";
这是因为我们不仅仅需要限定字符串内容的不可修改,同时也要限定指向字符串的指针不可修改(即 Name
始终指向字符串常量)。所以你需要写两次 const
。
更安全一点的方式是引入 std::string
,同时具备类型安全,而且语义上也更加清晰。
const std::string Name = "CongZhi"
类内静态常量成员:#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.
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 的宏的常见用法。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);
}
在第一次宏展开时,因为 ++a
比 b
大,所以会被 ++
两次;第二次宏展开时,因为 ++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 的出发点总归是好的——尽可能地用语言提供的机制代替预处理器技巧。那么如何真正解决我们的问题呢?更佳安全、且能够减少运行时的函数调用开销的方法何在?我们接着往下看。
在 C++11 之后,const
家族引入了新的关键字 constexpr
, consteval
和 constinit
。它们被称为常量表达式,用于表达编译器常量并约束初始化行为,用作函数上,就能给编译器一个强提醒去进行函数内联,避免调用开销。
在这里,我们接着本节中所举的例子,实例用常量表达式如何替代。常量表达式 (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
对编译期的语义并不像 consteval
和 constinit
那样强制,所以即使参数不是常量,替换后函数还是可以正常运行,不过和加不加 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);
}
有关常量表达式的详见: