0%

【C++对象模型】(一)关于对象

《深度探索 C++ 对象模型》第一章重点梳理。主要内容包括:

  • C++ 封装对象的布局成本
  • C++ 对象模型,详述 C++ 如何组织类对象及其成员
  • C++ 继承简述,简述 C++ 三种继承关系和三种继承方式,关于继承布局模型会在之后的章节详述
  • C++ 支持多态的三种方式
  • 指针类型和多态的实现原理

1 C++ 封装对象的布局成本

C++ 将数据和对数据的操作封装为一个类,相比于 C 语言不封装,并没有增加成本。因为类的数据成员直接内含在每一个实例化的对象中,而方法成员虽然含在类的声明中,但却不出现在每一个对象中,每一个非内联(non-inline)的成员函数只产生一个函数实体,而内联函数自然在使用的地方产生函数实体,因此不会有任何空间或执行的不良效应。

C++ 在布局以及存取时间上的主要额外负担是由 virtual 引起的,包括:

  • virtual function 机制:用以支持一个高效的“执行期绑定”(runtime binding)
  • virtual base class:用以实现“多次出现在继承体系中的 base class,只存在一个被共享的实体”

后面将会对这两种情况进行解释。

2 C++ 对象模型

C++ 对象模型按照以下方式组织类对象及其成员:

  • 非静态成员变量(non-static data member)存放于类对象内部
  • 静态成员变量(static data member)存放于所有类对象之外
  • 静态和非静态的成员函数(member function)存放于所有类对象之外
  • 虚函数(virtual function)通过虚函数表(virtual table)管理:每一个类产生出一堆指向虚函数的指针,放在表格之中构成虚函数表,接下来每一个类对象拥有一个指向虚函数表的指针,称为 vptr,vptr 的设定(set)和重置(reset)都由类的构造函数、析构函数、拷贝构造等完成。并且每一个类所关联的 type_info 对象(用来支持 runtime type identification,简称 RTTI)也经由虚函数表指出,通常放在虚函数表的第一位。

什么是 RTTI ?

RTTI 即运行阶段类型识别(Runtime Type Identification),旨在为程序在运行阶段确定对象类型提供一种标准方式。

RTTI 可以在只有一个指向基类的指针或引用时,确定所指对象的准确类型。C++ 有两种支持 RTTI 的运算:

  • dynamic_cast 运算符,如果可能的话,dynamic_cast 运算符将使用一个指向基类的指针来生成一个指向派生类的指针,否则该运算符返回空指针。
  • typeid 运算符,返回一个指出对象类型的 type_info 对象的引用,其中存储了有关对象类型的信息,比如类名等。

需要注意的是,只能将 RTTI 用于包含虚函数的类层次结构,原因在于只有对于这种类层次结构,才会将派生类对象的地址赋给基类指针(多态),即 RTTI 只适用于包含虚函数的类(实现多态)

以下面的类为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {
public:
Point(float xval);
virtual ~Point();

float x() const;
static int PointCount();

protected:
virtual ostream& print(ostream &os) const;

float _x;
static int _point_count;
};

其对象模型如下图所示:

image-20220505095707450

C++ 这样构建对象模型的主要优点在于空间和存取时间效率高,但缺点在于如果程序代码本身没有更改,但所用到的类对象的非静态成员变量有所修改,那么代码就需要重新编译。

另一种没有应用在 C++ 中的对象模型——双表格模型可以解决这个问题,双表格模型把所有成员变量放在数据表格中,把所有指向函数的指针放在函数表格中,每一个类对象只包含指向这两个表格的指针。这样虽然更灵活,但也因此付出了空间和执行效率两方面的代价。

3 C++ 继承简述

C++ 支持单一继承、多重继承和虚拟继承:

1
2
3
4
5
6
7
8
9
10
11
// 单一继承
class Library_Materials { ... };
class Book : public Library_Materials { ... };
class Rental_Book : public Book { ... };

// 多重继承
class iostream : public istream, public ostream { ... };

// 虚拟继承
class istream : virtual public ios {...};
class ostream : virtual public ios {...};

为什么需要虚拟继承?

虚拟继承是为了防止多重继承中,一个派生类继承自两个相同的基类的情况。

如上面的例子,类 iostream 继承自类 istream 和类 ostream,这两个类都继承自基类 ios,如果不使用虚拟继承,则类 iostream 中会出现两个基类 ios 的对象,而在虚拟继承中,无论基类在继承链中被派生多少次,都只产生一个实体(称为子对象,subobject),即在类 iostream 中,istream 对象和 ostream 对象共享一个 ios 基类对象,从而避免了重复继承的情况。

之后的章节中我们会具体讨论这些继承情况分别对应的继承模型,即派生对象和基类对象的组织关系和内存管理。

顺便讨论一下类成员的三种访问级别和三种继承方式。众所周知 C++ 类成员可以拥有三种访问级别:

  • public:可以被该类中的函数、子类的函数、其友元函数访问,也可以由该类的对象访问
  • protected:可以被该类中的函数、子类的函数、其友元函数访问,但不能被该类的对象访问
  • private:只能由该类中的函数、其友元函数访问,不能被任何其他访问,该类的对象也不能访问

在类继承中,也包含三种继承方式,不同的继承方式会导致类中成员的访问级别变化:

  • public 继承:父类中的成员属性不发生改变
  • protected 继承:父类的 protected 和 public 成员在子类中变为 protected,private 成员不变
  • private 继承:父类的所有成员在子类中变为 private

4 C++ 支持多态的三种方式

C++ 通过以下三种方式支持多态:

  • 隐式转换:将派生类指针转化为一个指向其基类的指针:
1
shape *ps = new circle();
  • 虚函数机制:基类中定义虚函数(纯虚函数),派生类进行重载,在运行时决定调用哪个函数对象:
1
ps->rotate();
  • dynamic_cast 和 type_id 运算符,在上面的虚拟继承提到过,用来将指向基类的指针转化为指向派生类对象的指针,或者获取对象的 type_info

5 指针类型和多态原理

指向类的指针和指向其他变量类型(比如 int, string)的指针有什么不同?

以内存需求的观点来说,没有什么不同,它们都需要有足够的内存来放置一个机器地址,指向不同类型的指针之间的差异既不在其声明方法不同,也不在其内容(代表一个地址)不同,而是在其所寻址出来的 object 类型不同。也就是说,”指针类型”会告诉编译器如何解释某个特定地址中的内存内容及其大小。

例如下面的类:

1
2
3
4
5
6
7
8
class ZooAnimal {
public:
...
void rotate();
protected:
int loc;
string name;
}

那么指向类 ZooAnimal 的对象的指针将会包含:

  • 指向 int 类型的指针,在 32 位计算机上,int 占 4 字节
  • 指向 string 类型的指针,占 8 字节(4 字节的字符指针和 4 字节的表示字符串长度的整数)
  • 指向虚函数表的指针,占 4 字节

因此类 ZooAnimal 的对象一共占用 16 字节,如果 ZooAnimal 对象存放在内存地址 1000,那么一个指向 ZooAnimal 对象的指针将会知道它需要涵盖内存的内存范围是 1000~1015.

image-20220505112402307

如果不告诉编译器这个指针指向什么类型,比如泛型指针 void*,那么编译器将不知道指针涵盖的地址范围,也就不能通过指针访问到正确的内存内容。

现在我们定义一个 Bear 类继承自 ZooAnimal 类:

1
2
3
4
5
6
7
8
9
10
class Bear : public ZooAnimal {
public:
...
void rotate();
protected:
enum Dances { ... }; //枚举类型默认使用int方式存储,占用4字节

Dances dances_known;
int cell_block;
}

那么指向 Bear 对象的指针覆盖的内存大小是其基类子对象的大小(16字节)加上该对象成员变量的大小(4 + 4 字节):

image-20220505112658895

假设 Bear 对象存放在内存地址 1000 处,那么指向基类 ZooAnimal 的指针 pz 和指向派生类 Bear 的指针 pb 都指向内存地址 1000,区别在于 pz 指针涵盖的内存范围只包含基类成员的 16 字节,而 pb 指针涵盖的内存范围包含整个 Bear 对象。

除了 ZooAnimal 对象中出现的成员,我们不能使用 pz 来处理 Bear 对象的任何成员,除非使用 virtual 机制:

1
2
3
4
5
6
7
8
9
// 不可以,虽然 pz 指向一个 bear 对象的地址
pz->cell_block;

// 可以,经过显式转换
((Bear*)pz)->cell_block;

// 更好的方式是使用 RTTI,dynamic_cast返回一个指定派生类的指针,如果无法转换则返回空指针
if(Bear* pb2 = dynamic_cast<Bear*>(pz))
pb2->cell_block;

现在来看这种情况:

1
2
3
4
Bear b;
ZooAnimal za = b;
// 这将会调用 ZooAnimal 的 rotate 方法而不是 Bear 的
za.rotate();

为什么 za 调用的是 ZooAnimal 的 rotate 方法?这是由于当一个基类对象直接被初始化为一个派生类对象时,会发生切割(sliced),以塞入较小的基类内存中,而无法体现出任何派生类的痕迹。这时多态就“失效”了,实际上这是由于多态不能够发挥在“直接存取对象”这件事情上,因为多态机制是依靠指针(pointer)或引用(reference)完成的

举例来说明,比如有下面的类继承关系:

image-20220505150626835

然后我们使用下面一组定义:

1
2
3
4
5
6
7
8
9
{
ZooAnimal za;
ZooAnimal *pza;

Bear b;
Panda *pp = new Panda;

pza = &b;
}

这组定义可能的内存布局如下图:

image-20220505150739142

将对象 za 或者 b 的地址,或者指针 pp 的内容(也是个地址)赋给指针 pza,是完全没有问题的,后续还可以通过各种转换得到想要的不同派生类的对象,也就实现了多态。一个指针或者引用之所以支持多态,就是因为他们并不会引发内存配置的资源量的改变,会受到改变的只是它们所指向的内存的“大小和解释方式”而已。

但如果直接对对象进行操作,就会改变内存中的资源需求量,比如之前的例子,把整个 Bear 对象指定给 za,就会溢出它所配置得到的内存,自然也就无法得到正确的结果。

总之,多态是一种强大的机制,允许你继一个抽象的 public 接口之后,封装相关的类型。需要付出的代价就是额外的间接性——不论是在“内存的获得”或是在“类型的决断”上。C++ 通过 class 的 pointers 和 references 来支待多态,这种程序设计风格就称为“面向对象”。

---- 本文结束 知识又增加了亿点点!----

文章版权声明 1、博客名称:LycTechStack
2、博客网址:https://lz328.github.io/LycTechStack.github.io/
3、本博客的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系博主进行删除处理。
4、本博客所有文章版权归博主所有,如需转载请标明出处。