目录
一、多态
引入:
多态就是函数调用的多种形态。使得我们的程序更加灵活。
多态的优点:1、代码组织结构清晰 2、代码可读性强 3、利于前期和后期的拓展维护,
符合对拓展开放,对修改进行关闭的原则。
多态分为:静态多态和动态多态
可以使用多态的例子:
“动物” 这个名词是个抽象的概念,不存在“动物”这种动物。而小猫,小狗都属于动物。我
们假设动物都可以叫,只是叫声不同而已,那么我们就可以利用C++多态的特性去实现。
1. 静态多态
静态多态:指程序在编译阶段就可以确定函数的地址。
静态多态包括函数重载,运算符重载等,因为它们的实现都是通过函数进行实现的,并且它们的共同点是在编译阶段就可以确定函数的地址。
2. 动态多态
动态多态:指程序在运行后才可以确定函数的地址。
注意:多态离不开继承
#include <iostream>
using namespace std;
#if 1
class Animal // 动物
{
public:
virtual void Call()
{
std::cout << "动物 在叫" << std::endl;
}
};
class Cat_Animal : public Animal // 猫
{
public:
virtual void Call()
{
std::cout << "小猫在叫" << std::endl;
}
};
void func1(Animal a)
{
a.Call();
}
void func2(Animal *a_ptr)
{
a_ptr->Call();
}
void func3(Animal &a_ref)
{
a_ref.Call();
}
void test01()
{
std::cout << "sizeof(Animal) = " << sizeof(Animal) << std::endl;
std::cout << "sizeof(Cat_Animal) = " << sizeof(Cat_Animal) << std::endl;
Animal animal_1;
Cat_Animal cat_1;
func1(animal_1);
func1(cat_1); // 父类只会拷贝子类中的父类属性,不会覆盖虚表中的指针地址
std::cout << "------------" << endl;
// 通过指针
func2(&animal_1);
func2(&cat_1);
std::cout << "------------" << endl;
// 通过引用
func3(animal_1);
func3(cat_1);
}
int main()
{
test01();
return 0;
}
#endif
运行结果:
解释:
1、sizeof(Animal) = 8 的原因是只要父类的成员函数用 virtual 关键字进行修饰,无论是否构成重写或者是否被继承,编译器都会自动存放一个虚函数表指针(vfptr)。
class Base final
{
public:
virtual void func() { }
};
int main()
{
std::cout << "sizeof(Base) = " << sizeof(Base) << std::endl;
return 0;
}
运行结果: (一个指针 8 个字节)
2、父类调用父类函数不构成多态。
3、实现多态的条件
2.1 构成多态的条件
构成条件:1、存在继承关系 2、子类重写父类的虚函数
实现条件:1、必须通过父类的指针或者引用指向子类对象
2、必须调用的是虚函数(直接调用或者间接调用都可以,但是必须调用),且子类必须对父类的虚函数进行重写。
2.2 虚函数
被 virtual 关键字修饰的成员函数叫做虚函数。
注意:(1)只有类的非静态成员函数才可以被 virtual 修饰。
(2)虚函数中的 virtual 与虚继承中的 virtual 完全不同。前者是实现多态,后者是为了解决菱形继承的数据冗余和二义性问题。
2.3 虚函数的重写
重写(覆盖):函数 返回值相同(协变的返回值不一样,但是也能构成多态),函数名称相同(析构函数除外),函数参数也要相同。
注意:1、子类在重写父类的虚函数时,一定要注意是否实现重写,可以通过 override 关键字进行检查。 2、子类重写父类虚函数时最好加上 virtual 关键字。
2.4 虚函数重写的两个例外
(1) 协变:(返回值的类型相同(必须是指针或引用),且是父子关系)
(2) 析构函数的重写(子类和父类的析构函数名字不同)
如果父类的析构函数前面加上 virtual 关键字就行修饰,那么子类的析构函数无论是否加上 virtual 修饰,都与父类的析构函数构成重写。因为编译器对析构函数的函数名统一处理成destructor。
2.5 final和 override
final 关键字:(1)修饰虚函数;如果一个虚函数不想被重写,可以在虚函数后面加 final 进行修饰。
(2)修饰类;如果一个类不想被继承,那么就在这个类后面加 final 进行修饰。
override 关键字:检查派生类(子类)虚函数是否重写完成,如果没有重写完成就编译报错。发生在编译时期。
2.6 虚析构的重要性
下面我们在使用多态时常进行的一个操作:
class Animal // 动物
{
public:
Animal()
{
std::cout << "Animal 构造函数调用" << std::endl;
}
virtual void Call()
{
std::cout << "动物 在叫" << std::endl;
}
~Animal() // 不是虚函数
{
std::cout << "Animal 析构函数调用" << std::endl;
}
};
class Cat_Animal : public Animal // 猫
{
public:
Cat_Animal()
{
p_ptrArr_ = new int[100];
std::cout << "Cat_Animal 构造函数调用" << std::endl;
}
virtual void Call()
{
std::cout << "小猫在叫" << std::endl;
}
~Cat_Animal() // 不构成重写
{
std::cout << "Cat_Animal 析构函数调用" << std::endl;
if(nullptr != p_ptrArr_)
{
delete[] p_ptrArr_;
p_ptrArr_ = nullptr;
}
}
private:
int *p_ptrArr_;
};
void doCall(Animal *animal_ptr_) // 用父类指针去接收
{
if(nullptr == animal_ptr_) return ;
animal_ptr_->Call();
delete animal_ptr_;
animal_ptr_ = nullptr;
}
void test01()
{
doCall(new Cat_Animal);
}
int main()
{
test01();
return 0;
}
运行结果如下:
我们可以看到,运行结果中并没有调用 子类 Cat_Animal 的析构函数,所以导致了内存泄漏!
解决办法:在父类的析构函数前面加 virtual 关键字进行修饰,使其成为虚析构。
修改后的运行结果:可以看到程序调用了子类中的析构函数
当函数执行 delete animal_ptr_ 时,可以分为两步:
1、调用父类的虚析构函数。由于发生了多态,那么编译器就会调用子类的析构函数,而子类的析构函数会自动调用父类的析构函数 (继承时析构的顺序也是 1、子类的析构函数调用 -----> 2、父类的析构函数调用 )。
2、free 掉子类自身。即直接释放掉对象的内存。
2.7 重载、重写与隐藏三者的区别
重载:即函数重载,函数重载的要求:1、同一作用域下 2、函数名称相同 3、函数的形参个数、顺序、类型不同
重写(覆盖):对应的成员函数,函数 返回值相同(协变的返回值不一样,但是也能构成多态),函数名相同(析构函数除外),函数参数也要相同。
隐藏(重定义):即子类中重新定义的成员函数的函数名与父类中的成员函数的 函数名相同,不管是静态成员函数还是非静态成员函数,都会被子类的成员函数覆盖(或者也可以说被隐藏了),所以当在子类中需要调用父类的成员函数时最好加上作用域。注意:成员属性也是一样。
子类和父类中的同名非静态成员函数不构成重写就构成重定义。
class Animal
{
public:
void func()
{
std::cout << "Animal::func()调用" << std::endl;
}
int func(int a)
{
std::cout << "Animal::func(int a)调用" << std::endl;
return 0;
}
static void func(double a)
{
std::cout << "static Animal::func(double a)调用" << std::endl;
}
};
class Cat_Animal : public Animal
{
public:
int func()
{
std::cout << "Cat_Animal::func()调用" << std::endl;
return 0;
}
int func(int a)
{
std::cout << "Cat_Animal::func(int a)调用" << std::endl;
return 0;
}
void func(double a)
{
std::cout << "Cat_Animal::func(double a)调用" << std::endl;
}
};
void test01()
{
Cat_Animal cat;
cat.func();
cat.func(1);
cat.func(1.1);
}
int main()
{
test01();
return 0;
}
二、抽象类
1. 纯虚函数
1.1 纯虚函数
定义:在虚函数的后面写 = 0 ,即不给予函数实现。
如: virtual void func() = 0;
不需要调用父类的虚函数实现时,就可以将虚函数设置为纯虚函数。
1.2 抽象类(接口类)
定义:只要包含纯虚函数的类就是抽象类。
性质:抽象类不能实例话对象。子类继承抽象类后也不能实例化,必须重写父类纯虚函数,子类才能实例化对象。其作用其实和 override 关键字的作用有点相似。
理解/意义:1、能庞统的表达一种抽象话实物,如 人,动物,形状等等。世界上不存在人这个人,也不存在动物这个动物。它们都是一个抽象的总称。并没有具体的事物。
2、体现了实例化对象的思想,我可以用一个人名,比如 “小明”这个人他属于人,他是一个实体。
3、体现了接口继承,强制子类去重写父类的纯虚函数(不重写的话,子类也是抽象类)。
2. 接口继承和实现继承
2.1 接口继承
虚函数和纯虚函数的继承都是接口继承,子类仅仅只继承父类的接口,父类中可有可无的实现这个接口函数实现。
注意:为了达到多态的目的和防止程序员忘记对重写函数的重写,最好将虚函数定义成纯虚函数。否则就不要将成员函数定义成虚函数。
2.2 实现继承
普通父类成员函数的继承属于接口继承,子类可以直接调用,是一种复用;
三、多态原理
1. 虚函数表
虚函数表也叫虚表。
每个包含虚函数的类都包含了一个虚函数表指针(_vfptr, v: virtual ,),而这个虚函数表指针指向虚表。虚表的本质就是一个指针数组,指针数组里面存放的是指向虚函数的函数指针,里面不包含指向普通成员函数的指针。
虚表属于类的,而不是属于某一个具体的对象,并且所有类对象共用同一个虚表。这一点和静态成员有点相似。
注意:同一个类的实例化对象共用同一个虚表。
2. 原理
构成多态 与 不构成多态:
(1)指向谁就调用谁的虚表,或者说去对应的虚表查找虚函数指针。跟指向的对象有关,跟指针类型/引用类型无关。调用接口继承只是为了方便拓展,而不能决定具体实现的虚函数。
(2)子类没有重写父类虚函数时,由于子类中的虚表没有被覆盖,子类虚表中的函数指针依然是指向父类的虚函数。所以不管是父类调用还是子类调用,都是一样的。
(3)*************记住最重要的一点就是,看对应对象的虚表!!!子类虚表是由父类的虚表 1、拷贝 + 2、覆盖 + 3、增加(子类自己定义的虚函数) 完成的
四、单继承、多重继承关系的虚函数表
1. 单继承的虚函数表
1.1 虚函数表的初始化时机
虚表指针是在构造函数初始化列表阶段初始化的,虚表在编译时就已经生成了。
1.2 子类虚表的生成过程
生成子类虚表时,会单独开辟一块空间,拷贝一份父类的虚表,并将对应位置虚函数的覆盖(如果子类重写完成,就覆盖;否则保留)。最后将子类自己定义的虚函数增加到表尾,没有就不加。虚表以 nullptr 作为结尾标识。
所以,子类虚表是由父类的虚表 1、拷贝 + 2、覆盖 + 3、增加(子类自己定义的虚函数) 完成的。
可以认为多重继承也是单继承的一种,所以多重继承的子类虚函数表的初始化也是一样。参考多重继承的初始化顺序。
class A
{
public:
A()
{
cout << "A 的构造函数" << endl;
}
};
class B : public A
{
public:
B()
{
cout << "B 的构造函数" << endl;
}
};
class C : public B
{
public:
C()
{
cout << "C 的构造函数" << endl;
}
};
void test01() // 测试多重继承的初始化顺序
{
C c1;
}
int main()
{
test01();
return 0;
}
运行结果如下:
五、多态面试题
1、什么是多态?
答:多态顾名思义多种形态,通过父类指针或引用调用不同子类的行为,产生不同的结果。
2、静态多态与动态多态有什么区别?
答:静态多态是编译时绑定;动态多态是运行时绑定。
3、多态的优点是什么?
答:1、代码组织结构清晰 2、代码可读性强 3、利于前期和后期的拓展维护,符合对拓展开放,对修改进行关闭的原则。
4、多态是原理是什么?
答:原理:通过虚函数表指针指向虚表,虚表中保存虚函数指针;运行时通过虚表中保存的对应的虚函数地址,才能确定需要运行的函数,达到运行时绑定的目的。
调用方法:通过父类指针或者引用去指向子类对象,再调用父类中的虚函数接口。
5、为什么要将父类和子类的析构函数设置为虚析构或纯虚析构?
答:防止内存泄漏。如果父类的析构函数不是虚析构时,当用父类的指针去接收子类堆区申请的内存,在释放时不会释放子类的析构函数,导致内存泄漏。
6、什么是抽象类?为什么要使用抽象类?
答:包含纯虚函数的类叫做抽象类。
1、抽象类不能实例化对象。子类继承父类后,也不能示例化对象,子类必须全部重写父类纯虚函数后才能示例化对象。
2、体现了接口继承,强制子类去重写虚函数。
3、体现了抽象这一思想,比如不存在 人,动物 这种对象。