在C++里,结构体是一种非常常用的自定义数据类型,用来把几个相关的变量打包在一起。当我们把结构体传给函数时,写法上有两种典型选择:一种是直接把结构体变量本身作为参数,另一种是使用引用(在类型后面加&符号)。这两种写法看起来只是差一个符号,实际运行起来差别可能非常大,尤其在结构体成员较多、调用频繁的场景下,直接影响程序的性能和正确性。这篇文章就来把这个问题彻底讲清楚。

值传递和引用传递的底层区别
先看最直观的写法对比。假设有一个表示学生信息的结构体:
struct Student {
std::string name; // 姓名
int age; // 年龄
double scores[10]; // 成绩数组
};
// 值传递:函数收到的是实参的完整拷贝
void printByValue(Student s) {
std::cout << s.name << " " << s.age << std::endl;
}
// 引用传递:函数收到的是实参的别名,不发生拷贝
void printByRef(const Student& s) {
std::cout << s.name << " " << s.age << std::endl;
}值传递时,编译器会在栈上为形参开辟一块和实参同样大小的空间,然后把实参的每一个字节都复制过去。对于上面这个结构体,一个std::string加上一个double数组,拷贝的成本并不低。如果这个函数在一个循环里被调用一万次,就有一万次完整的结构体拷贝,这些拷贝对程序逻辑没有任何帮助,纯粹是浪费。
引用传递则完全不同。引用在底层通常被实现为一个指针,函数拿到的是实参的地址,访问成员时通过地址间接寻址。所以无论结构体有多大,传递的成本基本固定,就是传一个地址的开销。上面的const Student&写法还明确告诉编译器和阅读代码的人:这个函数只读取数据,不会修改它。
可以简单总结成一张对照表:
| 对比项 | 值传递 | 引用传递 |
|---|---|---|
| 是否发生拷贝 | 完整拷贝整个结构体 | 不拷贝,只传地址 |
| 能否修改实参 | 不能,修改的是副本 | 可以,除非加const |
| 大结构体性能 | 差,拷贝开销随体积增长 | 好,开销基本恒定 |
| 能否为空 | 必须传有效对象 | 必须绑定到有效对象 |
什么时候该用const引用
引用传递解决了性能问题,但也带来一个副作用:被调用函数拿到了实参本身的访问权,理论上可以修改它。如果函数只是读取数据,比如打印、查询、计算,就应该加上const修饰,把只读的意图固化在函数签名里。这样即使函数体内部误写了修改语句,编译器也会直接报错,把问题拦在编译阶段。
// 只读场景,const引用是标准做法
double averageScore(const Student& s) {
double sum = 0;
for (int i = 0; i < 10; ++i) {
sum += s.scores[i];
}
return sum / 10;
}
// 需要修改实参时,用非const引用
void normalizeAge(Student& s, int base) {
s.age -= base; // 修改会直接作用在调用者的对象上
}有一个经验法则值得记住:对于基本类型(int、double、指针等),值传递反而更快,因为它们本身就只有一个机器字大小,拷贝比间接寻址还划算,没必要用引用。对于结构体和类对象,只要不是明确需要副本,默认写成const引用是更稳妥的习惯。很多成熟的C++代码库,比如标准库的算法接口,入参对象几乎清一色采用const引用,就是这个道理。
另外要注意,const引用还有一个特殊能力:它能绑定到临时对象。比如你可以直接传一个函数返回的结构体值给接受const引用的函数,临时对象的生命周期会被延长到引用作用域结束。而普通的非const引用不能绑定右值,直接传临时对象会编译报错。理解这一点对读懂别人的接口设计很有帮助。
引用传递和指针传递该怎么选
C++从C语言继承了指针,所以传结构体也可以用指针:void print(Student* s)。指针和引用都能避免拷贝,也都能修改实参,那两者怎么选?先看代码感受一下差别:
// 指针版本:可能传空指针,需要判空
void printByPtr(const Student* s) {
if (s == nullptr) return; // 防御性检查不可少
std::cout << s->name << std::endl;
}
// 引用版本:调用者保证传入有效对象
void printByRef(const Student& s) {
std::cout << s.name << std::endl; // 不需要判空
}两者的核心区别在于语义:引用必须绑定到一个已存在的有效对象,不存在空引用,所以函数内部不用判空;指针则可以为nullptr,也可以中途改变指向,灵活性更高但风险也更大。如果函数要求参数必须有效,用引用更合适;如果参数可以缺席(用空指针表示),或者需要在容器里存储、中途换指向,那就只能用指针。
现代C++的倾向很明显:在函数参数传递上优先用引用,尤其是const引用。指针更多保留给需要表达可空语义的场景,比如回调注册、可选配置项等。还有一个容易混淆的点是函数返回值,千万不要返回函数内部局部变量的引用。局部变量在函数结束时销毁,返回的引用就成了悬空引用,调用方通过它访问内存是未定义行为,程序可能崩溃也可能悄悄出错。需要返回新对象时直接按值返回,编译器的返回值优化和移动语义会让这个过程足够高效。
常见踩坑场景与排查思路
第一个坑是隐式拷贝。有时候函数签名明明可以改成const引用,但因为写代码时随手写成值传递,结构体又包含大容器(比如std::vector成员),一次调用就是一次深拷贝,程序整体性能莫名变慢。排查方法很简单,检查热点路径上所有接收结构体参数的函数签名,把不必要的值传递改成const引用,往往能立刻看到收益。
第二个坑是非const引用导致的意外修改。函数拿引用修改了结构体,而调用方以为传的是副本,两边预期不一致就会出诡异的bug。解决办法是在设计接口时就明确:需要修改就传非const引用并且写清楚注释,不需要修改一律加const。
Student loadFromFile(const std::string& path) {
Student s;
// 假设这里从文件读取数据填充s
return s; // 按值返回,配合移动语义,开销可控
}
void demo() {
Student st = loadFromFile("data.txt"); // 正确:接收返回值
// 错误示例:返回局部变量的引用会造成悬空引用
}第三个坑是临时对象与非常量引用的冲突。比如有一个接受Student&的函数,有人直接传一个临时构造的结构体过去,编译器会拒绝编译,报错信息还比较晦涩。遇到这种报错,先检查是不是把右值传给了非const引用,要么改成const引用,要么用一个具名变量接住临时对象再传。
把这几个规则记牢:只读传const引用,要改传非const引用,可空用指针,基本类型直接传值,返回局部对象绝不返回引用。掌握这些之后,结构体参数传递基本不会再成为你项目里的隐患点。