作为 C / C++ 的初学者,我最近学习到了宏定义函数的特性。为什么用它,无非两个原因:

不过,宏函数函数的痛点也在于其简单粗暴的替换与展开,若是不加检查,潜在的致命错误在所难免。我也是写了一些看似人畜无害的宏函数却死活发现不了问题之后才写下这篇文章。

比如下面这两个函数实现:

#define sqr(x) (x * x)
#define abs(x) ((x > 0) ? x : -x)

嗯,我知道宏定义只是简单的展开,如果不加括号会有运算优先级问题,所以我在整个表达式外加上了括号。sqr(3)abs(2) 这样简单的调用,好像都没有问题。但是调用 sqr(3+1)abs(3+1) 可就露出马脚了。原样展开意味着它不像调用普通函数一样先将表达式求值后再作为参数传入,而是将参数原模原样地用表达式替换。上面的表达式经过宏展开后事实上就变成了 (3 + 1 * 3 + 1)((3 + 1 > 0) ? 3 + 1 : -3 + 1),明显的运算优先级问题。对于初学者来说,不是这背后错误的原因难以理解,而是想不到问题竟然出在这么简单的“基础设施”上。

解决上述问题也很简单,两种方法均可:

当然这还没完,还有一种无解的情况——参数是一个非纯函数(Impure function)。这是函数式编程中的概念,简单地来说就是对于相同的输入,每次调用的结果可能不同,或者它会对函数外部的变量做修改;换用 Web 开发中的概念来解释,就是说它是“非幂等”的。一个典型的例子是:

#include <stdio.h>
#define max(a, b) (((a) > (b)) ? (a) : (b))

int main() {
	int a = 11, b = 45;
	int c = max(a++, b++);
	printf("a=%d, b=%d, c=%d\n", a, b, c);
	return 0;
}

如果 max 就像我们预期的那样,是个单纯的返回两个值中较大的值的函数,结果显然应该是 a=12, b=46, c=45。然而宏展开后其实是这样的:

int c = (((a++) > (b++)) ? (a++) : (b++));

这就会导致 b++ 被执行两次(三目运算符中,不满足条件的分支是不会被执行的,因此第二个 a++ 不执行,这个和短路求值[^1]的特性是相似的),于是输出 a=12, b=47, c=46

为了避免这样不起眼的错误,避免写宏定义函数自然是最好的。宏定义函数的两个优点,都可以通过其他方法实现:

  1. 内联:现代的编译器已足够聪明,会自动使用内联优化。如果要手动指定,我们可以通过给函数定义加上 static inline 关键字(在 C++ 中,只需要 inline 即可)来建议编译器使用内联。为什么说是“建议”,因为即使加上该关键字,编译器也不一定会启用内联,比如说对于一个复杂的函数。
  2. 泛型:在 C++ 中,通过模板元编程的特性即可实现泛型。以下是我稍微了解了一下这个特性后实现的 max 函数:
    
    template <typename T>
    inline T max(T a, T b) {
    return ((a > b) ? a : b);
    }
    

至于 C 如何实现泛型... 我不知道,如果类型太多一定要用泛型的话,要不还是写宏定义函数吧,只不过得格外小心,像使用 Go / Rust 的 Unsafe 那样。


[^1]: 短路求值(Short-circuit evaluation)指的是对于 a && b 这样的逻辑与(AND)表达式,总是从左向右计算,如果 a 的值是 false,即该表达式的结果已经确定,那么 b 的值将不会被计算。逻辑或(OR)表达式同理。大部分编程语言都有这样的特性。