目录
8.1 多态性概述
多态是指同样的消息被不同类型的对象接收时导致不同的行为。最简单的一个例子就是运算符,使用同样的加号“+”,就可以实现整型数之间,浮点数之间,双精度浮点数之间以及它们相互的加法运算,同样的消息——相加,被不同类型的对象——变量接收后,不同类型的变量采用不同的方式进行加法运算。如果是不同类型的变量相加,例如浮点数和整型数,则要先将整型数转换为浮点数,然后再进行加法运算,这就是典型的多态现象。
8.1.1 多态的类型
1.面向对象的多态性可以分为4类:专用多态:重载多态、强制多态
通用多态:包含多态、参数多态
2.重载多态:之前学过的普通函数及类的成员函数的重载都属于重载多态,运算符重载就是宠之爱的实例。
强制多态:将一个变元的类型加以转化,以符合一个函数或者操作的要求。
包含多态:类族中定义于不同类中的同名成员函数的多态行为,主要是通过虚函数来实现。
参数多态:与类模板相关联,在使用时必须赋予实际的类型才可以实例化。这样,由模板实例化的各个类都具有相同的操作,而操作对象的类型却各不相同。
8.1.2 多态的实现
多态从实现角度来讲可以划分为:编译时的多态和运行时的多态。
8.2 运算符重载
运算符重载是对已有的运算符赋予多重含义,使同一个运算符作用于不同类型的数据时导致不同的行为。运算符重载的实质就是函数重载。
8.2.1 运算符重载的规则
2.运算符的重载形式有两种,重载为类的非静态成员函数和重载为非成员函数。
重载为类的成员函数的一般语法形式为:
返回类型 类名::operator 运算符(形参表)
{
函数体
}
重载为非成员函数的一般语法形式为:
返回类型 operator 运算符(形参表)
{
函数体
}
返回类型指定了重载运算符的返回值类型,也就是运算结果类型;
operator是定义运算符重载函数的关键字;
运算符即是要重载的运算符名称,必须是C++中可重载的运算符;
形参表中给出重载运算符所需要的参数和类型。
8.2.2 运算符重载为成员函数
运算符重载实质上就是函数重载,重载为成员函数,它就可以自由地访问本类的数据成员。
1.对于双目运算符B,如果要重载为类的成员函数,使之能够实现表达式oprd1和oprd2,其中oprd1为A类的对象,则应当把B重载为A类的成员函数,该函数只有一个形参,形参的类型是oprd2所属的类型。经过重载后,表达式oprd1 B oprd2就相当于函数调用oprd1.operator B(oprd2)。
2.对于前置单目运算符U,如“-”等,如果要重载为类的成员函数,用来实现表达式U oprd,其中oprd为A类的对象,则U应当重载为A类的成员函数,函数没有形参。经过重载之后,表达式U oprd相当于函数调用oprd.operator U()。
3.对于后置运算符“++”和“--”,如果要将他们重载为类的成员函数,用来实现表达式oprd++或oprd--,其中oprd为A类的对象,那么运算符就应当重载为A类的成员函数,这时函数要带有一个整型(int)形参。重载后,表达式oprd++就相当于函数调用oprd.operator++(0)。这里int类型参数在运算中不起任何作用,只用于区别后置++和前置++。
例8-1 复数类加减法运算重载——成员函数形式
//8_1.cpp
#include<iostream>
using namespace std;
class Complex {
public:
Complex(double r=0.0,double i=0.0):real(r),imag(i) {} //构造函数
Complex operator+(const Complex &c2) const; //运算符+重载成员函数
Complex operator-(const Complex &c2) const; //运算符-重载成员函数
void display() const; //输出复数
private:
double real; //实部
double imag; //虚部
};
Complex Complex::operator+(const Complex &c2) const { //重载运算符函数实现
return Complex(real+c2.real,imag+c2.imag);
}
Complex Complex::operator-(const Complex &c2) const { //重载运算符函数实现
return Complex(real-c2.real,imag-c2.imag);
}
void Complex::display() const {
cout<<"("<<real<<","<<imag<<")"<<endl;
}
int main() {
Complex c1(5,4),c2(2,10),c3;
cout<<"c1=";c1.display();
cout<<"c2=";c2.display();
c3=c1+c2;
cout<<"c3=c1+c2=";c3.display();
c3=c1-c2;
cout<<"c3=c1-c2=";c3.display();
return 0;
}
运行结果:
c1=(5,4)
c2=(2,10)
c3=c1+c2=(7,14)
c3=c1-c2=(3,-6)
例 8-2 将单目运算符“++”重载为成员函数形式
//8_2.cpp
#include<iostream>
using namespace std;
class Clock {
public:
Clock(int hour=0,int minute=0,int second=0);
void showTime() const;
Clock& operator++(); //前置单目运算符重载
Clock operator++(int); //后置单目运算符重载
private:
int hour,minute,second;
};
Clock::Clock(int hour,int minute,int second) {
if(0<=hour&&hour<24&&0<=minute&&minute<60&&0<=second&&second<60) {
this->hour=hour;
this->minute=minute;
this->second=second;
} else
cout<<"Time Error"<<endl;
}
void Clock::showTime() const {
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
Clock &Clock::operator++() { //前置单目运算符重载实现
second++;
if(second>=60) {
second-=60;
minute+=1;
if(minute>=60) {
minute-=60;
hour=(hour+1)%24;
}
}
return *this;
}
Clock Clock::operator++(int) { //后置单目运算符重载实现
Clock old=* this;
++(*this); //调用前置"++"运算符
return old;
}
int main() {
Clock myClock(23,59,59);
cout<<"First Time:";
myClock.showTime();
cout<<"show myClock++:";
(myClock++).showTime();
cout<<"show ++myClock:";
(++myClock).showTime();
return 0;
}
运行结果:
First Time:23:59:59
show myClock++:23:59:59
show ++myClock:0:0:1
细节:对于函数参数表中并未使用的参数,C++语言允许不给出参数名。
8.2.3 运算符重载为非成员函数
运算符也可以重载为非成员函数。这时,运算所需要的操作数都需要通过函数的形参表来传递,在形参表中形参从左到右的顺序就是运算符操作数的顺序。如果需要访问运算符参数对象的私有成员,可以将该函数声明为类的友元函数。
1.对于双目运算符B,如果要实现oprd1 B oprd2,其中oprd1和oprd2中只要有一个具有自定义类型,就可以将B重载为非成员函数,函数的形参为oprd1和oprd2。经过重载之后,表达式oprd1 B oprd2就相当于函数调用operator B(oprd1,oprd2)。
2.对于前置单目运算符U,如“-”等,如果要实现表达式U oprd,其中oprd具有自定义类型,就可以将U重载为非成员函数,函数的形参为oprd。经过重载之后,表达式U oprd相当于函数调用operator U(oprd)。
3.对于后置运算符++和--,如果要实现表达式oprd++或oprd--,其中oprd为自定义类型,那么运算符就可以重载为非成员函数,这时函数的形参有两个,一个是oprd,另一个是int类型形参。第二个参数是用于与前置运算符函数相区别的。重载之后,表达式oprd++和oprd--就相当于函数调用operator++(oprd,0)和operator--(oprd,0)。
例 8-3 以非成员函数形式重载Complex的加减法运算和“<<”运算符。
1.本例将运算符“+”“-”重载为非成员函数,并将其声明为Complex类的友元函数,使之实现复数加减法。本例所针对的问题和例8-1完全相同,运算符的两个操作数都是复数类的对象,因此重载函数有两个复数对象作为形参。
2. 另外,本例重载了“<<”运算符,使得可以对cout使用"<<"操作符来输出一个Complex对象,使得输出变得更加方便、直观。
//8_3.cpp
#include<iostream>
using namespace std;
class Complex {
public:
Complex(double r=0.0,double i=0.0):real(r),imag(i) {} //构造函数
friend Complex operator+(const Complex &c1,const Complex &c2); //运算符“+”重载
friend Complex operator-(const Complex &c1,const Complex &c2); //运算符“-”重载
friend ostream & operator<<(ostream &out,const Complex &c); //运算符<<重载
private:
double real; //实部
double imag; //虚部
};
Complex operator+(const Complex &c1,const Complex &c2) {
return Complex(c1.real+c2.real,c1.imag+c2.imag);
}
Complex operator-(const Complex &c1,const Complex &c2) {
return Complex(c1.real-c2.real,c1.imag-c2.imag);
}
ostream & operator<<(ostream &out,const Complex &c) {
out<<"("<<c.real<<","<<c.imag<<")";
return out;
}
int main() {
Complex c1(5,4),c2(2,10),c3;
cout<<"c1="<<c1<<endl;
cout<<"c2="<<c2<<endl;
c3=c1+c2;
cout<<"c3=c1+c2="<<c3<<endl;
c3=c1-c2;
cout<<"c3=c1-c2="<<c3<<endl;
return 0;
}
运行结果:
c1=(5,4)
c2=(2,10)
c3=c1+c2=(7,14)
c3=c1-c2=(3,-6)
“<<”操作符的左操作数为ostream类型的引用,ostream是cout类型的一个基类,右操作数是Complex类型的引用,这样在执行cout<<c1时,就会调用operator<<(cout,c1)。该函数把通过第一个参数传入的ostream对象以引用形式返回,这是为了支持形如"cout<<c1<<c2"的连续输出,因为第二个“<<”运算符的左操作数是第一个“<<”运算符的返回结果。
提示 成员函数的重载方式更加方便,但有时出于以下原因,需要使用非成员函数的重载方式。
(1)要重载的操作符的第一个操作数不是可以更改的类型,例如例8-3中"<<"运算符的第一个操作数的类型为ostream,是标准库的类型,无法向其中添加成员函数。
(2)以非成员函数重载,支持更灵活的类型转换,例如例8-3中,可以直接使用5.0+c1,因为Complex的构造函数使得实数可以被隐含转换为Complex类型,这样5.0+c1就会以operator(Complex(5.0)+c1)的方式来执行。而以成员函数重载时,左操作数必须具有Complex类型。
(3)"=""[ ]""( )""->"只能被重载为成员函数,而且派生类中的“=”运算符函数总会隐藏基类中的“=”运算符函数。
8.3 虚函数
虚函数是动态绑定的基础。虚函数必须是非静态的成员函数。虚函数经过派生之后,在类族中就可以实现运行过程中的多态。
根据赋值兼容规则,可以使用派生类的对象代替基类对象。如果用基类类型的指针指向派生类对象,就可以通过这个指针来访问该对象,问题是访问到的只是从基类继承来的同名成员。解决这一问题的方法是:如果需要通过基类的指针指向派生类的对象,并访问某个与基类同名的成员,那么首先在基类中将这个同名函数说明为虚函数。这样,通过基类类型的指针,就可以使属于不同派生类的不同对象产生不同的行为,从而实现了运行过程的多态。
8.3.1 一般虚函数成员
声明语法:
virtual 虚函数类型 函数名{参数表}
虚函数声明只能出现在类定义中的函数原型声明中,而不能在成员函数实现的时候。运行过程中的多态需要满足3个条件,首先类之间满足赋值兼容性,其二是要声明虚函数,第三是要由成员函数来调用或者是通过指针、引用来访问虚函数。
习惯:虚函数一般不声明为内联函数。
例 8-4 虚函数成员
//8_4.cpp
#include<iostream>
using namespace std;
class Base1 {
public:
virtual void display() const; //虚函数
};
void Base1::display() const {
cout<<"Base1::display()"<<endl;
}
class Base2:public Base1 {
public:
void display() const; //覆盖基类的虚函数
};
void Base2::display() const {
cout<<"Base2::display()"<<endl;
}
class Derived:public Base2 {
public:
void display() const; //覆盖基类的虚函数
};
void Derived::display() const {
cout<<"Derived::display()"<<endl;
}
void fun(Base1 *ptr) { //参数为指向基类对象的指针
ptr->display(); //"对象指针->成员名"
}
int main() {
Base1 base1;
Base2 base2;
Derived derived;
fun(&base1); //用Base1对象的指针调用fun函数
fun(&base2); //用Base2对象的指针调用fun函数
fun(&derived); //用Derived对象的指针调用fun函数
return 0;
}
运行结果:
Base1::display()
Base2::display()
Derived::display()
1.在本程序中,派生类并没有显式给出虚函数声明,这时系统就会遵循以下规则来判断派生类的一个函数成员是不是虚函数。
(1)该函数是否与基类的虚函数有相同的名称。
(2)该函数是否与基类的虚函数有相同的参数个数及相同的对应参数类型。
(3)该函数是否与基类的虚函数有相同的返回值或者满足赋值兼容规则的指针、引用型的返回值。
如果派生类的函数满足了上述条件,就会自动确定为虚函数。这时,派生类的虚函数便覆盖了基类的虚函数。不仅如此,派生类中的虚函数还会隐藏基类中同名函数的所有其它重载形式。
细节: 指向派生类对象的指针仍然可以调用基类中被派生类覆盖的函数成员,方法是使用“::”进行限定。例如,例8-4中如果把fun函数中的ptr->display()改为ptr->Base1::display(),无论ptr所指向对象的动态类型是什么,最终被调用的总是Base1类的display()函数。在派生类的函数中,有时需要先调用基类被覆盖的函数,再执行派生类特有的操作,这时就可以用“基类名::函数名(...)”来调用基类中被覆盖的函数。
习惯: 一般在派生类的函数中也使用virtual关键字,因为这样可以清楚的提示这是一个虚函数。
2.当基类构造函数调用虚函数时,不会调用派生类的虚函数。假设有基类Base和派生类Derived,两个类中有虚成员函数virt(),在执行派生类Derived的构造函数时,需要首先调用Base类的构造函数。如果Base::Base()调用了虚函数virt(),则被调用的是Base::virt(),而不是Derived::virt()。这是因为当基类被构造时,对象还不是一个派生类的对象。
同样,当基类被析构时,对象已不再是一个派生类对象了,所以如果Base::~Base()调用了virt(),则被调用的是Base::virt(),而不是Derived::virt()。
3.只有虚函数是动态绑定的,如果派生类需要修改基类的行为(即重写与基类函数同名的函数),就应该在基类中将相应的函数声明为虚函数。而基类中声明的非虚函数,通常代表那些不希望被派生类改编的功能,也是不能实现多态的,一般也不要重写继承而来的非虚函数。
4.需要强调的是,只有通过基类的指针或引用调用虚函数时,才会发生动态绑定。例如,如果将例8-4中的fun函数的参数类型设定为Base1而非Base1 * ,那么3次fun函数的调用中,被执行的函数都会是Base1::display()。这是因为,基类的指针可以指向派生类的对象,基类的引用可以作为派生类对象的别名,但基类的对象却不能表示派生类的对象。
例如:
Derived d; //定义派生类对象
Base *ptr=&d; //基类指针ptr可以指向派生类对象
Base &ref=d; //基类引用ref可以作为派生类对象的别名
Base b=d; //调用Base1的复制构造函数用d构造b,b的类型是Base而非Derived
这里,Base b=d会用Derived类型的对象d为Base类型的对象b初始化,初始化时使用的是Base的复制构造函数。由于复制构造函数接收的是Base类型的常引用,Derived类型的d符合类型兼容性规则,可以作为参数传递给它,但由于执行的是Base的复制构造函数,只有Base类型的成员会被复制,Derived类中新增的数据成员既不会被复制,也没有空间去存储,因此生成的对象是基类Base的对象。这种用派生类对象复制构造基类对象的行为称作对象切片。
final和override说明符
派生类中如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的,但派生类中的函数并没有覆盖掉基类的版本,这往往意味着错误的发生。想要调试并发现这样的错误往往非常困难。若果使用override标记了某个函数,但该函数并没覆盖已存在的虚函数,此时编译器将报错:
class Base {
public:
virtual void f1(int) const;
virtual void f2();
void f3();
};
class Derived:public Base {
public:
void f1(int) const override; //正确,f1与基类中的f1匹配
void f2(int) override; //错误,基类中没有形如f2(int)的函数
void f3() override; //错误,f3不是虚函数
void f4() override; //错误,基类中没有名为f4的函数
};
相应还能把某个函数指定为final,这意味着该函数不能被覆盖,任何试图覆盖该函数的操作都将引发错误:
class Derived2:public Base {
public:
void f1(int) const final; //不允许后续的其它类覆盖f1(int)
};
class Derived3:public Derived2 {
public:
void f1(int) const; //错误,Derived2的f1已经声明为final
void f2(); //正确,覆盖从Base间接继承来的f2
};
8.3.2 虚析构函数
在C++中,不能声明虚构造函数,但是可以声明虚析构函数。语法为:
virtual ~类名();
如果一个类的析构函数是虚函数,那么,由他派生来的所有子类的析构函数也是虚函数。
简单来说,如果有可能通过基类指针调用对象的析构函数,就需要让基类的析构函数成为虚函数,否则会产生不确定的后果。
例8-5 虚析构函数举例
//8_5.cpp
#include<iostream>
using namespace std;
class Base {
public:
~Base();
};
Base::~Base() {
cout<<"Base destructor"<<endl;
}
class Derived:public Base {
public:
Derived();
~Derived();
private:
int *p;
};
Derived::Derived() {
p=new int(0);
}
Derived::~Derived() {
cout<<"Derived destructor"<<endl;
delete p;
}
void fun(Base *b) {
delete b;
}
int main() {
Base *b=new Derived();
fun(b);
return 0;
}
运行结果为:
Base destructor
这说明,通过基类指针删除派生类对象时调用的是基类的析构函数,派生类的析构函数没有被执行,因此派生类对象中动态分配的内存空间没有得到释放,造成内存泄露。
避免上述问题的方法就是将析构函数声明为虚函数:
class Base {
public:
virtual ~Base();
};
这时输出信息为:
Derived destructor
Base destructor
8.4 纯虚函数与抽象类
抽像类是一种特殊的类,它为一个类族提供统一的操作界面。
抽象类是带有纯虚函数的类。
8.4.1 纯虚函数
纯虚函数是一个在基类中声明的虚函数,声明格式为:
virtual 函数类型 函数名(参数表)=0;
声明为纯虚函数之后,基类中就可以不再给出函数的实现部分。纯虚函数的函数体由派生类给出。
细节:基类中仍然允许对纯虚函数给出实现,但即使给出实现,也必须由派生类覆盖,否则无法实例化。如果将析构函数声明为纯虚函数,必须给出它的实现,因为派生类的析构函数体执行完后需要调用基类的纯虚函数。
注意:纯虚函数不同于函数体为空的虚函数。
8.4.2 抽象类
带有纯虚函数的类是抽象类。抽象类的主要作用是通过它为一个类族建立一个公共的接口,使他们能够更有效的发挥多态特性。抽象类声明了一族派生类的共同接口,而接口的完整实现,即纯虚函数的函数体,要由派生类自己定义。
抽象类派生出新类后,如果派生类给出所有纯虚函数的函数实现,这个派生类就可以定义自己的对象,因而不再是抽象类;反之,仍为抽象类。
抽象类不能实例化,即不能定义一个抽象类的对象,但是,我们可以定义一个抽象类的指针和引用。通过指针和引用,就可以指向并访问派生类对象,进而访问派生类成员,这种访问是具有多态性的。
例8-6 抽象类举例
//8_6.cpp
#include<iostream>
using namespace std;
class Base1 { //基类Base1定义
public:
virtual void display() const=0; //纯虚函数
};
class Base2:public Base1 { //公有派生类Base2定义
public:
void display() const; //覆盖基类的虚函数
};
void Base2::display() const {
cout<<"Base2::display()"<<endl;
}
class Derived:public Base2 { //公有派生类Derived定义
public:
void display() const; //覆盖基类的虚函数
};
void Derived::display() const {
cout<<"Derived::display()"<<endl;
}
void fun(Base1 *ptr) {
ptr->display(); //对象指针->成员名
}
int main() {
Base2 base2; //定义Base2类对象
Derived derived; //定义Derived类对象
fun(&base2); //用Base2对象的指针调用fun函数
fun(&derived); //用Derived对象的指针调用fun函数
return 0;
}
运行结果为:
Base2::display()
Derived::display()