目录
一、C语言的可变参数:基于栈帧的手动读取
(1)C函数调用的栈帧结构
(2)C 可变参数的 4 个核心宏:如何 “手动读栈”
(3)实战代码:用 C 可变参数实现求和函数
(4)C 可变参数的缺点:没有类型安全
二、可变宏函数
(1)核心语法:
(2)实战1:用可变宏实现日志打印
(3) 实战 2:处理 “无可变参数” 的情况
(4)底层原理:预处理阶段的文本替换
三、C++的可变参数模板:编译器的“自动解包”
(1)核心语法:...的妙用
在写日志的时候,以往都是直接用cout、printf等函数。但是每次都需要在前面加上线程ID、时间等信息,没有什么复用性,然后想自己写一个日志组件,方便日后开发。
但是在学习日志组件的时候,发现有一些前置知识以前可能只是了解过,并未真正弄懂他的细节。本篇文章就谈谈日志组件中最为重要的可变参数部分,将从C语言函数栈帧出发,理解C形式的不定参数原理,再讨论一下C++对其优化及使用。
一、C语言的可变参数:基于栈帧的手动读取
C 语言通过<stdarg.h>头文件提供的一套宏(va_list、va_start、va_arg、va_end)实现可变参数,其核心依赖函数栈帧的内存布局—— 因为 C 语言函数调用时,参数会按固定顺序压入栈中,我们可以通过栈指针 “手动” 读取这些参数。
(1)C函数调用的栈帧结构
在讲解可变参数前,必须先理解 “函数栈帧”—— 它是函数调用时在内存栈区分配的一块空间,用于存储参数、局部变量、返回地址等信息。
以 32 位系统(栈向下增长,即从高地址向低地址延伸)为例,看看下面代码的汇编代码,可以更好的理解函数栈帧。
int add(int a,int b)
{int c = a + b;return c;
}int main()
{int x = 10;int y = 20;int z = add(x,y);printf("%d",z);system("pause");return 0;
}
关键规则:
- 调用者(main)会从右到左将参数压入栈;
- 被调用者(add)通过栈底指针ebp访问参数:第一个可变参数在ebp+8(因为ebp本身占 4 字节,返回地址占 4 字节),后续参数依次在ebp+12、ebp+16...
(2)C 可变参数的 4 个核心宏:如何 “手动读栈”
在C语言中,想要使用可变参数一定要有一个确定的形参,因为要用这个形参来作为锚点,计算其他参数的偏移量。
int add(int a,int b,...)
{int c = a + b;return c;
}int main()
{int x = 10;int y = 20;int a = 30;int c = 50;int b = 40;int z = add(x,y,a,b,c);printf("%d",z);system("pause");return 0;
}
(3)实战代码:用 C 可变参数实现求和函数
当使用va_list的时候,会创建一个char* 类型的指针。然后调用va_start把确定的形参传入,这个函数的底层会根据其类型自动偏移到可变参数部分。
后续要想使用va_arg提取可变参数部分,需要明确每一个的类型,否则编译器会解析错误,可能访问到空白地方,引发程序未定义的错误。
#include <stdio.h>
#include <stdarg.h> // 必须包含的头文件// 功能:计算n个整数的和(n是固定参数,后面是可变参数)
int sum(int n, ...) { // ... 表示可变参数列表va_list args; // 1. 定义参数列表指针int total = 0;// 2. 初始化:让args指向第一个可变参数(绑定ebp和固定参数n)va_start(args, n);// 3. 遍历可变参数:循环n次,每次读一个intfor (int i = 0; i < n; i++) {// 从args中读一个int,然后指针移动到下一个参数(int占4字节,所以移动4)total += va_arg(args, int);}// 4. 释放参数指针va_end(args);return total;
}int main() {// 调用:计算10+20+30的和(n=3,后面3个可变参数)int result = sum(3, 10, 20, 30);printf("总和:%d\n", result); // 输出:总和:60return 0;
}
底层执行逻辑:
- main调用sum时,先压入 30(右数第一个参数),再压 20,再压 10,最后压固定参数 3;
- sum中va_start(args, n):通过n的地址(ebp+8),让args指向第一个可变参数 10(ebp+12);
- va_arg(args, int):每次读取args指向的 4 字节(int),然后args += 4,移动到下一个参数;
- 循环结束后,va_end(args)将args置空,避免后续误用。
(4)C 可变参数的缺点:没有类型安全
C 的可变参数完全依赖 “手动指定类型”,编译器不会检查参数类型是否匹配。比如下面的错误代码,编译器不会报错,但运行结果会出错:
// 错误:第二个可变参数是字符串,但用va_arg读成int
int wrong = sum(2, 10, "hello"); // 编译通过,但运行时会读取字符串的地址(4字节)当int用,结果混乱
二、可变宏函数
除了函数的可变参数,还支持宏替换形式的可变参数。他是在预处理阶段直接替换,而普通的可变参数是运行时手动根据栈帧解析,相比较来,宏函数的运行效率肯定较高,因为他没有手动解析这一层的开销。
(1)核心语法:
#define 宏名(固定参数, ...) 替换内容(__VA_ARGS__)
- ...:表示宏的可变参数(必须放在参数列表最后);
- __VA_ARGS__:在替换时,会被宏调用时传入的可变参数 “原样替换”。
- ##__VA_ARGS__是编译器对##的特殊处理,##本身是用于字符拼接的,但是在和__VA_ARGS__放到一起的时候,编译器只会对其前面的逗号做处理。
(2)实战1:用可变宏实现日志打印
#include <stdio.h>// 可变宏:LOG(格式字符串, ...) → 替换为printf(格式字符串, 可变参数)
#define LOG(fmt, ...) printf("[" fmt "]\n", __VA_ARGS__)int main() {int age = 20;char name[] = "张三";// 宏替换后:printf("[年龄:%d,姓名:%s]\n", 20, "张三");LOG("年龄:%d,姓名:%s", age, name); // 输出:[年龄:20,姓名:张三]return 0;
}
也就是说...用于接住宏中传入的任意多少个参数,然后__VA_ARGS__在预处理阶段直接把刚刚...接住的所有内容原封不动的拷贝到这里。
(3) 实战 2:处理 “无可变参数” 的情况
// 改进版:##__VA_ARGS__ 会自动删除前面的逗号(如果没有可变参数)
#define LOG(fmt, ...) printf("[" fmt "]\n", ##__VA_ARGS__)int main() {LOG("程序启动"); // 替换后:printf("[程序启动]\n");(无逗号,正常编译)LOG("数值:%d", 100); // 替换后:printf("[数值:%d]\n", 100);return 0;
}
(4)底层原理:预处理阶段的文本替换
可变宏的本质是 “文本替换”,不涉及栈帧或编译期解包,完全由预处理程序处理:
- 预处理阶段(编译前),预处理程序扫描代码,找到LOG(...)的调用;
- 将fmt替换为传入的格式字符串,__VA_ARGS__替换为可变参数;
- 如果用了##,则自动处理逗号问题;
- 最终生成普通的printf语句,再进入编译阶段。
缺点:
- 无类型检查:和 C 可变参数一样,宏替换是文本级别的,编译器不会检查参数类型;
- 调试困难:宏替换后代码会变化,调试时看到的是替换后的代码,不是原始宏调用。
三、C++的可变参数模板:编译器的“自动解包”
C++11 引入了 “可变参数模板”(Variadic Templates),解决了 C 可变参数的 “类型不安全” 问题。它的核心是编译期递归解包—— 编译器会根据传入的参数数量和类型,自动生成对应的函数实例,无需手动操作栈指针,减少了程序员手动解析的错误。
(1)核心语法:...的妙用
我们先直接看看代码:
1. 形式1: print_single ——单个固定参数(终止器)代码实现#include <iostream>
#include <string>// 函数名:print_single(明确表示“处理单个参数”)
// 参数形式:T arg → 只有1个固定参数,类型为T(任意类型)
template <typename T>
void print_single(T arg) {// 功能:打印最后一个参数,末尾加换行(标志递归结束)std::cout << "[最后一个参数] " << arg << std::endl;
}核心解析- 参数本质:没有任何“可变参数”,就是一个普通的单参数模板函数;
- 为什么需要它:参数包展开是“从多到少”的过程(比如3个参数→2个→1个),当参数包只剩1个参数时,没有更多参数可拆,需要这个函数“接住”最后一个参数,避免递归无限进行;
- 调用时机:仅在参数包中只剩1个参数时被调用,是递归的“终点”。2. 形式2: print_pack ——固定首参+可变参数包(拆解器)代码实现// 函数名:print_pack(明确表示“处理参数包”)
// 参数形式:T first(第一个固定参数) + Args... rest(剩余可变参数包)
template <typename T, typename... Args>
void print_pack(T first, Args... rest) {// 第一步:先处理当前拆出的“第一个固定参数”std::cout << "[拆解出的参数] " << first << " | 剩余参数个数:" << sizeof...(rest) << " → ";// 第二步:判断剩余参数包是否为空,决定下一步调用if constexpr (sizeof...(rest) == 0) {// 若剩余参数为空,直接调用终止器(但此时rest为空,不符合print_single的单参数要求,实际不会走这里)std::cerr << "错误:剩余参数为空,无法调用print_single" << std::endl;} else if constexpr (sizeof...(rest) == 1) {// 若剩余参数只剩1个,调用终止器处理最后一个参数print_single(rest...);} else {// 若剩余参数多于1个,继续调用自己(拆解器),传递剩余参数包print_pack(rest...);}
}核心解析- 参数本质:是“固定参数+可变参数包”的组合,核心是**“拆解”** ——每次从完整参数包中拆出第一个参数( first ),剩下的部分仍用可变参数包( rest )表示;
- 关键操作: sizeof...(rest) 是“参数包大小运算符”,用于获取剩余参数的个数(比如 rest 是 (2,3) 时, sizeof...(rest)=2 );
- 调用时机:仅在参数包中参数个数≥2时被调用,负责逐步拆解参数包,直到剩余1个参数时,转调终止器( print_single )。
...是C语言、宏函数中用于存放可变参数部分的容器,在C++中也不例外。不过对...操作符赋予了更多功能。你可以把...当做T来用,使用typename...的时候,就是在定义一个可变参数包模板类型。
当...位于参数包类型名后面时用于打包(如typename... Args定义一个Args类型,后续使用时Args...就用来打包)。
而...位于参数包变量的后面时候就用来解包。(如Args定义的形参变量arg)
总结一下:
(1)在C++中,只要在类型或者变量后面使用了...就表示要展开。当对类型展开的时候,表示用这个类型参数包创建一个值参数包,会有一个变量名,此时,你无论传多少个变量,都会被这个值参数包接收。使用的时候也要通过这个变量名才能展开。当对值参数包展开的时候,相当于把原来的值直接写出来,而不存放在值参数包中了。
(2)而在前面使用则表示定义(或者说对原本C语言的定义进行扩展),比如sizeof原本只能用于计算普通变量的大小,但是sizeof...却表示参数包中的个数。比如typename 只能用于定义普通的模板类型,而typename...则表示一个参数包类型。
通过一个具体调用案例( print_pack(10, "Hello", 3.14) ),一步步看两种函数如何分工协作,彻底理清调用逻辑。调用案例:处理3个参数(int, string, double)int main() {// 初始调用:传入3个参数,触发参数包拆解std::cout << "开始处理参数包:(10, \"Hello\", 3.14)\n";print_pack(10, "Hello", 3.14);return 0;
}
(1)第一步:第一次调用 print_pack (处理3个参数)
- 实际参数: first=10 (int类型), rest=(\"Hello\", 3.14) (剩余2个参数,类型为 (const char*, double) );
- 执行逻辑:
1. 打印: [拆解出的参数] 10 | 剩余参数个数:2 → ;
2. 因 sizeof...(rest)=2 (多于1个),继续调用 print_pack(rest...) ,即 print_pack("Hello", 3.14) 。
(2)第二步:第二次调用 print_pack (处理2个参数)
- 实际参数: first="Hello" (const char*类型), rest=(3.14) (剩余1个参数,类型为double);
- 执行逻辑:
1. 打印: [拆解出的参数] Hello | 剩余参数个数:1 → ;
2. 因 sizeof...(rest)=1 (只剩1个),转调 print_single(rest...) ,即 print_single(3.14) 。
(3)第三步:调用 print_single (处理最后1个参数)
- 实际参数: arg=3.14 (double类型);
- 执行逻辑:打印 [最后一个参数] 3.14 ,递归结束。
(4)最终输出结果(清晰展示调用流程)
开始处理参数包:(10, "Hello", 3.14)
[拆解出的参数] 10 | 剩余参数个数:2 → [拆解出的参数] Hello | 剩余参数个数:1 → [最后一个参数] 3.14