继承,虚函数和多态

继承 with virtual function

构造由内而外:首先调用父类的构造函数,然后再调用自己。
析构由外而内:首先执行自己的析构函数,然后调用父类的析构函数。

non-virtual: 你不希望重新定义(重写)它。
virtual: 你希望子类重新定义它,且它有默认定义。
pure virtual: 你希望子类一定要重新定义它,你对它没有默认定义。

1
2
3
4
5
6
7
8
9
class Shape {
public:
virtual void draw() const = 0; // 纯虚函数
virtual void error(const std::string& msg); // 虚函数
int objectID() const; // 一般成员函数
};

class Rectangle: public Shape {...};
class Ellipse: public Shape {...};

多态

在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据所指对象的实际类型来调用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数。

虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表

虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针

  • 编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址
  • 编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数
  • 在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
  • 当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面

基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间

首先整理一下虚函数表的特征:

  • 虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
  • 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段
  • 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中

根据以上特征,虚函数表类似于类中静态成员变量。静态成员变量也是全局共享,大小确定,因此最有可能存在全局数据区。

由于虚表指针vptr跟虚函数密不可分,对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,当一个构造函数被调用时,它做的首要的事情之中的一个是初始化它的VPTR,并且存在对象内存布局的最前面。

虚函数表

单继承与多重继承下的虚函数表

总结来说:

  • 一般继承无虚函数覆盖:父类虚函数在前,子类在后。
  • 一般继承有虚函数覆盖:子类虚函数直接覆盖父类虚函数被重写的虚函数,其余不变。
  • 多重继承无虚函数覆盖:每个父类都有自己的虚表,子类虚函数被放到第一个父类的虚函数表中。
  • 多重继承无虚函数覆盖:子类虚函数覆盖所有父类虚函数表被重写的函数,其余不变。

补充:inline内联函数

内联函数和普通函数的区别在于:当编译器处理调用内联函数的语句时,不会将该语句编译成函数调用的指令,而是直接将整个函数体的代码插人调用语句处,就像整个函数体在调用处被重写了一遍一样。相比之下,普通函数能够避免将相同代码重写多次的麻烦,还能减少可执行程序的体积,但也会带来程序运行时间上的开销;而内联函数省去了调用函数的时间开销。

在函数调用执行过程中,首先要为在栈中的形参和局部变量分配存储空间,然后再将实参的值复制给形参,然后还要将函数的返回地址放入栈中,最后才跳转到函数内部执行。return语句返回时,还要从栈中回收形参和局部变量占有的存储空间,然后从栈中取出返回地址,跳转到该地址继续执行。

如果内联函数执行的时间很长,那函数调用的时间相比起来就微不足道,使用内联函数也就没有意义了。从另一方面来说,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。因此,内联函数中的代码应该是很简单,执行起来很快的一些语句。

Google C++编码规范对于inline的使用说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
内联函数:
Tip: 只有当函数只有 10 行甚至更少时才将其定义为内联函数.

定义: 当函数被声明为内联函数之后, 编译器会将其内联展开, 而不是按通常的函数调用机制进行调用.

优点: 当函数体比较小的时候, 内联该函数可以令目标代码更加高效. 对于存取函数以及其它函数体比较短, 性能关键的函数, 鼓励使用内联.

缺点: 滥用内联将导致程序变慢. 内联可能使目标代码量或增或减, 这取决于内联函数的大小. 内联非常短小的存取函数通常会减少代码大小, 但内联一个相当大的函数将戏剧性的增加代码大小. 现代处理器由于更好的利用了指令缓存, 小巧的代码往往执行更快。

结论: 一个较为合理的经验准则是, 不要内联超过 10 行的函数. 谨慎对待析构函数, 析构函数往往比其表面看起来要更长, 因为有隐含的成员和基类析构函数被调用!

另一个实用的经验准则: 内联那些包含循环或 switch 语句的函数常常是得不偿失 (除非在大多数情况下, 这些循环或 switch 语句从不被执行).

有些函数即使声明为内联的也不一定会被编译器内联, 这点很重要; 比如虚函数和递归函数就不会被正常内联. 通常, 递归函数不应该声明成内联函数.(递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的, 大多数编译器都不支持内联递归函数). 虚函数内联的主要原因则是想把它的函数体放在类定义内, 为了图个方便, 抑或是当作文档描述其行为, 比如精短的存取函数.

PS:内联函数和宏定义的区别:

  • 内联函数在编译时展开,宏在预编译时展开
  • 内联函数直接嵌入到目标代码中,宏是简单的做文本替换
  • 内联函数有类型检测、语法判断等功能,而宏没有
  • 内联函数是函数,宏不是
  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
  • 内联函数代码是被放到符号表中,使用时像宏一样展开,没有调用的开销,效率很高

构造函数、析构函数、虚函数可否声明为内联函数

将这些函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联。

  • register关键字:这个关键字请求编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率

首先在《Effective C++》中明确阐述:将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。其次,class中的函数默认是inline型的,编译器也只是有选择性的inline,将构造函数和析构函数声明为内联函数是没有什么意义的。

虚函数是通过指针或引用调用函数时,通过虚函数表来确定调用的函数,在运行时确定。内联函数是在编译时,将调用函数处插入内联函数的代码,省去了函数调用时的开销。

构造函数与虚函数

构造函数不可以是虚函数。

  1. 从vptr角度解释:虚函数对应一个vtable,可是这个vtable其实是存储在对象的内存空间的。 那么问题来了,如果构造函数是虚函数,就要通过vtable来调用,可是对象空间还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。
  2. 从使用角度:虚函数主要用于在信息不全的情况下,能够使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义。

析构函数与虚函数

在派生类中的析构函数常常为虚析构函数,是为了避免内存泄露。

如果不考虑虚函数的状况,给出一个基类和派生类,如果调用派生类的析构函数时,肯定会引发调用基类的析构函数,这和析构函数是不是虚函数没关系。如:[ Derive* p = new Derive(); ]

现在考虑虚函数的问题,由于使用虚函数使我们可以定义一个基类指针或引用可以直接对派生类进行操作,如:[ Base* p = new Derive(); ],这就存在两种情况:

如果,不把基类的析构函数设置为虚函数,则在删除对象时,如果直接删除基类指针,系统就只能调用基类析构函数,而不会调用派生类构造函数。这就会导致内存泄露。

如果,把基类的析构函数设置为虚函数,则在删除对象时,直接删除基类指针,系统会调用派生类析构函数,之后此派生类析构函数会引发系统自动调用自己的基类,这就不会导致内存泄露。

所以,在写一个类时,尽量将其析构函数设置为虚函数,但析构函数默认不是虚函数。

[问] 应该把所有的类的析构函数都设置为虚函数吗?
[答] 不一定。使用虚函数后的类对象要比不使用虚函数的类对象占的空间多,而且在查找具体使用哪一个虚函数时,还会有时间代价。即当一个类不打算作为基类时,不用将其中的函数设置为虚函数。

在构造函数和析构函数中调用虚函数

语法上没有问题,但是体现不出多态性。
当实例化一个子类对象时,会先调用父类构造函数,此时子类对象还没有被完全创建,被当成一个父类对象,调用的是父类的虚函数。
同理,析构是先析构子类,然后析构父类,若父类调用析构函数,子类已经被析构了,只能调用父类自己的虚函数。

构造函数和析构函数可以调用虚函数吗,为什么

不提倡在构造函数和析构函数中调用虚函数。

  • 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编
  • 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。

动态绑定和静态绑定

为了支持c++的多态性,才用了动态绑定和静态绑定。

  • 对象的静态类型:对象在声明时采用的类型。是在编译期确定的。
  • 对象的动态类型:目前所指对象的类型,是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。
1
2
3
4
5
6
7
class B {}
class C : public B {}
class D : public B {}
D* pD = new D();//pD的静态类型是它声明的类型D*,动态类型也是D*
B* pB = pD;//pB的静态类型是它声明的类型B*,动态类型是pB所指向的对象pD的类型D*
C* pC = new C();
pB = pC;//pB的动态类型是可以更改的,现在它的动态类型是C*
  • 静态绑定:绑定的是对象的静态类型,某特性(比如函数)依赖于对象的静态类型,发生在编译期。
  • 动态绑定:绑定的是对象的动态类型,某特性(比如函数)依赖于对象的动态类型,发生在运行期。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class B
{
void DoSomething();
virtual void vfun();
}
class C : public B
{
void DoSomething();//首先说明一下,这个子类重新定义了父类的no-virtual函数,这是一个不好的设计,会导致名称遮掩;这里只是为了说明动态绑定和静态绑定才这样使用。
virtual void vfun();
}
class D : public B
{
void DoSomething();
virtual void vfun();
}
D* pD = new D();
B* pB = pD;

pD->DoSomething() 和 pB->DoSomething() 是no-virtual函数,它是静态绑定的,也就是编译器会在编译期根据对象的静态类型来选择函数。

pD->vfun()和pB->vfun() 是一个虚函数,它动态绑定的,也就是说它绑定的是对象的动态类型,pB和pD虽然静态类型不同,但是他们同时指向一个对象,他们的动态类型是相同的,都是D*,所以,他们的调用的是同一个函数:D::vfun()。

需要特别注意:虚函数的缺省参数是静态绑定的!!!

总结:可以认为只有虚函数才使用的是动态绑定,其他的全部是静态绑定。

菱形继承/虚拟继承

作者

Benboby

发布于

2021-02-12

更新于

2021-03-27

许可协议

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×