VC++ 2008专题应用程序开发实例精讲
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.7 类

1.7.1 类的定义

类是一种复杂的数据类型,它是将不同类型的数据和与这些数据相关的操作封装在一起的集合体。这有点像C语言中的结构,唯一不同的就是结构没有定义“与数据相关的操作”。“与数据相关的操作”就是指平时经常看到的“方法”。类具有更高的抽象性,类中的数据具有隐藏性,类还具有封装性。

类的结构(也即类的组成)是用来确定一类对象的行为的,而这些行为是通过类的内部数据结构和相关的操作来确定的。这些行为是通过一种操作接口来描述的(也就是平时所看到的类的成员函数),使用者关心的只是接口的功能(也就是只关心类的各个成员函数的功能),对它是如何实现的并不感兴趣。操作接口又被称为这类对象向其他对象所提供的服务。

类的定义格式一般分为说明部分和实现部分。说明部分用来说明该类中的成员,包含数据成员的说明和成员函数的说明。成员函数是用来对数据成员进行操作的,又称为“方法”。实现部分用来对成员函数进行定义。

类的一般定义格式如下:

class <类名>
{
  public:    <成员函数或数据成员的说明>
  private:   <数据成员或成员函数的说明>
};    <各个成员函数的实现>

下面简单地对上面的格式进行说明。

class是定义类的关键字,<类名>是一种标识符,通常用T字母开始的字符串作为类名。花括号内是类的说明部分(包括前面的类头),说明该类的成员。类的成员包含数据成员和成员函数两部分。从访问权限上来分,类的成员又分为公有的(public)、私有的(private)和保护的(protected)三类。公有的成员用public来说明,公有部分往往是一些操作(即成员函数),它是提供给用户的接口。这部分成员可以在程序中引用。私有的成员用private来说明。私有部分通常是一些数据成员,这些成员是用来描述该类中的对象的属性的。用户无法访问它们,只有成员函数或经特殊说明的函数才可以引用它们,它们是被隐藏的部分。

关键字public, private和protected被称为访问权限修饰符或访问控制修饰符。它们与在类体内(即花括号内)出现的先后顺序无关,并且允许多次出现,用它们来说明类成员的访问权限。

<各个成员函数的实现>是类定义中的实现部分,这部分包含所有在类体内说明的函数的定义。如果一个成员函数已在类体内定义了,则实现部分将不出现。如果所有的成员函数都在类体内定义,则实现部分可以省略。

1.7.2 类的继承

C++提供了描述一般—特殊关系的语法,在C++中称为类的派生或继承,通常分为单一继承和多重继承。在C++中常把一般—特殊关系中的一般类称为父类,而把特殊类称为子类。

1.单一继承

单一继承是指只有一个基类的继承,这是类的一种常见继承方式,对于单一继承C++提供了下述常见的语法格式:

class <DerivedClassName>:<AccessSpecifier><BaseClassName>
{...};

其中,class为关键字,编译器遇到class后,将把其后的一对花括号括起来的部分作为类的说明,该类以标识符<DerivedClassName>为名字,其后的冒号说明该类是从名字为<BaseClassName>的类派生而来,<AccessSpecifier>是访问说明符。为了与类体中的访问说明符相区别,通常称为继承方式或派生方式,<AccessSpecifier>可以是public, private和protected三个关键字之一,分别称为公有派生、私有派生和保护派生,当这个位置空缺时默认为私有派生。花括号内的部分为类体,与一般类的类体相同。

类体中的成员为子类所特有的数据成员(属性)和成员函数(操作),虽然没有在子类中写明所继承的父类成员,但是父类成员在一定限制下属于子类。因此在由一个类的定义创建一个对象时,不但要初始化它自己的数据成员,也要初始化其父类的数据成员,即在构造函数中调用父类构造函数对在父类中描述的数据成员进行初始化。初始化顺序是首先进行父类数据成员的初始化,然后进行本身的初始化。

C++中派生类初始化构造函数的格式如下:

DerivedClassName::DerivedClassName(ArgList0):BaseClassName(ArgList1)
{...}

定义中指明了派生类构造函数调用基类的哪个构造函数初始化父类中的数据成员,格式中的ArgList0为构造函数的形参表,ArgList1为父类构造函数的实参表,其中的各实参是由ArgList0中各形参组成的常量表达式。

派生类可以有析构函数,其形式与一般类的析构函数相同。派生类对象消亡前会首先自动调用自身的析构函数,然后自动调用父类的析构函数,所以析构函数的调用顺序与构造函数正好相反。一个类既可以作为子类继承父类的属性和操作,同时又可以作为父类派生出其他子类,还可以从一个类派生出多个子类,或者同时继承多个父类。

2.基类成员访问控制

有两个因素同时控制着派生类对基类成员的访问权限,这两个因素就是基类类体中类成员的访问说明符及派生类的派生方式。表1-9总结了这两个因素对基类成员访问权限的影响规律。从表中可以看出,在任何派生方式下,基类的私有成员都是派生类不可访问的成员;在保护派生情况下,除私有成员外均被派生类以保护成员方式所继承;公有派生时,不改变基类公有和保护成员的访问权限。

表1-9 基类成员在派生类中的访问权限

C++提供的三种不同派生方式与类内访问控制结合在一起,形成了对类成员的访问控制机制。处于不同开发层次的编程者被允许访问不同层次类的成员,这样可以消除程序开发中的一些不稳定因素。比如对于基类中的静态数据成员,如果没有上述访问控制,在派生类中就可以改变基类数据成员的值,那么对同一基类的其他子类就会产生影响,如果这两个派生类不是同一个编程者所开发,这种影响往往是无法预知的。

1.7.3 类的多态

多态(polymorphism)一词最初来源于希腊语polumorphos,含义是具有多种形式或形态的情形。在程序设计领域,一个广泛认可的定义是“一种将不同的特殊行为和单个泛化记号相关联的能力”。和纯粹的面向对象程序设计语言不同,C++中的多态有着更广泛的含义。除了常见的通过类继承和虚函数机制生效于运行期的动态多态(dynamic polymorphism)外,模板也允许将不同的特殊行为和单个泛化记号相关联,由于这种关联处理在编译期而非运行期,因此被称为静态多态(static polymorphism)。

事实上,带变量的宏和函数重载机制也允许将不同的特殊行为和单个泛化记号相关联。然而,习惯上并不将它们展现出来的行为称为多态(或静态多态)。如今,当谈及多态时,如果没有明确所指,默认就是指动态多态,而静态多态则是指基于模板的多态。这里先介绍一下函数多态。

类的一个成员函数被说明为虚函数表明它目前的具体实现仅是一种假设,只是一种适用于当前类的实现,在未来类的派生链条中有可能重新定义这个成员函数的实现(在英文中称为函数的override,具有重写、替代和覆盖的含义)。关于虚函数的定义可以参考如下程序段:

class <ClassName>
{
...
vitual void MyFunction;
...
};
void <ClassName>::MyFunction
{...}

当某一个成员函数在基类中被定义为虚函数时,那么只要同名函数出现在派生类中,并且在类型、参数等方面均保持相同,则即使在派生类中函数前没有关键字virtual,它也被默认为一个虚函数。为了保证风格统一,建议在派生类的虚函数前仍然添加关键字virtual。

1.虚函数的实现

不同的语言环境实现虚函数的机制不同,下面通过类CDerived的派生介绍Visual C++中如何实现虚函数。

class CBase
{
public:
int nBase;
CBase()
{ nBase=1; }
virtual void BaseFunc(){}
};
class CDerived : public CBase
{
public:
int nDerived;
CDerived()
{ nDerived=2; }
virtual void DerivedFunc() {}
virtual void BaseFunc() {]
};
void main()
{
CBase bObj, *pb;
CDerived dObj;
pb-&dObj;
}

程序中类CDerived继承了CBase的数据成员和成员函数,它所说明的虚成员函数CDerived::BaseFunc()替代了父类的虚成员函数CBase::BaseFunc()。CDerived类还说明了另一个虚成员函数CDerived::DerivedFunc()。

对CBase类对象bObj,编译器添加了一个隐藏的指针变量CBase::vfptr,该指针变量指向由编译器放置在内存中的CBase类虚函数表(Visual C++中称为vftble),本例中这个表的起始地址为0x0041F01C,表中存有CBase类所有虚函数的入口地址,CBase类只定义了一个虚函数CBase::BaseFunc(),其入口地址被分配为0x0040100A,在开始地址为0x0040100A的连续5个字节中存放着一条转移指令,使程序跳转到CBase:::BaseFunc()的可执行代码段。派生类CDerived对象dObj的情形与此类似,但其中所含父类实例中的虚函数表指针被替换成为CDerived::vfptr,该指针指向派生类的虚函数表(本例为0x0041F020)。因为在派生类CDerived中重新定义了虚函数CDerived::BaseFunc(),所以该表中有一条记录,其中存放着cDerived::BaseFunc()的入口地址(本例为0x00401005),如果派生类cDerived中没有重新定义BaseFunc(),那么派生类虚函数表相应记录中将存放父类虚函数CBase::BaseFunc()的入口地址(本例为0x0040100A)。

对于CBase *类型的指针pb,赋值前,其所指对象的数据成员和成员函数的组成与bObj相同,其中含有CBase::vfptr,但它们的值均为无效值。执行pb=&dObj进行赋值后,pb指向dObj中父类的实例,但该实例与直接创建的CBase类对象(例如bObj)不同,这个实例的虚函数表指针指向派生类CDerived的虚函数表,而不是指向父类CBase的虚函数表。

由于派生类虚函数指针表中记录的函数指针BaseFunc随着派生类中是否重新定义了BaseFunc()而变,因此当父类指针pb指向派生类CDerived对象dObj时,函数引用pb->BaseFunc()将根据具体函数指针BaseFunc调用BaseFunc()不同实现,即如果派生类中重新定义BaseFunc()则调用它,否则调用父类CBase::BaseFunc()。

当pb指向CBase类对象时,函数引用pb->BaseFunc()将调用CBase::BaseFunc()。因为pb所指对象的虚函数表指针指向CBase的虚函数表。

2.虚函数的使用

虚函数的实现机制和调用方式与非虚函数不同,因此虚函数的使用具有特殊性。

(1)虚函数的访问权限

派生类中虚函数的访问权限并不影响虚函数的动态联编,例如下面的程序实例中,派生类Cderived中重新定义了虚函数Func4()。在程序的运行中,由于虚函数的机制,在CBase::Func3()中调用Func3()时会调用CDerived::Func3(),而该函数的访问权限是私有的。

(2)成员函数中调用虚函数

在类的成员函数中可以直接调用相应类中定义或重新定义的虚函数,分析这类函数的调用次序时要注意成员函数的调用一般是隐式调用,应该将其看成是通过this指针的显式调用。

在成员函数中调用虚函数的过程如下。

#include "iostream.h"
class CBase
{
public:
void Func1()
{
cout<<"=>CBase::Func1=>";
Func2();
}
void Func2()
{
cout<<"=>CBase::Func2=>";
Func3();
}
virtual void Func3()
{
cout<<"=>CBase::Func3=>";
Func4();
}
void Func4()
{ cout<<"CBase::Func4=>out"<<endl; }
};
class CDerived: public CBase
{
private:
virtual void Func4()
{ cout<<"Derved::Func4=>out"<<endl; }
public:
void Func1()
{
cout<<"=>Derived::Func1=>";
CBase::Func2();
}
void Func2()
{
cout<<"=>Derived::Func2=>";
Func3();
}
};

void main()
{
CBase * pBase;
CDerived dObj;
pBase=&dObj;
pBase->Func1();
dObj.Func1();
}

程序运行后屏幕将显示:

=>CBase::Func1=>CBase::Func2=>CBase::Func3=>Derved::Func4=>out
=>Derived::Func1=>CBase::Func2=>CBase::Func3=>Derved::Func4=>out

由上述结果可见,在成员函数中可以引用虚函数,其形式与引用非虚函数相同,但是由于虚函数的调用将与调用者相匹配,因此函数的调用顺序更复杂。在父类成员函数中调用虚函数可能导致对子类虚函数的调用,例如上述程序中,dObj.Func1()调用的是子类的非虚函数,在这个函数中调用父类中的CBase::Func2()。由于派生类中没有重定义虚函数Func3(),因此接下来调用CBase::Func3(),而该函数对虚函数Func4()的调用等价于this->Func4()。因为this指向的是CDerived类的对象dObj,所以CBase::Func3()中对Func4()的调用只是形式上对CBase类成员函数的调用。

程序中pBase->Func1()的调用情况也可以按类似方法进行分析,Func1()不是虚函数,因此尽管pBase指向子类对象dObj,但pBase的类型为CBase *,最后还是调用CBase::Func2()。接下来的函数调用顺序与上面dObj.Func1()的函数调用顺序相同。

(3)构造函数和析构函数调用虚函数

对于出现在构造函数和析构函数中的虚函数,C++编译器将采取静态联编的方式决定所调用的具体函数,因此调用的虚函数只能是基类或自己所属类中的虚函数。之所以这样规定,是因为子类实例的创建在父类初始化之前,所以在构造函数中无法调用子类的虚函数。另一方面,子类析构函数的调用又在父类实例消亡之前,所以在析构函数中无法调用子类的析构函数。

(4)空虚函数

在程序开发过程中有时需要在类的某个派生类中定义虚函数,但也并不是必须保持派生层次中虚函数链条上的每个虚函数都需要具体实现。如果某层虚函数无需重新实现但又必须提供该虚函数的说明,则只需要在相应层定义空的虚函数即可,这样就可以维系类派生层次中虚函数路径的存在了。

1.7.4 运算符重载

在C++中,对于同一个运算符“/”,表达式“1/2”和“1.0/2.0”的值不同,因为前一个表达式是整型数的除法,结果为0,而后一个表达式为浮点数的除法,结果为0.5。同样的参数值,同样的运算符,运算结果竟然不同!同一个运算符代表多于一种的操作,称为运算符的重载,通过提供运算符的重载,编程者可以提供自己的运算符操作定义,程序运行时运算符将根据参与运算的操作数类型而选择不同操作。

提供运算符重载是现代高级程序设计语言的特色语法之一。C++通过以运算符函数的形式定义运算符的操作,因此可以将运算符的重载问题转化为运算符函数的重载。

C++编译器在遇到表达式3@2时,首先将它解释成函数表达式operator@(3, 2),这里operator @相当于函数名,两个操作数分别为函数的参数,@代表+, -, *或/等一系列可以重载的运算符。编译器接下来的任务是查找形如operator @(int, int)的函数,并调用它。因此重载运算符只要重载形如operator@()的运算符函数即可。

下例将重载运算符+。

#include "iostream.h"
class CComplex
{
public:
double m_dReal;
double m_dImag;
public:
CComplex(double r=0, double i=0)
{
m_dReal=r;
m_dImag=i;
}
};
CComplex operator + (CComplex&a, CComplex&b)
{ return CComplex(a.m_dReal+b.m_dReal, a.m_dImag+b.m_dImag); }
void main()
{
CComplex c1(1,2), c2(3,4), z;
z=c1+c2;
cout<<z.m_dReal<<" + i"<<z.m_dImag<<endl;
}

程序运行后输出信息:

4+i6

上述程序段实现了简单复数类CComplex对象之间的复数加法操作。运算符的重载给编程者带来了方便,使得表达式的形式更加自然便捷,更符合人们的习惯。C++中运算符@的重载函数格式一般如下:

<ReturnType>operator @(<ArgList>)
{/*函数具体定义*/}

重载形式有很多,上式只不过是一种常见的形式,由于由运算符和操作数组成的表达式通常用于另外的表达式中,其结果通常用做其他表达式的参数,因此运算符重载函数一般具有返回值类型。运算符的重载可以分为友元运算符和类运算符两类。