目录
文章结尾附有面试题,帮助读者解析多态的问法及解答技巧。
多态的引入
在面向对象编程中,需要对不同对象的相同行为有不同的反应。比如对于买票,成人票原价,学生票半价,军人优先等等。因此多态是面向对象的三大基本特性之一。
多态是基于继承上实现的,比如学生,军人,成人都是基于人而来的身份。
实现多态的必须条件之一是虚函数。
多态分为两种:1)静态多态(在编译期间确定的):函数重载;
2)动态多态(在运行期间确定的):继承,虚函数重写,实现多态。
虚函数
概念
被virtual修饰的函数叫做虚函数。
class Person
{
public:
virtual void Buyticket()
{
cout << "成人:原价" << endl;
}
};
以上就是一个简单的虚函数。
虚函数的重写
当派生类中有一个与基类相同的虚函数,就称为派生类重写了子类的虚函数。
此处的相同指的是:函数名,参数,返回值;
注意区分多态的重写和继承中的隐藏,是两个完全不同的概念。在文章结尾会详细区分。
class Person
{
public:
virtual void Buyticket()
{
cout << "成人:原价" << endl;
}
};
class Student : public Person
{
public:
virtual void Buyticket()
{
cout << "学生:半价" << endl;
}
};
此段代码中可以称:派生类重写了基类的Buyticket函数。
重写的特例及细节
1)子类重写虚函数可以不加virtual关键字,但是基类必须加。此处建议都加上。
2)重写的两个要求:函数相同+虚函数;但是有一个特例:协变。
协变:两个函数返回值可以不相同,但是返回值必须是父子关系(不一定是当前类的父子关系)的指针或引用。协变在重写中用的很少。
多态调用条件
多态调用看的是指向的对象,而不是指针或引用的类型,普通对象才看当前类型。
多态实现条件有两个:1)调用的函数是重写的虚函数;
2)使用基类的指针或引用进行调用。
Person* ps = new Person;
ps->Buyticket(); //打印购票信息 :成人:原价
delete[] ps; //释放
ps = new Student; //向上转化,可以将派生类赋给基类。
ps->Buyticket(); //打印购票信息 :学生:半价
析构函数的重写
在上面对于多态的调用中,使用指针进行调用;如果指针指向的是一个派生类对象,当对这一指针指向的对象进行析构的时候却调用了基类的析构,这可能就会导致空间泄露。所以需要对析构函数进行多态调用。
但是析构函数的函数名是(~类名)。而每一个类的名称又不同,析构函数的函数名也就不同,这就不满足虚函数重写的要求。因此为了满足对析构函数的重写,C++规定:编译器将所有的析构函数名称统一视为destructor,这样就只需要在析构函数前加上virtual关键字就能实现对析构函数的重写了。
这样就使得在使用子类指针指向父类的时候,也能调用父类的析构函数,实现对对象的释放。
对析构函数重写是实现多态必不可少的一部分。
override和final
override和final是重写中常用的两个关键字。
override
放在函数后面可以帮助派生类虚函数检查是否重写了某个基类的虚函数,没有重写会报错。
class Car
{
public:
virtual void Drive()
{
cout << "Car";
}
};
class Truck:public Car
{
public:
void Drive() override
{
cout << "Truck";
}
};
final
放在基类的虚函数后面表示该虚函数不想被重写。
class Car
{
public:
virtual void Drive() final
{
cout << "Car";
}
};
以上代码中Drive函数,在被重写是就会报错。
如果想要让一个类不被继承也可以在类后面加上final意为最终类。
class Car final
{
public:
virtual void Drive()
{
cout << "Car";
}
};
如果想要让一个类无法被继承也可以将基类的构造函数设为私有防止派生类构造基类。
虚函数表
概念
虚函数表是一个指针数组用来存储每个虚函数的地址。一般情况,虚表的结尾放nullptr。
class A
{
public:
virtual void test()
{
cout << " A " << endl;
}
protected:
int _a;
};
当定义一个A对象时,用sizeof计算其对象的大小时,结果是8(x86环境)。但是这里只存储了一个int类型的变量呀,为什么呢???
因为实际上A的对象中还存储了一个指针,用来指向虚函数表。
调试信息中的_vfptr就是指向虚函数的指针。
当继承的时候,也会继承虚表,对于虚表中被重写的虚函数会被替换。
class A
{
public:
virtual void test1()
{
cout << " A " << endl;
}
protected:
int _a;
};
class B :public A
{
public:
virtual void test1()
{
cout << " B " << endl;
}
protected:
int _b;
};
派生类虚表:基类虚表先拷贝,再将派生类中重写的虚函数覆盖掉原来虚表位置的虚函数,再将派生类非重写的虚函数按照声明的顺序往后放。
虚函数并非存在于虚表中的,虚函数也是函数所以其实际上存在与代码段中。同样虚表也存在于代码段中。
打印虚表
根据大多数编译器虚表用nullptr结尾可以设计一段代码来打印虚表中存储的地址。
void Print(Ptr* table)
{
for (int i = 0; table[i] != nullptr; ++i)
{
cout << i << ": " << table[i] << endl;
}
}
只需要将虚表的地址穿过来就可以打印虚表。
class Test
{
public:
virtual void Func1()
{
cout << "Func1" << endl;
}
virtual void Func2()
{
cout << "Func1" << endl;
}
virtual void Func3()
{
cout << "Func1" << endl;
}
virtual void Func4()
{
cout << "Func1" << endl;
}
};
赋值时虚表的处理
Student st;
Person p;
Person* pst = &st; //满足多态
Person& ost = st; //满足多态
p = st;
以上代码中指针和引用是满足多态的,所以在使用时,指向哪一个类型的对象就用哪一个虚表,而对于赋值p=st来说,也不会拷贝虚表。
如果拷贝虚表就会导致基类会访问派生类的虚函数。
重写底层
重写:重写的是虚函数的实现。
什么理解呢:派生类对于虚函数的重写时,对函数实现进行重写,当调用多态函数时其虚函数的声明还是看基类的声明。可以理解为:派生类虚函数的声明只是作为判断是否重写基类虚函数。而并不在函数调用时使用。
class A
{
public:
virtual void func(int val = 1)
{
cout << "A " << val << endl;
}
virtual void test()
{
func(); //this->func(),此处的隐藏this指针是父类指针,所以会出现多态调用
}
};
class B :public A
{
virtual void func(int val = 2)
{
cout << "B " << val << endl;
}
};
int main()
{
B b;
b.test();
return 0;
}
对于以上代码的调用中会出现什么结果???
当调用test()函数后会导致多态调用,那是不是调用B的func()函数,打印出"B 2"。注意上面所说的虚函数的重写重写的是实现,函数的"壳"还是基类的,所以此处调用B重写的虚函数实际上应该是:
A函数声明:virtual void func(int val=1)
+
B{函数实现}
virtual void func(int val = 1)
{
cout << "B " << val << endl;
}
其使用基类的函数声明+派生类重写的函数实现。所以打印出的结果应当是"B 2";
多继承虚表问题
多继承中的重写
多继承中继承了父类就要继承他们的虚表。
当派生类继承的两个基类中有相同的虚函数,再去调用就会导致指向不明,此时在派生类中间虚函数重写就可以调用;也可以指定类域。
class Base1
{
public:
virtual void func1()
{
cout << "Base1" << endl;
}
};
class Base2
{
public:
virtual void func1()
{
cout << "Base2" << endl;
}
};
class Derive:public Base1,public Base2
{
public:
virtual void func1()
{
cout << "Derive" << endl;
}
};
当派生类将两个基类的虚函数都重写会怎么样???
毫无疑问两个基类的虚表都会被覆盖。
可以看到这两个基类的虚表确实都是实现了覆盖,但是为什么派生类的两个虚表中指向fun1()函数的地址不一样???
调用重写的虚函数都是从派生类的起始地址进行函数调用的。
base1的起始地址就是Derive的起始地址,所以base1可以直接调用;而base2不是,所以在对base2的指针进行调用之前需要对指针进行修正。修正完后才会调用。
base1虚表存储的地址就是重写虚函数的地址,base2存储的是进行指针修正的地址,指针修正完后也会去调用与base1相同的地址。
当有多继承虚表问题,自然也就有菱形继承的虚表问题。
菱形继承中的重写
关于菱形虚拟继承的虚表问题:D中的A的虚表只有一份,B和C中没有A的虚表,与虚继承相似也是通过偏移量来实现访问的。
当B,C中有自己独有的虚表是B,C中会各自再创建独自的虚表。D中有独自的虚表就会直接放在A的虚表中。
抽象类
抽象类:含有纯虚函数的类。
纯虚函数:在虚函数后面写上=0,则这个函数被称为纯虚函数。
抽象类不能被实例化出对象,也就是说在现实世界中不能找到其对应的本体,只有当继承时重写了纯虚函数才能才能使用。
class Car
{
public:
virtual void Drive() = 0
{
cout << "Car" << endl;
}
};
以上这个类就是抽象类,其无法实例化出对象。
可以被继承但是如果纯虚函数没有被重写,则派生类也是一个抽象类。
class Car
{
public:
virtual void Drive() = 0
{
cout << "Car" << endl;
}
};
class Truck :public Car
{
public:
virtual void Drive()
{
cout << "Car" << endl;
}
};
继承了抽象类,又重写了纯虚函数,此处的Truck可以实例化出对象。
重载 重写 重定义
重载:两个函数在同一个作用域中,函数名相同,函数参数不同;
重写/覆盖:在两个函数在子类和父类中,函数相同(名,参数,返回值)并且是虚函数;
重定义/隐藏:在子类和父类中,函数名相同。
面试题
什么是多态???
多态分为静态多态和动态多态,静态多态是在编译时就确定的,比如函数重载;动态多态是在函数运行时才确定的,比如继承,虚函数重写,多态调用。
多态实现原理???
静态多态思想原理是函数名的修饰规则,动态多态的实现原理是虚函数表。
内敛函数可不可以是虚函数???
内敛函数是没有地址的,所以其在编译期间就不会进入虚表,不可能是虚函数。
静态成员函数是不是虚函数???
静态成员函数可以直接通过类域访问,其没有this指针,没有对象,也就没有虚表,所以静态成员函数不可能是虚函数。
虚表在什么时候生成???
虚表在编译时就已经生成好了,虚表指针是在初始化列表期间生成的。
构造函数能否是虚函数???
不能,虚表指针实在构造函数的初始化列表期间生成的,若构造函数也是虚函数,就会导致还没有虚表就希望进行虚函数的多态调用。
析构函数为什么常被设为虚函数???
因为为了调用对虚函数多态调用,就需要用基类指针指向派生类成员,当对指向对象进行析构的时候就需要依据指向的对象进行析构函数的调用,而不是指针类型。
对象访问普通函数快,还是虚函数快???
访问普通函数更快,因为如果构成了多态调用,运行时就需要先在虚表中查找虚函数的位置,再去调用。