每周技巧 #149:对象生命周期与 `= delete`

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #149: Object Lifetimes vs. = delete

原文最初作为 TotW #149 发布于 2018 年 5 月 3 日。

作者:Titus Winters

更新于 2020 年 4 月 6 日。

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

钱花光之后再次进入蓝色之中一生一次,地下之水流动 —— David Byrne

生命周期上的 =delete

假设你有一个 API,需要引用某个长生命周期对象,但不取得它的所有权。

1
2
3
4
5
6
class Request {
  ...

  // The provided Context must live as long as the current Request.
  void SetContext(const Context& context);
};

你心想:“嘿,如果有人传了临时对象会怎么样?那会是 bug。不过这是现代 C++,我可以防止它!”于是你拼出一个 API 改动,添加一个删除的重载。

1
2
3
4
5
6
7
class Request {
  ...

  // The provided Context must live as long as the current Request.
  void SetContext(const Context& context);
  void SetContext(Context&& context) = delete;
};

你对自己的成果很满意,接着想:“嘿,现在 API 已经说明了一切,注释没必要了。”

1
2
3
4
5
6
class Request {
  ...

  void SetContext(const Context& context);
  void SetContext(Context&& context) = delete;
};

这是个好主意吗?为什么?

不要脱离使用场景做设计

照这样展示,你可能会觉得这是个好主意。然而,和许多 API 设计情况一样,盯着 API 定义看很诱人,但看 API 如何使用更有用。所以我们把这个场景重放一遍,把用法考虑进来。

某个用户尝试使用原始的 SetContext(),只想让简单东西先构建起来,又不知道去哪里找正确的 Context 对象,于是直接写出建议调用:

1
request.SetContext(Context());

没有你的 =delete 改动时,这能构建,但会在运行时失败(很可能以神秘方式失败)。查看 SetContext API 时,可以看到生命周期要求已记录,于是代码被修改为符合要求:

1
request.SetContext(request2.context());

另一方面,用户尝试使用带有你的 =delete 改动、但没有注释的“改进版” SetContext() 时,首先遇到构建失败:

1
2
3
4
5
6
7
error: call to deleted member function 'SetContext'

  request.SetContext(Context());
  ~~~~~~~~^~~~~~~~~~

<source>:4:8: note: candidate function has been explicitly deleted
  void SetContext(Context&& context) = delete;

然后用户会想:“好吧,我不能传临时对象。”但由于没有关于实际要求的信息,最可能的修复是什么?

1
2
Context context;
request.SetContext(context);

现在,问题核心来了:新的自动变量 context 的作用域正好是这个调用需要的生命周期的概率有多大?如果你的答案不是 100%,生命周期要求注释仍然必要。

1
2
3
4
5
6
7
class Request {
  ...

  // The provided Context must live as long as the current Request.
  void SetContext(const Context& context);
  void SetContext(Context&& context) = delete;
};

以这种方式删除重载集中的一个成员,充其量只是半措施。是的,你避免了一类 bug,但也让 API 复杂化。依赖这种设计一定会带来虚假的安全感:C++ 类型系统根本无法编码参数生命周期要求所需的细节。

既然类型系统实际上无法把这件事做对,我们建议不要用半措施把事情复杂化。保持简单:不要试图依赖这种模式来禁止临时对象,它不够有效,帮不上忙。

用于“优化”的 =delete

把情况反过来:也许你不是想防止临时对象,而是想防止拷贝。

1
2
future<bool> DnaScan(Config c, const std::string& sequence) = delete;
future<bool> DnaScan(Config c, std::string&& sequence);

你的 API 调用方永远不需要保留自己的值,这种可能性有多高?如果不能 100% 确定自己精确知道 API 会如何被使用,这就是惹恼用户的配方。考虑在普通(未删除)设计下拷贝并调用这样的 API:

1
2
3
4
5
6
7
Config c1 = GetConfig();
Config c2 = GetConfig();
std::string s = GetDna();

// Kick off scans for both configs.
auto scan1 = DnaScan(c1, s);
auto scan2 = DnaScan(c2, std::move(s));

既然我们看到第二次扫描是 s 的最后一次使用,就可以直接 std::move 到消耗值的调用中。使用“聪明优化”的版本后,代码看起来更凌乱。

1
2
3
4
5
6
7
8
Config c1 = GetConfig();
Config c2 = GetConfig();
std::string s = GetDna();
std::string s2 = s;

// Kick off scans for both configs.
auto scan1 = DnaScan(c1, std::move(s));
auto scan2 = DnaScan(c2, std::move(s2));

API 是作为构建块和抽象提供的;API 生态是一个平台,可以用新的、令人惊讶的方式组合在一起,超出任何单个 API 提供者的预测。相信自己确切知道没人应该进行拷贝,与这一点相悖。此外,当移动足够时仍然拷贝造成的低效问题,远比任何单个 API 更广泛,可能更适合用性能分析、培训、代码评审和静态分析的某种组合来解决。

在少数你确实能确定某个 API 必须以特定方式使用的情况下:你很可能应该把它编码进相关类型中。不要用 std::string 作为 DNA 序列的表示来操作;写一个 Dna 类,让它只可移动,并提供一个显式(易于搜索)的方式来执行昂贵拷贝。换句话说:类型的属性应该表达在这些类型中,而不是表达在操作它们的 API 中。

ref 限定

顺带一提:同样的推理也可以应用于破坏性访问器上的 ref 限定符。考虑类似 std::stringbuf 的类;在 C++20 中,它获得了一个消费所含字符串的访问器,并和已有访问器一起呈现为一个重载集:

1
2
const std::string& str() const &;
std::string str() &&;

(关于引用限定方法和重载集,更多信息见 技巧 #148。)观察 std::stringbuf 的现有用法,几乎每个用法都是一个 stringbuf 用来产生一个字符串。如果忽略旧代码,强制这一点并且提供“高效”的引用限定破坏性成员,不是最好吗?

当然不是,原因类似前面的 DnaScan 例子:你无法确切知道没人需要它,而且提供 const 重载并不不安全。只有在 ref 限定符作为优化重载集使用,或者它们对于强制语义正确性来说必要时,才使用 ref 限定符。

总结

试图结合右值引用或引用限定符与 =delete,提供更“用户友好”的 API、强制生命周期或防止优化问题,这很诱人。实践中,这些诱惑通常很糟糕。生命周期要求远比 C++ 类型系统能表达的复杂。API 提供者很少能预测其 API 未来所有有效用法。避免这类 =delete 技巧能让事情保持简单。

每周技巧 #148:重载集

上一节

每周技巧 #152:`AbslHashValue` 与你

下一节