再上一篇:11.4 虚基类
上一篇:第十二章 类的其它特性
主页
下一篇:12.3 静态成员
再下一篇:12.5 指向类成员的指针
文章列表

12.2 虚函数

《VC++程序设计基础》,讲述C++语言的语法和标准库,以及Visual C++ 函数库和MFC类库的使用,并附相关代码示例。

多态性是实现OOP的关键技术之一。它常用虚函数或重载技术来实现。利用多态性实现技术,可以实现调用同一个函数名的函数,但实现完全不同的功能。

在 C++中,将多态性分为二种:一种是编译时的多态性,另一种是运行时的多态性。编译时的多态性是通过函数的重载或运算符的重载来实现的。函数的重载是根据函数调用时,给出的不同类型的实参或不同的实参个数,在程序执行前就可确定应该调用哪一个函数;对于运算符的重载,是根据不同的运算对象在编译时就可确定执行哪一种运算,运算符的重载方法在下一章中介绍。运行时的多态性是指在程序执行之前,根据函数名和参数无法确定应该调用哪一个函数,必须在程序的执行过程中,根据具体的执行情况来动态地确定。这种多态性是通过类的继承关系和虚函数来实现的。这种多态性主要用来实现一些通用程序的设计。

12.2.1 虚函数的定义和使用

为实现某一种功能而假设的虚拟函数称为虚函数,虚函数只能是一个类中的成员函数,并且不能是静态的成员函数(下一节中介绍)。定义一个虚函数的一般格式为:

virtual FuncName();

其中关键字virtual指明这成员函数为虚函数。一旦把某一个类的成员函数定义为虚函数,由这个类所派生出来的所有派生类中,该函数均保持虚函数的特性。当在派生类中定义了一个与该虚函数同名的成员函数,并且这成员函数的参数个数、参数的类型以及函数的返回值类型都与基类中的同名的虚函数一样,则无论是否使用关键字virtual修饰这成员函数,它都成为一个虚函数。换言之,在派生类中重新定义基类中的虚函数时,可以不用关键字virtual来修饰这个成员函数。

例12.4 使用虚函数。

#include

class A{

protected:

int x;

public:

A(){x =1000;}

virtual void print()

{cout <<"x="<

};

class B:public A{

private:

int y;

public:

B() { y=2000;}

void print() //E

{cout <<"y="<

};

class C:public A{

int z;

public:

C(){z=3000;}

void print() //F

{cout <<"z="<

};

void main(void )

{

A a, *pa;

B b;

C c;

a.print();

b.print();

c.print();

pa=&a;

pa->print();

pa=&b;

pa->print();

pa=&c;

pa->print();

}

执行该程序后输出以下2行:

x=1000 y=2000 z=3000

x=1000 y=2000 z=3000

第一行的输出是明显的,是通过调用三个不同对象的成员函数,分别输出x,y,z的值。这种多态性是在编译时处理的,因在编译时,根据对象名就可以确定要调用哪一个成员函数。而后一行的输出,是将三个不同类型的对象起始地址赋给基类的指针变量pa,这在C++中是允许的,即可以将由基类所派生出来的派生类对象的地址赋给基类类型的指针变量。当基类指针指向不同的对象时,尽管调用的形式完全相同,但却是调用不同对象中的虚函数。因此,输出了不同的结果,这就是运行时的多态性。

关于虚函数,说明以下几点:

1、当在基类中把成员函数定义为虚函数后,在其派生类中定义的虚函数必须与基类中的虚函数同名,参数的类型、顺序、参数的个数必须一一对应,函数的返回的类型也相同。若函数名相同,但参数的个数不同或者参数的类型不同时,则属于函数的重载,而不是虚函数。若函数名不同,显然这是不同的成员函数。如上例中均使用相同的函数原形:

void print();

2、实现这种动态的多态性时,必须使用基类类型的指针变量,使该指针指向不同派生类的对象,并通过调用指针所指向的虚函数才能实现动态的多态性。

3、虚函数必须是类的一个成员函数,不能是友元函数,也不能是静态的成员函数。

4、在派生类中没有重新定义虚函数时,与一般的成员函数一样,当调用这种派生类对象的虚函数时,则调用其基类中的虚函数。

5、可把析构函数定义为虚函数,但是,不能将构造函数定义为虚函数。通常在释放基类中和其派生类中的动态申请的存储空间时,也要把析构函数定义为虚函数,以便实现撤消对象时的多态性。

6、虚函数与一般的成员函数相比较,调用时的执行速度要慢一些。为了实现多态性,在每一个派生类中均要保存相应虚函数的入口地址表,函数的调用机制也是间接实现的。因此,除了要编写一些通用的程序,并一定要使用虚函数才能完成其功能要求外,通常不必使用虚函数。

*例 12.5 成员函数调用虚函数。

#include

class A{

public:

virtual void fun1()

{cout <<"A::fun1"<<'\t';

fun2();

}

void fun2()

{ cout<<"A::fun2"<<'\t';

fun3();

}

virtual void fun3()

{cout <<"A::fun3"<<'\t';

fun4();

}

virtual void fun4()

{cout <<"A::fun4"<<'\t';

fun5();

}

void fun5()

{ cout<<"A::fun5"<<'\n'; }

};

class B:public A{

public:

void fun3()

{ cout <<"B::fun3"<<'\t';

fun4();

}

void fun4()

{ cout <<"B::fun4"<<'\t';

fun5();

}

void fun5()

{ cout <<"B::fun5"<<'\n';

}

};

void main(void )

{

B b;

b.fun1(); //E

A a;

a.fun1();

}

执行程序后输出以下2行:

A::fun1 A::fun2 B::fun3 B::fun4 ::fun5

A::fun1 A::fun2 A::fun3 A::fun4 A::fun5

第二行的输出是直观的,而第一行的输出可能会觉得不好理解。在一个基类或其派行生类的成员函数中当然可以调用该类中的虚函数。但对于虚函数的调用,必须针对具体情况进行分析。在调用成员函数时,都带有this指针。我们使用this指针来分析E行的调用关系。b.fun1()首先调用基类中的.fun1(),.fun1()又调用.fun2()。在A类中的.fun2()可用this指针改写为如下等同的形式:

void A::fun2()

{ cout<<"A::fun2"<<'\t';

this->fun3();

}

在执行E行时,this指向对象b,所以,A::fun2()要调用B::fun3(),而不是调用A::fun3()。其它的调用关系,请读者自行分析。

例 12.6 在构造函数中调用虚函数。

#include

class A{

public:

virtual void fun()

{cout <<"A::fun"<<'\t'; }

A(){ fun(); }

};

class B:public A{

public:

B() { fun(); }

void fun(){ cout<<"B::fun"<<'\t'; }

void g()

{ fun(); }

};

class C:public B{

public:

C() { fun(); }

void fun()

{ cout<<"C::fun"<<'\n'; }

};

void main(void )

{

C c; //D

c.g();

}

执行程序后的输出为:

A::fun B::fun C::fun

C::fun

为什么第一行的输出不是:

C::fun C::fun C::fun

呢?这是因为在构造函数中调用虚函数时,只调用自己类中定义的函数(若自己类中没有定义,则调用基类中定义的函数),而不是调用派生类中重新定义的虚函数。执行D行产生对象c时,首先要执行A类的构造函数,调用A类中定义的虚函数fun();然后执行B类的构造函数,调用B类中定义的虚函数fun();最后调用C类的构造函数。

12.2.2 纯虚函数

由一个基类派生出来的类体系中,使用虚函数可对类体系中的任一子类提供一个统一的接口,即用相同的方法来对同一类体系中任一子类的对象进行各种操作,并可把接口与实现两者分开,建立基础类库。在VC++的基础类库中正是使用了这种技术。在定义一个基类时,会遇到这样的情况:无法定义基类中虚函数的具体实现,其实现完全依赖于其不同的派生类。这时,可把基类中的虚函数定义为纯虚函数。定义纯虚函数的一般格式为:

virtual FuncName()=0;

有关纯虚函数的使用,说明以下几点:

1、在定义纯虚函数时,不能定义虚函数的实现部分。

2、把函数名赋于0,本质上是将指向函数体的指针值赋为初值0。所以,与定义空函数不一样,空函数的函数体为空,即调用该函数时,不执行任何动作。在没有重新定义这种纯虚函数之前,是不能调用这种函数的。

3、把至少包含一个纯虚函数的类,称为抽象类。这种类只能作为派生类的基类,不能用来说明这种类的对象。其理由是明显的:因为虚函数没有实现部分,所以不能产生对象。但可以定义指向抽象类的指针,即指向这种基类的指针。当用这种基类指针指向其派生类的对象时,必须在派生类中重载纯虚函数,否则会产生程序的运行错误。如前面的例12.4改写为:

#include

class A{

protected:

int x;

public:

A(){x =1000;}

virtual void print()=0;

};

class B:public A{

private:

int y;

public:

B(){ y=2000;}

void print(){cout <<"y="<

};

class C:public A{

int z;

public:

C(){z=3000;}

void print(){cout <<"z="<

};

void main(void )

{

A *pa;

B b;

C c;

pa=&b;

pa->print();

pa=&c;

pa->print();

}

执行该程序后的输出为:

y=2000

z=3000

如果在主函数中增加说明:

A a;

因为抽象类A不能产生对象,编译时将给出错误信息。而在主函数中增加说明:

A *pp;

pp-> print();

也要产生运行错误,因为pp的值是不确定的。

4、在以抽象类作为基类的派生类中必须有纯虚数的实现部分,即必须有重载纯虚函数的函数体。否则,这样的派生类也是不能产生对象的。

综上所述,可把纯虚函数归结为:抽象类的唯一用途是为派生类提供基类,纯虚函数的作用是作为派生类中的成员函数的基础,并实现动态多态性。

下面我们举二个例子来说明抽象类的简单应用。

例 12.7 建立一个双向链表,要完成插入一个结点、删除一个结点、查找某一个结点,输出链表上的各个结点值。为了把注意力集中在链表的操作上,简化结点上包含的信息,设结点只包含一个整数。

分析:因链表的插入、删除、查找等操作都是相同的,只是结点上包含的信息随不同的应用有所不同。所以,可把实现链表操作部分设计成通用的程序。一个结点的数据结构用二个类来表示,如图12.1所示。类Intobj的数据成员描述结点信息,函数成员完成二个结点的比较,输出结点上的数据等。类Node的数据成员中,包括要构成双向链表时,指向后一个结点的后向指针Next,指向前一个结点的前向指针Prev,指向描述结点数据的指针Info。另外定义一个类List,把它作为类Node的友元,它的数据成员包括指向链表的首指针Head,指向链尾的指针Tail,成员函数实现链表的各种操作,如插入一个结点,删除一个结点等。由于类List是类Node的友元,因此,它的成员函数可以访问Node的所有成员。

在以下的程序中,把实现链表操作的通用部分存放在头文件Ex12_7.h中,把非通用部分的程序放在Ex12_7.cpp中。

Intobj Intobj

描述结点 描述结点

的信息 的信息

指针 指针

前向指针 前向指针

后向指针 后向指针

Node Node

图12.1 链表结构

//Ex12_7.h

#include

#include

class Object{ //定义一个抽象类,用于派生描述结点信息的类public:

Object(){}

virtual int IsEqual(Object &)=0; //实现二个结点数据的比较

virtual void Show()=0; //输出一个结点上的数据

virtual ~Object(){ };

};

class Node{ //结点类

private:

Object *Info; //指向描述结点的数据域

Node *Prev,*Next; //用于构成链表的前后向指针public:

Node (){ Info=0; Prev=0; Next=0;}

Node ( Node &node) //完成拷贝功能的构造函数

{

Info=node.Info;

Prev=node.Prev;

Next=node.Next;

}

void FillInfo(Object *obj){Info =obj;} //使Info指向数据域

friend class List; //定义友元类

};

class List{ //实现双向链表操作的类

Node *Head,*Tail; //链表首和链表尾指针

public:

List(){Head=Tail=0;} //置为空链表

~List(){DeleteList();} //释放链表占用的存储空间

void AddNode(Node *); //在链表尾加一个结点

Node * DeleteNode(Node *); //删除链表中的一个指定的结点

Node *LookUp(Object &); //在链表中查找一个指定的结点

void ShowList(); //输出整条链表上的数据

void DeleteList(); //删除整条链表

};

void List::AddNode(Node *node)

{

if(Head ==0){ //条件成立时,为空链表

Head=Tail=node; //使链表首和链表尾指针都指向这结点

node->Next=node->Prev=0; //指该结点的前后向指针置为空

}

else { //链表不为空,将该结点加入链表尾

Tail->Next=node; //使原链表尾结点的后向指针指向这结点

node->Prev=Tail; //使该结点的前向指针指向原链表尾结点

Tail=node; //使Tail指向新的链表尾结点

node->Next=0;

}

}

Node * List::DeleteNode(Node *node) //删除指定的结点

{

if( node == Head ) //二者相等,表示删除链表首结点

if(node == Tail) //二者相等,表示链表上只有一个结点

Head=Tail=0;

else { //删除链表首结点

Head=node->Next;

Head->Prev=0;

}

else { //删除的结点不是链表上的首结点

node->Prev->Next=node->Next;//从后向链指针上取下该结点

if(node != Tail ) node->Next->Prev=node->Prev;

else {Tail = node->Prev ; Tail->next=0;} //要删除的结点为链表尾结点

}

node->Prev=node->Next=0; //将已删除结点的前后向指针置为空

return( node);

}

Node * List::LookUp(Object &obj) //从链表上查找一个结点

{

Node *pn=Head;

while(pn) {

if(pn->Info->IsEqual(obj)) return pn; //找到要找的结点

pn=pn->Next;

}

return 0; //链表上没有要找的结点

}

void List ::ShowList() //输出链表上各结点的数据值

{

Node *p=Head;

while(p) {

p->Info->Show();

p=p->Next;

}

}

void List::DeleteList() //删除整条链表

{

Node *p,*q;

p=Head;

while (p) {

delete p->Info; //释放描述结点数据的动态空间

q=p;

p=p->Next;

delete q; //释放Node占用的动态空间

}

}

//Ex12_7.cpp

class IntObj:public Object{ //由抽象类派生出描述结点数据的类

int data;

public:

IntObj(int x=0) {data=x;}

void SetData(int x){ data=x; }

int IsEqual(Object &);

void Show(){ cout <<"Data ="<< data <<'\t';} //重新定义虚函数};

int IntObj::IsEqual(Object &obj)

//重新定义比较二个结点是否相等的虚函数

{

IntObj &temp=(IntObj &) obj;

return (data == temp.data); //相等返回1,否则返回0}

void main(void )

{

IntObj *p;

Node *pn,*pt, node;

List list;

for (int i=1;i<5;i++) { //建立包含五个结点的双向链表

p= new IntObj( i+100); //动态建立一个IntOb类的对象

pn= new Node; //建立一个新结点

pn->FillInfo(p); //填写结点的数据域

list.AddNode(pn); //将新结点加入链表尾

}

list.ShowList(); //输出链表上各结点的数据值

cout <<"\n";

IntObj da;

da.SetData( 102); //置要查找的结点数据值

pn=list.LookUp(da); //从链表上查找指定的结点

if (pn) pt=list.DeleteNode(pn); //若找到,则从链表上删除该结点

list.ShowList(); //输出已删除结点后的链表

cout <<"\n";

if (pn) list.AddNode(pt); //将这结点加入链表尾

list.ShowList(); //输出已加一个结点后的链表

cout <<"\n";

}

该程序的输出为:

data=101 data=102 data=103 data=104

data=101 data=103 data=104

data=101 data=103 data=104 data=102

这个例子中对双向链表的操作作了简化,只提供把一个结点加到链表尾,删除链表上的一个结点,从链表上查找一个指定的结点,显示整个链表和删除整个链表。

例中的类IntObj是由抽象类Object 派生而来的,可以根据实际数据结构的需要来定义从基类中继承来的虚函数 IsEqual()和Show()的具体实现。在上例中,链表上的结点只有一个整数,所以,只要判断两个结点上的整数是否相同。由抽象类 Object 派生出来的不同的派生类均可重新定义这二个纯虚函数,这样就可以实现对不同类的对象使用相同的接口实现不同的操作。在程序中加入注解,说明了每一个函数的功能,及主要语句的作用。为此不对每一个函数作进一步的说明。下面举一个结点的数据为字符串的双向链表,也是由抽象类Object 派生出来的。

例 12.8 处理字符串的双向链表。

分析:设每一个结点上的数据是一个指向字符串的指针。由于链表的基本操作与例 12.7完全相同,所以只要包含头文件Ex12_7.h。要做的工作是从抽象类Object派生出描述结点数据的类StrObj,根据结点数据的特点,增加构造函数和析构函数,重新定义比较二结点是否相等的虚函数和输出结点上数据的虚函数。根据需要再设计相应的主函数。程序为:

//Ex12_8.cpp

#include “Ex12_7.h”

class StrObj:public Object{ //由抽象类派生出结点指向字符串的类

char *Str; //指向一个字符串的指针

public:

StrObj() {Str=0;}

StrObj(char *);

~StrObj();

void SetStr(char * );

int IsEqual(Object &); //重新定义虚函数

void Show(){cout <<"String ="<< Str <<'\n';} //重新定义虚函数};

StrObj::StrObj(char *s)

{

Str=new char[strlen(s)+1];

strcpy(Str,s);

}

StrObj::~StrObj()

{

if (Str) delete [ ] Str;

}

void StrObj::SetStr(char *s ) //重新设置字符串

{

if(Str) delete [ ] Str;

Str=new char [strlen(s)+1];

strcpy(Str,s);

}

int StrObj::IsEqual(Object &obj) //判二个字符串是否相同{

StrObj &temp=(StrObj &) obj;

return (strcmp(Str,temp.Str) == 0);

}

void main(void )

{

StrObj *p;

Node *pn,*pt, node;

List list;

char s[200];

for (int i=0;i<5;i++) {

cout<< "Input String:\n";

cin.getline(s,200); //输入一行字符串

p= new StrObj(s);

pn= new Node; //动态产生一个新结点

pn->FillInfo(p);

list.AddNode(pn); //把新结点加入链表中

}

list.ShowList();

cout <<"\n";

StrObj da;

cout<< "Input a String:";

cin.getline(s,200);

da.SetStr(s);

pn=list.LookUp(da); //从链表上查找一个结点

if (pn) pt=list.DeleteNode(pn); //删除已找到的结点

list.ShowList();

cout <<"\n";

if (pn) list.AddNode(pt);

list.ShowList();

}

比较这二个例子的程序,可以看出:无论抽象类Object的派生类的数据结构如何变化,类Node和类List均不要作任何修改,充分体现了OOP技术所支持的代码重用性。从这二个例子也可以看出,使用虚函数可以实现通用程序的设计。