当前位置: 首页 > news >正文

一文弄懂C/C++不定参数底层原理

目录

一、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;
}

底层执行逻辑:​

  1. main调用sum时,先压入 30(右数第一个参数),再压 20,再压 10,最后压固定参数 3;​
  1. sum中va_start(args, n):通过n的地址(ebp+8),让args指向第一个可变参数 10(ebp+12);​
  1. va_arg(args, int):每次读取args指向的 4 字节(int),然后args += 4,移动到下一个参数;​
  1. 循环结束后,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)底层原理:预处理阶段的文本替换​

可变宏的本质是 “文本替换”,不涉及栈帧或编译期解包,完全由预处理程序处理:​

  1. 预处理阶段(编译前),预处理程序扫描代码,找到LOG(...)的调用;​
  2. 将fmt替换为传入的格式字符串,__VA_ARGS__替换为可变参数;​
  3. 如果用了##,则自动处理逗号问题;​
  4. 最终生成普通的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)

通过一个具体调用案例( 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

http://www.xdnf.cn/news/1440289.html

相关文章:

  • Zygote 进程启动流程
  • 视频判重需求:别为同一内容花两次钱!
  • 涨了一倍多的顺丰同城,还能继续做大即时零售基建的蛋糕吗?
  • HTML5 标题标签、段落、换行和水平线
  • 光谱相机的探测器类型
  • 相机在两个机械臂上安装方式比较
  • 字节跳动后端 一面凉经
  • 单片机:GPIO、按键、中断、定时器、蜂鸣器
  • 知微传感Dkam系列3D相机SDK例程篇:CSharp连接相机及保存数据
  • Debezium日常分享系列之:Debezium 3.3.0.Alpha2发布
  • Gemini CLI源码解析:Agent与上下文管理实现细节
  • Airsim 笔记:Python API 总结
  • ESXI8多网卡链路聚合
  • 渗透测试中的常见误区与最佳实践
  • 【LeetCode 热题 100】72. 编辑距离——(解法一)记忆化搜索
  • DBSCAN 密度聚类分析算法
  • 【ProtoBuf 】C++ 网络通讯录开发实战:ProtoBuf 协议设计与 HTTP 服务实现
  • 构建下一代互联网:解码Web3、区块链、协议与云计算的协同演进
  • 【微信小程序预览文件】(PDF、DOC、DOCX、XLS、XLSX、PPT、PPTX)
  • 机器学习进阶,一文搞定模型选型!
  • 智能高效内存分配器测试报告
  • 根据fullcalendar实现企业微信的拖动式预约会议
  • Linux 用户的 Windows 改造之旅
  • Web端最强中继器表格元件库来了!55页高保真交互案例,Axure 9/10/11通用
  • 使用langgraph创建工作流系列3:增加记忆
  • 100种高级数据结构 (速查表)
  • 【NVIDIA B200】1.alltoall_perf 单机性能深度分析:基于 alltoall_perf 测试数据
  • 如何评价2025年数学建模国赛?
  • Debezium系列之:Flink SQL消费Debezium数据,只消费新增数据,过滤掉更新、删除数据
  • 计算机毕业设计选题推荐:基于Python+Django的新能源汽车数据分析系统