文章目录
第8章 多态性
8.1 多态性概述
多态是指同样的消息(对类的成员函数的调用)被不同类型的对象接收时导致不同的行为(不同的实现,也就是调用了不同的函数)。比如,使用同样的加号“+”,就可以实现整型数之间、浮点数之间、双精度浮点数之间的加法,以及这几种数据类型混合的加法运算。
《面向对象程序设计——C++语言描述(原书第2版)》P.172:多态就是将函数名称到函数入口地址(一个函数在内存中起始的地址就称为这个函数的入口地址)的运行期绑定机制。
多态的四种类型:强制多态、重载多态、包含多态、类型参数化多态。
又可分为:特殊多态性(只是表面的多态性)(包括强制多态、重载)和一般多态性(真正的多态性)(包括包含多态、类型参数化多态)。
强制多态:通过将一种类型的数据转换成另一种类型的数据来实现,也就是数据类型转换(隐式或显式)(比如前述的加法运算符在进行浮点数与整型数相加时,首先进行类型强制转换,把整型数变为浮点数再相加的情况)。
重载:指给同一个名字赋予不同的含义(普通函数和类的成员函数的重载、运算符重载)。
包含多态:C++中采用虚函数实现包含多态。
类型参数化多态:C++中采用模板实现类型参数化多态,包括函数模板和类模板。
多态从实现角度可分为两类:编译时的多态、运行时的多态。
编译时的多态:在编译过程中确定了同名操作的具体操作对象。
运行时的多态:在程序运行过程中才动态地确定操作所针对的具体对象(需要满足的三个条件见8.3.1节)。这种确定操作的具体对象的过程就是绑定。
绑定:指计算机程序自身彼此关联的过程,也就是把一个标识符名和另一个存储地址联系在一起的过程。用面向对象的术语讲,就是把一条消息(对类的成员的调用)和一个对象的方法相结合的过程。
按照绑定进行的阶段的不同,可分为两种绑定方法:静态绑定、动态绑定。
静态绑定(早期绑定,前绑定、编译期绑定):绑定工作在编译连接阶段完成的情况。比如强制、重载、参数多态中操作对象的确定。
动态绑定(晚期绑定,后绑定、运行期绑定):绑定工作在程序运行阶段完成的情况。比如包含多态中操作对象的确定。
8.2 运算符重载
运算符重载是对已有的运算符赋予多重含义,使同一个运算符作用于不同类型的数据时导致不同的行为。
运算符重载的实质是函数重载。在实现过程中,首先把指定的运算表达式转化为对运算符函数的调用,将运算对象转化为运算符函数的实参,然后根据实参的类型来确定需要调用的函数,这个过程是在编译过程中完成的。
8.2.1 运算符重载的规则
运算符重载的规则:
(1)C++中的运算符除了少数几个之外,全部可以重载,而且只能重载C++中已有的运算符,不能定义新运算符;
(2)重载后运算符优先级、结合性不变;
(3)运算符重载是针对新类型数据的实际需要,对原有运算符进行适当的改造。一般来说,重载的功能应当与原有功能相似,不能改变原运算符的操作对象个数,同时,至少要有一个操作对象是自定义类型。
运算符重载,操作对象个数、结合性、优先级、语法结构均不变。
不能重载的运算符:①类属关系(成员访问)运算符.
②成员指针访问运算符.*
③作用域分辨符::
④三目运算符? :
⑤长度运算符sizeof
。
运算符重载的两种形式:重载为类的非静态成员函数,重载为非成员函数。
运算符重载的一般语法形式:
返回类型 operator 运算符(形参表){ //运算符重载
函数体
}
其中,返回类型
即运算结果类型,operator
为定义运算符重载函数的关键字,运算符
是可重载的运算符名称,比如+
,形参表中给出重载运算符所需要的参数和类型。
提示:当以非成员函数形式重载运算符时,有时需要访问运算符参数所涉及的类的私有成员,这时可以把该函数声明为类的友元函数。
当运算符重载为类的非静态成员函数时,函数的参数个数比原来的操作数要少一个(后置“++
”,“--
”除外)。因为运算符重载为类的非静态成员函数时,第一个操作数会被作为函数调用的目的对象,因而无须出现在参数表中,函数体中可以直接访问第一个操作数的成员。
当运算符重载为非成员函数时,函数的参数个数与原操作数个数相同。因为运算符重载为非成员函数时,运算符的所有操作数必须显式地通过参数传递。
8.2.2 运算符重载为成员函数
运算符重载为类的非静态成员函数后,总是通过该类的某个对象来访问重载的运算符。
如果是双目运算符,左操作数是对象本身的数据,由this指针给出,右操作数则需要通过运算符重载函数的参数来传递。
如果是单目运算符,操作数由对象的this指针给出,就不再需要任何参数。
对于双目运算符B
,如果要重载为类的成员函数,用来实现表达式oprd1 B oprd2
,其中oprd1
为A类的对象,则应当把运算符B
重载为A类的成员函数,该函数只有一个形参,该形参的类型是oprd2
所属的类型。重载之后,表达式oprd1 B oprd2
就相当于函数调用oprd1.operator B(oprd2)
。
对于前置单目运算符U
,如果要重载为类的成员函数,用来实现表达式U oprd
,其中oprd
为A类的对象,则应当把运算符U
重载为A类的成员函数,该函数没有形参。重载之后,表达式U oprd
相当于函数调用oprd.oprerator U()
。
对于后置运算符“++
”和“--
”,如果要重载为类的成员函数,用来实现表达式oprd++
或oprd--
,其中oprd
为A类的对象,则应当把运算符++
、--
重载为A类的成员函数,这时函数要带有一个整型(int
)形参。重载之后,表达式oprd++
和oprd--
就相当于函数调用oprd.operator++(0)
和oprd.operator--(0)
。这里的int
类型参数在运算中不起任何作用,只是用于区别后置++
、--
和前置++
、--
(对于函数参数表中并未使用的参数,C++允许不给出参数名)。
复数类加减法运算(双目运算符)重载为成员函数形式的例子:
#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, c4; //定义复数类的对象
cout << "c1="; c1.display();
cout << "c2="; c2.display();
c3 = c1 + c2; //使用重载运算符+完成复数加法
cout << "c3=c1+c2="; c3.display();
c4 = c1 - c2; //使用重载运算符-完成复数减法
cout << "c4=c1-c2="; c4.display();
return 0;
}
前置和后置单目运算符“++
”重载为成员函数形式的例子:
#include<iostream>
using namespace std;
class Clock {
public:
Clock(int hour = 0, int minute = 0, int second = 0);
Clock& operator++(); //前置单目运算符重载
Clock operator++(int); //后置单目运算符重载
void showTime() const;
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;
}
}
Clock& Clock::operator++() { //前置单目运算符重载函数
second++;
if(second>=60){
second -= 60;
minute++;
if (minute >= 60) {
minute -= 60;
hour = (hour + 1) % 24;
}
}
return *this;
}
Clock Clock::operator++(int) { //后置单目运算符重载函数
Clock old = *this;
++(*this); //调用前置“++”运算符
return old;
}
void Clock::showTime() const {
cout << hour << ":" << minute << ":" << second << endl;
}
int main() {
Clock myClock(23, 59, 59);
cout << "First time output: ";
myClock.showTime(); //执行结果为First time output: 23:59:59
cout << "Show myClock++: ";
(myClock++).showTime(); //执行结果为Show myClock++: 23:59:59
cout << "Show ++myClock: ";
(++myClock).showTime(); //执行结果为Show ++myClock: 0:0:1
return 0;
}
8.2.3 运算符重载为非成员函数
运算符重载为非成员函数,运算符所需要的操作数都需要通过函数的形参表来表达(参数个数不变),在形参表中形参从左到右的顺序就是运算符操作数的顺序。
如果需要访问运算符参数对象的成员,可以将该函数声明为类的友元函数。
注意:不要机械地将重载运算符的非成员函数声明为类的友元函数,应仅在需要访问类的私有成员或保护成员时再这样做:(1)如果不将其声明为友元函数,该函数仅依赖于类的接口,只要类的接口不变化,该函数的实现就无需变化;(2)如果将其声明为友元函数,该函数会依赖于类的实现,即使类的接口不变化,只要类的私有数据成员的设置发生了变换,该函数的实现就需要变化。
对于双目运算符B
,如果要实现oprd1 B oprd2
,其中oprd1
和oprd2
中只要有一个为自定义类型,就可以将运算符B
重载为非成员函数,函数的形参为oprd1
和oprd2
。重载之后,表达式oprd1 B oprd2
就相当于函数调用operator B(oprd1,oprd2)
。
对于前置单目运算符U
,如果要实现表达式U oprd
,其中oprd
为自定义类型,就可以将运算符U
重载为非成员函数,函数的形参为oprd
。重载之后,表达式U oprd
相当于函数调用oprerator U(oprd)
。
对于后置运算符“++
”和“--
”,如果要实现表达式oprd++
或oprd--
,其中oprd
为自定义类型,就可以将运算符++
、--
重载为非成员函数。这时函数要有两个形参,一个是oprd
,另一个是整型(int
)形参。重载之后,表达式oprd++
和oprd--
就相当于函数调用operator++(oprd,0)
和operator--(oprd,0)
。这里的int
类型参数在运算中不起任何作用,只是用于区别后置++
、--
和前置++
、--
(对于函数参数表中并未使用的参数,C++允许不给出参数名)。
以非成员函数形式重载Complex的加减法运算和“<<
”运算符:
#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); //运算符+重载非成员函数,要访问private故声明为friend
friend Complex operator - (const Complex& c1, const Complex& c2); //运算符-重载非成员函数,要访问private故声明为friend
friend ostream& operator << (ostream& out, const Complex& c); //运算符<<重载非成员函数,要访问private故声明为friend
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, c4; //定义复数类的对象
cout << "c1=" << c1 << endl; //执行结果为c1=(5,4)
cout << "c2=" << c2 << endl; //执行结果为c2=(2,10)
c3 = c1 + c2; //使用重载运算符+完成复数加法
cout << "c3=c1+c2=" << c3 << endl; //执行结果为c3=c1+c2=(7,14)
c4 = c1 - c2; //使用重载运算符-完成复数减法
cout << "c4=c1-c2=" << c4 << endl; //执行结果为c4=c1-c2=(3,-6)
return 0;
}
上例中,“<<
”操作符的左操作数为ostream
类型的引用(ostream
是cout
类型的一个基类),“<<
”操作符的右操作数是Complex
类型的引用。这样,在执行cout<<c1
时,就会调用operator<<(cout,c1)
。该函数把通过第一个参数传入的ostream
对象以引用形式返回,这是为了支持形如“cout<<c1<<c2
”的连续输出,因为第二个操作数是第一个操作数的返回结果。
运算符重载形式的选择:运算符的两种重载形式各有优势。成员函数的重载方式更加方便,但有时出于以下原因,需要使用非成员函数的重载方式:
(1)要重载的运算符的第一个操作数是不可更改的类型。比如上例中“<<
”运算符的第一个操作数的类型是ostream
,是标准库类型,无法向其中添加成员函数。
(2)以非成员函数的形式重载,支持更灵活的类型转换。比如上例中,可以直接使用5.0+c1
,因为Complex
的构造函数使得实数可以被隐含转换为Complex
类型,这样5.0+c1
就会以operator+(Complex(5.0),c1)
的方式来执行,c1+5.0
也一样,从而支持了实数和复数的相加。而如果以类的成员函数的形式重载,左操作数必须是Complex
类型,不能是实数(因为左操作数会被作为函数调用的目的对象,而调用成员函数的目的对象不会被隐含转换),只有右操作数可以是实数(因为右操作数是函数的参数,可以隐含转换)。
8.3 虚函数
虚函数必须是非静态的成员函数。
虚函数是动态绑定(绑定工作在程序运行阶段完成的情况)的基础。虚函数经过派生之后,在类族中就可以实现运行过程中的多态。
根据类型兼容规则(见第7章7.3节),可以使用派生类的对象代替基类对象。如果用基类指针指向派生类对象,就可以通过这个指针来访问该对象,问题是访问到的只是从基类继承来的同名成员,无法访问到派生类的成员。解决这一问题的办法是:如果需要通过基类的指针指向派生类的对象,并访问某个与基类同名的成员,那么,可以在基类中将这个同名成员声明为虚函数。这样,通过基类类型的指针,就可以使属于不同派生类的不同对象产生不同的行为,从而实现运行过程的多态。
8.3.1 一般虚函数成员
虚函数必须是类的成员函数。
《面向对象程序设计——C++语言描述(原书第2版)》P.180:只有非静态成员函数才可以是虚成员函数,换言之,只有对象成员函数才可以是虚成员函数。
虚成员函数可以继承。
一般虚函数的声明:
virtual 函数类型 函数名(形参表); //类定义中虚函数的原型声明
虚函数的声明只能出现在类定义中的函数原型声明中,而不能出现在成员函数实现的时候。
虚函数一般不声明为内联函数。因为对虚函数的调用需要动态绑定,而对内联函数的处理是静态的。
《面向对象程序设计——C++语言描述(原书第2版)》P.177:C++使用**vtable(虚成员函数表)**来实现虚成员函数的运行期绑定。虚成员函数表的用途是支持运行时查询,使得系统可以将某一函数名绑定到虚成员函数表中的特定入口地址……使用动态绑定会影响程序的效率,因为虚成员函数表需要额外的存储空间,而且对虚成员函数表进行查询也需要额外的时间。因此程序员应选择性地设定哪些函数是虚成员函数。
运行过程中的多态需要满足三个条件:
(1)类之间满足赋值兼容规则(类型兼容规则);
(2)要声明虚函数;
**(3)要由成员函数来调用或者是通过指针、引用来访问虚函数。**如果是使用对象名来访问虚函数,则绑定在编译过程中就进行(为静态绑定),而无须再运行过程中进行。
类型兼容规则(见第7章7.3节):在需要基类对象的任何地方,都可以使用公有派生类的对象来替代。
类型兼容规则中的替代包括以下情况:
(1)派生类的对象可以隐含地转换为基类的对象;
(2)派生类的对象可以初始化基类的引用;
(3)派生类的指针可以隐含转换为基类的指针。
在替代之后,派生类对象就可以作为基类对象使用,但只能使用从基类继承来的成员。通过基类对象名、指针只能访问从基类继承的成员。
虚成员函数例子:
#include<iostream>
using namespace std;
class Base1 {
public:
virtual void display() const; //虚成员函数在类中声明时加virtual
};
void Base1::display() const { //虚成员函数在类外实现时不加virtual
cout << "Base1::display()" << endl;
}
class Base2: public Base1{
public:
void display() const; //覆盖基类的虚函数,覆盖时派生类中virtual可省略
};
void Base2::display() const {
cout << "Base2::display()" << endl;
}
class Derived: public Base2 {
public:
void display() const; //覆盖基类的虚函数,覆盖时派生类中virtual可省略
};
void Derived::display() const {
cout << "Derived::display()" << endl;
}
void fun(Base1* ptr) { //函数fun的参数为指向基类对象的指针
ptr->display(); //“对象指针名->成员名”
}
int main() {
Base1 base1;
Base2 base2;
Derived derived;
//用Base1对象的指针调用fun函数
fun(&base1); //执行结果为Base1::display()
//用Base2对象的指针调用fun函数
fun(&base2); //执行结果为Base2::display()
//用Derived对象的指针调用fun函数
fun(&derived); //执行结果为Derived::display()
return 0;
}
判断派生类的某个成员函数是否为虚函数:
(1)该函数是否与基类的虚函数有相同的名称;
(2)该函数是否与基类的虚函数有相同的参数个数及相同的对应参数类型;
(3)该函数是否与基类的虚函数有相同的返回值或者满足赋值兼容规则的指针、引用型的返回值。
若派生类的成员函数满足上述条件,就会被自动确认为虚函数。这时,派生类的虚函数便覆盖了基类的虚函数,不仅如此,派生类中的虚函数还会隐藏基类中同名函数的所有其他重载形式。
派生类覆盖基类的成员函数时,派生类函数的virtual关键字可以省略。但很多人习惯于在派生类函数中也使用virtual关键字,因为这样可以清楚地提示“这是一个虚函数”。
**用指向派生类对象的指针仍然可以调用基类中被派生类覆盖的成员函数,方法是使用“::
”进行限定。**比如若在上例中将fun函数中的ptr->display()
改为ptr->Base1::display()
,那么,无论ptr
所指向的对象的动态类型是什么,最终被调用的将总会是Base1
类中的display()
函数。
在派生类的函数中,有时需要先调用基类被覆盖的函数,再执行派生类特有的操作,这时,就可以用“基类名::函数名(...)
”来调用基类中被覆盖的函数。
**基类构造函数调用虚函数时,不会调用派生类的虚函数。**因为当基类被构造时,对象还不是一个派生类的对象。析构时类似。
只有虚函数是动态绑定的,如果派生类需要修改基类的行为,就应该在基类中将相应的函数声明为虚函数。而基类中声明的非虚函数,通常代表那些不希望被派生类改变的功能,也是不能实现多态的。
一般不要重写继承而来的非虚函数,因为那会导致通过基类指针和派生类额指针或对象调用同名函数时,产生不同的结果,从而引起混乱。
**在重写继承而来的虚函数时,如果函数有默认形参值,不要重新定义不同的值。**原因是,通过一个指向派生类对象的基类指针,可以访问到派生类的虚函数,但默认形参值却只能来自基类的定义。
只有通过基类的指针或引用调用虚函数时,才会发生动态绑定。比如,如果将上例中fun
函数的参数类型设定为Base1
而非Base1*
,那么三次fun
函数的调用中,被执行的函数都会是Base1::display()
。这是因为,基类的指针可以指向派生类的对象,基类的引用可以作为派生类对象的别名,但基类的对象却不能表示派生类的对象。比如:
Derived d;
Base* ptr=&d; //基类的指针可以指向派生类的对象
Base &ref=d; //基类的引用可以作为派生类对象的别名
Base b=d; //注意:基类的对象却不能表示派生类的对象。此行中,调用Base类的复制构造函数用d构造b,b的类型是Base而非Derived
上面最后一行,Base b=d
会用Derived
类型的对象d
为Base
类型的对象b
初始化,但初始化时使用的是Base
的复制构造函数。由于复制构造函数接收的是Base
类型的常引用,Derived
类型的d
符合类型兼容规则,可以作为参数传递给它。由于执行的是Base
类的复制构造函数,只有Base
类的成员会被复制,Derived
类中新增的数据成员既不会被复制,也没有空间去存储,因此生成的对象是基类Base
的对象。这种用派生类对象复制构造基类对象的行为称为对象切片。这时,如果用b
调用Base
类的虚函数,调用的目的对象是对象切片后得到的Base
对象,与Derived
类的d
对象全无关系,对象的类型很明确,因此无须动态绑定。
8.3.2 虚析构函数
在C++中,不能声明虚构造函数,但是可以声明虚析构函数。
虚析构函数的声明:
virtual ~类名(); //声明虚析构函数
虚析构函数可继承、可派生。如果一个类的析构函数是虚函数,那么由它派生而来的所有子类的析构函数也是虚函数。析构函数设置为虚函数之后,在使用指针引用时可以动态绑定,实现运行时的多态,保证使用基类类型的指针就能够调用适当的析构函数针对不同的对象进行清理工作。
先看一个未使用虚析构函数的程序
#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
《面向对象程序设计——C++语言描述(原书第2版)》P.180:通常来说,如果基类有一个指向动态分配内存的数据成员,并定义了负责释放这块内存的析构函数,那么,就应该将这个析构函数声明为虚析构函数,这样可以保证在以后添加该类的派生类时发挥多态性的作用。
8.4 纯虚函数与抽象类
抽象类是一种特殊的类,处于类层次的上层,一个抽象类自身无法实例化,也就是说无法定义一个抽象类的对象,只能通过继承机制,生成抽象类的非抽象派生类,然后再实例化。
抽象类是带有纯虚成员函数的类。
8.4.1 纯虚函数
对于在基类中无法实现的函数,可以只在基类中声明函数原型用于规定整个类族的统一接口形式,而在派生类中再给出函数的具体实现。C++中提供纯虚(成员)函数来实现这一功能。
纯虚(成员)函数是一个在基类中声明的虚函数,它在该基类中没有定义具体的操作内容,需要各派生类根据实际需要给出各自的定义。
纯虚函数的声明格式:
virtual 函数类型 函数名(参数表)=0; //必须在基类中才能声明纯虚函数
纯虚函数的声明与虚函数成员的原型声明,在书写格式上的不同只在于在后面加了“=0
”。
声明为纯虚函数之后,基类中就可以不再给出函数的实现部分。
纯虚函数的函数体由派生类给出。
其实,基类中仍然允许对纯虚函数给出实现,但即使给出实现,也必须由派生类覆盖(见8.3.1节),否则无法实例化。在基类中对纯虚函数定义的函数体的调用,必须通过“基类名::函数名(参数表)
”的形式。
如果将析构函数声明为纯虚函数,必须给出它的实现,因为派生类的析构函数的函数体执行完成后,需要调用基类的纯虚函数。
注意:纯虚函数不同于函数体为空的虚函数:
纯虚函数 | 虚函数 |
---|---|
根本没有函数体 | 空的虚函数的函数体为空 |
所在的类是抽象类,不能实例化 | 所在的类可以实例化 |
可以派生出新类,然后在新类中给出虚函数新的实现,并且该实现可以具有多态特征 | 可以派生出新类,然后在新类中给出虚函数新的实现,并且该实现可以具有多态特征 |
8.4.2 抽象类
抽象类:带有纯虚成员函数的类。
**抽象类只能作为基类使用,不能声明对象。**故抽象类又被称为抽象基类。
《面向对象程序设计——C++语言描述(原书第2版)》P.197:一个纯虚成员函数就可以使一个类成为抽象基类,一个抽象基类可以有其他不是纯虚成员函数或甚至不是虚函数的成员函数,还可以有数据成员。抽象基类的成员可以是private、protected或private。
抽象类的主要作用是通过它为一个类族建立一个公共接口,使它们能够更有效地发挥多态特性。而接口的完整实现,即纯虚函数的函数体,要由派生类自己定义。
《面向对象程序设计——C++语言描述(原书第2版)》P.196:所谓公共接口是一个成员函数的集合,任何支持该接口的类必须定义该集合中的所有函数,这些类应该以恰当的方式来定义这些成员函数。
抽象类派生出新类之后,如果派生类给出所有纯虚函数的函数实现,这个派生类就可以定义自己的对象,因而该派生类不再是抽象类;反之,如果派生类没有给出全部纯虚函数的实现,这时的派生类仍然是一个抽象类。
抽象类不能实例化,即不能定义一个抽象类的对象,但是可以定义一个抽象类的指针和引用。通过该指针或引用,可以指向并访问派生类的对象,进而访问派生类的成员,这种访问是具有多态特征的。
补充:重载、覆盖和遮蔽
(以下补充部分主要来源于《面向对象程序设计——C++语言描述(原书第2版)》P.189-194)
在C++中,仅有虚函数是在运行期进行绑定的,因此仅有虚函数才具有真正意义上的多态。
① 重载
如果顶层函数有不同的参数,它们的函数名可以相同。在一个类中,成员函数可以有相同的函数名,只要它们的函数签名不同即可,这种情况称为重载。
重载与编译期绑定(静态绑定)相对应,不管是成员函数还是顶层函数。
《面向对象程序设计——C++语言描述(原书第2版)》P.47:函数签名:C++要求重载的函数具有不同的签名,函数签名包括:函数名???(自相矛盾???)、参数的个数、数据类型和顺序。
② 覆盖
假定基类Base
有一个成员函数m
,其派生类Derived
也有一个具有相同函数签名的成员函数m
。
如果这个成员函数是虚函数,则任何通过指针或引用对成员函数m
的调用都会激活运行期绑定,对于这种情况,称派生类的成员函数Derived::m
覆盖了其基类的成员函数Base::m
。
如果成员函数不是虚函数,对m
的任何调用均为编译期绑定。
③ 遮蔽
假定基类Base
拥有一个非虚成员函数m
,其派生类Derived
也有一个成员函数m
,就称函数Derived::m
遮蔽了继承而来的函数Base::m
。
如果派生类的同名成员函数与基类的这个成员函数有不同的函数签名,那么这种遮蔽情况会相当复杂,建议谨慎运用这种遮蔽类型的名字共享机制。
④ 名字共享
如果函数共享一个函数名,可能引发一些问题,但有时我们又希望几个函数共享一个函数名:
- 重载函数名为顶层函数。对于程序员来说,只使用一个函数名(如print)就可以执行不同的函数体是非常方便的,另外,我们通常将一些操作符(如
<<
)设计为顶层重载函数。 - 重载构造函数。一个类经常有几个构造函数,这种情况也需要函数重载。
- 非构造函数是同一个类中名字相同的成员函数。比如设计三个print成员函数,这种做法与重载函数名为顶层函数类似。
- 继承层次中的同名函数(特别是虚函数)。为了发挥多态性的作用,虚函数必须具有相同的函数签名(具有相同的函数名)。在典型的多态情况下,派生类的虚函数覆盖了从基类直接继承来的虚函数。要形成覆盖,成员函数必须为函数签名相同的虚函数。