c++笔记之虚函数、虚继承、多态和虚函数表

参考:C++——来讲讲虚函数、虚继承、多态和虚函数表 - 知乎 (zhihu.com)

1、什么是虚函数?

虚函数是一种由virtual关键字修饰的一种类内函数,可分为虚函数和纯虚函数。还是先上代码看看吧:

#include<iostream>
#include<memory>

class A
{
public:
    virtual void func(){std::cout<<"A func() called"<<std::endl;}

};

class B:public A
{
public:
    void func(){std::cout<<"B func() called"<<std::endl;}


};



int main()
{

    A a;
    a.func();

    B b;
    b.func();


    return 0;
}

运行结果:

c++笔记之虚函数、虚继承、多态和虚函数表

ok,我们来看看virtual关键字在这里的作用,类B继承于类A,但类B中有和A同名的func函数,这个时候声明一个类B的对象,它就能正确地调用B的func。

你这个时候可能会有疑问,virtual关键字我没看到在这起了什么作用啊?

那我们把类A的virtual去掉,再看看输出:

输出为:

c++笔记之虚函数、虚继承、多态和虚函数表

没错!这时候发现去掉virtual关键字与否并不改变输出结果。。。看起来virtual在这里没有起到任何作用。

我们等下再说为什么要把需要重写的方法用virtual修饰,现在你就先认为它没用吧!

首先,我们补充一个知识点:析构函数可以写成虚的,但是构造函数不行。

为什么呢?其中的原因比较复杂,简单地来说就是虚函数是通过一种特殊的功能来实现的,它存储在类所在的内存空间中,构造函数一般用于申请内存,那连内存都没有,怎么能找到这种特殊的功能呢?

所以构造函数不能是虚的。当然还有其他原因,具体地原因可以参考以下文章:

为什么构造函数不能为虚函数

好,现在我们来试试把析构函数写成虚的,来看看会发生什么事?

#include<iostream>
#include<memory>

class A
{
public:
    A(){std::cout<<"A() called"<<std::endl;}
    virtual ~A(){std::cout<<"~A() called"<<std::endl;}

};

class B:public A
{
public:
    B(){std::cout<<"B() called"<<std::endl;}
    ~B(){std::cout<<"~B() called"<<std::endl;}


};



int main()
{

    B b;


    return 0;
}

运行结果如下:

c++笔记之虚函数、虚继承、多态和虚函数表

好,那么我们来观察一下这里的virtual有什么用呢?你可以尝试把virtual去掉,观察一下输出有没有不同。

结论是,没有不同,无论基类的析构函数virtual与否,输出都是这样的。

啊咧,那给析构函数加虚有什么用啊!那你现在也暂且先认为它没用吧……

那我们来讲一讲纯虚函数。

还是最前面的例子程序,将:

virtual void func(){std::cout<<"A func() called"<<std::endl;}

修改为:

virtual void func()=0;

这样类A的func就是一个纯虚函数。这个时候我们再编译一下,出现以下错误:

#include<iostream>
#include<memory>

class A
{
public:
    virtual void func()=0;
};

class B:public A
{
public:
    void func(){std::cout<<"B func() called"<<std::endl;}

};


int main()
{

    A a;
    a.func();

    B b;
    b.func();

    return 0;
}

c++笔记之虚函数、虚继承、多态和虚函数表

变量类型A是一个抽象类(因为凡是包含纯虚函数的类都是抽象类),在抽象类A中不能够被执行的纯虚方法func; 不能为抽象类A声明一个实例对象!!!

也就是等同于如下:

error C2259: “A”: 不能实例化抽象类
note: 由于下列成员:
note: “void A::func(void)”: 是抽象的
note: 参见“A::func”的声明

对!纯虚函数是不能被调用的,因为它根本就没有具体实现,只有声明。所以a.func();这样的代码是会报错的。

那么我们把代码改成以下这样:

#include<iostream>
#include<memory>

class A
{
public:
    virtual void func()=0;

};

class B:public A
{
public:
    void func(){std::cout<<"B func() called"<<std::endl;}


};



int main()
{
    B b;
    b.func();


    return 0;
}

运行结果如下:

c++笔记之虚函数、虚继承、多态和虚函数表

注意:

(1)好,那我们就知道了纯虚函数是一种不需要写实现,只需要写声明的一种函数,它留待派生类来实现它的具体细节,我们在这里称A为基类,B为派生类,下文同。

(2) 此外需要额外注意,因为类A拥有纯虚函数。所以我们也称类A为抽象类,称A::func()为抽象函数。

(3)请记住,抽象类是不能被实例化的,也就是说A a;这句语法是非法错误的。

那问题又来了,派生类可以是抽象类吗?

我们不妨试一试:

#include<iostream>
#include<memory>

class A
{
public:
    virtual void func()=0;

};

class B:public A
{
public:
    void func()=0;


};

class C:public B
{
  public:
    void func(){std::cout<<"C func() called"<<std::endl;}
};



int main()
{
    C c;
    c.func();


    return 0;
}

运行结果如下:

c++笔记之虚函数、虚继承、多态和虚函数表

从这个结果可以发现:在单继承的前提下,你只要实例化的派生类不是抽象类就可以了,且一个抽象类是可以继承自抽象类的,并且它可以被另一个类所继承。

2、什么是虚继承?

先看代码如下:

#include<iostream>
#include<memory>

class A
{
public:
    int a;

};

class B:public A
{
public:
    int b;


};

class C:public A
{
  public:
    int c;
};

class D:public B, public C
{
public:
    int d;
};


int main()
{
    D d;
    d.a = 5;


    return 0;
}

结果:

c++笔记之虚函数、虚继承、多态和虚函数表

main.cpp(26): error C2385: 对“a”的访问不明确
main.cpp(26): note: 可能是“a”(位于基“A”中)
main.cpp(26): note: 也可能是“a”(位于基“A”中)
main.cpp(27): error C2385: 对“a”的访问不明确
main.cpp(27): note: 可能是“a”(位于基“A”中)
main.cpp(27): note: 也可能是“a”(位于基“A”中)

后三条和前三条报的都是一样的错误。

指的就是D这个类被实例化了之后,对象d想访问基类A的成员a的时候,竟然不知道应该是通过B来找还是通过C来找。

这种就叫做菱形继承,如图:

c++笔记之虚函数、虚继承、多态和虚函数表

那这个时候我们该如何访问到经过B的A的成员a呢?以下代码给出解决方案:

#include<iostream>
#include<memory>

class A
{
public:
    int a;

};

class B:public A
{
public:
    int b;


};

class C:public A
{
  public:
    int c;
};

class D:public B, public C
{
public:
    int d;
};


int main()
{
    D d;
    d.B::a = 5;
    std::cout<<d.B::a<<std::endl;

    return 0;
}

运行结果如下:

c++笔记之虚函数、虚继承、多态和虚函数表

好的我们终于发现了一种经过指定类访问到爷类(基类的基类)的成员方法了!

那我又提出了一个新问题:经过B访问的a和经过C访问的a,它们,是一个a吗?

我们做个实验就知道了,因此,我们给出如下代码:

#include<iostream>
#include<memory>

class A
{
public:
    int a;

};

class B:public A
{
public:
    int b;


};

class C:public A
{
  public:
    int c;
};

class D:public B, public C
{
public:
    int d;
};


int main()
{
    D d;
    std::cout<<&d.B::a<<std::endl;
    std::cout<<&d.C::a<<std::endl;

    return 0;
}

c++笔记之虚函数、虚继承、多态和虚函数表

地址都不同,那就肯定不是一个a了,但是它们的地址位置相差8,这难道是一个巧合吗?

我们后面会说到,可以证明它们的偏移量在这个例子中,是不会随着代码执行的次数的多少而有所改变的。

那其实也就是说,如果是这样继承,D中将会有两份A的副本

这不对劲,我们应该只想要一份A而已。这个时候我们就需要引入虚继承了,在需要继承的基类前加virutal关键字修饰该基类,使其成为虚基类,见代码如下:

#include<iostream>

class A
{
public:
	int a;

};

class B :virtual public A
{
public:
	int b;
};

class C :virtual public A
{
public:
	int c;
};

class D :public B, public C
{
public:
	int d;
};

int main()
{
	D d;
	std::cout << &d.a << std::endl;
	std::cout << &d.B::a << std::endl;
	std::cout << &d.C::a << std::endl;
	
	return 0;
}

运行结果如下:

c++笔记之虚函数、虚继承、多态和虚函数表

我们可以发现,无论指不指定经过的类,a都只会在d中有一份副本了。

原文中给出但要记住并注意:把上面代码中的

class D :public B, public C

全部写成

class D :virtual public B, virtual public C

是不可以实现多继承!!!!!

但我这里发现改完以后也可以正常编译运行,代码如下:

#include<iostream>

class A
{
public:
	int a;

};

class B :virtual public A
{
public:
	int b;
};

class C :virtual public A
{
public:
	int c;
};

class D :virtual public B, virtual public C
{
public:
	int d;
};

int main()
{
	D d;
	std::cout << &d.a << std::endl;
	std::cout << &d.B::a << std::endl;
	std::cout << &d.C::a << std::endl;
	
	return 0;
}

c++笔记之虚函数、虚继承、多态和虚函数表

3、多态

我们来解决第一节中所提出的问题,在基类中给成员函数/析构函数分别加virtual到底有什么作用?

我们先来看看C++是如何实现多态的,见如下代码,代码给出了一种基类对象调用派生类中的方法的例子:

#include<iostream>


class Base
{
public:

	virtual void func() { std::cout << "Base func() called" << std::endl; }
};

class Derived :public Base
{
public:

	void func() { std::cout << "Derived func() called" << std::endl; }
};

int main()
{

	Base *b = new Derived;
	b->func();
	return 0;
}

c++笔记之虚函数、虚继承、多态和虚函数表

可以发现:给Base类的指针赋予派生类的属性,居然可以正确调用派生类中的方法!

那我们把Base类的virtual删掉呢?

那输出就会变成为:

c++笔记之虚函数、虚继承、多态和虚函数表

我们可以发现,这个时候派生类中的方法就不会去覆盖基类中的同名方法,从而无法调用派生类的方法。

那么同样地,我么可以猜想,如果Base类的析构函数不虚,将会发生怎么样的结果?

#include<iostream>


class Base
{
public:
	Base() { std::cout << "调用Base类的构造函数" << std::endl; }
	~Base() { std::cout << "调用Base类的析构函数" << std::endl; }
	
};

class Derived :public Base
{
public:
	Derived() { std::cout << "调用Derived类的构造函数" << std::endl; }
	~Derived() { std::cout << "调用Derived类的析构函数" << std::endl; }

	
};

int main()
{

	Base *b = new Derived;
	delete b;
	return 0;
}

c++笔记之虚函数、虚继承、多态和虚函数表

我们可以看见结果得出以分析结论:

1)Base *b = new Derived;即调用了Base类的构造函数也调用了Derived类的构造函数,且注意Base *b是在栈内存中申请的Base类指针b,new Derived是在堆内存中申请的Derived类的指针。

2)Base b;这样只会调用Base类的构造函数,没有涉及到Derived类的东西。

3)从结果中发现Derived类的空间没有被析构,即没有被释放,也就是发生了内存泄露(其实这里说泄漏是存在不严谨的,因为整个程序结束之后所有东西都会被系统回收,就没有所谓的内存泄漏一说了

注意:如果把Base类的析构函数设置为虚的,那就有:

#include<iostream>


class Base
{
public:
	Base() { std::cout << "调用Base类的构造函数" << std::endl; }
	virtual ~Base() { std::cout << "调用Base类的析构函数" << std::endl; }
	
};

class Derived :public Base
{
public:
	Derived() { std::cout << "调用Derived类的构造函数" << std::endl; }
	~Derived() { std::cout << "调用Derived类的析构函数" << std::endl; }

	
};

int main()
{

	Base *b = new Derived;
	delete b;
	return 0;
}

c++笔记之虚函数、虚继承、多态和虚函数表

这样就可以成功释放所有申请的内存了!!!

好,析构函数的虚特性我们就搞清楚了,但是我们还是没有搞清楚一个问题:

把函数声明为虚的,为什么Base的指针就可以在绑定派生类的属性之后寻找到正确的方法呢?

接下来我就来讲虚函数表及虚函数表指针,这两者是实现多态的必备工具。

同时,我们将会讨论内存分布,并且我们通过虚函数表明白,为什么不能把基类中的方法赋值给派生类指针

4、虚函数表及虚函数表指针

我们先来一段不表现多态的代码,来探究一下虚函数表及其指针的是什么,见如下代码:

#include<iostream>


class Base
{
public:
	Base() { std::cout << "调用Base类的构造函数" << std::endl; }
	virtual ~Base() { std::cout << "调用Base类的析构函数" << std::endl; }
	virtual void func() { std::cout << "调用Base类中的func()函数." << std::endl; }
	
};

class Derived :public Base
{
public:
	Derived() { std::cout << "调用Derived类的构造函数" << std::endl; }
	~Derived() { std::cout << "调用Derived类的析构函数" << std::endl; }
	void func() { std::cout << "调用Derive类中的func()函数" << std::endl; }

	
};

int main()
{

	Base *b = new Base;
	Derived* d = new Derived;
	delete b;
	delete d;
	return 0;
}

这时候我们打开监视,看一下类对象有什么东西:

c++笔记之虚函数、虚继承、多态和虚函数表

其中,b的地址为0x0000027ae0af2c30,其中有一个隐藏变量__vfptr,类型为void**,地址为0x00007ff63928bc30。

c++笔记之虚函数、虚继承、多态和虚函数表

d的地址为0x00000149f5142570,其中有一个Base类,Base类下有一个隐藏变量__vfptr,类型为void**,地址为0x00007ff63928bcb0。

那么这个__vfptr指向什么呢?,由其类型void**可以知道它应该指向一个void*类型的指针,即void**是指向指针的指针,即二维指针,也可以看作是二维数组。就是我们的虚函数表。我们仅看b,打开b的__vfptr往下展开:

c++笔记之虚函数、虚继承、多态和虚函数表

我们看到里面有两个数据,一个([0])是Base的析构函数的地址,地址为0x00007ff639281230;另一个([1])是Base的func,地址为0x00007ff6392814a1。

这个void**其实就存放着所有被virtual关键字修饰的函数的实际存放地址。void*就是指针,其值就是一个地址。

我们可以看看__vfptr指向的变量叫什么名字:project15.exe!void(*Base::`vftable`[3])()

就是一个叫`vftable`的函数指针数组(也即是void**类型)(指针数组:数组每一个元素都是一个指针,参考博客),长度为3。(为什么长度为3呢?)

好,这个虚函数表就真相大白了。

接下来我们就把那个上面不表现多态的代码改一下,使其展现出多态,具体代码如下:

#include<iostream>

class Base
{
public:
	Base() { std::cout << "调用基类Base的构造函数" << std::endl; }
	virtual ~Base() { std::cout << "调用基本Base的析构函数" << std::endl; }
	virtual void func() { std::cout << "调用基类函数func" << std::endl; }
};

class Dervied :public Base
{
public:
	Dervied() { std::cout << "调用派生类Dervied的构造函数" << std::endl; }
	~Dervied() { std::cout << "调用派生类Derivied的析构函数" << std::endl; }
	void func() { std::cout << "调用派生类函数func的析构函数" << std::endl; }

};



int main()
{
	Base* b = new Base();
	Dervied* d = new Dervied();
	delete b;
	delete d;

	b = new Dervied();
	delete b;
	
	return 0;
}

现在我们来看一下指针变量b的状态:

c++笔记之虚函数、虚继承、多态和虚函数表

我们可以发现,这个时候的b是一个Base类的指针,但是__vfptr指向的确是Dervied类的虚函数表。此外发现其地址0x00007ff65629bca8,这与上次d(即上面这张图所展示的)的

__vfptr保存的地址值是一样的。

那么这很好,那我要调用函数的时候(比如调用func),我要去虚函数表寻找函数地址的吗?那我不就一找就能够找到我想调用的Dervied类中的方法了?那我们也知道,在整个程序的生存周期中,

每个类的虚函数表都有唯一的一个地址。

那回到之前的问题,为什么不能把基类的属性赋值给派生类的指针呢?我们来举一个例子就知道了,如下代码例子:

#include<iostream>

class Base
{
public:
	Base() {}
	virtual ~Base() {}
	virtual void func() { std::cout << "调用基类函数func" << std::endl; }
	virtual void func2() { std::cout << "调用基类函数func2" << std::endl; }
};

class Dervied :public Base
{
public:
	Dervied() {}
	~Dervied() {}
	void func() { std::cout << "调用派生类func函数" << std::endl; }
	virtual void func3() { std::cout << "调用派生类func3函数" << std::endl; }

};



int main()
{
	Base* b = new Base();
	Dervied* d = new Dervied();
	delete b;
	delete d;
	
	return 0;
}

我们来看看b和d的虚函数表:

c++笔记之虚函数、虚继承、多态和虚函数表

可以发现:b的虚函数表的长度为4,而d的虚函数表的长度为5。

那也就是说,Dervied类对象指针本该接收一个长度为5的虚函数表,可是你给他传了一个Base类的属性,整个Base类只有长度为4的虚函数表,没办法填满这个长度为5的虚函数表。

那也就是说,缺失了一种方法的实现,缺失了哪个方法呢?

答案是:缺失了func3()

假设我有语句:

Dervied *d = new Base();

并且假设这个语句合法,那么我们随即调用Derived的func3()方法:

d->func3();

请记住:就算你把Base类的属性赋值给了d,可d本身依然还是一个Derived类的指针,编译器是不会管你把什么属性赋值给了d的。所以d->func3();这个语句本来就应该是合法的。

那这个时候程序跳转到d的虚函数表(注意这个虚函数表是Base类的虚函数表),发现找不到func3()方法,所以就崩了。

c++笔记之虚函数、虚继承、多态和虚函数表

 

      由于派生类继承了基类所有的公有虚函数,所以派生类是基类的超集(对应相反的就是子集)。所以把派生类属性赋值给基类是合法的,但基类赋值给派生类就一定是不合法的,因为基类缺失了一些派生类新定义的属性(即在基类中找不到派生类中新定义的成员)。

至于给基类种的private属性的函数打virtual会怎样?

你可以试试,在派生类中根本就没办法重写这些虚方法,也没法访问,一样是没有意义的。

c++笔记之虚函数、虚继承、多态和虚函数表

 

基础篇:继承,多态和虚函数 - 知乎 (zhihu.com)

(30条消息) C++多态虚函数表详解(多重继承、多继承情况)_青城山小和尚-CSDN博客_多继承虚函数表

(30条消息) C++(刨根问底)_虚函数_多态_抽象类_多态的原理_dodamce的博客-CSDN博客

C++虚函数表(多态的实现原理) (biancheng.net)

虚函数、虚函数表、虚继承_Fiona_新浪博客 (sina.com.cn)

C++ 为什么不把所有函数设置成虚函数? - 知乎 (zhihu.com)

请问这个c++多继承问题? - 知乎 (zhihu.com)

(1 条消息) c++虚函数的作用是什么? - 知乎 (zhihu.com)

12.虚函数与多态 - 知乎 (zhihu.com)

(2 条消息) C++为什么要弄出虚表这个东西? - 知乎 (zhihu.com)

C++基础-动态多态的理解 - 知乎 (zhihu.com)

(虚继承)防止重复内容的有趣操作 - 知乎 (zhihu.com)

c++多态和虚函数表实现原理 - 知乎 (zhihu.com)

(2 条消息) 多态实现原理——虚函数表原理解析,干货满满,面向对象特性 - 知乎 (zhihu.com)

深入剖析C++多重继承的虚函数表 - 知乎 (zhihu.com)

C++多态 - 知乎 (zhihu.com)

上一篇:【C++】带虚基类(virtual)的多继承构造函数执行顺序探究(经验规律)


下一篇:类的大小