本篇文章介绍C++的构造函数和虚构函数
因为介绍构造函数基本都会设计虚函数和虚基类的使用,可以参考之前的文章:
C++中的虚函数
C++的虚基类
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数控制其对象的初始化过程,这些函数叫做构造函数。
所以说,构造函数的唯一作用就是初始化类的实例
,注意初始化的意思两个:
这也是初始化和赋值的区别:
初始化和赋值的区别就看操作的是不是一个新对象,如果一个新对象被定义,那就是初始化,如果没有新对象定义,那就是赋值。
从参数数量上,构造函数分为无参构造函数和有参构造函数,无参构造函数也叫做默认构造函数。
我们经常从资料上看到这么一句话:
如果一个类没有任何构造函数,编译器会创建一个默认构造函数
我们看下面的例子:
#include <iostream>
class A
{
public:
int a;
};
int main(int argc, const char* argv[])
{
A* a = new A();
a->a = 1;
}
上面的类A有默认构造函数吗?
答案是并没有
那a是怎么初始化的呢?
我们看一下代码执行a的初始化的时候的代码:
A* a = new A();
00007FF70EA624D3 mov ecx,1Ch
00007FF70EA624D8 call operator new (07FF70EA6104Bh)
00007FF70EA624DD mov qword ptr [rbp+0E8h],rax
00007FF70EA624E4 cmp qword ptr [rbp+0E8h],0
00007FF70EA624EC je main+5Eh (07FF70EA6250Eh)
00007FF70EA624EE mov rdi,qword ptr [rbp+0E8h]
00007FF70EA624F5 xor eax,eax
00007FF70EA624F7 mov ecx,1Ch
00007FF70EA624FC rep stos byte ptr [rdi]
主要操作有两步:
调用new函数在堆上分配了内存
重点是这句话
rep stos byte ptr [rdi]
这句话的意思是给一段连续的内存赋值:
xor eax,eax
,就是灵eax寄存器为0.所以上面这行汇编的意思就是将这个类实例的内存全部初始化为0.
所以说一个类可以没有构造函数,这个时候,当初始化一个类的时候,直接根据类占用内存大小在栈或者堆中分配内存空间,然后初始化为0,仅此而已
那是不是说资料上的说法是错误的呢?其实,我们可以从两个角度来看是否存在默认构造函数:
对于用户来说,类一定有构造函数
,编译器帮我们处理了没有任何构造函数的情况,并且还会将成员变量的值全部置成0.// 依然可以这么调用。仿佛类是有默认构造函数的。
A* a = new A();
a->a = 1;
一个类,作者可以定义构造函数,可以定义多个,有参无参都可以。如果一个含参的构造函数的所有参数都有默认值,也相当于类包含一个默认构造函数。
如果一个类没有构造函数,我们从上面知道,类实例化的时候的操作很简单,我们不讨论这部分,我们讨论存在默认构造函数的情况。
那么如果我们代码没有定义构造函数,编译器什么时候帮我们合成一个默认构造函数呢?
编译器生成默认构造函数的情况如下:
默认构造函数
。这时候编译器会为当前类生成一个默认构造函数,来调用成员变量的默认构造函数。接下来我们分析一下为什么这些情况需要默认构造函数,先看下面两个前提,所有的分析都是基于下面的两个前提:
编译器生成默认构造函数的前提是类一定没有定义任何构造函数
。如果有构造函数,那所有的初始化工作应该是程序员负责,编译器可能进行协助。即使赋的初值是0也会生成默认构造函数
。构造函数的列表初始化是指在构造函数函数体执行之前执行的初始化操作
,有些操作是我们自己添加的,有些是编译器帮我们添加的,总的来说,列表初始化的操作范围如下:
这些基本都和上面我们介绍生成默认构造函数的条件是匹配的,我们现在来看一下这些初始化的顺序是怎样的。
看下面的代码:
class B1
{
public:
int b1 = 3;
};
class B2
{
public:
int b2 = 3;
};
class A:virtual public B1
{
public:
B2 b3;
int a1;
int a=0;
B2 b2;
A(int pA) :
a(pA) {}
virtual void A1() {}
};
我们看一下A的构造函数执行:
00007FF6A6122351 mov rax,qword ptr [this]
00007FF6A6122358 lea rcx,[A::`vbtable' (07FF6A612BC88h)]
00007FF6A612235F mov qword ptr [rax+8],rcx
00007FF6A6122363 mov rax,qword ptr [this]
00007FF6A612236A add rax,20h
00007FF6A612236E mov rcx,rax
00007FF6A6122371 call B1::B1 (07FF6A612154Bh)
00007FF6A6122376 mov rax,qword ptr [this]
00007FF6A612237D lea rcx,[A::`vftable' (07FF6A612BC80h)]
00007FF6A6122384 mov qword ptr [rax],rcx
00007FF6A6122387 mov rax,qword ptr [this]
00007FF6A612238E add rax,10h
00007FF6A6122392 mov rcx,rax
00007FF6A6122395 call B2::B2 (07FF6A6121541h)
00007FF6A612239A mov rax,qword ptr [this]
00007FF6A61223A1 mov ecx,dword ptr [pA]
00007FF6A61223A7 mov dword ptr [rax+18h],ecx
00007FF6A61223AA mov rax,qword ptr [this]
00007FF6A61223B1 add rax,1Ch
00007FF6A61223B5 mov rcx,rax
00007FF6A61223B8 call B2::B2 (07FF6A6121541h)
00007FF6A61223BD mov rax,qword ptr [this]
00007FF6A61223C4 lea rsp,[rbp+0C8h]
00007FF6A61223CB pop rdi
00007FF6A61223CC pop rbp
00007FF6A61223CD ret
通过上述汇编代码,我们能够得出下面的结论,注意,这些结论是针对于vs的编译器来说的,我个人觉得,有些初始化顺序是无所谓的,别的编译器并不一定和当前一致:
基类的初始化发生在虚函数指针初始化之前
。这个好理解,因为基类也有可能存在虚函数指针,我们必须保证最终的指针指向的是当前类的虚函数表。所以虚函数指针的初始化应该在基类的初始化后面才能覆盖之前的赋值。如果类的成员是const、引用或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
如果在构造函数中调用虚函数,并不通过虚函数表来调用,而是从起始构造函数向后查找,先找到虚函数就调用哪个?
不要在类的构造函数中和析构函数中调用虚函数
委托构造函数使用它所属类的其他构造函数来执行它自己的初始化过程。
委托构造函数的使用注意事项:
委托构造函数的初始值列表只有一个
,就是类名本身,相当于使用另外一个构造函数或者委托构造函数来进行初始化委托构造函数的执行顺序:
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体先依次执行,然后执行构造函数的函数体
看下面的例子:
#include <iostream>
class A
{
public:
int a;
int b;
int c;
public:
A():A(0){std::cout<<"A()"<<std::endl;};
A(int pa):A(pa,0){std::cout<<"A(int)"<<std::endl;};
A(int pa,int pb):A(pa,pb,0){std::cout<<"A(int,int)"<<std::endl;};
A(int pa,int pb,int pc):a(pa),b(pb),c(pc){std::cout<<"A(int,int,int)"<<std::endl;};
};
int main(int argc, const char * argv[])
{
A a;
return 0;
}
输出结果如下:
A(int,int,int)
A(int,int)
A(int)
A()
总得来说,当一个类执行默认初始化时会调用默认构造函数
在实际中,即使定义了其他构造函数,最好也提供一个默认构造函数。
如果一个构造函数只有一个实参,并且没有添加explicit修饰符,则该构造函数称为转换构造函数,转换构造函数定义了从实参类型到类类型的隐式转换机制。
看下面的例子:
class A
{
public:
int a=0;
A(int pA) :
a(pA) {}
};
我们可以使用下面的方法创建类A的实例:
int main(int argc, const char* argv[])
{
// 可以直接使用1来创建A的实例
A a = 1;
std::cout << "A::a=" << a.a << std::endl;
}
通过在函数定义前添加explicit修饰符,能够禁止隐式转换
。
满足下面这些条件的类称为聚合类:
聚合类可以使用初始化列表直接初始化类对象,使用形式跟数组是一致的。
如果在类的外部定义静态函数,static关键字只能在函数声明的时候使用。
可执行文件中会保存全局对象在运行时的内存地址,在main函数体执行之前,会有编译器插入的代码:
局部静态对象是在第一次使用的时候才会被分配内存,并且进行初始化,并且编译器在局部静态变量所在内存位置添加了一个四字节标志,用来表示是否初始化成功。如果初始化成功,局部静态对象再次被使用的时候就不进行初始化了,并且向全局添加程序执行完成后的析构代码。
~A();//声明
A::~A() {}//定义
从编译器角度来说,析构函数和构造函数一样,不一定存在。并且析构函数和构造函数的区别在于析构完成后我们就不会在使用对象了,鉴于此,析构函数不需要考虑类似构造函数中的赋值操作。因为只要把内存释放了就行了,反正值也不会再用。所以析构函数存在的必要比构造函数低,析构函数存在的情况:
基类中都要声明析构函数为虚函数
如果成员是基本类型,不用处理
#include <iostream>
class A
{
public:
int a=3;
~A() {};
virtual void vA() {}
};
class A1
{
public:
int a1=2;
~A1() {};
};
class B :public A
{
public:
int b=10;
A1 a;
A1* a3;
int b1 = 20;
A1 a2;
B() { a3 = new A1(); }
~B() { delete a3; }
};
void test()
{
B b;
std::cout << "b::" << &b << std::endl;
std::cout << "b::size::" << sizeof(B) << std::endl;
}
执行test方法,我们得到b的内存大小为40个字节,分配顺序如下:
下面是内存布局的截图:
我们分析一下当test执行完成后析构的执行顺序,指令代码如下,关键位置我添加了注释。
00007FF695AF1E41 mov rcx,qword ptr [rbp+0C8h]
// 执行析构函数体 delete a3
00007FF695AF1E48 call A1::`scalar deleting destructor' (07FF695AF151Eh)
00007FF695AF1E4D mov qword ptr [rbp+0D8h],rax
00007FF695AF1E54 jmp B::~B+71h (07FF695AF1E61h)
00007FF695AF1E56 mov qword ptr [rbp+0D8h],0
00007FF695AF1E61 mov rax,qword ptr [this]
// 定位到成员变量a2处,a2是A1类的实例
00007FF695AF1E68 add rax,24h
00007FF695AF1E6C mov rcx,rax
// 调用a2的析构函数
00007FF695AF1E6F call A1::~A1 (07FF695AF1523h)
00007FF695AF1E74 mov rax,qword ptr [this]
// 定位到成员变量a1处,a1是A1类的实例
00007FF695AF1E7B add rax,14h
00007FF695AF1E7F mov rcx,rax
// 调用a1的析构函数
00007FF695AF1E82 call A1::~A1 (07FF695AF1523h)
// 定位到当前类实例的起始位置
00007FF695AF1E87 mov rcx,qword ptr [this]
// 调用基类的析构函数
00007FF695AF1E8E call A::~A (07FF695AF150Ah)
00007FF695AF1E93 lea rsp,[rbp+0E8h]
00007FF695AF1E9A pop rdi
00007FF695AF1E9B pop rbp
00007FF695AF1E9C ret