(原文链接:https://abseil.io/tips/177 译者:clangpp@gmail.com)
每周贴士 #177: 可赋值性与数据成员类型
- 最初发布于:2020-04-06
- 作者:Titus Winters
- 更新于:2020-04-06
- 短链接:abseil.io/tips/177
做类型的实现,先要决定类型的设计。先考虑API,后考虑实现细节。这件事的一个常见例子是,类型的可赋值性与数据成员限定符之间的权衡。
决定如何呈现数据成员
设想你正在写一个City
类,然后正在探讨如何呈现它的数据成员。你知道它生存期很短,表示城市某个时刻的快照,所以诸如人口、名称和市长都可以被当做const
——我们不会在某个程序里把同一个对象用上好几年,所以我们不用考虑人口、新的普查结果或者选举结果的变化。
我们是否应该有这样的成员?
private:
const std::string city_name_;
const Person mayor_;
const int64 population_;
为什么,或者为什么不?
通常的建议“对,给它搞成const
”背后的思路是“你看,一个给定的City
的这些属性都不会改变,又因为所有可以是const
的东西都应该是const
,搞成const
吧。”这样可以让维护者的日子更好过,不至于不小心修改了这些属性。
这里面漏掉了一个至关重要的考量:City
是什么样的类型?它是个值吗?还是一整套业务逻辑?它被期待是可复制的,只能移动的,还是不可复制的?某个成员是否被搞成const
可能会直接影响City
(作为整体)可以高效写出的操作集合,而这通常是桩很差的买卖。
特别地说,如果你的类里有const
成员,那它就不能被赋值(不论是拷贝赋值还是移动赋值)。语言认定:如果你的类型有const
成员,拷贝赋值和移动赋值操作符不会被生成。你仍然可以拷贝(或移动) 构造 这样的对象,但在构造(即使“仅仅”是通过复制同类型的其他对象)完成后你就不能修改它了。即使你自己实现赋值运算符,你也会马上发现你(显然)不能改写这些const
成员。
那么有没有可能把问题改为“const
成员或赋值操作,哪个更好?”遗憾的是,就算这样还是容易被误导,因为二者都可以被一个 重要 的问题来回答,“City
是什么样的类型?”如果它应该是一个 值类型 ,那就限定了(包括赋值操作在内的)API,而API通常优先于实现考量。
考虑API设计决策优先于考虑实现细节的选择是很重要的:通常情况下,API影响到的工程师要比类型实现影响到的工程师要多。也就是说,一个类型的用户要比其维护者更多,所以影响用户的设计选择应该比影响实现者的设计选择优先级更高。即使你认为某个类型不会被维护它的团队以外的人使用,可软件工程是关于接口设计和抽象的活儿——我们应该优先考虑好的接口。
引用类型成员
存储引用类型数据成员时同理。就算我们知道一个成员必须是非空的,通常还是倾向于将值类型存储为T*
,因为引用不能被重新绑定。也就是说,我们不能将T&
指向别处——任何对该成员的修改都是在修改其底层的T
。
考虑std::vector<T>
的实现。几乎可以肯定任何std::vector
的实现都要有一个T* data
成员,指向申请的空间。从std::vector
的规格说明中可知,申请的空间通常必须是合法的(空向量情况除外)。如果一种实现下总是有申请的空间,该实现就可以使用T&
了,对不对?(是的,我这里忽略了数组和偏移。)
当然不对。std::vector
是个值类型,既可以复制又可以赋值。如果申请空间被存储为“指向首元素的引用”而不是“指向首元素的指针”,我们就不可能对存储空间进行移动赋值,在正常调整容量的时候也不清楚该如何更新data
。我们以机智的方式告诉其他维护者“这个值不可能为空”,却成了给用户提供期待的API的绊脚石。以此说明这是桩错误的买卖应该够清楚了吧。
不可复制/赋值的类型
当然了,如果在你的类型设计选项里,City
(或任何你想到的类型)应该是不可复制的,那就没那么多实现的桎梏了。一个类是否包含const
或引用类型,不是个 对 或 错 的问题,而只是要考虑到这些实现决策会限制或腐蚀这个类所展现的接口。如果你想明白了你的类型不需要复制,那关于如何呈现类的数据成员你当然可以合理地做出不同的决策。(但请参阅贴士 #116以了解更多关于参数生存期和引用存储的思考和陷阱。)
不常见的情况:不可变类型
有一种“有用但不常见”的设计可能 强制要求 const
成员:故意的不可变的类型。这种类型的实例在构造之后就是不可变的:没有可修改的方法,没有赋值运算符。这种用法一般不常见,但有时候挺有用。尤其是,这样的类型天然是 线程安全 的,因为没有可修改操作。这种类型的对象可以自由地跨线程共享,而不必担心数据冲突或同步。然而,代价是这些对象因为总是需要拷贝而可能带来可观的运行时代价。其不可变性甚至会导致这些对象不能被高效地移动。
相比于依赖“不可变性带来的线程安全”,通常更推荐将类型设计为可修改但是线程兼容(thread-compatible,译者注:不修改对象状态的成员函数并发调用不用加锁)。你类型的用户通常会处在更好的位置,来根据情况判定可变性带来的好处。在没有很强的证据说明为什么你的用例不寻常之前,不要强迫你的用户来对付不寻常的设计决策。
推荐
- 在考虑实现细节之前,先决定如何设计你的类型。
- 值类型是常见的也是推荐的。业务逻辑类型同理,虽然它们通常是不可复制的。
- 不可变类型有时候有用,但适用的情况不多。
- 优先考虑API设计和用户的需求,而不是(通常人数更少的)维护者的考量。
- 在构建值类型或只能移动的(move-only)类型时,避免
const
和引用类型数据成员。