虚拟函数


在〈遮敝父类方法〉中看到,在继承关系下,基于 is-a,子类实例可以指定给父类类型,如果你这么做,多数情况下想要的效果是,想以一般化的方式来操作实例,无论该实例是父类或子类实例。

例如,RoleSwordsMan都具有to_string方法,执行时期透过Role来操作SwordsMan,是因为SwordsMan是一种Role,你想要的就是操作角色的to_string,而且如果SwordsMan定义了to_string,多数情况下,希望执行的是实例重新定义后的版本。

对于父类的方法,你预期它的执行时期行为会被重新定义,也就是希望在执行时期,依照实例的类型绑定对应的方法版本,可以在父类定义方法时加上virtual,例如:

#include <iostream>
#include <string> 
using namespace std;

class Role {
    ...略

    virtual string to_string() {
        return "(" + 
            this->name + ", " + 
            std::to_string(this->level) + ", " + 
            std::to_string(this->blood) + 
        ")";
    };

    virtual ~Role() = default;
};

class SwordsMan : public Role {
    using super = Role;

    ...略

    string to_string() override {
        return "SwordsMan" + super::to_string();
    };
};

class Magician : public Role {
    using super = Role;

public:

    ...略

    string to_string() override {
        return "Magician" + super::to_string();
    };
};

void printInfo(Role &role) {
    cout << role.to_string() << endl;
}

int main() { 
    SwordsMan swordsMan("Justin", 1, 1000);
    Magician magician("Magician", 1, 800);

    printInfo(swordsMan);
    printInfo(magician);

    return 0;
}

被定义为virtual的函数,若代码中透过父类类型参考或指针操作,会在执行时期才绑定要执行的版本,因此printInfo会依指定的实例,调用各自重新定义后的to_string方法,执行结果如下:

SwordsMan(Justin, 1, 1000)
Magician(Magician, 1, 800)

如果定义类时,预期会在执行时期,以父类类型操作子类实例重新定义的方法,那么该方法要设定为virtual,在试图想重新定义父类的virtual方法时,很容易因为不符合方法签署,造成实际上定义了新方法而不是重新定义方法,若想避免这种情况,C++ 11 以后可以注解override,编译器就会检查,目前定义的方法是否真的是重新定义了父类的virtual方法。

父类中的方法若被标示virtual,子类重新定义方法时自然就会是virtual,因此重新定义时可以基于阅读上的方便性,自行选择是否注解virtual

若类中有方法被标示为virtual,编译器会隐含地在类中加入虚拟方法表(virtual method table),表中的指针用来指向被标示为virtual的方法,如果子类重新定义了virtual方法,子类的虚拟方法表中该方法的指针,会指向重新定义的方法,继承下来而没有被重新定义的virtual方法,该方法的指针会指向父类定义的virtual方法。

在这边的printInfo,限定必须得是Role的子类实例,因为是以父类观点来操作子类实例,被称为子类型多态(subtype polymorphism),因为是执行时期才有virtual方法的地址,也就是执行时期才能决定绑定的方法,又称为执行时期多态(runtime polymorphism)。

乍看之下,〈遮敝父类方法〉中谈到的编译时期多态,与这边谈到的执行时期多态,似乎有很大的重叠性,区别就只是编译时期或执行时期绑定?例如,单就「显示角色信息」来说,这边的printInfo与〈遮敝父类方法〉中的printInfo,似乎都可以解决需求?

不过,你要再厘清需求,「显示角色信息」表示你要接受的对象是「角色」,而不是具有to_string的任何对象,如果需求是「显示具有to_string对象的信息」,你要使用的是模版。

另一个用来厘清需求的方式是,定义virtual方法时可以完全不实现,也就是执行时期,这类方法在虚拟方法表中的指针,可以指向nullptr,这类方法称为纯虚拟方法,也被称为抽象方法,这之后再来谈。

在范例中Role的析构函数也被定义为virtual了,这表示执行时期才会决定使用哪个版本的解构器,这影响的会是动态创建Role的子类实例后,以delete删除该实例,会执行的是哪个版本的析构函数。例如:

Role *role = new SwordsMan("Justin", 1, 1000);
delete role;

如果Role的析构函数不是virtual,那么role会在编译时期就绑定Role定义的析构函数,delete role执行的就只会是Role定义的析构函数,这通常不会是你想要的结果,如果Role的析构函数是virtualrole是在执行时期,依实例类型绑定析构函数,就这边就是SwordsMan的析构函数,因此delete role执行的就会是SwordsMan定义的析构函数,接着是Role的析构函数。

绝大多数情况下,子类实例解决时,当然也想要执行子类的析构函数,析构函数默认并不是virtual,因此若定义的类,是会被用来继承的基类,应该定义析构函数为virtual

如果不希望方法被子类重新定义,可以定义方法为finalvirtual,例如:

class Foo {
public:
    virtual void foo() final {
        cout << "foo" << endl;
    }
};

这么一来,子类就不能定义foo方法了,如果类不希望有子类,可以定义类为final

class Foo final {
};

若非透过父类类型参考或指针操作,只是透过复制构造函数构造了父类实例罢了。例如:

SwordsMan swordsMan("Justin", 1, 1000);
Role role = swordsMan;
cout << role.to_string() << endl;

这边的role实际上是创建了Role实例,而不是参考了SwordsMan实例,类似地,以下也不是:

SwordsMan swordsMan("Justin", 1, 1000);
Role role("role", 0, 0);
role = swordsMan;

也就是说,如果想使用执行时期多态,必须透过参考或指针来操作。

父类类型可以参考子类类型实例,反过来则不行,例如:

SwordsMan swordsMan("Justin", 1, 1000);
Role &role = swordsMan;
SwordsMan &swordsMan2 = role; // 编译错误

道理很简单,SwordsMan一定是一种Role,然而Role未必是SwordsMan,当然,就上例来说,role参考的确实是SwordsMan实例,虽然不鼓励,不过还是可以明确地转换类型:

SwordsMan swordsMan("Justin", 1, 1000);
Role &role = swordsMan;
SwordsMan &swordsMan2 = dynamic_cast<SwordsMan&>(role);

类似地,指针也可以明确地转换类型:

SwordsMan *swordsMan = new SwordsMan("Justin", 1, 1000);
Role *role = swordsMan;
SwordsMan *swordsMan2 = dynamic_cast<SwordsMan*>(role);

dynamic_cast用于告知编译器,你就是要将父类的参考或指针向下转型为子类类型,这不单只是要编译器住嘴,继承体系中必须有virtual函数的存在,实际的类型转换会在执行时期进行,确定转换目标与来源是否有类阶层关系,如果是个指针,转换成功时返回地址,失败的话会返回nullptr,如果是参考的话,转换失败会丢出bad_cast异常,这令执行时期的转换失败,会有机会进行处理,如果你使用static_cast,虽然可以令编译器住嘴,然而错误的转换会有什么结果就无法预期了。


展开阅读全文