参数传递方式:才不是"传个参数而已"
数据以什么方式传进函数、结果以什么方式传回来,直接决定了程序的正确性和性能。您可能觉得"传个参数而已,有什么好讲的",但恰恰是这些看似不起眼的细节,在实际项目里制造了大量的 bug 和性能问题——拷贝了一个不该拷贝的大对象导致性能暴跌,或者不小心通过引用改掉了调用者的数据导致难以追踪的逻辑错误。
C++ 的三种核心参数传递方式:值传递、引用传递、const 引用传递。这里,我们就开始我们对他们的认识之旅。
值传递:函数拿到的是副本
值传递是最直观的传参方式:调用函数时,实参被复制一份。
调用函数时,实参被复制一份。调用函数时,实参被复制一份。调用函数时,实参被复制一份
笔者喜欢在这个地方上再三的强调。搞C的朋友会会心一笑。原来C++也有这个问题啊。是的。
我们的函数体操作的是这个副本,咱们传进去的原变量完全不受影响。
#include <iostream>
void add_ten(int x)
{
x += 10;
std::cout << "函数内 x = " << x << std::endl;
}
int main()
{
int value = 5;
add_ten(value);
std::cout << "函数外 value = " << value << std::endl;
return 0;
}运行结果:
函数内 x = 15
函数外 value = 5value 还是 5,一点没变——add_ten 的参数 x 是 value 的一份拷贝,改的是副本,原始变量毫发无损。这种隔离性在很多时候正是咱们想要的:函数内部的修改不会泄漏到外部。
但值传递的代价也很明显——每次调用都要拷贝。对于 int、double 这种只有几个字节的基本类型,拷贝开销可以忽略不计。可如果咱们要传的参数是一个包含数万个元素的结构体呢?
struct SensorData {
int readings[10000];
double timestamps[10000];
char description[256];
};
void process(SensorData data) // 整个结构体被拷贝一份
{
// 处理数据...
}每次调用 process,编译器都要把 SensorData 的大约 80 KB 数据完整复制一遍。咱们要是在循环里频繁调用,那就是灾难级的无意义拷贝。而且,很有可能根本达到的不是你想要的效果。非原生对象的值传递,很少见。
但是别滥用哦~,有人说我int也搞个引用,double也搞个引用。。。我可以告诉你,你这样做反而必然导致性能退化(在不开优化下),记住,自定义类型,我们才会建议考虑引用。原生类型,放心大胆的值传递,除非你确实希望你的入参被作用后还能带出这个函数。我们把这个叫做入参。
引用传递:直接操作原始数据
引用传递的思路是:不拷贝,直接让函数拿到调用者的原始变量。咱们在参数类型后加 &,就声明了一个引用参数。
void add_ten(int& x)
{
x += 10;
}
int main()
{
int value = 5;
add_ten(value);
// value 现在是 15
return 0;
}这回 value 变成了 15,咱们改的就是原件:x 是 value 的引用,value 本身的另一个名字。
引用传递最经典的应用场景是 swap 函数。在 C 语言里必须传指针,C++ 有了引用,咱们写起来干净多了:
/// @brief 交换两个整数的值
/// @param a 第一个整数
/// @param b 第二个整数
void swap_values(int& a, int& b)
{
int temp = a;
a = b;
b = temp;
}
int main()
{
int x = 3;
int y = 7;
swap_values(x, y);
std::cout << "x = " << x << ", y = " << y << std::endl;
return 0;
}运行结果:
x = 7, y = 3交换成功。调用语法也非常自然,咱们不需要像 C 那样取地址传指针。
但引用传递也带来了新的约束和陷阱。
非 const 引用参数只能绑定到左值——也就是有名字、有地址的变量。字面量、临时值(右值)都不能传给非 const 引用。比如当前的 add_ten(int&) 若被 add_ten(5) 调用会编译报错,因为 5 是个字面量,没有内存地址可供引用绑定。同理,swap_values(x, 3) 也编译不过:数字字面量 3 没法被改写,编译器直接拦下。咱们要是看到类似 cannot bind non-const lvalue reference to an rvalue 的编译错误,多半就是这个问题。
const 引用传递:两全其美
咱们面临的选择是:值传递安全但有拷贝开销,引用传递高效但能修改原始数据。有没有一种方式既不拷贝又不允许修改?有,就是 const 引用:
void print(const std::string& s)
{
std::cout << s << std::endl;
// s += "!"; // 编译错误,const 引用不允许修改
}const std::string& s 拆开看:& 表示引用,不发生拷贝;const 表示只读,函数内部不能通过 s 修改原始字符串。您在调用端看到 const,就知道"这个函数不会动我的数据",意图非常清晰。
const 引用还有一个咱们马上要用到的特性:它能绑定到右值。前面非 const 引用不肯收的字面量,const 引用都能收:
void print(const std::string& s);
print(std::string("hello")); // OK:const 引用绑定到临时对象
print("world"); // OK:const 引用绑定到隐式构造的临时 string这让 const T& 成为极其灵活的参数类型:左值右值都能收,又避免拷贝、保证只读。咱们后面会反复用到它。
咱们回头看前面那个拷贝大结构体的例子,用 const 引用改写:
void process(const SensorData& data) // 零拷贝,只读访问
{
// 处理数据...
}拷贝开销消失了,data 在函数内部还是只读的,不会意外改掉咱们传进去的数据——前面说的"两全其美",落在这儿了。
怎么选:传参方式的决策指南
三种传参方式各有适用场景,咱们把决策规则整理清楚。对于基本类型(int、double、float、指针等,通常不超过 8 字节),直接用值传递。这些类型拷贝成本极低,值传递既安全又简单,而且对编译器优化更友好。咱们要是看到有人写 void foo(const int& x),那大概率是过度优化——传一个 int 的引用并不比传 int 本身快,在某些平台上反而更慢(引用本质上是指针实现的,需要一次额外的间接寻址)。
对于比较大或者比较复杂的类型(std::string、std::vector、自定义结构体等),咱们只需要读取数据而不修改,用 const T&;要修改调用者的数据(比如 swap、填充输出结构体),用非 const 引用 T&。
咱们把决策规则总结成一张表:
| 参数类型 | 不修改 | 需要修改 |
|---|---|---|
| 基本类型 | T(值传递) | T(值传递后返回) |
| 非平凡类型 | const T& | T& |
这条规则在绝大多数情况下都适用。等您学到移动语义和完美转发之后,会知道还有更精细的传参策略(比如按值传递 + move),但在当前阶段,上面这张表就足够指导日常编码了。
返回值:怎么把结果交还给调用者
函数的返回值同样涉及传递方式的选择。大多数情况下,咱们直接按值返回就对了:
std::string greet(const std::string& name)
{
return "Hello, " + name + "!";
}您可能会担心:返回一个 std::string 不会发生拷贝吗?实际上,现代 C++ 编译器会做两种关键优化:RVO(Return Value Optimization)和 NRVO(Named Return Value Optimization)。简单来说,编译器会直接在调用者预留的内存空间里构造返回值,省去中间的拷贝或移动操作。从 C++17 开始,RVO 在某些情况下甚至是强制保证的。所以 return "Hello, " + name + "!"; 并不会产生额外的字符串拷贝,性能方面完全不用担心。
但您要是试图返回局部变量的引用,事情就危险了:
const int& get_value()
{
int x = 42;
return x; // 返回局部变量的引用——悬垂引用!
}这段代码能编译通过,但运行时是未定义行为(Undefined Behavior)。x 是函数内的局部变量,函数返回后 x 的内存就被回收了,咱们返回的引用指向一块已经不存在的内存。通过这个引用去读取数据,可能读到垃圾值,可能读到"碰巧还在"的旧值,也可能直接段错误。编译器不会报错(语法上完全合法,虽然可能会警告),所以这个 bug 非常隐蔽。原则很简单:永远不要返回局部变量的引用或指针。按值返回就好了,编译器会帮咱们优化。
输出参数 vs 返回值
当函数需要产生多个结果时,老式 C 代码常用引用参数来"输出",但调用端 divide(a, b, q, r) 咱们不看签名根本分不清输入输出。现代 C++ 更推荐用结构体直接返回:
struct DivResult {
int quotient;
int remainder;
};
DivResult divide(int a, int b)
{
return {a / b, a % b};
}调用端 auto result = divide(a, b); 一目了然,result.quotient 比 result.first 可读性强太多了。输出参数在往大缓冲区填充数据的场景下仍有意义,但大多数时候咱们优先选择返回值。
实战演练——passing.cpp
咱们把这一章的知识点串成一个完整的示例程序:演示 swap 操作、不同传参方式的性能对比,以及 const 引用在字符串处理中的用法。
展开代码收起代码共 95 行
// passing.cpp —— 演示值传递、引用传递和 const 引用传递
#include <iostream>
#include <string>
#include <chrono>
/// @brief 交换两个整数的值
void swap_values(int& a, int& b)
{
int temp = a;
a = b;
b = temp;
}
struct BigData {
int payload[4096]; // 16 KB
};
/// @brief 值传递版本:每次调用拷贝整个 BigData
long sum_by_value(BigData data)
{
long total = 0;
for (int i = 0; i < 4096; ++i) {
total += data.payload[i];
}
return total;
}
/// @brief const 引用版本:零拷贝
long sum_by_const_ref(const BigData& data)
{
long total = 0;
for (int i = 0; i < 4096; ++i) {
total += data.payload[i];
}
return total;
}
/// @brief 拼接问候语,const 引用避免字符串拷贝
std::string build_greeting(const std::string& name)
{
return "Hello, " + name + "! Welcome to Modern C++.";
}
int main()
{
// swap 演示
int a = 10;
int b = 20;
std::cout << "交换前: a = " << a << ", b = " << b << std::endl;
swap_values(a, b);
std::cout << "交换后: a = " << a << ", b = " << b << std::endl;
// 性能对比
BigData data{};
for (int i = 0; i < 4096; ++i) {
data.payload[i] = i;
}
constexpr int kIterations = 100000;
auto start = std::chrono::high_resolution_clock::now();
long result_value = 0;
for (int i = 0; i < kIterations; ++i) {
result_value = sum_by_value(data);
}
auto end = std::chrono::high_resolution_clock::now();
auto ms_value = std::chrono::duration_cast<std::chrono::milliseconds>(
end - start)
.count();
start = std::chrono::high_resolution_clock::now();
long result_ref = 0;
for (int i = 0; i < kIterations; ++i) {
result_ref = sum_by_const_ref(data);
}
end = std::chrono::high_resolution_clock::now();
auto ms_ref = std::chrono::duration_cast<std::chrono::milliseconds>(
end - start)
.count();
std::cout << "\n--- 性能对比 (" << kIterations << " 次调用) ---"
<< std::endl;
std::cout << "值传递: " << result_value
<< ", 耗时: " << ms_value << " ms" << std::endl;
std::cout << "const引用: " << result_ref
<< ", 耗时: " << ms_ref << " ms" << std::endl;
// 字符串处理
std::string name = "Charlie";
std::cout << build_greeting(name) << std::endl;
std::cout << build_greeting(std::string("World")) << std::endl;
return 0;
}编译运行:
g++ -std=c++17 -O2 -Wall -Wextra -o passing passing.cpp
./passing预期输出:
交换前: a = 10, b = 20
交换后: a = 20, b = 10
--- 性能对比 (100000 次调用) ---
值传递: 8386560, 耗时: 680 ms
const引用: 8386560, 耗时: 190 ms
Hello, Charlie! Welcome to Modern C++.
Hello, World! Welcome to Modern C++.性能数据会因机器和编译优化级别不同而有差异,但趋势一致:值传递每次拷贝 16 KB,const 引用版本避开了拷贝,快了数倍。注意咱们用了 -O2,即使如此编译器也必须遵守语言语义:您让它拷贝它就得拷贝。
build_greeting 的两次调用也值得咱们注意:第一次传左值 name,第二次传临时对象 std::string("World"),两者都能通过 const std::string& 接收,这正是 const 引用的灵活性所在。
在线运行
您也可以在线运行参数传递对比示例,观察值传递与 const 引用传递的性能差异:
Compiler Explorer
参数传递方式对比:值传递 vs const 引用
在线运行并对比值传递拷贝 16KB 结构体与 const 引用零拷贝的性能差异。
动手试试
练习一:实现 swap
请您写一个 swap_values 函数交换两个 double 的值,再写一个重载版本交换两个 std::string。用 main 函数验证结果。
参考答案
展开代码收起代码共 39 行
#include <iostream>
#include <string>
void swap_values(double &first, double &second)
{
double temporary = first;
first = second;
second = temporary;
}
void swap_values(std::string &first, std::string &second)
{
std::string temporary = first;
first = second;
second = temporary;
}
int main()
{
double first_number = 3.14;
double second_number = 2.71;
std::cout << "交换 double 前:" << first_number << ", "
<< second_number << '\n';
swap_values(first_number, second_number);
std::cout << "交换 double 后:" << first_number << ", "
<< second_number << '\n';
std::string first_text = "hello";
std::string second_text = "world";
std::cout << "交换字符串前:" << first_text << ", "
<< second_text << '\n';
swap_values(first_text, second_text);
std::cout << "交换字符串后:" << first_text << ", "
<< second_text << '\n';
return 0;
}编译运行:
g++ -std=c++17 -Wall -Wextra main.cpp -o main &&./main运行结果:
交换 double 前:3.14, 2.71
交换 double 后:2.71, 3.14
交换字符串前:hello, world
交换字符串后:world, hello练习二:高效处理大型结构体
请您定义一个包含至少 1000 个 double 元素数组的结构体 Measurement,再写两个函数:一个用值传递计算平均值,一个用 const 引用传递计算平均值。分别计时比较性能。
参考答案
展开代码收起代码共 86 行
#include <chrono>
#include <iostream>
#define kValueCount 1000
#define kIterations 100000
struct Measurement
{
double values[kValueCount];
};
double average_by_value(Measurement measurement);
double average_by_const_ref(const Measurement &measurement);
int main()
{
Measurement measurement{};
for (int i = 0; i < kValueCount; ++i)
{
measurement.values[i] = static_cast<double>(i + 1);
}
double value_average = 0.0;
const auto value_start = std::chrono::steady_clock::now();
for (int i = 0; i < kIterations; ++i)
{
value_average = average_by_value(measurement);
}
const auto value_end = std::chrono::steady_clock::now();
const auto value_time =
std::chrono::duration_cast<std::chrono::microseconds>(
value_end - value_start)
.count();
double const_ref_average = 0.0;
const auto const_ref_start = std::chrono::steady_clock::now();
for (int i = 0; i < kIterations; ++i)
{
const_ref_average = average_by_const_ref(measurement);
}
const auto const_ref_end = std::chrono::steady_clock::now();
const auto const_ref_time =
std::chrono::duration_cast<std::chrono::microseconds>(
const_ref_end - const_ref_start)
.count();
const double expected_average =
(1.0 + static_cast<double>(kValueCount)) / 2.0;
std::cout << "Measurement 大小: " << sizeof(Measurement) << " 字节\n";
std::cout << "值传递平均值: " << value_average
<< "(应为 " << expected_average << ")\n";
std::cout << "const 引用平均值: " << const_ref_average
<< "(应为 " << expected_average << ")\n";
std::cout << "值传递耗时: " << value_time << " 微秒\n";
std::cout << "const 引用耗时: " << const_ref_time << " 微秒\n";
if (const_ref_time > 0)
{
std::cout << "耗时比值(值传递 / const 引用): "
<< static_cast<double>(value_time) /
static_cast<double>(const_ref_time)
<< "x\n";
}
return 0;
}
double average_by_value(Measurement measurement)
{
double sum = 0.0;
for (double value : measurement.values)
{
sum += value;
}
return sum / static_cast<double>(kValueCount);
}
double average_by_const_ref(const Measurement &measurement)
{
double sum = 0.0;
for (double value : measurement.values)
{
sum += value;
}
return sum / static_cast<double>(kValueCount);
}编译运行:
g++ -std=c++17 -O2 -Wall -Wextra main.cpp -o main && ./main运行结果:
Measurement 大小: 8000 字节
值传递平均值: 500.5(应为 500.5)
const 引用平均值: 500.5(应为 500.5)
值传递耗时: 264553 微秒
const 引用耗时: 253918 微秒
耗时比值(值传递 / const 引用): 1.04188x在这个基准里,值传递形参每次调用都要复制完整的 Measurement(1000 个 double);const Measurement& 只传引用,不复制对象。示例中的耗时只是一次代表性运行,具体数值会受编译器、硬件和系统调度影响;咱们重复运行时应关注整体趋势,而不是某个固定数字。
练习三:修复悬垂引用
下面这段代码有什么问题?请您找到 bug 并修复它。
const std::string& get_prefix()
{
std::string prefix = "user_";
return prefix;
}
int main()
{
std::string name = get_prefix() + "admin";
std::cout << name << std::endl;
return 0;
}提示:您想想函数返回后局部变量 prefix 会发生什么。
参考答案
#include <iostream>
#include <string>
std::string get_prefix()
{
std::string prefix = "user_";
return prefix;
}
int main()
{
std::string name = get_prefix() + "admin";
std::cout << name << std::endl;
return 0;
}编译运行:
g++ -std=c++17 -Wall -Wextra main.cpp -o main &&./main运行结果:
user_admin原代码中的 prefix 是普通局部变量,只在 get_prefix 函数执行期间存在。函数返回时它的生命周期结束,咱们拿到的 const std::string& 指向已经销毁的对象,后续通过这个引用读取数据就是未定义行为。
修复方式是让 get_prefix 按值返回。prefix 仍是普通局部变量,只在函数执行期间存在;return prefix 会构造并返回一个独立的 std::string 对象,调用者接收的是这个返回值,而不是局部变量的引用,因此函数返回后不会产生悬垂引用。同时把返回类型上的 const 去掉,防止编译器没做 NRVO 时阻碍咱们移动它。移动构造的签名通常是 std::string(std::string&&)。