5、#与##运算符:字符串化与标记粘贴的魔法
说实话,C语言里让我觉得最像「黑魔法」的,就是 # 和 ## 这两个预处理运算符。刚学的时候我觉得它们可有可无,直到我在一个嵌入式日志系统项目里被代码量逼疯了,才真正体会到这两个运算符的威力。
今天我们就来彻底搞懂它们。我会结合我踩过的坑,把这两个运算符的用法、原理和实战场景讲清楚。
5.1 字符串化运算符 #
# 的作用很简单:把宏参数变成字符串字面量。说白了,就是给参数加个双引号。
看个最基础的例子:
#define STR(x) #x
printf("%s\n", STR(hello)); // 输出 "hello"
printf("%s\n", STR(123)); // 输出 "123"
你可能会问:这有什么用?我直接写字符串不就行了?
嗯,这里要注意——# 的真正价值在于:它能把宏参数的名字变成字符串,而不是参数的值。这在调试和日志场景下特别有用。
核心区别:
#x得到的是参数名字的字符串- 直接写字符串得到的是固定文本
5.1.1 我在项目中遇到的典型场景
我曾经维护过一个老旧的嵌入式项目,里面充斥着这样的代码:
printf("temp = %d\n", temp);
printf("pressure = %d\n", pressure);
printf("speed = %d\n", speed);
每个变量都要写两遍:一遍是名字的字符串,一遍是变量本身。如果变量名改了,还得同步改字符串,特别容易漏掉。
用 # 改造后:
#define PRINT_VAR(var) printf(#var " = %d\n", var)
PRINT_VAR(temp); // 输出 "temp = 23"
PRINT_VAR(pressure); // 输出 "pressure = 1013"
PRINT_VAR(speed); // 输出 "speed = 88"
你看,变量名只用写一次。宏展开时 #var 自动变成了字符串。我个人习惯把这种宏叫做「自描述打印宏」。
5.1.2 字符串化与拼接的细节
这里有个小坑:C语言会自动拼接相邻的字符串字面量。所以 #var " = %d\n" 会被编译器合并成一个字符串。这也是上面代码能工作的原因。
再来看一个更实用的例子——断言宏:
#define ASSERT(cond) \
do { \
if (!(cond)) { \
printf("ASSERT failed: %s, file %s, line %d\n", \
#cond, __FILE__, __LINE__); \
abort(); \
} \
} while(0)
ASSERT(ptr != NULL);
// 如果失败,输出:ASSERT failed: ptr != NULL, file main.c, line 42
避坑指南:我曾经在 # 后面跟了带空格的参数,结果字符串里也带了空格。比如 STR( hello ) 会变成 " hello "。所以建议宏参数不要加多余空格。
5.2 标记粘贴运算符 ##
## 的作用是把两个标记拼接成一个新的标记。它发生在预处理阶段,拼接后的结果必须是一个合法的C语言标记(变量名、函数名、类型名等)。
基础语法:
#define CONCAT(a, b) a ## b
int xy = 100;
printf("%d\n", CONCAT(x, y)); // 输出 100
宏展开后 CONCAT(x, y) 变成了 xy,也就是那个变量名。
5.2.1 为什么需要 ##?
你可能会想:我直接写 xy 不就行了?何必多此一举?
原因很简单:当名字需要动态生成时,## 就是唯一的选择。比如你要生成一系列结构相似的函数或变量名。
我记得有一次需要为多个外设驱动编写初始化函数:
#define INIT_DEVICE(name) \
void init_ ## name(void) { \
printf("Initializing %s device...\n", #name); \
/* 设备初始化代码 */ \
}
INIT_DEVICE(uart) // 生成 void init_uart(void)
INIT_DEVICE(spi) // 生成 void init_spi(void)
INIT_DEVICE(i2c) // 生成 void init_i2c(void)
你看,一个宏定义就生成了三个不同的函数。如果没有 ##,你得手动写三遍几乎一样的函数体。
5.3 实用案例:日志宏
把 # 和 ## 结合起来,就能做出非常强大的日志系统。这是我个人最常用的组合。
5.3.1 带级别的日志宏
#define LOG(level, fmt, ...) \
printf("[%s] " fmt "\n", #level, ##__VA_ARGS__)
LOG(INFO, "System started, version %d", 1);
LOG(WARN, "Temperature high: %d°C", temp);
LOG(ERROR, "Sensor %d failed", sensor_id);
输出效果:
[INFO] System started, version 1
[WARN] Temperature high: 85°C
[ERROR] Sensor 3 failed
这里有个关键点:##__VA_ARGS__ 中的 ## 是GCC扩展,作用是当可变参数为空时,删除前面的逗号。这样你写 LOG(INFO, "Hello") 也不会编译报错。
注意:标准C的可变参数宏不支持 ## 删除逗号。如果你的编译器不支持GCC扩展,可以用 __VA_OPT__(C23标准)或者手动处理。
5.3.2 带文件名和行号的日志
#define LOG_DEBUG(fmt, ...) \
printf("[DEBUG] %s:%d: " fmt "\n", \
__FILE__, __LINE__, ##__VA_ARGS__)
LOG_DEBUG("Entering function, param=%d", x);
// 输出:[DEBUG] main.c:42: Entering function, param=10
5.4 实用案例:通用数据结构
这是 ## 最硬核的应用——用宏实现泛型编程。C语言没有模板,但我们可以用宏+## 来模拟。
5.4.1 通用链表节点
#define DEFINE_LIST(type) \
typedef struct list_ ## type { \
type data; \
struct list_ ## type *next; \
} list_ ## type
DEFINE_LIST(int); // 生成 list_int 结构体
DEFINE_LIST(float); // 生成 list_float 结构体
DEFINE_LIST(char*); // 生成 list_char_ptr 结构体
展开后相当于:
typedef struct list_int {
int data;
struct list_int *next;
} list_int;
typedef struct list_float {
float data;
struct list_float *next;
} list_float;
// ... 以此类推
5.4.2 通用函数生成
#define DEFINE_PUSH_FUNC(type) \
void push_ ## type(list_ ## type **head, type value) { \
list_ ## type *node = malloc(sizeof(list_ ## type)); \
node->data = value; \
node->next = *head; \
*head = node; \
}
DEFINE_PUSH_FUNC(int); // 生成 push_int
DEFINE_PUSH_FUNC(float); // 生成 push_float
使用起来就像模板一样:
list_int *head = NULL;
push_int(&head, 42);
push_int(&head, 100);
list_float *fhead = NULL;
push_float(&fhead, 3.14f);
我的经验:这种写法在小型嵌入式项目中特别实用。但要注意,宏展开后的代码量会翻倍,如果类型太多,编译后的二进制体积会变大。我一般控制在5种类型以内。
5.5 知识体系总览
下面这张图总结了 # 和 ## 的核心用法和关系:
5.6 避坑指南与最佳实践
最后,分享几个我这些年积累的经验:
- 不要嵌套太深。宏嵌套超过3层,调试时根本看不清展开结果。我一般控制在2层以内。
- 多用括号。宏参数和整个表达式都要加括号,避免运算符优先级问题。这是老生常谈了,但每次都能遇到有人踩坑。
- 用
do { ... } while(0)包裹多语句宏。这样宏在使用时就像普通函数一样,不会因为分号或if语句而出问题。 - 调试时用
-E选项查看预处理结果。比如gcc -E test.c,能看到宏展开后的真实代码。我曾经靠这个找到了一个隐藏了两周的宏嵌套bug。 - 命名规范要统一。我习惯用全大写命名宏,这样一眼就能看出是宏而不是函数。
我曾经踩过的一个坑:在 ## 拼接时,如果拼接结果不是一个合法的C标记,编译器会报错。比如 CONCAT(123, abc) 会变成 123abc,这在C语言里不是合法的标识符(不能以数字开头)。所以一定要确保拼接结果符合C语言的命名规则。
好了,关于 # 和 ## 的内容就讲到这里。这两个运算符虽然看起来简单,但用好了能让你的代码简洁很多。下次写日志系统或者需要生成重复代码时,不妨试试它们。
公众号:蓝海资料掘金营,微信deep3321