值语义和数据抽象(一)

什么是值语义

值语义(value semantics)指的是对象的拷贝与原对象无关,就像拷贝 int 一样。C++ 的内置类型(bool/int/double/char)都是值语义,标准库里的 complex<> 、pair<>、vector<>、map<>、string 等等类型也都是值语意,拷贝之后就与原对象脱离关系。同样,Java 语言的 primitive types 也是值语义。

1
2
std::string a = "Hello World";
std::string b = a;

此时 a 和 b 指向的是并不是同一个对象,a、b之间并不相关。

与值语义对应的是“对象语义/object semantics”,或者叫做引用语义(reference semantics)。对象语义指的是面向对象意义下的对象,对象拷贝是禁止的。例如 C++ 标准库里的 thread 是对象语义,拷贝 thread 是无意义的,也是被禁止的:因为 thread 代表线程,拷贝一个 thread 对象并不能让系统增加一个一模一样的线程。同样的道理,拷贝一个 Employee 对象是没有意义的,一个雇员不会变成两个雇员,他也不会领两份薪水。拷贝 TcpConnection 对象也没有意义,系统里边只有一个 TCP 连接,拷贝 TcpConnection 对象不会让我们拥有两个连接。Printer 也是不能拷贝的,系统只连接了一个打印机,拷贝 Printer 并不能凭空增加打印机。凡此总总,面向对象意义下的“对象”是 non-copyable。

Java 里边的 class 对象都是对象语义/引用语义。

1
2
ArrayList<Integer> a = new ArrayList<Integer>(); 
ArrayList<Integer> b = a;

那么 a 和 b 指向的是同一个 ArrayList 对象,修改 a 同时也会影响 b。

值得补充的是,值语义、引用语义与不可变性( immutable) 无关。Java 有 value object 一说,按(PoEAA 486)的定义,它实际上是 immutable object,例如 String、Integer、BigInteger 等等。C++中的值语义对象也可以是 mutable,比如 complex<>、pair<>、vector<>、map<>、string 都是可以修改的。

值语义的对象也不一定是POD,例如 string 就不是 POD,但它是值语义的。

POD stands for Plain Old Data - that is, a class (whether defined with the keyword struct or the keyword class) without constructors, destructors and virtual members functions.

值语义与生命期

值语义的一个巨大好处是生命期管理很简单,就跟 int 一样——你不需要操心 int 的生命期。值语义的对象要么是 stack object,或者直接作为其他 object 的成员,因此我们不用担心它的生命期(一个函数使用自己stack上的对象,一个成员函数使用自己的数据成员对象)。相反,对象语义的 object 由于不能拷贝,我们只能通过指针或引用来使用它。

一旦使用指针和引用来操作对象,那么就要担心所指的对象是否已被释放,这一度是 C++ 程序 bug 的一大来源。此外,由于 C++ 只能通过指针或引用来获得多态性,那么在C++里从事基于继承和多态的面向对象编程有其本质的困难——资源管理。如果不使用 smart pointer,用 C++ 做面向对象编程将会困难重重。

值语义与标准库

C++ 要求凡是能放入标准容器的类型必须具有值语义。准确地说:type 必须是 SGIAssignable concept 的 model。但是,由 于C++ 编译器会为 class 默认提供 copy constructor 和 assignment operator,因此除非明确禁止,否则 class 总是可以作为标准库的元素类型——尽管程序可以编译通过,但是隐藏了资源管理方面的 bug。

因此,在写一个 class 的时候,需要考虑到对象的拷贝与移动,默认让它继承 boost::noncopyable,几乎总是正确的。

在现代 C++ 中,一般不需要自己编写 copy constructor 或 assignment operator,因为只要每个数据成员都具有值语义的话,编译器自动生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 为成员来持有其他对象,那么就能自动启用或禁用 copying&assigning。

例外:编写 HashMap 这类底层库时还是需要自己实现 copy control。

值语义与C++语言

C++ 的 class 本质上是值语义的,这才会出现 object slicing 这种语言独有的问题,也才会需要程序员注意 pass-by-value 和 pass-by-const-reference 的取舍。在其他面向对象编程语言中,这都不需要费脑筋。

值语义是C++语言的三大约束之一,C++ 的设计初衷是让用户定义的类型(class)能像内置类型(int)一样工作,具有同等的地位。为此C++做了以下设计(妥协):

  • class 的 layout 与 C struct 一样,没有额外的开销。定义一个“只包含一个 int 成员的 class ”的对象开销和定义一个 int 一样。甚至 class data member 都默认是 uninitialized,因为函数局部的 int 是 uninitialized。

  • class 可以在 stack 上创建,也可以在 heap 上创建。因为 int 可以是 stack variable。class 的数组就是一个个 class 对象挨着,没有额外的 indirection。因为 int 数组就是这样。

  • 编译器会为 class 默认生成 copy constructor 和 assignment operator。其他语言没有 copy constructor 一说,也不允许重载 assignment operator。C++ 的对象默认是可以拷贝的,这是一个尴尬的特性。

  • 当 class type 传入函数时,默认是 make a copy (除非参数声明为 reference)。因为把 int 传入函数时是 make a copy。当函数返回一个 class type 时,只能通过 make a copy(C++ 不得不定义 RVO 来解决性能问题)。因为函数返回 int 时是 make a copy。

  • 以 class type 为成员时,数据成员是嵌入的。例如 pair<complex, size_t> 的 layout 就是 complex 挨着 size_t。

这些设计在带来了性能上的好处(memory locality)之外,也为数据抽象奠定了基础……