C++的运行时多态

Last Updated: 2024-01-02 11:09:40 Tuesday

-- TOC --

polymorphism,多态,一个接口,多种实现。本文尝试总结C++的运行时多态(runtime polymorphism)。

有人说,没有继承,就没有多态。这句话针对的是运行时多态,因此只说对了一半。另一半是编译时多态(compile-time polymorphism),即著名的C++模板。

Virtual and Override

我们可以想象一下,有一个对象A,对象B和C都继承自A。A有个接口,比如do_job,干活。B和C分别重写(override)了这个do_job接口,override的时候,(必须)保持do_job接口签名(所有参数和属性)不变,但是里面的实现代码已经不一样了。在main中,我们要让ABC都干活,调用各自的do_job接口的代码,可以完全一样。这就是多态。

上面的描述,隐藏了一个重要细节:类型为基类的指针,可以指向子类,但反过来不行;类型为基类的引用,也可以赋值到子类,但反过来不行。这个定义,为实现多态,扫清了障碍。

子类包含所有基类的public接口,因此通过基类的指针或引用调用的接口,子类都肯定存在这些接口。但反过来不成立。

下面是一段简单的测试代码:

#include <cstdio>

struct A {
    virtual void do_job() {
        printf("A is doing A's job.\n");
    }
};

struct B: A {
    virtual void do_job() override {  // virtual keyword is not necessary
        printf("B is doing B's job.\n");
    }
};

struct C: A {
    void do_job() {  // override can be omitted, but is discouraged.
        printf("C is doing C's job.\n");
    }
};

void start_do_job(struct A &abc) {
    /* one interface, multiply implementation */
    abc.do_job();
}

// overload start_do_job with pointer argument
void start_do_job(struct A *abc) {
    /* one interface, multiply implementation */
    abc->do_job();
}

int main(void) {
    A a{};
    B b{};
    C c{};

    start_do_job(a);
    start_do_job(b);
    start_do_job(c);

    start_do_job(&a);
    start_do_job(&b);
    start_do_job(&c);

    return 0;
}

virtual申明的接口,可以在子类被override。这个override关键词虽然可以省略,但是强烈建议保留,override关键词可以让编译器检查接口的参数和属性是否完全一致(这里很容易出错),还可以增加代码的可读性。

代码运行效果:

$ g++ -Wall -Wextra test_pm.cpp -o test_pm
$ ./test_pm
A is doing A's job.
B is doing B's job.
C is doing C's job.
A is doing A's job.
B is doing B's job.
C is doing C's job.

这段测试代码的设计风格,有一个术语,叫做:Implementation Inheritance。含有virtual接口的类,叫做抽象类。这表示,ABC都是可以实例化的class,子类自己考虑是否override父类的virtual接口。Implementation Inheritance表示,继承了接口和default implementation,派生类是否要override接口并重新写implementation,自己看情况。

貌似Implementation Inheritance这种风格已经不流行了...看到一些中文资料说,Implementation Inheritance容易出错,比如一个派生类可能忘记了override,但编译和运行(调用父类的实现)都正常,但埋下了潜在的很隐蔽的bug。

Implementation inheritance allows you to build hierarchies of classes; each child inherits functionality from its parents. Over the years, accumulated experience with implementation inheritance has convinced many that it’s an anti­-pattern. For example, Go and Rust -- two new and increasingly popular system ­programming languages -- have zero support for implementation inheritance.

Pure Virtual

另一种运行时多态风格,叫做Interface Inheritance,interface的另一个名称是pure-virtual-class纯虚类,纯虚类中只需要定义纯虚接口,纯虚类不可以实例化,只能被继承。派生类会被强制自己实现纯虚接口。

将上面的测试代码修改为使用纯虚类,如下:

#include <cstdio>

struct A {
    virtual void do_job() = 0;  // pure virtual
};

struct B: A {
    void do_job() override {
        printf("B is doing B's job.\n");
    }
};

struct C: A {
    void do_job() override {
        printf("C is doing C's job.\n");
    }
};

void start_do_job(struct A &abc) {
    /* one interface, multiply implementation */
    abc.do_job();
}

void start_do_job(struct A *abc) {
    /* one interface, multiply implementation */
    abc->do_job();
}

int main(void) {
    B b{};
    C c{};

    start_do_job(b);
    start_do_job(c);

    start_do_job(&b);
    start_do_job(&c);

    return 0;
}

代码不可以实例化A,会报错。纯虚函数接口也不能定义implementation。

Virtual Destructor

下面是一个interface inheritance,我们讨论一下destructor的调用:

#include <cstdio>

struct A {
    virtual void do_job() = 0;
    virtual ~A() = default; // must be default,
                            // '=default' can be omitted.
};

struct B: A {
    void do_job() {
        printf("B is doing B's job.\n");
    }
    ~B() {
        printf("B destructor\n");
    }
};

void start_do_job(struct A *abc) {
    /* one interface, multiply implementation */
    abc->do_job();
}

int main(void) {
    A *ab{new B{}};
    start_do_job(ab);
    delete ab;
    return 0;
}

如果A不定义那个virtual destructor,代码编译就会出现一个warning,执行的时候,B定义的destructor得不到调用。这种情况如果不加注意,可能会造成内存泄漏。

A不能被实例化,但是A类型的指针,却可以指向A的子类,当delete A类型的指针的时候,由于destructor不是virtual,编译器默认会选择A的destructor,此时B的destructor得不到调用。这种行为很奇怪,不能被实例化的A,却可以这样变相地被错误地调用析构函数,只是因为没有显示地定义为virtual。而且,virtual destructor不能写成=0,这可能是因为destrucotor是个特殊的接口,只能用default来定义吧。

通过类型找成员函数,这个逻辑没错。如果这个成员函数接口是virtual的,才会涉及虚函数表vtable。

Design Pattern

有一种设计风格,叫做Object Composition,即在一个对象中,包含需要的其它对象,没有继承关系。比如A中包含B和C,B和C都分别实现了do_job接口,A根据情况,分别调用B和C的这个同名接口干活。这种设计风格会导致代码量较大,而且修改起来也很不方便,要修改的点很多。(可能会引入enum类型的变量来做switch...case...)

介绍两个术语:constructor injectionproperty injection

有了多态和申明为基类的指针或引用的接口参数,我们就可以将C++代码写的更优雅。

所谓constructor injection,就是在创建对象的时候,将某个其它对象的引用传入,接口使用其它对象的基类作引用申明,然后就可以在其它代码中使用这个其它对象。使用引用的好处是,它不可以被修改成别的对象,部分代码可以不用判断直接调用其接口。而限制就是在包含子类引用的这个对象的生命周期中,这个子类不可改变。

而如果需要改变其它对象(这些对象有共同的基类,即共同的接口),就需要使用申明为其它对象基类的指针,对象需要提供一个接口,能够改变这个指针的指向,这就是property injection

Whichever approach you choose, the combination of interface inheritance and composition pro­vides sufficient flexibility for most runtime polymorphic applications. You can achieve type­safe runtime polymorphism with little or no overhead. Interfaces encourage encapsulation and loosely coupled design. With simple, focused interfaces, you can encourage code reuse by making your code portable across projects.

静态/动态绑定

Virtual functions can incur runtime overhead, although the cost is typically low (within 25 percent of a regular function call). The compiler generates virtual function tables (vtables) that contain function pointers. At runtime, a consumer of an interface doesn’t generally know its underlying type, but it knows how to invoke the interface’s methods (thanks to the vtable). In some circumstances, the linker can detect all uses of an interface and devirtualize a function call. This removes the function call from the vtable and thus eliminates associated runtime cost. (也有些可以devirtualize的情况)

通过汇编分析vtable本质

override non-virtual interface

override virtual interface,需要保持接口所有参数和属性的完全一致,建议保留override关键字,让编译器去做检查。而override non-virtual interface,当然也是可行,也没有什么限制。下面给出一例:

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

struct xyz {
    int a;
    xyz(int a):
        a{a} {}
    void show(void) {
        cout << a << endl;
    }
};

struct xxx: xyz {
    void show(int b) {
        cout << "xxx " << a << " " << b << endl;
        xyz::show();
    }
};

int main(void) {
    xyz x{3};
    x.show();
    xxx y{6};
    y.show(9);

    xyz* z = new xxx{11};
    //z->show(22);
    z->show();  // z's type is xyz, so xyz::show is called.

    return 0;
}

xxx继承xyz,它俩都实现了show这个non-virtual接口。输出:

3
xxx 6 9
6
11

注意:

  1. z对象调用的show接口,是xyz中定义的,这是因为non-virtual interface是静态绑定。(根据类型找成员接口)
  2. xxx::show接口中,可以向上调用父类的接口。
  3. 据说《Effective C++》中建议:never redefine an inherited non-virtual function。

在一个类中声明一个非虚函数实际上为这个类建立了一种特殊的不变性,因为它表示的是不会改变的行为 ---- 不管一个派生类有多特殊,声明非虚函数的目的在于,使派生类继承函数的接口和强制性实现。

final的作用

identifier final有两个作用:

阻止一个method被子类override,或者阻止一个类被继承。

struct BostonCorbett {
    virtual void shoot() final {
        printf("What a God we have...God avenged Abraham Lincoln");
    }
};

struct BostonCorbett final{
    //...
}

既然shoot不能被override,为什么还要将其定义为virtual呢?

本文链接:https://cs.pynote.net/sf/c/cpp/202209051/

-- EOF --

-- MORE --