Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

关于对象——C++对象模式

Posted on 2018-04-30 | In Inside the C++ object model

前言

 
在C++对象中,data member分为两种:

  1. static
  2. non-static

function member分为三种:

  1. static
  2. non-static
  3. virtual

已知Point class声明如下:

1
2
3
4
5
6
7
8
9
10
11
class Point{
public:
Point(float xval);
virtaul ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream& print(ostream &os) const;
float _x;
static int _point_count;
};

我们应该以何种策略塑模出data member及function member?


简单对象模型

 
简单对象模型是了尽量降低C++编译器的复杂程度而开发出来的,并且因此付出了空间损耗增加与执行效率降低的代价。在这个简单模型中,一个object是一组slots,每一个slot指向了一个memeber:image_1ccagmliuke41hre8hg1ru1dcb9.png-64.7kB
在该简单对象模型中,member本身不并在对象内,仅有指向member的指针才位于对象内,这样保证了对象内部只需要存放一种数据类型:指针。显然,对象的大小等于member数量*指针大小。
尽管这种模型并不应用于实际生产,不过这种理念倒是被做成了某种设计手法:pimpl,或者Bridge设计模式。


表格驱动式对象模型

 
为了保证所有classes的objects均具有一致的表达方式,另一种对象模型的做法是:将所有与members相关的信息抽离出来,放在一个member data table与member function table之中。member data table直接持有数据,member function table则是一系列slots,每一个slots指向一个member function:image_1ccahehmvd6eeqqsd01qc18um.png-49.9kB
虽然这种模型也没有应用于直接生产,但member function table的观念却成为了支撑virtual function的一个有效方案。


C++对象模型

 

不考虑继承

C++对象模型由简单对象模型演化而来,并对内存空间及存储时间做出了优化。
在此模型中,non-static data members被配置于object之内,static data members则被存储于所有objects之外。static or non-static function members也都被存储于objects之外。
virtual function以两个步骤实现:

  1. 每一个clss产生一堆指向virtual functions的指针,用一个表格存放之,该表格也被成为虚函数表(vtbl)。虚函数表的第一个slot存放着当前class所对应的type_info object。(RTTI)
  2. 每一个object内部都存有指向vtbl的指针vptr,vptr的setting与resetting由构造、析构、拷贝赋值运算符自动完成。

image_1ccaikqsb150q9tr18s6e7gh0t13.png-58.5kB
该模型的优势主要在于空间与存储时间的效率,但缺陷也很明显:non-static data members一旦有所修改,应用程序就需要重新编译。在编译弹性方面,上一小节所提及的双表格模型就做的很好。


加入继承

C++支持单一继承与多重继承,甚至,继承关系也可以指定为virtual(可以理解为共享):
image_1ccaj89ql1qrvmpd1f0v1bmk14a61g.png-14.6kB
在虚继承的情况下,base class无论在继承体系中被派生多少次,其永远只会存在一个实体(subobject),例如iostream中就只有一个virtual ios base class的实体。

derived class塑模base class实体

在简单对象模型中,每一个base class可以被derived class object内的slot指出,该体系的缺点是:因为间接性导致空间和存取时间需要额外开销,优点在于class object不会因其base class发生改变而受到影响。

base class table模型:base class table被产出后,表中的每一个slot内含一个base class地址,就如同vtbl内含virtual function的地址一般。每一个class含有一个bptr,指向其base class table。该体系的缺点在于:因为间接性导致空间和存取时间需要额外开销。优点在于每一个class object对于继承都有一致的表现方式:每一个class object的固定位置都有一个base table指针,与base classes的大小或数目无关。此外,无需改变class objects本身,就可以放大、缩小、或更改base class table。

下图展示了ios继承体系在base class table下的对象模型:image_1ccb2lsns1aulp3q1dth1dh0d299.png-228.8kB
不管哪一种对象模型,“间接性”总是随着继承体系深度的增长而增加,也就是存取操作的时间会增长。当然,derived class可以多放置一些指针,指向继承体系中的每一个base class,但这付出了空间成本。

C++最初采用的对象模型不具备任何间接性:base class subobject的data member被直接置于derived class中,这保证了高效存取,缺点在于base class一旦发生改动,整个继承体系都必须重新编译。
virtual base class的导入增加了间接性,其原始模型是在class objects中为每一个有关联的virtual base class加上一个指针。其他模型无非是两种情况:导入一个virtual base class table,又或者扩充vtbl,在其中放入virtual base class的地址。


对象模型如何影响程序

 
不同对象模型,会导致如下两个结果:

  1. 现有程序必须修改
  2. 必须加入新的程序

实例

假设现有class X,定义有copy constructor,virtual destructor,以及一个virtual function foo,考虑如下函数:

1
2
3
4
5
6
7
8
X foobar(){
X xx;
X *px = new X;
xx.foo();
px->foo();
delete px;
return xx;
}

该函数可能会在内部被转化为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void foobar(X &_result){
//构造_result并取代local xx
_result.X::X();
//转化new语句
px = _new(sizeof(X));
if(px)
px->X::X();
//执行非虚函数
foo(&_result);
//执行虚函数
(*px->vtbl[2])(px);
//转化delete语句
if(px){
(*px->vtbl[1](px));
_delete(px);
}
//不需要摧毁local object xx
return;
}

这就是对象模型将实际代码转化后的一个可能实例,其解释可见后续诸章,下图给出了部分注解:image_1ccb4gg0fogo1ee2fm6k1enrmm.png-250.6kB

关于对象——前言

Posted on 2018-04-30 | In Inside the C++ object model

C语言

 
在C语言中,“数据”及“处理数据的操作”是分开声明的,这种处理方式被称为procedural:一组“分布在各个以功能为导向的函数中”的算法驱动和处理外部数据。

C struct实例

假设我们当前存在一个struct Point3d:

1
2
3
4
5
typedef struct point3d{
float x;
float y;
float z;
} Point3d;

如果我们需要打印该Point3d,则必须定义这样一个函数:
1
2
3
void Point3d_print(const Point3d *pd){
printf("(%f,%f,%f)",pd->x,pd->y,pd->z);
}

又或者为了追求效率,直接使用函数宏:
1
#define Point3d_print(pd)   printf("(%f,%f,%f)",pd->x,pd->y,pd->z);

同样地,某个点的特定坐标可以直接存取:
1
2
3
4
5
Point3d pt;
pt.x = 0.0;
//宏操作
# define SetX(p,xval) (p.x)=(xval);
SetX(pt,0.0);


C++

ADT

C++中实现Point3d可能会使用独立的“抽象数据类型”(abstract data type,ADT)来实现:

1
2
3
4
5
6
7
8
class Point3d{
public:
...
private:
...
};
inline
ostream& operator<<(ostream &os,const Point3d &pt){...}

继承体系

又或者以一个两层到三层的继承体系完成:image_1ccad3v788kbnc78s3bg4bfb9.png-9.4kB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Point{
public:
...
protected:
...
};
class Point2d:public Point{
public:
...
protected:
...
};
class Point3d:public Point2d{
public:
...
protected:
...
};

模板化

我们甚至可以更进一步地抽象,将坐标值类型甚至坐标数量参数化:

1
2
3
4
5
6
7
template <typename type,int dim>
class Point{
public:
...
private:
...//内含数组或容器
}


对比

 
仅从Point来看,C与C++处理问题的方式截然不同,这并非是语言间的区别,而是过程式与面向对象的差别。
从软件工程的角度而言,封装性比全局数据要好,但也付出了编写和使用上的代价。


封装的成本

 
从Point的角度而言,封装并未带来任何成本:

  1. data
    所有的数据均存储于Object内,就如同struct中的情况一样。
  2. member function
    成员函数虽然包含于class的声明之内,但却不会出现在object中。每一个non-inline成员函数都只会生成一个函数实体。

C++在内存布局以及存储时间上的额外负担主要由virtual引起:

  • virtual function
    实现函数执行期绑定。
  • virtual base class
    实现“多次出现在继承体系中的base class在派生类中只存在一个可共享的实体”。

一般而言,我们没有理由认为C++就一定会比C庞大且迟缓。

34.混合使用C与C++

Posted on 2018-04-30 | In More Effective C++

前言

 
在混合编程前,首先确保你的C++编译器和C编译器兼容。确认兼容后,还有四个需要考虑的问题:名变换、静态初始化、内存动态分配、数据结构兼容。


名变换

 
名变换,就是C++编译器给程序的每个函数换一个独一无二的名字。C没有函数重载,因此不需要该过程。重载不兼容于绝大部分链接程序,因为链接程序通常无法分辨同名的函数,它坚持函数名必须独一无二。名变换是C++对编译器的妥协。
在C++使用范围内用户无需考虑名变换,但在C运行库中,那么情况截然不同。

问题实例

举例而言,在Cpp包含的头文件中,drawline被声明为:

1
void drawLine(int x1, int y1, int x2, int y2);

代码体中通常也是调用drawLine,每一个这样的调用都被编译器转换为调用名变换后的函数,所以写下的是
1
drawLine(a, b, c, d);

在obj中被调用的是:
1
xyzzy(a, b, c, d);//编译器变换了名称

但如果drawline是一个C函数,obj文件(或者是动态链接库之类的文件)中包含的编译后的drawline函数仍然叫drawline,不会发生名变换动作。这意味着链接时将发生错误,因为链接程序找不到xyzzy函数的存在。


解决方案(extern “C”)

我们需要某种方法告诉C++编译器不要在这个函数上进行名变换,就是使用C++的extern "C"关键词:

1
2
extern "C"
void drawLine(int x1, int y1, int x2, int y2);

不要以为有一个extern “C”,那么就应该同样有一个extern “Pascal”和extern “FORTRAN”之类。该关键词的意思是下述函数应该被当作C语言写的一样。所以汇编也是用extern “C”声明,甚至可以在C++函数上申明 extern “C”。(用C++写库给其他语言的客户使用),这样编译器就不会对你的函数执行名变换。
如果你需要批量地声明函数无需名变换,extern “C”可以对一组函数生效,只要将它们放入一对大括号中:
1
2
3
4
5
6
extern "C" {
void drawLine(int x1, int y1, int x2, int y2);
void twiddleBits(unsigned char bits);
void simulate(int iterations);
...
}

这种技巧普遍适用于头文件中。假设当前头文件可能被C++编译器编译也可能会被C语言编译器编译。当用C++编译时,你应该加 extern “C”,但用C编译时则不必。通过只在C++编译器下定义的宏__cplusplus,头文件的写法很简单:
1
2
3
4
5
6
7
8
9
10
#ifdef __cplusplus
extern "C"{
#endif
void drawLine(int x1, int y1, int x2, int y2);
void twiddleBits(unsigned char bits);
void simulate(int iterations);
...
#ifdef __cplusplus
}
#endif

顺便提及一下,名变换没有标准规则,不同的编译器名变换不同。


静态初始化

 
在C++中,main函数执行前和执行后都有大量代码被执行。具体来说,静态的类对象和定义在全局的、命名空间中的或文件体中的类对象的构造函数通常在main执行前就被调用。这个过程称为静态初始化。同样,通过静态初始化产生的对象也要在静态析构过程中调用其析构函数,这个过程通常发生在main结束
运行之后。
为了解决main应该首先被调用,而对象又需要在main执行前被构造的两难问题,许多编译器在main的最开始处插入了一个特别的函数,由它来负责静态初始化。同样地,编译器在main结束处插入了一个函数来析构静态对象:

1
2
3
4
5
int main(int argc, char *argv[]){
performStaticInitialization();
...//the statements you put in main go here;
performStaticDestruction();
}

示例中函数的名称不需要在意,关注要点是:如果一个C++编译器采用这种方法来初始化和析构静态对象,那么除非你用C++写了main函数,否则这些对象既不会构造也不会析构。因此只要代码中有C++部分,就应该使用C++的main函数。
但有时似乎用C写main更有意义————比如当前程序的大部分都是用C写的,C++部分只是一个支持库。然而,这个C++库很可能含有静态对象(即使现在没有,以后也可能会有),所以仍然需要用C++写main函数。但我们并不需要改写C语言代码,只要将C写的main改名为realmain,然后用C++版本的main调用realMain:
1
2
3
4
5
extern "C"
int realMain(int argc, char *argv[]);
int main(int argc, char *argv[]){//in C++
return realMain(argc, argv);
}

如果不这么写,就无法确保确保静态对象是否已经构造和析构。


动态内存分配

 
动态内存分配的规则很简单:C++部分使用new和delete,C部分使用malloc和free。用free释放new 分配的内存或用delete释放malloc分配的内存,其行为没有定义。
说起来容易,做起来未必简单。比如strdup函数,它并不存在于C和C++标准库中,却很常见:

1
char * strdup(const char *ps); // return a copy of the string pointed to by ps

为了避免内存泄漏,strdup的调用者必须释放分配的内存。那是使用delete还是free呢?如果你调用的 strdup来自于C函数库中,使用free,否则使用delete.
在调用strdup后所需要做的操作,在不同的操作系统下不同,在不同的编译器下也不同。为了减少这种可移植性问题,应当尽可能避免调用那些既不在标准运行库中也没有固定形式的函数 。


数据结构兼容性

 
最后一个问题是在C++和C之间传递数据。
C与C++的交互必须限定在C可表示的概念上。因此,不可能传递给C语言对象或者成员函数指针。但C中存在着普通指针的概念,所以C和C++的函数可以安全地交换”指向对象的指针”和”指向非成员的函数或静态成员函数的指针”。同样,struct和内置类型的变量也可自由交换。
因为C++中struct兼容C中的规则,因此C编译器和C++编译器处理struct得到的结果一样。在C++ struct中增加非虚函数不会导致内存结构发生改变,因此,只有非虚函数的strcut的对象兼容于它们在C 中的孪生版本(C++成员函数并不在对象的内存布局中体现)。增加虚函数和加入继承体系会改变内存布局,此时无法完成C与C++的安全交换。
就数据结构而言,我们可以认为在C++和C之间相互传递数据结构是安全的(前提是结构式的定义在C和C++中都可编译)。在 C++版本中增加非虚成员函数或许不影响兼容性,但几乎其它的改变都将影响兼容。

33.将非尾端类设计为抽象类

Posted on 2018-04-29 | In More Effective C++

问题实例

 
假设我们正在抽象动物,其中晰蜴和小鸡需要特别处理,当前继承体系如下:image_1cc8lhmb189u17ud1g9qlaf1fc89.png-40.4kB
动物类处理所有动物共有的特性,晰蜴类和小鸡类特别化动物类以适用这两种动物的特有行为。它们的简化版定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal {
public:
Animal& operator=(const Animal& rhs);
...
};
class Lizard: public Animal {
public:
Lizard& operator=(const Lizard& rhs);
...
};
class Chicken: public Animal {
public:
Chicken& operator=(const Chicken& rhs);
...
};

考虑如下表达式:
1
2
3
4
5
6
Lizard liz1;
Lizard liz2;
Animal *pAnimal1 = &liz1;
Animal *pAnimal2 = &liz2;
...
*pAnimal1 = *pAnimal2;

显然,最后的赋值操作符调用的是Animal的,所以直接导致了liz1只有animal部分被修改,也就是部分赋值。


解决方案

虚函数

一个解决方法是将赋值运算符声明为虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal {
public:
virtual Animal& operator=(const Animal& rhs);
...
};
class Lizard: public Animal {
public:
virtual Lizard& operator=(const Animal& rhs);
...
};
class Chicken: public Animal {
public:
virtual Chicken& operator=(const Animal& rhs);
...
};

这种写法直接导致了operator=可以接受任意类型的Animal对象,也就是说,下面的代码是合法的:
1
2
3
4
5
Lizard liz;
Chicken chick;
Animal *pAnimal1 = &liz;
Animal *pAnimal2 = &chick;
*pAnimal1 = *pAnimal2;

显然这是一个混合类型赋值。在未引入虚函数之前,混合类型赋值会被C++的强类型原则判定非法,但引入虚函数之后它们变为了合法。


动态转换与同类型赋值

我们真正想要通过指针来完成同类型赋值,而非通过指针完成混合类型赋值,于是我们可以使用动态转换完成这一操作:

1
2
3
4
5
Lizard& Lizard::operator=(const Animal& rhs){
//make sure rhs is really a lizard
const Lizard& rhs_liz = dynamic_cast<const Lizard&>(rhs);
proceed with a normal assignment of rhs_liz to *this;
}

在上述代码中,如果可以转换则顺利执行operator=,否则抛出bad_cast的异常。


重载赋值

其实我们并不需要非得在运行期使用虚函数与dynamic_cast,下面的代码规避了在常规情况下它们的成本,而仅仅只需要增加了一个普通形式的赋值操作:

1
2
3
4
5
6
class Lizard: public Animal {
public:
virtual Lizard& operator=(const Animal& rhs);
Lizard& operator=(const Lizard& rhs);
...
};

事实上,有了第二个operator=后,第一个虚函数版operator=亦得到了简化:
1
2
3
Lizard& Lizard::operator=(const Animal& rhs){
return operator=(dynamic<const Lizard&>(rhs));
}

现在这个函数试图将rhs转换为一个Lizard。如果转换成功,通常的赋值操作被调用;否则,一个bad_cast异常被抛出。


私有化operator=

在看到了将operator=设置为虚函数之后的弊端后,我们应该想到重新整理代码以确保用户无法写出有问题的赋值语句,最好能够在编译期就会被拒绝。比较容易的方法是在Animal类中把operator=设为private:

1
2
3
4
5
6
7
8
9
10
11
12
class Animal {
private:
Animal& operator=(const Animal& rhs);
};
class Lizard: public Animal {
public:
Lizard& operator=(const Lizard& rhs);
};
class Chicken: public Animal {
public:
Chicken& operator=(const Chicken& rhs);
};

但需要注意的是Animal本身是一个具象类,也就是说这样一来Animal对象就无法执行opertaor=操作,另外,Lizard与Chicken的赋值也不可能正确完成,因为派生类的赋值操作函数需要调用基类的赋值操作函数。第二个问题可以通过将Animal中的operator=设为protected实现,但“不允许混合类型赋值”的任务并未完成,因为Lizard与Chicken仍然可以通过Animal的指针来互相赋值。


修改继承体系

最简单的方法就是修改继承体系。由于我们之前认定Animal必须作为一个实体类存在且有其意义,因此我们创建一个新类 AbstractAnimal,来包含 Animal、 Lizard、Chikcen的共有属性,并把它设为抽象类, 各具象类均由该抽象类派生而出,修改后的继承体系如下所示:image_1cc8mt77im8l89b6ns12b710grm.png-32kB
类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class AbstractAnimal {
protected:
AbstractAnimal& operator=(const AbstractAnimal& rhs);
public:
virtual ~AbstractAnimal() = 0;
...
};
class Animal: public AbstractAnimal {
public:
Animal& operator=(const Animal& rhs);
...
};
class Lizard: public AbstractAnimal {
public:
Lizard& operator=(const Lizard& rhs);
...
};
class Chicken: public AbstractAnimal {
public:
Chicken& operator=(const Chicken& rhs);...
};

至此,一切工作均已完成。同类型间的赋值被允许,部分赋值或不同类型间的赋值被禁止;派生类的赋值操作函数可以调用基类的赋值操作函数。


纯虚析构函数

 
当我们找不到一个函数可以作为纯虚函数时,我们定义可以析构函数为纯虚函数,把析构函数设置为纯虚函数时需要记住必须在类的定义之外实现它。
声明一个函数为纯虚函数并不意味着它没有实现,它意味着:

  1. 当前类是抽象类
  2. 任何从此类派生的实体类必须将此函数申明为一个“普通”的虚函数(不能带“= 0”)

尽管大部分纯虚函数都没有实现,但纯虚函数是一个特例,他必须被实现。因为它们在派生类析构函数被调用时也将被调用。而且,它们经常执行有用的任务,诸如释放资源或记录消息。


非尾端类与抽象类

 
用如AbstractAnimal这样的抽象基类替换如Animal这样的实体基类,其好处并不只有保证operator=的行为正常,它同时减少了你试图对数组使用多态的可能(More Effective C++ 3)。但该技巧最大的好处发生在设计层,因为我们强迫认定所有实体都具备明确行为准则,仅有抽象类承担了抽象的义务。
我们并没有方法明确判断未来情况是否需要抽象类,但我们可以肯定,如果存在两个具象类需要以公有继承的方式相互联系,那么通常表示当前情况需要一个抽象类:
image_1cc8ne4hf1aflqd51cd7oq0liv13.png-35.7kB
显然,C1和C2具有共性,我们通过建立抽象类将它们的共性抽取出来,使其具备明确的语义。但实际使用中我们可能会发现自己没有权限取更改类库。


总结

 
非尾端类应该是抽象类。我们对类库中的程序可能没有办法,但对于能控制的代码,遵守这一约定可以提高程序的可靠性、健壮性、可读性、可扩展性。

32.面向未来编程

Posted on 2018-04-29 | In More Effective C++

前言

 
唯一不变的就是变化本身。


软件的变化性

 
好的软件能够适应变化。软件的灵活性、健壮性、可靠性是程序员们在满足当前需求并关注将来的可能后设计和实现出来的。优秀的软件是那些时刻关注未来的人们编写的。
什么会发生变化呢?继承体系会变,现在的派生类将会是以后的基类;运行平台会变;程序的维护人员会更替,因此程序应该被设计得易于被别人理解、维护和扩充 。


如何表达设计条件的约束

 
我们应该习惯于使用C++语言自身来约束程序的性质,而不是依赖注释或文档。
如果一个类不可继承,那么应该按照More Effective C++ 26中的方法阻止。
如果一个类的实例必须创建于heap中,应该用More Effective C++ 27中的方法来强迫完成。
如果一个类的拷贝构造或赋值无意义,则应该将它们设置为private。
当我们定义成员函数时,应该判明其意义,以及派生类中的它是否有意义。如果它在派生类中依然存在意义,则令它为虚函数,即使当前并不需要override。如果不存在任何意义,声明为非虚函数。确保更改是为了整个类的运行环境和类所表示的抽象,而非仅仅满足某个需求。(Effetive C++ 35)
处理每个类的赋值和拷贝构造函数,以防止有人误调编译器提供的默认版本而产生意料之外的结果。(Effective C++ 07)
当我们建立一个类时,努力保证其操作和语法自然且直观,自定义数据类型的行为尽量与内置类型保持一致。(Effective C++ 19)
要确信用户必然会犯错,因此你的类必须设计得可以预防、检测、及修正这些错误。(Effective C++ 18)
努力去写可移植的代码。只有在性能极其重要时采用不可移植的结构才是可取的。
尽可能地封装,将实现细节申明为私有。(Effective C++ 23、29)
尽可能使用无名命名空间和文件内的静态对象或函数。(More Effective C++ 31)
避免导致虚基类的设计,因为这种类需要每个派生类都直接初始化它--即使是间接派生类。(Effective C++ 41)
避免需要RTTI的设计,因为它需要多重的if-else逻辑判断,每当继承层次改变,都需要更新原有的逻辑。(More Effective C++ 31)
面向未来编程认为:在设计一个类时,不要去关注现在该如何使用该类,而是关注该类被设计为如何使用。如果一个类被设计为作一个基类使用(即使现在还没有被这么使用),它就应该有一个虚析构函数。这样的类在现在和将来都行为正确,并且当新类从它们派生时并不影响其它库用户。


活在当下与面向未来

 
当然,我们依旧活在当下,首先要做的应该是确保完成当下的任务,但未来时态仅仅只是增加了一些额外的约束:

  1. 尽可能提供完备的类, 确保当产生新需求时不用去更改它们。
  2. 将你的接口设计得直观且高效,使class不易被错误使用。
  3. 尽量通用化代码。例如,如果在写树的遍历算法,考虑将其通用化至可以处理任何有向无环图。

总结

 
面向未来编程增加了代码的可重用性、可维护性、健壮性,并且保证了在运行环境发生改变时易于修改。但面向未来编程的前提是完成当前问题已经得到有效解决。

31.让函数根据一个以上的对象类型来决定如何虚化

Posted on 2018-04-29 | In More Effective C++

问题实例

 
假设我们在设计一个游戏,游戏的背景发生在太空,有宇宙飞船、太空站和小行星等对象。这些对象可能会发生碰撞,有规则如下:

  • 如果飞船和空间站以低速接触,飞船将泊入空间站。否则,它们将有正比于相对速度的损坏。
  • 如果飞船与飞船,或空间站与空间站相互碰撞,参与者均有正比于相对速度的损坏。
  • 如果小行星与飞船或空间站碰撞,小行星毁灭。如果是小行星体积较大,飞船或空间站也毁坏。
  • 如果两个小行星碰撞,将碎裂为更小的小行星,并向各个方向溅射。

问题分析

 
首先开始分析对象共性。显然,它们都在运动,都有一个速度描述运动,因此应该存在一个抽象基类,让各对象从抽象基类中派生出来。继承体系如下:image_1cc8b0o0hd4smo21uu2166j18fc9.png-53.9kB

1
2
3
4
class GameObject { ... };
class SpaceShip: public GameObject { ... };
class SpaceStation: public GameObject { ... };
class Asteroid: public GameObject { ... };

接着我们准备撰写最关键的碰撞函数:
1
2
3
4
5
6
7
8
void checkForCollision(GameObject& object1,GameObject& object2){
if (theyJustCollided(object1, object2)) {
processCollision(object1, object2);
}
else {
...
}
}

processCollision函数必然是一个虚函数,但关键问题在于,该函数的虚化并非由一个对象类型决定,而是由两个对象类型共同决定。
这种二重调度问题被称为double dispatch,相应地当然也有multiple dispatch。C++并没有提供相应的功能,因此我们必须手动模拟编译器来进行实现。


虚函数+RTTI

 
我们在GameObject中申明一个虚函数collide。这个函数被派生类以通常的形式重载:

1
2
3
4
5
6
7
8
9
10
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
...
};
class SpaceShip: public GameObject {
public:
virtual void collide(GameObject& otherObject);
...
};

为了节约篇幅,下文只针对派生类SpaceShip做出详细描述,其他派生类的情况类似于SpaceShip。
实现double-dispatch最常见的写法就是if-else逻辑链。在这种方法中,我们首先判断rhs的真实类型,然后测试所有可能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CollisionWithUnknownObject {//异常类
public:
CollisionWithUnknownObject(GameObject& whatWeHit);
...
};
void SpaceShip::collide(GameObject& otherObject){
const type_info& objectType = typeid(otherObject);
if (objectType == typeid(SpaceShip)) {
SpaceShip& ss = static_cast<SpaceShip&>(otherObject);
...//process a SpaceShip-SpaceShip collision;
}
else if (objectType == typeid(SpaceStation)) {
SpaceStation& ss = static_cast<SpaceStation&>(otherObject);
...//process a SpaceShip-SpaceStation collision;
}
else if (objectType == typeid(Asteroid)) {
Asteroid& a = static_cast<Asteroid&>(otherObject);
process a SpaceShip-Asteroid collision;
}
else {
throw CollisionWithUnknownObject(otherObject);
}
}

我们完全放弃了封装:每一个object都必须了解其同胞类的版本,一旦增了新类,我们必须更新每一个RTTI体系。
在没有虚函数的概念时,这种写法就是虚函数的粗陋实现,显然这种程序毫无维护性。


只使用虚函数

 
这个方法架构与RTTI版本其实无二,只不过其collide函数增加了各种重载版本,每一个重载处理一个类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SpaceShip;
class SpaceStation;
class Asteroid;
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
virtual void collide(SpaceShip& otherObject) = 0;
virtual void collide(SpaceStation& otherObject) = 0;
virtual void collide(Asteroid& otherobject) = 0;
...
};
class SpaceShip: public GameObject {
public:
virtual void collide(GameObject& otherObject);
virtual void collide(SpaceShip& otherObject);
virtual void collide(SpaceStation& otherObject);
virtual void collide(Asteroid& otherobject);
...
};

其基本原理就是用两个单一调度实现二重调度,也就是说有两个单独的虚函数调用:以rhs作为函数调用的对象再次调用虚函数,*this的静态类型已知,可以直接匹配。
1
2
3
void SpaceShip::collide(GameObject& otherObject){
otherObject.collide(*this);
}

该方法的缺陷和RTTI一样:每一个类都必须知道其同胞类,当增加新类时,所有代码必须更新,而且必须为每一个新类增加一个新的虚函数。如果当前依赖于某个运行库,这样的改动会导致整个运行库都必须重新编译。


模拟虚函数表

 
在More Effective C++ 24中我们提及了vtbl,虚函数实现的基础。使用vtbl,编译器避免了使用if…then…else链,并能在所有调用虚函数的地方生成同样的代码。我们没有理由无法在之前的RTTI体系中模拟vtbl。
首先,我们对GameObjcet继承体系中的函数作一些修改,为每个子类添加对应的碰撞函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
...
};
class SpaceShip: public GameObject {
public:
virtual void collide(GameObject& otherObject);
virtual void hitSpaceShip(SpaceShip& otherObject);
virtual void hitSpaceStation(SpaceStation& otherObject);
virtual void hitAsteroid(Asteroid& otherobject);
...
};
void SpaceShip::hitSpaceShip(SpaceShip& otherObject){
process a SpaceShip-SpaceShip collision;
}
void SpaceShip::hitSpaceStation(SpaceStation& otherObject){
process a SpaceShip-SpaceStation collision;
}
void SpaceShip::hitAsteroid(Asteroid& otherObject){
process a SpaceShip-Asteroid collision;
}

在Spaceship::collide中,我们需要一个方法来映射参数otherObject的动态类型到一个成员函数指针,我们使用一个中间函数lookup来完成此工作:
1
2
3
4
5
6
class SpaceShip: public GameObject {
private:
typedef void (SpaceShip::*HitFunctionPtr)(GameObject&);
static HitFunctionPtr lookup(const GameObject& whatWeHit);
...
}

既然有了lookup,那么collide的实现就很简单了:
1
2
3
4
5
6
7
8
9
void SpaceShip::collide(GameObject& otherObject){
HitFunctionPtr hfp = lookup(otherObject);
if(hfp) {
(this->*hfp)(otherObject);
}
else {
throw CollisionWithUnknownObject(otherObject);
}
}

接下来要做的就是保证动态类型与成员函数之间的映射了。


映射表

首先要做的是一个映射表。该映射表应该在它被使用前构造与初始化,并在不被需要时析构。以上操作应该由编译器自动完成,所以我们将映射表设为lookup函数中的static对象,在第一次调用lookup时构造,在main退出后自行析构:

1
2
3
4
5
6
7
8
9
10
11
class SpaceShip: public GameObject{
private:
typedef void (SpaceShip::*HitFunctionPtr)(GameObject&);
typedef map<string, HitFunctionPtr> HitMap;
...
};
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit){
static HitMap collisionMap;
...
}

通过使用map的find函数,lookup实现如下:
1
2
3
4
5
6
7
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit){
static HitMap collisionMap;
auto mapEntry = collisionMap.find(typeid(whatWeHit).name());
if (mapEntry == collisionMap.end()) return nullptr;
return mapEntry->second;
}


初始化映射表

我们需要写一个私有的静态成员函数initializeCollisionMap来构造和初始化映射表,然后用其返回值来初始化collisionMap:

1
2
3
4
5
6
7
8
9
10
class SpaceShip: public GameObject {
private:
static HitMap initializeCollisionMap();
...
};
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit){
static HitMap collisionMap = initializeCollisionMap();
...
}

这种写法意味着我们需要拷贝赋值,如果初始化函数返回指针则可以避免该问题,但堆中对象又可能会发生资源泄露。考虑到RAII,我们可以将 collisionMap改为一个智能指针,它将在自己被析构时delete 所指向的对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SpaceShip: public GameObject {
private:static HitMap* initializeCollisionMap();
...
};
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit){
static auto_ptr<HitMap> collisionMap(initializeCollisionMap());
...
}
SpaceShip::HitMap * SpaceShip::initializeCollisionMap(){
HitMap *phm = new HitMap;
(*phm)["SpaceShip"] = &hitSpaceShip;
(*phm)["SpaceStation"] = &hitSpaceStation;
(*phm)["Asteroid"] = &hitAsteroid;
return phm;
}

但这无法通过编译,因为HitMap内包容的是一堆指向成员函数的指针,他们的参数类型都是GameObject,而hitSpaceShip等函数带的不一样。虽然对象类型可以隐式转换,但函数指针并没有这种转换关系。
使用reinterpret_cast并不是好主意,而且存在极大风险,当spaceship位于多继承体系下时,编译器可能会传输错误的地址。为了不采取类型转换,我们不得不把hit函数的形参改为统一的gameobject,然后在每一个函数中使用dynamic_cast:
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
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
...
};
class SpaceShip: public GameObject {
public:
virtual void collide(GameObject& otherObject);
virtual void hitSpaceShip(GameObject& spaceShip);
virtual void hitSpaceStation(GameObject& spaceStation);
virtual void hitAsteroid(GameObject& asteroid);
...
};
void SpaceShip::hitSpaceShip(GameObject& spaceShip){
SpaceShip& otherShip = dynamic_cast<SpaceShip&>(spaceShip);
process a SpaceShip-SpaceShip collision;
}
void SpaceShip::hitSpaceStation(GameObject& spaceStation){
SpaceStation& station = dynamic_cast<SpaceStation&>(spaceStation);
process a SpaceShip-SpaceStation collision;
}
void SpaceShip::hitAsteroid(GameObject& asteroid){
Asteroid& theAsteroid = dynamic_cast<Asteroid&>(asteroid);
process a SpaceShip-Asteroid collision;
}


使用非成员碰撞函数

 
之前我们一直以成员函数指针存放于map中,这直接导致如果添加新类的话,依然存在更新成员函数的问题,这也导致了重新编译。
另外,当发生A撞B时,应该调用谁的成员函数呢?我们总以左侧参数决定调用对象,实际上我们应该认定,A与B的碰撞应该既不在A中处理也不在B中处理。
当我们把碰撞函数从类中移除,其文件组织形式大致如下:

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
#include "SpaceShip.h"
#include "SpaceStation.h"
#include "Asteroid.h"
namespace { //无名命名空间
void shipAsteroid(GameObject& spaceShip,GameObject& asteroid);
void shipStation(GameObject& spaceShip,GameObject& spaceStation);
void asteroidStation(GameObject& asteroid,GameObject& spaceStation);
...
void asteroidShip(GameObject& asteroid,GameObject& spaceShip){
shipAsteroid(spaceShip, asteroid);
}
void stationShip(GameObject& spaceStation,GameObject& spaceShip){
shipStation(spaceShip, spaceStation);
}
void stationAsteroid(GameObject& spaceStation,GameObject& asteroid){ asteroidStation(asteroid, spaceStation);
}
typedef void (*HitFunctionPtr)(GameObject&, GameObject&);
typedef map<pair<string,string>, HitFunctionPtr > HitMap;
pair<string,string> makeStringPair(const char *s1,const char *s2);
HitMap* initializeCollisionMap();
HitFunctionPtr lookup(const string& class1,const string& class2);
}//end namespace

void processCollision(GameObject& object1,GameObject& object2){
HitFunctionPtr phf = lookup(typeid(object1).name(),typeid(object2).name());
if(phf)
phf(object1, object2);
else
throw UnknownCollision(object1, object2);
}

注意这里使用了无名命名空间,其特点在于命名空间内所有东西均被本文件所私有(类似于声明在文件范围内的static函数)
非成员映射需要两个类型名与一个HitFunctionPtr,但只要用pair把那两个string绑一起就好了。于是映射表初始化函数实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace {
pair<string,string> makeStringPair(const char *s1,const char *s2){
return pair<string,string>(s1, s2);
}
} // end namespace
namespace {
HitMap * initializeCollisionMap(){
HitMap *phm = new HitMap;
(*phm)[makeStringPair("SpaceShip","Asteroid")] =&shipAsteroid;
(*phm)[makeStringPair("SpaceShip", "SpaceStation")] =&shipStation;
...
return phm;
}
} // end namespace

lookup 函数也必须被修改以处理pair<string,string>对象,并将它作为映射表的第一部分:
1
2
3
4
5
6
7
8
namespace {
HitFunctionPtr lookup(const string& class1,const string& class2){
static auto_ptr<HitMap> collisionMap(initializeCollisionMap());
auto mapEntry = collisionMap->find(make_pair(class1, class2));
if (mapEntry == collisionMap->end()) return nullptr;
return (*mapEntry).second;
}
} // end namespace

因为上述函数的声明都在无名命名空间中,因此实现也必须在无名命名空间中。


继承与模拟虚函数表

 
假设我们当前需要区分贸易飞船与军事飞船的区别,无疑,继承体系需要更改:
image_1cc8g09pf18qt12ag5u1hmh1r0am.png-76.5kB
我们会发现lookup根本找不到这两个子类,除非我们再次更新表,这样再次造成了重编译。


再次讨论初始化vtbl

 
之所以会出现上一个问题完全是由于我们的map是完全静态的,每当我们注册一个碰撞函数,其行为便被完全固定。从面向对象的角度而言,我们应该建立一个class存放映射表,并且为它提供动态修改映射关系的成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
class CollisionMap {
public:
typedef void (*HitFunctionPtr)(GameObject&, GameObject&);
void addEntry(const string& type1,const string& type2,
HitFunctionPtr collisionFunction,bool symmetric = true);
void removeEntry(const string& type1,const string& type2);
HitFunctionPtr lookup(const string& type1,const string& type2);
static CollisionMap& theCollisionMap();//单例模式
private:
CollisionMap();
CollisionMap(const CollisionMap&);
};

该类使用了单例模式,保证只存在一张表。同时允许增加对称性碰撞,自动添加对称关系。
为了确保发生碰撞前映射关系已存在,我们可以创建一个register类:
1
2
3
4
5
6
7
8
class RegisterCollisionFunction {
public:
RegisterCollisionFunction(const string& type1,const string& type2,
CollisionMap::HitFunctionPtr collisionFunction,
bool symmetric = true){
CollisionMap::theCollisionMap().addEntry(type1, type2,collisionFunction,symmetric);
}
};

用户于是可以使用此类型的一个全局对象来自动地注册他们所需要的函数:
1
2
3
4
5
6
7
RegisterCollisionFunction cf1("SpaceShip", "Asteroid",&shipAsteroid);
RegisterCollisionFunction cf2("SpaceShip", "SpaceStation",&shipStation);
RegisterCollisionFunction cf3("Asteroid", "SpaceStation",&asteroidStation);
...
int main(int argc, char * argv[]){
...
}

因为这些全局对象在main被调用前就构造了,它们在构造函数中注册的函数也在main被调用前就加入了映射表。如果以后增加了一个派生类或新的碰撞对象,也只需要在主函数执行前加入即可,不需要修改库文件。

30.Proxy Class

Posted on 2018-04-29 | In More Effective C++

前言

 
C++无法自由地创建多维数组,你可能会作出反驳:

1
int data[10][20];

但我指的是以变量作为构建数组的形参:
1
2
3
4
void processInput(int dim1, int dim2){
int data[dim1][dim2];//error
int *data = new int[dim1][dim2];//error
}


实现二维数组

 
为了实现二维数组,我们不得不自己新建一个class,以期待完成功能:

1
2
3
4
5
6
template<class T>
class Array2D {
public:
Array2D(int dim1, int dim2);
...
};

遗憾的是Array2D生成的对象并不具备索引功能。也就是说:
1
2
Array2D<int> data(10, 20);//ok
cout << data[3][6];//error

我们只能最多重载一个operator[],不可能重载operator[][](详见More Effective C++ 7)。
事实上,我们完全有理由相信,一个二维数组在调用operator[]后应当返回一个Array1D对象,然后我们通过重载Array1D的operator[]即可顺利完成索引操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T>class Array2D {
public:
class Array1D {
public:
T& operator[](int index);
const T& operator[](int index) const;
...
};
Array1D operator[](int index);
const Array1D operator[](int index) const;
...
};
Array2D<float> data(10, 20);
cout << data[3][6];//ok

Array2D的用户并不需要知道Array1D类的存在。这个背后的“一维数组”对象从概念上来说,并不是为 Array2D 类的用户而存在的。其用户编程时就象他们在使用真正的二维数组一样
每个Array1D对象扮演的是一个一维数组,而这个一维数组并不存在于使用Array2D的程序中。“用来代表其他对象”的对象通常被称为代理类。Array1D就是一个代理类,它的对象扮演的是一个在概念上不存在的一维数组。


区分opeator[]的读写操作

 
​我们已经了解operator[]会在两种不同的情况下调用:读和写。读是一个右值操作而写是一个左值操作。
在实现引用计数时我们假设所有operator[]操作均为写,这直接导致了一系列麻烦的操作,以及shareable标志位的诞生。
事实上我们可以通过Proxy class区分operato[]的操作,其原理为:将判断读和写的行为推迟到我们明确operator[]的结果会被如何使用。显然,这是Lzay evaluation的一大体现。

String中的proxy对象

结合String实例,我们可以修改operator[],令其返回一个proxy对象而非字符本身,然后观察proxy会被如何使用。
String中的proxy对象只能做3件事:

  1. 创建,指定扮演哪个字符
  2. 将其作为赋值的目标,此时扮演左值
  3. 以其他方式使用,扮演右值

以带有引用计数的String class为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class String {
public:
class CharProxy { // proxies for string chars
public:
CharProxy(String& str, int index);
CharProxy& operator=(const CharProxy& rhs);
CharProxy& operator=(char c);
operator char() const;
private:
String& theString;
int charIndex;
};
const CharProxy operator[](int index) const;
CharProxy operator[](int index);
...
friend class CharProxy;
private:
RCPtr<StringValue> value;
};

当前String的operator[]函数将返回的是CharProxy对象。然而,String类的用户并不需要了解这一点:
1
2
3
4
5
String s1, s2;
...
cout << s1[5];
s2[5] = 'x';
s1[3] = s2[8];


右值操作

1
cout << s1[5];

表达式s1[5]返回的是一个CharProxy对象。由于Proxy对象并没有定义IO流操作,于是编译器开始试图寻找令该语句编译成功的方法,最终找到了隐式转换。以上是代理类被作为右值操作时的常规行为。

左值操作

1
s2[5] = 'x';

表达式s2[5]返回的是一个CharProxy对象,但这次它是赋值操作的目标。由于赋值的目标是CharProxy 类,因此调用的是harProxy类中的赋值操作。这至关重要,因为当进入Proxy对象的赋值函数时,我们明确当前String的operator[]执行了左值操作,因此有必要执行某些操作保证程序正常运行。


String的operator[]实现

1
2
3
4
5
6
const String::CharProxy String::operator[](int index) const{
return CharProxy(const_cast<String&>(*this), index);
}
String::CharProxy String::operator[](int index){
return CharProxy(*this, index);
}

每个函数都创建和返回一个proxy对象来代替字符。根本没有对那个字符作任何操作:我们将它推迟到直到我们知道是读操作还是写操作。
需要注意的是const版本返回一个const Proxy对象,因此它不能被赋值,这正是我们想要的。并且在该函数中为了与构造函数匹配,我们使用了类型转换,此处类型转换构造的对象也是const,不用担心数据被篡改的问题。


Proxy对象的实现

构造与转换

通过operator[]返回的proxy对象记录了它属于哪个string,以及下标。当proxy对象作为右值被使用时,其返回值是一个不可修改的proxy对象:

1
2
3
4
5
String::CharProxy::CharProxy(String& str, int index)
: theString(str), charIndex(index) {}
String::CharProxy::operator char() const{
return theString.value->data[charIndex];
}

赋值操作

赋值操作的实现如下:

1
2
3
4
5
6
7
8
String::CharProxy&
String::CharProxy::operator=(const CharProxy& rhs){
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex];
return *this;
}

以char作为形参执行的赋值操作也大同小异:
1
2
3
4
5
6
7
String::CharProxy& String::CharProxy::operator=(char c){
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
theString.value->data[charIndex] = c;
return *this;
}

当然,我们应当编写一个private member function消除它们之间的代码重复。


Porxy class的局限性

Proxy并非完美,在某些场合下它离无缝替代差的很远。

取址

1
2
String s1 = "Hello";
char *p = &s1[1]; // error!

我们没法把一个charProxy*赋值给char*,要解决的话只能重载取址运算符。
const版本很容易实现:

1
2
3
const char * String::CharProxy::operator&() const{
return &(theString.value->data[charIndex]);
}

non-const版本则颇类似于前一节中的operator[]:
1
2
3
4
5
6
7
char * String::CharProxy::operator&(){
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
theString.value->markUnshareable();//无法保证其被用于何种操作,因此锁定不可共享
return &(theString.value->data[charIndex]);
}

带引用计数的数组模板

如果我们想用proxy类来区分其operator[]作左值还是右值时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T>
class Array {
public:
class Proxy {
public:
Proxy(Array<T>& array, int index);
Proxy& operator=(const T& rhs);
operator T() const;
...
};
const Proxy operator[](int index) const;
Proxy operator[](int index);
...
};

该带有引用计数的数组可能被这样使用:
1
2
3
4
5
Array<int> intArray;
...
intArray[5] = 22;
intArray[5] += 5;//error!
++intArray[5];//error!

无法编译的原因在于我们没有为代理类重载这些操作符。相似的,我们也无法通过代理类来调用实际对象的member function,也没法作为非const的引用传给函数。

隐式类型转换

我们之所以能用代理类是因为它和真实对象间存在隐式转换,但当我们期待原对象发生隐式转换时,表达式将无法通过编译,原因在于隐式转换无法在同一时间触发多次。


总结

 
Proxy Class可以完成一些其它方法很难甚至不可能实现的行为,例如:

  1. 多维数组
  2. 区分左右值操作
  3. 限制隐式类型转换

Proxy Class亦有缺点,代理对象始终是一个临时对象,不可避免地存在着构造与析构的成本。

29.引用计数

Posted on 2018-04-29 | In More Effective C++

前言

 
引用计数允许多个具有相同值的对象共享这个值的实现,其作用大致有二:

  1. 简化跟踪堆中对象。
    在使用引用计数后,对象明确自己拥有自己的资源,当没人使用时自动销毁,可以算是一个简单的垃圾回收。
  2. Lazy evaluation
    如果很多对象拥有相同的值,我们不应该存储这个值,而是让所有对象共享其实现。

引用计数实例

 
我们首先复习一下Lazy evaluation。

1
2
3
4
5
6
7
8
9
10
class String {//自定义string类
public:
String(const char *value = "");
String& operator=(const String& rhs);
...
private:
char *data;
};
String a, b, c, d, e;
a = b = c = d = e = "Hello";

a到e的具体值形态其实取决于string类的实现。如果不作特殊化处理,每一个string对象均应具有一个值的拷贝,此时有赋值操作符实现如下:
1
2
3
4
5
6
7
String& String::operator=(const String& rhs){
if (this == &rhs) return *this;
delete [] data;
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
return *this;
}

显然,在这种实现下,abcde如下所示:
image_1cc7oo06d1n3v1sjt1omc1h6b15gc9.png-48.3kB
​这无疑是冗余的,我们希望的理想情况是这样:
image_1cc7opfrj1avv1hha5ub1sdu84tm.png-30.9kB
但这种情况是不现实的,我们至少应该记录当前有多少对象在使用该资源,增设计数器之后的效果如下:image_1cc7oqndik0j1i0h1ue21mmkgue13.png-35.5kB


实现引用计数

 
仍以String为例。首先我们应当明确需要空间来存储计数值。该空间不可能存在于string内部,因为引用计数的本质是每一个资源一个计数,而非一个对象一个计数,这也表明了资源和引用计数之间存在一种耦合关系。
我们使用一个名为StringValue的struct帮助我们实现上述功能。它不仅仅保存计数器,同时也保存资源。我们将其置于String的private部分(将一个struct内嵌在类的私有区内,能便于这个类的所有成员访问这个结构,但阻止了其它任何人对它的访问)其设计与实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class String {
public:
...
private:
struct StringValue {
int refCount;
char *data;
StringValue(const char *initValue);
~StringValue();
};
StringValue* value;
};
String::StringValue::StringValue(const char *initValue)
:refCount(1){
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::~StringValue(){
delete [] data;
}

这便是引用计数的全部,你所认为缺少的功能将由String类提供。StringValue的功能主要就是:将一个特殊的值与共享此值的对象的数目联系起来。


String的构造

1
2
3
4
5
6
7
class String {
public:
String(const char *initValue = "");
...
};
String::String(const char *initValue)
: value(new StringValue(initValue)){}

可以看出,String对象是独立构造的,有同样初始化值的对象并不共享数据:

1
2
String s1("More Effective C++");
String s2("More Effective C++");

产生的数据结构如下所示:image_1cc7qinp41b8b1pd851p11dd1gv61t.png-42kB
消除这样的副本是可能的:通过让String或StringValue对象跟踪已存在的StringValue对象,并只在不同串时才创建新的对象。


String的拷贝

String 的拷贝构造函数效率很高,新生成的String对象与被拷贝的对象共享相同的StringValue对象:

1
2
3
4
String::String(const String& rhs)
: value(rhs.value){
++value->refCount;//需要注意value在heap中,为对象所共有
}

如下代码产生的数据结构如图所示:
1
2
String s1("More Effective C++");
String s2 = s1;

image_1cc7qpffq11lo13nkif8m84r0d2a.png-25.3kB
这必然比值拷贝系列效率要高,在本次拷贝中,我们只不过是拷贝了一个指针并增加了一次引用计数。


String的析构

析构函数实现较为容易:只要引用计数值不为0,也就是当前至少存在一个String对象使用这个值,这个值就不可以被销毁:

1
2
3
4
5
6
7
8
class String {
public:
~String();
...
};
String::~String(){
if (--value->refCount == 0) delete value;//先执行自减
}


String的赋值

当用户写下这样的代码:

1
s1 = s2;

其结果应该是s1和s2指向相同的StringValue对象,对象的引用计数在赋值时被增加。并且,s1原来指向的 StringValue对象的引用计数应该减少,如果s1是拥有原来的值的唯一对象,这个值销毁。上述功能实现如下:
1
2
3
4
5
6
7
8
9
10
11
String& String::operator=(const String& rhs){
if (value == rhs.value) {//类似于自赋值,这里指的是已经指向相同对象
return *this;
}
if (--value->refCount == 0) {
delete value;
}
value = rhs.value;
++value->refCount;
return *this;
}


写时拷贝

 
数组下标操作符[]允许字符串中的单个字符被读或写:

1
2
3
4
5
6
class String {
public:
const char& operator[](int index) const; // for const Strings
char& operator[](int index); // for non-const Strings
...
};

const成员函数很容易实现,因为它仅仅提供读操作。但non-const则较为繁琐,因为它需要分别处理读和写的情况:
1
2
3
4
String s;
...
cout << s[3];//read
s[5] = 'x';//write

当我们试图修改一个String对象的值时,必须确保没有修改和它共享StringValue的对象。但我们无法确定operator[]执行的是何种操作(proxy class可以帮助区分读写,详见More Effective C++ 30),所以我们必须假设所有operator[]都在执行写操作。
为了安全地实现non-const operator[],我们必须确保资源被当前String对象独占。简而言之,当我们返回StringValue对象中的一个字符的引用时,必须确保这个StringValue的引用计数是1:
1
2
3
4
5
6
7
char& String::operator[](int index){
if (value->refCount > 1) {
--value->refCount;//脱离当前资源共享阶段
value = new StringValue(value->data);//生成一个新资源
}
return value->data[index];
}

写时拷贝是这么一种情况:一个对象永远与其他等值对象共享资源,直到它需要修改自身时才迅速拷贝一份资源,你可以把它视为lazy-evacuation的一个应用特例。


指针、引用与写时拷贝

 
多数情况下上文所实现的写时拷贝兼具正确性与高效性,但加入了指针后正确性可能会因此失效:

1
2
String s1 = "Hello";
char *p = &s1[1];

其数据结构如下所示:image_1cc7skm1scn5emb6i1d1tsno2n.png-15.3kB
现增加一条语句:
1
String s2 = s1;

由于资源共享的原因,当前数据结构如下所示:image_1cc7smvc4e04147p1dml1tb757s34.png-21.5kB
如果我们试图通过p去更改s1,就会发现s2也遭到了更改。并非只有指针会造成这种情况,non-conts-reference也是如此。
解决方案不难实现,但我们付出了代价:降低一个值共享于对象间的次数。解决方案原理是:在每个StringValue对象中增加一个标志以指出它是否具备共享性,一开始标志位设置为可共享,一旦有operator[]被调用,标志位翻转为不可共享状态,并且永久保持为不可共享:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class String {
private:
struct StringValue {
int refCount;
bool shareable; // add this
char *data;
StringValue(const char *initValue);
~StringValue();
};
...
};
String::StringValue::StringValue(const char *initValue)
: refCount(1),shareable(true){
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::~StringValue(){
delete [] data;
}

在标志位增加后,String的成员函数也需要修改保持配合:
1
2
3
4
5
6
7
8
9
String::String(const String& rhs){//以拷贝构造举例
if (rhs.value->shareable) {
value = rhs.value;
++value->refCount;
}
else {
value = new StringValue(rhs.value->data);
}
}

non-const operator[]是唯一一个可以更改标志位的函数:
1
2
3
4
5
6
7
8
char& String::operator[](int index){
if (value->refCount > 1) {
--value->refCount;
value = new StringValue(value->data);
}
value->shareable = false;
return value->data[index];
}


引用计数与mixin class

 
不仅仅只有String需要引用计数,但我们不可能为所有需要引用计数的类都添加对应的struct,为了把引用计数功能抽象到与运行环境无关,我们想到了mixin class。

RCObject

首先构建一个基类RCObject,任何需要引用计数的类都必须继承自它,其具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class RCObject {
public:
RCObject();
RCObject(const RCObject& rhs);
RCObject& operator=(const RCObject& rhs);
virtual ~RCObject() = 0;//纯虚析构函数,表明该类仅能作为基类
void addReference();
void removeReference();
void markUnshareable();
bool isShareable() const;
bool isShared() const;
private:
int refCount;
bool shareable;
}

RCObject的具体实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
RCObject::RCObject():refCount(0), shareable(true) {}
RCObject::RCObject(const RCObject&):refCount(0), shareable(true) {}
RCObject& RCObject::operator=(const RCObject&){
return *this;
}
RCObject::~RCObject() {}//虚析构函数必须被定义
void RCObject::addReference() { ++refCount; }
void RCObject::removeReference(){
if (--refCount == 0)
delete this;
}
void RCObject::markUnshareable(){
shareable = false;
}
bool RCObject::isShareable() const{
return shareable;
}
bool RCObject::isShared() const{
return refCount > 1;
}

所有的构造函数都把refCount设为了0,因为我们会在构造完毕后把构造它的这个对象的count设为1。赋值操作也很奇怪,它什么都没做,因为我们不可能把计数对象从一个赋予另外一个。就算真的被赋值,它也什么都没变。removeReference函数不仅仅负责减少count值,还负责析构对象,因此这里我们必须要保证对象只被构建于堆中。(More Effective C++ 27)


使用实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class String {
private:
struct StringValue: public RCObject {
char *data;
StringValue(const char *initValue);
~StringValue();
};
...
};
String::StringValue::StringValue(const char *initValue){
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::~StringValue(){
delete [] data;
}

现在的StringValue什么都不用管,其基类接管了所有行为。


引用计数自动化处理

 
RCObject并没有提供自动化操作,一切关于refcount的行为都必须手动完成,比如在String的拷贝构造函数和赋值运算函数中,我们需要调用StringValue的addReference和removeReference函数。我们寄希望于某种操作,能够将大部分与引用计数相关的工作从所有具象类中移出。确实存在这种东西:智能指针。

计数对象所使用的智能指针

以下是计数对象所使用的智能指针模版:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
class RCPtr{
public:
RCPtr(T* realPtr = 0);
RCPtr(const RCPtr& rhs);
~RCPtr();
RCPtr& operator=(const RCPtr& rhs);
T* operator->() const; // see Item 28
T& operator*() const; // see Item 28
private:
T *pointee;
void init();
};

该模板允许了我们自定义构造、赋值、析构时执行的操作。当这些事件发生时,智能指针对象可以自动执行正确的操作来处理它们指向的对象(引用计数对象)的refCount字段。其具体实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<class T>
RCPtr<T>::RCPtr(T* realPtr): pointee(realPtr){
init();
}
template<class T>
RCPtr<T>::RCPtr(const RCPtr& rhs): pointee(rhs.pointee){
init();
}
template<class T>
void RCPtr<T>::init(){
if (pointee == nullptr) {
return;
}
if (pointee->isShareable() == false) {//如果计数对象所指向的资源不可共享
pointee = new T(*pointee);
}
pointee->addReference();
}

init存在的问题

当init()函数中发现拷贝构造的rhs处于不可共享状态,它会构建一个新的T型的对象,并且使用T对象的拷贝构造完成了初始化。对于一个String来说,T型对象是StringValue,我们没有对它声明拷贝构造函数,因此编译器选择调用默认版本,只拷贝了StringValue的数据pointer,而没有拷贝所指向的char*字符串。
正确的做法是令T含有正确的值拷贝行为(如深拷贝),以StringValue举例:

1
2
3
4
5
6
7
8
9
10
11
12
class String {
private:
struct StringValue: public RCObject {
StringValue(const StringValue& rhs);
...
};
...
};
String::StringValue::StringValue(const StringValue& rhs){
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
}

深copy并非唯一选择,我们应该将具体拷贝实现类型写于文档,告知用户。

智能指针的指向对象

RCPtr假设智能指针永远指向T型对象,但实际上我们知道可以指向T的派生类,为了防止一些奇怪的问题,建议使用虚拷贝构造等手段(More Effective C++ 25)。

智能指针的赋值与析构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//赋值操作
template<class T>
RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs){
if (pointee != rhs.pointee) {
if (pointee) {
pointee->removeReference();
}
pointee = rhs.pointee;
init();
}
return *this;
}
//析构操作
template<class T>
RCPtr<T>::~RCPtr(){
if (pointee)
pointee->removeReference();
}

解引用操作符

1
2
3
4
template<class T>
T* RCPtr<T>::operator->() const { return pointee; }
template<class T>
T& RCPtr<T>::operator*() const { return *pointee; }

最终实现

 
将之前提到的所有东西合在一起,真正的带有引用计数的String对象数据结构如下:
image_1cc813plr5sq19f6cqi19mviu3h.png-120.6kB
String类有定义如下:

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
template<class T>
class RCPtr {
public:
RCPtr(T* realPtr = 0);
RCPtr(const RCPtr& rhs);
~RCPtr();
RCPtr& operator=(const RCPtr& rhs);
T* operator->() const;
T& operator*() const;
private:
T *pointee;
void init();
};
class RCObject {
public:
void addReference();
void removeReference();
void markUnshareable();bool isShareable() const;
bool isShared() const;
protected:
RCObject();
RCObject(const RCObject& rhs);
RCObject& operator=(const RCObject& rhs);
virtual ~RCObject() = 0;
private:
int refCount;
bool shareable;
};
class String {
public:
String(const char *value = "");
const char& operator[](int index) const;
char& operator[](int index);
private:
struct StringValue: public RCObject {
char *data;
StringValue(const char *initValue);
StringValue(const StringValue& rhs);
void init(const char *initValue);
~StringValue();
};
RCPtr<StringValue> value;
};

String class没有声明拷贝构造、赋值运算、析构函数,但这并不是因为操作失误,而是这些函数均可以使用编译器自动生成的版本,真正本质上的上述操作均已在智能指针类及引用计数类中实现。
通过完美的封装,我们在没有更改String接口的同时增加了功能。


在现存类上增加引用计数

 
假设我们有一个不可更改的class Widget(不该更改的原因可能是其位于支持库中),如何给它添加引用计数功能?
首先从刚才已实现的思路入手,我们应该会建立一个RCWidget class,内部嵌套有一个struct public继承自RCObject,RCWidget内持有一个智能指针RCPtr指向Count对象:
image_1cc81soqn1pls1vmttmu3c2ukg4b.png-107.6kB
我们当前自然无法修改Widget内部,但我们可以试图构造一个中间层,然后完成这份任务:image_1cc81h7851qt1182q2fdffkmci3u.png-122.9kB
其中,CountHolder是人为构造的class,其内部有一个指针指向了Widget资源,我们用一个智能指针指向CountHolder。

PCIPtr与CountHolder

我们可以认为CountHolder是RCIPtr的实现细节,所以将其嵌套在该类中:

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
template<class T>class RCIPtr {
public:
RCIPtr(T* realPtr = 0);
RCIPtr(const RCIPtr& rhs);
~RCIPtr();
RCIPtr& operator=(const RCIPtr& rhs);
const T* operator->() const;
T* operator->();
const T& operator*() const;
T& operator*();
private:
struct CountHolder: public RCObject {
~CountHolder() { delete pointee; }
T *pointee;
};
CountHolder *counter;
void init();
void makeCopy();
};
template<class T>
void RCIPtr<T>::init(){
if (counter->isShareable() == false) {
T *oldValue = counter->pointee;
counter = new CountHolder;
counter->pointee = new T(*oldValue);
}
counter->addReference();
}
template<class T>
RCIPtr<T>::RCIPtr(T* realPtr)
:counter(new CountHolder){
counter->pointee = realPtr;init();
}
template<class T>
RCIPtr<T>::RCIPtr(const RCIPtr& rhs)
:counter(rhs.counter){
init();
}
template<class T>
RCIPtr<T>::~RCIPtr(){
counter->removeReference();
}
template<class T>
RCIPtr<T>& RCIPtr<T>::operator=(const RCIPtr& rhs){
if (counter != rhs.counter) {
counter->removeReference();
counter = rhs.counter;
init();
}
return *this;
}
template<class T>
void RCIPtr<T>::makeCopy(){
if (counter->isShared()) {
T *oldValue = counter->pointee;
counter->removeReference();
counter = new CountHolder;
counter->pointee = new T(*oldValue);
counter->addReference();
}
}
template<class T>
const T* RCIPtr<T>::operator->() const {
return counter->pointee;
}
template<class T> // non-constT* RCIPtr<T>::operator->() {
makeCopy();
return counter->pointee;
}
template<class T>
const T& RCIPtr<T>::operator*() const{
return *(counter->pointee);
}
template<class T>
T& RCIPtr<T>::operator*(){
makeCopy();
return *(counter->pointee);
}


RCwidget

有了RCIPtr,RCWidget很容易实现,因为RCWidget的每个函数都是将操作RCIPtr以完成对Widget对象的操作。若有Widget class有实例如下:

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget(int size);
Widget(const Widget& rhs);
~Widget();
Widget& operator=(const Widget& rhs);
void doThis();
int showThat() const;
};

那么RCWidget将被定义为:
1
2
3
4
5
6
7
8
class RCWidget {
public:
RCWidget(int size): value(new Widget(size)) {}
void doThis() { value->doThis(); }
int showThat() const { return value->showThat(); }
private:
RCIPtr<Widget> value;
};

需要注意的是,RcWidget没有拷贝,析构,赋值函数,因为RCIPtr将自动地执行这些行为。

28.智能指针

Posted on 2018-04-28 | In More Effective C++

前言

 
智能指针在多个领域都具备使用价值,例如资源管理与重复代码任务的自动化。


智能指针相对于raw pointer的优点

 

  1. 构造、析构
    智能指针将完美初始化,并且智能指针的析构会负责对象的释放。
  2. 拷贝与赋值操作
    可以自由地深拷贝,或者指针拷贝,又或者禁止这些行为。
  3. 解引用
    自行决定解引用功能,比如用它来实现lazy-fetching.

智能指针的实现雏形

 
智能指针从模版中生成,因为要与内置指针一致,所以它们是strong-typed:

1
2
3
4
5
6
7
8
9
10
11
class SmartPtr {
public:
SmartPtr(T* realPtr = 0);
SmartPtr(const SmartPtr& rhs);
~SmartPtr();
SmartPtr& operator=(const SmartPtr& rhs);
T* operator->() const;//解引用
T& operator*() const; //解引用
private:
T *pointee;
}

如果不允许copy与赋值,那就应该把拷贝构造与operator=设为private。解引用对象的两个操作符设为const,因为解引用并不能改变对象本身。


用户如何使用智能指针

 
假设存在一个分布式系统(对象一些在本地一些在远程),采用不同的方法分别处理本地对象和远程对象相当麻烦,我们试图让所有东西看起来在一个地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<class T>
class DBPtr{
public:
DBPtr(T *realPtr = nullptr);
DBPtr(DataBaseID id);//指向DB id对象
};
class Tuple{// 数据库元组类
public:
...
void displayEditDialog();//显示一个图形对话框,允许用户编辑
bool isValid() const;//返回合法性检验
};
template<class T>
class LogEntry {//修改T对象时完成日志登记
public:
LogEntry(const T& objectToBeModified);
~LogEntry();
};
void editTuple(DBPtr<Tuple>& pt){
LogEntry<Tuple> entry(*pt);
do{//重复显示对话框,直到数值合法
pt->displayEditDialog();
} while (pt->isValid() == false);
}

被编辑的元祖既可以位于本地也可以位于远程,在编写程序时我们感受不到他们的区别。Logentry的构造函数负责了启动日志记录,析构函数负责关闭记录。这在含有异常时能让程序更加robust,并且建立对象本身也比调用记录函数要直观。

如我们所见,使用智能指针与使用raw pointer区别不大,在了解了这一特性之后,我们开始编写智能指针。


构造、赋值、析构

 
一般来说构造较为简单:找到需要指向的对象,令raw pointer指向它,如果没有则将raw pointer指向nullptr或者抛出一个异常。
拷贝、赋值、析构由于对象所有权的问题,所以导致略微复杂。我们默认智能指针被释放时需要析构指向的对象,当然,这里的对象说的是动态分配得到的对象。
以STL中的auto_ptr为例:

1
2
3
4
5
6
7
8
9
template<class T>
class auto_ptr {
public:
auto_ptr(T *ptr = 0): pointee(ptr) {}
~auto_ptr() { delete pointee; }
...
private:
T *pointee;
};

当auto_ptr被拷贝或被赋值时,会发生什么情况?
1
2
3
4
auto_ptr<TreeNode> ptn1(new TreeNode);
auto_ptr<TreeNode> ptn2 = ptn1;//拷贝构造
auto_ptr<TreeNode> ptn3;
ptn3 = ptn2;//赋值

如果我们只拷贝内部的raw pointer,则会有两个智能指针指向一个对象,如果两个智能指针依次释放,我们会试图析构一个已经被析构的东西,结果未定义。
另一种方法是new一个新的对象,但这本身带有性能损耗,更何况我们也不知道建立什么对象,万一是派生类而非基类呢。
STL定义:“当auto_ptr被拷贝和赋值时,对象所有权随之传递”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class T>
class auto_ptr{
public:
...
auto_ptr(auto_ptr<T>& rhs);
auto_ptr<T>& operator=(auto_ptr<T>& rhs);
...
};
template<class T>
auto_ptr<T>::auto_ptr(auto_ptr<T>& rhs){
pointee = rhs.pointee;
rhs.pointee = nullptr;
}
template<class T>
auto_ptr<T>& auto_ptr<T>::operator=(auto_ptr<T>& rhs){
if (this == &rhs)//处理自赋值,此处内容建议翻阅Effective C++ 12
return *this;
delete pointee;
pointee = rhs.pointee;
rhs.pointee = nullptr;
return *this;
}

一般来说,拷贝构造和赋值运算符的形参都是const,但是auto_ptr的就不是,原因在于rhs被改变了。

最后,其析构函数如下所示:

1
2
3
4
5
6
template<class T>
SmartPtr<T>::~SmartPtr(){
if (*this owns *pointee) {
delete pointee;
}
}

不同的智能指针析构函数各有不同,比如说shared_ptr就需要检查引用计数。


解引用操作符

operator*

operator*返回所指向的对象,理论上来说这好像很简单:

1
2
3
4
5
template<class T>
T& SmartPtr<T>::operator*() const{
perform "smart pointer" processing;
return *pointee;
}

首先,我们必须初始化指针或者让raw pointer合法(例如Lazy evaluation中构建对象)。
其次,我们必须返回引用而非对象,因为在引入继承体系后,如果返回对象会导致slicing,而且智能指针也因此丧失了虚函数的功能,此外返回引用效率也高。

operator->

operator->与operator*差不多,我们先回头看用户使用实例中调用->的语句:

1
pt->displayEditDialog();

该语句被编译器解释为:
1
(pt.operator->())->displayEditDialog();

这意味着不论operator->返回什么,它必须在返回结果上使用->,那也就是说operator->仅能返回一个指针或类指针对象,一般来说,我们把一个raw pointer作为其返回值。


测试智能指针是否为nullptr

 
为了保证智能指针的行为与raw pointer完全一致,比如保证下述表达式能够编译:

1
2
3
4
SmartPtr<TreeNode> ptn;
if (ptn == nullptr)
if (ptn)
if (!ptn)

为了谨慎和保险,我们在智能指针类中重载operator!,当且仅当其为空指针时返回true:
1
2
3
4
5
6
template<class T>
class SmartPtr {
public:...
bool operator!() const;
...
};


把智能指针转为raw pointer

 
有时我们需要作出类似的转换,因为有些接口只接受raw pointer,可能你会写出&*sp之类的东西,但这无疑太丑了。所以某些人更倾向于隐式类型转换符:

1
operator T*() { return pointee; }

这种设计看起来很好,但实际上完全不行,如果直接可以让用户操纵raw pointer,那我们设计智能指针的意义何在?并且引用计数等功能也遭到了破坏。
此外,这种隐式转换编译器同一时间只能使用一次,比如说智能指针和raw pointer之间存在隐式转换,raw pointer又和A之间存在转换,智能指针无法隐式转为A,也就是说其行为与raw pointer不符。
这种写法还会诱导用户写下delete sp这种天理难容的东西,要知道智能指针是一个对象…
编译器不会报错,但你的对象会被删除两次(一次是delete,一次是sp被析构)
总之,智能指针应该拒绝提供隐式转换操作符,最好的操作就是提供显示接口,例如get。


智能指针与基于继承的类型转换(Effective C++ 46)

 
下图是模型化音乐商店商品的一个继承体系,其大致实现如下:image_1cc655eos1cq45b81l021alv1kj49.png-39.2kB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MusicProduct {
public:
MusicProduct(const string& title);
virtual void play() const = 0;
virtual void displayTitle() const = 0;
...
};
class Cassette: public MusicProduct {
public:
Cassette(const string& title);
virtual void play() const;
virtual void displayTitle() const;
...
};
class CD: public MusicProduct {
public:
CD(const string& title);
virtual void play() const;
virtual void displayTitle() const;
...
};

更进一步地假设存在一个函数,它对于任何一个Musicproduct对象都会显示标题并播放指定次数:
1
2
3
4
5
6
void displayAndPlay(const MusicProduct* pmp, int numTimes){
for (int i = 1; i <= numTimes; ++i) {
pmp->displayTitle();
pmp->play();
}
}

显然该函数本身具备多态性,但如果我们使用智能指针,则无法具备多态性,原因在于sp派生出的这几个class根本不具备任何相关性。

隐式转换

我们想到的第一个解决方案是令每一个sp class具备隐式类型转换符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SmartPtr<Cassette> {
public:
operator SmartPtr<MusicProduct>(){return SmartPtr<MusicProduct>(pointee);}
...
private:
Cassette *pointee;
};
class SmartPtr<CD> {
public:
operator SmartPtr<MusicProduct>(){return SmartPtr<MusicProduct>(pointee);}
...
private:
CD *pointee;
};

此方法缺点十分明显:

  1. 对于每一个sp class都需要加入大量的隐式类型转换符,这对template是一大讽刺。
  2. 如果当前对象位于继承体系的底层,那你必须为每一个直接或者间接继承的基类提供隐式类型转换,因为编译器无法执行多次转换。

非虚成员函数模板

1
2
3
4
5
6
7
8
9
10
11
12
template<class T>
class SmartPtr {
public:
SmartPtr(T* realPtr = 0);
T* operator->() const;
T& operator*() const;
template<class newType>
operator SmartPtr<newType>(){
return SmartPtr<newType>(pointee);
}
...
};

其具体工作原理如下:
假设编译器持有一个指向T对象的智能指针,它需要把该指针转换成指向T的基类的智能指针.
编译器检查了一番SmartPtr<T>,但并没有找到明确的类型转换符,然后检查有没有一个成员函数模板,通过该模板实例化一个对象完成类型转换。最终它构建了这么一个对象,完成了隐式转换。
结合具体实例而言,

1
2
3
4
5
void displayAndPlay(const SmartPtr<MusicProduct>& pmp,int howMany);
SmartPtr<Cassette> funMusic(new Cassette("Alapalooza"));
SmartPtr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));
displayAndPlay(funMusic, 10);
displayAndPlay(nightmareMusic, 0);

函数在调用时本质上使用了Smart<music>的构造函数,将新的对象作为参数传递给了函数,最终完成多态。

不要觉得这种方法只能用于继承体系中的相互转换,事实上,这是一种通用的、安全的智能指针转换操作,只要你能够把T*隐式地转为NT*,那我们就一定能安全直接地转换它们的智能指针。假设我们修改了继承体系:
image_1cc661nbnnb27o7m5i5rj17egm.png-53.5kB
并有如下代码:

1
2
3
4
5
6
template<class T>
class SmartPtr { ... };
void displayAndPlay(const SmartPtr<MusicProduct>& pmp,int howMany);
void displayAndPlay(const SmartPtr<Cassette>& pc,int howMany);
SmartPtr<CasSingle> dumbMusic(new CasSingle("Achy Breaky Heart"));
displayAndPlay(dumbMusic, 1);//error!

这里错误的原因很简单,二义性,所有类型的转换符具有同等地位,编译器无法知道该使用哪一个(这种行为与内置指针不符,内置指针会直接匹配继承体系最接近的重载)。


智能指针与const

 
对于raw pointer,我们的const属性分为顶层和底层,详见C++ Primer。
我们希望智能指针也拥有const属性,但实际上好像不太行,我们可以令智能指针自身const,无法让指向的对象也const.有一个简单的补救方法:

1
SmartPtr<const Sth>;

但该类型的对象与SmartPtr<Sth>不具备任何相关性,所以需要再次使用上文所提到的成员模板。需要注意的是,const的类型转换是单向的,就像public继承一样,non-const可以做const的事,反之则不行.既然如此,我们不如把它设为继承关系:image_1cc66ffmd52a1u2f15vkhp81b7u1j.png-66.2kB
1
2
3
4
5
6
7
8
9
10
11
12
template<class T>
class SmartPtrToConst {
protected:
union {
const T* constPointee;
T* pointee;
};
};
template<class T>
class SmartPtr:public SmartPtrToConst<T> {
...//没有数据成员
};

使用这种设计方法,指向non-const-T对象的智能指针包含一个指向const-T的raw pointer。union的特点是节约空间,但我们需要手动约束两个类各自使用专属指针。其使用大致如下:
1
2
SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtrToConst<CD> pConstCD = pCD;

27.要求或禁止在堆中产生对象

Posted on 2018-04-28 | In More Effective C++

前言

 
有时候我们希望可以自由的构造与析构对象,这要求它们存在于heap中。又有时我们希望对象不会产生资源泄漏的问题,那我们必须保证某个类不会在heap中构造对象。


仅允许在heap中建立对象

 
显然,为了达成这一目的,我们必须找到一种方法,禁止所有new之外的能够构造对象的手段。
non-heap object在定义它的地方被自动构造,生存时间结束后自动释放,所以只需要禁止隐式的构造和析构函数就可以实现这种限制。
最直接的手法就是把构造函数和析构函数声明为private,但这样的副作用太大,我们只需要令其中一个声明为private即可。

  1. 构造函数为public,析构函数为private
    这会导致对象依旧可以构造在stack中,只是在析构时会报错。
  2. 构造函数为private,析构函数为public
    一般来说,一个class会存在多个构造函数,因此必须将所有构造函数都声明为private。

通过限制访问一个类的析构或构造函数来阻止建立非堆对象固然很好,但这种方法也同时禁止了继承与containment.但我们有两种技术来克服这些缺陷:

  1. 对于继承而言,可以把构造或析构函数声明为protected。
  2. 对于包含来说,可以把包含对象改为包含指向对象的指针。(pimpl)

以下给出具体实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//derived
class UPNumber { ... };//protected:~UPNumber
class NonNegativeUPNumber:public UPNumber { ... };
//containment
class Asset {
public:
Asset(int initValue);
~Asset();
...
private:
UPNumber *value;// RAII is better
};
Asset::Asset(int initValue):value(new UPNumber(initValue)){ ... }
Asset::~Asset(){value->destroy();}


判断一个对象是否在堆中

 
如果采用上述方法,当我们写下如下表达式时:

1
NonNegativeUPNumber n;//n处于stack中

n的base部分是否处于stack中却是根据具体实现来确定的,如果现在我们需要作出强制性的保证:一个对象,哪怕是作为派生类的基类部分,也必须出现在堆中。该如何执行这种约束?

没有特别简单的办法来完成这个功能,因为构造函数在调用不可能检测当前环境。简而言之,UPNumber的构造函数无法区分如下两种构造环境:

1
2
NonNegativeUPNumber *n1 = new NonNegativeUPNumber;//in heap
NonNegativeUPNumber n2;//in stack

操作符修改

也许你可能会试图在new操作符上玩儿一些小花样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class UPNumber {
public:
class HeapConstraintViolation {};//如果建立一个非堆对象,抛出一个异常
static void* operator new(size_t size);
UPNumber();
...
private:
static bool onTheHeap; //指示当前是否在堆中
};
bool UPNumber::onTheHeap = false;
void *UPNumber::operator new(size_t size){
onTheHeap = true;
return ::operator new(size);
}
UPNumber::UPNumber(){
if (!onTheHeap) {
throw HeapConstraintViolation();
}
...//构造
onTheHeap = false; // 为下一个对象清除标记
}

当我们在heap中调用时势必会使用new,因此建立判断位,确保每一次构建时都完美运行。
这个方法很好,但不能应用于实际中。举例而言:
1
UPNumber *numberArray = new UPNumber[100];

new[]只申请了一次内存,然后调用了100次构造函数,z这和我们预期的根本不一致。
就算不考虑operator[],我们也未必能够取得成功:
1
UPNumber *pn = new UPNumber(*new UPNumber);

我们在堆中建立两个UPNumber,让pn先指向一个对象,这个对象利用另一个对象的值进行初始化。就算不用顾忌内存泄漏,编译器的行为也未必会按照预期执行:
1
2
3
4
5
6
7
8
9
10
11
预期行为:
调用第一个对象的 operator new
调用第一个对象的构造函数
调用第二个对象的 operator new
调用第二个对象的构造函数

可能发生的实际行为:
调用第一个对象的 operator new
调用第二个对象的 operator new
调用第一个对象的构造函数
调用第二个对象的构造函数


通过地址判断对象处于何处

在很多系统中,程序的地址空间被作为线性地址管理,程序的栈自顶而下,堆则自底而上,如下图所示:
image_1cc5qj8cd7eqsckjee1oe2q9g9.png-66kB
因此我们试图使用这么一个函数来判断某个特定的地址是否在堆中:

1
2
3
4
bool onHeap(const void *address){
char onTheStack;
return address < &onTheStack;
}

该函数的原理很简单:新建的局部变量必然是栈的最底层,如果某个地址比他还低,那必然是存在于heap中。但是,谁告诉你内存空间只有栈和堆了?static变量也会有特定的位置,一般情况下它们会出现在heap的下面,如下所示:image_1cc5qp2o01jht12ih1jcbue71tge16.png-88.7kB
那么该函数不能工作的原因就很清楚了:无法判断堆对象与静态对象。


安全delete

令人悲伤的是我们无法找到一个通用方法来判断对象到底在不在堆上,但我们迫切地需要了解某个对象在不在heap中的初衷很简单:我们需要判断该对象能不能被安全delete。
“能否安全删除一个指针”与“一个指针是否指向堆中的事物”并不相同,因为不是所有heap中的事物都能被安全delete:

1
2
3
4
5
6
7
class Asset {
private:
UPNumber value;
...
};
Asset *pa = new Asset;
delete pa->value;//error

报错的原因在于value并非是一个由new返回的指针,而是一个由new返回的指针初始化的指针。
但判断“能否删除一个指针”比判断“一个指针指向的对象是否在堆上”容易,因为对于前者我们只需要每一次new的时候记录一下返回的地址(以一个vector或list保存之),每一次delete之前判断一下地址在不在记录,在的话就把地址从记录中移除。这一系列操作只需要重载一下operator new与delete就好了。
但是,在实际使用中,这种方法也不是特别受欢迎,因为:

  1. 重载后的operator new与operator delete必须在全局作用域,这会引发一些不兼容
  2. 不是所有客户都需要这项功能,记录地址未必需要
  3. 记录地址未必不出错,比如说多继承或者继承自虚基类的类往往有多个地址。

mixin class

我们希望存在某些函数提供以上功能,但不污染全局命名空间,没有额外开销,这时mixin class出场了(“mix in” Effective C++ 7中的uncopyable也是一个mixin class)。具体来说,mixin是一个抽象类,其功能可以与其派生类的功能相融合,以上述要求为例:

1
2
3
4
5
6
7
8
9
10
11
class HeapTracked {
public:
class MissingAddress{};
virtual ~HeapTracked() = 0;
static void *operator new(size_t size);
static void operator delete(void *ptr);
bool isOnHeap() const;
private:
typedef const void* RawAddress;
static list<RawAddress> addresses;
};

其具体实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
list<RawAddress> HeapTracked::addresses;
HeapTracked::~HeapTracked() {}//析构函数必须被定义 即使是纯虚函数 详见Effective C++ 8
void* HeapTracked::operator new(size_t size){
void *memPtr = ::operator new(size);
addresses.push_front(memPtr);//把地址放到list前端
return memPtr;
}
void HeapTracked::operator delete(void *ptr){
auto it = find(addresses.begin(), addresses.end(), ptr);
if (it != addresses.end()) {
addresses.erase(it);
::operator delete(ptr);
}
else {
throw MissingAddress();
}
}
bool HeapTracked::isOnHeap() const{
const void *rawAddress = dynamic_cast<const void*>(this);//动态转换
auto it = find(addresses.begin(), addresses.end(), rawAddress);
return it != addresses.end();
}

在判定函数中使用了动态转换,这是为了解决我们之前提出的第三个问题,将指针永远指向当前对象起始地址。
mixin class使用范式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Asset: public HeapTracked {
private:
UPNumber value;
...
};
void inventoryAsset(const Asset *ap){
if (ap->isOnHeap()) {
...
}
else {
...
}
}

遗憾的是这种mixin不能用于内置类型,因为内置类型无法继承自某类。但我们的本意就是为了安全地delete某对象,内置类型无法delete。


禁止建立堆对象

 
对象的建立无非三种情况:

  1. 直接实例化
  2. 对象作为派生类的基类被实例化
  3. 对象被嵌入到其他对象内

直接实例化

禁止直接实例化很简单,把operator new和delete设为private,如果你需要同时禁止堆对象数组,也可以把operator new[]与operator delete[]设为private:

1
2
3
4
5
6
7
class UPNumber {
private:
static void *operator new(size_t size);
static void operator delete(void *ptr);
...
};
UPNumber *p = new UPNumber;//error

派生类基类

把operator new声明为private会导致派生类对象的基类无法被实例化。因为operator new与operator delete是自动继承的,除非你手动声明派生类中的它们为public,否则它们默认为基类中的private版本:

1
2
3
class UPNumber { ... };
class NonNegativeUPNumber:public UPNumber {...};//默认operator new为private
NonNegativeUPNumber *p = new NonNegativeUPNumber;//error

包含

operator new是private这一特性,不会对包含产生任何影响:

1
2
3
4
5
6
7
8
class Asset {
public:
Asset(int initValue);
...
private:
UPNumber value;
};
Asset *pa = new Asset(100);//调用Asset::operator new或::operator new

<i class="fa fa-angle-left"></i>1…131415…27<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4