一、静态编联与动态编联
1、什么是静态编联与动态编联?
将源代码中的函数调用解释为执行特定的函数代码块被称为函数编联。在C语言中这很简单,因为每个函数名都对应一个函数。在C++中,由于函数重载的原因,这项任务更复杂。编译器必须查看函数名与函数参数才能决定调用哪个函数。C/C++编译器可以在编译过程完成这种编联。在编译过程中进行编联称为静态编联(static binding),又称为早期编联。然而,C++中的虚函数使这项工作变的更加的复杂。使用虚函数时,使用哪一个函数不是在编译器确定的,因为编译器不知道用户选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态编联(dynamic binding),又称为晚期编联。
2、指针与引用类型的兼容性
通常C++不允许将一种类型的地址赋给另一种类型的指针或引用,下面的做法编译时都会出错:
int num = 10;
double *p = #
double *rnum = num;
但是指向基类的指针或引用可以指向派生类对象,而不必进行显示类型转换
Person *p;
Student s("student", "11041722");
p = &s;
3、向上类型转换
把一个派生类指针,转换成基类指针,称为向上类型转换。向上类型转换是安全的,也不需要进行强制类型转换。因为在派生类对象中,包含一个基类对象实例,向上类型转化可以理解为,把指向派生类对象的指针转换成指向派生类对象中包含的基类对象。
lass Base{
private:
int b;
public:
Base(int b){
this->b = b;
}
virtual void test(){
cout<<"Base test."<<endl;
}
};
class Derived : public Base{
private:
int d;
public:
Derived(int b, int d):Base::Base(b){
this->d = d;
}
virtual void test(){
cout<<"Derived test."<<endl;
}
};
int main(){
Derived derivedObj(10, 100);
Base baseObj = derivedObj; //直接赋值
baseObj.test();
Derived *pDerivedObj = new Derived(10, 100);
Base *pBaseObj = pDerivedObj; //使用引用赋值
pBaseObj->test();
}
4、向下类型转换
把一个基类指针转换成派生类指针,称为向下类型转换。向下类型转换存在安全隐患,必须进行强制类型转换,因为在派生类对象中可能会包含基类对象没有的成员函数或数据成员。如果指向基类对象的指针实际指向的是派生类对象,则向下类型转换可以成功;否则,不能成功。
class Base{
private:
int b;
public:
Base(int b){
this->b = b;
}
virtual void test(){
cout<<"Base test."<<endl;
}
};
class Derived : public Base{
private:
int d;
public:
Derived(int b, int d):Base::Base(b){
this->d = d;
}
virtual void test(){
cout<<"Derived test."<<endl;
}
};
int main(){
Base *pBaseObj = new Derived(10, 100); //基类对象指针实际指向的是一个派生类对象
Derived *pDerivedObj = dynamic_cast<Derived*>(pBaseObj);
if(pDerivedObj){
pDerivedObj->test();
} else {
cout<<"std::bad_cast"<<endl;
}
Base *pBase = new Base(10); //基类对象指针指向的是一个基类对象
Derived *pDerived = dynamic_cast<Derived*>(pBase);
if(pDerived){
pDerived->test();
} else {
cout<<"std::bad_cast"<<endl;
}
}
注意:如果基类指针实际指向的是派生类对象,则向下类型转换可以成功;如果基类指针实际指向的是基类对象,则向下类型转换不能成功。
5、虚成员函数与动态编联
Person *p;
Student s("student", "11041722");
p = &s;
p->show();
如果在基类中没有把show()函数声明为虚函数,则p->show()将根据指针类型(Person*)调用Person::show()。指针类型在编译时已知,因此编译器在编译时,可以将show()关联到Person::show()。总之,编译器对非虚函数使用静态编联。如果在基类中把show()函数声明为虚函数,则p->show()将根据对象的类型(Student)调用Student::show()。通常,只有在运行程序时才能确定对象的类型。所以编译器生成的代码将在程序执行时,根据对象类型将show()关联到Person::show()或Student::show()。总之,编译器对虚函数使用动态编联。
二、多态
1、什么是多态?
多态(polymorphism)的字面意思是多种表现形式,多态性可以简单地概括为”一个接口,多种方法”,程序在运行时才决定调用的函数,换句话说,方法的行为应取决于调用方法的对象,它是面向对象编程领域的核心概念。多态的目的是为了实现接口重用,也就是说,不论传递过来的究竟是那个类的对象,函数都能够通过同一个接口调用到对应于各自对象的实现方法。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时绑定,要么试图做到运行时绑定。因此C++的多态分为静态多态(编译时多态)和动态多态(运行时多态)两大类。静态多态通过重载、模板来实现;动态多态就是通过本文的主角虚函数来体现的。
2、虚函数
虚函数是实现多态的重要机制
class Person{
protected:
string name;
public:
Person():name("default"){}
Person(const string &name):name(name){}
virtual void show(){
cout<<"name: "<<name<<endl<<endl;
}
};
class Student : public Person{
protected:
string number;
public:
Student():number("000000"){}
Student(const string &name, const string &number):Person(name), number(number){}
virtual void show(){
cout<<"name: "<<name<<endl;
cout<<"number: "<<number<<endl<<endl;
}
};
虚函数的特点
- 当通过对象指针或引用调用虚函数时,会根据指针或引用实际指向的对象的类型来选择方法(动态编联)
- 在基类的方法声明中使用关键字virtual可使该方法在基类以及所有的派生类中是虚的。
注意:关键字virtual只用于类声明的方法原型中,而没有用于方法的定义中。
3、虚函数的工作原理
道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员,隐藏成员是一个指向虚函数表的指针,虚函数表中存放虚函数的地址。调用虚函数时,程序将通过对象中的vptr指针,找到虚函数表,然后在虚函数表中查找要调用的函数的地址。
4、继承时虚函数表的样子
4.1、单继承(无虚函数覆盖)
下面是一个类的继承图,以及派生类的虚函数表:
通过上面的图可以分析出下面两点:
- 虚函数按照其声明顺序放于表中
- 父类的虚函数在子类的虚函数前面
4.2、单继承(有虚函数覆盖)
下面是一个类的继承图,以及派生类的虚函数表:
通过上面的图可以分析出下面两点:
- 覆盖的f()函数被放到了虚表中原来父类虚函数的位置
- 没有被覆盖的函数依旧
4.3、多继承(无虚函数覆盖)
下面是一个类的继承图,以及派生类的虚函数表:
通过上面的图可以分析出下面两点:
- 每个父类都有自己的虚函数表
- 子类的虚函数放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数
4.4、多继承(有虚函数覆盖)
下面是一个类的继承图,以及派生类的虚函数表:
通过上面两图可以分析出
- 三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,就可以任一静态类型的父类来指向子类,并调用子类的f()
5、虚函数调用过程
当调用一个虚函数时,首先根据指针或引用实际指向的对象的内存中的vptr找到虚函数表vtbl,接着通过vtbl找到对应虚函数的实现区域并进行调用。当一个类声明了虚函数或者继承了虚函数,这个类就会有自己的vtbl,vtbl核心就是一个函数指针数组。vtbl数组中的每一个元素对应一个函数指针指向该类的一个虚函数,同时该类的每一个对象都会包含一个vptr,vptr指向该vtbl的地址。
6、使用虚函数带来的开销
使用虚函数时,在内存与执行速度方面都有一定的开销。虽然非虚函数的效率比虚函数高,但是不具有动态编联功能。
- 每个对象都将增大,增大量为存储隐藏成员(是一个指针)的空间。
- 对于每一个类,编译器都将创建一个虚函数表。
- 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。
7、使用虚函数应注意的问题
- 内联函数不能是虚函数。虚函数用于实现运行时的多态,或者称为晚绑定或动态绑定。而内联函数用于提高效率。内联函数的原理是,在编译期间,对调用内联函数的地方的代码替换成函数代码。内联函数与虚函数的实现原理不同,因此,内联函数不能声明为虚函数。
- 静态成员函数不能是虚函数。static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。此外静态与非静态成员函数之间有一个主要的区别,那就是静态成员函数没有this指针,从而导致两者调用方式不同。虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable。虚函数的调用关系:this -> vptr -> vtable ->virtual function,对于静态成员函数,它没有this指针,所以无法访问vptr. 这就是为何static函数不能为virtual。
- 构造函数不能是虚函数。虚函数基于虚表vtable(内存空间),而虚函数表在构造函数中进行初始化,即初始化vptr,让它指向正确的虚函数表。而在构造对象期间,虚函数表还没有被初始化,因此,不能根据vptr找到对应的虚函数,也就是说构造函数不能是虚函数。
- 析构函数应当是虚函数,除非类不用作基类。析构函数可以是虚函数,因为此时vptr已经通过构造函数初始化完成,可以在析构函数里面通过this -> vptr -> vtable ->virtual function调用对应的虚函数。为实现多态性,可以通过基类的指针或引用访问派生类的成员。也就是说,声明一个基类指针,这个基类指针可以指向派生类对象。如果析构函数不是虚函数,基类指针释放时,只会清理基类相关的资源,导致派生类申请的资源不能释放导致资源泄漏。
- 友元不能是虚函数。虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,友元没有this指针,因此,不能声明成虚函数。
8、虚函数与覆盖、隐藏
8.1、什么是覆盖(override)?
覆盖是指派生类中存在重新定义的函数,其函数名、参数列、返回值类型必须同父类中的相对应被覆盖的函数严格一致,覆盖函数和被覆盖函数只有函数体(花括号中的部分)不同,覆盖是一个类的多态性的体现。要想实现多态,派生类就要覆盖基类的函数,并且基类的虚函数前面必须有virtual关键字,如果基类对应的函数不是虚函数,那就是隐藏。下面的两种场景都不是覆盖,不体现多态性,例如:
(1)派生类修改基类的缺省的参数值
class Base
{
public:
virtual void display(std::string strShow = "I am Base class !")
{
std::cout << strShow << std::endl;
}
virtual ~Base(){}
};
class Derive: public Base
{
public:
virtual void display(std::string strShow = "I am Derive class !")
{
std::cout << strShow << std::endl;
}
virtual ~Derive(){}
};
(2)基类的成员函数是const,派生类是非const
class Base
{
public:
virtual void display() const
{
std::cout << ""I am Base class !"" << std::endl;
}
virtual ~Base(){}
};
class Derive: public Base
{
public:
virtual void display()
{
std::cout << ""I am Derive class !""<< std::endl;
}
virtual ~Derive(){}
};
8.2、什么是隐藏?
如果派生类中的成员(包括成员变量和成员函数)和基类中的成员重名,那么就会隐藏从基类继承过来的成员。所谓隐藏,就是在派生类中使用该成员(包括在定义派生类时使用,也包括通过派生类对象访问该成员)时,实际上使用的是派生类新增的成员,而不是从基类继承来的。
8.3、发生隐藏时不体现多态性(不进行动态编联)
如果派生类的虚函数隐藏了基类的虚函数,使用的是静态编联,不体现多态性,例如:
class Base
{
public:
virtual void Show(int x){
cout << "I am Base, x = " << x << endl;
}
};
class Derived : public Base
{
public:
virtual void Show(float x) {
cout << "I am Derived, x = " << x << endl;
}
};
void test(Base &obj){
int a = 10;
obj.Show(a);
float b = 20;
obj.Show(b);
}
int main()
{
Base b;
Derived d;
test(b);
test(d);
return 0;
}
输出结果
I am Base, x = 10
I am Base, x = 20
I am Base, x = 10
I am Base, x = 20
Process returned 0 (0x0) execution time : 0.014 s
Press any key to continue.
9、纯虚函数、抽象类、虚基类
9.1、纯虚函数
C++通过使用纯虚函数来提供未实现的函数,纯虚函数声明的结尾处为 = 0,下面是一个纯虚函数的声明,例如:
class Shape{
public:
virtual void draw() const = 0;
};
注意:C++允许为纯虚函数提供定义,但是调用它的唯一途径是在调用时指出其class名称,这个性质基本上不会被用到。
#include<iostream>
using namespace std;
class Shape{
public:
virtual void draw() const = 0;
};
void Shape::draw() const{
cout<<"Shape::draw()"<<endl;
}
class Rectangle : public Shape{
public:
void draw() const{
cout<<"Rectangle::draw()"<<endl;
}
};
int main(){
Rectangle *p = new Rectangle;
p->Shape::draw();
return 0;
}
9.2、抽象类
在实际的使用过程中我们发现,创建某些类的实例是没有意义的,例如:现在我们有一个动物类,创建一个动物类对象毫无意义。抽象类的唯一作用是作为派生类的基类。当一个类中包含一个纯虚函数,那么这个类就是一个抽象类。
class Shape{
public:
virtual void draw() const = 0;
};
抽象类的特点:
- 抽象类不能实例化。
- 如果派生类不实现基类的纯虚函数,那么派生类也是一个抽象类。
虚函数与纯虚函数的区别
- 虚函数必须提供函数定义,不然在编译时会出错。纯虚函数可以提供也可以不提供函数定义。
- 在许多情况下基类本身生成对象是不合理的,纯虚函数的作用就是把一个类变成抽象类,在派生类中去实现纯虚函数。使用虚函数的主要目的是为了实现多态。
- 定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的派生类必须实现这个函数
9.3、虚基类
虚基类就是一个类,只有当它被别人虚拟继承时才被称作虚基类,虚基类并不一定要有虚函数。