C++ 函数传参方式与适用场景
面试回答
常见问法
- C++ 常见函数传参方式有哪些?
- 值传递、引用传递、指针传递、右值引用分别怎么选?
- 为什么很多场景下
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); // 转发引用(需模板推导场景)
面试里我会先给一个判断框架:传参方式本质上是在表达语义,而不只是性能优化。 核心看四件事:
- 要不要拷贝/移动一份副本
- 要不要修改调用方对象
- 参数能不能为空
- 要不要接收并消费临时对象/资源
常见选择原则可以概括为:
- 小对象、标量、轻量句柄:优先值传递
- 只读大对象:优先
const T& - 要修改调用方对象:用
T& - 参数可能为空,或者要表达“可选对象”:用
T* - 要接管资源/区分左值右值语义:考虑
T&& - 泛型包装、完美转发:才用 转发引用
T&&+std::forward<T> - 函数内部本来就要保存一份副本:很多时候按值传参 + 内部
std::move更合适
典型例子:
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>
void wrapper(T&& x) { target(std::forward<T>(x)); } // 泛型转发
一个比较完整的面试结论可以这样说:
传参方式不是语法偏好,而是在表达对象所有权、可修改性、空值语义、值类别以及复制成本。工程上不能机械套模板,要结合对象大小、是否保存副本、是否允许为空、是否需要移动语义来选。
追问
- 为什么过去“大对象默认
const T&”几乎是共识? - 为什么现在很多 setter、构造函数更喜欢按值传参?
T&&什么时候是右值引用,什么时候是转发引用?- 输出参数和返回值应该怎么权衡?
shared_ptr/unique_ptr作为参数时应该怎么传?const T&能绑定临时对象,为什么还需要T&&?
原理展开
1. 值传递:语义最简单,但不等于总是慢
是什么
值传递会把实参构造成一个新的形参对象:
void f(int x) {
x += 1;
}
调用方和被调方互不影响,函数拿到的是一份独立副本。
为什么用
值传递最直接表达的是:我拿到一个自己的对象副本,后续怎么改都不影响调用方。
这类语义在以下场景非常自然:
- 参数本身就是小对象:
int、double、枚举、裸指针、迭代器等 - 函数内部本来就需要留存一份副本
- 希望接口更简洁,避免同时写左值/右值两套重载
什么时候适合
场景一:小对象
int add(int a, int b);
void set_flag(bool enabled);
void seek(size_t pos);
这类对象拷贝成本极低,值传递最简单。
场景二:函数要保存参数副本
class Person {
public:
void set_name(std::string name) {
name_ = std::move(name);
}
private:
std::string name_;
};
这里按值接收通常优于只写 const std::string&:
- 调用左值:先拷贝到
name,再 move 给成员 - 调用右值:直接移动到
name,再 move 给成员
这样接口统一,不需要同时写:
void set_name(const std::string& s);
void set_name(std::string&& s);
怎么选
可以记一个工程判断:
如果函数内部一定要持有一份副本,优先考虑按值传参。
常见误区
- 误以为“值传递一定最慢”。 不一定。对小对象基本无所谓;对可移动类型,如果本来就要拷贝一份,按值传参可能更简洁。
- 误以为“所有大对象都不能按值”。 也不对。要看函数内部是否最终会复制保存它。
2. const T&:只读大对象的经典默认方案
是什么
const T& 是常量左值引用,不复制对象,只读访问:
void print(const std::vector<int>& nums) {
for (int x : nums) {
std::cout << x << ' ';
}
}
为什么用
它解决了一个经典矛盾:
- 想避免大对象拷贝成本
- 又不想修改调用方对象
而且 const T& 既能绑定左值,也能绑定右值/临时对象:
void log(const std::string& s);
std::string name = "Tom";
log(name); // 左值
log("hello"); // 字面量转换后临时对象
log(std::string{}); // 右值
什么时候适合
- 大对象只读访问:
std::string、std::vector、大结构体 - 函数内部不需要保存副本
- 希望调用方既能传左值,也能传临时对象
为什么它曾经几乎是默认写法
因为在 C++11 之前,移动语义不完整或未广泛使用时,大对象按值传参通常意味着真实拷贝,代价更高。
所以“大对象输入参数默认 const T&”长期是最稳妥的经验法则。
即使到了今天,这条规则仍然成立于只读、不留存副本的场景。
怎么选
可以这样判断:
如果函数只是“读一下”,既不修改,也不保存,优先
const T&。
常见误区
- 把所有对象都写成
const T&。 这会错过“按值接收 + move”的简洁设计。 - 以为
const T&总比值传递快。 对小对象未必;对某些场景甚至会妨碍优化与接口表达。
3. T&:明确表达“我要改你的对象”
是什么
T& 是非常明确的可变引用,表示函数操作的是调用方对象本身:
void normalize(std::vector<int>& nums) {
nums.push_back(0);
}
为什么用
T& 表达的不是“避免拷贝”,而是:
这个参数是输入输出一体,函数会直接修改调用方传进来的对象。
什么时候适合
- 原地修改对象
- 交换、填充、重置、排序等操作
- 明确需要副作用,且参数不能为空
void sort_inplace(std::vector<int>& v);
void reset(Buffer& buf);
void parse(Header& out); // 不太推荐,见后文
怎么选
如果业务语义是“必须有一个对象,而且我要改它”,那引用比指针更清晰。
常见误区
-
把
T&当成“只是为了性能”。 真正更重要的是可修改语义。 -
滥用输出参数。 现代 C++ 更推荐返回值表达结果,除非:
- 对象特别大且复用缓冲区很重要
- 需要返回多个结果
- 接口与现有框架/底层 API 一致
例如:
// 更现代、更清晰
std::vector<int> parse();
// 输出参数风格,副作用更重
void parse(std::vector<int>& out);
4. 指针 T*:重点不是“传地址”,而是“可空”
是什么
指针传参本质上也是传地址,但和引用相比,最大的语义差别是:它可以为空。
void visit(Node* node) {
if (!node) return;
// ...
}
为什么用
使用指针通常是在表达以下含义之一:
- 参数可能不存在
- 需要兼容 C 接口
- 操作对象的地址本身更自然
- 可能需要改成指向别的对象(多级指针时更明显)
什么时候适合
- 可选参数 / nullable 语义
- 树、链表等天然以指针组织的数据结构
- C 风格接口互操作
- 显式表达“这是一个可能为空的观察者,不拥有对象”
void maybe_use(Node* p);
void c_api_adapter(const char* s);
怎么选
一个很好记的准则:
能保证非空时,优先引用;需要允许空值时,优先指针。
常见误区
- 用指针表达“必然存在的对象”。 这样会让调用者多出空指针心智负担。
- 用裸指针表达所有权。 在现代 C++ 里,裸指针更适合表达非拥有关系;拥有关系通常用智能指针表达。
5. 右值引用 T&&:不是“更快的引用”,而是“可被搬走的对象”
是什么
普通函数里的 T&& 是右值引用,通常绑定到临时对象或显式 std::move 后的对象:
void set_buffer(std::string&& s) {
data_ = std::move(s);
}
为什么用
右值引用主要用于表达:
- 这个参数是一个将亡值或临时对象
- 函数可能会转移其资源
- 需要对左值和右值做不同处理
什么时候适合
场景一:移动语义特别明确
class Socket {
public:
Socket(Socket&& other) noexcept;
Socket& operator=(Socket&& other) noexcept;
};
移动构造、移动赋值是 T&& 的典型使用场景。
场景二:需要区分左值/右值重载
void consume(const std::string& s); // 读
void consume(std::string&& s); // 可以偷资源
怎么选
面试里要强调一点:
右值引用的核心不是性能,而是资源转移语义。
如果只是单纯想“收一个参数并最终保存下来”,很多业务函数未必需要手写 T&& 重载,按值传参 + move 往往更省心。
常见误区
- 以为
T&&一定比按值更好。 不一定。很多 setter、构造函数里按值更简洁。 - 在函数体里忘记
std::move。 形参名字一旦有名字,就是左值:
void f(std::string&& s) {
// s 在表达式里是左值
data_ = std::move(s); // 才会触发移动
}
6. 转发引用:只在模板推导场景成立
是什么
只有在模板参数推导中,形如 T&& 才可能是转发引用(也叫万能引用):
template <typename T>
void wrapper(T&& arg) {
target(std::forward<T>(arg));
}
这里它不是单纯“只接右值”,而是会根据实参推导:
- 传左值时,
T推导成U& - 传右值时,
T推导成U
因此可以保留原始值类别。
为什么用
它解决的是泛型封装里的一个核心问题:
包装函数不应该破坏原实参的左值/右值属性。
什么时候适合
- 工厂函数
- 转发包装器
emplace风格接口- 泛型库设计
template<class T, class... Args>
std::unique_ptr<T> make_obj(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
怎么选
一个面试高频点:
不是看到
T&&就叫右值引用。
- 普通函数参数里的
T&&:右值引用- 模板推导上下文里的
T&&:转发引用const T&&不是转发引用
常见误区
- 把右值引用和转发引用混为一谈
- 转发时用
std::move,把左值也强行移动掉 - 不是泛型转发场景却滥用模板
T&&
7. 智能指针作为参数时怎么传
这是面试里很容易加分的一点。
std::unique_ptr<T>
它表达独占所有权。
- 函数要接管所有权:按值传
- 函数只读观察对象:传
T*/const T&/const T* - 函数要修改对象但不接管所有权:传
T&/T*
void sink(std::unique_ptr<Foo> p); // 接管所有权
void use(const Foo& x); // 只读对象本身
void mutate(Foo& x); // 改对象,不改所有权
一般不推荐写:
void use(const std::unique_ptr<Foo>& p);
除非你真的关心“调用方持有方式就是 unique_ptr 本身”。
大多数业务逻辑只关心 Foo,不关心它装在什么智能指针里。
std::shared_ptr<T>
它表达共享所有权。
- 函数要共享所有权,延长生命周期:按值传
shared_ptr<T> - 只是暂时使用对象,不需要共享所有权:传
T&/T*/const T&
void store(std::shared_ptr<Foo> p); // 持有一份 shared ownership
void inspect(const Foo& x); // 只读,不增加引用计数
高分点在于说出:
传
shared_ptr按值不是“为了方便”,而是在显式表达“我要共享所有权并增加引用计数”。
8. 输出参数 vs 返回值:现代 C++ 更偏向返回值
推荐原则
如果函数的主要目的就是“计算并产出结果”,优先考虑返回值:
std::string format_name(const User& user);
而不是:
void format_name(const User& user, std::string& out);
原因
- 可读性更好
- 更符合表达式风格
- 易组合、易链式调用
- 现代编译器有 RVO/NRVO、移动优化,返回值成本通常可接受
什么时候输出参数合理
- 需要返回多个结果
- 复用外部缓冲区,减少重复分配
- 调用非常高频,对性能路径极度敏感
- 与底层接口或旧框架风格保持一致
bool parse_header(std::string_view input, Header& out, Error& err);
对比总结
| 方式 | 写法 | 是否复制对象 | 是否可修改调用方 | 是否可为空 | 能否接收临时对象 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|---|---|---|---|---|
| 值传递 | T x | 通常会复制/移动 | 否 | 否 | 是 | 小对象;内部要保存副本;消耗所有权 | 语义简单,隔离副作用,接口统一 | 大对象可能有额外成本 |
| 指针传递 | T* p | 否 | 可以 | 是 | 不直接对应“临时对象”语义 | 可选对象、C 接口、树链结构 | 可表达空值;地址语义明确 | 需要判空;可读性不如引用 |
| 左值引用 | T& x | 否 | 是 | 否 | 否 | 原地修改、交换、重置 | 明确表达“修改实参” | 副作用明显,不适合只读输入 |
| 常量左值引用 | const T& x | 否 | 否 | 否 | 是 | 只读大对象 | 避免拷贝,可绑临时对象 | 不适合需要保存副本的场景 |
| 右值引用 | T&& x | 否 | 可修改形参对象并转移资源 | 否 | 是,主要接右值 | 移动语义、资源接管、区分左右值 | 明确表达“可被搬走” | 容易滥用;函数体里仍需 std::move |
| 转发引用 | template<class T> f(T&& x) | 否 | 取决于转发目标 | 否 | 是 | 泛型包装、工厂、emplace | 保留值类别,最灵活 | 语义复杂,误用成本高 |
相近概念区分:右值引用 vs 转发引用
| 项目 | 右值引用 | 转发引用 |
|---|---|---|
| 典型写法 | void f(T&& x) | template<class T> void f(T&& x) |
| 是否依赖模板推导 | 否 | 是 |
| 是否只接右值 | 基本是 | 否,左值右值都能接 |
| 典型用途 | 移动语义、资源接管 | 完美转发、泛型包装 |
| 转发方式 | 常用 std::move | 用 std::forward<T> |
| 面试关键点 | 表达“可被移动” | 表达“保留值类别” |
相近概念区分:const T& vs T
| 项目 | const T& | T |
|---|---|---|
| 是否复制 | 否 | 会复制/移动到形参 |
| 是否适合只读大对象 | 很适合 | 一般不优先 |
| 是否适合内部保存副本 | 不够直接 | 很合适 |
| 接口复杂度 | 低 | 也低 |
| 典型场景 | 只读输入参数 | 小对象;需要副本;sink 风格接口 |
相近概念区分:引用 vs 指针
| 项目 | 引用 | 指针 |
|---|---|---|
| 是否可为空 | 否 | 是 |
| 是否必须绑定对象 | 是 | 否 |
| 语义重点 | 必有对象 | 可能没有对象 |
| 可读性 | 更强 | 更接近底层地址语义 |
| 典型选择 | 必传且非空 | 可选参数、C 兼容 |
易错点
- 把“性能优化”当成唯一标准,忽略了语义表达。
- 机械地认为“大对象一律
const T&”。 - 本来就要保存副本,却仍写成
const T&,然后函数内部再拷贝一遍。 - 把右值引用和转发引用说成一回事。
- 在
T&&形参内部忘记std::move。 - 在转发场景里乱用
std::move,导致把左值也错误移动。 - 用指针表达“必定存在的对象”,让接口平白多出判空负担。
- 用
const std::unique_ptr<T>&/const std::shared_ptr<T>&传参,却其实只关心底层对象。 - 滥用输出参数,导致函数语义不清、调用体验差。
- 为了“炫技”写很多左值/右值重载,结果可维护性下降,收益不明显。
记忆技巧
- 值传递:我要一份自己的副本
const T&:我只读,不拷贝T&:我会改你的对象T*:这个参数可能没有T&&:这个对象可以被我搬走- 模板
T&&:我要保留你原来的左值/右值身份
再记一条实战决策线:
- 小对象:优先值传递
- 只读大对象:优先
const T& - 要改调用方:用
T& - 可能为空:用
T* - 要保存副本:优先
T - 要接管资源:考虑
T&&或按值 - 泛型转发:
T&& + std::forward<T>
一个很好背的总结句:
传参方式不是语法习惯,而是在表达复制成本、修改权限、空值语义和所有权转移语义。
面试速答版
C++ 常见传参方式有值传递、指针、左值引用、常量左值引用、右值引用和转发引用。 我的选择标准主要看四点:是否需要副本、是否修改实参、是否允许为空、是否涉及资源转移。
一般来说:
- 小对象用 值传递
- 只读大对象用
const T& - 需要修改调用方对象用
T& - 参数可能为空用
T* - 需要接管资源或区分左值右值时用
T&& - 泛型包装转发才用 转发引用
另外有个很实用的工程经验:如果函数内部本来就要保存一份副本,按值传参再 std::move 往往比 const T& 更合适。
所以传参方式本质上是在表达接口语义,而不是单纯追求“少一次拷贝”。
面试加分版
我一般把 C++ 传参方式理解成“接口在表达什么语义”,而不只是“哪种更快”。 主要看四个维度:要不要复制、要不要修改调用方、能不能为空、要不要转移资源。
第一类是值传递 T。它适合小对象,也适合函数内部本来就要保存副本的场景。比如 setter 或构造函数,如果最后一定要把参数存进成员,按值接收再 `std::move“ 往往更简洁,左值传进来拷贝一次,右值传进来可以高效移动,通常不需要专门写两套重载。
第二类是**const T&**。这是只读大对象的经典写法,因为它不拷贝,而且还能绑定临时对象。如果函数只是读取参数,不修改也不保存,那么 const T& 往往是最稳妥的默认选择。
第三类是**T&**。它明确表示函数会修改调用方对象,所以重点不是性能,而是副作用语义。比如原地排序、重置、填充缓冲区这类操作,用可变引用最自然。
第四类是指针 T*。在现代 C++ 里,指针最重要的语义不是“传地址”,而是“这个参数可能为空”。所以如果对象一定存在,用引用通常比指针更清晰;只有需要 nullable 语义、兼容 C 接口或者天然处理节点结构时,才优先用指针。
第五类是右值引用 T&&。它适合表达移动语义和资源接管,比如移动构造、移动赋值,或者确实要区分左值和右值的重载。但普通业务代码里不建议为每个参数都硬写 T&& 重载,很多时候按值接收更平衡。
最后一个很容易被追问的是转发引用。只有在模板参数推导场景下,T&& 才是转发引用,它的作用不是只接右值,而是保留实参原本的值类别,所以要配合 std::forward<T> 使用。这和普通右值引用是两个概念,不能混淆。
如果让我总结一句话: C++ 传参方式的选择,本质是在表达只读、可修改、可空、是否持有副本,以及是否转移所有权这些接口语义;性能只是结果,不是唯一出发点。