在这一篇文章当中,我们会讲到有关于虚函数和纯虚函数的内容。
首先我们介绍虚函数。什么是虚函数?虚函数是定义在父类当中,允许我们在子类里面重新实现该方法的函数。这个函数对于类的继承是非常重要的。
比如我们举这样一个例子,父类是Entity,子类是Player,两个类当中都有一个名字叫做GetName的函数,其中Entity当中的函数只会返回Entity,而Player类的名字是由用户自己定义的。
class Entity {
public:
std::string GetName() {
return "Entity";
}
};
class Player : public Entity {
public:
std::string m_Name;
public:
Player(const std::string& name)
:m_Name(name){}
std::string GetName() { return m_Name; }
};
然后我们分别实例化一个Entity对象和一个Player对象,然后打印它们的名字:
int main() {
Entity* e = new Entity;
Player* p = new Player("Cherno");
std::cout << e->GetName() << std::endl;
std::cout << p->GetName() << std::endl;
std::cin.get();
}
输出结果如下所示:
很好,我们成功的打印了这两个名字。
接下来我们定义一个函数,输入的变量就是一个Entity类的指针,然后用来打印这两个对象的名字。
void Function(Entity* e) {
std::cout << e->GetName() << std::endl;
}
int main() {
Entity* e = new Entity;
Player* p = new Player("Cherno");
Function(e);
Function(p);
std::cin.get();
}
但是打印的结果如下所示,很明显并不正确。
为什么会出现这种情况?我们明明想要调用Player类当中的GetName,结果打印出来的居然是Entity。这个原因是,当我们正确使用一个函数的时候,那么在调用方法时,函数调用的一定是这个类型的方法。这也就导致了这个函数调用出来的是Entity类的GetName。
那么我们如何能够避免这个问题?我们希望C++的编译器能够意识到,虽然输入类型是父类的类型,但是我们想要调用子类的方法,那么这个问题的答案就是虚函数。
虚函数引入了dynamic dispatch(动态编联),通常会通过v table(虚函数表)来实现编译。在v表当中,包含了基类的所有虚函数的映射,可以在程序运行的时候找到对应的那个子类的映射,从而帮我们把正确的方法挑选出来。
定义虚函数的方式是在函数名前面加上virtual关键字。比如这个问题中,我们把Entity类改为
class Entity {
public:
virtual std::string GetName() {
return "Entity";
}
};
那么输出结果就会恢复正常:
当然,在子函数这一边,我们也可以使用一个关键字override来标明这个函数目前是重写了父类当中的一个函数。可以选择不标注,不会影响到程序运行,但是通过这样的标注,可以让我们的代码看起来更加清晰和整洁。而且还可以防止出现bug,比如我们不小心把父类当中的虚函数变成普通函数了,那么这个override关键字就会提示我们这里出了问题。
std::string GetName() override { return m_Name; }
虚函数虽然很好,但是还是需要成本的,因为首先,虚函数会存下一个v表和v表指针,来保存对所有子类的映射;其次,需要遍历整个v表才能够决定使用哪个函数。但是实际使用当中,这个对于性能的影响不是很大,除非是嵌入式编程这类要求极致性能的,也许会考虑这样一个问题。但是在windows下写C++,这一点并没有太大影响。
另外有一个有趣的地方,如果我们不在Entity类内定义任何虚函数,我们输出它的大小,会发现它只占了一个byte的内存;但是如果我们在里面定义了一个虚函数再看它的大小,会发现它的大小变成了8byte,说明虚表和虚指针的存在确实增加了它的内存占用量。
那么如果我们定义了一个虚函数,但是在基类当中我们干脆就不实现它,只希望子类来实现它,这个可不可以?答案是可以的,我们把这类函数成为纯虚函数,而这样的类会被称为接口(interface)。在之前的例子当中,我们在父类中定义了虚函数,说明这个虚函数有了默认定义,但是有的时候我们不希望它有默认定义,我们希望这个函数必须由子类实现,那么我们就可以把父类中的这个函数改为纯虚函数,这也是C++当中的一种抽象方法。
需要注意的一点是,接口类里面有没有实现的方法,所以接口类是不可以实例化的;当然,如果子类也没有实现这个方法,同样的子类也是没有办法实例化的。
定义虚函数的方法:virtual + 类型 + 函数名() = 0
只有实现了这个虚函数,我们才可以实例化对应的类。
那么为什么我们要使用纯虚函数?因为比如有这样一个函数,它接受某种类型,而且希望这个类型一定会有某种方法,因为它会调用这个方法,那么我们只需要定义一个包含有这个方法作为纯虚函数的接口类,然后我们使用其它类来继承这个类,实例化这个方法,再把实例化的对象传入到这个函数中,就完成了我们的设计目标。
以下面这个Entity和Player类为例,这次我们定义一个接口Printable,表明是一个可以打印名字的接口。
#include<iostream>
#include<string>
class Printable {
public:
virtual std::string PrintName() = 0;
};
class Entity : public Printable {
public:
std::string PrintName() { return "Entity"; };
};
class Player : public Entity {
public:
std::string m_Name;
public:
Player(const std::string& name)
:m_Name(name){}
};
void Function(Printable* p) {
std::cout << p->PrintName() << std::endl;
}
int main() {
Entity* e = new Entity;
Player* p = new Player("Cherno");
Function(e);
Function(p);
std::cin.get();
}
如果我们这样做,那么输出的结果如下所示:
我们再一次遇到了打印的都是Entity的情况,因为我们目前只是在Entity类当中实现了这个函数,但是在Player类当中并没有,所以我们重新写一下这两个类:
class Entity : public Printable {
public:
virtual std::string PrintName() override { return "Entity"; };
};
class Player : public Entity {
public:
std::string m_Name;
public:
Player(const std::string& name)
:m_Name(name){}
std::string PrintName() override {
return m_Name;
}
};
这样我们打印出来的结果就是正确的了。
需要注意一个细节就是,其实在Entity类当中,我们无需将PrintName声明为虚函数,因为我们的输入其实是Printable类型,所以它自动的就会去找对应子类当中的实现方式。但是最后还是要注意的是,如果不定义父类当中的纯虚函数,那么我们就无法实例化。
以上就是关于虚函数和纯虚函数的全部内容了,希望大家喜欢!