每周技巧 #177:可赋值性与数据成员类型

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #177: Assignability vs. Data Member Types

原文最初作为 TotW #177 发布于 2020 年 4 月 6 日。

作者:Titus Winters

更新于 2020 年 4 月 6 日。

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

实现一个类型时,先决定类型设计。API 的优先级应高于实现细节。一个常见例子是类型的可赋值性与数据成员限定符之间的取舍。

决定如何表示数据成员

想象你正在写一个 City 类,并讨论如何表示它的成员变量。你知道它生命周期很短,表示某一时间点的城市快照,所以人口、名称和市长这些东西似乎都可以是 const。我们不会在一个程序中使用同一个对象好多年,因此不需要考虑人口变化、新人口普查结果或选举。

我们应该有这样的成员吗?

1
2
3
4
 private:
  const std::string city_name_;
  const Person mayor_;
  const int64_t population_;

为什么应该,或者为什么不应该?

常见的“是的,把它们设为 const”的建议基于这样一种想法:“这些值对给定 City 来说不会改变,所以既然凡是能 const 的都应该 const,那就把它们设成 const。”这会让类的维护者更容易避免意外修改这些字段。

但这忽略了一个极其重要的问题:City 是什么样的类型?它是一个值吗?还是一组业务逻辑?它预期可拷贝、仅可移动,还是不可拷贝?你能为 City(整体)高效编写哪些操作,可能会受到单个成员是否被设为 const 的影响,而这通常是糟糕的取舍。

具体来说,如果类有 const 成员,它就不能被赋值(无论是拷贝赋值还是移动赋值)。语言理解这一点:如果类型有 const 成员,拷贝赋值和移动赋值运算符不会被合成。你仍然可以拷贝(或移动)构造这样的对象,但构造之后不能以任何方式改变它(即使“只是”从同类型另一个对象拷贝)。即使你自己写赋值运算符,也很快会发现你(显然)无法覆盖这些 const 成员。

所以问题可能会变成:“我们应该偏好 const 成员,还是赋值操作?”不过,即使这样问也有误导性,因为二者都由一个重要问题决定:“City 是什么样的类型?”如果它打算成为一个值类型,那就规定了 API(包括赋值操作),而一般来说 API 优先于实现方面的顾虑。

让这些 API 设计决策优先于实现细节选择非常重要:通常受类型 API 影响的工程师,比受类型实现影响的工程师更多。也就是说,类型的使用者比维护者更多,因此应该优先考虑影响使用者的设计选择,而不是实现者的设计选择。即使你认为这个类型永远不会被维护它的团队之外的人使用,软件工程也关乎接口设计和抽象;我们应该优先提供好的接口。

引用成员

同样的推理也适用于把引用存为数据成员。即使我们知道该成员必须非空,对于值类型来说,通常仍然更适合存储 T*,因为引用不能重新绑定。也就是说,我们无法让一个 T& 重新指向别处;对这样一个成员的任何修改,都是在修改底层的 T

考虑 std::vector<T> 的实现。任何 std::vector 实现几乎肯定都会有一个 T* data 成员,指向那块分配出来的内存。根据 std::vector 的规范,我们知道这块分配通常必须有效(空 vector 可能例外)。一个总是拥有分配的实现,可以把它做成 T&,对吧?(是的,这里我忽略数组和偏移。)

显然不行。std::vector 是值类型,它可拷贝、可赋值。如果那块分配以内存首元素引用而不是首元素指针的形式存储,我们就无法移动赋值这块存储,也不清楚正常 resize 时该如何更新 data。我们用来告诉其他维护者“这个值非空”的聪明办法,会妨碍我们向用户提供期望的 API。希望这里已经清楚,这是一笔错误的交易。

不可拷贝/不可赋值类型

当然,如果你的类型设计选择表明 City(或你正在思考的任何类型)应该不可拷贝,那么实现上受到的约束就少得多。让类持有 const 成员或引用成员本身并非正确错误;只有当这些实现决策限制或破坏该类呈现的接口时,它们才成为问题。如果你已经有意识地认真决定你的类型不需要可拷贝,那么你完全可以对类的数据成员表示方式做出不同选择。(不过关于实参生命周期和引用存储的一些想法与陷阱,也请参见技巧 #116。)

不寻常场景:不可变类型

有一种有用但不常见的设计可能会要求 const 成员:有意设计的不可变类型。这类类型的实例在构造后不可变:没有修改方法,没有赋值运算符。它们相当少见,但有时很有用。特别是,这样的类型天然是线程安全的,因为没有修改操作。这类对象可以在线程之间自由共享,不需要担心数据竞争或同步。不过代价是,由于必须不断拷贝它们,这些对象可能有显著的运行时开销。不可变性甚至会阻止这些对象被高效移动。

几乎总是更好的做法是把类型设计为可变但仍然线程兼容,而不是依赖“通过不可变性获得线程安全”。你的类型的使用者通常更适合逐个场景判断可变性的收益。除非有非常强的证据说明你的用例确实不寻常,否则不要强迫他们绕开不寻常的设计选择。

建议

  • 先决定类型设计,再考虑实现细节。
  • 值类型很常见,也值得推荐。业务逻辑类型同样常见,而它们往往不可拷贝。
  • 不可变类型有时有用,但真正合理的场景相当少。
  • 优先考虑 API 设计和使用者需求,而不是维护者通常更小的顾虑。
  • 构建值类型或仅可移动类型时,避免 const 和引用数据成员。

每周技巧 #176:优先使用返回值,而不是输出参数

上一节

每周技巧 #180:避免悬垂引用

下一节