前言
函数名联编(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); } ... };
总结
- 需要被重新定义的基类的方法,应该被声明为虚函数;
- 虚函数根据对象类型找方法,非虚函数根据定义类型找方法;
- 最好为每个类都声明一个虚析构函数。
还不快抢沙发