C++ 宏生成访问函数的代价:访问权限、调试和类型检查

一个宏可以少写重复的成员变量和 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);

被切成四段:

  1. std::pair<int
  2. int>
  3. m
  4. Point

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: 决定,不会在每个字段后面被宏改回去。

相关推荐
划破黑暗的第一缕曙光3 小时前
[C++]:5.STL:string(2)
java·c++·redis
小超人诞生日记6 小时前
C++ 异常机制详解:从抛出捕获到异常安全
c++·算法
多弗朗皮卡丘6 小时前
C++的IO流
开发语言·c++·io流·文件io流
xiangyun617 小时前
C++ 手撕 shared_ptr:引用计数与控制块到底长什么样?
开发语言·c++·算法·面试·stl
SunkingYang7 小时前
C++14 vs C++17:新特性对比与用法指南
c++·新特性·用法·c++14·示例·c++17
SunkingYang8 小时前
C++ Lambda 表达式原理详解
c++·lambda·理论·原理·表达式
xiaoye-duck9 小时前
《C++ 工具库实战》GTest 单元测试:断言、全局环境、局部夹具与底层原理
c++·单元测试·第三方库
学逆向的9 小时前
C++ 匿名命名空间
开发语言·c++·网络安全
miller-tsunami9 小时前
内存管理&模板
c++·算法·内存管理·模板