C++ 函数传参方式与适用场景
面试回答
常见问法
- C++ 常见函数传参方式有哪些?
- 值传递、引用传递、指针传递、右值引用分别适合什么场景?
- 为什么很多场景下
const T&是默认选择?现在还有没有例外? - 什么时候“按值传参 +
std::move”反而优于const T&? - 右值引用和转发引用有什么本质区别?
- 输出参数该不该用引用/指针?返回值和输出参数怎么选?
回答
C++ 常见函数传参方式主要有六类:
void f(T x); // 值传递
void f(T* p); // 指针传递
void f(T& x); // 左值引用
void f(const T& x); // 常量左值引用
void f(T&& x); // 右值引用
template<class T> void f(T&& x); // 转发引用(万能引用)
面试里不要只背“语法形式”,核心要讲清楚:传参方式本质上是在表达语义和成本,通常要同时考虑四件事:
- 是否需要复制一份对象
- 是否要修改调用方对象
- 是否允许为空
- 是否要接收临时对象 / 转移资源 / 保留值类别
一个实用决策顺序是:
- 小对象、便宜拷贝:优先值传递
比如
int、double、枚举、裸指针、轻量迭代器、小型句柄。 - 只读大对象:优先
const T&避免不必要拷贝,同时能绑定左值和右值。 - 需要修改调用方对象:用
T& - 参数可空,或想显式表达“可能没有对象”:用
T* - 函数要消费参数、接管资源:常见做法是按值传递或
T&& - 泛型包装、完美转发:才用转发引用
T&&
典型例子:
void print(const std::string& s); // 只读大对象
void reset(Buffer& buf); // 需要修改调用方对象
void maybe_use(Node* p); // 参数可空
void set_name(std::string name); // 内部要保存副本,按值常很合适
void sink(std::unique_ptr<int> p); // 消费所有权,按值非常自然
template<class T, class... Args>
std::shared_ptr<T> make_obj(Args&&... args) {
return std::make_shared<T>(std::forward<Args>(args)...);
}
一句话总结可以这么说:
传参方式不是语法偏好,而是在表达所有权、是否可改、是否可空、是否复制,以及对象移动成本。
追问
追问 1:为什么以前“大对象默认 const T&”?
因为在 C++11 之前,移动语义不完善,大对象按值传参通常意味着一次昂贵拷贝。
const T& 可以避免拷贝,还能绑定临时对象,所以长期被当成只读大对象的默认写法。
但现在要补一句: 有了移动语义以后,如果函数内部本来就要保存一份副本,按值传参可能更优雅。
追问 2:什么时候按值传参反而更好?
当函数无论如何都要持有一份副本时,按值传参通常更合适:
class Person {
public:
void set_name(std::string name) {
name_ = std::move(name);
}
private:
std::string name_;
};
原因:
- 传左值:调用方拷贝到形参一次,再 move 到成员
- 传右值:可以直接移动到形参,再 move 到成员
- 接口统一,避免写两套重载:
void set_name(const std::string& s); // 左值
void set_name(std::string&& s); // 右值
所以判断依据不是“对象大不大”这么简单,而是:
函数是否需要自己留一份副本。
追问 3:右值引用和转发引用为什么不能混为一谈?
因为两者虽然都写成 T&&,但语义完全不同。
- 普通右值引用:明确接收右值,通常表示“可被移动/消费”
- 转发引用:只在模板参数推导场景下成立,目的是保留实参原始值类别
看两个例子:
void sink(std::string&& s); // 右值引用
这里只能接右值,不能直接接普通左值。
template<class T>
void wrapper(T&& x) { // 转发引用
target(std::forward<T>(x));
}
这里 x 既可能绑定左值,也可能绑定右值。它不是“只接右值”,而是“根据传入实参决定”。
追问 4:const T& 一定比值传递快吗?
不一定。
常见反例:
- 小对象拷贝本来就便宜,按值更简单
- 函数要留副本,按值可能比
const T&+ 再拷贝更直接 - 编译器优化、移动构造、寄存器传参都会影响实际成本
所以不能机械地说“只要是大对象就全用 const T&”,更不能把它当成唯一标准答案。
追问 5:输出参数和返回值怎么选?
工程上通常优先:
- 单一结果:优先返回值
- 多个结果:可考虑结构体/
std::tuple - 需要复用外部缓冲区、避免重复分配:才考虑输出参数
例如:
std::string build_name(); // 更自然
bool parse(const std::string& s, Result& out); // out 参数也常见
高分回答要补一句:
输出参数不是不能用,但如果到处都用,会让接口语义变差,调用点不直观。
追问 6:unique_ptr 该怎么传?
这是面试非常爱问的点:
- 只观察对象,不接管所有权:传
T*或T& - 共享
unique_ptr本身,但不转移:传const std::unique_ptr<T>& - 要接管所有权:传
std::unique_ptr<T>(按值)或std::unique_ptr<T>&&
工程上更常见的说法是:
void use(const Widget& w); // 只读对象
void maybe_use(const Widget* w); // 对象可空
void take(std::unique_ptr<Widget> w); // 接管所有权
不要把“传不传智能指针”与“用不用智能指针管理生命周期”混为一谈。
追问 7:为什么“引用不能为 null,指针可以”很重要?
因为这不只是语法差异,而是接口契约差异。
- 用
T&:表示“这里必须有一个有效对象” - 用
T*:表示“这里允许没有对象,调用方要接受判空语义”
所以引用和指针很多时候不是“都能实现”,而是在表达不同设计意图。
原理展开
1. 值传递:语义最简单,但要关注复制/移动成本
值传递会为形参创建一个新对象,函数内部操作的是副本,不会直接影响调用方。
void add_one(int x) {
++x;
}
优点:
- 语义清晰,隔离副作用
- 对小对象通常最自然
- 便于函数内部自由修改形参
- 如果函数本来就要保存副本,按值很顺手
缺点:
- 对大对象可能有拷贝成本
- 如果类型不可拷贝,按值就无法使用
典型适用:
- 基本类型、小型 POD、轻量句柄
- 函数内部要持有一份副本
- 参数天然就是“输入值”,不是“外部对象身份”
示例:
class Person {
public:
void set_name(std::string name) {
name_ = std::move(name);
}
private:
std::string name_;
};
这个设计的关键点不是“std::string 能不能拷贝”,而是:
既然最终要存进成员,就让参数先作为局部拥有者,再 move 进去。
2. 指针传递:表达“可空”或“地址语义”
指针传参最重要的语义不是“能改值”,而是:
- 参数可能为空
- 接口天然面向地址/对象存在性
- 需要兼容 C 风格接口
- 有时用于多态或数组首地址传递
void visit(Node* node) {
if (!node) return;
// use *node
}
优点:
- 明确表达“可能没有对象”
- 与 C 接口兼容
- 可以重新指向别处,适合某些底层场景
缺点:
- 需要显式判空
- 语义可能不如引用直观
- 容易被滥用成“所有地方都传指针”
注意区分两件事:
void f(Node* p); // p 本身按值传递,函数得到的是指针副本
void g(Node*& p); // 引用的是指针本身,可修改调用方持有的指针
面试里如果能主动补这句,会显得你对“对象”和“指针变量本身”区分得很清楚。
3. 左值引用 T&:表达“我要操作调用方对象本身”
左值引用适合“函数要修改实参”的场景。
void normalize(std::vector<int>& nums) {
nums.push_back(0);
}
优点:
- 不拷贝对象
- 语义明确:直接操作原对象
- 比指针更适合表达“必有对象”
缺点:
- 不能绑定到普通右值
- 会把副作用暴露给调用方
适用场景:
- in-place 修改
- 交换、重置、填充、排序等原地操作
- 输出参数(虽然工程上应克制使用)
例如:
void sort_data(std::vector<int>& data);
void clear(Buffer& buf);
高分点:
- 如果函数会修改对象,优先
T&而不是裸指针,除非确实需要“可空” - 面试里要强调:
T&表达的是强约束接口契约
4. 常量左值引用 const T&:只读访问的传统默认方案
这是只读大对象最经典的参数形式:
void print(const std::vector<int>& nums) {
for (int x : nums) {
std::cout << x << ' ';
}
}
优势非常重要:
- 避免拷贝
- 不允许修改实参
- 既能绑定左值,也能绑定右值/临时对象
例如:
void log(const std::string& s);
std::string name = "Alice";
log(name); // 左值
log("temporary"); // 临时对象也能绑定
它长期流行的根本原因是: 兼顾性能与通用性。
但要注意两个边界:
边界 1:函数如果要存副本,const T& 可能不是最佳
void set_name(const std::string& s) {
name_ = s; // 迟早还得拷贝
}
这时很多情况下,按值更简洁:
void set_name(std::string s) {
name_ = std::move(s);
}
边界 2:对极小对象,const T& 可能反而没有必要
比如:
void f(const int& x); // 通常没必要
void f(int x); // 更自然
小对象直接按值更简单,也常更符合 ABI/寄存器传参习惯。
5. 右值引用 T&&:接收可移动对象,表达“消费”语义
右值引用适合接收右值,常见于:
- 移动构造/移动赋值
- 明确“我要接管资源”
- 某些重载中区分左值和右值
void set_buffer(std::string&& s) {
data_ = std::move(s);
}
几个关键点一定要说清:
关键点 1:形参名字一旦有了名字,它本身是左值
这是经典面试坑。
void f(std::string&& s) {
g(s); // s 是左值
g(std::move(s)); // 才转成右值
}
所以右值引用形参在函数内部通常要搭配 std::move。
关键点 2:不是所有“想优化性能”的地方都该写 T&&
很多业务函数写成这样反而复杂:
void set_name(const std::string& s);
void set_name(std::string&& s);
如果逻辑只是“保存一份”,通常按值更统一、更易维护。
关键点 3:T&& 更像“消费接口”
尤其是资源所有权转移时,语义会更明确。
void consume(Buffer&& buf);
但工程里如果类型本身就是 move-only,比如 std::unique_ptr<T>,按值传递也很自然:
void take(std::unique_ptr<Widget> p);
调用方必须 std::move,已经足够表达所有权转移。
6. 转发引用:泛型代码里的“保留值类别”
只有在模板类型推导中,T&& 才可能是转发引用:
template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg));
}
这里的核心不是“接右值”,而是:
如果调用方传左值,就继续按左值转发;传右值,就继续按右值转发。
示例:
void target(const std::string&);
void target(std::string&&);
template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg));
}
调用:
std::string s = "abc";
wrapper(s); // 转发为左值
wrapper(std::string("x")); // 转发为右值
这是 emplace_back、make_unique、工厂函数、包装器的核心技术。
转发引用成立条件
必须满足以下典型形式之一:
template<class T>
void f(T&& x); // T 发生推导,x 是转发引用
如果不是推导场景,就不是:
template<class T>
void f(std::vector<T>&& x); // 这里不是转发引用,是右值引用
因为 std::vector<T> 已经是确定结构,不是单纯的 T&& 形态。
7. 传参选择的工程原则:先看语义,再看性能
实际工程里,传参设计一般遵循这条顺序:
第一层:先表达接口语义
- 是否可空?
- 是否修改调用方?
- 是否接管资源?
- 是否需要副本?
第二层:再看性能成本
- 对象拷贝是否昂贵?
- 是否支持移动?
- 接口是否会带来额外重载复杂度?
第三层:考虑维护性
- 是否让调用代码一眼看懂?
- 是否容易误用?
- 是否引入过多模板/重载,增加复杂度?
这是面试里非常加分的一句:
C++ 传参选择不该只看“快不快”,而要优先保证语义正确,然后在语义正确的前提下做成本优化。
8. 典型代码模板:面试里最好能脱口而出
只读大对象
void print(const std::vector<int>& nums);
修改调用方对象
void normalize(std::vector<int>& nums);
可空对象
void visit(Node* node);
需要保存副本
void set_name(std::string name) {
name_ = std::move(name);
}
接管唯一所有权
void take(std::unique_ptr<Resource> r);
泛型完美转发
template<class F, class... Args>
decltype(auto) call(F&& f, Args&&... args) {
return std::forward<F>(f)(std::forward<Args>(args)...);
}
9. 一个实战型决策口诀
可以把传参理解成四个问题:
- 我需不需要副本?
- 我会不会改调用方?
- 参会不会为空?
- 我是不是要消费这个对象 / 保留值类别?
然后对应到常用选择:
- 不需要副本、只读:
const T& - 要修改原对象:
T& - 可空:
T* - 要留副本:
T - 要消费右值资源:
T或T&& - 模板转发:
T&& + std::forward<T>
对比总结
| 方式 | 形式 | 是否拷贝对象 | 是否可修改调用方对象 | 是否可空 | 能否接临时对象 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|---|---|---|---|---|
| 值传递 | T x | 通常会拷贝/移动 | 否 | 否 | 是 | 小对象、需要副本 | 语义简单、隔离副作用 | 大对象可能有成本 |
| 指针传递 | T* p | 只拷贝指针值 | 可通过 *p 修改对象 | 是 | 否(一般不直接传临时地址) | 可空语义、C 接口、地址操作 | 明确表达“可能没有” | 需要判空,易滥用 |
| 左值引用 | T& x | 否 | 是 | 否 | 否 | 原地修改对象 | 不拷贝、语义强 | 副作用显式暴露 |
| 常量左值引用 | const T& x | 否 | 否 | 否 | 是 | 只读大对象 | 高效、通用 | 若最终要拷贝,可能不是最优 |
| 右值引用 | T&& x | 否 | 可修改该对象并 move 其资源 | 否 | 是,主要就是接右值 | 消费对象、移动语义 | 明确表达资源可转移 | 易与转发引用混淆 |
| 转发引用 | template<class T> void f(T&& x) | 否 | 取决于转发目标 | 否 | 是 | 包装器、工厂、emplace | 保留值类别,泛型最灵活 | 规则复杂,容易写错 |
相近概念对比 1:const T& vs T
| 对比项 | const T& | T |
|---|---|---|
| 是否避免入参拷贝 | 是 | 否,通常会拷贝/移动 |
| 是否适合只读大对象 | 很适合 | 未必 |
| 是否适合内部要保存副本 | 不一定 | 常常更合适 |
| 是否适合小对象 | 可以,但不一定必要 | 通常更自然 |
| 接口复杂度 | 低 | 低 |
| 面试判断 | “只读且不留副本”优先考虑 | “反正要留副本”优先考虑 |
相近概念对比 2:T& vs T*
| 对比项 | T& | T* |
|---|---|---|
| 是否允许为空 | 否 | 是 |
| 是否表达“必有对象” | 是 | 否 |
| 调用写法 | 更自然 | 需要传地址 |
| 是否需要判空 | 不需要 | 通常需要 |
| 典型场景 | 原地修改 | 可选参数、树/链表节点、C API |
相近概念对比 3:T&& vs 转发引用
| 对比项 | 右值引用 T&& | 转发引用 T&& |
|---|---|---|
| 是否只接右值 | 是 | 不一定 |
| 是否要求模板推导 | 否 | 是 |
| 核心语义 | 消费/移动资源 | 保留值类别并转发 |
| 常配套操作 | std::move | std::forward<T> |
| 典型场景 | sink 接口、移动构造 | wrapper、factory、emplace |
相近概念对比 4:按值接收 unique_ptr vs unique_ptr&&
| 对比项 | std::unique_ptr<T> | std::unique_ptr<T>&& |
|---|---|---|
| 是否表达接管所有权 | 是 | 是 |
调用方是否需 std::move | 是 | 是 |
| 接口是否更常见 | 更常见 | 较少 |
| 是否利于统一设计 | 是 | 一般 |
| 工程上建议 | 优先按值 | 特殊场景再考虑 |
易错点
- 把所有大对象入参机械地写成
const T&,不考虑函数是否本来就要留副本 - 把“右值引用”和“转发引用”说成同一个东西
- 忘了有名字的右值引用形参本身是左值
- 需要接管资源时,写出过度复杂的左值/右值双重载,而不是考虑按值接收
- 用
T*表示“必定存在的对象”,导致接口契约变弱 - 用
const T&接收小对象,比如int、bool、枚举,反而显得别扭 - 到处使用输出参数,让函数调用点难读
- 把“是否用智能指针管理对象”与“函数参数该不该传智能指针本身”混为一谈
- 在模板里用了
T&&却忘记std::forward<T>(x),导致值类别丢失 - 误以为“引用不能重新绑定,所以总比指针高级”,实际上二者表达的语义不同,不存在简单替代关系
记忆技巧
-
先记一句总原则: 传参方式 = 语义表达 + 成本控制
-
四问法快速判断:
- 要不要副本?
- 要不要改原对象?
- 能不能为空?
- 要不要消费资源/保留值类别?
-
口诀版:
- 小对象:值传递
- 只读大对象:
const T& - 要修改:
T& - 可空:
T* - 要接管:
T或T&& - 泛型转发:转发引用
-
再补一条面试高频记忆点: “如果函数最终一定要留一份副本,按值传参往往比
const T&更自然。” -
区分
std::move和std::forward:move:我明确要把它当右值forward:我想保留它原来的值类别
面试速答版
C++ 常见传参方式有值传递、指针、左值引用、const 左值引用、右值引用和模板里的转发引用。选择时我一般先看语义,再看性能。
如果是小对象,比如 int、指针、轻量句柄,通常直接按值传递。
如果是只读大对象,一般用 const T&,避免拷贝,还能接左值和右值。
如果函数要修改调用方对象,用 T&。
如果参数可能为空,或者要表达“可能没有对象”,用 T*。
如果函数要消费对象、接管资源,常用 按值或者 T&&。
如果是模板包装器、工厂函数这类泛型转发场景,才用 转发引用 T&& + std::forward。
所以本质上不是记语法,而是看:要不要副本、会不会修改、能不能为空、要不要转移资源。
面试加分版
我一般把 C++ 传参方式理解为一种接口语义设计,不只是性能问题。核心要表达四件事:是否复制、是否修改调用方、是否允许为空、是否涉及资源转移或值类别保留。
最常见的有六种:T、T*、T&、const T&、T&& 和模板里的转发引用。
其中:
- 值传递
T适合小对象,或者函数内部本来就要保存一份副本的场景。比如set_name(std::string name),这样左值传进来拷贝一次,右值传进来可以高效移动,接口也比写左右值两套重载更简洁。 const T&适合只读大对象,这是传统默认写法,因为它避免拷贝,还能绑定临时对象。但现在不能机械使用,如果函数最终还是要拷贝保存,那按值可能更合理。T&表示我要修改调用方对象本身,适合原地修改、填充、排序、reset 这类接口。它比指针更能表达“这里必须有一个有效对象”。T*的核心语义是“可空”,不是单纯为了能修改。比如树节点、链表节点、可选对象、C 接口兼容场景,用指针就比较自然。T&&主要用于消费右值、移动资源。比如接管缓冲区、实现移动构造。但普通业务代码里,很多时候“按值接收再std::move”比专门写T&&重载更实用。- 转发引用 只在模板参数推导里成立,核心作用是保留实参值类别,典型场景是包装器、工厂函数、
emplace系列接口,要配合std::forward用,不能和普通右值引用混为一谈。
所以我的选择原则是:
先用参数形式把接口语义表达对,再在这个前提下考虑拷贝、移动和维护成本。
工程上最容易犯的错,就是把 const T& 当成万能默认答案,或者把 T&& 误用到所有想“优化性能”的场景里。真正好的回答应该讲清楚:是什么、为什么、怎么选、边界在哪。