深入探索C++对象模型-关于对象 阅读笔记
什么是C++对象模型
包含两方面
- C++语言层面面向对象程序设计。比如相对C语言而言,支持了封装、继承、多态等性质。这些都是C++标准规定的,但是这些特性是如何实现的呢?即第二方面;
- 编译器底层实现模型。比如类对象的内存布局,虚函数的底层实现等。原书作者lippman参与设计了第一套C++编译器cfront。
C++对象模型
单继承
通过书中内容以及我自己的理解,绘制了这份C++对象模型图,只考虑单继承情况,且虚基类中没有虚函数。由于底层实现并不唯一,这只是其中一种实现方式。
重点:
-
在单继承情况下,子类会共享父类的vptr,指向子类的虚函数表。子类的虚函数表与父类共享,所谓共享是指用父类指针引用子类对象,或者通过子类指针引用子类对象时,调用虚函数时都会经由这个虚函数表进行转发(dispatch)。两者区别是用父类指针引用时,只能调用父类中声明的函数(即静态类型是Parent, 动态类型是Child),从虚函数表角度看,父类指针只能使用其中“父类的那部分”,比如上图中func1 func2是父类中声明的虚函数,myfunc1是子类中声明的虚函数,因此如果用Parent指针无法调用myfunc1。
-
虚基类在内存中的位置会随着继承体系的改变而改变,因此同虚函数一样,在多态场景下,虚基类的偏移量需要在运行时才能决定。比如用Parent指针访问虚基类的中的成员,Parent指针可以指向Parent对象,也可以指向Child对象,不同的对象偏移量不同。图中的实现方式是复用虚函数表,正数slot表示virtual functions的地址,负数slot表示虚基类的偏移量。
-
RTTI(RunTime Type Identification)放在虚函数表的第0个slot,因此如果该类没有虚函数表时,是无法表现出运行时对象的概念的,typeid的结果由编译器静态决定。很好理解,如果一个类中没有虚函数,也就没有多态行为,即父类指针无论指向子类对象还是父类对象,其行为是固定的。同时,由于dynamic_cast依赖RTTI,在没有多态行为时,dynamic_cast会出现编译错误(cannot dynamic_cast ‘p’ (of type ‘struct Parent*’) to type ‘struct Child*’ (source type is not polymorphic))
多继承
实际测试
(可以通过g++ -std=c++11 -fdump-class-hierarchy test.cpp
查看,更多细节参考 https://www.cnblogs.com/hamwj1991/p/12907683.html )
继承关系为:
struct Base {
int base_data;
virtual void BaseFunc() {}
};
struct Parent1 : virtual Base {
void BaseFunc() override {}
virtual void Parent1Func() {}
};
struct Parent2 : virtual Base {
void BaseFunc() override {}
virtual void Parent2Func() {}
};
struct Derived : Parent1, Parent2 {
virtual void DerivedFunc() {}
void BaseFunc() override {}
void Parent1Func() override {}
void Parent2Func() override {}
};
int main() {
Derived d;
return 0;
}
重点
- 在多继承场景下,第一个父类的行为跟单继承一致(如Parent1)。
- vptr指向的虚函数表的内容由vptr所属的静态类型决定。比如vptr2指向的虚函数表包含了继承自Parent2的虚函数以及继承自Base的虚函数(即Parent2指针所能调用的函数), 因为vptr1由Child和Parent1共享,因此需要从Child的角度和Parent1角度分别去看。虚函数表在编译期准备完成。
- 在多继承继承下,虚函数调用可能会发生指针偏移 。比如
Parent2* ptr = new Child(); ptr->Parent2Func();
ptr一开始指向Child对象中的Parent2子对象(该过程发生了指针偏移,在编译期完成),由于Parent2Func被子类重写,为了能够正确执行,ptr指针需要偏移,重新指向Child对象的起始处。由于ptr指向的实际类型在编译期并不可知,因此具体的偏移量只能在运行期才能确定(通过thunk技术)。
指针偏移
我理解为什么需要偏移:类成员名中保存了该成员变量基于对象首地址的偏移量(可从成员指针中解释),且固定不变。因此在访问成员变量前,需要确保对象首地址是正确的。
#include<stdio.h>
#include <iostream>
using namespace std;
struct Parent1
{
int val1;
virtual ~Parent1() {
cout << "~Parent1" << endl;
printf("this: %p\n", this);
}
};
struct Parent2
{
int val2;
virtual ~Parent2() {
cout << "~Parent2" << endl;
printf("this: %p\n", this);
}
};
struct Child: Parent1, Parent2
{
int val3;
~Child() {
cout << "~Child" << endl;
printf("this: %p\n", this);
}
};
int main()
{
Child *c = new Child;
Parent2 *p = c;
printf("Parent2::val2: %d\n", &Parent2::val2);
p->val2 = 1;
printf("c: %p, p: %p\n", c, p);
delete p;
return 0;-
}
/* 输出:
Parent2::val2: 8 (偏移量为8,这里gcc将vptr放在对象首部)
c: 0xed6010, p: 0xed6020 (地址相差16,即Parent1的大小,考虑了内存对齐)
~Child
this: 0xed6010
~Parent2
this: 0xed6020
~Parent1
this: 0xed6010
*/
C++多态
C++支持三种程序设计范式:
- 程序模型,即与C语言一样的,面向过程。数据和函数直接没有关联性,函数处理的是共同的外部数据。
- 抽象数据类型,Abstract Data Type (ADT),只使用了C++封装、继承特性,没有多态。函数调用在编译期就能决定。
- 面向对象模型,Object-oriented (OO),与ADT的主要区别是,使用了C++多态特性。