⚡C++ 语言基础

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); // 转发引用(需模板推导场景)

面试里我会先给一个判断框架:传参方式本质上是在表达语义,而不只是性能优化。 核心看四件事:

  1. 要不要拷贝/移动一份副本
  2. 要不要修改调用方对象
  3. 参数能不能为空
  4. 要不要接收并消费临时对象/资源

常见选择原则可以概括为:

  • 小对象、标量、轻量句柄:优先值传递
  • 只读大对象:优先 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;
}

调用方和被调方互不影响,函数拿到的是一份独立副本。

为什么用

值传递最直接表达的是:我拿到一个自己的对象副本,后续怎么改都不影响调用方。

这类语义在以下场景非常自然:

  • 参数本身就是小对象:intdouble、枚举、裸指针、迭代器等
  • 函数内部本来就需要留存一份副本
  • 希望接口更简洁,避免同时写左值/右值两套重载

什么时候适合

场景一:小对象

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::stringstd::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::movestd::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&&:我要保留你原来的左值/右值身份

再记一条实战决策线:

  1. 小对象:优先值传递
  2. 只读大对象:优先 const T&
  3. 要改调用方:用 T&
  4. 可能为空:用 T*
  5. 要保存副本:优先 T
  6. 要接管资源:考虑 T&& 或按值
  7. 泛型转发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++ 传参方式的选择,本质是在表达只读、可修改、可空、是否持有副本,以及是否转移所有权这些接口语义;性能只是结果,不是唯一出发点。