1.2 指针
1.2.1 指针解释方式
从内存的角度,一个指向类对象的指针与一个指向整数类型的指针或一个指向数组的指针,三者之间是没有任何区别的,它们内部都只存储了一个机器地址值(word)。不同类型指针的区别仅在于其寻址出来的object
类型的不同,也就是"指针类型"会教导编译器如何解释指针指向地址的内存内容以及大小。
编译器会根据指针指向的类型来进行寻址,例如对于一个指向整数的指针,在64
位的机器上,编译器就会从指针指向的起始位置开始寻址8
个字节。这也说明了为什么void*
的指针只能持有一个地址,而不能操作它指向的对象,因为编译器并不知道这个指针的指向类型,也就无法寻址。当然也可以通过static_cast
将void*
类型指针转换为具体类型,这个操作并不改变一个指针所含的真实地址,它只会影响"指针指向内存的大小和其内容"的解释方式。
对于如下的代码是一个类定义题,一个指向整型1000
的指针和一个指向ZooAnimal
的指针在内存上如下所示,是没有任何区别的。但是因为两者指针类型的不同,所以编译器在解释它们指向地址时会有所不同,在64
位系统上,int *
则只会解释为向后8
字节,而如下的ZooAnimal
实际对象则会被解释为8+8+8=32
字节。
class ZooAnimal {
public:
ZooAnimal() = default;
virtual ~ZooAnimal() = default;
virtual void rotate();
int getLoc() const { return loc; }
const string &getName() const { return name; }
protected:
int loc;
std::string name;
};
1.2.2 继承后解释方式
对于如下含有继承与多态的代码,在主函数里定义了三个变量b、pb、rb
,其内存布局如下所示:无论是指针还是引用,其本质上都是一个8
字节的指针,而Bear
对象b
则需要48
字节:自己的8
字节加上基类的32
字节。
class Bear : public ZooAnimal {
public:
Bear() = default;
virtual ~Bear() = default;
virtual void dance();
Dances getDancesKnown() const { return dances_known; }
int getCellBlock() const { return cell_block; }
protected:
enum class Dances {
};
Dances dances_known;
int cell_block;
};
int main() {
Bear b;
Bear *pd = &b;
Bear &rb = b;
}
在多态情况下,一个Bear
指针与一个ZooAnimal
指针是不同的,例如如下的定义,pz、pb
都是指向Bear
对象b
的指针,但是它们两个的差别在于pb
所涵盖的地址包含整个Bear object
,而pz
涵盖的地址只包含Bear object
中的Zoo Animal
的基类部分。对于上图即pb
指向的是整个对象,而pz
则只能指向深色部分。
Bear b;
ZooAnimal *pz = &b;
Bear *pb = &b;
所以父类指针是无法访问到子类的数据成员的,也无法访问到其相应的非虚成员函数。因为其本质上是一个父类类型的指针,编译器在调用时只会通过父类的大小来划分相应的内存块,是无法访问到子类成员的,也就是上图的白色部分,当然这里可以通过static_cast
或dynamic_cast
或实现向上转型。且这里更推荐使用dynamic_cast
,但是这种转型是运行时转型,成本也会更高。
// pz->getDancesKnown(); 不合法, pz是无法访问到dances_known的
cout << (static_cast<Bear *>(pz))->getCellBlock(); //合法,通过显式的downcast
if (Bear *pb2 = dynamic_cast<Bear *>(pz)) { //这个更好,但是这个是运行时操作,成本更高
cout << pb2->getCellBlock();
}
1.2.3 指针实现多态
对于上述的Bear
与Zoo Animal
类,其都含有一个虚函数rotate
。1.2.2
部分已近介绍了是不能通过基类指针访问子类的数据成员与非虚函数的,这里有一个例外即虚函数机制。
在运行过程中,pz
所实际指向的object
类型可以决定rotate()
所调用的实例。这个类型的裁决不是取决于pz
的指针类型,而是取决于虚指针与虚表中。
在介绍虚机制之前,首先介绍为什么普通对象无法实现虚机制。对于如下所示的代码,第二行将子类对象b
赋值给基类对象za
,这时会引起切割,也就是在内存栈中生成一个新的只包含灰色部分的内存快,所以这个过程是一个子类数据成员的部分拷贝操作。因此可以看到基类对象是无法通过函数调用运算符来实现多态的,因为在内存里其已经不包含了子类的数据成员。
Bear b;
ZooAnimal za = b;
za.rotate();
当然这里还存在一个问题是既然子类对象被赋值给了基类对象,那为什么子类的虚函数指针没有被复制过去,这样就可以实现za.rotate
调用Bear
的rotate
函?
这是因为编译器在对基类对象用子类对象做初始化时,父类的虚函数指针不会被子类的虚函数指针所初始化,而是被编译器直接指定相应的值,这个值即父类实际的虚函数表指向。
下面介绍指针为什么就可以实现多态,假设新增了一个继承于Bear
的新子类Panda
,其继承体系如下所示:
下述的代码块内存布局如下所示。可以注意到,将父类指针pza
指向子类对象b
是完全合法的,虽然这个指针类型此时是基类ZooAnimal
,也就是其只能寻找基类部分的数据成员,但是注意到这部分数据成员已经足够,因为这部分已经含有子类Pandas
的虚表指针,虚函数的调用不是由指针类型而决定的,是由虚函数指针和虚函数表所决定的,而这里显然是Panda
的虚函数指针,所以才能够实现父类指针调用子类对象。
ZooAnimal za;
ZooAnimal *pza;
Bear b;
Pandas **pp=new Pandas();
pza=&b;
总结:当一个父类对象被直接赋值为一个子类时,子类对象内部的值会被切割,以塞入到父类对象内存中,只保留父类相应的数据成员。同时子类的虚表指针也不会被直接初始化,父类虚表指针的值会由编译器直接指定。所以直接赋值后,子类的任何特征都不在基类中了,因此多态也不在呈现。同时,如果一个编译器比较优秀,当基类对象直接调用虚函数时例如za.rotate
,编译器也可以在编译阶段就直接解析到相应的调用函数,进而回避virtual
机制。
多态机制的强大之处在于对于一个抽象的pulic接口之后,还可以封装相应的类型,实现多类型调用。多态的实际利用可以参考:多态的应用