文章目录
- 多态
- 虚函数
- 重写
- 重定义(参数不同)
- 协变(返回值不同)
- 析构函数重写(函数名不同)
- final和override
- 重载、重写、重定义
- 抽象类
- 多态的原理
- 虚函数
- 常见问题解析
- 虚函数表
多态
一种事物,多种形态。换言之,对于同一个行为,不同的对象去完成就会产生不同的结果。
多态的构成条件
多态是继承体系中的一个行为,如果要在继承体系中构成多态,需要满足两个条件:
-
必须通过基类的指针or引用调用虚函数
-
被调用的函数必须是虚函数,并且派生类必须要对继承的基类的虚函数进行重写
虚函数
虚函数就是被 virtual
修饰的类成员函数 (这里的 virtual
和虚继承的 virtual
虽然是同一个关键字,但是作用不一样)。
- 任何构造函数之外的非静态函数都可以是虚函数。
- 关键字
virtual
只能出现在类内部的声明语句之前而不能用于类外部的函数定义。 - 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
重写
一般情况下,当派生类中有一个和基类完全相同的虚函数(函数名、返回值、参数完全相同),则说明子类的虚函数重写了基类的虚函数。
class Human
{
public:virtual void print(){cout << "i am a human" << endl;}
};class Student : public Human
{
public:virtual void print(){cout << "i am a student" << endl;}
};void ShowIdentity(Human &human) // 形参是基类引用,构成多态
{human.print(); // 被调用的函数是虚函数
}int main()
{Human h;Student s;ShowIdentity(h); ShowIdentity(s);
}
通常如果不满足函数名、返回值、参数完全相同,则不构成重写,即无法实现多态。但也有例外:
重定义(参数不同)
参数不同则会变成重定义:
class Base{
public:virtual void Show(int n = 10)const{ // 提供缺省参数值std::cout << "Base:" << n << std::endl;}
};class Base1 : public Base{
public:virtual void Show(int n = 20)const{ // 重新定义继承而来的缺省参数值std::cout << "Base1:" << n << std::endl;}
};int main(){Base* p1 = new Base1; p1->Show(); return 0;
}
如果子类重写了缺省值,此时的子类的缺省值是无效的,使用的还是父类的缺省值。
因为虚函数是动态绑定,而缺省值是静态绑定。
- 对于
p1
,他的静态类型即指针的类型——Base
,所以这里的缺省值是Base
的缺省值。 - 而动态类型也就是指向的对象是
Base1
,所以这里调用的虚函数则是Base1
中的虚函数。 - 调用了
Base1
中的虚函数,Base
中的缺省值,因此输出Base1:10
。
或者可以更简单的一句话描述,虚函数的重写只重写函数实现,不重写缺省值。
动态绑定和静态绑定
- 对象的静态类型:对象在声明时采用的类型。是在编译期确定的。(比如上面的
p1
,Base
是静态类型,指向的对象的类型Base1
是动态类型) - 对象的动态类型:目前所指对象的类型。是在运行期决定的。
对象的动态类型可以更改,但是静态类型无法更改。
- 静态绑定:绑定的是对象的静态类型,发生在编译期。
- 动态绑定:绑定的是对象的动态类型,发生在运行期。
协变(返回值不同)
当基类和派生类的返回值类型不同时,如果基类对象返回基类的 引用or指针
,派生类对象返回的是派生类的 引用or指针
,也能实现多态。这样实现多态的方式叫协变。
class Human
{
public:virtual Human& print(){cout << "i am a human" << endl;return *this;}
};class Student : public Human
{
public:virtual Student& print(){cout << "i am a student" << endl;return *this;}
};
但如果返回类型不是对应类的 指针or引用
,则不足以构成协变:
析构函数重写(函数名不同)
在特定条件下,函数名不同也能实现多态,最好的例子是析构函数,编译器为了让析构函数实现多态,会将它的名字处理成destructor
,也就是说,实际上析构函数的函数名也是“相同的”,其多态实现遵循重写的规定。
class Human
{
public:~Human(){cout << "~Human()" << endl;}
};class Student : public Human
{
public:~Student(){cout << "~Student()" << endl;}
};
可以看到,如果不构成多态,那么指针是什么类型的,就会调用什么类型的析构函数。那么,如果派生类的析构函数中有资源释放的操作,而这里却没有释放掉那些资源,就会导致内存泄漏的问题。
所以为了防止这种情况,必须要将析构函数定义为虚函数。这也就是编译器将析构函数重命名为 destructor
的原因:
final和override
final
和 override
是 C++11
中提供给用户用来检测是否进行重写的两个关键字。
final
使用 final
修饰的基类虚函数不能被重写。
如果虚函数不想被派生类重写,就可以用 final
来修饰这个虚函数:
override
override
关键字是用来检测派生类虚函数是否构成重写的关键字。C++11
允许派生类显式地注明它覆盖了继承基类的虚函数。
在我们写代码的时候难免会出现些小错误,如 基类虚函数没有 virtual
或者 派生类虚函数名拼错
等问题,这些问题不会被编译器检查出来,发生错误时也很难一下子锁定,所以 C++
增添了 override
这一层保险,当修饰的虚函数不构成重写时就会编译错误。
具体做法是在:
- 形参列表后面
- 或者
const
成员函数的const
关键字后面 - 或者 引用成员函数的引用限定符后面
加一个关键字 override
。
下例中,基类虚函数没有 virtual
因此会报错:
重载、重写、重定义
重载:
- 在同一作用域
- 函数名相同,参数的类型、顺序、数量不同。
- 重载不一定要求返回值相同:参数相同、返回值不同不构成重载;参数、返回值都不同则构成重载。(会发现返回值不同是否构成重载还是看参数相同与否……)
重写(覆盖):
- 作用域不同,一个在基类一个在派生类。
- 函数名,参数,返回值必须相同(协变和析构函数除外)
- 基类和派生类必须都是虚函数(派生类可以不加
virtual
,基类的虚函数属性可以继承,但是最好要加上virtual
)
考虑这样一个问题,下面有几个虚函数:
正确答案是 3
个,A 中的 fun1
,B 中的 fun1
、fun2
。原因就如第三点所说,基类的虚函数属性可以继承 ,但是如果有 C类
继承了 B类
,且也有一个 没有virtual关键字的 void fun1(); 函数
,该函数并不是虚函数,因为 B类
的 fun1
并没有显式声明 virtual
属性。
而形如 fun2
这样的,子类是虚函数而父类没有 virtual
属性的,父类的 fun2
不是虚函数,虚函数不具备对称性。
重定义(隐藏):
- 作用域不同,一个在基类一个在派生类
- 函数名相同
- 派生类和基类同名函数如果不构成重写那就是重定义
重定义无法覆盖虚函数,只能覆盖普通函数,但是父类被覆盖的普通函数可以通过作用域运算符调用:
class A
{
public:virtual void f2(){cout << "A.f2()" << endl;}
};
class B :public A {
public:void f2(int){cout << "B.f2(int)" << endl;}
};
class C:public B{
public:// C类中的两个f2函数互相构成重载,但又分别构成重定义和重写void f2() { // 重写了A类中的虚函数f2()cout << "C.f2()" << endl;}void f2(int) { // 重定义了B类中的普通函数f2(int)cout << "C.f2(int)" << endl;}
};
抽象类
如果在虚函数的后面加上 =0
,并且不进行实现,这样的虚函数就叫做纯虚函数。而包含纯虚函数的类,也叫做抽象(基)类或者接口类。抽象类不能实例化出对象,因为他所具有的信息不足以描述一个对象,派生类继承后也只有在重写纯虚函数后才能实例化出对象。
我们也可以对纯虚函数提供定义,不过函数体必须在类的外部。
抽象类就像是一个蓝图,为派生类描述好一个大概的架构,派生类必须实现完这些架构,至于要在这些架构上面做些什么,增加什么,就属于派生类自己的问题。
class Human
{
public:virtual void print() = 0;
};class Student : public Human
{
public:virtual void print(){cout << "i am a student" << endl;}
};class Teacher : public Human
{
public:virtual void print(){cout << "i am a teacher" << endl;}
};void ShowIdentity(Human& human)
{human.print();
}
- 普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
- 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
虚函数
class Human
{
public:virtual void print(){cout << "i am a human" << endl;}virtual void test1(){cout << "1test1" << endl;}void test2(){cout << "1test1" << endl;}int _age;
};class Student : public Human
{
public:virtual void print() {cout << "i am a student" << endl;}void test2(){cout << "2test2" << endl;}int _stuNum;
};
我们创建一个 Human
类对象 h
,观察它的大小,按理来说应该输出 4
,因为它只有一个 int类型
的数据成员,但是结果却是 8
。
可以看到奇怪的是除了 _age
之外,还有个 void**(void*类型的指针,注意不是数组)
类型的 _vfptr
,这个 _vfptr
也被称为虚函数表指针,其指向了一个函数指针数组,这个函数指针数组也就是虚函数表,其中的每一个成员指针指向的都是虚函数,而不是虚函数的 test2
则没有被放入表中。
此时再创建一个
Student
类的对象s
,进一步观察:
我们可以看到,如果派生类实现了某个虚函数的重写,那么在派生类的虚函数表中,重写的虚函数就会覆盖掉原有的函数,如Student::print
。而没有完成重写的 test1
则依旧保留着从基类继承下来的虚函数 Human::test1
。
总结
- 派生类会继承基类的虚函数表,如果派生类完成了重写,则会将重写的虚函数覆盖掉原有的函数。
- 指针或引用指向哪一个对象,就调用对象中虚函数表中对应位置的虚函数,来实现多态。
继续分析构成多态的另一个条件,为什么必须要指针或者引用才能构成多态?
- 如果将派生类对象赋值给基类对象,会因为对象切割,导致他的内存布局整个被修改,完全转换为基类对象的类型,虚函数表也与基类相同,所以不能实现多态。
- 如果用基类指针或者引用指向派生类对象,他们的内存布局是兼容的,不会像赋值一样改变派生类对象的内存结构,所以派生类对象的虚函数表得到了保留,所以他可以通过访问派生类对象的虚函数表来实现多态。
总结一下派生类虚函数表的生成过程:
- 首先派生类会将基类的虚函数表拷贝过来
- 如果派生类完成了对虚函数的重写,则用重写后的虚函数覆盖掉虚函数表中继承下来的基类虚函数
- 如果派生类自己又新增了虚函数,则添加在虚函数表的最后面
常见问题解析
内联函数可以是虚函数吗?
不可以,内联函数没有地址,无法放进虚函数表中。
静态成员函数可以是虚函数吗?
不可以,静态成员函数没有 this指针
,无法访问虚函数表。
构造函数可以是虚函数吗?
不可以,虚函数表指针也是对象的成员之一,是在构造函数初始化列表初始化时才生成的
析构函数可以是虚函数吗?
可以,上面有写,最好把基类析构函数声明为虚函数,防止使用基类指针或者引用指向派生类对象时,派生类的析构函数没有调用,可能导致内存泄漏。
对象访问虚函数快还是普通函数快?
- 如果不构成多态,虚函数和普通函数的访问是一样快的,都是直接在编译时从符号表中找到函数的地址后调用。
- 如果构成多态,调用虚函数就得在运行期到虚函数表中查找,就会导致速度变慢,所以普通函数更快一些。
虚函数表
从上面的观察可以看出来,虚函数存于虚函数表中,那么虚函数表又存储在哪里呢?
虚函数表在编译阶段生成,存储于代码段。
详情可以看这篇博客。
注意:
- 同一个类的不同实例(对象)共用同一份虚函数表。
- 子类
特有的虚函数
会加在父类虚函数表
中的父类虚函数的后面
。 - 如果子类继承多个父类、且这些父类都有虚函数表,
子类特有的虚函数
加在第一个父类的虚函数表
中。 - 如果子类继承多个父类、但只有部分父类有虚函数表,
子类特有的虚函数
加在第一个有虚函数表的父类
的虚函数表
中。 - 如果子类继承多个父类、且这些父类都没有虚函数表,子类会自己创建一个虚函数表来存储特有的虚函数。