目录
- 一切从继承讲起
- 继承的语义是什么
- std::vector
- 虚函数登场
- 虚函数定义
- 子类中如何改变一个虚函数的行为
- override 限定符
- final 限定符
- covariant 返回类型
- virtual destructor 虚析构函数
- 虚函数如何实现的
- 函数指针
- 虚函数表
- 虚函数表的概念
- vtable指针
- 虚函数的消耗
- 总结
一切从继承讲起
我们有一个基类 Animal。
有一个 Dog 类继承了 Animal。
有一个 Fish 类也继承了 Animal。
一切从上面的小例子开始讲起。
假设 Animal 有一个成员函数 print,可以打印自己是什么物种,在 Animal类中,可以这么写:
class Animal
{
public:
void print()
{
std::cout << "我是 Animal" << std::endl;
}
};
class Dog: public Animal{};
class Fish: public Animal{};
上面代码里Dog和Fish没有任何新的改动,仅仅继承了Animal而已。所以当实例化Dog或者Fish的时候,将生成的对象调用print函数,只能显示出"我是 Animal"。
Dog d;
d.print(); // 打印 我是 Animal
Fish f;
f.print(); // 打印 我是 Animal
这样不好,我们想要更精确的打印物种信息,所以我们在子类中重定义print函数:
class Dog: public Animal
{
public:
void print()
{
std::cout << "我是 Dog" << std::endl;
}
}
class Fish: public Animal
{
public:
void print()
{
std::cout << "我是 Fish" << std::endl;
}
}
这样的话,Dog类和Fish类的变量调用print函数的时候,就会打印相应的信息了:
Dog d;
d.print(); // 打印 我是 Dog
Fish f;
f.print(); // 打印 我是 Fish
到目前为止,一切都是顺理成章。
继承的语义是什么
请思考一下这个问题:Dog 和 Animal 之间是什么关系?
在C++里,Dog继承自Animal,我们就说,Dog就是Animal。
就是说,子类就是父类。
不是谁包含谁的关系。
这很重要,但是还是需要进一步分析,【子类就是父类】这种关系到底在哪里能体现出来。
举一个例子,我们有一个函数,参数是 Animal*, 如下:
void foo(Animal* a)
{
}
由于C++是一个强类型系统,大部分语法都是来限制类型的。所以我们经常可以从函数传参来试图理解一些比较难理解的概念,比如说【子类就是父类】这个概念。
Animal a;
Dog d;
foo(&a); // 这个天经地义,完美匹配类型系统
foo(&d); // ????? 这个行不行呢
上面代码最后一句到底行不行?
根据【子类就是父类】 -> 【Dog 就是 Animal】。答案很明显,行!
我们再来看一个例子:
Animal a;
Animal* pa {&a}; // 依然天经地义
Dog d;
Animal* pd {&d}; // 依然?????
上面的代码不是函数传参,却与函数传参无二,花括号里需要填一个东西,来匹配前面的类型声明。
很明显,&d的类型是Dog*类型,完全可以当做Animal*来使用。
小总结,【子类就是父类】这个东西,在实践里,就是说,当我们需要一个【父类指针】的变量的时候,我们完全可以把一个【子类指针变量】丢进去。
上面的总结不仅仅对于指针来说,对于引用也是同样的。毕竟C++里,引用本身的概念与指针类似。
这里给个例子:
Dog d;
Animal& r{d}; // 完全可以
这是为什么呢,为什么可以这么做呢?
这是因为,子类对象的内存里,确实包含了完整的基类对象。
注意,对象之间的关系可以说包含与被包含了。
std::vector
我们在使用std::vector的时候,只能存储同种类型的变量,比如说,我们要存的是Animal*类型的变量,根据上面的说法,我们不仅仅能存Animal对象的指针,也可以存Dog对象 或者 Fish对象的指针。
这就给我们的代码带来了便利,一个std::vector可以来存储所有Animal子类的指针了。
否则,我们需要给每一个子类声明一个std::vector变量。
接着往下说,我们考虑下面的例子:
std::vector<Animal*> list;
Animal a;
Dog d;
Fish f;
list.push_back(&a);
list.push_back(&d);
list.push_back(&f);
for (auto e : list)
{
e->print();
}
我们知道,这三个类,都有自己定义的print函数,那么这个for循环执行的时候,到底怎么打印呢?
我是 Animal
我是 Animal
我是 Animal
这种结果是出乎意料,还是不出所料呢,不同的人有不同的见解。
这里应该是不出所料的,因为,c++是一个静态类型的语言,大部分特性都是静态的,所谓静态,就是编译的时候就能确定一些事情,比如说,调用哪个函数。
由于e的类型是Animal*, 所以在编译的时候,就已经确定好了,for循环里的print是Animal::print。这就是所谓静态。
我们发现,这个std::vector确实能存储Animal对象指针、 Dog对象指针、 Fish对象指针, 但好像一旦存储进去了,就无法区分,谁是谁了。
这怎么行,有一些行为,确实在子类里覆盖了,比如说print的行为。
如何让静态的c++编译器生成一些看起来动态的机器码呢,比如说,上面的循环里,能够调用各自类里面重新定义的print函数,而不简单粗暴的直接使用Animal::print呢?
虚函数登场
虚函数定义
虚函数是一种特殊的类成员函数, 这种函数在编译器,无法确定真正的函数地址在哪里,所以称之为虚函数。
程序运行的时候,根据具体的对象是什么,就调用什么相应的版本。
用严格一点的话来说:调用该虚函数出现的那个类和当前对象的类,这两个类之间,最靠下的那个版本的函数。
如何让一个普通成员函数成为一个虚函数呢,在声明的时候,前面加上virtual就行了。
话太绕了,我们来看例子:
class L
{
};
class L: public L1
{
public:
virtual void print()
{
std::cout << "L" << std::endl;
}
};
class L: public L2
{
}
class L: public L3
{
public:
virtual void print()
{
std::cout << "L" << std::endl;
}
}
///
void test()
{
L l4;
L* pL1 {&l4};
pL->print(); // 1. 打印什么
L* pL2 {&l4};
pL->print(); // 2. 打印什么
L l3;
pL = &l3;
pL->print(); // 3. 打印什么
}
我们来看上面的三个问题.
- 问题1: pL1->print();。这句话其实很简单,压根就不能编译,因为pL1的类型是L1*, 而L1类里面根本就没有print函数。
- 问题2: pL2->print();。L2*的身子装了L4指针,这就很明显了,L2和L4之间,最靠下的print,出现在L4中,所以这里应该打印L4。
- 问题3: pL2->print();。L2*的身子装了L3指针,根据我们的说法,也是很明显的,L2和L3之间,最靠下的,还是L2,所以这里应该打印L2。
通过这三个小问题,应该稍微了解虚函数到底调用哪一个的问题了。
子类中如何改变一个虚函数的行为
如果想要在子类中改变一个虚函数的行为,那么就必须严格按照基类中该虚函数的函数签名,重新实现这个虚函数:
class A
{
public:
virtual void print(){}
}
class B: public A
{
public:
virtual void print(int a){}
}
来看看上面的子类B中,我们给print加了一个参数,此时B中的print还是A中的那个print吗?
答案是否定的,
- 首先这个代码是能编译过的
- 只不过,B::print和A::print压根就没啥联系,在具体的搜索虚函数进行调用的时候,他们被看做完全不同的两个函数。
再来看看虚函数的返回值类型所带来的问题:
class A
{
public:
virtual void print(){}
}
class B: public A
{
public:
virtual int print(){return;}
}
问,此时B::print还是A::print吗?
答案,是的。。。。只不过,这个直接编译不过。
编译不过是好的,为什么,因为在编译的时候,就告诉你错在哪了。
上面那个由于疏忽或者别的原因,给原本的虚函数多加了一个参数,这种才可怕呢,因为编译通过了。
那怎么防范生成了一个新的函数?
override 限定符
如果在子类里面,我们确定要重新实现一个虚函数,那么我们就在函数签名的后面加上这个override限定符。
class A
{
public:
virtual void print(){};
};
class B: public A
{
public:
void print(int a) override {};
}
看上面代码,B这个子类中print函数前面前面,我们去掉了virtual, 而在花括号前面加了override。
此时,编译器就报错了,逻辑是这样的:
- 编译器看到override,它就认为print是从基类继承而来的一个虚函数,所以它去看看A::print, 发现这个函数没有参数。
- 回过头来,发现B::print(int) 带了一个参数,编译器直接报错。
这就让错误尽早出现在编译时期,棒!
final 限定符
可能会有这么一种情况,有一个类A,里面有一个虚函数print,你写了一个类B,继承了类A,然后override了这个print函数。然后别人写了一个类C继承了类B,你不想类C拥有override这个print函数的权限。
此时,在类B中,override print 函数的地方,可以加一个final:
class A
{
public:
virtual void print(){};
}
class B: public A
{
public:
void print() override final {}; // 注意看,加了final
}
class C: public B
{
public:
void print() override {}; // 编译报错
}
上面的代码演示了,class C中无法继续override print的写法。
还有一种极端的情况,你写了一个类A,你压根就不想别人去继承这个类A:
class A final
{
};
class B: public A // oh, 直接报错
{
};
加了final之后,就可以阻止别的类来继承了。
covariant 返回类型
上面讲过,一个虚函数,想要在子类里override,那么函数签名必须一模一样,包括返回值类型。但是有一种特殊的情况,需要考虑。看下面的例子
class A
{
public:
void print()
{
std::cout << "This is A" << std::endl;
}
};
class B: public A
{
public:
void print()
{
std::cout << "This is B" << std::endl;
}
};
class L
{
public:
virtual A* get()
{
return new A{};
}
};
class L: public L1
{
public:
B* get() override
{
return new B{};
}
};
我们先注意到,B和A就是两个普通的有继承关系的类,里面并没有出现virtual函数。
真正要研究的是L2和L1,get 函数是一个virtual函数,但是L2里get返回值类型是B*。
这似乎违反了virtual函数的规定,那就是函数签名必须一致。
但是又能说的通:【子类就是父类】。
所以上面的代码能编译过吗?
答案是能。这种特殊的情况被称之为covariant 返回类型,有的地方翻译成协变返回类型。
接着看如下的代码:
void test()
{
L l2;
l.get()->print(); // 问题1,这里打印什么?
L& rl1{l2};
rl.get()->print(); // 问题2,这里打印什么?
}
- 问题1:这个地方不难,就是打印This is B。
- 问题2:我们来慢慢分析,rl1 声明的类型是 L1& ,但是引用了一个子类对象l2。此时 rl2.get()是遵循虚函数的调用逻辑,也就是肯定调用的是L2::get。L2::get的返回类型是什么,是B*,所以直接得出结果应该是 B::print, 打印This is B。
不好意思,问题2的结论是错的。
虚函数不会改变原本的函数返回类型,在L1这个基类中,返回类型就是A*,即使调用了L2::get,仍然返回了A*这个类型,如果你有IDE,你可以将鼠标悬停在
rl1.get()->print();
get这个地方,会显示出,返回类型是A*, 于是乎,最后的print其实是A::print, 所以打印了
This is A。
virtual destructor 虚析构函数
在大部分时候,我们都无需为自定的class提供一个析构函数, 因为大部分时候自定义的class里面不包含需要释放的资源,比如说内存,文件等等。此时c++会提供一个默认的析构函数。
但是,如果我们的class里有这种动态的资源,那么就不得不提供一个自定义的析构函数,来针对这些动态资源进行释放。
更进一步的是,如果一个拥有动态资源的class同时继承了别的class,此时最好小心一点:
这是啥意思, 来看例子:
class L
{
public:
~L()
{
std::cout << "L 正在析构" << std::endl;
}
};
class L: public L1
{
int* resource;
public:
L():resource{new int}
{
}
~L()
{
delete resource;
}
};
void test()
{
L* l2{new L2};
L* pl1{l2};
delete pl;
}
分析以上代码,pl1 指向了一个子类L2的对象,在delete pl1的时候,编译器发现,L1 的析构函数是正常函数,所以编译器在这里指定决定调用L1::~L1这个函数,然后就结束了。
我们会发现,L2 的析构函数并没有被调用到,也就是说, resource 所指向的资源没有被回收!!!
怎么办呢,将 L1 中的析构函数标记成virtual:
class L
{
public:
virtual ~L(){};
}
这样才能保证,任何继承自L1的类中的动态资源被回收。
结论:如果写了一个类,这个类有可能被别的类继承的话,那么最好将这个类的析构函数标记成virtual的:
class A
{
public:
virtual ~A() = default;
}
关于这一点,有很多大师级人物都讨论过,不同的人有不同的看法,不过,上面的结论还是稳妥的,虽然有一点性能消耗。
虚函数如何实现的
为什么要有这个疑问,难道这种实现不正常吗?
不正常,非常不正常,C++是一个静态语言,必须先编译再运行,执行什么函数,一定是编译时就决定好的。
而虚函数打破了这种既有的规则,而这种规则的打破依赖于函数指针。
下面来讲讲虚函数这一套逻辑到底是怎么跑起来的。
函数指针
这是一种指针,这个指针指向的是一块代码,用这个指针可以进行函数调用:
void print_v()
{
std::cout << "print_v" <<std::endl;
}
void print_v()
{
std::cout << "print_v" <<std::endl;
}
void test()
{
auto f {print_v};
f(); // 打印 print_v
f = print_v;
f(); // 打印 print_v
}
观察上面的代码,发现,两个f()调用了不同的函数,这是一种动态行为。也就是说,程序运行的时候,根据f本身的指向,才能决定真正调用哪一块代码。
虚函数表
有了函数指针,使得动态行为有了可能,剩下的就是奇思妙想,让虚函数逻辑跑起来。
大部分编译器采用了所谓虚函数表的东西来实现虚函数逻辑。
这种东西文字描述不清,直接看例子:
class L
{
public:
virtual void func()
{
}
virtual void func()
{
}
};
class Sub: public L1
{
public:
void func() override
{
}
};
class Sub: public L1
{
public:
void func() override
{}
};
先描述一下,上面有三个class,L1是一个基类,里面有两个virtual 函数:
- func1
- func2
然后
- Sub1继承了L1, 然后override了 func1
- Sub2继承了L1, 然后override了 func2
此时,先来考虑一个小问题,sizeof 三个 class,应该是多大呢,假如是64bit机器。
答案是都是占8字节,也就是64bit。
那么这8字节存了啥东西?
答案就是,这8字节其实是一个指针,指向哪,先不说,一会再来说明。
虚函数表的概念
对于上面的例子来说,编译器生成了三个虚函数表,也就是L1、Sub1、Sub2每个class,各一个。
注意这个虚函数表是每个class一个,而不是每个对象一个,一定要搞明白。
这很类似于 class 里的静态成员,这么说就好理解了。
那虚函数表长啥样?
其实虚函数表就是一个数组,数组里的每一项就是一个简单的函数指针。
我们来画一画上面例子的虚函数表:
在右边的代码段里,我们可以看见,一共有四个不同的函数,这与我们的代码是一致的。
再来看左边的虚函数表,可以清晰的看出来,每个类里的两个虚函数都真实地指向了正确的版本。
光有这个虚函数表,是没用的,在调用虚函数的地方,必须与这个虚函数表联系起来。
还记得刚才说的那个8字节的指针吗。
那个指针就是起到这种关联的。
我们看下面的例子:
void test()
{
L l2;
L* p{&l2};
p->func();
}
我们画出上面的整个关系图:
此时用 p->func1() 的时候,为什么会调用到Sub1::func1就一目了然了,一直跟着指针往下走就明白了!
vtable指针
我们将上面的那个8字节指针称做vtable指针,它的作用就是来指向相应的class的虚函数表的。
一般而言,这个变量是在基类里声明的,子类是继承了这个变量。
在对象初始化的时候,这个指针会指向真正的本class的虚函数表。
比如说
- Sub1对象里的vtable就会指向Sub1的虚函数表
- Sub2对象里的vtable就会指向Sub2的虚函数表
虚函数的消耗
我们从上面的实现可以看出,在使用虚函数的class里,强行塞入了一个vtable指针,占了8字节,这无疑会增加内存的消耗。
其次,调用虚函数的时候,需要三步走。
- 从vtable找到虚函数表
- 从虚函数表找到真正的函数指针
- 然后由函数指针找到函数,进行调用
而一般的函数只有最后一步,这无疑也是增加了一些步骤的,不过这种消耗不怎么明显,所以该用虚函数,还是尽量用吧,不要有什么心理负担,然后搞什么静多态。
对了,我们把整个虚函数所进行的行为称之为多态,这是一种动态多态,因为这是运行时的行为。
至于什么叫静多态,那就不属于本文所讨论的了。