C++ 签名:这不仅关乎性能,更关乎所有权
Source: Dev.to
按值传递 vs. 按引用传递
在 Java 和 Python 中,每个对象都由指针跟踪,但语言隐藏了该指针,以简化开发者的使用。
如果你想 按值传递,必须主动创建深拷贝(或调用内部会创建深拷贝的方法)。
对于这些通常更关注可用性而非原始性能的语言来说,按引用传递 是一种通用且良好的做法。
你可能会想:“但是在 C++ 中按引用传递总是更好!这可以避免创建新对象。”
为什么还会需要不同的方式?关键就在细节上。
受管语言与非受管语言中的所有权
- 受管语言(Java、Python 等) 隐藏变量的所有权。如果所有权难以追踪,又有什么关系?垃圾回收器最终会帮我们清理混乱!
- 非受管语言(C++、Rust 等) 将所有权管理交给开发者。按引用传递可能会变成噩梦。这也是智能指针和 RAII 被创建的原因:确保分配内存的人不会忘记释放,或更糟的是,重复释放。
本文简要讨论了一些 经验法则,帮助你在编写 API 签名时跟踪所有权。
C++ Primer:rvalues 与 lvalues
| Category | Description | Example |
|---|---|---|
| rvalue | 临时对象 没有 持久的内存地址(例如字面量、临时返回值)。 | std::println("{}", 1); // prints an rvalue |
| lvalue | 在内存中有可辨识位置(地址)并且有名称的对象(例如具名变量)。 | int x = 2; std::println("{}", x); // prints an lvalue |
在编写函数时,通常你并不知道(也不必在意)参数是 rvalue 还是 lvalue。下面的经验法则可以帮助你在不必过度思考且避免所有权陷阱的情况下,决定使用哪种函数签名。
Source: …
API‑签名类别
核心关键词是 OWNERSHIP(所有权)。方法可以分为三大类:
- 拥有方法 – 函数取得参数的所有权。
- 非拥有方法 – 函数仅观察参数,不取得所有权。
- 通用方法 – 函数既可以接受拥有者也可以接受非拥有者的调用者。
下面我们把每一类细分为子类型,但指导原则始终不变:谁拥有谁。
1. 拥有方法(按值传递)
你以 值 的方式接收参数并 拥有 它(通常通过移动)。
Person temp_person;
void set_name(std::string str) {
temp_person.name = std::move(str); // 获取所有权
}
调用点
set_name(std::string("Jane Doe")); // 1 – 右值
std::string name = "John Doe";
set_name(name); // 2 – 左值(拷贝 + 移动)
set_name(std::move(name)); // 3 – 左值强制转为右值(仅移动)
三种情况的说明
| 情形 | 发生了什么 | 操作 |
|---|---|---|
| 1 – 右值 | str 从临时对象移动构造,随后再移动到 temp_person.name。 | 2 次移动 |
| 2 – 左值 | str 从 name 拷贝构造,随后再移动。 | 1 次拷贝 + 1 次移动 |
| 3 – 左值强制转右值 | str 移动构造(因为 std::move(name) 产生右值)。 | 1 次移动 |
如果这段代码位于热点路径且需要去掉额外的拷贝,可以使用重载:
// 左值重载
void set_name(std::string& str) {
temp_person.name = std::move(str);
}
// 右值重载
void set_name(std::string&& str) {
temp_person.name = std::move(str);
}
这样无论参数的值类别如何,每次调用都只会进行 一次移动。
陷阱演示
Person temp_person;
void set_name(std::string& str) { temp_person.name = std::move(str); }
void set_name(std::string&& str){ temp_person.name = std::move(str); }
std::string my_precious_string = "password123";
set_name(my_precious_string); // 所有权被转移!
std::println("My password: {}", my_precious_string); // 未定义 / 为空
要点: 按值(拷贝)传参通常是安全的设计选择,因为所有权语义明确。
2. 通用(管道)方法 – 非拥有
管道 是 非所有者,它仅临时访问对象。它接收引用 但从不取得所有权。
void add_exclamation(std::string& str) {
// 这里不要取得所有权——这是反模式。
str.push_back('!');
}
因为函数不拥有参数,任何修改对调用者都是可见的。
3. 按值返回(所有权转移)
按值传入、修改后再返回对象,是将所有权回传给调用者的简洁方式。
std::string add_exclamation(std::string str) {
// 在这里取得所有权是可以的。
str.push_back('!');
return str; // 移动返回(NRVO 或 move)
}
std::string phrase = "Lorem Ipsum";
phrase = add_exclamation(phrase); // 所有权仍然属于 `phrase`
4. 只能移动的类型
按值传参天然适用于只能移动的对象,如 std::unique_ptr 或 std::thread。
std::unique_ptr foo(std::unique_ptr ptr) {
// `ptr` 在函数内部被拥有。
// 修改不会影响调用者。
return ptr; // 移动返回
}
void bar(std::unique_ptr& ptr) {
// `ptr` 在这里 **不** 被拥有。
// 修改会影响调用者。
}
5. 只读访问(非拥有,const 引用)
当你只需要观察对象时,使用 const 引用。
void print_name(const std::string& str) {
std::println("Name: {}", str);
}
print_name(std::string("Jane Doe")); // 右值 – 绑定到 const 引用
std::string name = "John Doe";
print_name(name); // 左值 – 绑定到 const 引用
经验法则概览
| 情况 | 推荐的函数签名 | 原因 |
|---|---|---|
| 获取所有权(你会在函数内部存储或从参数中移动) | void f(T obj)(按值传递) | 确保有本地拷贝/移动;调用者知道所有权已转移。 |
| 仅读取 / 观察 | void f(const T& obj) | 不改变所有权;同时适用于左值和右值。 |
| 修改但不获取所有权 | void f(T& obj) | 调用者保留所有权;修改对调用者可见。 |
| 高效支持左值和右值 | 提供 重载:void f(T&); void f(T&&); | 每个重载都可以从其参数移动,避免额外拷贝。 |
| 处理只能移动的类型 | 按值传递(或 T&& 重载) | 允许将资源移动到函数内部。 |
通过在 API 签名中保持所有权的明确性,你可以避免细微的错误,让意图对用户清晰可见,并让编译器帮助你强制正确的语义。
理解参数传递与转发引用
print_name(std::string("Jane Doe")); // rvalue – binds to const ref
std::string name = "John Doe";
print_name(name); // lvalue – binds to const ref
情形
| # | 情况 | 发生了什么 |
|---|---|---|
| 1 | 传递 rvalue | 绑定到 const std::string&。由于引用是 const,语言允许绑定 rvalue。不会产生拷贝或移动。 |
| 2 | 传递 lvalue | 同样绑定到 const std::string&。不会产生拷贝或移动。 |
注意: 唯一的例外是当你传递的类型 小于或等于引用的大小(通常 ≤ 8 字节),例如
int、char、bool等。在这些情况下,按值传递可能更合适。
这个话题仍有争议;除非在热点路径上需要优化,否则可以为了保持一致性而使用 const T&。
何时使用转发引用
当您需要一个 高度通用 的方法时,请使用:
- Forwarding references
autoreturn types- Perfect forwarding
Caution: 将转发引用仅用于 generic wrappers 和 factory‑style utilities。在常规 API 中过度使用它们通常会导致难以调试的 overload‑resolution 问题。
语法(仅限模板)
template <class T>
decltype(auto) omega_foo(T&& t) { // T&& is a forwarding reference
// Forward the argument preserving its value‑category
return omega_bar(std::forward<T>(t));
}
该模式在整个 STL 中被广泛使用;经典示例是 emplace_back。
std::vector<std::pair<int,int>> v;
v.emplace_back(1, 1); // perfect‑forwarded arguments
std::pair<int,int> x = {2, 2};
v.emplace_back(x); // copy
v.emplace_back(std::move(x)); // move
在每次调用中,都会选择合适的(通常已优化的)构造函数。
Source:
使用转发引用构造对象
简单包装器
template <class T, class U>
auto constructor_wrapper(U&& u) {
// Build and return an object of type T initialized with `u`
return T(std::forward<U>(u));
}
可变参数版本
template <class T, class... Args>
auto constructor_wrapper(Args&&... args) {
// Build and return an object of type T initialized with whatever was passed
return T(std::forward<Args>(args)...);
}
限制可接受的参数
因为可变参数版本接受 所有 参数,你可以对其进行约束:
template <class T, class... Args>
concept ValidArg = !std::is_null_pointer_v<std::remove_cvref_t<Args>>;
template <class T, class... Args>
requires (ValidArg<Args> && ...)
auto constructor_wrapper(Args&&... args) {
return T(std::forward<Args>(args)...);
}
- 该实现 拒绝
std::nullptr_t。 - 传入
NULL将被转发为整数0。 - 对
std::initializer_list的推导 不太适用。
另一个细节:包装器使用的是 圆括号构造函数 T(...)。
这可能与 统一初始化 形式 T{...} 不同(例如,对 std::vector)。
选择合适的参数签名
| 类别 | 推荐签名 | 为什么? |
|---|---|---|
| 拥有方法(Sink) | void f(T arg) + std::move inside | 高效获取所有权(移动或拷贝)。 |
| 管道方法 | T& arg or T f(T arg) | 根据所有权意图,就地修改或通过“复制‑修改‑返回”。 |
| 非拥有方法(读取) | const T& arg | 在不复制的情况下观察对象。 |
| 非拥有方法(小型*) | T arg | 小型类型通过值传递更快。 |
| 通用方法 | template <class T> f(T&& arg) | 在包装器/工厂中实现完美转发。 |
*小型类型:int、double、bool、std::string_view、std::span 等。
最后思考
这些例子看起来并不像表面那样简单。要完全掌握概念和边缘情况,请阅读 Modern Effective C++。
记住: “API 的寿命比优化更长。”
在合适的情境下使用恰当的签名,只有在真正重要时才进行优化。