C++的动态联编与虚函数 ゞ 浴缸里的玫瑰 2022-03-10 19:58 129阅读 0赞 # C++的动态联编与虚函数 # ### 文章目录 ### * C++的动态联编与虚函数 * 前言 * 指针和引用的兼容性 * 静态联编与动态联编 * 动态联编的缺点 * 虚函数工作原理 * 虚函数注意项 * 总结 # 前言 # 函数名联编(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); } ... }; # 总结 # * 需要被重新定义的基类的方法,应该被声明为虚函数; * 虚函数根据对象类型找方法,非虚函数根据定义类型找方法; * 最好为每个类都声明一个虚析构函数。
还没有评论,来说两句吧...