一个宏可以少写重复的成员变量和 set / get。展开之后,成员本身仍是 private,公开的是这对函数。代价在宏外面:宏的最后一行停在 public:,紧跟在后面、没有再写访问说明的成员会变成公开的。类型参数在预处理阶段按逗号切开,类型里再有一个逗号就传不进去。
要把这两件事看清楚,需要先知道源文件是怎么变成正在跑的程序的。
1. 源文件到程序跑起来
一个 C++ 程序从源文件到跑起来,经过四步。
预处理。 读 .cpp 和被 #include 进来的头文件,处理以 # 开头的行。#include 把头文件贴进来,#define 把宏换成正文,#if 删掉不编译的行。做完仍然是一份文本,这一步不检查类型。
编译。 读展开后的文本。这里才认识 std::string、std::pair<int, int>,检查传参类型,然后生成这个文件的机器码,放进目标文件 .o。一个 .cpp 对应一个 .o。
链接。 把多个 .o 和库拼成一个可执行文件。
运行。 操作系统把可执行文件载入内存,CPU 按里面的机器码执行。预处理、编译、链接到这里都已经结束。平时说的 C++ runtime,是这一步刚开始、进入 main 之前由库代码做的准备,例如准备堆、调用全局对象的构造函数。它发生在运行这一步里面,不是编译流程里多出来的一步。
g++ a.cpp b.cpp -o app 会把前三步连着做完。
机器码和空着的地址
机器码是 CPU 能直接执行的一串字节。源文件里的 return bar(); 是给人看的文字。编译之后,它变成若干字节,意思是「跳到某个地址去执行,再跳回来」。
cpp
// a.cpp
int bar();
int main() { return bar(); }
// b.cpp
int bar() { return 42; }
编译 a.cpp 时,编译器知道 main 要调用 bar,但还不知道 bar 最终会放在内存的哪一格,因为 b.cpp 还没和它拼在一起。a.o 里的调用指令先把目标地址空着,旁边记一笔:这个空位,链接时填上名字叫 bar 的函数。
链接排好位置。假设 main 放在 0x400000,bar 放在 0x400100,那条指令就变成 call 0x400100。运行时调用的是这个地址。两个 .cpp 已经收进同一个程序,不再按文件名去找函数。
CPU 有一个指令指针,指向当前要执行的那一格内存。它读出那里的字节,做完,指针往后移。遇到 call 0x400100,先记下返回位置,再把指针改到 0x400100。bar 返回后,指针回到 call 的下一格。
编译时和运行时
运行时对上的是编译时。编译时就能确定的事也叫静态。静态对上的是运行时,不是流程里另有一个叫「静态」的步骤。
宏展开、setName 的参数必须是 std::string、链接时把 bar 的地址填进 call,都发生在编译时。main 真正跳到 bar,发生在运行时。
2. 预处理为什么按逗号切参数
带参数的宏写成函数调用的样子,参数就照函数那样用逗号分开:
cpp
#define MAX(a, b) ((a) > (b) ? (a) : (b))
MAX(foo(1, 2), y)
foo(1, 2) 外面有圆括号,里面的逗号算在同一个参数里,所以 MAX 仍然是两个参数。尖括号不算一层括号。
< 和 > 同时也是小于号、大于号。预处理不查这个名字是什么,分不清下面两种写法:
cpp
std::pair<int, int>
a < b, c > (d)
第一行是一个类型。第二行在 a、b、c 是数值时,是「a < b」和「c > (d)」。预处理不把 <> 当成括号。
按逗号切参数、只认圆括号,是 C 里就有的规则。1979 年 C++ 出现时,这一步就已经这样工作。1998 年的 C++98 把同一套切分写进了 C++ 标准,之后没有改成「尖括号里的逗号不算分隔符」。
现在编译 C++,这一步由 C++ 编译器自己完成,写在 C++ 标准里。它不是后来新做的、能认识 pair<int, int> 的另一套程序,所以尖括号里的逗号仍然会切开。
3. 用宏生成成员和 set / get
重复的访问函数可以收成一个宏。下面这个定义是说明用的最小例子:
cpp
#define PROPERTY(type, member, name) \
private: \
type member##name; \
public: \
void set##name(type const& name##_) { member##name = name##_; } \
const type& get##name() const { return member##name; }
## 在预处理时把两段文字拼成一个名字。写一行:
cpp
PROPERTY(std::string, m, Name);
换成:
cpp
private:
std::string mName;
public:
void setName(std::string const& Name_) { mName = Name_; }
const std::string& getName() const { return mName; }
好处是几十个字段不用各写三行。std::string、int、std::vector<unsigned int> 这种类型里面没有逗号,切开之后正好是宏要的三段:类型、成员前缀、名字。所以这些行能正常生成代码。
宏自己生成的那几行,权限是对的。mName 写在 private: 下面,是私有的。公开的是 setName 和 getName。
4. 可读性:访问说明藏在宏里面
类里看到的是一行宏,看不到 private: 和 public:。这两个字在宏定义里。于是成员区看起来是连续的,读者要到宏定义里才知道每一行都在切换访问权限。
搜索 void setName 也搜不到这个函数,它是拼出来的。跳转到定义时,落到 PROPERTY(...) 那一行,而不是类里的一个函数体。
5. 调试:断点落在宏上,所有字段共用这几行
手写的 setName 在类里面有自己的那一行。断点打在那一行,只有给这个字段赋值时会停。
用宏时,所有字段的 set / get 都从同一段宏正文展开。断点打在宏上,给 Name、Id、Text 赋值都会停在那几行。单步进去看到的也是宏定义,不是这个类里单独的 setName。
6. 类型检查:逗号出现在类型里面时才会坏
类型检查指编译器认得「这个参数是某一种类型」,传错了就报错。
手写时,逗号在类型里面,编译器把它看成一个类型:
cpp
void setPoint(std::pair<int, int> const& point_);
参数类型是 std::pair<int, int>。传一个 int 进去,编译器会拒绝。
宏在编译器之前就处理,只按逗号切文字。PROPERTY 要三段。这一行里有三个逗号:
cpp
PROPERTY(std::pair<int, int>, m, Point);
被切成四段:
std::pair<intint>mPoint
std::pair<int, int> 没有作为一整个类型交进去。宏对不上三段,这一行在编译器做类型检查之前就失败了。
现有那些没有逗号的类型不会因此失效。展开成功之后,生成出来的 set / get 仍然会检查调用时的类型,setName(1) 还是过不了。类型检查变差,指的是宏接收类型参数的这一步。
类型里必须带逗号时,先起一个没有逗号的别名,再传给宏:
cpp
using Point = std::pair<int, int>;
PROPERTY(Point, m, PointValue);
7. 宏结尾停在 public
「宏结尾」是替换进去的最后一行。这一行上面最近的访问说明是 public:,宏里没有再写 private:。C++ 的访问权限会一直生效,直到下一个 private: 或 public:。所以宏结束时,类停在 public。
文件里可能是这样,两个补偿量写在可见的 public: 上面:
cpp
class Summary {
PROPERTY(unsigned long, m, TotalTime);
double sumCompensation;
double squareCompensation;
public:
Summary() : mTotalTime(0), sumCompensation(0), squareCompensation(0) {}
};
PROPERTY 那一行表面没有访问说明。上面这一段看起来像成员区,写在可见的 public: 之前,就会当成已经是私有的,不再自己写一个 private:。
编译前实际是:
cpp
class Summary {
private:
unsigned long mTotalTime;
public:
void setTotalTime(unsigned long const& TotalTime_) { mTotalTime = TotalTime_; }
const unsigned long& getTotalTime() const { return mTotalTime; }
double sumCompensation; // 还没遇到新的 private:,所以是 public
double squareCompensation;
public:
Summary() : mTotalTime(0), sumCompensation(0), squareCompensation(0) {}
};
mTotalTime 仍是私有的。变成公开的是写在宏后面的那两个 double。外面的代码可以直接读写它们。
紧挨着补一个 private:,这两个 double 就是私有的。宏每用一次都以 public: 结束,后面再手写成员,就要再写一次 private::
cpp
PROPERTY(unsigned long, m, TotalTime);
private:
double sumCompensation; // 私有
PROPERTY(std::string, m, Name); // 这一行结尾又是 public:
double extra; // 又变成公开的
漏一次就又公开。手写时只留一个写在文件上的 private:,它后面的成员都归它管:
cpp
class Summary {
public:
void setName(std::string const& name_) { mName = name_; }
const std::string& getName() const { return mName; }
private:
std::string mName;
double sumCompensation;
double squareCompensation;
};
set / get 的行为和宏展开相同。区别是访问权限只由文件里写出来的 public: 和 private: 决定,不会在每个字段后面被宏改回去。