5.1 类和对象
面向对象最大的特征就是提出了类和对象的概念。在以面向对象的方式开发应用程序时,将遇到的各种事物抽象为类,类中包含数据和操作数据的方法,用户通过实例化类对象来访问类中的数据和方法。举例来说,杯子是一个类,那么茶杯是该类的对象,酒杯也是该类的对象,玻璃杯、塑料杯同样都是杯子类的对象。本节将介绍有关类和对象的相关知识。
5.1.1 类的定义
C++语言中类和结构体类似,其中可以定义数据和方法。C++语言提供了class关键字定义类,其语法格式如下。
类的定义包含两部分,即类头和类体。类头由class关键字和类名构成;类体由一组大括号“{}”和一个分号“;”构成。类体中通常定义类的数据和方法,其中数据描述的是类的特征(也被称为属性);方法实际上是类中定义的函数,描述的是类的行为。下面的代码定义了一个CUser类。
【例5.1】 定义一个CUser类。
上述代码定义了一个CUser类,其中包含两个数据成员和一个方法。对于方法的定义,直接放在了类体中;此外,也可以将方法放在类体的外面进行定义。
【例5.2】 将方法放置在类体之外。
当方法的定义放置在类体外时,方法的实现部分首先是方法的返回值,然后是方法名称和参数列表,最后是方法体。
说明
当方法的定义放置在类体外时,方法名称前需要使用类名和域限定符“::”来标记方法属于哪一个类。
注意
在定义类的数据成员时,注意不能像定义不同变量一样进行初始化。
例如,下面的类定义是错误的。
【例5.3】 不能直接对类数据成员进行初始化。
注意
在定义类时,不要忘记末尾的分号,这是初学者最容易也最经常犯的一个错误。
5.1.2 类成员的访问
类成员主要是指类中的数据成员和方法(方法也被称为成员函数)。在定义类时,类成员是具有访问限制的。C++语言提供了3个访问限定符用于标识类成员的访问,分别为public、protected和private。public成员也被称为公有成员,该成员可以在程序的任何地方进行访问。protected成员被称为保护成员,该成员只能在该类和该类的派生类(子类)中访问,除此之外,程序的其他地方不能访问保护成员。private成员被称为私有成员,该成员只能在该类中访问,派生类以及程序的其他地方均不能访问私有成员。如果在定义类时没有指定访问限定符,默认为private,如5.1.1节中定义的CUser类。
下面重新定义CUser类,将各个成员设置为不同的访问级别。
【例5.4】 设置类成员的不同访问级别。
在上述代码中,读者需要注意的是GetUsername方法的定义,在方法的最后使用了const关键字。在定义类方法时,如果不需要在方法中修改类的数据成员,建议在方法声明的最后使用const关键字,表示用户不能在该方法中修改类的数据成员。例如,如果在GetUsername方法中试图修改m_Password成员或m_Username成员将是非法的。
如果类中包含有指针成员,在const方法中不可以重新为指针赋值,但是可以修改指针所指向地址中的数据。例如:
在定义类后,需要访问类的成员。通常类成员(静态成员除外)的访问是通过对象实现的,对象被称为类的实例化。当程序中定义一个类时,并没有为其分配存储空间,只有当定义类的对象时,才分配存储空间。对象的定义与普通变量的定义是相同的,下面的代码定义了一个CUser类的对象user。
定义了类的对象后,即可访问类的成员。例如:
类成员是具有访问权限的,如果类的外部访问私有或受保护的成员将出现访问错误。例如:
上述代码试图访问CUser类的私有成员m_Password,产生了编译错误。
在定义类对象时,也可以将类对象声明为一个指针。例如:
程序中可以使用new运算符来为指针分配内存。例如:
也可以写成如下形式:
说明
如果类对象被定义为指针,需要使用“->”运算符来访问类的成员,而不能使用“. ”运算符来访问。
例如:
如果将类对象定义为常量指针,则对象只允许调用const方法。例如:
5.1.3 构造函数和析构函数
每个类都具有构造函数和析构函数。其中,构造函数在定义对象时被调用,析构函数在对象释放时被调用。如果用户没有提供构造函数和析构函数,系统将提供默认的构造函数和析构函数。
1. 构造函数
构造函数是一个与类同名的方法,可以没有参数、有一个参数或多个参数,但是构造函数没有返回值。如果构造函数没有参数,该函数被称为类的默认构造函数。下面的代码显式地定义了一个默认的构造函数。
【例5.5】 定义默认的构造函数。(实例位置:资源包\TM\sl\5\1)
说明
如果用户为类定义了构造函数,无论是默认构造函数还是非默认构造函数,系统均不会提供默认的构造函数。
下面定义一个CUser类的对象,它将调用用户定义的默认构造函数。
执行上述代码,结果如图5.1所示。
从图5.1中可以发现,在定义user对象时,调用了默认的构造函数对数据成员进行了赋值。下面再定义一个非默认的构造函数,使用户能够在定义CUser类对象时为数据成员赋值。
【例5.6】 定义非默认的构造函数。(实例位置:资源包\TM\sl\5\2)
下面定义两个CUser对象,分别采用不同的构造函数。
执行上述代码,效果如图5.2所示。
图5.1 默认构造函数
图5.2 非默认构造函数
说明
一个类可以包含多个构造函数,各个构造函数之间通过参数列表进行区分。
从图5.2所示的输出结果可以发现,语句“CUser Customer("SK","songkun");”调用了非默认的构造函数,通过传递两个参数初始化CUser类的数据成员。如果想要定义一个CUser对象的指针,并调用非默认构造函数进行初始化,可以采用如下形式。
在定义常量或引用时,需要同时进行初始化。5.1.1节中已经介绍了在类体中定义数据成员时,为其直接赋值是非法的,那么如果在类中包含常量或引用类型数据成员时该如何初始化呢?
类的构造函数通过使用“:”运算符提供了初始化成员的方法。
【例5.7】 在构造函数中初始化数据成员。
上述代码在定义CBook类的构造函数时,对数据成员m_Price和m_ChapterNum进行了初始化。
说明
编译器除了能够提供默认的构造函数外,还可以提供默认的复制构造函数。当函数或方法的参数采用按值传递时,编译器会将实际参数复制一份传递到被调用函数中,如果参数属于某一个类,编译器会调用该类的复制构造函数来复制实际参数到被调用函数。复制构造函数与类的其他构造函数类似,以类名作为函数的名称,但是其参数只有一个,即该类的常量引用类型。因为复制构造函数的目的是为函数复制实际参数,没有必要在复制构造函数中修改参数,因此参数定义为常量类型。
下面的代码为CBook类定义了一个复制构造函数。
【例5.8】 定义复制构造函数。(实例位置:资源包\TM\sl\5\3)
下面定义一个函数,以CBook类对象为参数,演示在按值传递函数参数时调用了复制构造函数。
执行上述代码,结果如图5.3所示。
从图5.3中可以发现,执行“CBook book;”语句时调用了构造函数,输出了“构造函数被调用”的信息。执行“OutputBookInfo(book);”语句首先调用复制构造函数,输出“复制构造函数被调用”的信息,然后执行OutputBookInfo函数,输出m_BookName成员的信息。
注意
如果对OutputBookInfo函数进行修改,以引用类型作为函数参数,将不会执行复制构造函数。
【例5.9】 引用类型作为函数参数。(实例位置:资源包\TM\sl\5\4)
执行上述代码,结果如图5.4所示。
图5.3 复制构造函数1
图5.4 复制构造函数2
从图5.4中可以发现,复制构造函数没有被调用。因为OutputBookInfo函数是以引用类型作为参数,函数参数按引用的方式传递,直接将实际参数的地址传递给函数,不涉及复制参数,所以没有调用复制构造函数。
技巧
编写函数时,尽量按引用的方式传递参数,这样可以避免调用复制构造函数,极大地提高了程序的执行效率。
2. 析构函数
在介绍完构造函数后,下面介绍一下析构函数。析构函数在对象超出作用范围或使用delete运算符释放对象时被调用,用于释放对象占用的空间。如果用户没有显式地提供析构函数,系统会提供一个默认的析构函数。析构函数也是以类名作为函数名,与构造函数不同的是,在函数名前添加一个“~”符号,标识该函数是析构函数。析构函数没有返回值,甚至void类型也不可以;析构函数也没有参数,因此不能够重载。这是析构函数与普通函数最大的区别。下面为CBook类添加一个析构函数。
【例5.10】 定义析构函数。(实例位置:资源包\TM\sl\5\5)
为了演示析构函数的调用情况,定义一个CBook类对象。
执行上述代码,结果如图5.5所示。
上述代码中定义了一个CBook对象book,当执行“CBook book;”语句时将调用构造函数输出“构造函数被调用”的信息;然后执行“printf("定义一个CBook类对象\n");”语句输出一行信息;最后在函数结束时,也就是book对象超出了作用域时调用析构函数释放book对象,因此输出了“析构函数被调用”的信息。
图5.5 析构函数
5.1.4 内联成员函数
在定义函数时,可以使用inline关键字将函数定义为内联函数。在定义类的成员函数时,也可以使用inline关键字将成员函数定义为内联成员函数。
说明
其实对于成员函数来说,如果其定义是在类体中,即使没有使用inline关键字,该成员函数也被认为是内联成员函数。
例如,定义一个内联成员函数。
【例5.11】 定义内联成员函数。
在上述代码中,GetUsername函数即为内联成员函数,因为函数的定义处于类体中。此外,还可以使用inline关键字表示函数为内联成员函数。
【例5.12】 使用inline关键字定义内联成员函数。
此外,还可以在类成员函数的实现部分使用inline关键字标识函数为内联成员函数。例如:
说明
对于内联成员函数来说,程序会在函数调用的地方直接插入函数代码,如果函数体语句较多,将会导致程序代码膨胀。因此,将类的析构函数定义为内联成员函数,可能会导致潜在的代码膨胀。
分析下面的代码(假设CBook类的析构函数为内联成员函数)。
【例5.13】 将类的析构函数定义为内联成员函数,可能会导致潜在的代码膨胀。
由于CBook类的析构函数是内联成员函数,因此上述代码在每一个return语句之前,析构函数均会被展开。因为return语句表示当前函数调用结束,所以book对象的生命期也就结束了,自然调用其析构函数。
根据上述分析,main函数中switch语句的编写是非常不明智的,下面对其进行修改,将return语句替换为break语句。
【例5.14】 switch语句的注意事项。
通过修改switch语句,避免了可能产生的代码膨胀,这也是使用switch语句应该注意的事项。
5.1.5 静态类成员
本节之前所定义的类成员都是通过对象来访问的,不能通过类名直接访问。如果将类成员定义为静态类成员,则允许使用类名直接访问。静态类成员是在类成员定义前使用static关键字标识。
【例5.15】 定义静态数据成员。
在定义静态数据成员时,通常需要在类体外部对静态数据成员进行初始化。例如:
对于静态数据成员来说,不仅可以通过对象访问,还可以直接使用类名访问。例如:
在一个类中,静态数据成员是被所有的类对象所共享的。这就意味着无论定义多少个类对象,类的静态数据成员只有一份;同时,如果某一个对象修改了静态数据成员,其他对象的静态数据成员(实际上是同一个静态数据成员)也将改变。
【例5.16】 类对象共享静态数据成员。(实例位置:资源包\TM\sl\5\6)
执行上述代码,结果如图5.6所示。
由于静态数据成员m_Price被所有CBook类对象所共享,因此Book对象修改了m_Price成员,将影响到vcBook对象对m_Price成员的访问。
图5.6 静态数据成员
对于静态数据成员,还需要注意以下几点。
(1)静态数据成员可以是当前类的类型,而其他数据成员只能是当前类的指针或引用类型。
在定义类成员时,对于静态数据成员,其类型可以是当前类的类型,而非静态数据成员则不可以,除非数据成员的类型为当前类的指针或引用类型。
【例5.17】 静态数据成员可以是当前类的类型。
(2)静态数据成员可以作为成员函数的默认参数。
在定义类的成员函数时,可以为成员函数指定默认参数,其参数的默认值也可以是类的静态数据成员,但是普通的数据成员则不能作为成员函数的默认参数。
【例5.18】 静态数据成员可以作为成员函数的默认参数。
在介绍完类的静态数据成员后,下面介绍类的静态成员函数。定义类的静态成员函数与定义普通的成员函数类似,只是在成员函数前添加static关键字。例如:
类的静态成员函数只能访问类的静态数据成员,而不能访问普通的数据成员。例如:
在上述代码中,语句“printf("%d\n",m_Pages);”是错误的,因为m_Pages是非静态数据成员,不能在静态成员函数中访问。
注意
静态成员函数不能定义为const成员函数,即静态成员函数末尾不能使用const关键字。
例如,下面静态成员函数的定义是非法的。
在定义静态成员函数时,如果函数的实现代码处于类体之外,则在函数的实现部分不能再标识static关键字。例如,下面的函数定义是非法的。
上述代码如果去掉static关键字则是正确的。
5.1.6 隐藏的this指针
对于类的非静态成员,每一个对象都有自己的一份备份,即每个对象都有自己的数据成员和成员函数。
【例5.19】 每一个对象都有自己的一份备份。(实例位置:资源包\TM\sl\5\7)
执行上述代码,结果如图5.7所示。
从图5.7中可以发现,vbBook和vcBook两个对象均有自己的数据成员m_Pages,在调用OutputPages成员函数时,输出的均是自己的数据成员。在OutputPages成员函数中只是访问了m_Pages数据成员,那么每个对象在调用OutputPages方法时是如何区分自己的数据成员的呢?答案是通过this指针。在每个类的成员函数(非静态成员函数)中都隐含包含一个this指针,指向被调用对象的指针,其类型为当前类类型的指针类型(在const方法中,为当前类类型的const指针类型)。当vbBook对象调用OutputPages成员函数时,this指针指向vbBook对象;当vcBook对象调用OutputPages成员函数时,this指针指向vcBook对象。在OutputPages成员函数中,用户可以显式地使用this指针访问数据成员。例如:
图5.7 访问对象的数据成员
实际上,编译器为了实现this指针,在成员函数中自动添加了this指针用于对数据成员或方法的访问,类似于上面的OutputPages方法。
说明
为了将this指针指向当前调用对象,并在成员函数中能够使用,在每个成员函数中都隐含包含一个this指针作为函数参数,并在函数调用时将对象自身的地址隐含作为实际参数传递。
以OutputPages成员函数为例,编译器将其定义为:
在对象调用成员函数时,传递对象的地址到成员函数中。以“vc.OutputPages();”语句为例,编译器将其解释为“vbBook.OutputPages(&vbBook);”。这样就使得this指针合法,并能够在成员函数中使用。
5.1.7 运算符重载
定义两个整型变量后,可以对两个整型变量进行加运算。如果定义两个类对象,它们能否进行加运算呢?答案是不可以。两个类对象是不能直接进行加运算的,因此下面的语句是非法的。
但是,如果对“+”运算符进行重载,则可以实现两个类对象的加运算。
说明
运算符重载是C++语言提供的一个重要特性,允许用户对一些编译器提供的运算符进行重载,以实现特殊的含义。
下面以为CBook类添加运算符重载函数为例来演示如何进行运算符重载。
【例5.20】 为类添加运算符重载函数。
在上述代码中,为CBook类实现了“+”运算符的重载。运算符重载需要使用operator关键字,其后是需要重载的运算符,参数及返回值根据实际需要来设置。通过为CBook类实现“+”运算符重载,允许用户实现两个CBook对象的加运算。例如:
如果用户想要实现CBook对象与一个整数相加,可以通过修改重载运算符的参数来实现。例如:
通过修改运算符的参数为整数类型,可以实现CBook对象与整数相加。如下面的代码是合法的:
两个整型变量相加,用户可以调换加数和被加数的顺序,因为加法符合交换律。但是,通过重载运算符实现两个不同类型的对象相加则不可以,因此下面的语句是非法的。
对于“++”和“--”运算符,由于涉及前置运算和后置运算,在重载这类运算符时如何区分是前置运算还是后置运算呢?默认情况下,如果重载运算符没有参数,则表示是前置运算。例如:
如果重载运算符使用了整数作为参数,则表示是后置运算。此时的参数值可以被忽略,它只是一个标识,标识后置运算。
默认情况下,将一个整数赋值给一个对象是非法的,可以通过重载赋值运算符“=”将其变为合法的。例如:
通过重载赋值运算符,可以进行如下形式的赋值。
此外,用户还可以通过重载构造函数将一个整数赋值给一个对象。例如:
【例5.21】 通过重载构造函数将一个整数赋值给一个对象。
上述代码定义了一个重载的构造函数,以一个整数作为函数参数,这样同样可以将一个整数赋值给一个CBook类对象。例如:
语句“vbBook = 200;”将调用构造函数CBook(int page)重新构造一个CBook对象,将其赋值给vbBook对象。
说明
无论是重载赋值运算符还是重载构造函数,都无法实现反向赋值,即将一个对象赋值给一个整型变量。
为了实现将一个对象赋值给一个整型变量的功能,C++提供了转换运算符。
【例5.22】 转换运算符。
上述代码在定义CBook类时定义了一个转换运算符int,用于实现将CBook类赋值给整型变量。转换运算符由关键字operator开始,其后是转换为的数据类型。在定义转换运算符时,注意operator关键字前没有数据类型,虽然转换运算符实际返回了一个转换后的值,但是不能指定返回值的数据类型。下面的代码演示了转换运算符的应用。
【例5.23】 转换运算符的应用。(实例位置:资源包\TM\sl\5\8)
执行上述代码,结果如图5.8所示。
图5.8 转换运算符
从图5.8中可以发现,语句“int page = vbBook;”是将vbBook对象的m_Pages数据成员赋值给了page变量,因此page变量的值为300。
用户在程序中重载运算符时,需要遵守如下规则和注意事项。
(1)并不是所有的C++运算符都可以重载。
C++中的大多数运算符都可以进行重载,但是“::”“?”“:”和“. ”运算符不能够被重载。
(2)运算符重载存在如下限制。
不能构建新的运算符。
不能改变原有运算符操作数的个数。
不能改变原有运算符的优先级。
不能改变原有运算符的结合性。
不能改变原有运算符的语法结构。
(3)运算符重载遵循以下基本准则。
一元操作数可以是不带参数的成员函数,或者是带一个参数的非成员函数。
二元操作数可以是带一个参数的成员函数,或者是带两个参数的非成员函数。
“=”“[]”“->”和“()”运算符只能定义为成员函数。
“->”运算符的返回值必须是指针类型或者能够使用“->”运算符类型的对象。
重载“++”和“--”运算符时,带一个int类型参数,表示后置运算,不带参数表示前置运算。
5.1.8 友元类和友元方法
类的私有方法,只有在该类中允许访问,其他类是不能访问的。在开发程序时,如果两个类的耦合度比较紧密,在一个类中访问另一个类的私有成员会带来很大的方便。C++语言提供了友元类和友元方法(或者称为友元函数)来实现访问其他类的私有成员。当用户希望另一个类能够访问当前类的私有成员时,可以在当前类中将另一个类作为自己的友元类,这样在另一个类中即可访问当前类的私有成员。
【例5.24】 定义友元类。
在上述代码中,定义CItem类时使用了friend关键字将CList类定义为CItem类的友元,这样CList类中的所有方法就都可以访问CItem类中的私有成员了。在CList类的OutputItem方法中,语句“m_Item.OutputName()”演示了调用CItem类的私有方法OutputName。
在开发程序时,有时需要控制另一个类对当前类的私有成员的访问。例如,只允许CList类的某个成员访问CItem类的私有成员,而不允许其他成员函数访问CItem类的私有数据,此时可以通过定义友元函数来实现。在定义CItem类时,可以将CList类的某个方法定义为友元方法,这样就限制了只有该方法允许访问CItem类的私有成员。
【例5.25】 定义友元方法。(实例位置:资源包\TM\sl\5\9)
在上述代码中,定义CItem类时使用了friend关键字将CList类的OutputItem方法设置为友元函数,在CList类的OutputItem方法中访问了CItem类的私有方法OutputName。执行上述代码,结果如图5.9所示。
注意
对于友元函数来说,不仅可以是类的成员函数,还可以是一个全局函数。
下面的代码在定义CItem类时,将一个全局函数定义为友元函数,这样在全局函数中即可访问CItem类的私有成员。
【例5.26】 将全局函数定义为友元函数。(实例位置:资源包\TM\sl\5\10)
执行上述代码,结果如图5.10所示。
图5.9 友元函数
图5.10 全局友元函数
5.1.9 类的继承
继承是面向对象的主要特征(此外还有封装和多态)之一,它使一个类可以从现有类中派生,而不必重新定义一个新类。例如,定义一个员工类,其中包含员工ID、员工姓名、所属部门等信息;再定义一个操作员类,通常操作员属于公司的员工,因此该类也包含员工ID、员工姓名、所属部门等信息,此外还包含密码信息、登录方法等。如果当前已经定义了员工类,则在定义操作员类时可以从员工类派生一个新的员工类,然后向其中添加密码信息、登录方法等即可,而不必重新定义员工ID、员工姓名、所属部门等信息,因为它已经继承了员工类的信息。下面的代码演示了操作员类是如何继承员工类的。
【例5.27】 类的继承。(实例位置:资源包\TM\sl\5\11)
上述代码在定义COperator类时使用了“:”运算符,表示该类派生于一个基类;public关键字表示派生的类型为公有型;其后的CEmployee表示COperator类的基类,也就是父类。这样,COperator类将继承CEmployee类的所有非私有成员(private类型成员不能被继承)。
说明
当一个类从另一个类继承时,可以有3种派生类型,即公有型(public)、保护型(protected)和私有型(private)。派生类型为公有型时,基类中的public数据成员和方法在派生类中仍然是public,基类中的protected数据成员和方法在派生类中仍然是protected。派生类型为保护型时,基类中的public、protected数据成员和方法在派生类中均为protected。派生类型为私有型时,基类中的public、protected数据成员和方法在派生类中均为private。
下面定义一个操作员对象,演示通过操作员对象调用操作员类的方法以及调用基类—员工类的方法。
执行上述代码,结果如图5.11所示。
注意
用户在父类中派生子类时,可能存在一种情况,即在子类中定义了一个与父类的方法同名的方法,称之为子类隐藏了父类的方法。
例如,重新定义COperator类,添加一个OutputName方法。
【例5.28】 子类隐藏了父类的方法。(实例位置:资源包\TM\sl\5\12)
定义一个COperator类对象,调用OutputName方法。
执行上述代码,结果如图5.12所示。
图5.11 访问父类方法
图5.12 隐藏基类方法
从图5.12中可以发现,语句“optr.OutputName();”调用的是COperator类的OutputName方法,而不是CEmployee类的OutputName方法。如果用户想要访问父类的OutputName方法,需要显式使用父类名。例如:
如果子类隐藏了父类的方法,则父类中所有同名的方法(重载方法)均被隐藏。因此,例5.29中黑体部分代码的访问是错误的。
【例5.29】 如果子类隐藏了父类的方法,则父类中所有同名的方法均被隐藏。
在上述代码中,CEmployee类中定义了重载的OutputName方法,而在COperator类中又定义了一个OutputName方法,导致父类中的所有同名方法被隐藏。因此,语句“optr.OutputName("MR");”是错误的。如果用户想要访问被隐藏的父类方法,依然需要指定父类名称。例如:
在派生完一个子类后,可以定义一个父类的类型指针,通过子类的构造函数为其创建对象。例如:
如果使用pWorder对象调用OutputName方法,如执行“pWorker->OutputName();”语句,则执行的是CEmployee类的OutputName方法还是COperator类的OutputName方法呢?答案是调用CEmployee类的OutputName方法。编译器对OutputName方法进行的是静态绑定,即根据对象定义时的类型来确定调用哪个类的方法。由于pWorker属于CEmployee类型,因此调用的是CEmployee类的OutputName方法。那么是否有方法将“pWorker->OutputName();”语句改为执行COperator类的OutputName方法呢?通过定义虚方法可以实现这一点。
说明
在定义方法(成员函数)时,在方法的前面使用virtual关键字,该方法即为虚方法。使用虚方法可以实现类的动态绑定,即根据对象运行时的类型来确定调用哪个类的方法,而不是根据对象定义时的类型来确定调用哪个类的方法。
下面的代码修改了CEmployee类的OutputName方法,使其变为虚方法。
【例5.30】 利用虚方法实现动态绑定。(实例位置:资源包\TM\sl\5\13)
在上述代码中,CEmployee类中定义了一个虚方法OutputName,在子类COperator类中改写了OutputName方法,其中COperator类中的OutputName方法仍为虚方法,即使没有使用virtual关键字。下面定义一个CEmployee类型的指针,调用COperator类的构造函数构造对象。
执行上述代码,结果如图5.13所示。
图5.13 虚方法
从图5.13中可以发现,“pWorker->OutputName();”语句调用的是COperator类的OutputName方法。
注意
在C++语言中,除了能够定义虚方法外,还可以定义纯虚方法,也就是通常所说的抽象方法。一个包含纯虚方法的类被称为抽象类,抽象类是不能够被实例化的,通常用于实现接口的定义。
下面的代码演示了纯虚方法的定义。
在上述代码中,为CEmployee类定义了一个纯虚方法OutputName。纯虚方法的定义是在虚方法定义的基础上在末尾添加“= 0”。包含纯虚方法的类是不能够实例化的,因此下面的语句是错误的。
抽象类通常用于作为其他类的父类,从抽象类派生的子类如果也是抽象类,则子类必须实现父类中的所有纯虚方法。
【例5.31】 实现抽象类中的方法。(实例位置:资源包\TM\sl\5\14)
上述代码从CEmployee类派生了两个子类,即COperator和CSystemManager。这两个类分别实现了父类的纯虚方法OutputName。下面编写一段代码演示抽象类的应用。
执行上述代码,结果如图5.14所示。
从图5.14中可以发现,同样的一条语句“pWorker->OutputName();”,由于pWorker指向的对象不同,其行为也不同。
下面分析一下子类对象的创建和释放过程。当从父类派生一个子类后,定义一个子类的对象时,它将依次调用父类的构造函数、当前类的构造函数来创建对象。在释放子类对象时,先调用的是当前类的析构函数,然后是父类的析构函数。下面的代码说明了这一点。
图5.14 纯虚方法
【例5.32】 子类对象的创建和释放过程。(实例位置:资源包\TM\sl\5\15)
执行上述代码,结果如图5.15所示。
从图5.15中可以发现,在定义COperator类对象时,调用的是父类CEmployee的构造函数,然后是COperator类的构造函数。子类对象的释放过程则与其构造过程恰恰相反,先调用自身的析构函数,然后再调用父类的析构函数。
在分析完对象的构建、释放过程后,考虑这样一种情况—定义一个基类类型的指针,调用子类的构造函数为其构建对象,当对象释放时,是先调用父类的析构函数还是先调用子类的析构函数,再调用父类的析构函数呢?答案是如果析构函数是虚函数,则先调用子类的析构函数,然后再调用父类的析构函数;如果析构函数不是虚函数,则只调用父类的析构函数。可以想象,如果在子类中为某个数据成员在堆中分配了空间,父类中的析构函数不是虚方法,上述情况将使子类的析构函数不会被调用,其结果是对象不能被正确地释放,导致内存泄漏的产生。因此,在编写类的析构函数时,析构函数通常是虚函数。
图5.15 构造函数调用顺序
说明
前面所介绍的子类的继承方式属于单继承,即子类只从一个父类继承公有的和受保护的成员。与其他面向对象语言不同,C++语言允许子类从多个父类继承公有的和受保护的成员,称之为多继承。
例如,鸟能够在天空飞翔,鱼能够在水里游,而水鸟既能够在天空飞翔,又能够在水里游,则在定义水鸟类时,可以将鸟和鱼同时作为其基类。下面的代码演示了多继承的应用。
【例5.33】 实现多继承。(实例位置:资源包\TM\sl\5\16)
执行上述代码,结果如图5.16所示。
上述代码定义了鸟类CBird和鱼类CFish,然后从鸟类和鱼类派生了一个子类—水鸟类CWaterBird,水鸟类自然继承了鸟类和鱼类所有公有和受保护的成员,因此CWaterBird类对象能够调用FlyInSky和SwimInWater方法。在CBird类中提供了一个Breath方法,在CFish类中同样提供了Breath方法,如果CWaterBird类对象调用Breath方法,将会执行哪个类的Breath方法呢?答案是将会出现编译错误,编译器将产生歧义,不知道具体调用哪个类的Breath方法。为了让CWaterBird类对象能够访问Breath方法,需要在Breath方法前具体指定类名。例如:
图5.16 多继承
在多继承中存在这样一种情况,假如CBird类和CFish类均派生于同一个父类,如CAnimal类,那么当从CBird类和CFish类派生子类CWaterBird时,在CWaterBird类中将存在两个CAnimal类的备份。能否在派生CWaterBird类时,使其只存在一个CAnimal基类?为了解决该问题,C++语言提供了虚继承的机制。下面的代码演示了虚继承的使用。
【例5.34】 虚继承。(实例位置:资源包\TM\sl\5\17)
执行上述代码,结果如图5.17所示。
在上述代码中,定义CBird类和CFish类时使用了关键字virtual从基类CAnimal派生而来。实际上,虚继承对于CBird类和CFish类没有多少影响,但是却对CWaterBird类产生了很大影响。CWaterBird类中不再有两个CAnimal类的备份,而只存在一个CAnimal的备份,图5.17充分说明了这一点。
通常在定义一个对象时,先依次调用基类的构造函数,最后才调用自身的构造函数。但是对于虚继承来说,情况有些不同。在定义CWaterBird类对象时,先调用基类CAnimal的构造函数,然后调用CBird类的构造函数,这里CBird类虽然为CAnimal的子类,但是在调用CBird类的构造函数时将不再调用CAnimal类的构造函数,此操作被忽略了,对于CFish类也是同样的道理。
图5.17 虚继承
说明
在程序开发过程中,多继承虽然带来了很多方便,但是很少有人愿意使用它,因为多继承会带来很多复杂的问题,并且多继承能够完成的功能,通过单继承同样也可以实现。如今流行的C#、Delphi和Java等面向对象语言只采用了单继承,而没有提供多继承的功能是经过设计者充分考虑的。因此,读者在开发应用程序时,如果能够使用单继承实现,尽量不要使用多继承。
5.1.10 类域
在定义类时,每个类都存在一个类域,类的所有成员均处于类域中。当程序中使用点运算符(.)和箭头运算符(->)访问类成员时,编译器会根据运算符前面的对象的类型来确定其类域,并在其类域中查找成员。如果使用域运算符(::)访问类成员,编译器将根据运算符前面的类名来确定其类域,查找类成员。当用户通过对象访问一个不属于类成员的“成员”时,编译器将提示其“成员”没有在类中定义,因为在类域中找不到该成员。
在类中如果有自定义的类型,则自定义类型的声明顺序是很重要的。在定义类的成员时如果需要使用自定义类型,通常将自定义类型放置在类成员定义的前方,否则将出现编译错误。例如,下面的类定义将是非法的。
【例5.35】 自定义类型应放置在类成员定义的前方。
上述代码中应将自定义类型UINT的定义放置在m_Wage数据成员定义的前方。下面的类定义是正确的。
说明
如果在类中自定义了一个类型,在类域内该类型将被用来解析成员函数参数的类型名。
5.1.11 嵌套类
C++语言允许在一个类中定义另一个类,称之为嵌套类。例如,下面的代码在定义CList类时,在内部又定义了一个嵌套类CNode。
【例5.36】 定义嵌套类。
在上述代码中,嵌套类CNode中不仅定义了一个私有成员m_Tag,还定义了一个公有成员m_Name。对于外围类CList来说,通常它不能够访问嵌套类的私有成员,虽然嵌套类是在其内部定义的。但是,上述代码在定义CNode类时将CList类作为自己的友元类,这便使得CList类能够访问CNode类的私有成员。
说明
对于内部的嵌套类来说,只允许其在外围的类域中使用,在其他类域或者作用域中是不可见的。
例如,下面的定义是非法的。
上述代码在main函数的作用域中定义了一个CNode对象,导致CNode没有被声明的错误。对于main函数来说,嵌套类CNode是不可见的,但是可以通过使用外围的类域作为限定符来定义CNode对象。下面的定义是合法的。
上述代码通过使用外围类域作为限定符访问到了CNode类。但是这样做通常是不合理的,也是有限制条件的。因为既然定义了嵌套类,通常不允许在外界访问,这违背了使用嵌套类的原则。其次,在定义嵌套类时,如果将其定义为私有的或受保护的,即使使用外围类域作为限定符,外界也无法访问嵌套类。
5.1.12 局部类
类的定义也可以放置在函数中,这样的类称为局部类。
【例5.37】 定义局部类。
上述代码在LocalClass函数中定义了一个CBook类,该类被称为局部类。对于局部类CBook来说,在函数之外是不能够被访问的,因为局部类被封装在了函数的局部作用域中。在局部类中,用户也可以再定义一个嵌套类,其定义形式与5.1.11节介绍的嵌套类完全相同,在此不再赘述。