当前位置:   article > 正文

C/C++编程:虚函数与纯虚函数_c++ 虚函数和纯虚函数

c++ 虚函数和纯虚函数

虚函数 VS 纯虚函数

虚函数

  • 虚函数是应在派生类中重新定义的函数。当使用指针或者对基类的引用来引用派生类的对象时,可以为该对象调用虚函数并执行该派生类的版本。

  • 虚函数的“虚”,虚在“推迟联编/动态联编”上,一个类函数的调用不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为"虚"函数。

虚函数实现原理

虚函数的一般实现模型:

  • 每个都有一个虚函数表,内含该类中有作用的虚函数的地址
  • 每个类对象都有一个vptr,指向相应的虚函数表

单继承下的虚函数

为了支持虚函数机制,必须首先能够对于多态对象有某种形式的执行期类型判断方法

也就是说,以下的调用操作将需要ptr在执行期的某些相关信息:

ptr->z();
  • 1

这样才能够找到并调用z()的适当实体

最直接的策略是把必要的信息加到ptr身上。此时,一个指针(或者引用)含有两项信息:

  • 它所参考到的对象的地址(当前它含有的东西)
  • 对象类型的某种编码,或者是某个结构(内含某些信息,用以正确决议出z()函数实例)的地址

这个方法带来了两个问题:

  • 它明显增加了空间负担,即使程序并不使用多态
  • 它打断了与C程序间的链接兼容性

那要把这些必要的信息放在类对象本身而不是指针上吗?还是会有上面两个问题。

从上面可以看出,只要在那些类真正需要这些额外信息的时候才存在比较好。那么,到底何时才需要这些信息呢?很明显在必须支持某种形式的执行期多态的时候。

在C++中,多态表示以一个public基类的指针或引用,寻址出一个派生类对象的意思。 比如对于:

Point *ptr;
  • 1

可以指定ptr寻址出一个Point2d或者Point3d对象:

ptr = new Point2d;
// ...
ptr = new Point3d;
  • 1
  • 2
  • 3
  • ptr的多态机制主要扮演一个输送机制的角色,经由它,我们可以在程序的任何地方采用一组public派生类型。这种多态形式称为消极的,可以在编译期完成—虚基类的情况除外
  • 当被指出的对象真正被使用时,多态也就变成积极的了。

对积极动态,C++有两个支持:

  • 虚函数调用的决议
ptr->z();
  • 1
  • 执行期间查询一个动态的指针或引用:
if(Point3d *pd3d = dynamic_cast<Point3d *>(ptr)){

}
  • 1
  • 2
  • 3

综上:

  • 想要鉴定哪些类展现多态特性,我们需要额外的执行期信息
  • 预识别一个类是否支持多态,唯一适当的方法是看看它是否有任何的虚函数。只要类拥有一个虚函数,他就需要这份额外的执行期信息

下一个问题:什么样的额外信息是需要我们存储起来的?也就是说,对于调用:

ptr->z(); // z()是虚函数
  • 1

什么信息才能让我们在执行期调用正确的z()实体?

  • ptr所指对象的真实类型。这可以令我们选出正确的z()实体
  • z()实体位置,以便调用

在实现上,首先我们可以在每一个多态类对象上增加两个成员:

  • 一个字符串或数值,表示类的类型
  • 一个指针,指向某表格,表格中带有程序的虚函数的执行期地址
    • 表格中的虚函数地址怎么构建呢?
      • 在C++中,虚函数地址可以在编译期获得
      • 这一组地址是固定不变的,执行期不可能新增或者替换
      • 由于程序运行时,表格的大小和内容是不会被改变的,所以其构建和存取都可以由编译器完全掌握,不需要执行期的接入
    • 那该怎么找到这些执行期就准备好的地址呢?
      • 为了找到表格,每一个类对象被安插上一个由编译器内部产生的指针,指向该表格
      • 为了找到函数地址,每个虚函数被指派一个表格索引值
    • 这些工作都由编译器完成。执行期要做的,只是在特定的虚函数表slot(记录着虚函数的地址)中激活虚函数

一个类只会有一个虚函数表,每个表内含其对应的类对象中是由激活的虚函数函数实体的地址。

  • 这些激活的虚函数包括:
    • 这个所定义的函数实体。它会改写一个可能存在的基类虚函数函数实体
    • 继承自基类的函数实体。这是在派生类决定不改写虚函数是才会出现的情况
    • 一个pure_virtual_called()函数实体,它既可以扮演纯虚函数的空间保卫者角色,也可以当作执行期异常处理函数
  • 每一个虚函数都被指定一个固定的索引值,这个索引在整个继承体系中保持与特定的虚函数的关联。
举个例子
class Point{
public:
    virtual ~Point();
    virtual Point& mult(float) = 0;

    float x() const { return _x;}
	virtual float y() const {return 0; }
	virtual float z() const {return 0; }
protected:
    Point(float x = 0.0);
    float _x;
};

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

每一个虚函数都被指定一个固定的索引值。比如:

  • 虚析构函数被赋值slot 1
  • mult()被赋值slot 2。此例并没有multi()的函数定义(因为它是一个纯虚函数),所以pure_virtual_called()的函数地址会被放在slot 2中。如果该函数意外被调用,通常的操作是结束掉这个程序
  • y()被赋值slot 3
  • z()被赋值slot4
  • x()表示虚函数,所以不会被赋值

当一个类派生自Point时:

  • 它可以继承基类所声明的虚函数的函数实体。正确的说,是该函数实体的地址会被拷贝到派生类虚函数表相对应的slot
  • 它可以使用自己的函数实体,这表示它自己的函数实体必须放在对应的slot中
  • 它可以加入一个新的虚函数,这时候虚函数表的尺寸会增大一个slot,而新的函数实体地址会被放进该slot中
class Point2d : public Point{
public:
    Point2d(float x = 0.0, float y = 0.0)
        : Point(x), _y(y){}
    ~Point2d();
    
    //改写 基类虚函数
    Point2d& mult(float);
    
    float y() const { return _y;}
protected:
    float _y;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

Point2d的虚函数表:

  • slot1中指出析构函数
  • 在slot2中支持multi(取代纯虚函数)

同样,Point3d派生自Point2d:

class Point3d : public Point2d{
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0)
        : Point2d(x, y), _z (z) {}
    ~Point3d();

    //改写 基类虚函数
    Point3d& mult(float);
    float z() const { return _z;}
    
protected:
    float _z;
};

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

其虚函数表:

  • slot1放置Point3d的析构函数
  • slot2放置Point3d::multi()的函数地址
  • slot3放置Point2d::y()的函数地址
  • slot4放置Point3d:: z ()的函数地址

在这里插入图片描述

现在对于:

ptr->z();
  • 1

应该怎么在编译期设定虚函数的调用呢?

  • 一般来说,我们不知道ptr所值对象的真正类型。但是能够知道的是,经由ptr可以存取到该对象的虚函数表
  • 虽然不知道哪一个z()函数实体会被调用。但是能够知道的是每一个z()函数地址都被放在slot4

这些信息使得编译器可以将调用转换为:

(*ptr->vptr[4])(ptr)
  • 1

在这个转换中,vptr表示编译器所安插的指针,指向虚函数表;4表示z()被赋予的slot编号(关联到Point体系的虚函数表)。唯一一个在执行期才能够知道的东西是:slot4所指的到底是哪一个z()函数实体。

虚函数是怎么引发类的代码膨胀的
class Point{
public:
	Point(float x = 0.0, float y = 0.0) 
		: _x(x), _y(y){}

	virtual float z();
protected:
	float _x, _y;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

虚函数的引用将初始每一个Point object拥有一个虚函数表。

  • 优点:提供了virtual接口的弹性
  • 缺点:
    • 每一个对象需要一个额外的word空间
    • 引发编译器对Point class 产生代码膨胀

我们来看看虚函数是怎么引发的代码膨胀的。

  • 我们所定义的构造函数被附加了一些码,以便将vptr初始化。这些码必须被附加在任何基类构造函数的调用之后,但必须在任何由程序员提供的码之前。比如:
// 代码膨胀
Point* Point::Point(Point *this, float x, float y)
	: _x(x), _y(y){
	//设定object的virtual table pointer(vptr)
	this->__vptr_Point = __vptr_Point ;

	// 扩展member initialization list
	this->_x = x;
	this->_y = y;

	//传回this对象
	return this;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 合成一个拷贝构造函数和一个copy assignment operator,而且其操作不再是trivial(但是implicit destructor仍然是trivial)。
    ·12
// 拷贝构造函数的内部合成
inline Point* Point::Point(Point *this, const Point &rhs){
	//设定object的virtual table pointer(vptr)
	this->__vptr_Point = __vptr_Point ;
	
	// 将rhs坐标中的连续位拷贝到this对象,
	// 或者是经由member assignmnt提供给一个member
	
	//传回this对象
	return this;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

多重继承下的虚函数

在单一继承体系中,虚函数机制的行为有效率而且很容易实现。但是在多重继承和虚拟继承中,对虚函数的支持就没那么好了

在多重继承中支持的虚函数中,其复杂度主要在第二个以及后继的基类上,以及"必须在执行期调整this指针"这一点。

class Base1{
	public:
		Base1();
		virtual ~Base1();
		virtual void speakClearly();
		virtual Base1 *clone() const;
	protected:
		float data_Base1;
};

class Base2{
	public:
		Base2();
		virtual ~Base2();
		virtual void mumble();
		virtual Base2*clone() const;
	protected:
		float data_Base2;
};

class Derived : public Base1, public Base2{
	public:
		Derived();
		virtual ~Derived();
		virtual Derived *clone() const;
	protected:
		float data_Derived;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

在这里插入图片描述

  • 在多继承中,会有多个vptr:自己的vprt+每一个基类都会产生vtbl。

对于上面的Derived而言,会有两个虚函数表被编译期产生:

  • 一个主要实体,与Base1(最左端基类)共享
  • 一个次要实体,与Base2(第二个基类)有关

针对每一个虚函数表,Derived对象中有对应的vptr。vptr将在构造函数中设置初值(由编译期产生的码)

用以支持一个类拥有多个虚函数表的传统方法是:将每一个表以外部对象的形式产生出来,并给与独一无二的名称。比如,派生类所关联的两个表可能由这样的名称:

vtbl_Derived; // 主要表格
vtvl_Base2_Derived; // 次要表格
  • 1
  • 2

所以将一个Derived对象地址指定给一个Base1指针或者Derived指针式,被处理的虚函数表是主要表格vtbl_Derived。当将一个Derived对象指定给一个Base2指针式,被处理的虚函数表是次要表格vtvl_Base2_Derived
在这里插入图片描述
多个表的符号链接可能会很慢,为了调节执行期链接器的效率,可以将多个虚函数表连锁成一个:指向次要表格的指针,可以由主要表格名称加上一个offset获得。在这样的策略下,每个类只有一个具名虚函数表

有三种情况,第二或后继的基类对影响对虚函数的支持。

  • 第1种情况是:通过一个指向第二个基类的指针,调用派生类虚函数(此时ptr必须调整指向派生类对象的起始处),比如:
Base2 *ptr = new Derived;  // ptr指向Derived对象中的Base2子对象

// 调用Derived::~Derived, ptr必须调整指向Derived对象的起始处,也就是说,ptr必须向后调整sizeof(Base1)个bytes
delete ptr;
  • 1
  • 2
  • 3
  • 4
  • 第2种情况是:通过一个指向派生类的指针,调用继承自第二个基类的虚函数。 (此时ptr必须调整指向第二个基类子对象
Derived *ptr = new Derived;
// ptr必须向前调整sizeof(Base1)个bytes
ptr->mumble();
  • 1
  • 2
  • 3
  • 第3种情况是:允许一个虚函数的返回值类型有所变化,可以是基类,也可以是派生类。比如:
Base2 *pb1 = new Derived;
// 调用 Derived *Derived::clone() 
// 返回值必须调整,以指向Base2子对象
Base2 *pb2 = pbl->clone();
  • 1
  • 2
  • 3
  • 4

当pb1->clone()时,pb1会被调整指向Derived对象的起使地址,于是clone()的Derived版被调用:它会传回一个指针,指向一个新的Derived对象,该对象的地址在被指定给pb2之前,必须先经过调整,以指向Base2 subobject

多继承经常导致对虚基类的需求。没有虚基类,如果一个派生类有一个以上从基类的继承路径,基类的数据成员被复制到每一个继承类对象里,继承类和基类间的每条路径都有一个拷贝,程序员一般不会发生这种复制,而把基类定义为虚基类可以消除这种复制。
在这里插入图片描述
在这里插入图片描述
如果在vtbl加入这个对象的图片,那么D的内存布局如下:
在这里插入图片描述
上面虽然存在四个类,但是只有三个vptr,这是因为B和D共享了一个vptr。

虚拟继承下的虚函数

class Point{
public:
	Point(float x = 0.0, float y = 0.0) 
		: _x(x), _y(y){}
	Point(const Point&);
	Point& operator=(const Point&);

	virtual ~Point();
	virtual float z() {return 0.0;};
protected:
	float _x, _y;
};

class Point3d : public virtual Point{
public:
    Point3d(float  x = 0.0, float  y = 0.0, float z = 0.0)
        : Point(x, y), _z(z) {}
    Point3d(const Point3d& rhs)
    : Point(rhs), _z(rhs._z){}
    ~Point3d();
    Point3d& operator=(const Point3d&);
    
    virtual float z(){return _z;}
protected:
    float _z;
};

class Vertex : virtual public Point{};
class Vertex3d : public Point3d, public Vertex{};
class PVertex : public Vertex3d {};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

在这里插入图片描述
当Point3d和Vertex同为Vertex3d的subobject时它们对Point构造函数的调用一定不可以发生,取而代之的是,作为一个最底层的类,Vertext3d有责任将Point初始化。而更后面的继承,则有PVertex来负责完成被共享的Point subobject的构造。

因此,Point3d的构造函数的扩充内容:

// 在虚基类情况下的构造函数的扩充内容
Point3d * Point3d::Point3d(Point3d *this, bool __most_derived, float x, float y, float z){
	if(__most_derived != false){
		this->Point::Point(x, y);
	}

	this->__vptr_Point3d = __vtbl_Point3d;
	this->__vptr_Point3d_Point = __vptr_Point3d_Point ;
	this->_z = z;
	return this;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

在更深层的继承情况下,比如Vertex3d,当调用Point3d和Vertex的constructor时,总会把__most_derived参数设为false,于是就压制了两个constructors中对Point constructor的调用操作:

// 在虚基类情况下的构造函数的扩充内容
Vertex3d * Vertex3d::Vertex3d (Vertex3d  *this, bool __most_derived, float x, float y, float z){
	if(__most_derived != false){
		this->Point::Point(x, y);
	}

    //调用上一层的基类
    // 设定__most_derived = false;
	this->Point3d::Point3d(false, x, y, z);
	this->Vertex::Vertex(flase, x, y);

    // 设定vptrs
    // 安插用户代码

	return this;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

举个例子,当我们定义:

Point3d origin;
  • 1

时,Point3d可以正确调用其Point virtual base class subobject

当:

Vertex3d  cv;
  • 1

时,Vertex3d 构造函数会正确的调用Point virtual base class subobject。Point3d和Vertex的构造函数会做每一个该做的事情—对Point的调用除外

综上: virtual base class constructor的调用有明确的定义:只有当一个完整的类对象被定义出来是,就被调用。如果object只是某个完整object的subobject,他就不会被调用

因此,某些编译器会把每一个constructor一分为二,一个针对完整的object,另一个针对subobject。完整object版无条件调用virtual base constructor,设置所有的vptr等。subobject版不调用virtual base constructor,也可能不设置vptrs

vptr初始化
当我们定义一个PVertex对象时,构造函数的调用顺序是:

Point (x, y);
Point3d(x, y, z);
Vertex(x, y, z);
Vertex3d(x, y, z);
PVertex(x, y, z);
  • 1
  • 2
  • 3
  • 4
  • 5

假如这个继承体系中给每一个类都定义了virtual function size(),该函数负责传回类的大小,那么:

PVertex pv;
Point3d p3d;

Point *pt = &pv;
pt->size();              //将传回PVertex 的大小

pt = &p3d;
pt->size();              //将传回Point3d 的大小
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

假如这个继承体系中每一个构造函数内带一个调用操作,像这样:

Point3d::Point3d(float x, float y, float z)
	: _x(x), _y(y), _z(z){
	if(spyOn)
		std::cerr << "Within Point3d::Point3d() size:" << size() << "\n";
}
  • 1
  • 2
  • 3
  • 4
  • 5

当我们定义PVertex对象时,前述的五个构造函数将会怎样?每一次size()调用都会被决议为PVertex::size()吗?或者每次调用会被决议为"当前正在指向的构造函数所对应的类"的size()函数实体?

  • C++语言规则告诉我们,在Point3d构造函数中调用的size()函数,必须被决议为Point3d::size()而部署PVertex::size()
  • 更一般的,在一个类(本例为Point3d)的构造函数(和析构函数)中,经由构造中的对象(本例为PVertex)来调用一个虚函数,其函数实体应该是在此类(本例为Point3d)中有作用的那个

由于各个构造函数的调用顺序的原因,上面情况是必要的

  • 构造函数的调用顺序:从根源到末端,从内到外
  • 当base class constructor执行时,derived实体还没有被构造出来
  • 在PVertex constructor指向完毕之前,PVertex并不是一个完整的对象
  • 在Point3d constructor指向之后,自有Point3d subobject构造完毕

这就意味着,当每一个PVertex base class constructor被调用时,编译系统必须保证有适当的size()函数实体被调用。怎样才能做到这一点呢?

  • 如果调用操作限制必须在constructor(或者destructor)中直接调用,那么答案十分明显:将每一个调用操作以静态方式决议,千万不要用到虚拟机制。也就是说,如果是在Point3d constructor中,就明确调用Pont3d::size()

如果size()中又调用一个虚函数,会发生什么事情呢?

  • 这种情况下,这个调用也必须决议为Point3d的函数实体。
  • 而在其他情况下,这个调用是纯正的virtual,必须由虚拟机制来决定其归向。也就是说,虚拟机制本身必须知道这个调用源自于一个constructor中。

另一个可以采取的方法是:在构造函数或者析构函数内设置一个flag(必须以静态方式来决议),然后我们就可以根据flag作为判断依据,产生条件式的调用

上面解决方法可能比较麻烦,最根本的解决方法是:在执行一个constructor时,必须限制一组虚函数候选名单

那怎么决定一个类的虚函数名单呢?

  • 答案是根据虚函数表。那怎么处理虚函数表呢?答案是通过vptr
  • 所以,为了控制一个类中有作用的函数,编译系统只要简单的控制住vptr的初始化和设定操作即可。
  • 当然,设置vptr是编译器的责任,程序员不需要管

那么vptr初始化操作应该如何处理本质上,这需要看vptr在构造函数中的应该在何时被初始化而定

vptr应该在何时被初始化呢在基类构造函数调用操作之后,在程序员提供的码或者成员初始化列表中所列的成员初始化操作之前

  • 这解决了在类中限制一组虚函数名单的问题:
  • 如果每一个构造函数都一直等待这其基类构造函数执行完毕之后才设定其对象的vptr,那么每次它都能够调用正确的虚函数实体

每一个基类构造函数设定其对象的vptr使它指向相关的虚函数表之后,构造中的对象就可以正确的编程"构造过程中所幻化出来的每一个类"的对象。也就是说,一个PVertex对象会先形成一个Point对象、一个Poin3d对象,一个Verte对象,一个Vertex3d对象,然后才成为一个PVertex对象。在每一个基类构造函数中,对象可以和构造类的完整对象做比较。构造函数的执行算法如下:

  • 派生类构造函数中,所有虚基类以及上一层基类构造函数会被调用
  • 上述完成之后,对象的vptr(s)被初始化,指向相关的virtual table(s)
  • 如果有成员初始化列表的话,将在构造函数体内扩展开来。这必须在vptr被设定之后才进行,以免有虚函数被调用
  • 最后,指向程序员所提供的码

看个例子:

PVertex::PVertex(float x, float y, float z)
	:_ next(0), Vertex3d(x, y, z), Point(x, y){
	if(spyOn)
		std::cerr << "Within PVertex::PVertex() size:" << size() << "\n";	
}
  • 1
  • 2
  • 3
  • 4
  • 5

它可能被扩展为:

// PVertex构造函数的扩展码
PVertex* PVertex::PVertex(PVertex * this, bool __most__derived, float x, float y, float z){
	// 条件式的调用virtual base constructor
	if(__most__derived != false){
		this->Point::Point(x, y);
	}

	// 无条件的调用上一层的base
	this->Vertex3d::Vertex3d(x, y, z);
	
	// 将相关的vptr初始化
	this->__vptr_PVertex = __vtbl_PVertex;
	this->__vptr_Point_PVertex = __vptr_Point_PVertex ;

	// 程序员所写的码
	if(spyon)
		std::cerr << "Within PVertex::PVertex() size:"
		          << (*this->__vptr_Point_PVertex[3].faddr)(this)
		          << "\n";

	// 传回被调用的对象
	return this;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

这就解决了有关限制虚拟机制的问题。但真的解决了吗?假设Point构造函数定义为:

Point: Point(float x, float y)
	: _x(x), _y(y) {}
  • 1
  • 2

Point3d构造函数定位为:

Point3d::Point3d(float x, float y, float z)
	: Point(x, y), _z(z){}
  • 1
  • 2

怎么办?

下面是vptr必须被设定的两种情况:

  • 当一个完整的对象被构造起来时。如果我们声明一个Point对象,Point构造函数必须设定其vptr
  • 当一个subobject constructor调用了一个虚函数(不管是直接调用还是间接调用)时

如果我们声明一个PVertex对象,然后由于我们对其base class constructor的最新定义,其vptr将不再需要在每一个base class constructor中被设定。解决方法是把constructor分裂成一个完整的object实体和一个subobject实体。在subobject实体中,vptr的设定可以省略(如果可能的话)

问题:在类的构造函数的成员初始化列表中调用该类的一个虚函数,安全么?

  • 实际上是安全的。因为,vptr保证能够在成员初始化列表被扩展之前,由编译器正确设定好
  • 语意上肯能是不安全的,因为函数本身可能还需要依赖未被设定初值的成员,所以不推荐在成员初始化列表中使用虚函数

虚函数实现代价

当调用一个虚函数时,被执行的代码必须与调用函数的对象的动态类型一致,指向对象的指针或引用的类型是不重要的。编译器如何能够高效的提供这种行为呢?大多数编译器是使用virtual pointer(vtbl)和virtual table pointer(vptr)实现的。

一个vtbl通常是一个函数指针数组。在程序中每个类只要声明了虚函数或者继承了虚函数,它就有自己的vtbl,并且类种vtbl的项目是指向虚函数实现体的指针。

class C1{
public:
	C1();
	virtual ~C1();
	virtual void f1();
	virtual int f2(char c) const;
	virtual void f3(const string &s);
	void f4() const;
};

class C1{
public:
	C2();
	virtual ~C2();
	virtual void f1();
	virtual void f5(*str);	
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

在这里插入图片描述
对于C1的vtbl:

  • 注意非虚函数【f4和构造函数】不在vtbl种
  • 非虚函数就像普通的C函数那样被实现,所以有关它们的使用在性能上没有特殊的考虑

对于C2的vtbl:指向与对象相适合的函数。这些项目包括指向没有被C2重定义的C1虚函数的指针。

这个论述引出了虚函数所需的第一个代价:你必须为每个包含虚函数的类的虚函数表留出空间。类的vtbl的大小与类种声明的虚函数数量成正比(包括从基类继承的虚函数)、每个类应该只有一个虚函数表,所以虚函数表所需的空间不会太大。

虚函数表只实现了虚函数的一般机制,还需要用某种方法指出每个对象的vtbl时,它们才能使用,这就是vritual table pointer的工作,它来建立这种联系。

每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。是编译器自己添加的。

在这里插入图片描述
这张图片表示vptr位于对象的底部,但是实际上不同的编译器防止它的位置也不同。存在继承的情况下,一个对象的vptr经常被数据成员所包围。现在,我们只需要记住虚函数所需的第二个代价:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价

如果对象很小,这是一个很大的代价,额外的vptr可能会使成员数据大小增加一倍。在内存受到限制的系统里,这意味这你必须减少建立对象的数量。即使在内存没有限制的系统里,你也会发现这会降低软件的性能,因为较大的对象有可能不适合放在cache或者虚拟内存页(virtual memory page),这就可能使得系统换页操作增多。

如果我们有一个程序,保护几个C1和C2对象。那么
在这里插入图片描述

void makeACall(C1 *pC1){
	pC1->f1();
}
  • 1
  • 2
  • 3

因为pC1可能指向C1对象或者C2对象,因此你不会知道它调用的是哪一个f1-------C1::f1()或者C2::f1()。编译器必须确保无论pC1指向什么对象,函数的调用必须正确。编译器生成的代码会做如下事情:

  • 通过对象的vptr找到类的vtbl
  • 找到对应vtbl内的指向被调用函数的指针(f1)----通过编译器为每个虚函数在vtbl内分配的唯一的索引
  • 调用第二步找到的指针所指向的函数。
//pC1->f1();
(*pC1->vptr[i])(pC1);
  • 1
  • 2

这几乎与调用非虚函数效率一样。在大多数计算机上它多执行了很少的一些指令。调用虚函数的代价基本上和通过函数指针调用函数一样。虚函数本身通常不是性能瓶颈。

在实际运行中,虚函数所需的代价与内联函数有关。实际上虚函数不能是内联的,这是因为”内联“是指”在编译期被调用的函数体本身来代替函数调用的指令“。但是虚函数的”虚“是指”直到运行时才能直到要调用哪一个函数“。如果编译期在某个函数的调用者不知道具体哪个函数被调用,你就能知道为什么它不会内联该函数的调用。这是虚函数的第三个代价:你实际上放弃了使用内联函数(当通过对象调用虚函数时,它可以被内联,但是大部分虚函数是通过指针或者引用被调用的,这种调用不能被内联。因为这种调用是标准的调用方式,所以虚函数实际上不能被内联)

虚函数可以是内联函数吗?

  • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现动态性时就不能内联
  • 内联是在编译器建议编译器内联,而虚函数的多态性在运行期间,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性(运行期)不可以内联
  • inline virtual唯一可以内联的时候:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或者引用时才会发生

C++构造函数和析构函数可以是虚函数吗

先说结论:C++中,构造函数不可以是虚函数,而析构函数可以且常常是虚函数。

构造函数与虚函数

在这里插入图片描述
当类中声明虚函数时,编译器会在类中生成一个虚函数表,虚函数表时一个存储成员函数指针的数据结构

虚函数表是由编译器自动生成与维护的,virtual成员函数会被编译器放入虚函数表中,当存在虚函数时,每个对象都有一个指向虚函数的指针(vptr指针)。在实现多态的过程中,父类和派生类都有vptr指针。

vptr的初始化:当对象在创建时,由编译器对vptr指针进行初始化。在定义子类对象时,vptr先指向父类的虚函数表,在父类构造完成之后,子类的vptr才指向自己的虚函数表。

如果构造函数时虚函数,那么调用构造函数就需要去找vptr,而此时vptr还没有初始化。

因此,构造函数不可以是虚函数。

析构函数常常是虚函数

与构造函数不同,vptr已经完成初始化,析构函数可以声明为虚函数,且类有继承时,析构函数常常必须为虚函数。

#include <iostream>
using namespace std;

class base {
public:
    base() {
        cout << "base constructor" << endl;
    }
    ~base() {
        cout << "base destructor" << endl;
    }
};

class derived : public base {
public:
    derived() {
        cout << "derived constructor" << endl;
    }
    ~derived() { // virtual  ~derived() 效果相同
        cout << "derived destructor" << endl;
    }
};

int main()
{
    base *pBase = new derived;
    cout << "---" << endl;
    delete pBase;

    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

在这里插入图片描述

#include <iostream>
using namespace std;

class base {
public:
    base() {
        cout << "base constructor" << endl;
    }
    virtual ~base() {
        cout << "base destructor" << endl;
    }
};

class derived : public base {
public:
    derived() {
        cout << "derived constructor" << endl;
    }
    virtual      ~derived() {  //~derived() 效果相同
        cout << "derived destructor" << endl;
    }
};

int main()
{
    base *pBase = new derived;
    cout << "---" << endl;
    delete pBase;

    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

在这里插入图片描述

绝对不要在构造和析构函数中调用虚函数

在派生类对象的基类构造期间,对象的类型是基类而不是派生类。不只是虚函数会被编译器解析至基类,如使用运行期类型类型(比如dynamic_cast),也会把对象视为基类

因此,如果在派生类构造函数中调用虚函数,那么对象的作为就像隶属于基类一样,非正式的说法是:在基类构造期间,虚函数不是虚函数。

相同道理也适用于析构函数。一旦派生类析构函数开始执行,对象内的派生类成员变量便呈现未定义值。所以C++视它们彷佛不存在。进入基类析构函数后对象也就成为了一个基类对象,而C++的任何部分包括虚函数、dynamic_cast等待也就那么看待它。

比如:

class Transaction{   // 所有交易的基类
public:
	Transaction();                    
	virtual void logTransaction() const = 0;   // 做出一份因类型不同而不同的日志记录
};

Transaction::Transaction(){
	//...
	logTransaction();                  // 最后动作是记录这笔交易
}

class BugTransaction : public Transaction{
public: 
	virtual void logTransaction() const ;   // 记录这种类型的交易
}

class SellTransaction : public Transaction{
public: 
	virtual void logTransaction() const ;   // 记录这种类型的交易
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

现在,当如下被执行,会发生什么事情?

BugTransaction  b;
  • 1

这时被调用的是Transaction内的版本,而不是BugTransaction 内的版本—即使目前即将建立的对象类型是BugTransaction。

纯虚函数(抽象函数)

纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 =0:

virtual void funtion1()=0
  • 1

什么是纯虚函数

对于类:

class Abstract_base{
public:
    virtual ~Abstract_base() = 0;  // 必须实现这个,编译器不可能自己合成一个虚析构函数
    virtual void interface() const  = 0;  // 纯虚函数
    virtual const char * mumble() const { return _mumble; }
protected:
    char *_mumble;
};

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

问题:虽然类被设计为一个抽象的基类(其中有纯虚函数,使得Abstract_base类不可能拥有实体),但它仍需要一个明确的构造函数以初始化其数据成员_mumble。如果没有这个初始化操作,其派生类的局部对象_mumble将无法决定初值:

class Concrete_derived : public Abstract_base{
public:
    Concrete_derived();
    //...
};

void foo(){
	// Abstract_base::_mumble尚未初始化
	Concrete_derived concreteDerived;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

也许你可能会认为Abstract_base的设计者视图让每一个派生类提供_mumble的初值。那么如果是这样,派生类将要求Abstract_base必须提供一个唯一参数的protected 构造函数:

// 一般来说,类的数据成员必须初始化,而且只能通过构造函数或者成员函数设置或者修改。
Abstract_base::Abstract_base(char *mumble = 0) : _number(mumble ){}
  • 1
  • 2

也许你可能会认为设计者的错误并不在于没有提供一个显式构造函数,而是不应该在抽象的基类中声明类成员。但是将被共享的数据抽取出来放在基类中,是理所当然的设计

可以定义和调用一个纯虚函数,不过它只能被静态的调用,不能经由虚拟机制调用


inline void Abstract_base::interface() const {
    printf("call  Abstract_base::interface\n");
}
inline void Concrete_derived::interface() const {
    Abstract_base::interface();  // 静态调用
    printf("call  Concrete_derived::interface\n");
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

一定要实现纯虚函数

  • 因为每一个派生类的析构函数会被编译器扩展,以静态调用的方式调用其每一个虚基类以及上一层基类析构函数(这个行为不能被抑制,因为基类的纯虚函数可能被定义了)
  • 因此,只要缺乏任何一个基类析构函数的定义,就会导致链接失败
Abstract_base::~Abstract_base(){}  
  • 1

编译器不可能合成一个纯虚函数:

  • 因为编译器对一个可执行文件采取分离编译器模型

一个比较好的替代方案是:

  • 不要把虚析构函数声明为pure

另外:

  • 把所有的成员函数都声明为虚函数,然后再考编译器的优化操作把非必要的virtual invocation去除,并不是好的设计观念

虚拟函数中的const

面对一个抽象基类,决定一个虚函数是否需要const,不是件容易的事情。做这件事情,意味着需要假设子类实体可能被无限次调用。

  • 不把函数声明为const,意味着该函数不能获得一个const引用或者const指针。
  • 声明一个函数为const,然后才发现实际上derived instance必须修改某一个数据成员,就很头大了

推荐,不要使用const

重新定义类

class Abstract_base{
public:
    virtual ~Abstract_base() ;  // no purt
    virtual void interface()   = 0;  // no const
    const char * mumble() const { return _mumble; }  // no vritual
protected:
    Abstract_base(char *pc){_mumble = pc;}  // add parm constructor
    char *_mumble;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

为什么要有纯虚函数

  • 为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
  • 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的默认实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

总结

1、纯虚函数声明如下: virtual void funtion1()=0; 纯虚函数一定没有定义,纯虚函数用来规范派生类的行为,即接口。包含纯虚函数的类是抽象类,抽象类不能定义实例,但可以声明指向实现该抽象类的具体类的指针或引用。

2、虚函数声明如下:virtual ReturnType FunctionName(Parameter) 虚函数必须实现,如果不实现,编译器将报错,错误提示为:

error LNK****: unresolved external symbol "public: virtual void __thiscall ClassName::virtualFunctionName(void)"
  • 1

3、对于虚函数来说,父类和子类都有各自的版本。由多态方式调用的时候动态绑定。

4、实现了纯虚函数的子类,该纯虚函数在子类中就编程了虚函数,子类的子类即孙子类可以覆盖该虚函数,由多态方式调用的时候动态绑定。

5、虚函数是C++中用于实现多态(polymorphism)的机制。核心理念就是通过基类访问派生类定义的函数。

6、在有动态分配堆上内存的时候,析构函数必须是虚函数,但没有必要是纯虚的。

7、友元不是成员函数,只有成员函数才可以是虚拟的,因此友元不能是虚拟函数。但可以通过让友元函数调用虚拟成员函数来解决友元的虚拟问题。

8、析构函数应当是虚函数,将调用相应对象类型的析构函数,因此,如果指针指向的是子类对象,将调用子类的析构函数,然后自动调用基类的析构函数。

纯虚类(抽象类)

抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。

  1. 什么是纯虚类

至少包含一个纯虚函数的类叫做纯虚类

virtual void Test(int *p) = 0;
  • 1
  1. 纯虚类的作用

纯虚类主要作为基类使用,被其他子类继承

  1. 为什么要纯虚类

指定不能实现的虚函数,使得每个子类中该函数都被定义。该类主要是用作接口规范,使得子类的行为有一个参考。

  1. 注意

抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。

纯虚类不能实例化

绝不重新定义继承而来的non-virtual函数

如果有:

class B{
public:
	void mf();
}

class D : public B {...};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

那么对于:

D x;
  • 1

如果以下行为:

B * pB		 = &x;
pB->mf();
  • 1
  • 2

和以下行为:

D * pD = &x;
pD->mf();
  • 1
  • 2

表现不同,这是不对的。但是这是有可能发生的。更明确的说,如果mf是个non-virtual函数而D定义有自己的函数,就会行为两面:

class D : public B{
public:
	void mf(); // 隐藏了B::mf()
};

pB->mf(); // 调用B::mf
pD->mf(); //调用D::mf
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

造成行为两面的原因是,non-virtual函数比如B::mf和D::mf都是静态绑定的。也就是说,由于pB被声明为一个pointer-to-B,通过pB调用non-virtual函数永远是B所定义的版本,即使pB指向一个类型为”B派生的类“

另一方面,虚函数是动态绑定,所以它们不受这个问题之苦。如果mf是个虚函数,不管是通过pB或者pD调用mf,都会调用D::mf,因为pB和pD真正指的都是一个类型为D的对象

我们知道,所谓public继承意味着is-a关系;在类内声明一个non-virtual函数会为该类建立起一个不变性,凌驾其特异性。如果你将这两个观点施行于两个类B和D以及non-virtual成员函数B::mf身上,那么:

  • 适用于B对象的每一件事,也适用于D对象,因为每个D对象都是一个B对象
  • B的派生类一定会继承mf的接口和实现,因为mf是B的一个non-virtual函数

现在,如果D重新定义mf,你的设计就出现矛盾。如果D真的有必要实现出与B不同的mf,并且如果每一个B对象—不管多么特化—真的必须使用B所提供的mf实现码,那么”每个D都是一个B“就不为真,既然如此D就不该以public形式继承B。另一方面,如果D真的必须以public方式继承B,并且如果D真有需要实现出与B不同的mf,那么mf就无法为B反映出”不变性凌驾于特异性“的性质。既然这样mf应该声明为虚函数。

C/C++编程:为多态基类声明virtual析构函数这里解释了为什么多态基类内的析构函数应该是virtual。如果你违反这个准则(也就是在多态基类声明一个non-virtual函数),你也就违法了本准则,因为派生类绝不该重新定义一个继承而来的non-virtual函数(这里指基类析构函数)。本质而言,C/C++编程:为多态基类声明virtual析构函数只不过是本条款的一个特殊案例

运行时类型识别RTTI

RTTI能够让我们在运行时找到对象和类的有关信息,所以肯定有某个地方存储了这些信息让我们查询。这些信息被存储为type_info的对象里,你能够通过使用typeid操作符访问一个类的type_info对象。

在每个类仅仅需要一个RTTI的拷贝,但是必须保证有办法找到任何对象的类型信息。实际上上面的说明不是很准确。语言规范上这样描述:我们保证获得一个对象动态类型信息,如果该类型有至少一个虚函数。这使得RTTI有些像虚函数表,实际上RTTI是在类的vtbl上实现的。

比如,vtbl数组的索引0处可以包含一个type_info对象的指针,这个对象属于该vtbl相对应的类。
在这里插入图片描述

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/801906
推荐阅读
相关标签
  

闽ICP备14008679号