【C++】虚函数及其内存布局

目录

一、函数调用捆绑

二、 虚函数

三、C++实现晚捆绑(编译器如何处理虚函数)

虚函数机制

虚函数内存布局

三、抽象基类和纯虚函数

对象切片

五、虚函数和构造函数

六、虚析构函数


一、函数调用捆绑

把函数体与函数调用相联系称为捆绑

当捆绑在程序运行之前(由编译器和连接器)完成时,称为早捆绑。C编译只有一种函数调用方式,就是早捆绑。

早捆绑引起的问题:因为编译器在只有对象的地址时它并不知道要调用的正确函数。

根据对象的类型,捆绑发生在运行时,这种捆绑方式称为晚捆绑,又称动态捆绑

二、 虚函数

对于特定的函数,为了引起晚捆绑,C++要求在基类中声明这个函数时使用virtual关键字,这样的函数称为虚函数。晚捆绑只对virtual函数起作用,而且只在使用含有virtual函数的基类的地址时发生。

虚函数只需要在声明时使用关键字virtual,定义时并不需要。如果一个函数在基类中被声明为virtual,那么在所有的派生类中,它都是virtual的。在派生类中virtual函数的重定义称为重写。

【注意】只要在基类中声明一个函数为virtual,调用所有匹配基类声明该行为的派生类函数都将使用虚机制。

示例演示:

class A
{
private:
	int i;
public:
	virtual void play()const
	{
		cout << "A:play" << endl;
	}

};

class B:public A
{
private:
	int j;
public:
	void play()const
	{
		cout << "B:play" << endl;
	}
};

class C :public B
{
public:
	void play()const
	{
		cout << "C:play" << endl;
	}
};

int main()
{
	B b;
	A a;
	b.play();
	a.play();
	A* ptr = &b;
	ptr->play();
	C c;
	A* ptr2 = &c;
	ptr2->play();
}

 输出结果:

 有结果可知虽然B的play()没有声明virtual,但是它是继承A的play(),所以虚机制延续下去了。

三、C++实现晚捆绑(编译器如何处理虚函数)

编译器对每个包含虚函数的类创建一个虚表(VTABLE)。虚表中放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密放置一个指针,称为虚指针vpointer(VPTR),指向这个对象的VTABLE。

当通过基类指针做虚函数调用时,编译器静态地插入能取得这个VPTR并在VTABLE表中查找函数地址的代码,这样就能调用正确的函数并引发晚捆绑。

class A
{
private:
	int i;
public:
	void fun1() {}
	void fun2() {}
};

class B 
{
private:
	int i;
public:
	virtual void fun1() {}
	void fun2() {}
};

class C 
{
private:
	int i;
public:
	virtual void fun1() {}
	virtual void fun2() {}
};

int main()
{
	A a;
	B b;
	C c;
	cout << "sizeof(a):" << sizeof(a) << endl;    //4
	cout << "sizeof(b):" << sizeof(b) << endl;    //8
	cout << "sizeof(c):" << sizeof(c) << endl;    //8
}

由上示例可知,不带虚函数,对象a的长度恰好是成员对象的长度:int 4字节。而带单个虚函数的对象b,对象长度是不带虚函数的长度 + 一个void指针的长度:4+4=8字节。带两个虚函数的长度同样是8字节,反映出无论是一个还是多个虚函数,编译器只会插入一个虚指针vptr(因为vptr指向一个存放函数地址的表,只需要一个表就可以存放所有的虚函数地址)。

虚函数机制

class A
{
private:
	int i;
public:
	virtual void func1()const
	{
		cout << "A:func1" << endl;
	}
	virtual void func2()const
	{
		cout << "A:func2" << endl;
	}
	virtual void func3()const
	{
		cout << "A:func3" << endl;
	}


};

class B :public A
{
private:
	int j;
public:
	void func1()const
	{
		cout << "B:func1" << endl;
	}
	void func2()const
	{
		cout << "B:func2" << endl;
	}
	void func3()const
	{
		cout << "B:func3" << endl;
	}
};

class C :public A
{
public:
	void func1()const
	{
		cout << "C:func1" << endl;
	}
	void func2()const
	{
		cout << "C:func2" << endl;
	}
	void func3()const
	{
		cout << "C:func3" << endl;
	}
};

int main()
{
	B b;
	C c;
	A* ptr[] = { &b,&c };
	ptr[0]->func1();
	ptr[1]->func1();
}

 由上图可知,每当创建一个包含由虚函数的类或从包含由虚函数的类派生一个类时,编译器就为这个类创建一个唯一的VTABLE,这个VTABLE中放置了这个类或它的基类中所已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址。

当使用简单继承时,对于每个对象只有一个vptr,vptr必须被初始化为指向相应的VTABLE的起始地址。一旦vptr被初始化为指向相应的VTABLE,对象就知道它自己是什么类型。但是只有当虚函数被调用时这种自我认知才有用

用基类指针调用一个虚函数时要特殊处理。它不是实现典型的函数调用(通过汇编语言CALL特定的地址),而要编译器为完成这个函数调用产生不同的代码。实际上通过“vptr+函数所在VTABLE的偏移”调用这个函数。因为获取vptr和确定实际函数地址发生在运行时,所以这样就得到希望的晚捆绑。

虚函数内存布局

要想查看虚函数在内存中布局,可以通过Visual Studio编译器。

步骤1:选择想要编辑的文件,右键点击“属性”。

 步骤2:编辑C/C++命令行的其他选项

 reportAllClassLayout 表示生成所有类的内存布局,也可以使用reportSingleClassLayout[classname]来生成指定的类内存布局。

步骤3:点击“生成” ,选择“生成解决方案”,即可查看对应的内存布局了

示例演示

示例1:(普通类)

#include <iostream>
using namespace std;

class Base
{
    int a;
    int b;
public:
    void CommonFunction();
};

int main()
{
    cout << sizeof(Base) << endl;  //8
}

 编译后生成的布局情况:

 

由上示例可知普通类的内存布局方式是成员变量按照声明的顺序进行排列,成员函数不占用内存空间。sizeof(a)+sizeof(b)=4+4=8。

示例2:(派生类)

class DerivedClass : public Base
{
    int c;
public:
    void DerivedCommonFunction();
};

int main()
{
    cout << sizeof(DerivedClass) << endl;//12
}

 编译后生成的布局情况:

 对于派生类而言,首先可以看出派生类继承了基类的成员变量。内存布局上,先排列基类的成员变量,接着排列派生类的成员变量,成员函数依旧不占内存空间。sizeof(a)+sizeof(b)+sizeof(c)=4+4+4=12

示例3:(单个虚函数的类)

class Base
{
    int a;
    int b;
public:
    void CommonFunction();
    virtual void VirtualFunction();
};

  编译后生成的布局情况:

上面内存结构图分为两部分,一是内存分布,二是虚表。内存分布图中vs编译器把虚指针(vfptr)放在起始位置(0地址偏移的位置),然后存放成员变量。虚指针指向的虚表跟在&Base_meta后面的0表示,左侧的0是虚函数的序号(0表示第一个,如果有多个,可以依次进行编码)。编译器是在构造函数创建这个虚指针和虚表的。sizeof(void*)+sizeof(a)+sizeof(b)=4+4+4=12.

示例4:(多个虚函数的类)

class Base
{
    int a;
    int b;
public:
    void CommonFunction();
    virtual void VirtualFunction();
    virtual void VirtualFunction2();
};

class DerivedClass : public Base
{
    int c;
public:
    void DerivedCommonFunction();
    void VirtualFunction();
};

 

 由上基类的内存结构图,可知多个虚函数只有一个虚指针,多个虚函数放在同一个虚表中。

 通过派生类的内存结构图,可知派生类本身不会再次生成一个虚指针,而是继承基类的虚指针。

示例5:(派生类含有虚函数)

class Base
{
	int a;
	int b;
public:
	void CommonFunction();
	virtual void VirtualFunction();
	virtual void VirtualFunction2();
};

class DerivedClass : public Base
{
	int c;
public:
	void DerivedCommonFunction();
	virtual void VirtualFunction3();
};

class DerivedClass : public Base
{
    int c;
public:
    void DerivedCommonFunction();
    virtual void VirtualFunction3();
};

 由上派生类内存结构图,可知虚指针被继承且仍然处于内存排列的起始位置,然后是基类成员变量,接下来是派生类的成员变量。虚表的函数地址排列也是先排列基类的虚函数地址,然后是派生类的虚函数。

示例6:(普通菱形继承)

class Base
{
    int a;
    int b;
public:
    void CommonFunction();
    void virtual VirtualFunction();
};


class DerivedClass1 : public Base
{
    int c;
public:
    void DerivedCommonFunction();
    void virtual VirtualFunction();
};

class DerivedClass2 : public Base
{
    int d;
public:
    void DerivedCommonFunction();
    void virtual VirtualFunction();
};

class DerivedDerivedClass : public DerivedClass1, public DerivedClass2
{
    int e;
public:
    void DerivedDerivedCommonFunction();
    void virtual VirtualFunction();
};

 Base、DerivedClass1、DerivedClass2和前面描述的情况差不多,重点查看DerivedDerivedClass

 

从上面的内存结构图可知,DerivedClass1、DerivedClass2及DerivedDerivedClass的成员变量e并排,DerivedClass1起始位置开始布局,DerivedClass1中包含继承自Base的虚指针,成员变量a,b及自身成员变量c;DerivedClass2是接在DerivedClass1后面开始布局,DerivedClass2中也有独立的一份Base,包含继承自Base的虚指针,成员变量a,b及自身成员变量d。

注意,DerivedDerivedClass中的虚函数放在了继承自DerivedClass1的虚表中。

同时,两个虚指针分别指向两个虚表,DerivedClass1的vfptr偏移量是0,-16表示DerivedClass2的vfptr的内存偏移。

示例7:(虚继承多继承混合)

class DerivedClass1: virtual public Base
{
    int c;
public:
    void DerivedCommonFunction();
    void virtual VirtualFunction();
};

class DerivedClass2 : virtual public Base
{
    int d;
public:
    void DerivedCommonFunction();
    void virtual VirtualFunction();
};

class DerivedDerivedClass :  public DerivedClass1, public DerivedClass2
{
    int e;
public:
    void DerivedDerivedCommonFunction();
    void virtual VirtualFunction();
};

 注意:DerivedClass1发生了变化,DerivedClass1创建了一个属于自己的虚指针vbptr,同时继承了基类的虚指针vfptr。vbptr指向的是虚表是vbtable,vfptr指向的是虚表是vftable。虚表vbtable中的8表示vbptr与vfptr的偏移,虚表vftable中-8表示该表虚指针位于内存的偏移量。

另外,DerivedClass1的vbprt和成员变量c排列在继承的基类Base前面。

DerivedClass2和DerivedClass1类似。

DerivedDerivedClass中有三个指针,一个是继承自DerivedClass1的vbptr,一个是继承自DerivedClass2的vbptr,还有保留了一份Base的vfptr。

虚继承的作用是减少对基类的重复,代价是增加了虚表指针的负担(有更多的虚表指针)。

三、抽象基类和纯虚函数

在设计时,常希望基类仅作为其派生类的一个接口,而不希望用户实际地创建一个基类的对象。要做到这点,可以在基类中加入至少一个纯虚函数,来使基类成为抽象类纯虚函数使用关键字virtual,并在其后面加上=0.如果试着生成一个抽象类的对象,编译器就会制止该行为

当继承一个抽象类时,必须实现所有的纯虚函数,否则派生类也将是一个抽象类。创建一个纯虚函数允许在接口中放置成员函数,而不一定要提供一段可能对这个函数毫无意义的代码。

建立公共接口的唯一原因时它能对于每个不同的子类有不同的表示。当希望通过一个公共接口来操纵一组类,且这个公共接口不需要实现(或不需要完全实现)时,可以创建一个抽象类。

纯虚函数语法格式:

virtual 返回值类型 函数名 (函数参数) = 0;

纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0,表明这个函数是纯虚函数。

=0并不表示函数返回值为0,它只起到形式上的作用,告诉编译器这是纯虚函数。

包含纯虚函数的类称为抽象类。之所以称为抽象类,是因为它无法实例化。原因是纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间

纯虚函数使用示例:

#include <iostream>
using namespace std;

//线
class Line{
public:
    Line(float len);
    virtual float area() = 0;
    virtual float volume() = 0;
protected:
    float m_len;
};
Line::Line(float len): m_len(len){ }

//矩形
class Rec: public Line{
public:
    Rec(float len, float width);
    float area();
protected:
    float m_width;
};
Rec::Rec(float len, float width): Line(len), m_width(width){ }
float Rec::area(){ return m_len * m_width; }

//长方体
class Cuboid: public Rec{
public:
    Cuboid(float len, float width, float height);
    float area();
    float volume();
protected:
    float m_height;
};
Cuboid::Cuboid(float len, float width, float height): Rec(len, width), m_height(height){ }
float Cuboid::area(){ return 2 * ( m_len*m_width + m_len*m_height + m_width*m_height); }
float Cuboid::volume(){ return m_len * m_width * m_height; }

//正方体
class Cube: public Cuboid{
public:
    Cube(float len);
    float area();
    float volume();
};
Cube::Cube(float len): Cuboid(len, len, len){ }
float Cube::area(){ return 6 * m_len * m_len; }
float Cube::volume(){ return m_len * m_len * m_len; }

int main(){
    Line *p = new Cuboid(10, 20, 30);
    cout<<"The area of Cuboid is "<<p->area()<<endl;
    cout<<"The volume of Cuboid is "<<p->volume()<<endl;
  
    p = new Cube(15);
    cout<<"The area of Cube is "<<p->area()<<endl;
    cout<<"The volume of Cube is "<<p->volume()<<endl;

    return 0;
}

这四个类继承关系为:Line --> Rec --> Cuboid --> Cube。

Line 是一个抽象类,在 Line 类中定义了两个纯虚函数 area() 和 volume()。

在 Rec 类中,实现了 area() 函数;所谓实现,就是定义了纯虚函数的函数体。但这时 Rec 仍不能被实例化,因为它没有实现继承来的 volume() 函数,volume() 仍然是纯虚函数,所以 Rec 也仍然是抽象类。

直到 Cuboid 类,才实现了 volume() 函数,才是一个完整的类,才可以被实例化。

可以发现,Line 类表示“线”,没有面积和体积,但它仍然定义了 area() 和 volume() 两个纯虚函数。这样的用意很明显:Line 类不需要被实例化,但是它为派生类提供了“约束条件”,派生类必须要实现这两个函数,完成计算面积和体积的功能,否则就不能实例化。

在实际开发中,可以定义一个抽象基类,只完成部分功能,未完成的功能交给派生类去实现(谁派生谁实现)。这部分未完成的功能,往往是基类不需要的,或者在基类中无法实现的。虽然抽象基类没有完成,但是却强制要求派生类完成,这就是抽象基类的“霸王条款”。

抽象基类除了约束派生类的功能,还可以实现多态。注意代码Line *p = new Cuboid(10, 20, 30),指针p 的类型是 Line,但是它却可以访问派生类中的 area() 和 volume() 函数,正是由于在 Line 类中将这两个函数定义为纯虚函数;如果不这样做,后面的代码都是错误的。这是C++提供纯虚函数的主要目的(即让对基类对象操作的代码也能透明地操作派生类对象)。

【注意】纯虚函数禁止对抽象类的函数以传值的方式调用,这也是防止对象切片的一种方法。通过抽象类,可以保证向上类型转换期间总是使用指针或引用。

对象切片

如果对一个对象进行向上类型转换,而不是用地址或引用,将会发送对象切片。

#include <iostream>
#include <string>
using namespace std;

class Base
{
private:
	int a;
public:
	Base(int aa) :a(aa) {}
	virtual void test()
	{
		std::cout << "Base getval" << std::endl;
	}
};

class Derived :public Base
{
private:
	int b;
public:
	Derived(int aa, int bb) :Base(aa), b(bb) {}
	void test()
	{
		std::cout << "Derived getval" << std::endl;
	}
};

int main()
{
	Base bobj(1);
	Derived dobj(1, 2);

	Base b1= dobj;//向上转型-直接赋值产生切割
    b1.test();

    Base& b2 = dobj;
	b2.test();

	Base* b3 = &dobj;
	b3->test();
}

将子类的对象赋值给基类对象做向上类型转换,做了对象切片(切割)。对象切片实际上是当它拷贝到一个新的对象时,去掉原来对象的一部分,而不是像使用指针或引用那样简单地改变地址地内容。

 上面两个图是引用和指针的向上转换没有做切割。

五、虚函数和构造函数

对于在构造函数调用一个虚函数,被调用的只是这个函数的本地版本。(虚机制在构造函数不起作用)

原因:构造函数的工作是生成一个对象。在任何构造函数,我们只能知道基类已被初始化,但并不能知道哪个类是从这个基类继承而来的。虚函数在继承层次是向前进行调用。它可以调用在派生类中对的函数。如果在构造函数中也这样做,所调用的函数可能操作还没有被初始化的成员。

六、虚析构函数

构造函数不能为虚函数,但析构函数能够且常常必须是虚的

示例演示:

#include <iostream>
using namespace std;

class Base1
{
public:
	~Base1()
	{
		cout << "~Base1()" << endl;
	}
};

class Derived1:public Base1
{
public:
	~Derived1 ()
	{
		cout << "~Deriveed1()" << endl;
	}
};

class Base2
{
public:
	virtual ~Base2()
	{
		cout << "~Base2()" << endl;
	}
};

class Derived2 :public Base2
{
public:
	~Derived2()
	{
		cout << "~Deriveed2()" << endl;
	}
};

int main()
{
	Base1* ptr1 = new Derived1;
	delete ptr1;
	Base2* ptr2 = new Derived2;
	delete ptr2;
}

 由上图结果可知,如果不把析构函数设为虚函数,当使用基类指针指向一个派生类的对象,析构的时候,只会调用基类本身的析构,这是错误的。而基类析构函数为虚函数时,ptr2调用析构时,先调用了派生类的析构,然后调用了基类的析构函数,这是正确的。

不把析构函数设为虚函数是一个隐匿的错误,因为它常常对程序不会有直接的影响。但是它不知不觉中引入内存泄漏(关闭程序时内存未释放)。

  • 5
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值