第3章〓简易C++基础

Qt是一个基于C++的开发库,在跨系统、跨硬件平台程序开发方面有着持久的积累和得天独厚的优势。学习Qt需要具有一定的C++基础。如果只有C语言基础,直接学习Qt难免会事倍功半。由于C++的知识浩如烟海,本章只能根据后续章节的需要讲解一些C++的基础知识,如面向对象编程的概念、基本输入输出、名称空间、函数重载、类的继承和派生等。通过学习本章的内容,可以打下继续学习C++和Qt的基础。




视频讲解


3.1C和C++
◆

3.1.1C++简史

C++是从C语言发展而来的。要梳理C++的历史,就不得不提C语言。

C语言是一种面向过程的编程语言。1972年,贝尔实验室的Dennis Ritchie(1941.09.09—2011.10.12)以B语言为基础,在一台DEC PDP11计算机上实现了最初的C语言。C语言不但语法简洁灵活、运算符和数据结构丰富,而且具有结构化控制语句,程序执行效率高。与同时期的其他高级语言相比,C语言可以直接访问物理地址; 与汇编语言相比,C语言具有良好的可读性和可移植性。

1979年10月,贝尔实验室的Bjarne Stroustrup博士为C语言增加了类和一些其他特性,并将这种新的编程语言命名为C with class。1983年,C with class更名为C++。在C语言中,++运算符的作用是对一个变量进行递增操作,由此也可以看出C++的定位。在这一时期,C++引入了许多重要的特性,例如虚函数、函数重载、引用、const关键字、双斜线引导的单行注释等。

1985年,Bjarne Stroustrup博士的著作C++ Programming Language出版。同年,C++的商业版本问世。1990年,Annotated C++ Reference Manual发布,Borland公司的Turbo C++编译器问世。1998年,C++标准委员会发布了C++的第一个国际标准——ISO/IEC 14882:1998。该标准即为广泛应用的C++ 98。随后C++进入了高速发展时期。

C++在C语言的基础上增加了面向对象以及泛型编程机制,大大提高了大中型程序开发的效率。由于C++是C语言的扩展,因此C语言代码几乎可以不加修改地用于C++。如果不使用C++的特性,那么C++的执行效率和C语言几乎相同。

3.1.2面向过程编程和面向对象编程

C++最早称为C with class。可见class(类)是C++的最重要特征。那么为什么要在C语言的基础上引入类?引入类又有什么优点?要回答这两个问题,就要从面向过程编程(Procedure Oriented Programming,POP)和面向对象编程(Object Oriented Programming,OOP)说起。

面向过程编程和面向对象编程是解决问题的两种思路。使用面向过程编程的思路解决问题时,会把任务拆分成一个个函数和数据(见图3.1),然后按照一定的顺序执行函数。C语言就是一种典型的面向过程的语言。



图3.1面向过程编程的程序结构示意图


例如,要通过边长计算三角形的面积。如果使用面向过程的思路,会自然地将这个问题分解为数据和算法两部分。数据是三角形的3个边长(a、b、c),算法则是海伦公式: 


S=p(p-a)(p-b)(p-c)


其中,


p=a+b+c2


有了数据和算法,只要用代码将算法一步一步实现,问题就解决了。

面向对象编程的思路则不同。使用这一思路解决问题时会先把事物进行抽象,得到描述事物的信息(数据)和事物的行为(功能)并组成类。具体问题反而成了类的特例。按照面向对象的思路编写的程序结构示意图如图3.2所示。



图3.2面向对象编程的程序结构示意图


还是以计算三角形面积的问题为例。既然三角形是二维图形的一种,那么是不是可以编写一个通用的类来计算各种二维图形的面积?计算时是否可以使用不同的算法?能否可以提供不同精度的结果?将这些内容放到一起,就可以组成一个类。当然,也可以只围绕三角形做文章,比如在计算面积的基础上增加计算周长、计算正弦和余弦(只针对直角三角形)的功能,或者计算外接圆、内切圆半径的功能等。当然,具体如何设计这个类需要根据实际需求进行。

如果说面向过程编程是使问题满足编程语言的语法要求,那么面向对象编程就是试图让语言来满足问题的要求。面向对象编程本质是通过建立模型来体现抽象思维过程。因为模型不可能反映客观事物的一切具体特征,所以必须要对事物特征和变化规律进行抽象,得到一个能普遍、深刻地描述问题特征的模型,也就是类。通过这一过程,不仅加深了对问题的认识,还可以把原本分散开来的数据与功能整合到类中,使程序的扩展性得以提高。

3.1.3面向对象编程的特征

面向对象编程有4个特征,即抽象、封装、继承、多态。由于本书并非专门的C++教程,因此只简单介绍一下这4个特征的含义。

(1) 抽象,就是对同一类事物的共有的属性特征和功能行为进行提取、归纳、总结的过程。如汽车都有轮子、发动机等部件,这些是汽车的属性。汽车能前进后退、能载客载货,这些是汽车的功能。通过类似的抽象可以把汽车的属性与功能提取出来,供描述汽车的程序使用。


(2) 封装,就是隐藏对象的属性和实现细节,仅公开接口,从而控制程序对数据的访问。例如,在驾驶汽车时,驾驶人员只能接触到方向盘、油门、刹车等接口设备,而汽车复杂的机械原理则隐藏在接口之后。

(3) 继承,即从已有的类产生出新的类。新的类能接收已有类的数据和功能,并能扩展新的数据和功能。通过继承可以有效地提高代码的重用性。例如,在上述的汽车类的基础上,还可以派生出轿车、SUV、MPV等不同车型的类。

(4) 多态,指同一种事物在不同的情况下有多种表现形式。例如,在同一个类中,可以有多个名称相同但参数不同的函数。程序运行时可以根据需要调用对应的函数。




视频讲解


3.2Hello, C++!
◆

3.2.1一个简单的C++程序

虽然C++是一门面向对象的编程语言,但是C代码仍然可以在C++环境下运行。作为第一个例子,不妨先看一个C++风格的面向过程程序,其代码如下: 



1// 示例代码\ch3\ch3-1CPPDemo\main.cpp

2#include <iostream>

3namespace mySpace

4{

5int nNum = 321;

6}

7using namespace std;

8int nNum = 0;

9int main()

10{

11char cStr[] = "Please enter a number:";

12cout << "Hello!" << endl<< cStr;

13cin >> nNum;

14cout << "The number you entered is: " << nNum << endl;

15cout << "The number in mySpace is: " << mySpace::nNum << endl;

16return 0; 

17}





在这段代码中,第1行是C++风格的注释。编译器会忽略从双斜杠(“//”)到行尾的所有内容。在C++中,双斜杠注释和C风格的/*…*/注释均可使用。这段代码在本书配套资源中的路径和文件名为“配套代码\ch3\ch31CPPDemo\main.cpp”。

第2行为预处理内容。iostream是头文件,定义了标准输入/输出流对象。该头文件使得C++程序能够从键盘输入信息并通过屏幕上输出信息。关于输入/输出流的具体内容请见3.2.2节。

第3~6行定义了名称空间mySpace和属于mySpace的变量nNum,并将nNum初始化为321。关于名称空间的具体内容请见3.2.3节。

第7行指明了后续代码使用的默认名称空间为std。

第8行(在默认名称空间std中)声明了全局变量nNum。

第9行声明了主函数main(),这与C语言相同。

第11行定义了字符串cStr并初始化。

第12行调用cout在屏幕上显示“Hello!”和字符串cStr的内容。cout是C++的标准输出流对象,endl是C++风格的换行符。

第13行调用cin读取用户输入的数字并保存到变量nNum中。cin是C++的标准输入流对象。

第14行和第15行输出了名称空间mySpace中的变量nNum和名称空间std中的变量nNum。

第16行程序结束,返回0。

要运行该程序,可以使用Qt Creator、VS Code、Code::Blocks等工具,也可以使用在线C++编译器OnlineGDB等。如果使用Qt Creator,可以直接打开该项目的pro文件并运行。下面是该程序的运行结果,其中符号代表按Enter键: 



Hello!

Please enter a number:12↙

The number you entered is: 12

The number in mySpace is: 321





3.2.2C++的基本输入/输出

1. 输入/输出流


C++将数据从一个对象到另一个对象的流动抽象为“流”。流在使用前要建立,使用后要删除。从流中获取数据的操作称为提取操作,向流中添加数据的操作称为插入操作。C++的头文件iostream负责实现输入/输出流。iostream由输入流istream、输出流ostream、文件流fstream、缓冲流streambuf等多个库组合而成的。开发者也可以单独引用istream或ostream等头文件,从而减小程序的大小。

2. cin和cout

在C语言中使用scanf()和printf()实现输入和输出。在C++中,可以使用预定的流对象cin和cout。cin是一个输入流对象,用来处理标准输入(即从键盘读取的信息)。在使用cin时需要紧跟运算符。cout是一个输出流对象,用来处理标准输出(即通过屏幕输出的信息)。在使用cout时需要紧跟运算符。这两个运算符可以自动分析数据类型,无须像scanf()和printf()一样给出格式控制字符串。在使用cout时,可以使用C++风格的换行符endl(即end of line的缩写)。endl与C语言中的'\n'作用相同,在C++中可以相互替代,例如: 



// 示例代码\ch3\ch3-2cincout\main.cpp

#include <iostream>

using namespace std;

int main ()

{

int nNum;

float fNum;

cout << "Please input an int number and a float number:" << endl;

cin >> nNum >> fNum;

cout << "The int number is " << nNum << '\n';

cout << "The float number is " << fNum << endl;

return 0;

}





运行该程序,结果如下: 



Please input an int number and a float number:

8 7.4↙

The int number is 8

The float number is 7.4





运行程序后,用户首先要通过键盘输入两个数字(如8和7.4),并按Enter键。这两个数字分别存储到nNum、fNum两个变量中。然后程序将这两个变量的值输出在了屏幕上。

3.2.3名称空间

一个中大型软件往往由多名程序员共同开发。不同程序员使用的变量名或函数名难免会出现冲突。为了解决这一问题,C++引入了名称空间(Namespace,也称为命名空间)的概念。通过名称空间,开发者可以将自己的代码封装在私有的空间中,从而避免与其他代码出现冲突。

名称空间的关键字为namespace。定义名称空间的方法为: 



namespace spacename

{

//定义变量、函数、类等

}





其中,spacename是名称空间的名字,花括号表明了名称空间的范围。在花括号内部可以定义变量、函数、类,也可以进行typedef、#define等操作。C++默认的名称空间是std。C++的标准函数或者对象都是在std中定义的,如cin和cout。

例如,小赵与小钱各自定义了自己的名称空间,并在各自的名称空间里定义了变量dSum(见示例代码\ch3\ch33Namespace): 



namespace Zhao

{

double dSum = 1000;

}



namespace Qian

{

double dSum = 100;

}





虽然这两个变量名称相同,但是分别处于不同的名称空间,所以可以共存。在使用时,可以通过域解析操作符::来指明变量(或函数、类等)的归属,例如,



Zhao::dSum = 123;//使用小赵定义的变量dSum

Qian::dSum = 456;//使用小钱定义的变量dSum





要声明默认使用的名称空间,可以使用using namespace关键字,例如,



using namespace Zhao;



int main()

{

std::cout << dSum << "  " << Qian::dSum << std::endl;









dSum = 1001;



Qian::dSum = 101;

std::cout << dSum << "  " << Qian::dSum << std::endl;



return 0;

}





在这段代码中,首先声明了默认使用的名称空间Zhao,然后在主函数中进行了变量输出和赋值操作。需要注意的是,cout、endl均是在名称空间std中定义的。由于默认的名称空间改为了Zhao,因此在使用cout、endl时需要加上std::前缀。这段代码的运行结果为: 



1000100

1001101








视频讲解


3.3函数和new运算符
◆

3.3.1函数的默认参数

C++允许在定义函数时给形参指定默认的值。在调用函数时,如果没有给形参赋值,那么就使用默认值; 如果给出了形参的值,则忽略默认值。

在下面的代码中,定义了用于输出两个数最大值的函数printMax()。函数的两个参数分别具有默认值2和3。printMax()函数在主函数中被重复调用。每次调用时形参的数量均不相同。



// 示例代码\ch3\ch3-4DefaultArguments\main.cpp

#include <iostream>

using namespace std;



void printMax(int nArg1 = 2, int nArg2 = 3)

{

cout << "Max of " << nArg1 << " and " << nArg2 << " is " << (nArg1 > nArg2 ? nArg1 : nArg2) << endl;

}



int main()

{

printMax(10, 6);//形参nArg1=10,nArg2=6

printMax(5);//形参nArg1=5,nArg2为默认值3

printMax();//形参nArg1为默认值2,nArg2为默认值3

return 0;

}





程序的运行结果为: 



Max of 10 and 6 is 10

Max of 5 and 3 is 5

Max of 2 and 3 is 3





C++规定带默认参数的形参只能放在形参列表的末尾。一旦为某个形参指定了默认值,那么后面的所有形参都必须有默认值。以下定义和调用函数的方法都是错误的: 



void printMax(int nArg1 = 2, int nArg2);//错误的定义方法

printMax( , 6);//错误的调用方法





3.3.2函数重载

在实际开发中,有时候需要实现几个功能类似的函数,例如,需要分别求两个int型变量、两个float型变量、两个char型变量的最大值。在C语言中需要编写不同的函数来实现这一功能,如: 



int maxInt(int nArg1, int nArg2);//求两个int型变量的最大值

float maxFloat(float fArg1, float fArg2);//求两个float型变量的最大值

char maxChar(char cArg1, char cArg2);//求两个char型变量的最大值





但是在C++中,借助函数重载(Function Overloading)功能可以编写多个函数名相同但参数不同的函数,例如: 




int max(int nArg1, int nArg2)//求两个int型变量的最大值

float max(float fArg1, float fArg2);//求两个float型变量的最大值

char max(char cArg1, char cArg2);//求两个char型变量的最大值





在调用max()函数时,系统会根据参数的类型自动选择合适版本的max()函数。例如: 



// 示例代码\ch3\ch3-5FunctionOverloading\main.cpp

#include <iostream>

using namespace std;

int max(int nArg1, int nArg2)// int型

{

return nArg1 > nArg2 ? nArg1 : nArg2;

}



float max(float fArg1, float fArg2)// float型

{

return fArg1 > fArg2 ? fArg1 : fArg2;

}



char max(char cArg1, char cArg2)// char型







{

return cArg1 > cArg2 ? cArg1 : cArg2;

}



int main()

{

int n1 = 3, n2 = 4;

cout << "Max of n1 and n2 is: " << max(n1, n2) << endl;

float f1 = 2.72, f2 = 3.14;

cout << "Max of f1 and f2 is: " << max(f1, f2) << endl;

char c1 = 'A', c2 = 'B';

cout << "Max of c1 and c2 is: " << max(c1, c2) << endl;

return 0;

}





程序的运行结果为: 



Max of n1 and n2 is: 4

Max of f1 and f2 is: 3.14

Max of c1 and c2 is: B





在实现函数重载时,函数的名称必须相同,但是函数的参数列表必须不同。这里的参数列表涉及参数的类型、参数的个数和参数的顺序。只要其中有一个不同就叫作参数列表不同。函数的返回值不能作为函数重载的判定依据。

3.3.3new和delete运算符

动态内存分配是编程中经常用到的方法。在C语言中,常使用malloc()函数动态申请内存。当这部分内存使用完毕后,需要用free()函数释放内存。例如: 



int *p = (int*) malloc( sizeof(int) * 10 );//申请内存

p[1]=1;//使用内存

free(p);//释放内存





C++在malloc()函数和free()函数的基础上增加了new和delete两个运算符。new用来动态申请内存,delete用于释放new申请的内存,例如: 



int *p2 = new int;//申请1个int型的内存空间

int *p3 = new int[10];//申请10个int型的内存空间

delete p2;//释放1个int型的内存空间

delete[] p3;//释放整个int型数组的内存空间





new运算符会根据后面的数据类型来自动推断所需空间的大小,无须使用sizeof()计算。通过new申请的内存必须调用delete手动释放,否则只能等到程序运行结束由操作系统回收。值得注意的是,当使用new运算符为类对象指针申请内存时,会自动调用类的构造函数(见3.4.3节),但是使用malloc()函数为类对象指针申请内存则不会调用构造函数。




视频讲解


3.4类和对象
◆

3.4.1抽象、类和对象
1. 抽象

抽象的过程是对问题(也就是研究对象)进行分析和认识的过程,也是类和对象的基础。一般来说,对研究对象的抽象应该包括两个方面: 数据抽象和行为抽象。前者描述研究对象的属性或状态,也就是研究对象区别于其他对象的特征; 后者也叫作功能抽象、代码抽象,描述的是研究对象的共同行为或功能特征。

例如,如果要对日常生活中常用的时钟进行抽象,则需要3个int型变量来存储时、分和秒,这就是对时钟所具有的数据进行抽象。时钟要有显示时间、设置时间等功能,这就是对时钟的行为进行抽象。用变量和函数可以将抽象后的时钟属性描述如下: 



变量int nHour, int nMinute, int nSecond

函数showTime(), setTime()





2. 类和对象

在学习C语言时我们了解了结构体(Struct)的知识。结构体是一种构造类型,可以包含若干不同类型的成员变量(也称为元素)。C++中的类也是一种构造类型,但是其功能远比结构体强大得多。类的成员不但可以是变量,还可以是函数,也可以是另外一个类。通过类定义出来的变量也有特定的称呼,叫作“对象”(Object)。

类规定了可以使用哪些数据来表示对象,以及可以对这些数据执行哪些操作。例如,上面分析了一个时钟具有的数据和功能。在这个基础上,就可以定义一个通用的时钟类。要实现时钟程序,只需要定义一个时钟类的对象。对象包含了时钟所有的数据和使用时钟所有的操作。如果需要在程序中实现多个时钟,则可以定义多个类对象。

3.4.2定义类和类对象

在了解了类的含义以后,下面来学习如何用代码定义、实现一个类,以及如何定义、使用类对象。

1. 类的定义和实现

仍以上面的时钟类为例来讲解类的定义和实现。此处为时钟类额外增加了闹钟功能。

(1) 定义类。在定义类时,要在头文件(.h文件)中按照C++语法对类的成员变量和成员函数进行定义。这非常类似于变量声明和函数声明。定义类的通用格式为: 



class 类名称

{







public:

公有成员/接口

protected:

受保护的成员

private:

私有成员

};





上述代码第1行的class是C++的关键词,代表这段代码定义了一个类。这与C语言的结构体关键词struct非常类似。代码中的花括号{}限定了类的范围。public(公有的)、protected(受保护的)、private(私有的)都是C++的关键词,用于定义成员函数和成员变量的访问类型。一般也将它们称为访问权限限定符。这一部分内容将在访问控制部分讲解。类的每个成员变量和成员函数都要有自己的访问类型。例如,如果希望成员变量是私有的,那么只要将变量定义写在private下方即可。

假如将时钟类命名为ClassClock,那么可以按照上述格式在头文件ClassClock.h中完成时钟类的定义: 



// 示例代码\ch3\ch3-6ClassClock\ClassClock.h

class ClassClock

{

private:

int m_nHour;//当前时间的小时部分

int m_nMinute;//当前时间的分钟部分

int m_nSecond;//当前时间的秒部分



public:

int m_nHourAlarm;//闹钟时间的小时部分

int m_nMinuteAlarm;//闹钟时间的分钟部分



void setTime(int h, int m, int s);//设置当前时间

void setAlarm(int h, int m);//设置闹钟时间

void getTime(int *h, int *m, int *s);//读取当前时间

void getAlarm(int *h, int *m);//读取闹钟时间

};





在上述代码中,当前时间(m_nHour、m_nMinute、m_nSecond)是私有成员,而闹钟时间(m_nHourAlarm、m_nMinuteAlarm)和所有的成员函数(setTime()、setAlarm()、getTime()、getAlarm())都是公有成员。代码中没有受保护的成员。

在类定义的过程中,private、protected、public的出现顺序没有要求,出现的次数也没有限制。为了使程序清晰,可以使每种访问限定符在类定义中只出现一次。如果没有显式地声明访问权限,则默认为private。例如,在上面ClassClock类的定义中,如果删去private一行,那么所有类成员的访问权限保持不变。

(2) 成员函数实现。头文件ClassClock.h用于保存类的定义,而同名的.cpp文件则保存成员函数的具体代码。在本例中,时钟类的代码实现如下: 



// 示例代码\ch3\ch3-6ClassClock\ClassClock.cpp

#include "ClassClock.h"



void ClassClock::setTime(int h, int m, int s)

{

m_nHour = h;

m_nMinute = m;

m_nSecond = s;

}



void ClassClock::setAlarm(int h, int m)

{

m_nHourAlarm = h;

m_nMinuteAlarm = m;

}



void ClassClock::getTime(int *h, int *m, int *s)

{

*h = m_nHour;

*m = m_nMinute;

*s = m_nSecond;

}



void ClassClock::getAlarm(int *h, int *m)

{

*h = m_nHourAlarm;

*m = m_nMinuteAlarm;

}





在ClassClock.cpp中,每个成员函数的函数名前面都需要增加前缀ClassClock::,从而指明该函数所属的类。如果不指明所属的类,那么编译器会报告错误。

2. 类对象的创建和使用

类的使用和结构体的使用十分类似。可以通过以下两种方法创建ClassClock类的对象: 



ClassClock clock1;//方法1

ClassClock * clock2 = new ClassClock();//方法2





方法1直接创建了一个类对象clock1; 方法2则先创建了一个类对象指针clock2,然后通过new运算符为该指针申请了内存。从内存管理的角度看,方法1是将变量存放在内存的栈中,方法2是将变量存放在内存的堆中。由于方法2使用了new运算符,因此在类对象完成任务后需要调用delete运算符释放内存,即



delete clock2;





对于类对象clock1,可以使用“.”运算符(即英文句号)访问其成员,例如: 



clock1.setTime(12, 34, 56);

clock1.m_nHourAlarm = 11;





对于类对象指针clock2,可以使用“>”运算符访问其成员,例如: 



clock2->setTime(12, 34, 56);

clock2->m_nHourAlarm = 11;





3. 类成员的访问控制

在面向对象编程中,一个核心原则就是数据和数据的操作相分离。在理想的情况下,数据是隐藏的(如汽车的发动机),而数据的接口是公开的(如汽车的油门、方向盘)。数据是无法直接访问的,但是可以通过数据接口来间接访问或者操作数据。类的访问控制机制可以有效地控制谁能访问类成员、谁不能访问类成员。

在C++中,类的成员有3种访问权限,分别是public(公有的)、protected(受保护的)、private(私有的)。所谓public权限,就是该成员可以被任何代码访问(类似于汽车内的所有人都可以接触方向盘)。private指该成员只能被类中的代码访问,不能被外界直接访问(例如只能通过油门间接控制发动机的工作状态)。protected权限主要用于类的继承和派生,具体在3.5节进行讲解。在不考虑类的继承和派生的情况下,可以简单地认为protected权限和private权限相同。

下面通过一个例子来体会类成员访问权限的作用。下面的代码中定义一个时钟类ClassClock的对象,并访问了类对象的各个成员变量和成员函数: 



1// 示例代码\ch3\ch3-6ClassClock\main.cpp

2#include <iostream>

3#include "ClassClock.h"//引用类定义文件

4using namespace std;

5int main()

6{

7ClassClock myClock;//定义类对象

8int h, m, s;

9myClock.setTime(12, 34, 56);//调用公有成员函数

10myClock.m_nHourAlarm = 3;//直接访问公有成员变量

11myClock.m_nMinuteAlarm = 30;//直接访问公有成员变量

12// myClock.m_nHour = 1;//直接访问私有成员变量,会引发错误!

13myClock.getTime(&h, &m, &s);//调用公有成员函数

14cout << "Current time is " << h << ":" << m << ":" << s << endl;

15cout << "Alarm time is " << myClock.m_nHourAlarm << ":" << myClock.m_nMinuteAlarm << endl;//直接访问公有成员变量

16return 0;

17}





因为类的公有成员可以任意访问,所以可以在主函数中直接调用公有成员函数(见代码第9行、第13行),也可以在主函数中直接操作公有成员变量(见代码第10行、第11行、第15行)。而类的私有成员是不能直接访问的,因此在主函数代码的第12行无法为私有成员变量m_nHour赋值。但是在类的内部,所有成员变量和成员函数都可以互相访问,不受权限限制。所以公有成员函数setTime()可以访问类的私有成员变量来设置时间。

由于成员变量一般设置为私有,不能在类外部直接访问,所以常常设计一组公有的set()函数和get()函数来访问私有的成员变量。set()函数用于修改私有成员变量的值; get()函数用于读取、输出、打印私有成员变量的值。因为set()和get()函数可以为外界提供接口来访问类内部的成员,所以将它们统称为接口函数。使用接口函数体现了面向对象编程中的封装特性。

3.4.3构造函数和析构函数
1. 构造函数

通过前面的学习可以看到,创建类对象和创建int型变量并没有本质上的区别。但是类是一种复杂的构造类型,内部包含了不同的成员。在创建int型变量时,可以为变量提供初始值。但是创建类对象时,成员变量的初始值是多少呢?要回答这一问题,就不得不提到类的构造函数(Constructor)。

构造函数是类的一种特殊的公有成员函数,会在创建类对象时自动执行,完成类对象初始化操作。构造函数的函数名和类名相同,但是没有返回值(返回值是void也不可以),也不能含有return语句。用户不能手动调用构造函数。在设计类的过程中,如果开发者没有显式地定义构造函数,那么编译器会自动生成一个默认的构造函数。默认构造函数的函数体是空的,也没有形参。下面仍以闹钟类ClassClock为例讲解,默认的构造函数为: 



ClassClock()

{}





下面为闹钟类ClassClock显式地添加构造函数,并在这个构造函数中将成员变量初始化为0。添加构造函数的具体步骤如下。

(1) 在类定义中增加一个没有返回值的公有函数ClassClock(),函数名与类名相同: 



// 示例代码\ch3\ch3-7Constructor\ClassClock.h

public:

ClassClock();





(2) 在cpp文件中实现构造函数: 



// 示例代码\ch3\ch3-7Constructor\ClassClock.cpp

ClassClock::ClassClock()

{

m_nHour = 0;

m_nMinute = 0;







m_nSecond = 0;

m_nHourAlarm = 0;

m_nMinuteAlarm = 0;

}





这样在创建类对象时会自动地调用构造函数,将成员变量初始化为0。

构造函数是允许重载的。一个类可以有多个重载的构造函数。创建对象时,系统会根据传递的实参类型和数量来调用相应的构造函数。下面继续为ClassClock类增加一个重载的带参数的构造函数,从而在创建类对象的时候指定当前时间和闹钟时间。该构造函数的定义为: 



// 示例代码\ch3\ch3-7Constructor\ClassClock.h

public:

ClassClock(int h, int m, int s, int ha, int ma);





该构造函数的代码为: 



// 示例代码\ch3\ch3-7Constructor\ClassClock.cpp

ClassClock::ClassClock(int h, int m, int s, int ha, int ma)

{

m_nHour = h;

m_nMinute = m;

m_nSecond = s;

m_nHourAlarm = ha;

m_nMinuteAlarm = ma;

}





在创建类对象时,可以通过构造函数的参数决定调用哪个构造函数,例如,



// 示例代码\ch3\ch3-7Constructor\main.cpp

#include <iostream>

#include "ClassClock.h"

using namespace std;



int main()

{

ClassClock clock1;//调用不带参数的构造函数

ClassClock clock2(0, 0, 0, 3, 30);//调用带参数的构造函数

return 0;

}





构造函数的调用是强制性的。一旦在类中定义了构造函数,那么创建对象时就一定会调用它。如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配。

2. 析构函数

创建类对象时系统会自动调用构造函数进行初始化工作,销毁对象时系统也会自动调用一个函数来进行清理工作,如调用delete释放内存、关闭打开的文件等。这个负责清理工作的函数就是析构函数(Destructor)。

析构函数的函数名是在类名前面加一个波浪线“~”,如: ~ClassClock()。析构函数没有返回值、没有参数,不能显式地调用,也不能重载。如果用户没有定义析构函数,那么编译器会自动生成一个默认的析构函数,例如,



~ClassClock()

{}





3.4.4this指针

一个类可以有多个类对象。这些类对象有着相同的成员变量和成员函数。对于一个特定的类对象而言,应该如何区分自己的成员和别的对象的成员?换言之,不同类对象之间的边界如何界定?要解决这一问题,就需要用到this指针。

this是C++中的关键字。this指针是一个常(const)指针,指向当前对象本身。通过this指针可以访问当前对象的所有成员。所谓当前对象,也就是调用this指针的对象。使用this指针可以明确变量的归属,从而解决重名问题。

例如,在为ClassClock类编写构造函数时,一位初学者是这样编写的: 



ClassClock(int m_nHour, int m_nMinute, int m_nSecond, int m_nHourAlarm, int m_nMinuteAlarm)

{

m_nHour = m_nHour;

m_nMinute = m_nMinute;

m_nSecond = m_nSecond;

m_nHourAlarm = m_nHourAlarm;

m_nMinuteAlarm = m_nMinuteAlarm;

}





从这段代码看,该初学者的思路是正确的,只是出现了重名的变量。在Visual Studio和Qt中,这段代码可以通过编译,但是运行结果不正确。这是由于编译器无法区分哪个是形参、哪个是类成员变量。要解决这一问题,可以适当修改形参的名称,也可以使用this指针。例如,只要在类成员变量前面加上this指针作为限定,就能轻松地区分出变量的身份: 



ClassClock(int m_nHour, int m_nMinute, int m_nSecond, int m_nHourAlarm, int m_nMinuteAlarm)

{

this->m_nHour = m_nHour;

this->m_nMinute = m_nMinute;

this->m_nSecond = m_nSecond;

this->m_nHourAlarm = m_nHourAlarm;

this->m_nMinuteAlarm = m_nMinuteAlarm;

}





由于this指针指向对象本身,所以只有在创建对象后才会为this指针赋值。这个赋值的过程是编译器自动完成的,不需要用户干预,用户也不能给this指针赋值。this指针只能在类成员函数内部使用,但不能在3.4.5节即将讲到的静态成员函数中使用。

3.4.5静态成员
1. 静态成员变量

同一个类的不同对象之间是相互隔离的,占用着不同的内存空间。如果需要在类的多个对象之间共享同一份数据(例如,统计类对象的个数),就可以使用类的静态成员变量。

静态成员变量是一种特殊的成员变量,不属于某个具体的对象,而是属于整个类。它在所有类对象之外开辟内存,所有的类对象都共用这份内存中的数据。即使从未创建过类对象,静态成员变量也是可以访问的。

要定义静态成员变量,需要使用关键字static,例如,



// 示例代码\ch3\ch3-8StaticMembers\ClassClock.h

public:

static int ms_nCount; 





静态成员变量必须在类的外部初始化。没有初始化的静态成员变量不能使用。静态成员变量的初始化语法类似于全局变量的声明,即



type classname::name = value;






其中,type是变量的类型,classname是类名,name是静态成员变量名,value是初始值。下面是示例代码中静态成员的初始化代码。在这段代码中,静态成员变量ms_nCount的初始化位于主函数前: 



// 示例代码\ch3\ch3-8StaticMembers\main.cpp

#include <iostream>

#include "ClassClock.h"

using namespace std;

int ClassClock::ms_nCount = 0;//初始化静态成员变量

int main()

{

//…

}





静态成员变量既可以通过类对象访问,也可以通过类名访问,例如,



clock1.ms_nCount = 2;//通过类对象访问静态成员变量

ClassClock::ms_nCount = 1;//通过类名访问静态成员变量





需要注意的是,静态成员变量的访问权限可以是private、protected或public。但是私有的静态成员变量不能通过类对象访问,也不能通过类名访问,只能通过静态成员函数访问。

2. 静态成员函数

static除了可以声明静态成员变量,还可以声明静态成员函数。静态成员函数与普通成员函数的区别在于: 普通成员函数可以访问类中的任意成员,但是静态成员函数只能访问静态成员(包括静态成员变量和静态成员函数),不能访问非静态成员。这是因为编译器在编译一个普通成员函数时,会隐式地增加一个形参this,并把当前对象的地址赋值给this。所以普通成员函数只能在创建对象后通过对象调用。但是静态成员函数可以通过类名直接调用,编译器不会增加形参this。因为静态成员函数不需要当前对象的地址,所以不管有没有创建对象都可以调用。在Qt编程中经常会用到类的静态成员函数。

作为例子,下面为ClassClock类增加一个静态成员函数getCount(),用于获取静态成员变量ms_nCount的值。具体步骤如下: 

(1) 在类定义中增加静态成员函数getCount()。



// 示例代码\ch3\ch3-8StaticMembers\ClassClock.h

public:

static int ms_nCount;

static int getCount();//静态成员函数





(2) 在cpp文件中完成该函数。



// 示例代码\ch3\ch3-8StaticMembers\ClassClock.cpp

int ClassClock::getCount()

{

return ms_nCount;

}





(3) 在主函数中调用该函数。



1// 示例代码\ch3\ch3-8StaticMembers\main.cpp

2#include <iostream>

3#include "ClassClock.h"

4using namespace std;

5int ClassClock:: ms_nCount = 11;//初始化静态变量

6int main()

7{

8ClassClock clock1, clock2;

9clock1.ms_nCount = 11;//通过类对象访问

10cout << "clock1.ms_nCount: " << clock1.ms_nCount << endl;//通过类对象访问

11cout << "clock2.getCount(): " << clock1.getCount() << endl; //通过静态成员函数访问

12ClassClock::ms_nCount = 22;//通过类名访问

13cout << "ClassClock:: ms_nCount: " << ClassClock::ms_nCount << endl;
//通过类名访问

14return 0; 

15}





在代码中,先定义了两个类对象(第8行),并通过类对象clock1访问静态成员变量(第9行和第10行)。然后通过类对象clock2的静态成员函数访问该静态变量(第11行)。最后通过类名实现了静态成员变量的赋值和访问(第12行和第13行)。程序的运行结果为: 



clock1.ms_nCount: 11

clock2.getCount(): 11

ClassClock::ms_nCount: 22








视频讲解


3.5类的继承和派生
◆

3.5.1继承和派生的概念

在C++中,继承(Inheritance)可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如,类B继承于类A,那么B就拥有A的成员变量和成员函数。A被B继承,所以A称为B的“父类”或“基类”。B继承了A,所以B称为A的“子类”或“派生类”。子类除了拥有父类的成员,还可以再增加自己的成员,从而增强类的功能。

派生(Derive)和继承是同一个概念的两个方面。如果说继承是儿子(子类)接受父亲(父类)的“产业”(成员变量和成员函数),那么派生就是父亲(父类)把“产业”(成员变量和成员函数)传承给儿子(子类)。所以“子类”和“父类”相对应,而“基类”和“派生类”相对应。

使用继承的典型场景有: 

(1) 当新的类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承。这样不但会减少代码量,而且派生类会拥有基类的所有功能。

(2) 当需要创建多个类,且这些类拥有很多相似的成员变量或成员函数时,可以将这些类的共同成员提取出来定义为基类,然后从基类继承。

3.5.2类的3种继承方式

前面介绍了C++类成员的3种访问权限,即public、private、protected。在继承时,也有3种继承方法,即public(公有继承)、private(私有继承,默认的继承方式)、protected(受保护的继承)。虽然3种继承方法和3种访问权限都使用了相同的关键字,但是含义完全不同。3种访问权限控制着谁能够访问类成员,谁不能访问类成员,而3种继承方法则控制着基类成员在派生类中的存在状态。

类的3种继承方式和类成员的3种访问控制权限是相辅相成的。在类的继承过程中,继承方式和访问控制权限会相互影响,共同决定派生类中各个成员的访问权限。表3.1汇总了不同继承方式和不同访问权限的类成员相互作用的结果。例如,当采用private继承时,基类中public成员的访问权限会收紧到private。如果采用public继承,那么基类public成员的权限保持不变。类似地,当采用private继承时,基类private成员在派生类对象中不能直接访问,但是可以调用基类的公有函数来间接访问。


表3.1不同继承方式对不同属性的成员的影响


继 承 方 式public成员protected成员private成员


public继承publicprotected不可见
protected继承protectedprotected不可见
private继承privateprivate不可见


通过表3.1可以总结出如下结论: 

(1) 基类成员在派生类中的访问权限不高于继承方式指定的权限。例如,当继承方式为protected时,那么基类成员在派生类中的访问权限最高为protected——高于protected的会降级为protected,低于protected保持不变。

(2) 不管继承方式如何,基类中的private成员在派生类中始终不可见。需要注意的是,不可见不代表不存在。在派生类中,基类的private成员仍然能被继承下来,也会占用内存空间。

(3) 如果希望基类的成员能够被派生类继承并且使用,那么只能将基类成员声明为public或protected。如果希望基类的成员不向外暴露(即不能通过派生类对象访问),但又能在派生类中使用,则要将基类成员声明为protected权限。

3.5.3继承和派生的实现

在C++中,定义派生类的语法为: 



class 派生类名 : 继承方式 基类名1, 继承方式 基类名2, …, 继承方式 基类名n

{

派生类成员声明

}





一个派生类只有一个基类的情况称为单继承。一个派生类有多个基类的情况称为多继承。单继承可以看作是多继承的特例,多继承可以看作是多个单继承的组合。

下面来看一个单继承的例子。假设将闹钟类ClassClock作为基类,希望派生出秒表类ClassStopwatch。秒表类额外提供了开始计时startTiming()、停止计时stopTiming()、清除计时结果clear()等成员函数,同时还增加了m_nTiming(保存计时时长)、m_nRunningFlag(秒表运行状态)这两个成员变量。

要实现上述功能,首先要在闹钟类ClassClock的基础上新建头文件ClassStopwatch.h,并在其中完成类的继承。此处采用公有继承,从而保持基类成员的权限不变: 



// 示例代码\ch3\ch3-9Inheritance\ClassStopwatch.h

#include "ClassClock.h"

class ClassStopwatch : public ClassClock

{

private:







int m_nTiming;//计时时间

int m_nRunningFlag;//秒表运行状态



public:

void startTiming();//开始计时

void stopTiming();//停止计时

void clear();//归零

};





同时还要在ClassStopwatch.cpp中实现秒表类的成员函数: 



// 示例代码\ch3\ch3-9Inheritance\ClassStopwatch.cpp

#include "ClassStopwatch.h"

void ClassStopwatch::startTiming()

{

m_nRunningFlag = 1;

}



void ClassStopwatch::stopTiming()

{

m_nRunningFlag = 0;

}



void ClassStopwatch::clear()

{

m_nTiming = 0;

}





3.5.4派生类的使用

派生类的使用方法与非派生类的使用方法相同,都是引用头文件并定义类对象,然后对类对象进行操作,例如: 



1// 示例代码\ch3\ch3-9Inheritance\main.cpp

2#include <iostream>

3#include "ClassStopwatch.h"//引用头文件

4using namespace std;

5int ClassClock::ms_nCount = 0;//初始化基类的静态成员变量

6int main()

7{

8ClassStopwatch myWatch;//定义派生类对象

9myWatch.setTime(12, 0, 0);//调用基类的公有成员函数

10myWatch.startTiming();//调用派生类的公有成员函数

11ClassClock::ms_nCount = 2;//通过基类名访问基类静态成员变量

12cout << "myWatch.ms_nCount:" << myWatch.ms_nCount << endl;   

//访问基类静态成员变量

13ClassStopwatch::ms_nCount = 5;   //通过派生类名访问基类静态成员变量








14cout << "myWatch.getCount():" << myWatch.getCount() << endl; 
//访问基类静态成员函数

15return 0;

16}





程序的运行结果为: 



myWatch.ms_nCount:2

myWatch.getCount():5





从这个例子可以看到,基类的公有成员函数在经过公有继承以后,仍可以通过子类访问,并且使用起来与子类的成员函数没有任何差异。但是如果将继承方式改为私有继承,则基类所有的私有成员都不能访问了。在这种情况下,代码的第9行、第12~14行都无法运行。

3.6本章小结
◆

本章根据后续章节的需要介绍了C++相对于C的一些新功能和新特性,方便没有C++基础的读者学习后面的内容。受篇幅限制,本章只讨论了C++的基础内容。在学习完本章的内容后,可以继续学习C++的其他功能和特性,如运算符重载、友元、引用、异常、Lambda表达式等,也可以将本章介绍的C++和自己熟悉的编程语言进行对比,找出异同点,思考背后的原因。