概念
多态使同一个接口可以用于不同的类型,并产生不同的状态,提高了代码的灵活性和可复用性。
通俗来说,多态的目的就是实现一些场景下灵活的调用,传什么就调用什么,即相同的动作,不同的人去做会产生不同的结果,例如:买票时传不同的对象会产生不同的价格和优先级,点外卖时不同用户的优惠可能不一样…
构成多态的条件
条件1:完成虚函数的重写
什么是重写?
回顾一下之前学过的重载和隐藏的概念
重载:1.两个函数在同一作用域 2.函数名相同,参数列表不同(类型/个数/顺序),返回值类型可同可不同
隐藏(重定义):1.两个函数分别在基类和派生类作用域 2.函数名相同即构成隐藏
重写(覆盖):1.两个函数分别在基类和派生类作用域 2.函数名/参数/返回值都必须相同 3.两个函数都必须是虚函数
可以发现,隐藏的条件下再加上参数,返回值相同,并且都是虚函数就构成重写。可以理解成重写的虚函数既有重写关系也有隐藏关系
什么是虚函数?
即被virtual修饰的类成员函数称为虚函数
下面演示构成重写的两个函数
class Base
{
public:
virtual void func1()
{
cout << “Base::func1” << endl;
}
};
class Derive :public Base
{
public:
virtual void func1()
{
cout << “Derive::func1” << endl;
}
};
Base类func1函数和Derive类func1函数:函数名,返回值,参数类型相同,且都是虚函数(都加了virtual关键字),此时这两个函数构成重写。
两个函数构成重写后会发生什么?
重写实际上描述的是一个过程,在两个函数构成重写后,子类函数会先拷贝父类的声明,然后再重写自己的实现
上述例子不好描述发生的现象,来观察如下代码
class Base
{
public:
virtual void func2(int x = 1)
{
cout << “Base:”<<x << endl;
}
};
class Derive :public Base
{
public:
virtual void func2(int x = 2) //子类的virtual可写可不写
{
cout << x << endl;
}
};
这里的两个func2也构成重写关系,子类(Derive)的func2会被重写
class Derive :public Base
{
public:
virtual void func2(int x = 1) //先继承父类的声明
{
cout << “Derive:”<<x << endl; //再重写自己的实现
}
};
思考:既然父类的声明会被拷贝过来,那么子类不加virtual关键字可以吗
可以!父类成员函数有virtual关键字,子类不加virtual也是虚函数,就底层而言构成重写后都会继承父类的声明,继承下来后子类就也有virtual啦,所以C++规定子类不加virtual,仅父类有,在其他条件满足时,也构成重写。
补充:函数重写的两个例外/特殊情况
例外1:协变
即基类和派生类返回值不同,但必须是父子类关系的指针或引用,此时也构成重写
//下列代码仅演示协变,代码并不规范(存在内存泄漏问题)
//下列代码中两个func函数虽然返回值不同,仍构成重写关系
class A
{};
class B:public A
{};
class Base
{
virtual A* func()
{
return new A;
}
};
class Derive :public Base
{
virtual B* func()
{
return new B;
}
};
例外2:析构函数的重写
即基类与派生类析构函数名字不同,仍然构成重写
在继承中提到由于编译器会对析构函数特殊处理即统一处理成destrutor(),即使父子类析构函数名不同,也构成隐藏,这种特殊处理便导致了析构函数重写的特殊情况。
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。建议父类析构函数都要加上virtual,防止多态调用时内存泄漏(子类加不加都可以,这里后面会解释)
class Base
{
public:
virtual ~Base()
{
cout << “~Base()” << endl;
}
};
class Derive :public Base
{
public:
virtual ~Derive() //这里virtual可加不加都和父类析构构成重写
{
cout << “~Derive()” << endl;
}
};
思考:虚函数的重写,两个虚函数返回值必须相同对吗?
错误,正常来说应该相同,满足协变时即使返回值不同也构成重写。
上面过程只是满足了条件1:父子类函数成员构成重写,但还并不满足多态的条件,要实现多态,还应满足下面的
条件2:必须是父类的指针或引用去调用虚函数
多态实际上也叫多态调用,满足重写的条条件后,使用父类的指针或者引用区调用虚函数才是多态调用,否则就是普通的函数调用。
对于上述func2函数,我们对它进行多态调用
int main()
{
//使用父类指针调用
Derive d;
Base b;
Base* p = &d;
p->func1();
p = &b;
p->func1();
cout << endl;
//使用父类引用调用
Base& bb1 = d;
bb1.func1();
Base& bb2 = b;
bb2.func1();
return 0;
}
可以看到,我们调用相同的函数,产生了不同的结果
(1)为什么x输出都是1,子类里的缺省值不是2吗?
我们前面有提到重写时会继承父类声明,子类的func2继承了父类声明,也会继承父类的参数列表的缺省值,所以x为1
(2)多态调用和普通调用的区别?
普通调用:普通函数调用在编译链接时根据函数的命名规则就确定地址,调用时直接跳转到该地址执行,这种调用方式与对象的状态和指针指向的具体对象无关
多态调用:不再是编译时确定函数地址,而是运行时到指向的对象的虚函数表中找函数地址进行调用,所以指向父类调父类,指向子类调子类。(如果不符合多态调用,则是普通调用)
对于上述例子,父类指针、引用根据自己指向的对象去相应的虚函数表中找,而各自的对象又有自己对应的func2函数(父类有自己的,子类有重写的),从而产生了不同的结果。
现在我们知道了,多态调用的实现通过虚函数表,什么是虚函数表?
首先需要弄清楚类对象的存储方式,在学习类和对象的时候讨论过类对象的存储方式可能有以下几种:
方式一:对象中包含类的各个成员,包括类成员变量、类成员函数
这种方式下,每个对象的成员变量是不同的,需要各自拥有一份自己的,但需要调用的成员函数是同一份,不需要每个对象存储一次,若按照这种方式存储,当一个类创建多个对象时,每个对象中都会保存一份成员函数,相同内容保存多次,浪费空间
方式二:代码只保存一份,在对象中保存存放代码的地址
方式三:只保存成员变量,成员函数存放在公共的代码段
那到底是通过哪种方式存储的呢,来看下面代码(在32位机器下演示)
class A
{
public:
void func1()
{
cout << "A::func1()" << endl;
}
protected:
int _a;
};
class B
{
public:
void func2()
{
cout << "B::func2()" << endl;
}
protected:
int _b;
};
class C:public A,public B
{
public:
void func3()
{
cout << "C::func3()" << endl;
}
protected:
int _c;
};
运行结果:
A类里只有一个int成员_a,显然A大小是4,也没有根据方式二所说,在对象中保存存放代码的地址,B类里也只有一个int成员_b,B大小为4,C类继承了A,B类,C类里包括了A类的成员,B类的成员,自身的成员,三个int类型成员,大小为12,很显然上述对象的存储方式是按照方式三进行存储,对象里只存储成员变量,成员函数存放在公共的代码段。
上面三个类里面都没有虚函数,若有了虚函数存储方式还一样吗?
class A
{
public:
virtual void func1()
{
cout << "A::func1()" << endl;
}
protected:
int _a;
};
class B
{
public:
virtual void func2()
{
cout << "B::func2()" << endl;
}
protected:
int _b;
};
class C:public A,public B
{
public:
void func3()
{
cout << "C::func3()" << endl;
}
protected:
int _c;
};
int main()
{
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
cout << sizeof(C) << endl;
return 0;
}
运行结果:
在前面基础上我们把A类里的func1函数,B类里的func2函数变成虚函数,同样它们都只有一个int类型的成员变量,结果却不一样了
①A类里多了4个字节:实际上就是方式二所说的在对象中保存了存放代码的地址,地址即指针,指针的大小在32位机器上就是4字节,所以A的大小是8字节,B同理
②C继承了A,B,但是C类自己并没有虚函数,所以C类自己大小是成员变量int的类型,再加上继承下来的A,B,一共是20字节。
我们通过内存来观察A类的存储情况
首先实例化一个A类对象aa,&aa后看到aa里面先是存放了一个指针,这个指针指向前面所说的虚函数表,在虚函数表里存放了虚函数的地址,然后存储了成员变量_a,明显看出是按照方式二来存储。即存放函数地址的指针就是虚函数表指针,简称虚表指针。它所指向的就是虚函数表,里面存放虚函数的地址
B类实例化出的bb对象存储模型和上述基本一致
注意:虚表指针存储的是虚表首元素的地址,由于上述示例类里都只有一个虚函数,所以虚函数表里只存有一个虚函数的地址,实际上若有多个虚函数则后面会继续存储多个。上述示例虚函数表后有0,通常在VS编译器下虚函数表后以0结尾,但在编译器之间有差别,其它编译器上一般不会这样处理
观察了基类A,B的存储方式,继续观察继承了A,B的C类对象存储方式,前面打印出来cc的大小是20字节
C对象里有两个虚表指针,A的和B的,但由于C类自己没有虚函数,所以cc里并没有存自己的虚表指针,func3函数仍然存在公共代码段。
上述示例C类没有虚函数,也没有重写,子类若有自己的虚函数后如何存储,会生成自己的虚表吗?下述示例仅改变C类的代码,A,B类不变
首先和上一个例子一样,cc里存储了整个A类成员(一张虚表,_a成员),存储了整个B类成员(一张虚表,_b成员),然后存储了自己的成员_c,从内存存储情况上看C类并没有生成自己的虚表。观察继承下来A的虚函数表,发现除了A里存储了两个指针,猜测一个是A类虚函数成员func1,另一个则是C类自己的的虚函数成员func3,再观察B的虚函数表里只有一个指针,只能B类里的虚函数成员func2,猜测是否正确呢,我们验证一下
打印出A的虚表内存储的所有函数,发现存储A类成员了虚函数func1和C类成员虚函数func3,确实C类的成员函数存储在A中的虚函数表里。由此我们可以得出结论,如果派生类有自己单独的虚函数(没有重写的),会放在第一个继承的类里面。
若子类重写了父类虚函数如何存储?
前面说到由于虚函数只需要有一份,所以可以想到一个类实例化的所有对象应该共用同一张虚函数表,如下,验证属实
再看如下代码,此时C类继承了B类,但没有重写B类中func2函数,观察func2函数在两个对象中存储
可以看到在未重写情况下,func2函数在两个类实例化的对象中存储的地址是一样的,即在未重写情况下子类和父类不共用同一张虚表,子类是拷贝一份父类的虚表,里面函数地址一样。
下面观察重写后内存存储情况
C类重写了B类func2后发现地址改变了,前面说的重写也叫覆盖在这里就体现了,首先C先拷贝一份B类的虚函数表,若没有发生重写,则C类中的函数地址与B函数表一致,若发生了重写,则会再为子类生成一份重写后的虚函数,旧的函数地址也会被新的函数地址覆盖。
总结
①实现多态需要两个条件,首先完成重写,其次必须用父类指针或引用去调用虚函数,满足这两个条件后才是多态调用
②多态调用下会根据实例化的对象去调用函数,即通过对象的虚函数表指针,找到虚函数表,再通过虚函数表里的函数地址便可调用。若是父类对象,则去找父类的虚函数表,若是子类对象,则找子类的虚函数表,而子类的虚函数表里可以存储重写后的函数,此时就可以做到虽然调用的是同样的函数,但产生的结果不一样。也就是我们前面说的同样的事不同的对象去做会有不同的行为和结果。“同样的事”即调用相同的函数/方法,“不同的结果”即不同的函数体,不同的类对象生成自己的函数体。
③若子类有自己的虚函数,不会重新生成自己的虚函数表,而是直接存在第一个父类的虚函数表里,若子类重写了父类虚函数,则会生成新的函数从而覆盖原有的虚函数地址
②前面继承中提到了赋值兼容即切片的原理,当一个派生类继承多个父类,仍然需要支持赋值兼容,所以派生类里必须完整包含每个父类,这样在进行多态调用,进行切片时才能完整地切出父类的部分。
纯虚函数
在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后若不重写纯虚函数,则派生类也不能实例化出对象。纯虚函数规范了派生类必须重写。
class Base
{
public:
//纯虚函数
virtual void func() = 0;
};
class Derive :public Base
{
public:
//必须重写
virtual void func()
{
cout << “Derive::func()” << endl;
}
};
int main()
{
//Base b; //这里会报错Base是抽象类,无法示例化对象
Derive d;
return 0;
}
抽象类的使用:对于某些父类不期望它实例化出对象,并且对于它的某些函数想强制派生类重写,就可以使用抽象类。父类可以有多个纯虚函数,但是只要有一个纯虚函数就不能实例化了。
C++11 override和final
从上面可以看出,C++对函数重写的要求比较严格,但有些情况下由于疏忽,可能会导致函数无法构成重写,而一些错误在编译期间是不会报错的,只有在程序运行时没得到预期结果才来debug,因此在C++11提供了两个关键字,可以帮助用户检测是否重写
(1)final:修饰虚函数,表示该虚函数不能再被重写
class Base
{
public:
virtual void func() final
{
// …
}
};
class Derived : public Base
{
public:
void func() override //编译错误:func 函数不能被重写
{
// …
}
};
题目:写一个类,这个类不可被继承
方法1:父类构造函数私有,派生类实例化不出对象
class Base
{
private:
Base()
{}
};
class Derive :public Base
{
//…
};
int main()
{
Base b;
Derive d;
return 0;
}
方法2:C++11 final修饰的类为最终类,不能被继承
class Base final //final修饰类
{
public:
void func()
{
// …
}
};
class Derived : public Base // 编译错误:Base 类不能被继承
{
//…
};
(2)override:检查派生类虚函数是否重写了基类某个函数,如果没有重写编译报错,override也明确指出了某个函数是重写的,提高了代码的可读性和可维护性。
class Base
{
public:
virtual void func()
{
std::cout << “Base::func()” << std::endl;
}
virtual void anotherFunc()
{
std::cout << “Base::anotherFunc()” << std::endl;
}
};
class Derived : public Base
{
public:
void func() override
{
std::cout << “Derived::func()” << std::endl;
}
// 如果这里拼写错误,编译器会报错
// void anoterFunc() override
{
// std::cout << “Derived::anoterFunc()” << std::endl;
// }
};
补充
(1)动态绑定与静态绑定
----静态绑定又称为早期绑定(早绑定),在编译期间确定了程序的行为,也成为静态多态,比如函数重载
----动态绑定又称后期绑定(晚绑定)是在运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态
(2)inline函数可以是虚函数吗
可以,不过编译器可能会忽略inline属性,特别是在调用的情况下,由于函数要放到虚表中去,必须生成地址,这个函数就不再是inline函数
(3)静态成员可以是虚函数吗
不能,因为静态函数没有this指针,虽然静态函数不需要对象便可以完成调用(使用类型: :成员的方式),但这样的调用无法完成多态调用,多态调用一定是虚函数完成重写,再去虚函数表里面找对应的函数,而静态成员函数没有this指针,无法访问虚函数表,所以静态成员函数无法放进虚函数表
(4)构造函数可以是虚函数吗
不能。
①虚函数表的初始化:构造函数的主要任务是初始化对象的数据成员和虚表指针,在构造函数开始执行之前,虚函数表指针还没有被初始化,因此无法通过虚函数表来调用虚函数。
②多态的限制:虚函数是为了支持多态而设计的,允许派生类重写虚函数,而构造函数是用于创建对象的,每个类的构造函数都是特定的,不能被派生类重写,所以构造函数不能实现多态机制
(5)析构函数可以是虚函数吗
可以,并且最好把基类的析构函数都定义成虚函数。
从运行结果中可以看到,由于这里没有实现多态调用,清理p2资源时会根据类型调用析构函数,从而导致子类只调用了父类的析构函数去清理父类部分的资源
将析构函数定义成虚函数后
运行结果:此时多态调用,编译器不再根据类型调用函数,而是根据对象中的虚函数表去调用
p2指针调用子类的析构函数,而C++规定当一个派生类对象被销毁时,其析构函数会被调用,然后自动调用其基类的析构函数
(6)对象调用普通函数快还是虚函数快
①若对象普通调用虚函数,则一样快
②若对象多态调用虚函数,运行时调用虚函数需要到虚函数表中去查找,因此普通函数快
(7)虚函数表是在什么阶段生成的,存在哪里?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)
打印出各个存储位置的对象地址,可以看到虚函数表最接近常量区