一、普通成员函数调用方式
1 | class MYACLS |
(1)c++语言设计的时候有一个要求:要求对普通成员函数的调用不应该比全局函数效率差。基于这种设计要求,编译器内部实际上是将对成员函数myfunc()的调用转换成了对全局函数的调用。
(2)成员函数有独立的内存地址,是跟着类走的,并且成员函数的地址是在编译的时候就确定好的。
(3)编译器额外增加了一个叫this的形参,是个指针,指向的其实就是生成的对象。
(4)常规成员变量的存取,都通过this形参来进行,比如上述 this->m_i + abc。
二、虚成员函数、静态成员函数调用方式
虚成员函数(虚函数)调用方式
1 | class MYACLS |
(1)用对象调用虚函数,就像调用普通成员函数一样,不需要通过虚函数表。
(2)用指针调用虚函数,要通过虚函数表指针查找虚函数表,通过虚函数表在好到虚函数的入口地址,完成对虚函数的调用。
静态成员函数调用方式
1 | class MYACLS |
(1)静态成员函数没有this指针,这点最重要。
(2)无法直接存取类中普通的非静态成员变量,因为非静态成员变量是通过this指针来操作的。
(3)静态成员函数不能在后面使用const,也不能设置为virtual。
(4)可以用类对象调用,但不非一定要用类对象调用。
(5)静态成员函数等同于非成员函数,有的需要提供回调函数的这种场合,可以将静态成员函数作为回调函数;
三、静态、动态类型,绑定,多态实现深谈
静态类型和动态类型
(1)静态类型:对象定义时的类型,编译期间就确定好的。
(2)动态类型:对象目前所指向的类型(运行的时候才决定的类型)。
(3)一般只有指针或者引用才有动态类型的说法。而且一般都是指父类的指针或者引用。另外,动态类型在执行过程中可以改变。
仔细看如下代码分析:
1 | class Base |
静态绑定和动态绑定
(1)静态绑定:绑定的是静态类型,所对应的函数或者属性依赖于对象的静态类型,发生在编译期
(2)动态绑定:绑定的是动态类型,所对应的函数或者属性依赖于对象的动态类型,发生在运行期
(3)普通成员函数是静态绑定,而虚函数是动态绑定。缺省参数一般是静态绑定。
代码分析:
1 | class Base |
普通成员函数是静态绑定,换句话说,myfunc()是普通成员函数。这里到底调用父类的myfunc还是子类的myfunc取决于调用者的静态类型。
因为这里pbase的静态类型是Base,所以调用的是Base里的myfunc();pderive的静态类型是Derive,所以调用的是Derive的myfunc()。
结论:为了避免混淆,不应该在子类中重新定义一个继承来的非虚函数。
再看如下代码:
1 | class Base |
虚函数是动态绑定,换句话说,myvirfunc()是虚函数,这里到底执行哪个myvivfunc()取决于调用者的动态类型。
这里pbase的动态类型分别Derive,Base,所以调用的也分别是Derive和Base的myvirfunc(),pderive的动态类型是Derive,所以调用的是Derive的myvirfunc()。
从上面代码中可以看出,虚函数的参数缺省值是静态绑定,所以不要重新定义虚函数的缺省参数的值。
c++中的多态性的体现
多态性这个概念,分两方面谈:
a)从代码实现上
b)从表现形式上
有一个观点是肯定的:多态,必须是存在虚函数,没有虚函数,绝不可能存在多态,有虚函数并且调用虚函数。
(1)从代码实现上来看,当我们调用一个虚函数时,走的是不是通过查询虚函数表来找到虚函数入口地址,然后去执行虚函数,如果走的是这个途径,那就是多态,如果不走这个途径,它就不是多态。看代码分析:
1 | class A |
(2)从表现形式上来看(通过代码来体现)
a)有继承关系,有父类有子类,父类中必须有虚函数(这意味着子类中一定有虚函数),子类重写父类的虚函数。
b)父类指针或者引用指向子类对象。
c)当以父类指针或者引用调用子类中重写了的虚函数时,我们就能看出来多态的表现了,因为调用的是子类的虚函数。
代码分析:
1 | class Base |
四、多继承下的第二基类问题探讨
先给出一个继承关系,稍后做具体分析。
1 | class Base |
多重继承的复杂性往往体现在后边这个基类上,先来看使用后面这个基类的情况:
1 | Base2 *pb2 = new Derive(); |
现在,我们考虑如何成功删除用第二基类指针new出来的继承类对象。注意,我们要删除的实际是整个Derive()对象,要能够保证Derive()对象的析构函数被正常调用,那么编译器会调用Base2的析构函数,还是调用Derive的析构函数呢?执行delte pb2时,系统的动作会是什么?
事实上,这里分为以下几种情况:
(1)如果Base2里没有析构函数,编译器会直接删除以pb2开头的这段内存,一定报异常,因为这段内存压根就不是new起始的内存。
(2)如果Base2里有一个析构函数,但整个析构函数是个普通析构函数(非虚析构函数),那么当delte pb2,这个析构函数就会被系统调用,但是delete的仍旧是pb2开头这段内存,所以一定报异常。因为这段内存压根就不是new起始的内存。(析构函数如果不是虚函数,编译器会实施静态绑定,静态绑定意味着你delete Base2指针时,删除的内存开始地址就是pb2的当前位置,所以肯定是错误的)。
(3)如果Base2里是一个虚析构函数,则delete的时候,编译器会自动按~Dervice() –> ~Base2() –> ~Base()的顺序调用,此时就是把整个Derive()对象的内存都释放了。
(4)Derive里就就算没有虚析构函数,因为Base2里有虚析构函数,编译器也会为此给Derive生成虚析构函数,为了调用~Base2()和~Base()虚析构函数。
由上述结论知,凡是涉及到继承的,所有类都加上虚析构函数,以防异常。
另外,Derive类的第二个虚函数表中发现了thunk字样,一般它用在多重继承中(从第二个虚函数表开始可能就 会有),用于this指针调整。它其实是一段汇编代码,这段代码干两个事情:
(1)调整this指针(this指针调整的目的就是让对象指针正确的指向对象首地址,从而能正确的调用对象的成员函数或者说正确确定数据成员的存储位置)
(2)调用Derive析构函数
五、虚基类带虚函数的成员分布以及继承开销
1 | class Base |
由此可以看出,此种情况下的数据分布应该是:虚基类表指针(1-4字节)、子类数据成员(5-8字节)、虚函数表指针(9-12字节)、基类数据成员(13-16字节)。
一般来说,
(1)随着继承深度的增加,开销或者说执行时间也会增加。
(2)多重继承一般也会导致开销增加。
(3)虚函数也会导致开销增加。
六、指向成员函数的指针以及vcall探讨
指向普通成员函数的指针
成员函数地址是在编译时就确定好的。但是,要想调用成员函数,是需要通过对象来调用的。所有常规(非静态)成员函数,要想调用,都需要一个对象来调用它。并且,通过成员函数指针对常规的成员函数调用的成本,和通过普通的函数指针来调用静态成员函数,成本上差不多。
1 | class A |
指向虚成员函数的指针及vcall理解
vcall(vcall trunk) = virtual call:虚调用。它代表一段要执行的代码的地址,这段代码引导编译器去执行正确的虚函数,或者我们直接把vcall看成虚函数表,如果这么看待的话,那么vcall{0}代表的就是虚函数表里的第一个函数,vcall{4}就代表虚函数表里的第二个虚函数。
1 | class A |
完善理解:&A::myvirfunc,打印出来的是一个地址,这个地址中有一段代码,这个代码中记录的是该虚函数在虚函数表中的一个偏移值,有了这个偏移值,再有了具体的对象指针,我们就能够知道调用的是哪个虚函数表里边的哪个虚函数了。
成员函数指针里,保存的可能是一个vcall(vcall trunk)地址(如果指针指向的是虚函数),要么也可能是一个真正的成员函数地址(指针指向的不是虚函数)。如果是一个vcall地址,那vcall能够引导编译器找出正确的虚函数表中的虚函数地址进行调用。
七、inline函数介绍及其扩展细节
inline函数介绍
使用inline之后,编译器内部会有一个比较复杂的测试算法来评估这个inline函数的复杂度,可能会统计这个inline函数中,赋值次数,内部函数调用,虚函数调用等次数(权重)。
(1)开发者写inline只是对编译器的一个建议,但如果编译器评估这个inline函数复杂度过高,这个inline建议就被编译器忽略。
(2)如果inline被编译器采纳,那么inline函数的扩展,就要在调用这个inline函数的那个点上进行,此时可能带来额外的问题比如:参数求值,可能导致临时对象的生成和管理。
inline扩展细节
见代码的注释部分:
1 | inline int myfunc(int testv) |