本节内容源于对C++ primer第13章的学习,这本书把C++的原理将得明明白白。网上的博客往往讲得一头雾水。到头来还不如看原书本。
问题
首先给出一题:
#include<stdio.h>
class A{
public:
~A();
};
A::~A(){
printf("deleteA");
}
class B:public A{public: ~B();
B::~B(){
printf("deleteB");
}
int main()
{
A * pa = new B();
delete pa;
}
问,最后程序输出的是什么?
答案是 deleteA. 为什么?
再看另一题
#include<stdio.h>
class A{
public:
virtual ~A();
};
A::~A(){
printf("deleteA");
}
class B:public A{public: virtual ~B();
B::~B(){
printf("deleteB");
}
int main()
{
A * pa = new B();
delete pa;
}
又会输出什么? 答案是deleteBdeleteA,这又为什么呢?
阅读以下内容。
基类指针指向派生类对象
以上两个问题都是基类指针指向了派生类对象,为什么会不一样?这涉及到派生类对象的产生过程。
首先,公有派生是派生类继承基类时候用public修饰符。
A:public B{
};
于是B就是公有基类。公有派生会使基类的公有成员成为派生类的公有成员。基类的私有成员虽然会成为派生类一部分,但是派生类无法访问,只能通过基类公有方法访问。
派生类不能直接访问基类的私有成员,必须通过基类方法进行访问。
派生类在产生对象时候,派生类的构造函数会在执行其函数体之前先将参数通过成员初始化列表将参数传递给基类构造函数,然后调用它构造出基类对象——因此基类对象在派生类对象之前便被创建,
这也是题目中基类指针可以指向派生类对象的原因。如果用基类指针,实际上指针所指向的就是派生类对象中嵌套的基类对象,所以该指针只能调用的方法和成员都是基类的。这就解释了题1中为什么会用A类的析构函数。
而为什么题目二中会先调用B的析构函数,再调用A的析构函数呢?
这里有两个原因:
1.派生类的析构函数本身就会先调用自身,在调用派生类自动生成的基类对象的析构函数,原因之前讲过了,派生类会对象会首先自动生成基类对象,这是继承的实质。
2.题目2中的虚函数使得基类指针调用的方法是派生类对象的方法,所以题目2调用了类B的析构函数。
3.综上,由于虚函数,使得析构了B对象,而B对象的析构函数执行最后还会调用基类对象的析构函数将自动生成的基类对象也释放掉。
公有继承的本质:
新的派生类对象将继承基类的对象的公有成员的实现,包括方法和变量。
派生类对象可以直接使用基类的方法(继承了基类的接口)
实际上,相当于派生类自动地实现了基类的方法和变量。
此时调用的方法,都是基类对象的方法和数据。
在C++中,如果基类指针指向派生类对象,且指针调用的函数如果不是虚函数,则该函数具体是哪一个取决于指针的类型。而如果调用的函数是虚函数,则该函数具体属于哪一个取决于被指向的对象的类型。而对象是运行时动态分配的,
所以识别其类型需要编译器进行动态绑定。
所以题目2中,A 和 B的析构函数都是虚函数, 因此指针指向类B对象,析构时调用的也是类B的析构函数。
C++的析构函数执行时,派生类会先调用自己的析构函数,再调用基类的析构函数(因为前者在栈顶,后者在栈底部)。
这里还引申出了一个问题,为什么虚函数要是析构函数? 因为如果不是的话,就如图1,析构的只是派生类中嵌套的基类对象,所以剩余的派生类对象就没有析构。这导致了内存泄漏。
ps(派生类指针最好不要指向基类对象)
如果基类和派生类都定义了相同名称的成员函数,那么经由对象指针调用成员函数时候,到底调用哪一个函数,必须根据该指针的原始类型而定。
而不是视指针所指的对象的类型而定。
基类指针指向派生类指针的时候,所调用的都是基类对象的方法和数据,因为**派生类继承基类并且创建对象后基类对象会首先被创建,并且调用基类构造函数**,而派生类构造函数会初始化新增的成员。
派生类是实际上是包含了基类对象的。所以基类指针所覆盖的大小只包含了派生类的基类部分,所以无法调用派生类对象的成员。
派生类对象的堆栈状态是, 基类构造函数首先调用,再调用派生类构造函数,这通过派生类构造函数调用基类构造函数构建嵌套基类对象完成。
所以析构的时候,首先是派生类析构,然后再析构基类。
C++使用成员初始化列表句法实现了功能: 即在基类对象在程序进入派生类构造函数之前被创建。
派生类构造函数中的实参会先通过成员初始化列表将其传到基类对象构造函数中
后者创建一个嵌套的基类对象,然后程序才会进入派生类构造函数体,并创建派生类。见图13.2
如果不使用初始化成员列表,等同于基类调用默认构造函数。
派生类对象可以使用基类方法,基类指针可以直接指向派生类对象。引用也一样。
不过这样的话,基类指针和引用只能用于调用基类方法。
公有继承是is-a关系, 即 is-a-kind-of 的关系,午餐可能包含水果,但是水果不是午餐的一种。 公有继承将public给派生类对象使用。
多态公有继承
多态,同一个方法随着上下文改变而改变。c++的多态实现,由两种机制实现:
1 在派生类中重写基类函数
2 两个函数都是用虚方法
记住: 如果没有关键字 virtual , 程序将根据指针或引用类型选择方法;如果使用了virtual则程序根据它们指向的对象选择方法。
这是由于派生类在构造的时候会先生成基类对象,派生类会通过成员初始化列表将参数传递给基类构造函数构造基类对象,然后再在派生类构造函数的函数体中构造嵌套的派生类对象。‘
并且公有继承的派生类无法直接访问基类的私有成员,而只能通过基类的公有方法,即派生类方法中可以调用基类公有方法:
void B::func(){
A::func();
}
这是除了构造函数使用成员初始化列表访问基类私有成员的另一种做法。
此外注意, B::func函数体中的func使用了作用域解析操作符,表明该函数属于A类,如果不使用作用域解析操作符,则func会被认为是B类的。
但如果派生类没有重写基类的函数,例如:func2,该函数只在基类中有而派生类中没有,那么不写作用域解析操作符也没关系,在C++中,重写并不一定表示覆盖。
常见面试题——析构函数为什么是虚函数
一般情况下,如果是直接使用派生类对象,则它在析构的时候没有这个问题,但是如果使用基类指针指向派生类对象,则通过基类指针调用的析构函数只能是基类的析构函数,所以派生类对象的析构函数就没有调用,这就造成了内存的泄露。
这个根本的原因是由于C++的类继承,派生类在公有继承基类的时候,还在派生类构造函数执行前先执行基类构造函数生成一个嵌套的基类对象,派生类拥有基类对象的公有成员和方法并且可通过公有方法访问基类私有成员。而派生类对象过期的时候会先析构派生类对象,再析构基类对象。
虚函数无法使用静态联编确定使用哪一个函数,而只能通过动态联编。动态联编是编译器生成在程序运行时选择正确虚函数的代码。
对非虚函数进行静态联编,基类指针指向派生类对象,且指针调用非虚方法,这时编译器根据指针类型确定调用哪个方法;而指针调用的方法是虚方法时候,根据指针指向的对象的类型,
但是对象的类型只有在运行时才能确定,在运行时根据对象类型将函数关联到某个具体对象中。
如果派生类不重新定义基类的方法以及如果类不会用作基类,则不需要动态联编。静态联编的效率更好。C++的指导原则就是,不要为不使用的特性付出代价。
如果要在派生类中重新定义基类方法,就是用虚函数,虚函数可以实现多态。
如果在基类和派生类上的析构函数都添加虚函数,则在用基类指针指向派生类的时候,
析构的时候,也是先析构派生类对象,后析构基类对象。
回到那个问题,由于程序是基类指针指向了派生类对象,所以通过该指针只能调用基类的一切方法,而无法调用派生类的。
所以指针释放内存的时候,也只是将派生类构建的基类对象释放了。造成了内存泄漏。
虚函数的实现原理——虚函数表
虚函数通过虚函数表实现,虚函数表就是指向函数地址数组的指针。
所有的虚函数都会被放到该指针数组。
基类对象包含一个指针,指向基类中所有虚函数的地址表。
基类和派生类都会有各自的隐藏指针,保存各自的虚函数表,将类中的所有虚函数按照顺序存放到指针数组里面,
派生类中的虚函数表的顺序一开始和基类一样,如果派生类中有重新定义的
虚表是属于类的,每个类只需要一个虚表即可。同一个类的所有对象都使用通过一个虚表。派生类会继承基类的虚表
每个类会将其中的虚函数组成一个虚表,就是指针数组来指向它们。
经过虚表调用虚函数的过程叫做动态绑定。
动态绑定的时候, 基类指针指向派生类对象,调用虚函数的时候,会调用派生类对象的vptr,vptr指向的虚函数会被调用,而vptr指针调用的对象就是用
虚函数表指向了虚函数,指针调用虚函数的时候,通过虚函数表找到虚函数。就像页表一样。 所有的虚函数,被这些类的虚函数表包含了。
所以通过虚函数表就能够准确地找到具体的虚函数。
为什么构造函数不能是虚函数
因为派生类不继承基类构造函数,派生类的构造函数会在执行前先调用基类构造函数生成基类对象然后再执行自己的构造函数。
虚函数是用于继承和多态的,构造函数不用于继承。
析构函数要是虚函数,因为析构函数应当用于一种特别的方案。
包含纯虚函数的类只能作为基类无法产生对象。用作抽象类。