值语义与数据抽象

值语义与数据抽象

值语义与数据抽象

一、什么是值语义

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

二、什么是对象语义

对象语义指的是面向对象意义下的对象,对象拷贝是禁止的。例如 muduo 里的 Thread 是对象语义,拷贝 Thread 是无意义的,也是被禁止的。因为 Thread 代表线程,拷贝一个 Thread 对象并不能让系统增加一个一模一样的线程。同样的,拷贝一个 Employee 对象也是没有意义的,一个雇员不会变成两个雇员,他也不会领两份薪水。拷贝 TcpConnection 对象也没有意义,系统中只有一个 TCP 连接,拷贝 TcpConnection 对象不会让我们拥有两个连接。Printer 也是不能拷贝的,系统只连接了一个打印机,拷贝 Printer 并不能凭空增加打印机。

Java 中的 class 对象都是对象语义/引用意义

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

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

值语义与 immutable 无关。Java 有 value object 一说,它实际上是 immutable object,例如 String、Integer、BigInteger、joda.time.DataTime等等(因为 Java 没有办法实现真正的值语义 class,只好用 immutable object 来模拟)。

C++ 中的值语义对象也可以是 mutable,比如 complex<>、pari<>、vector<>、map<>、string 都是可以修改的。

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

值语义的对象不一定小,例如 vector 的元素可多可少,但它始终是值语义的。

三、值语义与生命期

值语义的对象要么是 stack object,要么直接作为其他 object 的成员,因此不用担心它的生命期(一个函数使用自己 stack 上的对象,一个成员函数使用自己的数据成员对象)。相反,对象语义的 object 由于不能拷贝,因此我们只能通过指针或引用来使用它。

一旦使用指针和引用来操作对象,那么就要担心所指的对象是否已被释放,这一度是 C++ 程序 bug 的一大来源。由于 C++ 只能通过指针或引用来获得多态性,在 C++ 里基于继承和多态的面向编程有其本质的困难—对象生命期管理(资源管理)。

考虑一个简单的对象建模—家长与子女:a Parent has a Child, a Child knows its Parent。Java 中不用担心内存泄露,也不用担心空悬指针。

1
2
3
4
5
6
7
8
9
public class Parent
{
private Child myChild;
}

public class Child
{
private Parent myParent;
}

C++ 的写法如下:

1
2
3
4
5
6
7
8
9
10
11
class Child;

class Parent : boost::noncopyable
{
Child* myChild;
};

class Child : boost::noncopyable
{
Parent* myParent;
};

C++ 可以借助 smart pointer 把对象语义转换为值语义,从而轻松解决对象生命期问题:让 Parent 持有 Child 的 smart pointer,同时让 Child 持有 Parent 的 smart pointer。其中一个 smart pointer 应该是 smart pointer 应该是 week reference,否则会出现循环引用,导致内存泄露。

如果 Parent 拥有 Child,Child 的生命期由其 Parent 控制,Child 的生命期小于 Parent。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Parent;

class Child : boost::noncopyable
{
public:
explicit Child(Parent* myParent_)
: myParent(myParent_)
{ }

private:
Parent* myParent;
};

class Parent : boost::noncopyable
{
public:
Parent()
: myChild(new Child(this))
{ }

private:
boost::scoped_ptr<Child> myChild;
};

上面的设计中,Child 的指针不能泄露给外界,否则仍然有可能出现空悬指针。

如果 Parent 与 Child 的生命期相互独立

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Parent;
typedef boost::scoped_ptr<Parent> ParentPtr;

class Child : boost::noncopyable
{
public:
explicit Child(const ParentPtr& myParent_)
: myParent(myParent_)
{ }

private:
boost::scoped_ptr<Parent> myParent;
};

typedef boost::scoped_ptr<Child> ChildPtr;

class Parent : public boost::enable_shared_from_this<Parent>,
private boost::noncopyable
{
public:
Parent()
{ }

void addChild()
{
myChild.reset(new Child(shared_from_this()));
}

private:
ChildPtr myChild;
};

int main()
{
ParentPtr p(new Parent);
p->addChild();
}

考虑一个稍微复杂一点的对象模型:“a Child has parents: mom and dad; a Parent has one or more Child(ren); a Parent knows his/her spouse.”

1
2
3
4
5
6
7
8
9
10
11
12
// Java 实现
public class Parent
{
private Parent mySpouse;
private ArrayList<Child> myChildren;
}

public class Child
{
private Parent myMom;
private Parent myDad;
}

如果用 C++ 来实现,如何才能避免出现空悬指针,同时避免出现内存泄露呢?借助 shared_ptr 把裸指针转换为值语义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Parent;
typedef boost::shared_ptr<Parent> ParentPtr;

class Child : boost::noncopyable
{
public:
explicit Child(const ParentPtr& myMom_,
const ParentPtr& myDad_)
: myMom(myMom_)
, myDad(myDad_)

{
}

private:
boost::weak_ptr<Parent> myMom;
boost::week_ptr<Parent> myDad;
};

typedef boost::shared_ptr<Child> ChildPtr;

class Parent : boost::noncopyable
{
public:
Parent()
{
}

void setSpouse(const ParentPtr& spouse)
{
mySpouse = spouse;
}

void addChild(const ChildPtr& child)
{
myChildren.push_back(child);
}

private:
boost::weak_ptr<Parent> mySpouse;
std::vector<ChildPtr> myChildren;
};

int main()
{
ParentPtr mom(new Parent);
ParentPtr dad(new Parent);
mom->setSpouse(dad);
dad->setSpouse(mom);
{
ChildPtr child(new Child(mom, dad));
mom->addChild(child);
dad->addChild(child);
}
{
ChildPtr child(new Child(mom, dad));
mom->addChild(child);
dad->addChild(child);
}
}

四、值语义与标准库

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

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

五、值语义与C++语言

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

  • class 的 layout 与 C struct 一样,没有额外的开销。定义一个“只包含一个 int 成员的 class” 的对象开销和定义一个 int 一样。
  • 甚至 class data member 都默认是 uninitialized,因为函数局部的 int 也是如此。
  • 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。C++ 的函数调用比其他语言复杂之处在于参数传递和返回值传递。C、Java 等语言都是传值,复制几个字节的内存就行了。但是 C++ 对象是值语义,如果以 pass-by-value 方式把对象传入函数,会涉及拷贝构造。代码里看到一句简单的函数调用,实际背后发生的可能是一长串对象构造操作,因此减少无谓的临时对象是 C++ 代码优化的关键之一。
  • 当函数返回一个 class type 时,只能通过 make a copy (C++ 不得不定义 RVO 来解决性能问题)。因为函数返回 int 时是 make a copy。
  • 以 class type 为成员时,数据成员是嵌入的。例如 pair< complex, size_t > 的 layout 就是 complex< double > 挨着 size_t。

六、什么是数据抽象

数据抽象 (data abstraction) 是与面向对象 (object-oriented) 并列的一种编程范式。

C++ is a general-purpose programming language with a bias towards systems programming that

  • is a better C
  • supports data abstraction
  • supports object-oriented programming
  • supports generic programming

数据抽象是用来描述(抽象)数据结构的。数据抽象就是 ADT。一个 ADT 主要表现为它支持的一些操作,比如 stack::push()、stack::pop(),这些操作应该具有明确的时间和空间复杂度。另外,ADT 可以隐藏其实现细节,例如 stack 既可以用动态数组实现,又可以用链表实现。ADT 通常是值语义,而 object-based 是对象语义。ADT class 是可以拷贝的,拷贝之后的 instance 与原 instance 脱离关系。

1
2
3
4
stack<int> a;
a.push(10);
stack<int> b = a;
b.pop();

栈 a 里元素10还在。

C++标准库中的数据抽象

vector 是动态数组,它的主要操作有 size()、begin()、end()、push_back()等等。list 是链表,map 是有序关联数组,set 是有序集合、stack 是 FILO 栈、queue 是 FIFO 队列。

数据抽象与面向对象的区别

面向对象(object-oriented)有三大特征:封装、继承、多态。基于对象(object-based)则只有封装,没有继承和多态,即只有具体类,没有抽象接口。它们两个都是对象语义。

面向对象的核心思想是消息传递(messaging)。数据抽象不是对象语义,而是值语义。比方说 muduo 里的 TcpConnection 和 Buffer 都是具体类,但前者是基于对象的(object-based),而后者是数据抽象。

如果一个 class 代表了其他资源(文件、员工、打印机、账号),那么它通常就是 object-based 或 object-oriented,而不是数据抽象。ADT class 可以作为 Object-based/object-oriented class 的成员,但反过来不成立。

七、数据抽象所需的语言设施

支持数据聚合

数据聚合即 data aggregation,或者叫 value aggregation。即定义 C-style struct,把有关数据放到同一个 struct 里。这种数据聚合 struct 是 ADT 的基础,struct list、struct HashTable 等能把链表和哈希表结构的数据放到一起,而不是用几个零散的变量来表示它。

全局函数与重载

定义了 complex,可以同时定义 complex sin(const complex& x) 和 complex exp(const complex& x) 等等全局函数来实现复数的三角函数和指数运算。sin() 和 exp() 不是 complex 的成员,而是全局函数 double sin(double) 和 double exp(double) 的重载。这样能让 double a = sin(b); 和 complex a = sin(b); 具有相同的代码形式,而不必写成 complex a = b.sin();。

成员函数与private数据

数据声明为 private,可以防止外界意外修改。不是每个 ADT 都适合把数据声明为 private,例如 complex、Point、pari<> 这样的 ADT 使用 public data 更加合理。

要能够在 struct 里定义操作,而不是只能用全局函数来操作 struct。比方说 vector 有 push_back() 操作,push_back() 是 vector 的一部分,它必须直接修改 vector 的 private data members,因此无法定义为全局函数。

拷贝控制(copy control)

copy data 是拷贝 stack a; stack b = a; 和赋值 stack b; b = a; 的合称。

C++ class 是值语义,copy control 是实现深拷贝的必要手段,而且 ADT 用到的资源只涉及动态分配的内存,所以深拷贝是可行的。相反,object-based 编程风格中的 class 往往代表某样真实的事物(Employee、Accout、File 等等),深拷贝无意义。

C 语言没有 copy control,也没有办法防止拷贝。File* 可以随意拷贝,但是主要关闭其中一个 copy,其他 copy 也都失效了。整个 C 语言对待资源(malloc() 得到的内存,open() 打开的文件,socket() 打开的连接)都是这样的,用整数或指针来代表(即句柄)。而整数和指针类型的的句柄是可以随意拷贝的,很容易造成重复释放、遗漏释放、使用已经释放的资源等常见错误。

操作符重载

如果写动态数组,能像使用内置数组一样使用它,比如支持下标操作。C++ 可以重载 operator[] 来做到这一点。如果要写复数,能像使用内置的 double 一样使用它,比如支持加减乘除。C++ 可以重载 operator+ 等操作符来做到这一点。如果要写日期与时间,希望它能直接用大于或小于号来比较先后,用 == 来判断是否相等。C++ 可以重载 operator< 来做到这一点。这要求语言能重载成员与全局操作符。

如果没有操作符重载,那么用户定义的 ADT 与内置类型用起来不一样了(有的语言要区分 == 和 equals)。Java 里有 BigInteger,但是 BigInteger 用起来和普通的 int/long 大不相同。

1
2
3
4
5
6
7
8
public static BigInteger mean(BigInteger x, BigInteger y) {
BigInteger two = BigInteger.valueOf(2);
return x.add(y).divide(two);
}

public static long mean(long x, long y) {
return (x + y) / 2;
}

效率无损

在 C++ 中,提高抽象的层次并不会降低效率。

模板与泛型

写了个 IntVetcor,不想为 double 和 string 再实现一遍同样的代码。应该把 vector 写成 template,然后用不同的类型来具现化它,从而得到 vector< int >、vector< double >、vector< complex >、vector< string > 等具体类型。

评论

:D 一言句子获取中...

加载中,最新评论有1分钟缓存...