⚡C++ 语言基础

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);     // 转发引用(万能引用)

面试里不要只背“语法形式”,核心要讲清楚:传参方式本质上是在表达语义和成本,通常要同时考虑四件事:

  1. 是否需要复制一份对象
  2. 是否要修改调用方对象
  3. 是否允许为空
  4. 是否要接收临时对象 / 转移资源 / 保留值类别

一个实用决策顺序是:

  • 小对象、便宜拷贝:优先值传递 比如 intdouble、枚举、裸指针、轻量迭代器、小型句柄。
  • 只读大对象:优先 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& 一定比值传递快吗?

不一定。

常见反例:

  1. 小对象拷贝本来就便宜,按值更简单
  2. 函数要留副本,按值可能比 const T& + 再拷贝更直接
  3. 编译器优化、移动构造、寄存器传参都会影响实际成本

所以不能机械地说“只要是大对象就全用 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 << ' ';
    }
}

优势非常重要:

  1. 避免拷贝
  2. 不允许修改实参
  3. 既能绑定左值,也能绑定右值/临时对象

例如:

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_backmake_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. 一个实战型决策口诀

可以把传参理解成四个问题:

  1. 我需不需要副本?
  2. 我会不会改调用方?
  3. 参会不会为空?
  4. 我是不是要消费这个对象 / 保留值类别?

然后对应到常用选择:

  • 不需要副本、只读:const T&
  • 要修改原对象:T&
  • 可空:T*
  • 要留副本:T
  • 要消费右值资源:TT&&
  • 模板转发: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::movestd::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& 接收小对象,比如 intbool、枚举,反而显得别扭
  • 到处使用输出参数,让函数调用点难读
  • 把“是否用智能指针管理对象”与“函数参数该不该传智能指针本身”混为一谈
  • 在模板里用了 T&& 却忘记 std::forward<T>(x),导致值类别丢失
  • 误以为“引用不能重新绑定,所以总比指针高级”,实际上二者表达的语义不同,不存在简单替代关系

记忆技巧

  • 先记一句总原则: 传参方式 = 语义表达 + 成本控制

  • 四问法快速判断:

    1. 要不要副本?
    2. 要不要改原对象?
    3. 能不能为空?
    4. 要不要消费资源/保留值类别?
  • 口诀版:

    • 小对象:值传递
    • 只读大对象const T&
    • 要修改T&
    • 可空T*
    • 要接管TT&&
    • 泛型转发:转发引用
  • 再补一条面试高频记忆点: “如果函数最终一定要留一份副本,按值传参往往比 const T& 更自然。”

  • 区分 std::movestd::forward

    • move:我明确要把它当右值
    • forward:我想保留它原来的值类别

面试速答版

C++ 常见传参方式有值传递、指针、左值引用、const 左值引用、右值引用和模板里的转发引用。选择时我一般先看语义,再看性能。

如果是小对象,比如 int、指针、轻量句柄,通常直接按值传递。 如果是只读大对象,一般用 const T&,避免拷贝,还能接左值和右值。 如果函数要修改调用方对象,用 T&。 如果参数可能为空,或者要表达“可能没有对象”,用 T*。 如果函数要消费对象、接管资源,常用 按值或者 T&&。 如果是模板包装器、工厂函数这类泛型转发场景,才用 转发引用 T&& + std::forward

所以本质上不是记语法,而是看:要不要副本、会不会修改、能不能为空、要不要转移资源。


面试加分版

我一般把 C++ 传参方式理解为一种接口语义设计,不只是性能问题。核心要表达四件事:是否复制、是否修改调用方、是否允许为空、是否涉及资源转移或值类别保留。

最常见的有六种:TT*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&& 误用到所有想“优化性能”的场景里。真正好的回答应该讲清楚:是什么、为什么、怎么选、边界在哪。