虚方法(virsual method)挺起来玄乎其玄,向从未听说过这个概念的人解释清楚是一件相当困难的事情。 因为这是一个很不容易理解的概念,但它在比较抽象的代码里边是不可少的。 那么既然用枯燥的文字来描述虚方法不可行,我们毅然选择走另一条路:通过一个简单的例子引发的问题来探究虚方法的作用以及完整的解决方案。
以非常熟悉的阿猫阿狗例子程序是我们这次探索的出发点。我们将使用指针代替局部变量来容纳 Pet 对象。 需要我们认识两个新的C++保留字:new和delete 前边我们已经讲解过一些关于指针的知识,说白了就是一种专门用来保存内存地址的数据类型。 以前我们常用的做法是:创建一个变量,再把这个变量的地址赋值给一个指针。然后,我们就可以没羞没臊地用指针去访问这个变量的值了。
引发问题:使用指向对象的指针
事实上在C和C++中,我们完全可以在没有创建变量的情况下为有关数据分配内存。也就是直接创建一个指针并让它指向新分配的内存块:
int *pointer = new int;//定义一个指向整型的指针pointer,用new创建一个整型的内存,即声明一个指向整型地址空间的指针pointer *pointer = 110;//赋值给new出来的内存为110 std::cout << *pointer; delete pointer;//删除指针,释放内存
最后一步非常必要和关键,这是因为程序不会自动释放内存,程序中的每一个 new 操作都必须有一个与之对应的 delete 操作!
那么我们把阿猫阿狗程序做一下改造:pet.cpp
#include <iostream> #include <string> class Pet { public: Pet(std::string theName); void eat(); void sleep(); void play(); protected: std::string name; }; class Cat : public Pet { public: Cat(std::string theName); void climb(); void play(); }; class Dog : public Pet { public: Dog(std::string theName); void bark(); void play(); }; Pet::Pet(std::string theName) { name = theName; } void Pet::eat() { std::cout << name << "正在吃东西! "; } void Pet::sleep() { std::cout << name << "正在睡大觉! "; } void Pet::play() { std::cout << name << "正在玩儿! "; } Cat::Cat(std::string theName) : Pet(theName) { } void Cat::climb() { std::cout << name << "正在爬树! "; } void Cat::play() { Pet::play(); std::cout << name << "玩毛线球! "; } Dog::Dog(std::string theName) : Pet(theName) { } void Dog::bark() { std::cout << name << "旺~旺~ "; } void Dog::play() { Pet::play(); std::cout << name << "正在追赶那只该死的猫! "; } int main() { Pet *cat = new Cat("加菲"); Pet *dog = new Dog("欧迪"); cat -> sleep(); cat -> eat(); cat -> play(); dog -> sleep(); dog -> eat(); dog -> play(); delete cat; delete dog; return 0; }
结果:
加菲正在睡大觉! 加菲正在吃东西! 加菲正在玩儿! 欧迪正在睡大觉! 欧迪正在吃东西! 欧迪正在玩儿! 请按任意键继续. . .
仔细一瞧,程序与我们的预期不符:我们在 Cat 和 Dog 类里对 play() 方法进行了覆盖,但实际上调用的是 Pet::play() 方法而不是那两个覆盖的版本。 WHY??
使用虚方法
程序之所以会有这样奇怪的行为,是因为C++的创始者希望用C++生成的代码至少和它的老前辈C一样快。
所以程序在编译的时候,编译器将检查所有的代码,在如何对某个数据进行处理和可以对该类型的数据进行何种处理之间寻找一个最佳点。
正是这一项编译时的检查影响了刚才的程序结果:cat 和 dog 在编译时都是 Pet 类型指针,编译器就认为两个指针调用的 play() 方法是 Pet::play() 方法,因为这是执行起来最快的解决方案。
而引发问题的源头就是我们使用了 new 在程序运行的时候才为 dog 和 cat 分配 Dog 类型和 Cat 类型的指针。 这些是它们在运行时才分配的类型,和它们在编译时的类型是不一样的!
为了让编译器知道它应该根据这两个指针在运行时的类型而有选择地调用正确的方法(Dog::play() 和 Cat::play()),我们必须把这些方法声明为虚方法。
声明一个虚方法的语法非常简单,只要在其原型前边加上 virtual 保留字即刻。
virtual void play();
另外,虚方法是继承的,一旦在基类里把某个方法声明为虚方法,在子类里就不可能再把它声明为一个非虚方法了。 这对于设计程序来说是一件好事,因为这可以让程序员无需顾虑一个虚方法会在某个子类里编程一个非虚方法。
使用虚方法使得程序如预期完成:pet2.cpp
#include <iostream> #include <string> class Pet { public: Pet(std::string theName); void eat(); void sleep(); virtual void play();//只有这里和上述程序不一样 protected: std::string name; }; class Cat : public Pet { public: Cat(std::string theName); void climb(); void play(); }; class Dog : public Pet { public: Dog(std::string theName); void bark(); void play(); }; Pet::Pet(std::string theName) { name = theName; } void Pet::eat() { std::cout << name << "正在吃东西! "; } void Pet::sleep() { std::cout << name << "正在睡大觉! "; } void Pet::play() { std::cout << name << "正在玩儿! "; } Cat::Cat(std::string theName) : Pet(theName) { } void Cat::climb() { std::cout << name << "正在爬树! "; } void Cat::play() { Pet::play(); std::cout << name << "玩毛线球! "; } Dog::Dog(std::string theName) : Pet(theName) { } void Dog::bark() { std::cout << name << "旺~旺~ "; } void Dog::play() { Pet::play(); std::cout << name << "正在追赶那只该死的猫! "; } int main() { Pet *cat = new Cat("加菲"); Pet *dog = new Dog("欧迪"); cat -> sleep(); cat -> eat(); cat -> play(); dog -> sleep(); dog -> eat(); dog -> play(); delete cat; delete dog; return 0; }
结果:
加菲正在睡大觉! 加菲正在吃东西! 加菲正在玩儿! 加菲玩毛线球! 欧迪正在睡大觉! 欧迪正在吃东西! 欧迪正在玩儿! 欧迪正在追赶那只该死的猫! 请按任意键继续. . .
TIPS
- 如果拿不准要不要把某个方法声明为虚方法,那麽就把它声明为虚方法好了。
- 在基类里把所有的方法都声明为虚方法会让最终生成的可执行代码的速度变得稍微慢一些,但好处是可以一劳永逸地确保程序的行为符合你的预期!
- 在实现一个多层次的类继承关系的时候,最顶级的基类应该只有虚方法。
-
有件事现在可以告诉大家了:析构器都是虚方法!从编译的角度看,它们只是普通的方法。如果它们不是虚方法,编译器就会根据它们在编译时的类型而调用那个在基类里定义的版本(构造器),那样往往会导致内存呢泄露!