每周技巧 #234:按值、按指针,还是按引用传递?

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #234: Pass by Value, by Pointer, or by Reference?

原文最初作为 TotW #234 发布于 2024 年 8 月 29 日。

作者:Steve Wang

更新于 2024 年 9 月 30 日。

快捷链接:abseil.io/tips/234

概览

许多编程语言(例如 Java 和 Python)总是通过引用访问对象,接受对象的函数会获得调用方对象的自己的引用。另一些语言(例如 C 和 Go)允许显式指定指向对象的指针。C++ 进一步允许你选择是按值传递(让被调用函数得到实参的一份副本),还是按引用传递(让被调用函数访问调用方的对象)。本技巧会说明 C++ 中只输入函数参数的各种传递方式,并给出建议和注意事项。

当我们谈论按值传递时,我们明确指语言确保函数调用作用域拥有其实参的一份独占副本。1 给这个变量重新赋值不会修改调用方作用域中的对应对象。不过,调用该实参的方法仍可能修改它的底层状态。

同时,当我们谈论按引用传递时,我们实际上把调用方作用域中的对象带入当前函数作用域,重新赋值会修改底层对象。

按指针传递与按引用传递有一些相似之处,但技术上它是按值传递的一个特殊情况,因为指针本身是一个值,对应底层对象的地址(或空指针,表示根本不引用任何对象)。

考虑下面代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
void AddOneToValue(int x) {
  ++x;
}

void AddOneToReference(int& x) {
  ++x;
}

// 这里,指针指向一个“被指对象”;我们给被指对象加一。
void AddOneToPointee(int* x) {
  ++*x;
}

...

int x = 5;
AddOneToValue(x);
// x 仍然是 5。
AddOneToReference(x);
// x 现在是 6。
AddOneToPointee(&x);
// x 现在是 7。

因此,在 C++ 中编写函数时,语言迫使我们考虑如何传递参数:应该按值、按指针,还是按引用(如果按引用,哪种引用)?

为什么关心按值传递?

敏锐的读者可能会问,总是按引用传递有什么问题?首先,不必要的 const T&(例如 Add(const int& a, const int& b))会增加视觉噪音。

其次,在 C++ 中,如上所述,引用很大程度上是指针的语法糖,2 除非编译器能优化掉,否则当我们使用它时,会有一次内存查找的相关开销。通过按值传递小类型,可以把它们放在寄存器中传递,而不需要存储到栈上。

1
2
3
4
5
6
7
8
// 小值按值传递时,编译器可以避免栈分配,并在寄存器中传递。
int foo = 5;
Bar(foo);

// 但是,小值按引用传递要求 `foo` 被复制(“溢出”)到栈上,
// 因为你不能取得寄存器的地址。
int foo = 5;
Bar(&foo);

当然,如果变量已经在栈上或堆上(例如它是数组的一部分),那么这个顾虑无关紧要;但如果它已经加载在寄存器中,我们仍应优先按值传递,以避免一些缓存未命中和内存压力。

无论如何,引用还会引入关于别名3 的顾虑。由于函数没有对象的独占副本,即使我们有一个 const 引用(它只承诺我们不会通过那个特定参数修改对象),也不能保证对象在函数生命周期中保持不变。

为什么关心按引用传递?

从光谱另一端看,也有人可能会问,为什么不把所有输入参数都按值传递?

在 C++ 中,如果按值传递变量,根据函数如何被调用,变量的值可能被拷贝或移动(或者都没有)。4 另一方面,按引用(或指针)传递允许引用已有对象,因此完全避免拷贝。所以一般来说,对象越大,越应该偏好按引用传递。

从内存安全角度看,按值传递既有好处也有缺点。一方面,如果你拥有对象的唯一副本,就不用担心其他线程践踏它的状态。另一方面,如果你保留了对此对象的引用,一旦它离开作用域,就会有 use-after-free bug(与任何其他局部变量一样)。

指南

以下规则同等适用。如果没有任何规则适用,一个安全选择是对必需参数按 const 引用传递,对可选参数按指针传递。

按值传递

在某些情况下,按值传递可能更高效(当相关类型足够小,以至于移动或拷贝比通过指针工作更高效时),并且有助于表达所有权(通常是被调用函数想拥有这个值、从中移动,或以其他方式修改自己的副本)。

具体来说,下面类型通常应按值传递:

  • 数值和枚举类型(包括 protobuf 枚举)。
  • 智能指针,当被调用函数无条件接管所有权时。

还有一些类型可以作为优化高效地按值传递:

  • 提供高效移动构造函数的类型,仅当被调用函数需要拥有该值自己的副本时。例子包括 std::vector<T>std::stringabsl::flat_hash_map<T> 和其他不把内容内联存储的容器。5

    在这些情况下,应该按值传递,并在需要时在调用点 std::move(或传入受技巧 #166 所述强制拷贝消除影响的临时对象)。关于拷贝消除和按值传递,见技巧 #117 的补充阅读。

    按值传递在把这些类型之一存储到成员变量中的构造函数中尤其常见。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Foo {
 public:
  // 这里按引用传递 bar,并把它拷贝进 bar_。
  Foo(const std::vector<int>& bar) : bar_(bar) {}

  // 但在某些情况下,我们可以改用 std::vector 的移动构造函数,
  // 完全避免昂贵拷贝。
  Foo(std::vector<int> bar) : bar_(std::move(bar)) {}
 private:
  std::vector<int> bar_;
};
  • T,其中 sizeof(T) <= 166,且 T 要么是整数或指针类型这样的标量类型,要么是满足以下条件的类7

    • 它有未删除的拷贝构造函数或移动构造函数。
    • 所有拷贝和移动操作都是平凡的,其中一个要求是它们必须被省略或显式默认化(技巧 #131)。
    • 析构函数平凡且未删除。

    对于你团队不拥有的类型,8 只有当它们明确记录应按值传递时,才应依赖这种行为,例如 spanner::Databaseabsl::Duration

  • std::optional<T>,其中按值传递 T 适用。

    T 相比,std::optional<T> 会增加一些大小开销,这进一步限制了可高效按值传递的类型。因此,例如 std::optional<std::span<U>>std::optional<absl::string_view> 太大,因为每个被包装类型在计入 std::optional 开销前已经是 16 字节。

    如果 sizeof(std::optional<T>) > 16,或者 T 有非平凡拷贝构造函数,那么优先按 absl::Nullable<const T*> 传递(技巧 #163),并用空指针表示原本由 std::nullopt 捕获的情况。

    注意,技巧 #163 也适用于这里:如果所有调用者总是拥有 std::optional<T>,那么可以按 const& 传递。

    不要对智能指针或其他已经有“无值”表示的类型使用这个惯用法。例如,不要写 std::optional<std::unique_ptr<U>>;相反,优先直接使用 std::unique_ptr<U>,并传递空指针表示“无值”。

按引用或按指针传递

注意:如果调用 f(x) 中的实参 x 需要比函数调用活得更久,不要按引用传递它

下面列出的类型通常应按引用传递(对于必需参数)或按指针传递(对于可选参数)。

  • 智能指针(例如 std::unique_ptr<T>),当你不想转移所有权时:如果已知(且要求)被指值总是非空,请解引用智能指针并传递 const T&;否则传递 absl::Nullable<const T*>技巧 #188)。

    在共享所有权9 场景中,如果你只是有时想取得所有权,可能希望传递 std::shared_ptr 的引用,以避免更新引用计数的轻微开销。

  • 内联存储内容的容器,例如 std::array<T, N>absl::InlinedVector<T, N>

    虽然当 sizeof(T) * N <= 16 时,std::array<T, N> 可以高效按值传递,但 absl::InlinedVector<T, N> 有非平凡拷贝构造函数,因此永远不会在寄存器中传递。

  • 有非平凡拷贝构造函数,且你不打算使用移动语义的类型。

  • Protocol buffers。

    你可能以为下面定义的 Duration 类型:

1
2
3
4
5
6
edition = "2023";

message Duration {
  int64 seconds = 1;
  int32 nanos = 2;
}

只包含一个 int64(8 字节)和一个 int32(4 字节),因此是 12 字节(填充到 16 字节),但这并不正确,因为 protobuf 可能有 vtable 指针(8 字节)或其他元数据。此外,默认不应按值传递 protos(即便它们字段不多),因为它们不承诺可被平凡拷贝(实践中通常也不能)。

传递视图(按值)

对于某些类型,对应的视图类型是一种好办法:这种类型提供对底层数据的只读访问,并可能支持多种不同底层类型,适合用于接受不需要拥有自己副本的输入的函数。

  • 对于接受字符串实参的函数,无论输入是 std::stringabsl::string_view 还是 const char*,把参数定义为 absl::string_view 都高效并支持所有这些输入类型(见技巧 #179)。
  • 对于接受 std::vector<T> 的函数,把参数定义为 absl::Span<const T> 更高效、更灵活(见技巧 #93);不过如果约束使 absl::Span 不实际,使用 const std::vector<T>& 也可以是合理选择。
  • 对于接受可调用对象(例如 lambda)的函数,可以在把函数参数定义为 const Fn&(其中 Fn 是模板参数)和类型擦除 callable(例如 absl::FunctionRef,见技巧 #145)之间选择。

结束语

虽然把函数参数按 const& 传递是一个不错的默认选择,但很多场景中它并不是最佳选项。本技巧中的指南可以帮助权衡相关因素,并设计安全高效的 API。

我们也想强调,它们只是指南;如果你有充分理由偏离这些建议(例如 benchmark 或 profiling 识别出了潜在性能收益),我们鼓励你这么做(并为下一位读者记录你的理由)。

每周技巧 #232:变量声明中何时使用 `auto`

上一节

C++ 每周技巧

下一节

  1. 这不一定意味着函数会创建其实参的单独副本,因为可能已经发生了拷贝消除。 ↩︎

  2. const 引用稍微更复杂:其一,它们可以绑定到临时对象。此外,引用不能为 null,因此对于不需要比函数调用活得更久的必需输入参数,我们通常建议传引用而不是指针。 ↩︎

  3. 最好情况下,指针别名会阻止编译器做某些优化。最坏情况下,指针别名可能导致前置条件被破坏、逻辑 bug 和缓冲区溢出。 ↩︎

  4. 粗略地说,当你传入具名对象(例如非引用栈变量或数据成员)时,这会导致拷贝。技巧 #166 有更详细说明。 ↩︎

  5. Protocol buffers 也定义了移动构造函数,它通常类似浅拷贝;例外是如果你在位于不同 arenas 的两个 message 之间移动,或在堆分配 message 与 arena 分配 message 之间移动,此时它类似深拷贝。 ↩︎

  6. 在典型 Google 生产环境中,即 x86-64 Linux。见 ELF x86-64 ABI 规范第 3.2.3 节。在 Windows 上,只有 8 字节或更小的类型会在寄存器中传递。 ↩︎

  7. 形式上,这个类必须不是“for the purpose of calls 的非平凡类型”。这与 C++ 中平凡可拷贝类非常相似,但并不完全相同。正式定义见 ABI 规范。 ↩︎

  8. 虽然按值传递小类型可能更高效,但你可能会无意中让给这些类型添加新字段或以其他方式改变内部表示变得更困难,因为你增加了对类型大小以及其定义的构造函数和析构函数的隐式依赖。 ↩︎

  9. 正如风格指南所说,共享所有权只应在有充分理由时使用,而不是作为避免思考对象生命周期的方式。 ↩︎