Item 2:以 const、enum、inline 替换 #define

对齐 Effective C++ 第三版 Item 2:用有类型、有作用域的 const/constexpr、enum 与 inline template 替换对象式和函数式宏,并正确处理 linkage、ODR-use 与少数预处理需求。

学习目标

  • 能比较对象式宏与 const、constexpr、enum 常量在类型、作用域、符号和链接上的差异
  • 能实现 inline function template 替换函数式宏,验证参数单次求值、运算符优先级和诊断位置
  • 能设计跨两个翻译单元的常量测试,判断内部链接、inline variable 与 ODR-use 是否满足预期
Macro migration / language entity maptoken 替换 → 类型实体 → 作用域、身份与求值证据#definetoken substitutionno type / scopeconst constanttype + namespacevalue / addressenum hacklegacy integral boundno address identityprefer consts enums and inlineschoose by contractnot by token counttypecompiler checksscopenamespace / classidentitylinkage / ODR-useevaluationonce / side effect替换成功的证据不是宏数量减少,而是类型、身份、求值次数和诊断都可验证
把宏迁移成语言实体时,分别检查类型、作用域、链接身份和副作用;不同需求对应不同替代物。

宏迁移诊断实验

先预测:替代后改变了哪条语义?

先预测类型、地址身份和实参求值次数,再切换场景查看迁移证据。

观察

#define 只替换 token,没有 type、scope 或 symbol;const constant 把值带回 compiler 能检查的语言世界。

决策

数字和字符串常量优先用 const、constexpr 或 string_view;需要程序级身份时选择 inline variable,并明确 linkage。

当前场景 · #define / const constant

数字和字符串常量优先用 const、constexpr 或 string_view;需要程序级身份时选择 inline variable,并明确 linkage。

从“编译器为什么不知道宏的名字”开始

预处理器在 C++ 编译器建立类型和符号之前做 token 替换。

例如:

#define ASPECT_RATIO 1.653

编译错误和调试信息中可能只出现 1.653,而不是 ASPECT_RATIO。宏没有类型,也不遵守 namespace 或 class 作用域。

Item 2 的核心不是少打一个 #,而是尽量让名称进入编译器掌握的语言世界。

对象式宏优先替换为有类型常量

原书以 const double 替代宏:

const double aspectRatio = 1.653;

现代头文件若需要一个跨翻译单元共享身份的编译期常量,可写:

inline constexpr double aspectRatio{1.653};
inline constexpr std::string_view applicationName{"Renderer"};

是原书原则在现代 C++ 的直接延伸。不要机械把所有宏替换为 namespace const 而忽略身份和链接需求。

字符串常量避免裸指针语义

#define AUTHOR_NAME "Scott" 更有类型,但现代只读接口常用 std::string_view,它同时携带长度且不取得所有权。需要独立拥有内容时使用 std::string

inline constexpr std::string_view authorName{"Scott Meyers"};
 
void print_author(std::string_view name);

仍有借用生命周期:不能返回指向已销毁临时 string 的 view。替换宏后获得类型系统,不代表生命周期自动正确。

class 专属常量与 enum hack

原书时代常见:

class GamePlayer {
public:
    static const int NumTurns = 5;
    int scores[NumTurns];
};

若程序 ODR-use 该成员,例如取地址,旧语言规则可能要求源文件再提供定义。现代可写 inline static constexpr int NumTurns{5};

决定“类内初值已经编译”是否足够。

class GamePlayer {
public:
    enum { NumTurns = 5 };
    int scores[NumTurns];
};

它解释历史模板代码与元编程风格,现代新代码通常优先 static constexpr。条款要求理解替代关系,不要求复古写法成为默认。

linkage 决定“有几个常量”

头文件中的普通 namespace const 传统上具有 internal linkage,每个翻译单元可能有一份实体;inline constexpr 可表示一个程序级实体。若只用值,两者结果常相同;若比较地址、注册身份或把引用作为模板参数,差异就可观察。

必须用至少两个 .cpp 测试头文件常量,单文件无法暴露重复定义或身份错误。

// constants.hpp
inline constexpr int protocolVersion{7};
 
// a.cpp / b.cpp
const int* version_from_a() { return &protocolVersion; }
const int* version_from_b() { return &protocolVersion; }

断言两个地址相同,证明需求与实现一致;若身份不重要,则测试值和链接成功即可。

函数式宏的重复求值

经典最大值宏即使加满括号,仍可能重复求值:

#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
 
int x = 5;
int y = 0;
CALL_WITH_MAX(++x, y);      // x 可能增加两次
CALL_WITH_MAX(++x, y + 10); // x 可能只增加一次

括号只能修复优先级,不能修复次数、类型和名称查找。

inline function template 保留效率与语义

template<class T>
inline void callWithMax(const T& left, const T& right)
{
    f(left > right ? left : right);
}

函数实参在进入函数前各求值一次;参数和返回类型受检查;名称可放入 namespace;调试器能识别函数。inline 不是强制展开,而是允许多处相同定义并向优化器表达候选。

现代泛型最大值还要处理不同类型、引用悬空与比较投影;不要为了模仿宏随意返回 const T& 指向临时值。

运算符优先级与 token 污染

#define SQUARE(x) x * xSQUARE(a + b) 下展开为 a + b * a + b。即使写成 ((x) * (x)),副作用仍存在。

系统头中的 min/max 宏是常见例子。语言函数可通过作用域、重载和 ADL 正常解析,宏做不到。

哪些场景仍需要预处理器

include guard、#if 平台适配、编译器属性封装、字符串化和 token paste 有时确实只能在预处理阶段完成。条款是“宁可以语言实体替换”,不是“所有 #define 都违法”。

#if defined(_WIN32)
  #define LIB_EXPORT __declspec(dllexport)
#else
  #define LIB_EXPORT [[gnu::visibility("default")]]
#endif

可以让平台宏只决定属性,业务代码继续使用类型与函数。宏名采用项目级前缀,并在可能时 #undef,降低污染。

迁移与验证顺序

先搜索定义与全部调用,分类为常量、类常量、函数行为或真正预处理。记录是否取地址、跨翻译单元、传递副作用表达式、依赖字符串化。再选择替代物并保持测试。

先预测:对 CALL_WITH_MAX(++x, y)CALL_WITH_MAX(++x, y + 10) 手工展开,写出 x 的增量;替换为函数模板后重新预测。运行计数器测试,确认每个实参只求值一次。

  • 常量测试覆盖值、类型、作用域和必要的地址身份。
  • class 常量同时测试普通使用与 ODR-use。
  • 函数宏测试副作用、混合类型、异常和返回生命周期。
  • 两个翻译单元共同 include 头文件并链接。
  • 条件编译在每个目标平台至少编译一个分支。

迁移失败模式

第一类失败是“值正确、身份错误”。把宏改成头文件 namespace const 后,每个翻译单元都可能持有独立对象;普通比较看不出来,但地址注册、引用模板参数或跨模块缓存会分裂。先写清是否需要统一身份,再选择 internal linkage 或 inline variable。

第二类失败是“单文件正确、完整程序错误”。类内 static const 的整数初值可满足数组界限,却在取地址时暴露缺失定义;非 inline 外部变量则会造成重复定义。测试必须包含编译、两个翻译单元链接和实际 ODR-use,不能只运行头文件单测。

第三类失败是“宏副作用消失,却引入引用生命周期”。函数模板若为避免复制返回 const T&,而调用点传入临时量,结果可能在完整表达式后悬空。替换宏时要重新设计返回值语义,而不是逐 token 模仿展开结果。小型值通常直接返回值,并让优化器处理复制消除。

迁移完成后保留一张对照表:旧宏职责、替代实体、作用域、linkage、是否可取地址、实参求值次数和失败测试。它比“宏数量减少”更能证明条款真正落实。

小结

  • 对象式宏不参与类型、作用域和符号系统,优先改为 const/constexpr
  • 类内整数历史上可用 enum hack,现代通常用 inline static constexpr
  • linkage 与 ODR-use 决定头文件常量需要怎样声明和定义
  • 函数式宏的括号不能解决重复求值,inline function template 才能恢复函数语义
  • 条件编译等真正文本处理可以保留,但应隔离并减少污染

资料与写作方式声明

本章以Effective C++, Third Edition, Item 2权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

本章出现的专业名词,用大白话再讲一遍。

对象式宏

由预处理器直接替换为 token 序列的名称。

预处理阶段

在 C++ 类型检查前处理 include、条件与宏的阶段。

const 常量

有类型、作用域和名称且不可修改的对象或值。

constexpr

允许对象或函数参与常量表达式求值的说明符。

inline variable

允许头文件多处相同定义表示同一程序实体的变量。

const char pointer

指向只读字符的裸指针,不携带长度与所有权。

string_view

以指针长度表示、不拥有字符存储的只读值。

class 常量

属于类并由所有实例共享的常量成员。

ODR-use

要求变量作为有存储实体存在的使用方式。

enum hack

用匿名 enum 提供类作用域编译期整数的旧式技巧。

linkage

名称在翻译单元和程序中连接到实体的规则。

翻译单元

源文件及其递归头文件预处理后的编译单位。

函数式宏
参数 token 被代入宏体的伪函数宏。
重复求值
同一宏实参因多次代入而执行多次。
inline function template

保留函数和类型语义的可内联函数模板。

内联展开
优化器用函数体替换调用的决定。
宏优先级陷阱

宏展开后调用点运算符改变表达式结构的问题。

token 污染

宏不受 namespace 限制而替换正常标识符的现象。

条件编译

预处理阶段按平台或配置选择 token 的机制。

宏隔离层

把不可避免宏限制在适配头并暴露正常 C++ 接口的边界。

宏迁移

分类宏职责并以保持语义的语言实体替换的过程。

练习

  1. 问题 1:prefer consts enums and inlines 与 const constant。 头文件含数字、字符串和 class 数组长度三个宏,分别选择现代替代并说明 linkage 测试。
  1. 问题 2:#define 与 enum hack。 展开一个带 ++ 实参的 max 宏,证明括号为何不足,并写出模板替代的测试。
  1. 问题 3:#define 与 prefer consts enums and inlines。 为平台导出属性、日志函数和编译期开关分别决定保留、隔离或替换方案。

讨论

评论区加载中…