目录
2.一个类的构造函数除了处理用户的代码逻辑外,还做了哪些事情?
c++对象模型的基础不扎实,好好学学
c++对象模型
非静态成员变量被配置于每一个对象之内,静态成员变量则存放在所有的对象之外。静态和非静态成员函数也存放在所有的对象之外。虚函数则以两个步骤支持之:
1.每一个class产生出一堆指向虚函数的指针,放在表格之中,这个表格被称为virtual table(虚表)
2.每一个对象被添加了一个指针,指向相关的虚表,通常这个指针被称为vptr。vptr的设定(setting)和重置(resetting)都由每一个class的构造函数、析构函数、赋值运算符自动完成(后面讨论)。每一个class所关联的type_info object也经由虚表被指出来,通常是放在表格的第一个slot处。
- 一个知识点:指向不同类型对象的指针有啥不同之处?
如:
A *p;
int *pi;
char *pc;
从内存需求的观点来说,三者并无不同,他们都需要4字节或8字节(不同位数的机器而定)的存储空间来存放一个机器地址。他们之间的不同之处在于其所寻址出来的对象类型不同,也就是说,“指针类型”会告诉编译器如何解释某个特定地址中的内存内容及其大小。
如pi++是往后移动4byte,pc++是移动1byte。
那么一个void*指针是怎么样的呢?是的,我们不知道,这就是为何一个类型为void*的指针只能够含有一个地址,而不能够通过他操作其所指对象的缘故。
所以,类型转换cast其实是一种编译器指令,大部分情况下它并不改变一个指针所含的真正地址,它只影响“被指出之内存的大小和其内容”的解释方式。
- 在多态场景下,指针指向的内存是咋样的?
假设已有一个基类zooAnimal,占用内存16byte,现在有一个子类bear如下:
class bear : public zooAnimal{
public:
bear();
~bear();
void rotate();
virtual void dance();
protected:
enum Dances{...};
Dances dances_know;
int cell_block;
}
bear b("Yogi");
bear *pb = &b;
bear &rb = *pb;
b,pb,rb会有怎样的内存需求呢?不管是指针还是引用都只需要4字节的空间,bear对象需要24byte,也就是zooAnimal的16bytes再加上bear所带来的8bytes,可能的内存布局如下:
好,假设我们的bear对象放在地址1000处,一个bear指针和一个zooAnimal指针有什么不同?
bear b;
zooAnimal *pz = *b;
bear *pb = &b;
他们都指向bear对象的第一个byte,其间的差别是,pb所涵盖的地址包含整个bear对象,而pz所涵盖的地址只包含bear对象中的zooAnimal子对象。
除了zooAnimal 子对象中出现的members,你不能够使用pz来直接处理bear的任何members,唯一例外是通过virtual机制:
//不合法
pz->cell_block;
//ok,经过一个下行转换就没问题
((bear*)pz)->cell_block;
//这样更好,但他是一个run-time operation
if(bear* pb2 = dynamic_cast<bear*>(pz))
pb2->cell_block;
//ok,因为cell_block是bear的一个成员
pb->cell_block;
- 虚指针的指向:
创建两个子类对象时,他们的虚指针将指向同一个虚表
bear yogi;
bear winnie = yogi;//拷贝构造
//给函数形参赋值、传回函数返回值的情况下也是用到拷贝构造
当用一个子类对象作为拷贝构造的参数传给父类对象时,父类对象的虚指针是指向父类的虚表的,而不是指向子类的虚表
zooAnimal franny = yogi;
虚继承的情况:
编译器无法确定foo()之中“由pa存取X::i的实际偏移位置”,因为pa的真正类型可以改变。
解决方法:在子类对象的每一个虚基表(指向祖先类)中安插一个指针,通过偏移量来找到祖先类中的成员变量或成员函数。
佐证:
(32位机器下)我们对四个类进行sizeof可得大小为
class X{};
class A :virtual public X {};
class B :virtual public X {};
class C :virtual public A,virtual public B {};
int main() {
cout << sizeof(X) << endl;
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
cout << sizeof(C) << endl;
return 0;
}
分析:
X类的大小是1byte的原因:编译器会为空类插入一个char,用以区分此类创建的每个对象
AB类大小是4byte的原因:虚继承情况下,有虚基表,因此会存放一个指向虚基类的指针,4byte
C类大小12byte的原因:4+4+1=9byte,加上字节对其补上的3byte, 共12byte
成员变量
static成员变量:
按照其字面意义,被编译器提出于class之外,并被视为一个全局变量(但只在class生命范围之内可见)。每一个静态成员变量只有一个实体,存放在程序的data segment之中。每一次程序取用静态成员变量,就会被转化为对该唯一的extern实体的直接操作。如A::a。 注意,静态成员变量并不属于某一个具体的对象,而是归属此class的所有对象共享。
如果有两个class都声明了一个静态成员变量(取名一样),那么他们放在数据段时会发生名称冲突。编译器解决的方法是暗中对每一个静态成员变量进行编码(name-mangling),保证二者的独立性
非静态成员变量:
继承情况下的内存占用和分布:
三个类如下:
class A {
public:
int a;
};
class B :public A {
public:
char b;
};
class C :public B {
public:
char c;
};
他们各自的大小是多少呢?
A的是4bytes,B的是4+1+3(内存对齐)=8 bytes,c的是8+1+3=12 bytes。如下图:
继承且有多态情况下:
父类中有虚函数,就会多出虚指针
多重继承情况:
内存分布:
虚继承的情况:
内存分布:
我们知道,虚继承保证了每个子类中只有一份基类的成员,从而避免菱形继承情况下的访问二义性问题。
我们可以看一下虚继承下的内存分布情况:
看最下面的孙类内存分布,祖先类的成员分布称为virtual base table虚基表,在作者的编译器版本中,将访问虚基表的offset偏移量放置在类的virtual function table 虚函数表中,通过对vptr虚指针的索引来实现访问。vptr的正值索引就是访问虚函数表中的虚函数,负值索引就可以访问到祖先类虚基表的偏移量,从而实现对继承的来的祖先类中成员的访问。
注意:这里我们可以看到孙类中是有两个虚函数表的,也有两个虚指针,分别继承自两个父类。
成员函数
非静态成员函数:
c++的设计准则之一就是:非静态成员函数至少必须和一般的非成员函数有相同的效率。其实现手段是编译器将成员函数实体转换为对等的非成员函数实体。
float magnitude3d(const Point3d* p){
return sqrt(p->x*p->x + p->y*p->y + p->z*p->z);
}
乍一看似乎这个非成员函数比较没有效率,他间接地经由参数取用对象成员,而成员函数可以直接取用对象成员。然而实际上成员函数被内化为非成员函数的形式,下面就是转化步骤:
- 改写函数的signature(函数原型)以安插一个额外的参数到成员函数中,用以提供一个存取管道,使得class object得以调用该函数,该额外参数被称为this指针:
Point3d Point3d::magnitude(Point3d *const this)
*后加const表示此指针的值不可改变,即不能改变其指向
若成员函数是const的(括号后面加上const关键字),则变成:
Point3d Point3d::magnitude(const Point3d *const this)
- 将每一个“对非静态成员变量的存取操作”改为经由this指针来存取
return sqrt(this->x*this->x + this->y*this->y + this->z*this->z);
- 将成员函数重新写成一个外部函数,对函数名称进行“mangling”处理,使他在程序中称为独一无二的语汇:
extern magnitude_7Point3dFv(register Point3d *const this);
//现在这个函数已经被转换好了,而其每一个调用操作也都必须转换,于是:
obj.magnitude();
//变成了:
magnitude_7Point3dFv(&obj);
//而
ptr->magnitude();
//变成了:
magnitude_7Point3dFv(ptr);
名称的特殊处理(name mangling)
一般而言,member 的名称前面会被加上class名称,形成独一无二的命名,例如
class Bar{
public:
int ival;
};
其中的ival可能会变成这样:
ival_3Bar
为啥要这样做?考虑这种继承情况,子类中自己也定义了一个与基类重名的成员变量:
不管要处理哪一个ival,通过“name mangling”都可以绝对清楚地指出来。
还有一个典型应用就是函数重载:
class Point{
public:
void x(float a);
float x();
};
加上参数列表的name mangling才能使这两个函数在重载后区分开。(返回值不参与重载)
虚成员函数
如果normalize()是一个virtual member function,那么以下的调用:
ptr->normalize();
//会被内部转化为:
(*ptr->vptr[1])(ptr);
其中:
vptr是编译器产生的虚指针,指向虚表。每个对象都会有,其名称也会被“mangled”,因为在一个复杂的类派生体系中,可能一个对象会由多个vptrs。
1是virtual table slot的索引值,关联到normalize()函数
第二个ptr表示this指针
虚函数与inline的pk:
先回顾下inline:
在函数返回类型前加上关键字inline就可以将函数指定为内联函数:
1 inline const string& shortString(const string &s1, const string &s2) {
2 return s1.size() < s2.size() ? s1 : s2;
3 }
函数指定为内联函数,(通常)就是将它在程序调用点上”内联地“展开。假设我们将shortString定义为内联函数,则调用:
cout<<shortString(s1,s2)<<endl;
在编译时展开为:
cout<<(s1.size() < s2.sizre() ? s1 : s2)<<endl;
将函数声明为内联的方法:
1. 在函数返回类型前加inline(inline return_type function(parameters))。成员函数可以在声明时候添加inline也可以在定义时候添加inline。
2. 将成员函数定义在类定义式内,这个成员函数就被隐喻声明为inline.
内联说明(inline specification)对于编译器来说只是一个建议,编译器可以忽略这个建议。
内联函数的优点:
可以避免调用函数的开销。当函数体比较小的时候,内联函数可以令目标代码更加高效。对于存取函数以及其他一些比较短的关键执行函数。
内联函数的缺点:
1. 由于将对函数的每一个调用都以函数本体替换之。所以会增加目标代码的大小。造成代码膨胀。这将导致程序体积太大,不利于在内存不大的机器上运行。
2. inline函数无法随着程序库的升级而升级。如果程序库中包含内联函数,一旦内联函数被改变,那么所有用到程序库的客户端程序都要重新编译。如果函数不是内联函数,一旦它有任何修改,客户端只需要重新连接就好,远比重新编译的负担少的多。在头文件加入或修改内联函数时,使用该头文件的所有源文件都必须重新编译。
3. 很多调试器无法调试内联函数。很多建置环境仅仅只能”在调试板程序中禁止发生inlining“
一般来说,内联机制适用于优化小的、只有几行的而且经常被调用的函数。一个比较得当的处理规则是,不要内联超过10行的函数。只有当函数为10行甚至更少时才会将其定义为内联函数。
此时我们知道:virtual意味“等待,直到运行期才确定调用哪个函数”,而inline意味着“执行前,先将调用动作替换为被执行函数的本体”。
那么,如果将虚函数前面加上inline会发生啥?
书中提到了这样一个例子:
用class scope operator(就是类名::函数名)明确调用一个虚函数,其决议(resolved)方式会和非静态成员函数一样:
register float mag = Point3d::magnitude();
会被转化为:
register float mag = magnitude_7Point3dFv(this);
此时,将虚函数加上inline关键字会提升效率:
inline virtual float magnitude();
对于如下调用:
Point3d obj;
obj.normalize();
//被转化为:
normalize__7Point3dFv(&obj);
具体的虚函数细节将在下面一节讨论。
一般来说,编译器会忽略对虚函数的inline建议。
静态成员函数:
主要特性就是它没有this指针,由此衍生出的次要特性是:
- 不能直接存取其class中的非静态成员变量
- 不能够被声明为const、volatile或virtual
- 不需要经过class 对象才被调用
如果取一个静态成员函数的地址,获得的将是其在内存中的位置,也就是其地址,由于它没有this指针,所以其地址的类型并不是一个“指向成员函数的指针”,而是一个“非成员函数指针”。也就是说
&Point3d::object_count();
//会得到一个数值,类型是
unsigned int (*)();
//而不是
unsigned int (Point3d::*) ();
再说虚函数:
当一个class中含有虚函数时,此class会有一个虚表,内含该class中有作用的虚函数地址,然后每个对象有一个vptr,指向虚表的所在。
编译器做的工作:
- 将虚函数的地址填入虚表。由于程序执行时,表格的大小和内容都不会改变,所以虚表的构建和存取皆可以由编译器完全掌握
- 为了找到表格,在每一个对象内安插一个虚指针vptr,指向虚表
- 为了找到函数地址,每一个虚函数被指派一个表格索引值
单继承下的虚表:
多继承下的虚表:
由于执行器链接器的降临(可以支持动态共享函数库),符号名称的链接可能变得非常缓慢。
为了提高执行器链接器的效率,sun编译器将多个虚表连锁成为一个:指向次要表格的指针,可由主要表格名称加上一个offset获得,这样的策略下,每一个class只有一个具名的虚表。(主要表格指的是子类中Base1类的虚表,次要表格指的是Base2类的虚表)。
构造、析构与拷贝
带有虚函数的类:
假设有一个类,带有虚函数,我们可以知道他会有一个虚表和虚指针,那么编译器会对构造函数加上一些代码,如下:
Point* Point::Point(Point *this, float x, float y)
:_x(x),_y(y)
{
//设置vptr
this->__vptr_Point = __vtbl__Point;
//扩展menber initialization list
this->_x=x;
this->_y=y;
//传回this
return this;
}
编译器会合成一个拷贝构造函数:
inline Point* Point::Point(Point* this, const Point &another)
{
this->__vptr_Point = __vtbl__Point;
//...一些成员函数的拷贝操作
return this;
}
继承体系下的对象构造:
构造函数可能带有大量的隐藏代码,因为编译器会扩充每一个构造函数,扩充成都视class的继承体系而定,一般包含:
- 如果对象有虚指针,他们必须被设定初值,并指向适当的虚表
- 调用所有上一层的基类构造函数,以基类声明顺序为顺序
如果基类被列于初始化成员函数列表中,那么任何明确指定的参数都应该传递过去,如果不在列表中,而他有默认构造(或默认拷贝构造),则调用之。
如果基类是多重继承下的第二或后继的基类,那么this指针必须有所调整
- 所有虚基类的构造函数必须被调用,从左到右,从最深到最浅
如果class被列于初始化成员列表中,那么任何明确指定的参数都应该传递过去如果不在列表中,而他有默认构造(或默认拷贝构造),则调用之。
此外,class中的每一个virtual base class subject的偏移量(offset)必须在执行期可被存取
如果对象是最底层的class,其构造函数可能被调用,某些用以支持这个行为的机制必须被放进来
- 记录在初始化成员列表中的成员变量初始化操作会被放进构造函数本身,并以成员变量的声明顺序为顺序
- 如果有一个成员变量没有出现在初始化成员列表中,但他有一个默认构造函数,则调用此默认构造函数
构造函数做的工作步骤:
- 在子类构造函数中,所有虚基类及上层的基类的构造函数会被调用
- 上述工作完成后,对象的vptrs被初始化,指向相关的虚表
- 如果有初始化成员列表的话,将在构造函数体内扩展开来。这必须在vptr被设定之后才进行,以免有一个虚成员函数被调用
- 最后,执行程序员所提供的代码
因此,在构造函数中调用虚函数是安全的
析构函数做的工作步骤:
- 析构函数的本身被执行
- 如果class拥有成员对象,而后者拥有析构函数,那么他们会以其声明顺序的相反顺序被调用
- 如果对象内有一个vptr,则现在被重新设定,指向适当的基类的虚表(才能调用其析构函数),也就是说vptr会在程序员的代码执行前被重置
- 如果有任何直接的(上一层)非虚基类拥有析构函数,他们会以其声明顺序的相反顺序被调用
- 如果有任何虚基类拥有析构函数,而当前讨论的这个类是最尾端的类,那么他们会以 其原来的构造顺序的相反顺序被调用
因此,在析构函数中调用虚函数是不安全的
一些注意点:
1.全局对象的内存
全局对象的内存保证会在程序激活的时候被清为0,局部对象配置于程序的堆栈中,堆对象配置于自由空间中,都不一定会被清为0,他们的内容将是内存上次被使用后的痕迹。
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区(https://blog.csdn.net/qq_28114615/article/details/98041319)
2.一个类的构造函数除了处理用户的代码逻辑外,还做了哪些事情?
对于有虚函数的类,在构造函数中编译器会初始化每个对象的虚指针vptr,放置适当的虚表地址。
将初始化成员列表扩展为用this指针存取赋值。
若类中含有别的class object,则在构造函数和析构函数中自动添加object的构造和析构函数
3.explict的作用:防止隐式转化
class A {
public:
explicit A(int a) {
this->a = a;
}
int a;
};
int main() {
A a = 10; //有explicit的话就不会触发隐式转换,这条语句编译不过
return 0;
}
4.初始化成员列表
class word{
public:
word(){
_name = 0;
_cnt = 0;
}
string _name;
int _cnt;
}
这样写没问题,但是to naive,构造函数会先产生一个临时的string对象并初始化,然后用赋值构造给_name进行初始化,最后销毁临时对象
较好的方式是用初始化成员列表来进行成员变量的初始化:
class word{
public:
word():_name(0),_cnt(0){};
string _name;
int _cnt;
}
但是要注意,初始化列表中的成员顺序应该与类中成员变量的声明顺序保持一致。因为他的初始化顺序是按声明顺序进行的。避免出现下面这样隐秘的bug:
5.一个多态情况下观察虚函数地址的例子
#include <iostream>
using namespace std;
class Base
{
public:
int base_data;
Base() { base_data = 1; }
virtual void func1() { cout << "base_func1" << endl; }
virtual void func2() { cout << "base_func2" << endl; }
virtual void func3() { cout << "base_func3" << endl; }
};
class Derive : public Base
{
public:
int derive_data;
Derive() { derive_data = 2; }
virtual void func1() { cout << "derive_func1" << endl; }
virtual void func2() { cout << "derive_func2" << endl; }
};
typedef void(*func)();
int main()
{
Base base;
cout << "&base: " << &base << endl;
cout << "&base.base_data: " << &base.base_data << endl;
cout << "----------------------------------------" << endl;
Derive derive;
cout << "&derive: " << &derive << endl;
cout << "&derive.base_data: " << &derive.base_data << endl;
cout << "&derive.derive_data: " << &derive.derive_data << endl;
cout << "----------------------------------------" << endl;
for (int i = 0; i < 3; i++)
{
// &base : base首地址
// (unsigned long*)&base : base的首地址,vptr的地址
// (*(unsigned long*)&base) : vptr的内容,即vtable的地址,指向第一个虚函数的slot的地址
// (unsigned long*)(*(unsigned long*)&base) : vtable的地址,指向第一个虚函数的slot的地址
// vtbl : 指向虚函数slot的地址
// *vtbl : 虚函数的地址
unsigned long* vtbl = (unsigned long*)(*(unsigned long*)&base) + i;
cout << "slot address: " << vtbl << endl;
cout << "func address: " << *vtbl << endl;
func pfunc = (func)*(vtbl);
pfunc();
}
cout << "----------------------------------------" << endl;
for (int i = 0; i < 3; i++)
{
unsigned long* vtbl = (unsigned long*)(*(unsigned long*)&derive) + i;
cout << "slot address: " << vtbl << endl;
cout << "func address: " << *vtbl << endl;
func pfunc = (func)*(vtbl);
pfunc();
}
cout << "----------------------------------------" << endl;
system("pause");
return 0;
}
运行结果:
延伸一些问题:
- 编译器怎么确定虚函数地址在虚表里面的位置?
是根据虚函数在类中的声明顺序来查找虚函数表slot吗
- 虚表指针存在对象的哪个位置
每个对象的起始位置 (int*)&derive
6.对象数组的问题
我们知道,创建和删除对象数组可以这样写:
B* arrptr = new B[10];
delete[] arrptr;
- new[] 应该与 delete[] 配合使用
因为delete[] 会先找到arrptr前面4字节内存放的数组元素个数n,然后执行n次析构函数
若用delete去释放new[],会如下所示,只调用一次析构函数并且程序崩溃了
- 不要用基类指针去指向子类对象数组
假设有:
class B {
public:
B() { cout << "B construct" << endl; }
virtual ~B() { cout << "B destroy" << endl; }
};
class C :public B{
public:
C() { cout << "C construct" << endl; }
virtual ~C() { cout << "C destroy" << endl; }
int c;
};
B* arrp = new C[10];
delete[] arrp;
运行结果:好像没毛病啊,跟书上说的有点不一样,是不是编译器不同,做了一些优化
书上是这种情况: