C++的动态联编与虚函数

C++·语法 2019-03-08 4278 字 21 浏览 点赞

前言

函数名联编(binding):将源代码中的函数调用解释为执行特定的函数代码块的过程。

静态联编:在编译过程中进行联编叫作静态联编。

动态联编:程序运行时才选择需要执行的代码叫作动态联编。

指针和引用的兼容性

派生类引用或指针转换为基类引用或指针,称为向上强制转换,可隐式。示例如下:

class Animal {  /* 基类 */
};

class Dog: public Animal {  /* 派生类 */
};

int main() {
    Animal* dogOne = new Dog();
    /* `new Dog()`返回派生类指针,但可以赋值给基类指针`dogOne` */

    Dog dogTwo;
    Animal& dogTwoRef = dogTwo;  /* 基类引用指向了派生类对象 */
    return 0;
}

上述代码中,new Dog()返回派生类指针,但可以赋值给基类指针dogOne;作为基类引用的dogTwoRef指向了派生类对象dogTwo。编译这段代码不会报错,这是因为C++允许这样,本质上是发生了向上强制转换,体现了指针和引用的兼容性。

基类指针或引用转换为派生类指针或引用被称为向下强制转换,要求必须显式地转换。

静态联编与动态联编

设计一个类时,可以将成员函数设计为虚函数(virtual)和非虚函数,函数“虚不虚”,直接影响了编译器对代码的处理方式。这里有一个结论:编译器对非虚函数使用静态联编;对虚函数使用动态联编。

通过一个简单示例,我们看看里边区别:

class Animal {
public:
    void run() { cout << "not implemented." << endl; }  /* 非虚函数 */
    virtual void fly() { cout << "not implemented." << endl; }  /* 虚函数 */
};

class Dog: public Animal {
public:
    void run() { cout << "can run." << endl; }
    void fly() { cout << "can not fly." << endl; }
};

int main() {
    Animal* dog = new Dog();  /* 用基类指针指向派生类对象地址 */
    /* 注意run()与fly()的输出 */
    dog->run();
    dog->fly();
    return 0;
}
// 输出:
not implemented.
can not fly.

由于还不知道作为派生类的具体动物(狗、猫、鱼、鸟等)会不会飞,会不会跑,所以让基类Animal中的run()fly()直接打印“未完成”,希望后面的设计人员来设计。

现在设计了一个狗(派生类Dog),它会跑但不会飞,如上述代码那样。最后用基类指针Animal*管理派生类对象new Dog(),调用run()和fly()。可以从输出结果中看到,dog->run()调用的是基类版的run(),所以输出“not implemented.”;而dog->fly()调用的是派生类版的fly,因此打印“can not fly.”

会出现这种结果,是因为基类Animal中的run()是非虚函数,编译器静态联编,此时它将根据定义类型寻找方法,定义类型是Animal*,所以找到了基类版的run()。而fly()是虚函数,编译器动态联编,当程序执行到调用语句,它会根据对象类型寻找方法,对象类型是Dog*(new Dog()的返回对象),所以找到了派生类的fly()。

大多时候,为让代码具有多态特性,通过基类指针或引用 管理 派生类对象是常见手段。同时要让代码清晰明辨,一般会把派生类中重新定义的虚函数也标志为virtual,上述代码最好写成这样:

class Dog: public Animal {
public:
    void run() { cout << "can run." << endl; }
    virtaul void fly() { cout << "can not fly." << endl; }
};

动态联编的缺点

尽管动态联编看上去优质,然而动态联编和静态联编同时存在C++中,设计是有讲究的,主要基于以下两点:

  • 效率:动态联编需要跟踪 基类指针或引用 指向的对象类型,这将增加额外的处理开销。C++的指导原则之一是:不要为不使用的特性付出代价。并非所有的基类方法都需要多态,所以如果全部采取动态联编一定会损耗性能。
  • 概念模式:(virtual)标记出需要重新定义的函数,让代码呈现更清晰的意图。

虚函数工作原理

虚函数的一种实现机制:

  • 编译器给每个对象添加一个隐藏成员,隐藏成员中保存了一个指向虚函数表的指针;
  • 虚函数表中存储了 为类对象 进行声明的 虚函数的地址;
  • 如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;
  • 如果派生类定义了新的虚函数,该函数的地址也将添加到虚函数表中;
  • 调用虚函数时,程序会找到对象的虚函数表,然后查找相应的函数的地址,最后执行。

执行虚函数带来的成本:

  • 每个对象都将增大,增大的是存储地址的空间;
  • 对于每个类,编译器都会创建一个虚函数地址表;
  • 对于每个函数调用,都需要执行额外操作,即到虚函数表中查询函数地址。

虚函数注意项

虚函数的好处显而易见,但有些地方仍要留心注意:

  • (1)基类中用virtual修饰方法,可使该方法在基类及其所有派生类中都是虚的;
  • (2)构造函数不能是虚函数。这是因为派生类不继承基类的构造函数,所以将构造函数声明为虚函数没有意义;
  • (3)友元不能是虚函数,因为友元不是类成员,只有类成员才能使虚函数;
  • (4)最好为每一个基类提供一个虚析构函数,即便它并不需要析构函数。如果析构函数非虚,此时基类指针管理派生类对象,对该指针做delete,只会执行基类的析构函数,而不会执行派生类的,可能造成内存泄露:
class Animal {
public:
    ~Animal() { cout << "~Animal() called" << endl; }
};

class Dog: public Animal {
public:
    ~Dog() { cout << "~Dog() called" << endl; }
};

int main() {
    Animal* dog = new Dog();  /* 基类指针管理派生类对象 */
    delete dog;
    return 0;
}
// 输出:
~Animal() called  /* 不会执行 ~Dog() */
  • (5)如果派生类没有重新定义函数,使用该函数的基类版本;
  • (6)重新定义不会生成函数的两个重载版本,而是隐藏该方法的基类版本。比方基类中是void run() {...} 而派生类中是void run(bool isFast) {...}。对派生类对象来说,如果直接调用run()是会报错的,因为void run()已经被隐藏了,能被调用的是void run(bool isFast)

由第6点引出的两条经验:

  • 如果重新定义继承的方法,应确保与原来的原型完全相同。但返回类型协变除外。即,如果存在某个虚方法需要返回数据的类型是类:
class Animal {
public:
    virtual Animal get_kind();
};

此时允许返回类型跟随类改变:

class Dog: public Animal {
public:
    virtual Dog get_kind();
};
  • 如果基类声明被重载了,则应该在派生类中重新定义所的基类版本(否则派生类指针管理派生类对象时,无法使用被重载了的、其他版本的方法):
class Animal {
public:
    /* 因为重载,而存在许多版本的greet() */
    virtual void greet();
    virtual void greet(string who);
    virtual void greet(int count);
    ...
};

比较好的方式是,如果其他版本的greet()没有改动,那么直接调用基类对应版本的greet():

class Dog: public Animal {
public:
    virtual void greet() { ... }
    virtual void greet(string who) { Animal::greet(who); }
    virtual void greet(int count) { Animal::greet(count); }
    ...
};

总结

  • 需要被重新定义的基类的方法,应该被声明为虚函数;
  • 虚函数根据对象类型找方法,非虚函数根据定义类型找方法;
  • 最好为每个类都声明一个虚析构函数。


本文由 Guan 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论