第一章 预备知识#
C++总览简介
C++ 是一种静态类型的、编译式的、通用的、大小写敏感的、不规则的编程语言,支持过程化编程、面向对象编程和泛型编程。 C++ 被认为是一种中级语言,它综合了高级语言和低级语言的特点。 C++ 是由 Bjarne Stroustrup 于 1979 年在新泽西州美利山贝尔实验室开始设计开发的。C++ 进一步扩充和完善了 C 语言,最初命名为带类的C,后来在 1983 年更名为 C++。 C++ 是 C 的一个超集,事实上,任何合法的 C 程序都是合法的 C++ 程序。
注意:使用静态类型的编程语言是在编译时执行类型检查,而不是在运行时执行类型检查。
面向对象程序设计
C++最大的亮点就是面向对象程序设计理念的运用。包括面向对象开发的四大特性: 封装 抽象 继承 多态
C++的组成部分
标准的 C++ 由三个重要部分组成: 核心语言,提供了所有构件块,包括变量、数据类型和常量,等等。 C++ 标准库,提供了大量的函数,用于操作文件、字符串等。 标准模板库(STL),提供了大量的方法,用于操作数据结构等。
第二章 开始学习 C++#
(略)这一章很简单,无笔记。
C++用分号隔开每一个执行语句。
第三章 处理数据#
面向对象编程(OOP)的本质是设计并扩展自己的数据类型。设计自己的数据类型就是让类型与数据匹配。
内置的C++类型分两组:基本类型和复合类型。本章将介绍基本类型,即整数和浮点数。似乎只有两种类型,但C++知道,没有任何一种整型和浮点型能够满足所有的编程要求,因此对于这两种数据,它提供 了多种变体。
3.1 简单变量#
把信息存储在计算机中,程序必须记录3个基本属性:
- 信息将存储在哪里;
- 要存储什么值;
- 存储何种类型的信息。
实际上,程序将找到一块能够存储整数的内存,将该内存单元标记为braincount,并将5复制到该内存单元中;然 后,您可在程序中使用braincount来访问该内存单元。
可以使用&运算符来检索braincount的内存地址。
3.1.1 变量名#
C++ 提倡使用有一定含义的变量名,并遵循几 种简单的 C++ 命名规则:
- 在名称中只能使用字母字符、数字和下划线 ( _ );
- 名称的第一个字符不能是数字;
- 区分大写字符与小写字符;
- 不能将C++关键字用作名称;
- 以两个下划线 或 下划线+大写字母 打头的名称被保留给实现(编译器及其使用的资源)使用;
- 以 一个下划线 开头的名称被保留给实现,用作全局标识符;
C++对于名称的长度没有限制,名称中所有的字符都有意义,但有些平台有长度限制。
倒数第二点与前面几点有些不同,因为使用像 __time_stop 或 _Donut 这样的名称不会导致编译器错误,而会导致行为的不确定性。换句话说,不知道结果将是什么。不出现编译器错误的原因是,这样的名称不 是非法的,但要留给实现使用。
如果想用两个或更多的单词组成一个名称,通常的做法是用下划线字符将单词分开,如 my_onions (用这类);或者从第二个单词开始将每个单词的第一个字母大写,如 myEyeTooth。
在 C++ 所有主观的风格中,一致性和精度是最重要的。请根据自己的需要、喜好和个人风格来使用变量名(或必要时,根据雇主的需要、喜好和个人风格来选择变量名)。
3.1.2 整型#
不同C++整型使用不同的内存量来存储整数。使用的内存量越大,可以表示的整数值范围也越大。
C++的基本整型(按宽度递增的顺序排列)分别是char、short、int、long和C++11新增的long long,其中每种类型都有符号版本和无符号版本,因此总共有10种类型可供选择。
3.1.3 整型short、int、long和long long#
C++ 的short、int、long和long long类型通过使用不同数目的位来存储值,最多能够表示4种不同的整数宽度:
- short至少16位;
- int至少与short一样长;
- long 至少32位,且至少与int一样长;
- long long至少64位,且至少与long一样长。
这意味着可以把16位单元设置成65 536个不同的值,把 32位单元设置成4 294 672 296个不同的值,把64位单元设置为18 446 744 073 709 551 616个不同的值。作为比较,unsigned long存储不了地球上当前的人数和银河系的星星数,而long long 能够。
当前很多系统都使用最小长度,即short为16位,long为32位。这仍然为int提供了多种选择,其宽度可以是16位、24位或32位,同时又符合标准;甚至可以是64位,因为long和long long至少长64位。
类型的宽度随实现而异,这可能在将C++程序从一种环境移到另一种环境(包括在同一个系统中使用不同编译器)时引发问题。
首先,sizeof运算符返回类型或变量的长度;其次,头文件climits(在老式实现中为limits.h)中包含了关于整型限制的信息。具体地说,它定义了表示各种限制的符号名称。例如,INT_MAX为int的最大取值,CHAR_BIT为字节的位数。
在我的 Macbook Air 64位 OS 上,数据类型是这样的:
|
|
**赋值与声明合并在一起叫做初始化。**如果知道变量的初始值应该是什么,则应对它进行初始化。
如果不对函数内部定义的变量进行初始化,该变量的值将是不确定的。这意味着该变量的值 将是它被创建之前,相应内存单元保存的值。
3.1.5 选择整型类型#
通常,int被设置为对 目标计算机而言最为“自然”的长度。自然长度(natural size)指的是计 算机处理起来效率最高的长度。如果没有非常有说服力的理由来选择其 他类型,则应使用int。
如果知道变量可能表示的整数值大于16位整数的最大可能值,则使用long。即使系统上int为32位,也应这样做。这样,将程序移植到16位系统时,就不会突然无法正常工作(参见图3.2)。如果要存储的值超过20亿,可使用long long。
所以对于基因组数据上的位置信息,最好是 long 类型咯,即便 unsigned int 类型已经足够。(不过我可能还是希望用 unsigned int )
如果short比int小,则使用short可以节省内存。通常,仅当有大型整 型数组时,才有必要使用short。如果只需要一个字节,可使用char。
3.1.6 整型字面值#
整型字面值(常量)是显式地书写的常量,如212或1776。
诸如cout«hex;等代码不会在屏幕上显示任何内容,而只是修改 cout显示整数的方式。因此,控制符hex实际上是一条消息,告诉cout采 取何种行为。另外,由于标识符hex位于名称空间std中,而程序使用了 该名称空间,因此不能将hex用作变量名。然而,如果省略编译指令 using,而使用std::cout、std::endl、std::hex和std::oct,则可以将hex用作 变量名。
3.1.7 C++如何确定常量的类型#
3.1.8 char类型:字符和小整数#
char类型是专为存 储字符(如字母和数字)而设计的。
char类型是另一种整型。实际上,很多系统支持的字符都不超过128个,因此用一个字节就可以表示所有的符号。因此,虽然char最常被用来处理字符,但也可以 将它用做比short更小的整型。
C++对字符用单引号,对字符串使用双引号。 cout对象能够处理这两种情况,但正如第4章将讨论的,这两者有天壤之别)。
与int不同的是,char在默认情况下既不是没有符号,也不是有符号。是否有符号由C++实现决定,这样编译器开发人员可以最大限度地将这种类型与硬件属性匹配起来。如果char有某种特定的行为对您来说 非常重要,则可以显式地将类型设置为signed char 或unsigned char。
如果将char用作数值类型,则unsigned char和signed char之间的差异将非常重要。unsigned char类型的表示范围通常为0~255,而signed char 的表示范围为−128到127。
例如,假设要使用一个char变量来存储像200这样大的值,则在某些系统上可以,而在另一些系统上可能不可以。但 使用unsigned char可以在任何系统上达到这种目的。
如果大型字符 集是实现的基本字符集(如中文日文系统),则编译器厂商可以将char定义为一个16位的字节或更长的字节。其次,一种实现可以同时支持一个小型基本字符集和 一个较大的扩展字符集。8位char可以表示基本字符集,另一种类型 wchar_t(宽字符类型)可以表示扩展字符集。wchar_t类型是一种整数类型,它有足够的空间,可以表示系统使用的最大扩展字符集。这种类 型与另一种整型(底层(underlying)类型)的长度和符号属性相同。对底层类型的选择取决于实现,因此在一个系统中,它可能是unsigned short,而在另一个系统中,则可能是int。
3.2 const 限定符#
C++有一种比 #define
更好的处理符号常量的方法,这种方法就是使用 const 关键字来修改变量声明和初始化。
|
|
常量(如Months)被初始化后,其值就被固定 了,编译器将不允许再修改该常量的值。如果您这样做,g++将指出程 序试图给一个只读变量赋值。关键字const叫做限定符,因为它限定了声 明的含义。
#define 定义符号常量的方式应抛弃。
一种常见的做法是将名称的首字母大写,以提醒您Months是个常量。这决不是一种通用约定,但在阅读程序时有助于区分常量和变量。另一种约定是将整个名称大写,使用#define创建常量时通常使用这种约定。
注意: 如果在声明常量时没有提供值,则该常量的值将是不确定的,且无 法修改。
3.3 浮点数#
使用浮点类型可以表示诸如2.5、3.14159和122442.32这样的数字,即带小数部分的数字。计算机将这样的值分成两部分存储。一部分表示值,另一部分用于对值进行放大或缩小。下面打个比方。对于数字34.1245和34124.5,它们除了小数点的位置不同外,其他都是相同的。 可以把第一个数表示为0.341245(基准值)和100(缩放因子),而将第二个数表示为0.341245(基准值相同)和10000(缩放因子更大)。缩放因子的作用是移动小数点的位置,术语浮点因此而得名。C++内部表示浮点数的方法与此相同,只不过它基于的是二进制数,因此缩放因 子是2的幂,不是10的幂。
3.3.1 书写浮点数#
C++有两种书写浮点数的方式:
- 第一种是使用常用的标准小数点表示法;
- 第二种表示浮点值的方法叫做E表示法,其外观是像这样的: 3.45E6,这指的是3.45与1000000相乘的结果;E6指的是10的6次方,即 1后面6个0。因此,3.45E6表示的是3450000,6被称为指数,3.45被称为 尾数。
E表示法确保数字以浮点格式存储,即使没有小数点。注意,既可 以使用E也可以使用e,指数可以是正数也可以是负数,不要有空格。
电子的质量是 9.11e-31 kg 表示 0.000000000000000000000000000000911 kg, 而美国报警电话 911 竟然很巧合地与此相同。
3.3.2 浮点类型#
C++也有3种浮点类型:float、double 和 long double。
事实上,C和C++对于有效位数的要求是,float至少32位,double至少48位,且不少于float,long double至少和double一样多。这三种类型的有效位数可以一样多。然而,通常,float为32位,double为64位, long double为80、96或128位。另外,这3种类型的指数范围至少是−37到37。可以从头文件cfloat或float.h中找到系统的限制。
3.3.3 浮点常量#
在默认情况下,像8.24和2.4E8这样的浮点常量都属于double类型。如果希望常量为float类型,请使用f或F后缀。对于long double类型,可使用l或L后缀(由于l看起来像数字1,因此L是更好的选择)。
3.3.4 浮点数的优缺点#
与整数相比,浮点数有两大优点。首先,可以表示整数之间的 值。其次,由于有缩放因子,它们可以表示的范围大得多。另一方面, 浮点运算的速度通常比整数运算慢,且精度将降低。
如: 2.34E+22是一个小数点左边有23位的数字。加上1,就 是在第23位加1。但float类型只能表示数字中的前6位或前7位,因此修改第23位对这个值不会有任何影响。
3.4 C++算术运算符#
11.17 + 50.25
应等于61.42,但是输出的却是61.419998。这不是运算问题,而是由于float类型表示有效位数的能力有限。记住,对于float,C++只保证6位有效位。如果将 61.419998四舍五入成6位,将得到61.4200,这是在保证精度下的正确值,如果用double 则精度足够,所以直接可以获得 61.42 的值。
通常来说 double 都比 float 更精准,应尽量使用 double。
3.4.1 运算符优先级和结合性#
算术运算符遵 循通常的代数优先级,先乘除,后加减。
3.4.2 除法分支#
除法运算符(/)的行为取决于操作数的类型。如果两个操作数都 是整数,则C++将执行整数除法。这意味着结果的小数部分将被丢弃, 使得最后的结果是一个整数。如果其中有一个(或两个)操作数是浮点 值,则小数部分将保留,结果为浮点数。
浮点常量在默认情况下为 double类型。
3.4.3 求模运算符#
求模运算符返回整数除法的余数。它与整数除 法相结合,尤其适用于解决要求将一个量分成不同的整数单元的问题。
3.4.4 类型转换#
由于有11种整型和3种浮点类型,因此计算机需 要处理大量不同的情况,C++自动执行很多类型转换:
- 将一种算术类型的值赋给另一种算术类型的变量时,C++将对值进 行转换;
- 表达式中包含不同的类型时,C++将对值进行转换;
- 将参数传递给函数时,C++将对值进行转换。
C++允许将一种类型的值赋给另一种类型的变量。这样做时,值将 被转换为接收变量的类型。例如,假设so_long的类型为long,thirty的类 型为short。
将一个值赋给值取值范围更大的类型通常不会导致什么问题。如,将short值赋给long变量并不会改变这个值,只是占用的字节更多而 已。然而,将一个很大的long值(如2111222333)赋给float变量将降低 精度。因为float只有6位有效数字,因此这个值将被四舍五入为 2.11122E9。因此,有些转换是安全的,有些则会带来麻烦。
如下的报错是一个可能:
将0赋给bool变量时,将被转换为false;而非零值将被转换为true。
当同一个表达式中包含两种不同的算术类型时,将出现什么情况 呢?在这种情况下,C++将执行两种自动转换:首先,一些类型在出现 时便会自动转换;其次,有些类型在与其他类型同时出现在表达式中时 将被转换。
- 在计算表达式时,C++将bool、char、unsigned char、signed char和short值转换为int。具体地说,true被转换为1,false 被转换为0。这些转换被称为整型提升(integral promotion)。
int 是一种最自然的类型,运算速度也最快,要主用。
同样,wchar_t被提升成为下列类型中第一个宽度足够存储wchar_t 取值范围的类型:int、unsigned int、long或unsigned long。
将不同类型进行算术运算时,也会进行一些转换,例如将int和float 相加时。当运算涉及两种类型时,较小的类型将被转换为较大的类型。
- 传递参数时的类型转换通常由C++函数原型控制。
- C++还允许通过强制类型转换机制显式地进行类型转换。强制类型转换 的格式有两种。如下:
新格式的想法 是,要让强制类型转换就像是函数调用。这样对内置类型的强制类型转换就像是为用户定义的类设计的类型转换。
更安全的转换方式是使用 static_cast<typeName> (value)
函数。
Stroustrup认为,C语言式的强制类型转换由于有过多的可能性而极 其危险,这将在第15章更深入地讨论。运算符 static_cast<>
比传统强制 类型转换更严格。
3.4.5 C++11中的auto声明#
在初始化声明中,如果 使用关键字auto,而不指定变量的类型,编译器将把变量的类型设置成 与初始值相同。
3.5 总结#
C++的基本类型分为两组:一组由存储为整数的值组成,另一组由存储为浮点格式的值组成。整型之间通过存储值时使用的内存量及有无符号来区分。整型从最小到最大依次是:bool、char、signed char、 unsigned char、short、unsigned short、int、unsigned int、long、unsigned long以及C++11新增的long long和unsigned long long。还有一种wchar_t 类型,它在这个序列中的位置取决于实现。C++11新增了类型char16_t 和char32_t,它们的宽度足以分别存储16和32位的字符编码。C++确保了char足够大,能够存储系统基本字符集中的任何成员,而wchar_t则可以存储系统扩展字符集中的任意成员,short至少为16位,而int至少与short一样长,long至少为32位,且至少和int一样长。确切的长度取决于 实现。
浮点类型可以表示小数值以及比整型能够表示的值大得多的值。3种浮点类型分别是float、double和long double。C++确保float不比double 长,而double不比long double长。通常,float使用32位内存,double使用 64位,long double使用80到128位。
对变量赋值、在运算中使用不同类型、使用强制类型转换时,C++将把值从一种类型转换为另一种类型。
第四章 复合类型#
本章很重要(特别是指针部分),需要掌握的内容为:
- 创建和使用数组;
- 创建和使用C-风格字符串;
- 创建和使用 string 类字符串;
- 使用方法 getline() 和 get() 读取字符串;
- 混合输入字符串和数字;
- 创建和使用结构体;
- 创建和使用共用体;
- 创建和使用枚举类型;
- 创建和使用指针;
- 使用 new 和 delete 管理动态内存;
- 创建动态数组;
- 创建动态结构;
- 自动存储、静态存储和动态存储;
- vector 和 array 类简介。
4.1 数组#
数组(array) 是一种数据格式,能够存储多个同类型的值。每个值都存储在一个独立的数组元素中,计算机在内存中依次存储数组的各个元素。
即一个数组所在的内存区域是连续的。
要创建数组,可使用声明语句。数组声明应指出以下三点:
- 存储在每个元素中的值的类型;
- 数组名;
- 数组中的元素数。
在C++中,通过在简单变量后面添加中括号(其中包含 元素数目)来完成数组声明。而数组中的每一个元素都看作是一个简单变量。声明数组的通用格式为 :
typeName arrayName[arraySize]
表达式arraySize指定元素数目,它必须是整型常数(如10)或const 值,也可以是常量表达式(如8 * sizeof(int)),即其中所有的值在编 译时都是已知的。具体说,arraySize不能是变量,变量的值是在程序 运行时设置的。如:
|
|
C++ 通过使用下标来访问数组中的每一个元素,C++ 数据从0开始编号,数组总长度-1 是最后一个元素,如上:months[0] 为第一个元素,months[11] 是最后一个元素。
4.1.1#
C++可以在声明语句中初始化数组,只需提供一个用逗号分隔的值列表(初始化列表),并将它们用花 括号括起即可。如 :
|
|
如果没有初始化数组的值,则其元素值将是不确定的,这意味着元素的值为以前驻留在该内 存单元中的值。
将sizeof运算符用于数组名,得到的将是整个数组 中的字节数。
4.1.2 数组的初始化规则#
只有在定义数组时才能使用初始化,此后就不能使用了,也不能将一个数组赋给另一个数组:
这个方式和 Python 完全不同,原因可能是 Python 以引用为基础赋值,C++此处则是值赋值,只能一个一个来(声明时除外)。
但此后,仍然可以通过下标分别给数组中的元素赋值。注意,这里是赋值不再是初始化。
初始化数组时,提供的值可以少于数组的元素数目,编译器将把其他元素设置为 0。因此,将数组中所有的元素都初始化为0非常简单—只要显式地将第 一个元素初始化为0,然后编译器会自动将其他元素都初始化为0。
还有一种方法,如果初始化数组时方括号内([ ])为空,C++编译器将计算元素个 数。但这是很糟糕的做法,不提倡,不举例。
4.1.3 C++11数组初始化方法#
数组以前就可使用列表初始化, 但C++11中的列表初始化新增了一些功能。
- 首先,初始化数组时,可省略等号(=);
- 其次,可不在大括号内包含任何东西,这将把所有元素都设置为零;
- 第三,列表初始化禁止缩窄转换。
在上述代码中,第一条语句不能通过编译,因为将浮点数转换为整 型是缩窄操作,即使浮点数的小数点后面为零。第二条语句也不能通过 编译,因为1122011超出了char变量的取值范围(这里假设char变量的长 度为8位)。第三条语句可通过编译,因为虽然112是一个int值,但它在 char变量的取值范围内。
4.2 字符串#
字符串是存储在内存的一片连续字节中的一系列字符,这里的连续是重点。 存储在连续字节中的一系列字符意味着可以将字符串存储在char数 组中,其中每个字符都位于自己的数组元素中。字符串提供了一种存储 文本信息的便捷方式。本章介绍两种处理字符串的方法:(1)C-风格字符串;(2)基于 string 类的方法。
C-风格字符串具有 一种特殊的性质:以空字符(null character)结尾,空字符被写作 \0, 其ASCII码为0,用来标记字符串的结尾。
这两个数组都是char数组,但只有第二个数组是字符串,空字符对 C-风格字符串而言至关重要。C++有很多处理字符串的函数,其 中包括cout使用的那些函数。它们都逐个地处理字符串中的字符,直到 到达空字符为止。如果使用cout显示上面的cat这样的字符串,则将显示 前7个字符,发现空字符后停止。使用cout显示上面的dog数 组(它不是字符串),cout将打印出数组中的8个字母,并接着将内存 中随后的各个字节解释为要打印的字符,直到遇到空字符为止(因此不加空字符是C-风格字符串中极其危险的行为)。
有一种更好的、 将字符数组初始化为字符串的方法—只需使用一个用引号括起的字符串 即可,这种字符串被称为字符串常量(string constant)或字符串字面值 (string literal),如下所示:
用引号括起来的字符串隐式地包括结尾的空字符,因此不用显式地包括它。
最需要注意的是,使用C-风格字符串,在确定存储字符串所需的最短数组时,别忘了+1,要将结尾的空字符计算在内。
这里的赋值改为:
char shirt_size[2] = "S";
才是正确的。
4.2.1 拼接字符串常量#
事实上,任何两个由空 白(空格、制表符和换行符)分隔的字符串常量都将自动拼接成一个。 因此,下面所有的输出语句都是等效的:
注意,拼接时不会在被连接的字符串之间添加空格,第二个字符串 的第一个字符将紧跟在第一个字符串的最后一个字符(不考虑\0)后 面。第一个字符串中的\0字符将被第二个字符串的第一个字符取代。
4.2.2 在数组中使用字符串#
从程序清单4.2中可以学到什么呢? 首先,sizeof运算符指出整个数组的长度:15字节,但 strlen( )函数返回的是存储在数组中的字符串的长度,而不是数组本身的长度。 另外,strlen( ) 只计算可见的字符,而不把空字符计算在内。因此,对于Basicman,返回的值为8,而不是 9。如果cosmic是字符串,则要存储该字符串,数组的长度不能短于strlen(cosmic)+1。 由于name1和name2是数组,所以可以用索引来访问数组中各个字符。例如,该程序使用 name1[0]找到数组的第一个字符。另外,该程序将name2[3]设置为空字符。这使得字符串在第 3个字符后即结束,虽然数组中还有其他的字符(参见图4.3)。
程序清单 4.2 使用 cin 暗含两个问题:
- 遇到空格结束;
- 输入字符串长于目标数组。
4.2.3 字符串输入#
cin是如何确定已完成字符串输入呢?由于不能通过键盘输入空字 符,因此cin需要用别的方法来确定字符串的结尾位置。cin使用空白 (空格、制表符和换行符)来确定字符串的结束位置,这意味着cin在 获取字符数组输入时只读取一个单词。读取该单词后,cin将该字符串 放到数组中,并自动在结尾添加空字符。
这个例子的实际结果是,cin把Alistair作为第一个字符串,并将它 放到name数组中。这把Dreeb留在输入队列中。当cin在输入队列中搜索 用户喜欢的甜点时,它发现了Dreeb,因此cin读取Dreeb,并将它放到 dessert数组中。
另一个问题是,输入字符串可能比目标数组长(运行中没有揭示出 来)。像这个例子一样使用cin,确实不能防止将包含30个字符的字符 串放到20个字符的数组中的情况发生。
文章将这个问题的系统处理留到第 17 章,但下文也能解决。
4.2.4 每次读取一行字符串输入#
每次读取一个单词通常不是最好的选择。具体地说,需要采用面向行而不是面向单词的方法。istream中的类(如 cin)提供了一些面向行的类成员函数:getline()
和 get()
。这两个函数都读取一 行输入,直到到达换行符(回车符)。区别是,getline()
将读入并丢弃换行符,而 get()
则将换行符保留在输入队列中。注意是还保留在输入流里,没有被读走!
1.面向行的输入:getline( )
getline( )函数读取整行,它使用通过回车键输入的换行符来确定输入结尾。通过 cin.getline()
调用。该函数有两个参数,第一个参数是存储输入行的数组名称,第二个参数是要读取的字符数。如果这个参数为20,则函数最多读取19个字符或者碰到换行符为止,并自动在结尾处添加空字符。如:
|
|
例子程序清单 4.4:
2.面向行的输入:get( )
iostream
中的 get()
虽然和 getline()
类似,它们接受的参数相同,解释参数的方式也相同,并且都读取到行尾。但 get 将换行符保留在输入队列中,假设我们连续两次 调用 get( )
那么要注意了。
第一次调用后,换行符将被留在输入队列中,因此第二次调用时看到的第一个字符便是换行符,此时 get()
以为已经到达行尾了,而没有发现任何可读取的内容。如果不借助于帮助,get()
将不能跨过该换行符。
这时要用 get()
的另一种变体——不带任何参数,可读取下一个字符(即使是换行符),因此可以用它来处理换行符,为读取下一行输入做好准备。例子:
由于 cin.get(name, ArSize)
返回的还是 cin 对象,因此还可以合并起来调用:
|
|
但风险就是,假如 ArSize 小于一行的字符数,那么调用 get()
后就会发生字符丢失。
getline()
成员函数也同样可以合并调用连续读取字符串:
|
|
这语句将把输入中 连续的两行分别读入到数组name1和name2 中,其效果与两次调用 cin.getline()
相同。
为什么要使用 get()
,而不是 getline()
呢?
首先,老式实现没有 getline()
。其次,get()
使输入更仔细。例如,假设用get( )
将一行读入数组中。如何知道停止读取的原因是由于已经读取了整行,而不是由于数组已填满呢? (getline,判断不了), 查看下一个输入字符,如果是换行符,说明已读取了整行;否则,说明该行中还有其他输入。
总之, getline()
使用起来简单一些,但 get( )
使得检查错误更简单些。
3.空行和其他问题
当 getline()
或 get()
读取空行时,将发生什么情况?最初的做法是, 下一条输入语句将在前一条 getline()
或 get()
结束读取的位置开始读取; 但当前的做法是,当 get()
(不是getline)读取空行后将设置失效位 (failbit)。这意味着接下来的输入将被阻断,要用 cin.clear()
的命令来恢复输入。
另一个潜在的问题是,输入字符串可能比分配的空间长。如果输入行包含的字符数比指定的多,则 getline()
和 get()
将把余下的字符留在输入队列中,getline()
还会设置失效位,并关闭后面的输入。
4.2.5 混合输入字符串和数字#
清单程序4.6 的一个问题是: 当cin读取年份,将回 车键生成的换行符留在了输入队列中。后面的cin.getline( )看到换行符 后,将认为是一个空行,并将一个空字符串赋给address数组。解决之道 是,在读取地址之前先读取并丢弃换行符。这可以通过几种方法来完 成,其中包括使用没有参数的get( )和使用接受一个char参数的get( )。
也可以利用表达式cin»year返回cin对象,将调用拼接起来:
|
|
4.3 string 类简介#
string
类包含于头文件 string
并位于名称空间 std
中,使用起来比字符数组简单,它提供了将字符串作为一种数据类型的表示方法,符合 C++ 的风格。
string 类定义隐藏了字符串的数组性质,让我们能够像处理普通变量那样处理字符串。
在很多方面,使用 string 对象的方式和使用字符数组相同:
- 可以使用 C-风格字符串来初始化 string 对象;
- 可以使用 cin 来将键盘输入存储到 string 对象中;
- 可以使用 cout 来显示 string 对象;
- 可以使用数组表示法来访问存储在 string 对象中的字符。
string 对象和字符数组的主要区别是,可以将 string 对象声明为简单变量,而不是数组。
类设计让程序能够自动处理string的大小。例如,str1的声明创建一 个长度为0的 string 对象,但程序将输入读取到str1中时,将自动调整 str1 的长度。
这使得与使用数组相比,使用string对象更方便,也更安全。从理论上说,可以将char 数组视为一组用于存储一个字符串的 char 存储单元,而 string 类变量是一个表示字符串的实体。
4.3.1 C++11字符串初始化#
4.3.2 string 赋值、拼接和附加#
string 可以如简单变量那般操作。比如,虽然不能将一个数组赋给另一个数组,但却可以将一个 string 对象赋给另一个 string 对象。
可以使用运算符 + 将两个string 对象合并起来,还可以使用运算符 += 将字符串附加到string对象的末尾。
|
|
4.3.3 string类的其他操作#
处理string对象的语法通常比使用C字符串函数简单,尤其是执行较为复杂的操作时。例如,对于下述操作:
str3 = str1 + str2;
使用C-风格字符串时,需要使用的函数如下:
|
|
strcpy()
和 strcat()
函数包含在头文件 cstring 中。函数 strcpy()
将字符串复制到字符数组中,使用函数 strcat()
将字符串附加到字符数组末尾。
另外,使用字符数组时,总是存在目标数组过小,无法存储指定信息的危险。
函数strcat( )试图将全部12个字符复制到数组site中,这将覆盖相邻的内存。这可能导致程序终止,或者程序继续运行,但数据被损坏。string类具有自动调整大小的功能,从而能够避免这种问题发生。
4.3.4 string类I/O#
在用户输入之前,该程序指出数组charr中的字符串长度为27,这比 该数组的长度要大。这里要两点需要说明。首先,为初始化的数组的内 容是未定义的;其次,函数strlen( )从数组的第一个元素开始计算字节 数,直到遇到空字符。在这个例子中,在数组末尾的几个字节后才遇到 空字符。对于未被初始化的数据,第一个空字符的出现位置是随机的, 因此您在运行该程序时,得到的数组长度很可能与此不同。
用户输入之前,str中的字符串长度为0。这是因为未被初始化的string对象的长度被自动设置为0。 这是和字符串数组不同的地方。
源代码中是将一行输入读取到string对象中的代码是:getline(cin, str);
。这个 getline()
不是类方法,它将 cin 作为参数,指出去哪里查找输入,另外,也没有指出字符串长度 的参数,因为 string
对象将根据字符串的长度自动调整自己的大小。
4.3.5 其他形式的字符串字面值#
除char类型外,C++还有类型wchar_t;而C++11新 增了类型char16_t和char32_t。可创建这些类型的数组和这些类型的字符 串字面值。对于这些类型的字符串字面值,C++分别使用前缀L、u和U 表示,下面是一个如何使用这些前缀的例子:
C++11还支持Unicode字符编码方案UTF-8。
C++11新增的另一种类型是原始(raw)字符串,并使用前缀 R 来标识,原始字符表示的就是自己,例如,序列 \n
不表示换行符,而表示两个常 规字符—斜杠和n,因此在屏幕上显示时,将显示这两个字符。
4.4 结构体简介#
**结构体(Struct)**是 一种比数组更灵活的数据格式,因为同一个结构体可以存储多种不同类型的数据,从而将数据的表示合并到一起。
原文称 struct 为 “结构”,我认为这不符合一贯以来的称呼,因此在这个笔记中我一律改为通常在
C/C++
中所称呼的 “结构体”。
结构体是用户定义的类型,而结构体声明定义了这种类型的数据属性。结构体的定义需要用 关键字 struct 做标识。 定义了类型后,便可以创建这种类型的变量。以下例子是一个结构体的定义,它使用了一个适合用于存储字符串的 char数组、一个float和一个double。列表中的每一项都被称为结构体成员,因此 infatable 结构体有3个成员(参见图4.6)
定义之后就可以将这个结构体当做常规的 C++ 数据类型(如同 int、double、string等)来使用。
在结构体类型中,可以通过使用成员运算符 .
来访问各个成员。
4.4.1 在程序中使用结构体#
结构声明的位置很重要。
结构体的初始化方式和数组一样,使用逗号分隔值列表,并将这些值用花括号括起。如:
花括号中每个值可以独占一行,也可以将他们都放在同一行中,注意变量之间有逗号隔开就行,其他的随意。
4.4.2 C++11 结构体初始化#
4.4.3 结构体可以将string类作为成员吗?#
可以,只要编译器支持 string 类型就没问题,唯一要注意的问题注意添加名字空间 std 。 如:
4.4.4 其他结构属性#
结构体和 C++ 内置类型的用法类似,它可以作为参数传递给函数,也可以让函数返回一个结构体。另外,还可以使用赋值运算符(=)将结构体赋给另一个同类型的结构体,这样结构体中每个成员都将被设置为另一个结构中相应成员的值,即使成员是数组。这种赋值被称为成员赋值(memberwise assignment)。
结构体类型还可以同时完成定义结构体和创建结构体变量的工作,只需将变量名放在结束括号的后面即可:
但不推荐这样做,将结构体定义和变量声明分开,可以使程序更易于阅读和理解。
4.4.5 结构体数组#
创建结构体数组的方法和创建 C++ 基本类型的数组完全相同。
要初始化结构体数组,可以使用初始化数组的规则(用逗号分隔每个元素的值,并将这些值用花括号括起)和初始化结构体的规则(用逗号分隔每个成员的值,并将这些值用花括号括起)。由于数组中的每个 元素都是结构,因此可以使用结构初始化的方式来提供它的值。
4.5 共用体#
共用体(union),也叫联合体是一种数据格式,它能够存储不同的数据类型, 但只能同时存储其中的一种类型。也就是说,结构体可以同时存储 int、 long和double,共用体只能存储int、long或double。共用体的句法与结构相似,但含义不同。如:
共用体每次只能存储一个值,因此它必须有足够的空间来存储最大的成员,所以,共用体的长度为其最大成员的。
共用体的用途之一是,当数据项使用两种或更多种格式(但不会同时使用)时,可节省内存空间,其实对当前内存充裕的计算机来说,并非很有必要使用共用体。
4.6 枚举#
这也是一个很少用的类型,略。
4.7 指针和自由存储空间#
计算机在存储数据时必须跟踪的3种基本属性:
- 信息存储在何处;
- 存储的值为多少;
- 存储的信息是什么类型。
C++ 提供了一种策略,可以在程序内部跟踪内存单元,这个策略以指针为基础。C++ 中 指针是一个变量,其存储的是值的地址,而不是值本身。
首先,我们如何找到常规变量的地址?在C/C++中只需对变量应用地址运算符 &
,就可以获得它的位置;例如,如果 home
是一个变量,则 &home
是它在内存中的地址。
使用常规变量时,值是指定的量,而地址为派生量。接下来看看指针策略,它是C++内存管理编程理念的核心。
指针处理存储数据的新策略刚好相反,指针将地址视为指定的量,而将值视为派生量,因此,指针名表示的是地址。*
运算符被称为间接值(indirect velue)或解除引用 (dereferencing)运算符,应用于指针得到该地址处所存储的值。例如,假设 manly
是一个指针,则 manly
表示的是一个地址,*manly
表示存储在该地址处的值,即 *manly
和常规变量等效。变量的指针和变量的值本身本质只不过是一个硬币的两个面。
变量是编译时分配出来的一个有名称的内存,这是变量的实质。而指针是一个可以通过名称直接访问内存的别名,内存里存储的东西就是变量的值。
4.7.1 声明和初始化指针#
不同数据类型存储值时的内存格式不同,所以指针声明时必须 指定指针指向的数据类型,声明一个指针的通用方式:
typeName *p_name;
*
符用于指明该变量是指针,每个指针变量声明时都需要带 *
。
使用指针的时候要注意区分地址是地址,值是值。指针本身也是一种数据类型,它的值是地址,这个地址也有它自己的字节长度(由计算机系统内定),它的长度和它这个地址里存储的数值没有任何关系。指向 int 类型和指向 double 类型或者 string 类型的指针类型的字节长度都是一样的,它们都是地址。
4.7.2 指针的危险#
创建指针时,计算机将分配用来存储指针这个变量本身的内存,但不会分配指针所指向的数据所需的内存。为数据提供空间是一个独立的步骤,如果忽略这一步麻烦就大了。如:
|
|
声明时,只是告诉程序 fellow
是一个 long
指针,程序只给 fellow
这个指针变量分配了一个属于 fellow
自己的存储空间,至于它里面要存什么地址值还完全不知道。必须先申请一个存 long
类型的地址空间,然后赋值给 fellow
,最后才可以将 223323 赋值过去。
一定要在对指针应用解除引用运算符
*
之前,将指针初始化为一个确定的、适当的地址,这是关于使用指针的铁律。
4.7.3 指针和数字#
指针不是整型,虽然计算机通常把地址当作整数来处理。在有些平台 中,int 类型是个2字节值,而地址是个4字节值。
4.7.4 使用new来分配内存#
指针真正的用武之地在于,运行阶段分配未命名的内存用于存储值。C++ 通过 new 运算符为变量分配内存。
程序员要告诉new,需要为哪种数据类型分配内存;new将找到一个长 度正确的内存块,并返回该内存块的地址。程序员的责任是将该地址赋 给一个指针。下面是一个这样的示例:
|
|
new int
告诉程序,需要适合存储 int
的内存。new
运算符根据类型来确定需要多少字节的内存。然后,它找到这样的内存,并返回其地址。 接下来,将地址赋给 pn
,pn
是被声明为指向 int
的指针。现在,pn
是地址,而 *pn
是存储在那里的值。
注意:pn
指向的内存不是变量(那是一个地址,是不变的),内存里存储的东西才是可变的。
这引出了一个问题:pn指向的内存没 有名称,如何称呼它呢?我们说pn指向一个数据对象,这里的“对象”不 是“面向对象编程”中的对象,而是一样“东西”。术语“数据对象”比“变 量”更通用,它指的是为数据项分配的内存块。
为一个数据对象(可以是结构,也可以是基本类型)获得并指定分 配内存的通用格式如下:
|
|
需要指出的另一点是,new
分配的内存块通常与常规变量声明分配的内存块不同。常规变量存储在被称为栈 (stack)的内存区域中,而 new
从被称为堆(heap)或自由存储区(free store)的内存区域分配内存。
如果内存分配失败,new
运算符将返回空指针(null pointer),其值为 0。
4.7.5 使用 delete 释放内存#
delete
运算符,用于将不再使用的内存归还给内存池,归还或释放(free)的内存可供程序的其他部分使用。使用 delete
时,后面要加上指向内存块的指针(这些内存块最初是用new分配的)。
|
|
归还 ps 指向的内存,并不会删除指针 ps 本身,它可以继续用来指向新分配的内存。
new
和 delete
一定要配对使用,否则会导致程序发生内存泄露(memory leak),即分配出去的内存拿不回来,无法再使用。
另外,不要尝试释放已经释放的内存块,C++标准指出,这样做的结果将是不确定的,这意味着什么情况都可能发生。
一般来说,不要创建两个指向同一个内存块的指针,因为这将增加 错误地删除同一个内存块两次的可能性。
另外,不能使用 delete
释放声明变量所获得的内存,只能用它释放 new
分配的内存,这是关键点。
4.7.6 使用new来创建动态数组#
对于大型数据(如数组、字符串和结 构),应使用new,这正是new的用武之地。
在编译时给数组分配内存被称为静态联编(static binding),这就是为什么只能分配固定大小的数组。但使用 new
时,在运行阶段需要数组,则创建它;如果不需 要,则不创建,还可以选择数组的长度,这被称为动态联 编(dynamic binding),意味着数组是在程序运行时创建的。
1.使用 new 创建动态数组
创建动态数组很容易;只要将数组的元素类型和元素数 目告诉new即可。必须在类型名后加上方括号,其中包含元素数目。为数组分配内存的通用格式如下:
typeName *pointer_name = new typeName [num_elements];
给个例子如:
|
|
new
返回第一个元素的地址,并将该地址被赋给指针 psome
。
释放这一块内存时要这样做:
|
|
方括号告诉程序,应释放整个数组,而不仅仅是指针指向的元素。 注意delete和指针之间有方括号。对于 ANSI/ISO标准来说,new
与 delete
的格式要匹配,否则导致的后果是不确定。
总之,使用new和delete时,应遵守以下规则:
- 不要使用
delete
来释放不是new
分配的内存; - 不要使用
delete
释放同一个内存块两次; - 如果使用
new []
为数组分配内存,则应使用delete []
来释放; - 如果使用
new []
为一个实体分配内存,则应使用delete
(没有方括 号)来释放(这一点要找例子来理解); - 对空指针应用
delete
是安全的。
2.使用动态数组
创建动态数组后,如何使用它呢?只需要将指针名当做数组名,然后按照数组的访问方式即可,C/C++ 中数组和指针是基本等价的(但也有实质区别,后续再说)。例子:
|
|
对于第1个元素,可以使用 psome[0]
,而不是 *psome
;对于第2个元 素,可以使用 psome[1]
,依此类推。
程序清单4.18演示了如何使用new来创建动 态数组以及使用数组表示法来访问元素;它还指出了指针和真正的数组名之间的根本差别(注意红框)。
将p3
加1导致它指向第2个元素而不是第1个。将它减1后,指针将指向原来的值,这样程序便可以给 delete[]
提供正确的地址。(这叫指针移动)
4.8 指针、数组和指针算术#
指针算术是有特殊之处的。
指针和数组基本等价的原因在于指针算术(pointer arithmetic)和 C++ 内部处理数组的方式。
将整数变量加1 后,其值将增加1;但将指针变量加1后,增加的量等于它指向的类型的 字节数。将指向double的指针加1后,如果系统对double使用8个字节存 储,则数值将增加8;将指向short的指针加1后,如果系统对short使用2 个字节存储,则指针值将增加2。
4.8.1 程序说明#
将指针变量加1后,其增加的值等于指向的类型占用的字节数。 也就是跳到下一个存储值的地址上。
从该程序的输出可知,*(stacks + 1)
和 stacks[1]
是等价的。同样, *(stacks + 2)
和 stacks[2]
也是等价的。通常,使用数组表示法时,C++
都执行下面的转换:
arrayname[i] becomes *(arrayname + i)
如果使用的是指针,而不是数组名,则C++也将执行同样的转换:
pointername[i] becomes *(pointername + i)
因此,在很多情况下,可以相同的方式使用指针名和数组名。也就是说,使用 new
来创建数组以及使用指针来访问不同的元素时,只要把指针当作数组名对待即可。
【注意】数组名被解释为其第一个元素的地址,而对数组名应用地址运算符 时,得到的是整个数组的地址,区别很大。
4.8.2 指针小结#
1.声明指针 要声明指向特定类型的指针,请使用下面的格式:
|
|
2.给指针赋值
应将内存地址赋给指针。可以对变量名应用 &
地址运算符,来获得被命名的内存的地址,或 new
运算符返回未命名的内存的地址。
3.对指针解引用
对指针解引用意味着获得指针指向的值。*
是指针应用解引用或间接值运算符。
4.区分指针和指针所指向的值
如果 pt
是指向int的指针,则 *pt
不是指向 int
的指针,而是完全等同于 一个 int
类型的变量,pt
才是指针。
5.数组名
大多数情况下,C++将数组名视为数组的第一个元素的地址。一种例外情况是,将sizeof运算符用于数组名用时,此时将返回整个数组的长度(单位为字节)。
6.指针算术
C++允许将指针和整数相加。加1的结果等于原来的地址值加上指向的对象占用的总字节数,也就是移动到下一个存储值的地址。还可以将一个指针减去另一个指针,获得两个指针的差。后一种运算将得到一个整数,仅当两个指针指向同一个数 组(也可以指向超出结尾的一个位置)时,这种运算才有意义,这将得到两个元素的间隔。
7.数组的动态联编和静态联编
使用数组声明来创建数组时,将采用静态联编,即数组的长度在编 译时设置。
使用 new[]
运算符创建数组时,将采用动态联编(动态数组),即将在运行时为数组分配空间,其长度也将在运行时设置。使用完这种数组后,应使用 delete []
释放其占用的内存。
8.数组表示法和指针表示法
使用方括号数组表示法等同于对指针解引用。数组名和指针变量都是如此,因此对于指针和数组名,既可以使用 指针表示法,也可以使用数组表示法。
4.8.3 指针和字符串#
数组和指针的特殊关系可以扩展到C-风格字符串。
|
|
以上代码中,数组名是第一个元素的地址,因此cout语句中的flower是包含字符r 的 char 元素的地址。cout 对象认为 char 的地址是字符串的地址,因此它打 印该地址处的字符,然后继续打印后面的字符,直到遇到空字符 (\0) 为止。总之,如果给cout提供一个字符的地址,则它将从该字符开始打 印,直到遇到空字符为止。
这意味着可以将指向char的指针变量作为cout的参数,因为它也是 char的地址。 在C++中,用引号括起的字符串像数组名一样,也是第一个元素的地 址。这意味着对于数组中的字符串、用引号括起的字符串常量以及指针 所描述的字符串,处理的方式是一样的,都将传递它们的地址。与逐个 传递字符串中的所有字符相比,这样做的工作量确实要少。
在 cout 和多数 C++ 表达式中,
char
数组名、char
指针以及用引号括起的字符串常量都被解释为字符串第一个字符的地址。
请不要使用字符串常量或未被初始化的指针来接收输入。为避免这 些问题,也可以使用std::string对象,而不是数组。
在将字符串读入程序时,应使用已分配的内存地址。该地址可以是数组名,也可以是使用new 初始化过的指针。
注意这段代码及其输出:
一般来说,如果给cout提供一个指针,它将打印地址。但如果指针 的类型为 char *
,则 cout将显示指向的字符串。如果要显示的是字符串的 地址,则必须将这种指针强制转换为另一种指针类型,如 int *
(上面的 代码就是这样做的)。
经常需要将字符串放到数组中。初始化数组时,请使用=运算符; 否则应使用strcpy( )或strncpy( )。应使用strcpy( )或strncpy( ),而不是赋值运算符来将字符串赋给数组。
这是 C-风格 字符串的操作方式,C++ string 类型则并不需要如此,会简单得多,不用担心字符串会导致数 组越界,并可以使用赋值运算符而不是函数strcpy( )和strncpy( )。
4.8.4 使用 new 创建动态结构体#
在运行时创建数组优于在编译时创建数组,对于结构也是如此。通过使用new,可以创建动态结构。
将 new 用于结构体由两步组成:创建结构体和访问其成员。要创建结构体,需要同时使用结构体类型和 new。
|
|
这将把足以存储inflatable结构的一块可用内存的地址赋给ps。这种 句法和C++的内置类型完全相同。
此时要访问结构体成员,需通过箭头成员运算符 −>
,它用于指向结构体的指针,就像点运算符可用于结构体名一样,如 ps->price
指向了结构体中的 price 成员。
如果结构标识符是结构名,则使用句点运算符;如果标识符是指向结构 的指针,则使用箭头运算符。
另一种访问结构成员的方法是先对指针解引用。如果 ps
是指向结构的指针,则 *ps
就是被指向的值—结构本身。由于 *ps
是一个结构,因此 (*ps).price
是 该结构的 price
成员。
4.8.5 自动存储、静态存储和动态存储#
根据用于分配内存的方法,C++有3种管理数据内存的方式:自动存储、静态存储和动态存储(有时也叫作自由存储空间或堆)。在存在时间的长短方面,以这3种方式分配的数据对象各不相同。
- 自动存储
在函数内部定义的常规变量使用自动存储空间,被称为自动变量 (automatic variable),只在包含它的代码中有效,这意味着它们在所属的函数被调用时自动产生,在该函数结束时消亡。
自动变量通常存储在栈中。
- 静态存储
静态存储是整个程序执行期间都存在的存储方式。使变量成为静态 的方式有两种:一种是在函数外面定义它;另一种是在声明变量时使用 关键字 static
。
- 动态存储
new
和 delete
运算符提供了一种比自动变量和静态变量更灵活的方法。它们管理了一个内存池,这在C++中被称为自由存储空间(free store)或堆(heap)。该内存池同用于静态变量和自动变量的内存是分开的。
4.9 类型组合#
指针数组,指向指针的指针,也叫二维指针可以用来创建动态二维数组,类型较为复杂。
4.10 数组的替代品#
STL 标准库中的模板类 vector
和 array
是数组的替代品。
4.10.1 模板类 vector#
模板类 vector
类似于 string
类,也是一种动态数组,创建时要包含头文件 vector 。可以在运行阶段设置 vector
对象的长度,也可在末尾附加新数据,还可以在中间插入新数据,vector
的长度不要求必须设定,因为它会依据插入的数据量自动增长,容量增长的速度是 2 的 n
次方。
基本上,它是使用 new
创建动态数组的替代品。实际上,vector
类确实使用 new
和delete
来管理内存,但这种工作是自动完成的。
vector
的声明方式:
|
|
这样 v_name 就是一个 vector 对象,但 number 不是必须的。
vector
的最完整文档和例子,应参照网站 http://cplusplus.com/reference/vector/vector 。
4.10.2 模板类array(C++11)#
vector
类的功能比数组强大,但付出的代价是效率稍低(其实没那么低效)。如果您需 要的是长度固定的数组,使用数组是更佳的选择,但代价是不那么方便 和安全。有鉴于此,C++11 新增了模板类 array
,它也位于名称空间 std
中。与数组一样,array
对象的长度也是固定的,也使用栈(静态内存分 配),而不是自由存储区,因此其效率与数组相同,但更方便,更安全。
要创建array对象,需要包含头文件array。array对象的创建语法与 vector稍有不同:
|
|
与创建 vector
对象不同的是,n_element 不能是变量,必须是固定的常量和普通数组要求一样。
通过
array
模板类创建数组有什么优势?
https://cplusplus.com/reference/array/array
4.10.3 比较数组、vector 对象和 array 对象#
这三者的异同是什么?
首先,无论是数组、vector
对象还是 array
对象,都可使用标 准数组表示法来访问各个元素;
其次,从地址可知,array
对象和数组存 储在相同的内存区域(即栈)中,而vector
对象存储在另一个区域(自由存储区或堆)中;
第三,可以将一个 array
对象直接赋给另一个 array
对象,而标准数组,必须逐元素复制数据 (使用 array 模板类的两个好处之一,另一个好处是控制数组超界风险)。
例子:
在上面代码中注意一个语句:
|
|
这是什么意思?这个语句会被转换为:
|
|
含义是:找到a1指向的地方,向前移两个double元素,并将 20.2存储到目的地。也就是说,将信息存储到数组的外面。与C语言一 样,C++也不检查这种超界错误,也就是说数组的这种行为是不安全的,C++ 却不禁止,要小心。
vector
和 array
对象能够禁止这种行为吗?如果您让它们禁止,它们 就能禁止。另外,这 些类还让您能够降低意外超界错误的概率。例如,它们包含成员函数 begin()
和 end()
,让您能够确定边界,以免无意间超界。
4.11 总结#
数组、结构体和指针是C++的3种复合类型。
数组可以在一个数据对 象中存储多个同种类型的值。通过使用索引或下标,可以访问数组中各 个元素。
结构体可以将多个不同类型的值存储在同一个数据对象中,可以使用成员关系运算符.
来访问其中的成员。使用结构体的第一步是创建结构体模板,它定义结构存储了哪些成员。模板的名称将成为新类型的标识符,然后就可以声明这种类型的结构变量。
指针是被设计用来存储地址的变量,指针指向它存储的地 址。指针声明指出了指针指向的对象的类型。对指针应用解引用运算符 *
,将得到指针指向的位置中的值。
字符串是以空字符为结尾的一系列字符。字符串可用引号括起的字 符串常量表示,其中隐式包含了结尾的空字符。可以将字符串存储在 char数组中,可以用被初始化为指向字符串的char指针表示字符串。
string
对象将根据要存储的字符串自动调整其 大小,用户可以使用赋值运算符来复制字符串。
new
运算符允许在程序运行时为数据对象请求内存。该运算符返回 获得内存的地址,可以将这个地址赋给一个指针,程序将只能使用该指 针来访问这块内存。使用解引用运算符 *
获得其值;如果数据对象是数组,则可以像使用数组名那样使用指针来访问元素;如果数据对象是结构体,则可以用指针解引用运算符 ->
访问其成员。
指针和数组紧密相关。如果 ar
是数组名,则表达式 ar[i]
被解释为 *(ar + i)
( i
可以是负值,表示指针左移),其中数组名被解释为数组第一个元素的地址。这样,数组名的作用和指针相同。反过来,可以使用数组表示法,通过指针名来访 问new分配的数组中的元素。
运算符 new
和 delete
允许显式控制何时给数据对象分配内存,何时将内存归还给内存池。
第五章 循环和关系表达式#
本章内容包括:
- for 循环;
- 表达式和语句;
- 递增运算符和递减运算符: ++ 和 –;
- 组合赋值运算符;
- 复合语句(语句块);
- 逗号运算符;
- 关系运算符:>、>=、==、<=、< 和 !=;
- while 循环;
- typedef 工具;
- do while 循环;
- 字符输入方法 get() ;
- 文件尾条件;
- 嵌套循环和二维数组。
5.1 for 循环#
5.1.1 for 循环初始化#
for循环为执行重复的操作提供了循序渐进的步骤。组成部分完成下面这些步骤:
- 设置初始值;
- 执行测试,判断循环是否应当继续进行;
- 执行循环操作;
- 更新用于测试的值。
初始化、测试和更 新操作构成了控制部分,这些操作由括号括起。其中每部分都是一个表 达式,彼此由分号隔开。控制部分后面的语句叫作循环体,只要测试表 达式为true,它便被执行:
|
|
循环体如果大于一条语句,需要加花括号:
|
|
C++语法将整个for看作一条语句—虽然循环体可以包含一条或多条 语句。循环只执行一次初始化。
for
是一个 C++ 关键字,编译器不会将 for
视为一个函 数,这还将防止将函数命名为 for
。
【编程风格 Tips】C++ 常用的方式是,在 for 和括号之间加上一个空格,但省略函数名与括号之间的空格。
看这个表达式赋值逻辑:
|
|
赋值运算符是从右向左结合的,因此首先将0赋给z,然 后将z = 0赋给y,依此类推。
C++在C循环的基础上添加了一项特性,要求对for循环句法做一些 微妙的调整。
这是原来的句法:
|
|
但,C++循环允许像下面这样做:
|
|
也就是说,可以在for循环的初始化部分中声明变量。这很方便,但并不适用于原来的句法,因为声明不是表达式。这个变量的生存时间只存在于for语句中,也就是说,当程序离开循环后,i
这个变量将消失。
5.1.2 回到for循环#
很简单,略。
5.1.3 修改步长#
循环示例每一轮循环都将循环计数加1或减1。可以通 过修改更新表达式来修改步长,例如改为表达式 i = i + by
,其中 by
是用户选择的步长值。
5.1.4 使用for循环访问字符串#
很简单,略。
5.1.5 递增运算符(++)和递减运算符(−−)#
这两个运算符执行两种极其常见 的循环操作:将循环计数加1或减1。这两个运算符都有两种变体。前缀(prefix)版本位于操作数前 面,如++x;后缀(postfix)版本位于操作数后面,如x++。两个版本对操作数的影响是一样的,但是影响的时间不同。这就像对于钱包来说, 清理草坪之前付钱和清理草坪之后付钱的最终结果是一样的,但支付钱 的时间不同。
粗略地讲,a++意味着使用a的当前值计算表达式,然后将a的值加 1;而++b的意思是先将b的值加1,然后使用新的值来计算表达式。 递减操作符也同理。
5.1.6 副作用和顺序点#
C++就递增运算符何时生效的哪些方面做了规 定,哪些方面没有规定。首先,副作用(side effect)指的是在计算表达式时对某些东西(如存储在变量中的值)进行了修改;顺序点 (sequence point)是程序执行过程中的一个点,在这里,进入下一步之 前将确保对所有的副作用都进行了评估。在C++中,语句中的分号就是 一个顺序点,这意味着程序处理下一条语句之前,赋值运算符、递增运算符和递减运算符执行的所有修改都必须完成。
另外,任何完整的表达式末尾都是一个顺序点。
何为完整表达式呢?它是这样一个表达式:不是另一个更大表达式 的子表达式。完整表达式的例子有:表达式语句中的表达式部分以及用 作while循环中检测条件的表达式。
5.1.7 前缀格式和后缀格式#
如果变量被用于某些目的(如用作函数参数或给变量赋 值),使用前缀格式和后缀格式的结果将不同。
然而,虽然选择使用前缀格式还是后缀格式对程序的行为没有影 响,但执行速度可能有细微的差别。对于内置类型和当代的编译器而 言,这看似不是什么问题。然而,C++允许您针对类定义这些运算符, 在这种情况下,用户这样定义前缀函数:将值加1,然后返回结果;但后缀版本首先复制一个副本,将其加1,然后将复制的副本返回。因 此,对于类而言,前缀版本的效率比后缀版本高。
5.1.8 递增/递减运算符和指针#
可以将递增运算符用于指针和基本变量。将递增 运算符用于指针时,将把指针的值增加其指向的数据类型占用的字节数,这种规则适用于对指针递增和递减。
前缀递增、前缀递减和解引用运算符的优先级相同,以从右到左的方式进行结合。后缀递增和后缀递减的优先级相同,但比前缀运算符的优先级高,这两个运算符以从左到右的方式进行结合。
前缀运算符的从右到到结合规则意味着 *++pt
的含义如下:现将 ++
应用于 pt
(因为 ++
位于 *
的右边),然后将 *
应用于被递增后的 pt
。 另一方面,++*pt
意味着先取得 pt
指向的值,然后将这个值加 1,pt
依然指向原来的地址;
而这个组合 (*pt)++
圆括号指出,首先对指针解引用,得到值。然后,运算符++将这个值递增 1,但 pt
仍然指在原来的地址。
5.1.9 组合赋值运算符#
+=
运算符将两个操作数相加,并将结果赋给左边的操作数。这意味着左边的操作数必须能够被赋值,如变量、数组元素、结构体成员或通过对指针解引用来标识的数据。
5.1.10 复合语句(语句块)#
只有这个地方要注意:假设对循环体进行了缩进,但省略了花括号,编译器将忽略缩进,因此只有第一条语句位于循环中。
5.1.11 其他语法技巧—逗号运算符#
语句块允许把两条或更多条语句放到按C++句法 只能放一条语句的地方。逗号运算符对表达式完成同样的任务,允许将 两个表达式放到C++句法只允许放一个表达式的地方。如:
|
|
但逗号并不总是逗号运算符。 如:
|
|
到目前为止,逗号运算符最常见的用途是将两个或更多的表达式放 到一个for循环表达式中。不过C++还为这个运算符提供了另外两个特 性。首先,它确保先计算第一个表达式,然后计算第二个表达式(换句 话说,逗号运算符是一个顺序点)。
|
|
在所有运算符中,逗号运算符的优先级是最低的。
5.1.12 关系表达式#
5.1.13 赋值、比较和可能犯的错误#
很简单,略。
5.1.14 C-风格字符串的比较#
假设要知道字符数组中的字符串是不是mate。如果word是数组名, 下面的测试可能并不能像我们预想的那样工作:
|
|
请记住,数组名是数组的地址。同样,用引号括起的字符串常量也 是其地址。因此,上面的关系表达式不是判断两个字符串是否相同,而 是查看它们是否存储在相同的地址上。两个字符串的地址是否相同呢? 回答是否定的,虽然它们包含相同的字符。
由于C++将C-风格字符串视为地址,因此如果使用关系运算符来比 较它们,将无法得到满意的结果。需要用 C 字符串库中的 strcmp() 函数来比较。 该函数接受两个字符串地址作为参数。这意味着 参数可以是指针、字符串常量或字符数组名。如果两个字符串相同,该 函数将返回零;如果第一个字符串按字母顺序排在第二个字符串之前, 则strcmp( )将返回一个负数值;如果第一个字符串按字母顺序排在第二 个字符串之后,则strcpm( )将返回一个正数值。实际上,“按系统排列顺 序”比“按字母顺序”更准确。这意味着字符是根据字符的系统编码来进 行比较的。例如,使用ASCII码时,所有大写字母的编码都比小写字母 小,所以按排列顺序,大写字母将位于小写字母之前。因此,字符 串“Zoo”在字符串“aviary”之前。
存储在不同长度的数组 中的字符串彼此不相等。但是C-风格字符串是通过结尾的空值字符定义 的,而不是由其所在数组的长度定义的。这意味着两个字符串即使被存 储在长度不同的数组中,也可能是相同的。
5.1.15 比较string类字符串#
如果使用string类字符串而不是C-风格字符串,比较起来将简单些。直接通过运算符比较:>, <, == 等。
5.2 while 循环#
while
循环是一个没有初始化和更新部分的 for
循环,它只有测试条件和循环体,与for循环一样,循环体也由一条语句或两个花括号定义的语句块组成。句法如下:
|
|
如果循环体有多条语句,需要花括号:
|
|
首先,程序计算圆括号内的测试条件(test-condition)表达式。如 果该表达式为true,则执行循环体中的语句。执行完循环体后,程序返 回测试条件,对它进行重新评估。如果该条件为非零,则再次执行循环 体。测试和执行将一直进行下去,直到测试条件为false为止。
while 循环和 for 循环一样,也是一 种入口条件循环。因此,如果测试条件一开始便为false,则程序将不会 执行循环体。
5.2.1 for 与 while#
在C++中,for 和 while 循环本质上是相同的。
但它们之间存在三个差别:
- 在
for
循环中省略了测试条件时,将认为条件为true
; - 在
for
循环中,可使用初始化语句声明一个局部变量,但在while
循环中不能这样做; - 如果循环体中包括
continue
语句,情况将稍有不同,continue
语句将在第6章讨论。
通常,程序员使用 for
循环来为循环计数,因为 for
循环格式允许将所有相关的信息—初始值、终止值和更新计数器的方法—放在同一个地方。在无法预先知道循环将执行的次数时,程序员常使用 while
循环。
5.2.2 等待一段时间:编写延时循环#
函数 clock()
可以返回程序开始执行后所用的系统时间。但,首先,clock( )返回时间的单位不一定是秒;其次,该函数的返回类 型在某些系统上可能是long,在另一些系统上可能是unsigned long或其 他类型。
头文件 ctime
提供了这些问题的解决方案。首先,它定义了一个符号常量—CLOCKS_PER_SEC
,该常量等于 每秒钟包含的系统时间单位数。因此,将系统时间除以这个值,可以得 到秒数。或者将秒数乘以 CLOCK_PER_SEC
,可以得到以系统时间单位 为单位的时间。其次,ctime
将 clock_t
作为 clock()
返回类型的别名,这意味着可以将变量声明为 clock_t
类型,编译器将把它转换为long、unsigned int 或适合系统的其他类型。
do while 循环#
它 不同于另外两种循环,因为它是出口条件(exit condition)循环。这意 味着这种循环将首先执行循环体,然后再判定测试表达式,决定是否应 继续执行循环。如果条件为false,则循环终止;否则,进入新一轮的执 行和测试。这样的循环通常至少执行一次,循环体是一条语句或用括号括起的语句块。句法:
|
|
5.4 基于范围的for循环(C++11)#
C++11新增了一种循环:基于范围(range-based)的for循环。这简化了一种常见的循环任务:对数组(或容器类,如vector和array)的每 个元素执行相同的操作。
5.5 循环和文本输入#
5.5.1 使用原始的cin进行输入#
为什么程序在输出时省略了空格呢?原因 在cin。读取char值时,与读取其他基本类型一样,cin将忽略空格和换行 符。因此输入中的空格没有被回显,也没有被包括在计数内。
更为复杂的是,发送给cin的输入被缓冲。这意味着只有在用户按 下回车键后,他输入的内容才会被发送给程序。这就是在运行该程序 时,可以在#后面输入字符的原因。按下回车键后,整个字符序列将被 发送给程序,但程序在遇到#字符后将结束对输入的处理。
5.5.2 使用cin.get(char)进行补救#
通常,逐个字符读取输入的程序需要检查每个字符,包括空格、制 表符和换行符。cin
所属的 istream
类(在 iostream
中定义)中包含一个能 够满足这种要求的成员函数。具体地说,成员函数cin.get(ch)读取输入 中的下一个字符(即使它是空格),并将其赋给变量ch。使用这个函数 调用替换cin»ch,可以修补程序清单5.16的问题。
在C语言中, 要修改变量的值,必须将变量的地址传递给函数。但程序清单5.17调用 cin.get( )时,传递的是ch,而不是&ch。在C语言中,这样的代码无效, 但在C++中有效,只要函数将参数声明为引用即可。引用是C++在C语 言的基础上新增的一种类型。头文件iostream将cin.get(ch)的参数声明为 引用类型,因此该函数可以修改其参数的值。
5.5.3 使用哪一个cin.get( )#
函数重载允许创建多个同名函数,条件是它们 的参数列表不同。例如,如果在C++中使用 cin.get(name,ArSize)
, 则编译器将找到使用 char*
和 int
作为参数的 cin.get()
版本;如果使用 cin.get(ch)
,则编译器将使用接受一个 char
参数的版本;如果没有提 供参数,则编译器将使用不接受任何参数的cin.get()
版本。函数重载允 许对多个相关的函数使用相同的名称,这些函数以不同方式或针对不同类型执行相同的基本任务。
5.5.4 文件尾条件#
如果输入来自于文件,则可以使用一种功能更强大的技 术—检测文件尾(EOF)。C++输入工具和操作系统协同工作,来检测文件尾并将这种信息告知程序。
检测到EOF后,cin将两位(eofbit和failbit)都设置为1。可以通过 成员函数eof( )来查看eofbit是否被设置;如果检测到EOF,则cin.eof( )将 返回bool值true,否则返回false。同样,如果eofbit或failbit被设置为1, 则fail( )成员函数返回true,否则返回false。
5.5.5 另一个cin.get( )版本#
为成功地使用cin.get( ),需要知道其如何处理EOF条件。当该函数 到达EOF时,将没有可返回的字符。相反,cin.get( )将返回一个用符号 常量EOF表示的特殊值。该常量是在头文件iostream中定义的。EOF值 必须不同于任何有效的字符值,以便程序不会将EOF与常规字符混淆。 通常,EOF被定义为值−1,因为没有ASCII码为−1的字符,但并不需要知道实际的值,而只需在程序中使用EOF即可。
另一方面,使用cin.get(ch)(有一个参数)进行输入时,将不会导 致任何类型方面的问题。前面讲过,cin.get(char)函数在到达EOF时,不 会将一个特殊值赋给ch。事实上,在这种情况下,它不会将任何值赋给 ch。ch不会被用来存储非char值。
那么应使用cin.get( )还是cin.get(char)呢?使用字符参数的版本更符 合对象方式,因为其返回值是istream对象。这意味着可以将它们拼接起 来。例如,下面的代码将输入中的下一个字符读入到ch1中,并将接下 来的一个字符读入到ch2中:
|
|
5.6 嵌套循环和二维数组#
C++没有提供二维数组类型,但用户可以创建每个元素本身都是数 组的数组。可以这样声明数组:
|
|
假设要打印数组所有的内容,可以用一个for循环来改变行,用另一 个被嵌套的for循环来改变列:
|
|
实际上,我更喜欢用 vector 来创建动态二维数据,只要创建 vector 的vector 就行,用起来更简单。
5.7 总结#
C++提供了3种循环:for循环、while循环和do while循环。如果循环 测试条件为true或非零,则循环将重复执行一组指令;如果测试条件为 false或0,则结束循环。for循环和while循环都是入口条件循环,这意味 着程序将在执行循环体中的语句之前检查测试条件。do while循环是出 口条件循环,这意味着其将在执行循环体中的语句之后检查条件。
关系表达式对两个值进行比较,常被用作循环测试条件。关系表达 式是通过使用6种关系运算符之一构成的:<
、<=
、==
、>=
、>
或 !=
。 关系表达式的结果为bool类型,值为true或false。
第六章 分支语句和逻辑运算符#
本章的内容包括:
if
语句;if else
语句;- 逻辑运算符:
&&
、||
和!
; cctype
字符函数库;- 条件运算符:
?:
; switch
语句;continue
和break
语句;- 读取数字的循环;
- 基本文件输入/输出。
6.1 if
语句#
如果测试条件为 true
, 则 if
语句将引导程序执行语句或语句块;如果条件是false
,程序将跳过 这条语句或语句块。因此,if
语句让程序能够决定是否应执行特定的语句。
6.1.1 if else
语句#
if
语句让程序决定是否执行特定的语句或语句块,而if else
语句则让程序决定执行两条语句或语句块中的哪一条,这种语句对于选择其中一 种操作很有用。
if else
语句的通用格式如下:
6.1.2 格式化 if else
语句#
if else
中的两种操作都必须是一条语句。如果需要多条语句,需要 用大括号将它们括起来,组成一个块语句。
6.1.3 if
- else if
- else
结构#
计算机程序也可能提供两个以上的 选择。可以将C++
的 if else
语句进行扩展来满足这种需求。正如读者知 道的,else
之后应是一条语句,也可以是语句块。由于 if else
语句本身是 一条语句,所以可以放在 else
的后面。
6.2 逻辑表达式#
C++ 提供了3种逻辑运算符,来组合或修改已有的表达式。这些运算符分别是逻 辑 OR (||)
、逻辑 AND (&&)
和逻辑 NOT (!)
。
6.2.1 逻辑OR运算符:||
#
C++可以采用逻辑OR运算 符 ||
,将两个表达式组合在一起。如果原来表达式中的任何一个或 全部都为 true
(或非零),则得到的表达式的值为 true
;否则,表达式的值为 false
。
C++ 规定 ||
运算符是个顺序点(sequence point)。也是说,先修改左侧的值,再对右侧的值进行判定(C++11的说法是,运算符左边的子表达式先于右边的子表达式)。
6.2.2 逻辑AND运算符:&&
#
逻辑AND运算符 &&
,也是将两个表达式组合成一个表达式。 仅当原来的两个表达式都为 true
时,得到的表达式的值才为 true
。
&&
运算符也是顺序点,因此将首先判定左侧, 并且在右侧被判定之前产生所有的副作用。如果左侧为 false
,则整个逻 辑表达式必定为 false
,在这种情况下,C++ 将不会再对右侧进行判定。
6.2.3 用 &&
来设置取值范围#
&&
运算符还允许建立一系列if
else if
else
语句,其中每种选择都对 应于一个特定的取值范围。
6.2.4 逻辑NOT运算符:!
#
!
运算符将它后面的表达式的真值取反。也是说,如果 expression
为 true
,则 !expression
是 false
;如果 expression
为 false
,则 !expression
是 true
。更准确地说,如果 expression
为 true
或非零,则 !expression
为 false
。
然而,!
运算符对于返回 true-false
值或可以被解释为 true-false
值的函数来说很有用。例如,如果C-风格字符串 s1
和 s2
不同,则 strcmp(s1,s2)
将返回非零true
值,否则返回 0
。这意味着如果这两个字符串相同, 则 !strcmp(s1,s2)
为 true
。
6.2.5 逻辑运算符细节#
!
运算符的优先级高于所有的关系运算符和算术运算符。因此,要对表达式求反,必须用括号将其括起。
6.2.6 其他表示方式#
并不是所有的键盘都提供了用作逻辑运算符的符号,因此C++标准 提供了另一种表示方式,如表6.3所示。标识符and
、or
和not
都是C++保 留字,这意味着不能将它们用作变量名等。它们不是关键字,因为它们 都是已有语言特性的另一种表示方式。另外,它们并不是C语言中的保 留字,但C语言程序可以将它们用作运算符,只要在程序中包含了头文 件 iso646.h
。C++不要求使用头文件。
6.3 字符函数库 cctype
#
C++ 从 C 语言继承了一个与字符相关的、非常方便的函数软件包, 它可以简化诸如确定字符是否为大写字母、数字、标点符号等工作,这 些函数的原型是在头文件cctype
(老式的风格中为 ctype.h
)中定义的。 例如,如果 ch
是一个字母,则 isalpha(ch)
函数返回一个非零值,否则返回 0
。同样,如果 ch
是标点符号(如逗号或句号),函数 ispunct(ch)
将返回 true
。(注意这些函数的返回类型为 int
,而不是 bool
, 但通常 bool
转换能够将它们视为bool
类型)
isalpha()
不仅更容易使用,而且更通用。例子中用AND/OR格式假设A-Z的字符编码是连续的,其他字符的编码不在这个范围内。这种假设对于ASCII码来说是成立的,但也并非总是如此。
具体地说:
isalpha()
用来检查字符是否为字母字符;isdigit()
用来测试字符是否为数字字符(0~9),如 3;isspace()
用来测试字符是否为空白,包括换行符、空格和制表符;ispunct()
用来测试字符是否为标点符号。
总结一下这个函数包:
6.4 ?:
运算符#
C++有一个常被用来代替 if else
语句的运算符,这个运算符被称为条件运算符 ?:
,它是C++中唯一一个需要3个操作数的运算符,也叫三目运算符。
|
|
如果 expression1
为 true
,则整个条件表达式的值为 expression2
的值; 否则,整个表达式的值为 expression3
的值。
6.5 switch
语句#
switch
语句与 Pascal 等语言中类似的语句之间存在重大的差别。 C++中的 case
标签只是行标签,而不是选项之间的界线。也是说,程序跳到 switch
中特定代码行后,将依次执行之后的所有语句,除非有明确的其他指示。程序不会在执行到下一个 case
处自动停止,要让程序执行 完一组特定语句后停止,必须使用break
语句。这将导致程序跳到 switch
后面的语句处执行。
6.5.1 将枚举量用作标签#
通常,cin
无法识别枚举类型(它不知道程序员是如 何定义它们的),因此该程序要求用户选择选项时输入一个整数。当 switch
语句将 int
值和枚举量标签进行比较时,将枚举量提升为 int
。另 外,在 while
循环测试条件中,也会将枚举量提升为 int
类型。
6.5.2 switch
和 if else
#
switch
语句和if else
语句都允许程序从选项中进行选择。相比之下, if else
更通用,例如,它可以处理取值范围。
然而,switch
并不是为处理取值范围而设计的。switch
语句中的每 一个case
标签都必须是一个单独的值。另外,这个值必须是整数(包括 char
),因此 switch
无法处理浮点测试。另外 case
标签值还必须是常量。 如果选项涉及取值范围、浮点测试或两个变量的比较,则应使用 if else
语句。
6.6 break
和 continue
语句#
break
和 continue
语句都使程序能够跳过部分代码。可以在 switch
语句或任何循环中使用 break
语句,使程序跳到 switch
或循环后面的语句处执行。continue
语句用于循环中,让程序跳过循环体中余下的代码,并开始新一轮循环。
6.7 读取数字的循环#
假设要编写一个将一系列数字读入到数组中的程序,并允许用户在 数组填满之前结束输入,一种方法是利用cin。看下面的代码:
|
|
如果用户输入一个单词,而不是一个数字,发生这种类型不匹配的情况时,将发生4种情况:
n
的值保持不变;- 不匹配的输入将被留在输入队列中;
cin
对象中的一个错误标记被设置;- 对
cin
方法的调用将返回false
(如果被转换为bool
类型)。
方法返回 false
意味着可以用非数字输入来结束读取数字的循环。当用户输入的不是数字时,该程序将不再读取输入。非数字输入设置错误标记意味着必须重置该标记,程序才能继续读取输 入。clear()
方法重置错误输入标记,同时也重置文件尾(EOF
条件,参见第5章)。输入错误和 EOF
都将导致cin
返回 false
。
如果用户输入非数字输入,程序将拒绝,并要求用户继续输入数字。可以看到,可以使用 cin
输入表达式的值来检测输入是不是数字。 程序发现用户输入了错误内容时,应采取3个步骤:
- 重置
cin
以接受新的输入; - 删除错误输入;
- 提示用户再输入。
请注意,程序必须先重置cin,然后才能删除错误输入。如下程序清单 6.14演示了如何完成这些工作。
|
|
如果用户输入 88,则
cin
表达式将为true
,因此将一个值放到数组中;而表达式!(cin >> golf [i])
为false
,因此结束内部循环。然而,如果用户输入must i?
,则cin
表达式将为false
,因此不会将任何值放到数组 中;而表达式!(cin >> golf [i])
将为true
,因此进入内部的while
循环。该循环的第一条语句使用clear()
方法重置输入,如果省略这条语句,程序将拒绝继续读取输入。接下来,程序在while
循环中使用cin.get()
来读取 行尾之前的所有输入,从而删除这一行中的错误输入。另一种方法是读取到下一个空白字符,这样将每次删除一个单词,而不是一次删除整 行。最后,程序告诉用户,应输入一个数字。
6.8 简单文件输入/输出#
6.8.1 文本I/O和文本文件#
6.8.2 写入到文本文件中#
文件输出:
- 必须包含头文件
fstream
; - 头文件
fstream
定义了一个用于处理输出的ofstream
类; - 需要声明一个或多个
ofstream
变量(对象),并以自己喜欢的方式 对其进行命名,条件是遵守常用的命名规则; - 必须指明名称空间
std
。例如,为引用元素ofstream
,必须使用编译指令using
或前缀std::
; - 需要将
ofstream
对象与文件关联起来。为此,方法之一是使用open()
方法; - 使用完文件后,应使用方法
close()
将其关闭; - 可结合使用
ofstream
对象和运算符<<
来输出各种类型的数据。
虽然头文件 iostream
提供了一个预先定义好的名为 cout
的 ostream
对象,但您必须声明自己的 ofstream
对象,为其命名,并将其同文件关联起来。
注意,方法open()
只接受C-风格字符串作为参数,这可以是一个 字面字符串,也可以是存储在数组中的字符串。
重要的是,声明一个ofstream
对象并将其同文件关联起来后,便可以像使用cout那样使用它。所有可用于cout的操作和方法(如«、endl 和setf( ))都可用于ofstream对象(如前述示例中的outFile和fout)。
总之,使用文件输出的主要步骤如下:
- 包含头文件
fstream
; - 创建一个
ofstream
对象; - 将该
ofstream
对象同一个文件关联起来; - 就像使用
cout
那样使用该ofstream
对象; - 调用
close()
成员函数,关闭文件。
默认情况下,
open()
的将首先截断该文件,即将其长度截短到零——丢弃原有的内容,然后将新的输出加入到该文件中。
6.8.3 读取文本文件#
接下来介绍文本文件输入,它是基于控制台输入的。控制台输入涉 及多个方面,下面首先总结这些方面:
- 必须包含头文件
fstream
; - 头文件
fstream
定义了一个用于处理输入的ifstream
类; - 需要声明一个或多个
ifstream
变量(对象),并以自己喜欢的方式对其进行命名,条件是遵守常用的命名规则; - 必须指明名称空间
std
;例如,为引用元素ifstream
,必须使用编译指令using
或前缀std::
; - 需要将
ifstream
对象与文件关联起来。为此,方法之一是使用open()
方法; - 使用完文件后,应使用
close()
方法将其关闭; - 可结合使用
ifstream
对象和运算符>>
来读取各种类型的数据; - 可以使用
ifstream
对象和get()
方法来读取一个字符,使用ifstream
对象和getline()
来读取一行字符; - 可以结合使用
ifstream
和eof()
、fail()
等方法来判断输入是否成功; ifstream
对象本身被用作测试条件时,如果最后一个读取操作成 功,它将被转换为布尔值true
,否则被转换为false
。
如果试图打开一个不存在的文件用于输入,情况将如何呢?这种错误将导致后面使用ifstream
对象进行输入时失败。检查文件是否被成功打开的首选方法是使用is_open()
,为此,可以使用类似于下面的代码:
|
|
如果文件被成功地打开,方法 is_open()
将返回 true
;因此如果文件没有被打开,表达式 !inFile.is_open()
将为 true
。函数 exit()
的原型是在头文件 cstdlib
中定义的,在该头文件中,还定义了一个用于同操作系统通信的参数值 EXIT_FAILURE
。函数 exit()
终止程序。
方法
is_open()
是C++中相对较新的内容。如果读者的编译器不支持它,可使用较老的方法good()
来代替。
程序例子:
假设该文件名为scores.txt,包含的内容如下:
|
|
检查文件是否被成功打开至关重要。
读取文件时,有 几点需要检查。首先,程序读取文件时不应超过 EOF。如果最后一次读取数据时遇到 EOF
,方法 eof()
将返回 true
。其次,程序可能遇到类型不 匹配的情况。例如,程序清单6.16期望文件中只包含数字。如果最后一次读取操作中发生了类型不匹配的情况,方法 fail()
将返回 true
(如果遇到了 EOF
,该方法也将返回 true
)。最后,可能出现意外的问题,如文 件受损或硬件故障。如果最后一次读取文件时发生了这样的问题,方法 bad()
将返回 true
。不要分别检查这些情况,一种更简单的方法是使用 good()
方法,该方法在没有发生任何错误时返回 true
:
|
|
方法 good()
指出最后一次读取输入的操作是否成功,这一点至关重要。这意味着应该在执行读取输入的操作后,立刻应用这种测试。为此,一种标准方法是,在循环之前(首次执行循环测试前)放置一条输 入语句,并在循环的末尾(下次执行循环测试之前)放置另一条输入语句:
|
|
鉴于以下事实,可以对上述代码进行精简:表达式 inFile >> value
的结果为inFile
,而在需要一个 bool
值的情况下,inFile
的结果为 inFile.good()
,即 true
或 false
。
因此,可以将两条输入语句用一条用作循环测试的输入语句代替。 也就是说,可以将上述循环结构替换为如下循环结构:
|
|
这些代码紧跟在循环的后面,用于判断循环为何终止。由于 eof()
只能判断是否到达 EOF
,而 fail()
可用于检查 EOF
和类型不匹配,因此上述 代码首先判断是否到达 EOF
。这样,如果执行到了 else if
测试,便可排除 EOF
,因此,如果 fail()
返回 true
,便可断定导致循环终止的原因是类型不匹配。
这种设计仍然遵循了在测试之前进行读取的规则,因为要计算表达式 inFile >> value
的值,程序必须首先试图将一个数字读取到 value
中。
以上仅是对文件 I/O 的初步介绍。
6.9 总结#
C++提供了 if
语句、if-else
语句和 switch
语句来管理选项。
C++还提供了帮助决策的运算符。通过使用逻辑运算符(&&
、||
和 !
),可以组合或修改关系表达式,创建更细致的测试。条件运算符 (?:)
提供了一种选择两个值之一 的简洁方式。
cctype
字符函数库提供了一组方便的、功能强大的工具,可用于分析字符输入。
文件 I/O 与控制台 I/O 极其相似。声明 ifstream 和 ofstream 对象,并将它们同文件关联起来后,便可以像使用 cin 和 cout 那样使用这些对象。
第七章 函数——C++的编程模块#
本周内容包括:
- 函数基本知识;
- 函数原型;
- 按值传递函数参数;
- 设计处理数据的函数;
- 使用
const
指针参数; - 设计处理文本字符串的函数;
- 设计处理结构的函数;
- 设计处理
string
对象的函数; - 调用自身的函数(递归);
- 指向函数的指数。
7.1 函数基本知识#
自定义函数三要素:
- 函数定义;
- 函数原型;
- 调用函数。
7.1.1 定义函数#
函数有两类:没有返回值的函数和有返回值的函数。
函数定义通用格式:
|
|
C++ 对于返回值的类型有一定的限制:不能是数组,但可以是其他任何类型——整数、浮点数、指针,甚至可以是结构体和对象。
C++ 函数返回值的原理是什么? 首先,函数将返回值复制到指定的CPU寄存器或内存单元中将值返回;随后,调用程序将查看该内存单元。返回函数和调用函数必须就该内存单元中存储的数据的类型达成一致。函数原型将返回值类型告知调用程序,而函数定义命令被调用函数应返回什么类型的数据。
7.1.2 函数原型和函数调用#
- 为什么需要函数原型
原型描述了函数到编译器的接口,也就是说,它将函数返回值的类型(如果有的话)以及参数的类型和数量告诉编译器。举个例子:
|
|
首先,原型告诉编译器,cube()
有一个 double 参数。如果程序没有 提供这样的参数,原型将让编译器能够捕获这种错误。其次,cube()
函数完成计算后,将把返回值放置在指定的位置——可能是 CPU 寄存器, 也可能是内存中。然后调用函数(这里为 main()
)将从这个位置取得返回值。由于原型指出了 cube()
的类型为 double,因此编译器知道应检索多少个字节以及如何解释它们。如果没有这些信息,编译器将只能进行猜测,而编译器是不会这样做的。
为何编译器需要原型,它就不能在文件中进一步查找,以了解函数是如何定义的吗?这种方法的一个问题是效率不高。编译器在搜索文件的剩余部分时必须停止对 main()
的编译。一个更严重的问题是,函数甚至可能并不在文件中。C++ 允许将一个程序放 在多个文件中,单独编译这些文件,然后再将它们组合起来。在这种情况下,编译器在编译 main()
时,可能无权访问函数代码。如果函数位于库中,情况也将如此。避免使用函数原型的唯一方法是,在首次使用函数之前定义它,但这并不总是可行的。
- 原型的语法
函数原型是一条语句,因此必须以分号结束。获得原型最简单的方 法是,复制函数定义中的函数头,并添加分号。 函数原型不要求提供变量名,有类型列表就足够了。通常,在原型的参数列表中,可以包括变量名,也可以不包括。原 型中的变量名相当于占位符,因此不必与函数定义中的变量名相同。
- 原型的功能
它们可以极大地降低程序出错的几率。具体来说,原型 确保以下几点:
- 编译器正确处理函数返回值;
- 编译器检查使用的参数数目是否正确;
- 编译器检查使用的参数类型是否正确,如果不正确,则转换为正确的类型。
仅当有意义时,原型化才会导致类型转换。例如,原型不会将整数转换为结构或指针。
在编译阶段进行的原型化被称为静态类型检查(static type checking)。
7.2 函数参数和按值传递#
C++通常按值传递参数,这意味着将 数值参数传递给函数,而后者将其赋给一个新的变量。
|
|
side 是一个变量,被调用时,该函数将创建一个新的名为x的double变量,并将其初 始化为5。这样,cube( )执行的操作将不会影响main( )中的数据,因为 cube( )使用的是side的副本,而不是原来的数据。
在函数中声明的变量(包括参数)是该函数私有的。在函数被调用 时,计算机将为这些变量分配内存;在函数结束时,计算机将释放这些 变量使用的内存。这样的变量被称为局部变量,因为它们被限制在函数中。前面提到过,这样做有助于确保数据的完整性。这还意 味着,如果在main( )中声明了一个名为x的变量,同时在另一个函数中 也声明了一个名为x的变量,则它们将是两个完全不同的、毫无关系的变量。
7.2.1 多个参数#
函数可以有多个参数。在调用函数时,只需使用逗号将这些参数分开即可。
它使用cin>>ch
,而不是cin.get(ch)
或ch = cin.get()
来读取一个字符。这样做是有原因的。前面讲过,这两个cin.get()
函数读取所有的输入字符,包括空格和换行符,而 cin>>
跳过空格和换行符。当用户对程序提示作出响应时,必须在每行的最后按 Enter
键,以生成换行符。cin>>ch
方法可以轻松地跳过这些换行符,但当输入的下一个字符为数字时,cin.get()
将读取后面的换行符,虽然可以通过编程来避开这种麻烦,但比较简便的方法是像该程序那样使用 cin
。
7.3 函数和数#
需要将数组名作为参数传递给它,为使函数通用,而不限于特定长度的数组,还需要传递数组长度。
|
|
方括号指出 arr
是一个数组,而方括号为空则表明,可以将任何长度的数组传递给该函数。但实际情况并非如此:arr
实际上并不是数组,而是一个指针,好消息是,在编写函数的其余部分时,可以将 arr
看作是数组。
7.3.1 函数如何使用指针来处理数组#
在大多数情况下,C++和C语言一样,也将数组名视为指针。C++将数组名解释为其第一个元素的地址:
|
|
首先,数组声明使用数组名来标记存储位置; 其次,对数组名使用sizeof将得到整个数组的长度(以字节为单位); 第三,正如第4章指出的,将地址运算符&用于数组名时,将返回整个数组的地址。
当且仅当在函数头中或者函数原型中,
int *arr
和int arr[]
的含义是相同的。它们都指 arr 是一个 int 指针。
对于数组,以下两个语句是恒等的:
|
|
7.3.2 将数组作为参数意味着什么#
传递常规变量时,函数将使用该变量的拷贝;但传递数组时,函数将使用原来的数组(传地址)。实际上,这种区别并不违反C++按值传递的方法,sum_arr()
函数仍传递了一个值,这个值被赋给 一个新变量,但这个值是一个地址,而不是数组的内容。
将数组地址作为参数可以节省复制整个数组所需的时间和内存。如果数组很大,则使用拷贝的系统开销将非常大;程序不仅需要更多的计算机内存,还需要花费时间来复制大块的数据。但另一方面,使用原始数据增加了破坏数据的风险。
sum_arr(cookies+4, 4)
和 sum_arr(&cookies[4], 4)
是等效的。
7.3.3 更多数组函数示#
1.填充数组
2.显示数组及用const保护数组
创建显示数组内容的函数很简单。只需将数组名和填充的元素数目传递给函数,然后该函数使用循环来显示每个元素。然而,还有另一个问题——确保显示函数不修改原始数组。除非函数的目的就是修改传递给它的数据,否则应避免发生这种情况。使用普通参数时,这种保护将自动实现,这是由于C++按值传递数据,函数使用数据的副本。然而,接受数组名的函数将使用原始数据。为防止函数无意中修改数组的内容,可在声明形参时 使用关键字const
:
|
|
show_array()
将数组视为只读数据。
3.修改数组
在这个例子中,对数组进行的第三项操作是将每个元素与同一个重新评估因子相乘。需要给函数传递3个参数:因子、数组和元素数目。 该函数不需要返回值,因此其代码如下:
|
|
由于这个函数将修改数组的值,因此在声明 arr
时,不能使用 const
。
4.将上述代码组合起来
前面已经讨论了与该示例相关的重要编程细节,因此这里回顾一下 整个过程。我们首先考虑的是通过数据类型和设计适当的函数来处理数据,然后将这些函数组合成一个程序。有时也称为自下而上的程序设计 (bottom-up programming),因为设计过程从组件到整体进行。这种方 法非常适合于OOP——它首先强调的是数据表示和操纵。而传统的过程性编程倾向于从上而下的程序设计(top-down programming),首先指定模块化设计方案,然后再研究细节。这两种方法都很有用,最终的产品都是模块化程序。
6.数组处理函数的常用编写方式
假设要编写一个处理double数组的函数。如果该函数要修改数组, 其原型可能类似于下面这样:
|
|
如果函数不修改数组,其原型可能类似于下面这样:
|
|
7.3.4 使用数组区间的函数#
对于处理数组的C++函数,必须将数组中的数据种类、数组的起始位置和数组中元素数量提交给它;传统的C/C++方法是,将指向数组起始处的指针作为一个参数,将数组长度作为第二个参数(指针指出数组的位置和数据类型),这样便给函数提供找到所有数据所需的信息。
还有另一种给函数提供所需信息的方法,即指定元素区间 (range),这可以通过传递两个指针来完成:一个指针标识数组的开头,另一个指针标识数组的尾部。
它将 pt
设置为指向要处理的第一个元素(begin
指向的元素)的指针,并将*pt
(元素的值)加入到 total
中。然后,循环通过递增操作来更新 pt
,使之指向下一个元素。只要 pt
不等于 end
,这一过程就将继续下去。当 pt
等于 end
时,它将指向区间中最后一个元素后面的一个位置,此时循环将结束。
7.3.5 指针和 const
#
将 const
用于指针有一些很微妙的地方,可以用两种不同的方式将 const
关键字用于指针。第一种方法是让指针指向一个常量对象,防止使用该指针来修改所指向的值,第二种方法是将指针本身声明为常量,这样可以防止改变指针指向的位置。
首先看一个指向常量的指针:
这里要求 pt
指向的是一个 const int
,所以,赋值之后我们是不能用 *pt
来修改 age 的值的。
现在来看一个微妙的问题。pt的声明并不限定着它指向的值就必须得是一个常量,只是对pt来说,这个值是常量。例如,pt指向age,但age不是const。我们是可以直接通过 age 变量来修改 age 的值的,但不能使用 pt 指针来修改它。
我觉得这类指针可以称为:只读指针。这个指针可以移动,但是它只能读出它所指向地址的内容,但是无法通过这个指针修改其中的数据。
C++禁止第二种情况的原因很简单——如果将 g_moon
的地 址赋给 pm
,那么就可以用 pm
来修改 g_moon
的值,这使得 g_moon
的 const
状态很荒谬,因此, C++禁止将 const 的地址赋给非 const 指针。
假设有一个由 const
数据组成的数组则禁止将常量数组的地址赋给非常量指针将意味着不能将数组名作为参数传递给使用非常量形参的函数。
只要条件允许,则应将指针形参声明为指向 const
的指针。
尽可能使用 const: 将指针参数声明为指向常量数据的指针有两个优点:
- 避免由于无意间修改数据而导致的编程错误;
- 使用
const
使得函数能够处理const
和非const
实参,否则将只能接受非const
数据。
const
只能防止修改 pt
指向的值(这里为39),而不能防止修改 pt
的值。也就是说,可以将一个新地址赋给 pt
。
第二种使用 const
的方式使得无法修改指针的值:
这样的指针,我想将其称为:静止指针。指针本身定死在一个地址上了,但是地址里的内容是可以通过这个指针随便修改的。
通常,将指针作为函数参数来传递时,可以使用指向const
的指针来保护数据。
在该声明中使用 const
意味着 show_array()
不能修改传递给它的数组中的值。只要只有一层间接关系,就可以使用这种技术。例如,这里的数组元素是基本类型,但如果它们是指针或指向指针的指针,则不能使用 const
。
7.4 函数和二维数组#
数组名被视为其地 址,因此,相应的形参是一个指针,就像一维数组一样。
Data
是一个数组名,该数组有3个元素。第一个元素本身是一个数组,由4个 int
值组成。因此 data
的类型是指向由4个 int
组成的数组的指针,因此正确的原型如下:
|
|
还有另外一种格式,这种格式与上述原型的含义完全相同,但可读性更强:
|
|
对于二维数据,必须对指针 ar2
执行两次解除引用,才能得到数据。最简单的方法是使用方括号两次:ar2[r][c]
。然而,如果不考虑难看的话,也可以使用运算符*
两次:
|
|
7.5 函数和C-风格字符串#
C-风格字符串由一系列字符组成,以空值字符(\0
)结尾。
7.5.1 将C-风格字符串作为参数的函数#
假设要将字符串作为参数传递给函数,则表示字符串的方式有三 种:
char
数组;- 用引号括起的字符串常量(也称字符串字面值);
- 被设置为字符串的地址的
char
指针。
但上述3种选择的类型都是char
指针(准确地说是char*
),因此可 以将其作为字符串处理函数的参数。
可以说是将字符串作为参数来传递,但实际传递的是字符串第一个字符的地址。这意味着字符串函数原型应将其表示字符串的形参声明为 char *
类型。
C-风格字符串与常规 char
数组之间的一个重要区别是,字符串有内置的结束字符。这意味着不必将字符串长度作为参数传递给函数,而函数可以使用循环依次检查字符串中的每个字符,直到遇到结尾的空值字符为止。
不以空值字符结尾的char数组只是数组,而不是字符串。
7.5.2 返回C-风格字符串的函数#
函数无法返回一 个字符串,但可以返回字符串的地址,这样做的效率更高。这种设计(让函数返回一个指针,该指针指向 new
分配的内存)的缺点是,程序员必须记住使用 delete
。
7.6 函数和结构体#
结构体变量的行为更接近于基本的单值变量。也就是说,与数组不同,结构体将其数据组合成单个实体或数据对象,该实体被视为一个整体。前面讲过,可以将一个结构体赋给另外一个结构体。同样,也可以按值传递结构体,就像普通变量那样。在这种情况下,函数将使用原始结构体的副本。另外,函数也可以返回结构体。与数组名就是数组第一个元素的地址不同的是,结构体名只是结构体的名称,要获得结构的地址,必须使用地址运算符 &
。
使用结构编程时,最直接的方式是像处理基本类型那样来处理结构体;也就是说,将结构体作为参数传递,并在需要时将结构体用作返回值使用。
7.6.1 传递和返回结构体#
当结构体比较小时,按值传递结构体最合理。
7.6.2 另一个处理结构的函数示例#
7.6.3 传递结构体的地址#
假设要传递结构的地址而不是整个结构以节省时间和空间,则需要 重新编写前面的函数,使用指向结构的指针。首先来看一看如何重新编写 show_polar()
函数。需要修改三个地方:
- 调用函数时,将结构的地址(
&pplace
)而不是结构本身(pplace
) 传递给它; - 将形参声明为指向
polar
的指针,即polar *
类型。由于函数不应该修改结构,因此使用了const
修饰符; - 由于形参是指针而不是结构,因此应间接成员运算符(
->
),而不是成员运算符(句点)。
7.7 函数和 string
对象#
可以将对象作为完整的实体进行传递,如果需要多个字符串,可以声明一个string
对象数组,而不是二维 char
数组。
7.8 函数与 array
对象#
7.9 递归#
C++函数有一种有趣的特点——可 以调用自己,但不允许main()调用自己,这种功能称为递归。
我不喜欢递归,太耗资源,可读性还差。
7.9.1 包含一个递归调用的递归#
7.9.2 包含多个递归调用的递归#
7.10 函数指针#
与数据项相似,函数也有地址。函数的地址是存储其机器语言代码 的内存的开始地址。对程序而言很有用,可以编写以另一个函数的地址作为参数的函数。
这样第一个函数就可以找到第二个函数,并运行它。与直接调用另一个函数相比,这种方法很笨拙,但它允许在不同的情况下传递不同函数的地址,这意味着可以在不同的时间使用不同的函数。
7.10.1 函数指针的基础知识#
1.获取函数的地址 获取函数的地址很简单:只要使用函数名(后面不跟参数)即可。要将函数作为参数进行传递,必须传递函数名,要区分传递的是函数的地址还是函数的返回值。
2.声明函数指针
声明指向函数的指针时,必须指定指针指向的函数类型。意味着声明应指定函数的返回类型以及函数的特征标(参数列表)。也就是说,声明应像函数原型那样指出有关函数的信息:
3.使用指针来调用函数
为何pf和
(*pf
等价呢?一种学派认为,由于pf是函数指针,而*pf
是函数,因此应将(*pf)()
用作函数调用。另一种学派认为,由于函数名是指向该函数的指 针,指向函数的指针的行为应与函数名相似,因此应将pf( )用作函数调用使用。C++进行了折 衷——这2种方式都是正确的,或者至少是允许的,虽然它们在逻辑上是互相冲突的。在认为 这种折衷粗糙之前,应该想到,容忍逻辑上无法自圆其说的观点正是人类思维活动的特点。
可能看起来比较深奥,但指向函数指针数组的指针并不少见。实际上,类的虚方法实现通常都采用了这种技术(参见第13章)。
7.10.4 使用typedef进行简化#
这里采用的方法是,将别名当做标识符进行声明,并在开头使用关键字 typedef
。
使用typedef可减少输入量,让您编写代码时不容易犯错,并让程序 更容易理解。
7.11 总结#
函数是C++的编程模块。要使用函数,必须提供定义和原型,并调 用该函数。函数定义是实现函数功能的代码;函数原型描述了函数的接 口:传递给函数的值的数目和种类以及函数的返回类型。函数调用使得 程序将参数传递给函数,并执行函数的代码。
C++提供了3种表示C-风格字符串的方法:字符数组、字符串常量 和字符串指针。它们的类型都是char*
(char
指针),因此被作为char*
类型参数传递给函数。C++使用空值字符 \0
来结束字符串,因此字符 串函数检测空值字符来确定字符串的结尾。
C++处理结构体的方式与基本类型完全相同,这意味着可以按值传递结构体,并将其用作函数返回类型。然而,如果结构体非常大,则传递结构体指针的效率将更高,同时函数能够使用原始数据。
第八章 函数探幽#
本章内容:
- 内联函数;
- 引用变量;
- 如何按引用传递函数参数;
- 默认参数;
- 函数重载;
- 函数模板;
- 函数模板具体化。
8.1 C++ 内联函数#
内联函数是C++为提高程序运行速度所做的一项改进。常规函数和内联函数之间的主要区别不在于编写方式,而在于C++编译器如何将它们组合到程序中。 常规函数调用也使程序跳到另一个地址(函数的地址),并在函数结束时返回。
C++内联函数提供了另一种选择。内联函数的编译代码与其他程序代码“内联”起来了。也就是说,编译器将使用相应的函数代码替换函数调用。对于内联代码,程序无需跳到另一个位置处执行代码,再跳回来。因此,内联函数的运行速度比常规函数稍快,但代价是需要占用更多内存。
要使用这项特性,必须采取下述措施之一:
- 在函数声明前加上关键字 inline;
- 在函数定义前加上关键字 inline;
内联函数最好都是一些很简单、行数少的函数。
8.2 引用变量#
C++新增了一种复合类型——引用变量。引用是已定义的变量的别名(另一个名称)。
8.2.1 创建引用变量#
C++给&
符号赋予另一个含义,将其用来声明引用。例如,要将 rodents
作为rats
变量的别名,可以这样做:
|
|
其中,&
不是地址运算符,而是类型标识符的一部分。就像声明中的 char*
指的是指向 char
的指针一样,int &
指的是指向 int
的引用。
将 rodents
加1将影响这两个变量。更准确地说, rodents++
操作将一共有两个名称的变量加1。
引用看上去很像伪装表示的指针(其中,*
解除引用运算符被隐式理解)。实际上,引用还是不同于指针的。除了表示法不同外,还有其他的差别。例如,差别之一是, 必须在声明引用时将其初始化,而不能像指针那样,先声明,再赋值。
引用更接近const指针,必须在创建时进行初始化,一旦与某个变量关联起来,就将一直效忠于它。也就是说:某个变量的引用是不可更改的。
引用是别名。
8.2.2 将引用用作函数参数#
引用经常被用作函数参数,使得函数中的变量名成为调用程序中的变量的别名。这种传递参数的方法称为按引用传递。按引用传递允许被调用的函数能够访问调用函数中的变量。C++新增的这项特性是对C语言的超越,C语言只能按值传递。按值传递导致被调用函数使用调用程序的值的拷贝(参见图8.2)。
交换函数必须能够修改调用程序中的变量的值。这意味着按值传递变量将不管用,因为函数将交换原始变量副本的内容,而不是变量本身的内容。但传递引用时,函数将可以使用原始数据。另一种方法是,传递指针来访问原始数据。
8.2.3 引用的属性和特别之处#
refcube()
函数修改了 main()
中的 x
值,而 cube()
没有,这提醒我们为何通常按值传递。变量 a
位于 cube()
中,它被初始化为 x
的值,但修改 a
并不会影响 x
。但由于 refcube()
使用了引用参数,因此修改 ra
实际上就是修改 x
。如果只是让函数使用传递给它的信息,而不对这些信息进行修改,同时又想使用引用,则应使用常量引用。
例如,在这个例子中,应在函数原型和函数头中使用const
:
|
|
如果要编写类似于上述示例的函数(即使用基本数值 类型),应采用按值传递的方式,而不要采用按引用传递的方式。当数据比较大(如结构和类)时,引用参数将很有用。
函数中应尽可能将引用形参声明为 const,这样好处有三个:
- const 可以避免无意中修改数据,从而导致编程错误;
- const 使函数能够处理 const 和非 const 实参,否则只能接受非 const 数据;
- const 引用使函数能够正确生成并使用临时变量。
8.2.4 将引用用于结构体#
2.为何要返回引用
下面更深入地讨论返回引用与传统返回机制的不同之处。传统返回机制与按值传递函数参数类似:计算关键字return后面的表达式,并将结果返回给调用函数。从概念上说,这个值被复制到一个临时位置,而 调用程序将使用这个值。
返回引用的函数实际上是被引用的变量的别名。
3.返回引用时需要注意的问题
返回引用时最重要的一点是,应避免返回函数终止时不再存在的内存单元引用。应避免编写如下代码:
该函数返回一个指向临时变量(newguy)的引用,函数运行完毕后 它将不再存在。同样,也应避免返回 指向临时变量的指针。
为避免这种问题,最简单的方法是,返回一个作为参数传递给函数 的引用。作为参数的引用将指向调用函数使用的数据,因此返回的引用 也将指向这些数据。
4.为何将const用于引用返回类型
8.2.5 将引用用于类对象#
将类对象传递给函数时,C++通常的做法是使用引用。
8.2.6 对象、继承和引用#
8.2.7 何时使用引用参数#
使用引用参数的主要原因有两个:
- 能够修改调用函数中的数据对象;
- 通过传递引用而不是整个数据对象,可以提高程序的运行速度。
当数据对象较大时(如结构和类对象),第二个原因最重要。这些也是使用指针参数的原因。这是有道理的,因为引用参数实际上是基于指针的代码的另一个接口。
以下总结使用引用的原则:
- 如果数据对象很小。如内置数据类型或者小型数据结构,则按值传递;
- 如果数据对象是数组,则使用指针,因为这是唯一的选择,并将指针声明为指向 const 的指针;
- 如果数据对象是较大的结构体,则使用 const 指针或者 const 引用,以便提升程序的效率。这样可以节省复制结构体所需的时间和空间;
- 如果数据对象是类对象,则使用 const 引用。类设计的语义常常要求使用引用,这是C++新增这项特性的主要原因。因此,传递类对象参数的标准方式是按引用传递。
对于修改调用函数中数据的函数:
- 如果数据对象是内置数据类型,则使用指针(不使用引用)。看到诸如:
fixit(&x)
这样的代码(x是int),则很明显,该函数将要修改x; - 如果数据对象是数组,则只能使用指针;
- 如果数据对象是结构体,则可以使用引用或者指针;
- 如果数据对象是类对象,则(首选)使用引用。
8.3 默认参数#
默认参数指的是当函数调用中省略了实参时自动使用的一个值。例如:
|
|
对于带参数列表的函数,必须从右向左添加默认值。也就是说,要为某个参数设置默认值,则必须为它右边的所有参数提供默认值:
|
|
实参按从左到右的顺序依次被赋给相应的形参,而不能跳过任何参数。
默认参数只在声明函数的时候给出,定义函数时,则不需要给出。
8.4 函数重载#
函数多态是C++在C语言的基础上新增的功能。默认参数让我们能够使用不同数目的参数调用同一个函数,而函数多态(函数重载)让我们能够使用多个同名的函数,这称为函数重载,它们完成相同的工作,但使用不同的参数列表。
函数重载的关键是函数的参数列表——也称为函数特征标 (function signature)。
如果两个函数的参数数目和类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名是无关紧要的。 C++允许定义名称相同的函数,条件是它们的特征标不同。
一些看起来彼此不同的特征标是不能共存的。例如,请看下面的两 个原型:
|
|
可能认为可以在此处使用函数重载,因为它们的特征标看起来不同。然而,请从编译器的角度来考虑这个问题。假设有下面这样的代码:
|
|
参数 x
与 double x
原型和 double &x
原型都匹配,因此编译器无法确定究竟应使用哪个原型。为避免这种混乱,编译器在检查函数特征标时,将把类型引用和类型本身视为同一个特征标。
请记住,是特征标(即,函数特征列表),而不是函数返回类型使得可以对函数进行重载。 例 如,下面的两个声明是互斥的:
|
|
因此,C++不允许以这种方式重载gronk( )。返回类型可以不同,但特征标也必须不同。
匹配函数时,并不区分const
和非const
变量(这就要小心了)。看下面的原型:
|
|
8.4.1 函数重载示例#
8.4.2 何时使用函数重载#
虽然函数重载很吸引人,但也不要滥用。仅当函数基本上执行相同的任务,但使用不同形式的数据时,才应采用函数重载。
8.5 函数模板#
现在的C++编译器实现了C++的另一个新增特性——函数模板。函数模板是通用的函数描述,也就是说,它们使用泛型来定义函数,其中的泛型可用具体的类型(如int
或double
)替换。通过将类型作为参数传递给模板,可使编译器生成该类型的函数。由于模板允许以泛型(而不是具体类型)的方式编写程序,因此有时也被称为通用编程。由于类型是用参数表示的,因此模板特性有时也被称为参数化类型(parameterized types)。
第一行指出,要建立一个模板,并将类型命名为 AnyType
。关键字 template
和 typename
是必需的,除非可以使用关键字 class
代替 typename
。 另外,必须使用尖括号。类型名可以任意选择(这里为 AnyType
),只要遵守C++命名规则即可;许多程序员都使用简单的名称,如 T
。
模板并不创建任何函数,而只是告诉编译器如何定义函数。需要交换int
的函数时,编译器将按模板 模式创建这样的函数,并用int
代替AnyType
。同样,需要交换double
的函数时,编译器将按模板模式创建这样的函数,并用double
代替 AnyType
。
最终 的代码不包含任何模板,而只包含了为程序生成的实际函数。使用模板 的好处是,它使生成多个函数定义更简单、更可靠。
typename
关键字使得参数AnyType
表示类型这一点更为明显;然而,有大量代码库是使用关键字class开发的。在这种上下文中,这两个关键字是等价的。
需要多个对不同类型使用同一种算法的函数时,可使用模板。
8.5.2 模板的局限#
- 下面的代码假定定义了赋 值,但如果T为数组,这种假设将不成立;
- 下面的语句假设定义了
<
,但如果T为结构,该假设便不成立;另外,为数组名定义了运算符>
,但由于数组名为地址,因此它比较的是数组的地址,而这可能不是您希望的。
总之,编写的模板函数很可能无法处理某些类型。另一方面,有时 候通用化是有意义的,但C++语法不允许这样做。
8.5.3 显式具体化#
由于C++允许将一个结构赋给另一个结构,因此即使T是一个
job
结构,上述代码也适用。然而,假设只想交换salary
和floor
成员,而不交 换name
成员,则需要使用不同的代码,但Swap()
的参数将保持不变 (两个job
结构的引用),因此无法使用模板重载来提供其他的代码。
- 对于给定的函数名,可以有非模板函数、模板函数和显式具体化模 板函数以及它们的重载版本;
- 显式具体化的原型和定义应以
template<>
打头,并通过名称来指出 类型; - 具体化优先于常规模板,而非模板函数优先于具体化和常规模板。
如果有多个原型,则编译器在选择原型时,非模板版本优先于显式具体化和模板版本,而显式具体化优先于使用模板生成的版本。
8.5.4 实例化和具体化#
为进一步了解模板,必须理解术语实例化和具体化。记住,在代码中包含函数模板本身并不会生成函数定义,它只是一个用于生成函数定义的方案。编译器使用模板为特定类型生成函数定义时,得到的是模板实例(instantiation)。
函数调用Swap(i,j
)导致编译器生成Swap()
的一个实例,该实例使用int
类型。模板并非函数定 义,但使用int的模板实例是函数定义。这种实例化方式被称为隐式实例 化(implicit instantiation),因为编译器之所以知道需要进行定义,是 由于程序调用Swap( )函数时提供了int参数。
最初,编译器只能通过隐式实例化,来使用模板生成函数定义,但现在C++还允许显式实例化(explicit instantiation)。这意味着可以直接 命令编译器创建特定的实例,如Swap<int>()
。其语法是,声明所需的种类——用<>
符号指示类型,并在声明前加上关键字 template
:
|
|
实现了这种特性的编译器看到上述声明后,将使用Swap()
模板生成 一个使用int
类型的实例。也就是说,该声明的意思是“使用Swap()
模板生成int
类型的函数定义“。
与显式实例化不同的是,显式具体化使用下面两个等价的声明之 一:
|
|
区别在于,这些声明的意思是“不要使用 Swap()
模板来生成函数定 义,而应使用专门为 int
类型显式地定义的函数定义”。这些原型必须有自己的函数定义。显式具体化声明在关键字 template
后包含 <>
,而显式实例化没有。
隐式实例化、显式实例化和显式具体化统称为具体化 (specialization)。它们的相同之处在于,它们表示的都是使用具体类 型的函数定义,而不是通用描述。
引入显式实例化后,必须使用新的语法——在声明中使用前缀template
和template <>
,以区分显式实例化和显式具体化。通常,功能 越多,语法规则也越多。下面的代码片段总结了这些概念:
8.5.5 编译器选择使用哪个函数版本#
对于函数重载、函数模板和函数模板重载,C++需要(且有)一个 定义良好的策略,来决定为函数调用使用哪一个函数定义,尤其是有多 个参数时。这个过程称为重载解析(overloading resolution):
- 第1步:创建候选函数列表。其中包含与被调用函数的名称相同的函数和模板函数。
- 第2步:使用候选函数列表创建可行函数列表。这些都是参数数目 正确的函数,为此有一个隐式转换序列,其中包括实参类型与相应 的形参类型完全匹配的情况。例如,使用float参数的函数调用可以 将该参数转换为double,从而与double形参匹配,而模板可以为 float生成一个实例;
- 第3步:确定是否有最佳的可行函数。如果有,则使用它,否则该 函数调用出错。
接下来,编译器必须确定哪个可行函数是最佳的。它查看为使函数 调用参数与可行的候选函数的参数匹配所需要进行的转换。通常,从最 佳到最差的顺序如下所述:
- 完全匹配,但常规函数优先于模板;
- 提升转换(例如,char和shorts自动转换为int,float自动转换为 double);
- 标准转换(例如,int转换为char,long转换为double);
- 用户定义的转换,如类声明中定义的转换。
8.5.6 模板函数的发展#
8.6 总结#
C++扩展了C语言的函数功能。通过将 inline 关键字用于函数定义, 并在首次调用该函数前提供其函数定义,可以使得C++编译器将该函数视为内联函数。也就是说,编译器不是让程序跳到独立的代码段,以执行函数,而是用相应的代码替换函数调用(相当于复制进去)。只有在函数很短时才能采用内联方式。
引用变量是一种伪装指针,它允许为变量创建别名(另一个名称)。引用变量主要被用作处理结构和类对象的函数的参数。
C++原型让您能够定义参数的默认值。如果函数调用省略了相应的参数,则程序将使用默认值;如果函数调用提供了参数值,则程序将使用这个值(而不是默认值)。只能在参数列表中从右到左提供默认参数。
函数的特征标是其参数列表。程序员可以定义两个同名函数,只要其特征标不同。这被称为函数多态或函数重载。
第九章 内存模型和名称空间#
本章内容包括:
- 单独编译;
- 存储持续性、作用域和链接性;
- 定位(placement)new运算符;
- 名称空间。
9.1 单独编译#
第1章介绍过,可以单独编译文件,然后将它们链接成可执行的程序。通常,C++编译器既编译程序,也管理链接器。如果只修改了一个文件,则可以只重新编译该文件,然后将它与其他文件的编译版本链接,这使得大程序的管理更便捷。
例如,UNIX和Linux系统都具有make程 序,可以跟踪程序依赖的文件以及这些文件的最后修改时间。运行make 时,如果它检测到上次编译后修改了源文件,make将记住重新构建程序 所需的步骤。
例如但需要将一个结构体在多个文件中复用时,与其将结构体声明加入到每一个文件 中,不如将其放在头文件中,然后在每一个源代码文件中包含该头文件。这样,要修改结构体声明时,只需在头文件中做一次改动即可。另外,也可以将函数原型放在头文件中。因此,可以将原来的程序分成三 部分。
- 头文件:包含结构体声明和使用这些结构体的函数的声明;
- 源代码文件:包含与结构体有关的函数代码;
- 源代码文件:包含调用与结构体相关的函数代码;
这是一种非常有用的组织程序的策略。例如,如果编写另一个程序 时,也需要使用这些函数,则只需包含头文件,并将函数文件添加到项 目列表或make列表中即可。另外,这种组织方式也与OOP方法一致。一个文件(头文件)包含了用户定义类型的定义;另一个文件包含操纵用 户定义类型的函数的代码。这两个文件组成了一个软件包,可用于各种程序中。
不要将函数定义或变量声明放到头文件中。这样做通常会引来麻烦,因为,如果在头文件包含了一个函数的定义,然后在(同一个程序的)另外两个文件中分别包含了该头文件,那么同一个程序中将包含同一个函数的两个定义,除非函数是内联的,否则这将出错。
头文件常包含的内容:
- 函数原型(也叫函数声明);
- 使用
#define
或const
定义的符号常量; - 结构体声明;
- 类声明;
- 模板声明;
- 内联函数。
将结构体声明放在头文件中是可以的,因为它们不创建变量,而只是在源代码文件中声明结构体变量时,告诉编译器如何创建该结构体变量。同样,模板声明也不是将被编译的代码,它们指示编译器如何生成与源代码中函数调用相匹配的函数定义。被声明为const
的常量和内联函数有特殊的链接属性(稍后将介绍),因此可以将其放在头文件中,而不会引 起问题。
在包含头文件时,我们使 用"coordin.h"
,而不是 <coodin.h>
。如果文件名包含在尖括号中,则 C++编译器将在存储标准头文件的主机系统的文件系统中查找;但如果文件名包含在双引号中,则编译器将首先查找当前的工作目录或源代码目录(取决于编译器),如果没有找到, 则将在标准位置查找。因此在包含自己的头文件时,应使用引号而不是 尖括号。
在同一个文件中只能将同一个头文件包含一次。记住这个规则很容易,但很可能在不知 情的情况下将头文件包含多次。例如,可能使用包含了另外一个头文件的头文件。有一种标准的C/C++技术可以避免多次包含同一个头文件。它是基于预处理器编译指令#ifndef(即if not defined)的。
#ifndef-#define-#endif
语句的这种方法并不能防止编译器将文件包含两次,而只是让它忽略除第一次包含之外的所有内容。大多数标准C和C++头文件都使用这种防护 (guarding)方案。否则,可能在一个文件中定义同一个结构体、函数、类等两次,这将导致编译错误。
9.2 存储持续性、作用域和链接性#
存储类别如何影响信息在文件间的共享?
- 自动存储持续性:在函数定义中声明的变量(包括函数参数)的存 储持续性为自动的。在程序开始执行其所属的函数或代码块时 被创建,在执行完函数或代码块时,它们使用的内存被释放;
- 静态存储持续性:在函数定义外定义的变量和使用关键字
static
定义 的变量的存储持续性都为静态。它们在程序整个运行过程中都存在; - 线程存储持续性(C++11):如果变量是使用关键字
thread_local
声明的,则其生命 周期与所属的线程一样长; - 动态存储持续性:用
new
运算符分配的内存将一直存在,直到使用delete
运算符将其释放或程序结束为止。这种内存的存储持续性为动态,有时被称为自由存储(free store)或堆(heap)。
9.2.1 作用域和链接#
作用域(scope)描述了名称在文件(翻译单元)的多大范围内可见。链接性(linkage)描述了名称如何在不同单元间共享。链接性为外部的 名称可在文件间共享,链接性为内部的名称只能由一个文件中的函数共 享。自动变量的名称没有链接性,因为它们不能共享。
作用域为局部的变量只在定义它的代码 块中可用。代码块是由花括号括起的一系列语句。例如函数体就是代码块,但可以在函数体中嵌入其他代码块。作用域为全局(也叫文件作用域)的变量在定义位置到文件结尾之间都可用。自动变量的作用域为局部,静态变量的作用域是全局还是局部取决于它是如何被定义的。在函数原型作用域(function prototype scope)中使用的名称只在包含参数列表的括号内可用(这就是为什么这些名称是什么以及是否出现都不重要的原因)。在类中声明的成员的作用域为整个类。在名 称空间中声明的变量的作用域为整个名称空间
9.2.2 自动存储持续性#
在默认情况下,在函数中声明的函数参数和变量的存储持续性为自 动,作用域为局部,没有链接性。
如果在代码块中定义了变量,则该变量的存在时间和作用域将被限 制在该代码块内。例子:
1.自动变量的初始化 2.自动变量和栈
由于自动变量的数目随函数的开始和结束而增减,因此程序必须 在运行时对自动变量进行管理。常用的方法是留出一段内存,并将其视为栈,以管理变量的增减。之所以被称为栈,是由于新数据被象征性地放在原有数据的上面(也就是说,在相邻的内存单元中,而不是在同一 个内存单元中),当程序使用完后,将其从栈中删除。
3.寄存器变量 这旨在提高访问变量的速度。
9.2.3 静态持续变量#
要想创建链接性为外部的静态持续变量,必须在代码块的外面声明它;
要创建链接性为内部的静态持续变量,必须在代码块的外面声明它,并使用 static
限定符;
要创建没有链接性的静态持续变量,必须在代码块内声明它,并使用 static
限定符。
正如前面指出的,所有静态持续变量(上述示例中的global
、 one_file
和 count
)在整个程序执行期间都存在。在 funct1()
中声明的变量 count
的作用域为局部,没有链接性,这意味着只能在funct1()
函数中使用它,就像自动变量 llama
一样。然而,与llama
不同的是,即使在 funct1()
函数没有被执行时,count
也留在内存中。global
和 one_file
的作用域都为整个文件,即在从声明位置到文件结尾的范围内都可以被使 用。具体地说,可以在 main()
、funct1()
和 funct2()
中使用它们。由于 one_file
的链接性为内部,因此只能在包含上述代码的文件中使用它; 由于 global
的链接性为外部,因此可以在程序的其他文件中使用它。
应少用静态外部变量,如使用最好都声明为内部连接性的静态变量。
9.2.4 静态持续性、外部链接性#
链接性为外部的变量通常简称为外部变量,它们的存储持续性为静 态,作用域为整个文件。外部变量是在函数外部定义的,因此对所有函 数而言都是外部的。
1.单定义规则 C++有“单定义规则”(One Definition Rule,ODR),该规则指出, 变量只能有一次定义。请注意,单定义规则并非意味着不能有多个变量的名称相同。例 如,在不同函数中声明的同名自动变量是彼此独立的,它们都有自己的 地址。
9.2.5 静态持续性、内部链接性#
将static限定符用于作用域为整个文件的变量时,该变量的链接性将 为内部的。在多文件程序中,内部链接性和外部链接性之间的差别很有 意义。链接性为内部的变量只能在其所属的文件中使用;但常规外部变 量都具有外部链接性,即可以在其他文件中使用。
在多文件程序中,可以在一个文件(且只能在一个文件)中定义一个外部变量。使用该变量的其他文件必须使用关键字
extern
声明它。
可使用外部变量在多文件程序的不同部分之间共享数据;可使用链接性为内部的静态变量在同一个文件中的多个函数之间共享数据(名称空间提供了另外一种共享数据的方法)。另外,如果将作用域为整个文 件的变量变为静态的,就不必担心其名称与其他文件中的作用域为整个 文件的变量发生冲突。
9.2.6 静态存储持续性、无链接性#
这种变量是这样创建的,将static限定符用于在代码块中定义的变 量。在代码块中使用static时,将导致局部变量的存储持续性为静态的。 这意味着虽然该变量只在该代码块中可用,但它在该代码块不处于活动 状态时仍然存在。因此在两次函数调用之间,静态局部变量的值将保持 不变。(静态变量适用于再生——可以用它们将瑞士银行的秘密账号传 递到下一个要去的地方)。另外,如果初始化了静态局部变量,则程序 只在启动时进行一次初始化。以后再调用函数时,将不会像自动变量那样再次被初始化。
|
|
注意,在这个程序中,由于数组长度为10,因此程序从每行读取的字符数都不超过 9个。另外还需要注意的是,每次函数被调用时,自动变量count都被重 置为0。然而,静态变量total只在程序运行时被设置为0,以后在两次函 数调用之间,其值将保持不变,因此能够记录读取的字符总数。
9.2.7 说明符和限定符#
- 再谈const
在C++(但不是在C语言)中,const
限定符对默认存储类型稍有影响。在默认情况下全局变量的链接性为外部的,但const
全局变量的链接性为内部的。也就是说,在C++看来,全局 const
定义就像使用了 static
说明符一样。
C++修改了常量类型的规则,让程序员更轻松。例如,假设将一组 常量放在头文件中,并在同一个程序的多个文件中使用该头文件。那 么,预处理器将头文件的内容包含到每个源文件中后,所有的源文件都 将包含类似下面这样的定义:
|
|
如果全局const声明的链接性像常规变量那样是外部的,则根据单定 义规则,这将出错(幸亏不是)。
由于外部定义的const
数据的链接性为内部的,因此可以在所有文件中使用相同的声明。
9.2.8 函数和链接性#
和变量一样,函数也有链接性,虽然可选择的范围比变量小。和C 语言一样,C++不允许在一个函数中定义另外一个函数,因此所有函数 的存储持续性都自动为静态的,即在整个程序执行期间都一直存在。在 默认情况下,函数的链接性为外部的,即可以在文件间共享。实际上, 可以在函数原型中使用关键字extern来指出函数是在另一个文件中定义 的,不过这是可选的。还可以使用关键字static
将函数的链接性设置为内部的,使之只能在一个文件中使用,但必须同时在原型和函数定义中使用该关键字。
9.2.9 语言链接性#
在 C++中,同一个名称可能对应多个函数,必须将这些函数翻译为不同的 符号名称。因此,C++编译器执行名称矫正或名称修饰(参见第8 章),为重载函数生成不同的符号名称。例如,可能将spiff(int)
转换 为 _spoff_i
,而将 spiff(double,double)
转换为 _spiff_d_d
。这种方法被称为C++语言链接(C++ language linkage)。
9.2.10 存储方案和动态分配#
动态内存由运算符 new
和 delete
控制,而不是由作用域和链接性规则控制。因此,可以在一个函数中分配动态内存,而在另一个函数中将其释放。与自动内存不同,动态内存不是LIFO,其分配和释放顺序要取决于 new
和 delete
在何时以何种方式被使用。通常,编译器使用三块独立的内存:一块用 于静态变量(可能再细分),一块用于自动变量,另外一块用于动态存 储。
9.3 名称空间#
名称可以是变量、函数、结构体、枚举、类以及类和结构体的成员。C++标准提供了名称空间工具,以便更好地控制名称的作用域。
9.3.1 传统的C++名称空间#
C++关于全局变量和局部变量的规则定义了一种名称空间层次。每 个声明区域都可以声明名称,这些名称独立于在其他声明区域中声明的 名称。在一个函数中声明的局部变量不会与在另一个函数中声明的局部 变量发生冲突。
9.3.2 新的名称空间特性#
C++新增了这样一种功能,即通过定义一种新的声明区域来创建命 名的名称空间,这样做的目的之一是提供一个声明名称的区域。一个名 称空间中的名称不会与另外一个名称空间的相同名称发生冲突,同时允 许程序的其他部分使用该名称空间中声明的东西。
名称空间可以是全局的,也可以位于另一个名称空间中,但不能位于代码块中。因此,在默认情况下,在名称空间中声明的名称的链接性 为外部的(除非它引用了常量)。
1.using声明和using编译指令
using声明由被限定的名称和它前面的关键字using组成:
|
|
using
声明将特定的名称添加到它所属的声明区域中。例如 main()
中的 using
声明 Jill::fetch
将 fetch
添加到 main()
定义的声明区域中。完成该声明后,便可以使用名称 fetch
代替 Jill::fetch
。
using声明使一个名称可用,而using编译指令使所有的名称都可 用。using编译指令由名称空间名和它前面的关键字using namespace组成,它使名称空间中的所有名称都可用,而不需要使用作用域解析运算符。在全局声明区域中使用using编译指令,将使该名称空间的名称全局可用。
2.using编译指令和using声明之比较
一般说来,使用using
声明比使用using
编译指令更安全,这是由于它只导入指定的名称。如果该名称与局部名称发生冲突,编译器将发出 指示。using
编译指令导入所有名称,包括可能并不需要的名称。如果与局部名称发生冲突,则局部名称将覆盖名称空间版本,而编译器并不会发出警告。另外,名称空间的开放性意味着名称空间的名称可能分散在多个地方,这使得难以准确知道添加了哪些名称。
3.名称空间的其他特性 可以将名称空间声明进行嵌套。
这里,flame指的是element:🔥:flame。同样,可以使用下面的 using编译指令使内部的名称可用。
4.未命名的名称空间
可以通过省略名称空间的名称来创建未命名的名称空间。
该名称空间中声明的名称的潜在作用域为:从声明点到该声明区域末尾,这与全局变量相似。然而,这种名称空间没有名称,因此不能显式地使用 using
编译指令或 using
声明来使它在其他位置都可用。也就是说,不能在未命名名称空间所属文件之外的其他文件中,使用该名称空间中的名称。这相当于是一个链接性为内部的静态变量的替代品。
9.3.4 名称空间及其前途#
使用名称空间的原则:
- 使用在已命名的名称空间中声明的变量,而不是使用外部全局变量;
- 使用在已命名的名称空间中声明的变量,而不是使用静态全局变量;
- 如果开发了一个函数库或类库,将其放在一个名称空间中;
- 仅将编译指令using作为一种将旧代码转换为使用名称空间的权宜之计;
- 不要在头文件中使用
using
编译指令。首先,这样做掩盖了要让哪些名称可用;另外,包含头文件的顺序可能影响程序的行为。如果非要使用编译指令using
,应将其放在所有预处理器编译指令#include
之后; - 导入名称时,首选使用作用域解析运算符或using声明的方法;
- 对于using声明,首选将其作用域设置为局部而不是全局。
9.4 总结#
C++提倡在开发程序时使用多个文件。一种有效的组织策略 是,使用头文件来定义用户类型,为操纵用户类型的函数提供函数原 型;并将函数定义放在一个独立的源代码文件中。头文件和源代码文件 一起定义和实现了用户定义的类型及其使用方式。
C++的存储方案决定了变量保留在内存中的时间(储存持续性)以 及程序的哪一部分可以访问它(作用域和链接性)。
动态内存分配和释放是使用new和delete进行的,它使用自由存储区 或堆来存储数据。调用new占用内存,而调用delete释放内存。程序使用 指针来跟踪这些内存单元。
名称空间允许定义一个可在其中声明标识符的命名区域。这样做的 目的是减少名称冲突,尤其当程序非常大,并使用多个厂商的代码时。可以通过使用作用域解析运算符、using声明或using编译指令,来使名 称空间中的标识符可用。
第十章 对象和类#
本章内容包括:
- 过程性编程和面向对象编程;
- 类概念;
- 如何定义和实现类;
- 公有类访问和私有类访问;
- 类的数据成员;
- 类方法(类成员函数);
- 创建和使用类对象;
- 类的构造函数和析构函数;
- const 成员函数;
- this 指针;
- 创建对象数组;
- 类作用域;
- 抽象数据类型。
面向对象编程(OOP)是一种特殊的、设计程序的概念性方法,OOP的最终特性是:
- 抽象;
- 封装和数据隐藏;
- 多态;
- 继承;
- 代码的可重用性。
为了实现这些特性并将它们组合在一起,C++所做的最重要的改进 是提供了类。
10.1 过程性编程和面向对象编程#
采用过程性编程方法时,首先考虑要遵循的步骤,然后考虑 如何表示这些数据。 如果换成一位OOP程序员,又将如何呢?首先考虑数据——不仅要 考虑如何表示数据,还要考虑如何使用数据。 用户与数据交互的方式有三种:初始化、更新和报告——这就是用户接口。
总之,采用OOP方法时,首先从用户的角度考虑对象——描述对象 所需的数据以及描述用户与数据交互所需的操作。完成对接口的描述 后,需要确定如何实现接口和数据存储。最后,使用新的设计方案创建 出程序。
10.2 抽象和类#
生活中充满复杂性,处理复杂性的方法之一是简化和抽象。
10.2.1 类型是什么#
首先,倾向于根 据数据的外观(在内存中如何存储)来考虑数据类型。但是稍加思索就会 发现,也可以根据要对它执行的操作来定义数据类型。总之,指定基本类型完成了三项工作:
- 决定数据对象需要的内存数量;
- 决定如何解释内存中的位(
long
和float
在内存中占用的位数相同,但将它们转换为数值的方法不同); - 决定可使用数据对象执行的操作或方法。
10.2.2 C++中的类#
类是一种将抽象转换为用户定义类型的C++工具,它将数据表示和 操纵数据的方法组合成一个整洁的包。
定义类时,一般来说,类规范由两个部分组成:
- 类声明:以数据成员的方式描述数据部分,以成员函数(称为方法)的方式描述共有接口;
- 类方法定义:描述如何类成员函数。
简单地说,类声明提供了类的蓝图,而方法定义则提供了细节。例子:
|
|
首先, C++关键字class指出这些代码定义了一个类设计。
1.访问控制
关键字 private
和 public
也是新的,它们描述了对类成员的访问控制。使用类对象的程序都可以直接访问公有部分,但只能通过公有成员 函数(或友元函数,参见第11章)来访问对象的私有成员。
因此,公有成员函 数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。防止程序直接访问数据被称为数据隐藏(参见图10.1)。C++还提 供了第三个访问控制关键字protected,第13章介绍类继承时将讨论该关 键字。
类设计尽可能将公有接口与实现细节分开。公有接口表示设计的抽 象组件。**将实现细节放在一起并将它们与抽象分开被称为封装。**数据隐藏(将数据放在类的私有部分中)是一种封装,将实现的细节隐藏在私 有部分中,就像Stock
类对set_tot()
所做的那样,也是一种封装。封装的 另一个例子是,将类函数定义和类声明放在不同的文件中。
OOP是一种编程风格,从某种程度说,它用于任何一种语言中。
数据隐藏不仅可以防止直接访问数据,还让开发者(类的用户)无需了解数据是如何被表示的。从使用类的角度看,使用哪种方 法没有什么区别。所需要知道的只是各种成员函数的功能;也就是说,需要知道成员函数接受什么样的参数以及返回什么类型的值。原则是将实现细节从接口设计中分离出来。如果以后找到了更好的、实现数据表 示或成员函数细节的方法,可以对这些细节进行修改,而无需修改程序接口,这使程序维护起来更容易。
2.控制对成员的访问:公有还是私有
无论类成员是数据成员还是成员函数,都可以在类的公有部分或私 有部分中声明它。但由于隐藏数据是OOP主要的目标之一,因此数据项 通常放在私有部分,组成类接口的成员函数放在公有部分;否则,就无 法从程序中调用这些函数。
10.2.3 实现类成员函数#
还需要创建类描述的第二部分:为那些由类声明中的原型表示的成 员函数提供代码。成员函数定义与常规函数定义非常相似,它们有函数 头和函数体,也可以有返回类型和参数。但是它们还有两个特殊的特征:
- 定义成员函数时,使用作用域解析运算符
::
来标识函数所属的类; - 类方法可以访问类的
private
组件。
例如:
|
|
作用域解析运算符确定了方法定义对应的类的身份。
类方法的第二个特点是,方法可以直接访问类的私有成员,如同访问一个已经声明好的常用变量一样。例如,show( ) 方法可以使用这样的代码:
|
|
其中,company、shares等都是Stock类的私有数据成员。
另外,类声明常将短小的成员函数作为内联函数在头文件中随类声明一起定义。
内联函数的特殊规则要求在每个使用它们的文件中都对其进行定 义。确保内联定义对多文件程序中的所有文件都可用的、最简便的方法是:将内联定义放在定义类的头文件中。
所创建的每个新对象都有自己的存储空间,用于存储其内部变量和 类成员;但同一个类的所有对象共享同一组类方法,即每种方法只有一 个副本。例如,假设 kate
和 joe
都是 Stock
对象,则 kate.shares
将占据一个 内存块,而 joe.shares
占用另一个内存块,但kate.show()
和 joe.show()
都调用同一个方法,也就是说,它们将执行同一个代码块,只是将这些代码用于不同的数据。在OOP中,调用成员函数被称为发送消息,因此将 同样的消息发送给两个不同的对象将调用同一个方法,但该方法被用于两个不同的对象。
10.2.4 使用类#
使用类与使用基本的内置类型(如int和char)尽可能相同。要创建类对象,可以声明类变量,也可以使用 new
为类对象分配存储空间。可以将对象作为函数的参数和返回值,也可以将一个对象赋给另一个。
要使用新类型,最关键的是要了解成员函数的功能,而不必考虑 其实现细节。
10.2.5 修改实现#
10.2.6 小结#
指定类设计的第一步是提供类声明。类声明类似结构声明,可以包 括数据成员和函数成员。声明有私有部分,在其中声明的成员只能通过 成员函数进行访问;声明还具有公有部分,在其中声明的成员可被使用 类对象的程序直接访问。通常,数据成员被放在私有部分中,成员函数 被放在公有部分中,因此典型的类声明的格式如下:
10.3 类的构造函数和析构函数#
C++的目标之一是让使用类对象就像使用标准类型一样。
一般来说,最好是在创建对象时对它进行初始化。
就Stock类当前的实现而言,gift对象的company成员是没有值的。 类设计假设用户在调用任何其他成员函数之前调用acquire( ),但无法强 加这种假设。避开这种问题的方法之一是在创建对象时,自动对它进行 初始化。为此,C++提供了一个特殊的成员函数——类构造函数,专门 用于构造新对象、将值赋给它们的数据成员。名称与类名相同。例如,Stock类一个可能的构造函数是名为Stock( )的成员函 数。构造函数的原型和函数头有一个有趣的特征——虽然没有返回值, 但没有被声明为void类型。实际上,构造函数没有声明类型。
|
|
程序声明对 象时,将自动调用构造函数。
10.3.1 声明和定义构造函数#
通常定义两个构造函数:一个默认空参数,在未提供显式初始值时,用来创建对象;另一个则提供对私有变量做初始化的参数。
|
|
10.3.2 使用构造函数#
C++提供了两种使用构造函数来初始化对象的方式。第一种方式是 显式地调用构造函数:
|
|
另一种方式是隐式地调用构造函数:
|
|
这种格式更紧凑,它与下面的显式调用等价。
|
|
每次创建类对象(包括使用 new
动态分配内存)时,C++都使用类构造函数。
|
|
这条语句创建一个Stock对象,将其初始化为参数提供的值,并将 该对象的地址赋给pstock指针
但无法使用对象来调用构造函数,因为在构造函数构造出对象之 前,对象是不存在的。因此构造函数被用来创建对象,而不能通过对象来调用。
10.3.3 默认构造函数#
默认构造函数是在未提供显式初始值时,用来创建对象的构造函数。如果没有提供任何构造函数,则C++将自动提供默认构造函数。它是默认构造函 数的隐式版本,不做任何工作。对于Stock类来说,默认构造函数可能 如下:
|
|
当且仅当没有定义任何构造函数时,编译器才会提供默 认构造函数。为类定义了构造函数后,程序员就必须为它提供默认构造 函数。如果提供了非默认构造函数(如Stock(const char * co, int n, double pr)),但没有提供默认构造函数,则下面的声明将出错:
|
|
定义默认构造函数的方式有两种。一种是给已有构造函数的所 有参数提供默认值:
|
|
另一种方式是通过函数重载来定义另一个构造函数——一个没有参 数的构造函数:
|
|
用户定义的默认构造函数通常给所有成员提供隐式初始值。
10.3.4 析构函数#
用构造函数创建对象后,程序负责跟踪该对象,直到其过期为止。 对象过期时,程序将自动调用一个特殊的成员函数,该函数称为析构函数。析构函数很有用,用于对象过期时的完成清理工作。例 如,如果构造函数使用new来分配内存,则析构函数将使用delete来释放 这些内存。Stock的构造函数没有使用new,因此析构函数实际上没有需 要完成的任务。在这种情况下,只需让编译器生成一个什么要不做的隐式析构函数即可。
析构函数的名称很特殊:在类名前加上~
。
因此,Stock
类的析构函数为 ~Stock()
。另外,和构造函数一样,析构函数也可以没有返回值和声明类型。与构造函数不同的是,析构函数没有参数,因此 Stock
析构函数的原型 必须 是这样的:
|
|
什么时候应调用析构函数呢?这由编译器决定,通常不应在代码中 显式地调用析构函数。如果创建的是静态存储类对象,则其析构函数将在程序结束 时自动被调用。如果创建的是自动存储类对象,则其析构函数将在程序执行完代码块时(该对象是在其中定义 的)自动被调用。如果对象是通过new创建的,则它将驻留在栈内存或 自由存储区中,当使用delete来释放内存时,其析构函数将自动被调 用。最后,程序可以创建临时对象来完成特定的操作,在这种情况下,程序将在结束对该对象的使用时自动调用其析构函数。
10.3.5 改进 Stock
类#
|
|
下面的语句表明可以将一个对象赋给同类型的另一个对象:
|
|
在默认情况下,将一个对象赋给同类型的另一个对象时,C++将源对象的每个数据成员的内容复制到目标对象中相应的数据成员中。
构造函数不仅仅可用于初始化新对象。例如,该程序的main( )
中包含下面的语句:
|
|
stock1对象已经存在,因此这条语句不是对stock1进行初始化,而 是将新值赋给它。这是通过让构造程序创建一个新的、临时的对象,然后将其内容复制给 stock1
来实现的。临时对象复制完成之后,程序调用析构函数,删除该临时对象。
输出表明,下面两条语句有根本性的差别:
|
|
第一条语句是初始化,它创建有指定值的对象,可能会创建临时对象(也可能不会);第二条语句是赋值。像这样在赋值语句中使用构造函数总会导致在赋值前创建一个临时对象。
如果既可以通过初始化,也可以通过赋值来设置对象的值,则应采用初始化方式。通常这种 方式的效率更高。
6.const
成员函数
请看下面的代码片段:
|
|
对于当前的C++来说,编译器将拒绝第二行。这是什么原因呢?因为 show()
的代码无法确保调用对象不被修改。
我们以前通过将函数参数声明为 const
引用或指向 const
的指针来解决这种问题。但这里存在语法问题:show()
方法没有任何参数。相反,它所使用的对象是由方法调用隐式地提供。
需要一种新的语法来保证函数不会修改调用对象。C++的解决方法是将 const
关键字放在函数的括号后面。也就是说,show()
声明应像这样:
|
|
同样,函数定义的开头应像这样:
|
|
以这种方式声明和定义的类函数被称为 const
成员函数,从而确保函数内不会修改调用对象。
10.3.6 构造函数和析构函数小结#
构造函数是一种特殊的类成员函数,在创建类对象时被调用。构造 函数的名称和类名相同,但通过函数重载,可以创建多个同名的构造函 数,条件是每个函数的特征标(参数列表)都不同。另外,构造函数没 有声明类型。通常,构造函数用于初始化类对象的成员,初始化应与构 造函数的参数列表匹配。
默认构造函数没有参数,因此如果创建对象时没有进行显式地初始 化,则将调用默认构造函数。如果程序中没有提供任何构造函数,则编 译器会为程序定义一个默认构造函数;否则,必须自己提供默认构造函 数。默认构造函数可以没有任何参数;如果有,则必须给所有参数都提供默认值。
当对象被删除时,程 序将调用析构函数。每个类都只能有一个析构函数。析构函数没有返回 类型(连 void
都没有),也没有参数,其名称为类名称前加上 ~
。
如果构造函数使用了 new
,则必须提供使用 delete
的析构函数。
10.4 this 指针#
有时候类方法可能涉及到两个对象,在这种情况下需要使用 this
指针。
如何将方法的答案传回给调用程序呢?最直接的方法是让方法返回一个引用,该引用指向股价总值较高的对象。因此,用于比较的类方法 topval
的原型如下:
|
|
该函数隐式地访问一个对象,而显式地访问另一个对象,并返回其 中一个对象的引用。括号中的const
表明,该函数不会修改被显式地访问的对象;而括号后的 const
表明,该函数不会修改被隐式地访问的对象。 由于该函数返回了两个 const
对象之一的引用,因此返回类型也应为 const
引用。
比较之后,返回引用时有一个问题需要解决:
C++解决这种问题的方法是:使用被称为 this
的特殊指针。this
指针指向用来调用成员函数的对象(this
被作为隐藏参数传递给方法)。这样,函数调用 stock1.topval(stock2)
将 this
设置为 stock1
对象的地址,使得这个指针可用于 topval()
方法。
一般来说,所有的类方法都将 this
指针设置为调用它的对象的地址。而 topval()
中的 total_val
只不过是 this->total_val
的简写。
|
|
10.5 对象数组#
声明对象数组的方法与 声明标准类型数组相同。
|
|
可以用构造函数来初始化数组元素。在这种情况下,必须为每个元 素调用构造函数:
|
|
这里的代码使用标准格式对数组进行初始化:用括号括起的、以逗 号分隔的值列表。其中,每次构造函数调用表示一个值。如果类包含多 个构造函数,则可以对不同的元素使用不同的构造函数。
10.6 类作用域#
在类中定义的名称(如类数据成员名和类成员函数名)的作用域都 为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可 知的。因此,可以在不同类中使用相同的类成员名而不会引起冲突。
总之,在类声明或成员函数定义中,可以使用未修饰的成员名称 (未限定的名称),就像sell( )
调用 set_tot()
成员函数时那样。构造函数名称在被调用时,才能被识别,因为它的名称与类名相同。在其他情况下,使用类成员名时,必须根据上下文使用直接成员运算符、间接成员运算符 ->
或作用域解析运算符 ::
。
10.6.1 作用域为类的常量#
有时候,使符号常量的作用域为类很有用。例如,类声明可能使用 字面值30来指定数组的长度,由于该常量对于所有对象来说都是相同 的,因此创建一个由所有对象共享的常量是个不错的主意。你以为可以这样:
|
|
但这是不行的!! 因为声明类只是描述了对象的形式,并没有创建对象。
C++提供了另一种在类中定义常量的方式——使用关键字 static
:
|
|
10.6.2 作用域内枚举(C++11)#
10.7 抽象数据类型#
10.8 总结#
面向对象编程强调的是程序如何表示数据。使用OOP方法解决编程问题的第一步是根据它与程序之间的接口来描述数据,从而指定如何使 用数据。然后,设计一个类来实现该接口。
通常,将类声明分成两部分组成,这两部分通常保存在不同的文件 中。类声明(包括由函数原型表示的方法)应放到头文件中。定义成员 函数的源代码放在方法文件中。这样便将接口描述与实现细节分开了。
类是用户定义的类型,对象是类的实例。
每个对象都存储自己的数据,而共享类方法。
如果希望成员函数对多个对象进行操作,可以将额外的对象作为参数传递给它。如果方法需要显式地引用调用它的对象,则可以使用 this
指针。this
指针被设置为调用对象的地址,因此*this
是该对象的别名。
第十一章 使用类#
本章内容包括:
- 运算符重载;
- 友元函数;
- 重载
<<
运算符,以便用于输出; - 状态成员;
- 使用
rand()
生成随机值; - 类的自动转换和强制类型转换;
- 类转换函数。
学习C++的难点之一是需要记住大量的东西,但在拥有丰富的实践 经验之前,根本不可能全部记住这些东西。从这种意义上说,学习 C++ 就像学习功能复杂的字处理程序或电子制表程序一样。任何特性都不可怕,但多数人只掌握了那些经常使用的特性,如查找文本或设置为 斜体等。您可能在那里曾经学过如何生成替换字符或者创建目录,除非经常使用它们,否则这些技能可能根本与日常工作无关。也许,学习本 章知识的最好方法是,在我们自己开发的C++程序中使用其中的新特性。
11.1 运算符重载#
运算符重载是一种形式的 C++ 多态。
运算符 重载将重载的概念扩展到运算符上,允许赋予C++运算符多种含义。实 际上,很多C++(也包括C语言)运算符已经被重载。例如,将 *
运算符用于地址,将得到存储在这个地址中的值;但将它用于两个数字时,得到的将是它们的乘积。C++根据操作数的数目和类型来决定采用哪种操 作。
重载运算符可使代码看起来更自然。
要重载运算符,需使用被称为运算符函数的特殊函数形式。运算符函数的格式为:operatorop(argument-list)
。
例如,operator +()
重载 +
运算符,operator *()
重载 *
运算符。op
必须是有效的C++运算符,不能虚构一个新的符号。例如,不能有 operator@()
这样的函数,因为C++中没有 @
运算符。然而,operator
函数可以重载 []
运算符,因为 []
是数组索引运算符。
11.2 计算时间:一个运算符重载示例#
定义一个 Time
类:
|
|
该类的定义:
|
|
来看一下 Sum()
函数的代码。注意参数是引用,但返回类型却不是 引用。将参数声明为引用的目的是为了提高效率。如果按值传递 Time
对象,代码的功能将相同,但传递引用,速度将更快,使用的内存将更少。
然而,返回值不能是引用。因为函数将创建一个新的Time对象 (sum),来表示另外两个Time对象的和。返回对象(如代码所做的那 样)将创建对象的副本,而调用函数可以使用它。然而,如果返回类型 为 Time &
,则引用的将是 sum
对象。但由于 sum
对象是局部变量,在函数结束时将被删除,因此引用将指向一个不存在的对象。使用返回类型 Time
意味着程序将在删除 sum
之前构造它的拷贝,调用函数将得到该拷贝。
不要返回指向局部变量或临时对象的引用。函数执行完毕后,局部变量和临时对象将消失, 引用将指向不存在的数据。
但这种 sum 时间的方法看起来很傻。
11.2.1 添加加法运算符#
将Time类转换为重载的加法运算符很容易,只要将Sum()
的名称改为 operator +()
即可。
|
|
类定义中改为:
|
|
将该方法命令改为 operator +()
后,就可以使用运算符表示法:
|
|
|
|
11.2.2 重载限制#
多数C++运算符(参见表11.1)都可以用这样的方式重载。重载的运算符(有些例外情况)不必是成员函数,但必须至少有一个操作数是 用户定义的类型。下面详细介绍C++对用户定义的运算符重载的限制:
- 1.重载后的运算符必须至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符;
- 2.使用运算符时不能违反运算符原来的句法规则。例如不要将
-
重载为加法运算。同样,不能修改运算符的优先级; - 3.不能创建新运算符。例如,不能定义
operator **()
函数来表示求幂; - 4.不能重载下面的运算符:
- 5.表11.1中的大多数运算符都可以通过成员或非成员函数进行重 载,但下面的运算符只能通过成员函数进行重载。
11.2.3 其他重载运算符#
还有一些其他的操作对 Time
类来说是有意义的。例如,可能要将两个时间相减或将时间乘以一个因子,这需要重载减法和乘法运算符。这 和重载加法运算符采用的技术相同,即创建 operator –()
和 operator *()
方法。也就是说,将下面的原型添加到类声明中:
|
|
修改类定义:
|
|
11.3 友元#
通常,公有类方法提 供唯一的访问途径,但是有时候这种限制太严格,以致于不适合特定的 编程问题。在这种情况下,C++提供了另外一种形式的访问权限:友 元。友元有3种:
- 友元函数;
- 友元类;
- 友元成员函数。
通过让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限。
需要友元的原因是为了解决这样一个问题:
对于非成员重载运算符函数来说,运算符表达式左边的操作数对应于运算符函数的第一个参数,运算符表达式右边的操作数对应于运算符函数的第二个参数。而原来的成员函数则按相反的顺序处理操作数,也就是说,double值乘以Time值。
使用非成员函数可以按所需的顺序获得操作数(先是double,然后 是Time),但引发了一个新问题:非成员函数不能直接访问类的私有数据,至少常规非成员函数不能访问。然而,有一类特殊的非成员函数可以访问类的私有成员,它们被称为友元函数。
11.3.1 创建友元#
创建友元函数的第一步是将其原型放在类声明中,并在原型声明前 加上关键字 friend
。
|
|
该原型意味着下面两点:
- 虽然
operator *()
函数是在类声明中声明的,但它不是成员函数,因 此不能使用成员运算符来调用; - 虽然
operator *()
函数不是成员函数,但它与成员函数的访问权限相同。
第二步是编写函数定义。因为它不是成员函数,所以不要使用 Time::
限定符。另外,不要在定义中使用关键字 friend
,定义应该如下:
|
|
有了上述声明和定义后,语句:A = 2.75 * B
就被自动转换为 A = operator*(2.75, B)
。
总之,类的友元函数是非成员函数,但访问权限与成员函数相同。
11.3.2 常用的友元:重载 <<
运算符#
|
|
代码中对 friend Time operator*(double m, const Time & t)
的重载定义非常棒,是一个相当聪明的做法!它不是重新写一段代码,而是调用已重载的 Time operator*(double n) const
成员函数。
11.4 重载运算符:作为成员函数还是非成员函数#
一般来说,非成员函数应是友元函数,这样它才能直接访 问类的私有数据。加法运算符需要两个操作数。对于成员函数版本来说,一个操作数 通过this指针隐式地传递,另一个操作数作为函数参数显式地传递;对 于友元版本来说,两个操作数都作为参数来传递。
对于某些运算符来说(如前所述),成员函数是唯一合法的选择。在其他情况下,这两种格式没有太大的区别。有时,根据类设计,使用非成员函数版本可能更好(尤其是为类定义类型 转换时)。
11.5 再谈重载:一个矢量类#
|
|
operator-( )有两种不同的定义。这是可行的,因为它们的特征标不同(两个矢量相减,或者单个矢量取反)。可以定义−运算符的一元和二元版本,因为C++提供了该运 算符的一元和二元版本。对于只有二元形式的运算符(如除法运算符),只能将其重载为二元运算符。
因为运算符重载是通过函数来实现的,所以只要运算符函数的特征标不同,使用的运算符数 量与相应的内置C++运算符相同,就可以多次重载同一个运算符。
11.6 类的自动转换和强制类型转换#
11.7 总结#
一般来说,访问私有类成员的唯一方法是使用类方法。C++使用友 元函数来避开这种限制。要让函数成为友元,需要在类声明中声明该函 数,并在声明前加上关键字friend。
C++扩展了对运算符的重载,允许自定义特殊的运算符函数,这种 函数描述了特定的运算符与类之间的关系。运算符函数可以是类成员函数,也可以是友元函数(有一些运算符函数只能是类成员函数)。
最常见的运算符重载任务之一是定义«运算符,使之可与cout一起 使用,来显示对象的内容。要让ostream对象成为第一个操作数,需要将 运算符函数定义为友元;要使重新定义的运算符能与其自身拼接,需要 将返回类型声明为 ostream &
。
然而,如果类包含这样的方法,它返回需要显示的数据成员的值, 则可以使用这些方法,无需在operator<<()
中直接访问这些成员。在这种情况下,函数不必(也不应当)是友元。
C++允许指定在类和基本类型之间进行转换的方式。首先,任何接 受唯一一个参数的构造函数都可被用作转换函数,将类型与该参数相同 的值转换为类。如果将类型与该参数相同的值赋给对象,则C++将自动调用该构造函数。
要将类对象转换为其他类型,必须定义转换函数,指出如何进行这种转换。转换函数必须是成员函数。
第十二章 类和动态内存分配#
本章内容包括:
- 对类成员使用动态内存分配;
- 隐式和显示复制构造函数;
- 隐式和显示重载赋值运算符;
- 在构造函数中使用
new
所必须完成的工作; - 使用静态类成员;
- 将定位
new
运算符用于对象; - 使用指向对象的指针;
- 实现队列抽象数据类型(ADT)。
对类使用 new
和 delete
将影响构造函数和析构函数的设计以及运算符的重载。
12.1 动态内存和类#
12.1.1 复习示例和静态类成员#
|
|
对这个声明,需要注意的有两点。首先,它使用char指针(而不是 char数组)来表示姓名。这意味着类声明没有为字符串本身分配存储空 间,而是在构造函数中使用new来为字符串分配空间。这避免了在类声 明中预先定义字符串的长度。
其次,将num_strings成员声明为静态存储类。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,StringBad
类的所有对象共享同一个 num_strings
。假设创建了10个 StringBad
对象,将有10个 str
成员和10个 len
成员,但只有一个共享的 num_strings
成员(参见图12.1)。
|
|
int StringBad::num_strings = 0;
这条语句将静态成员 num_strings
的值初始化为零。注意:不能在类声明中初始化静态成员变量,这是因为类声明中只描述如何分配内存,但并不分配内存。
可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。
另外,初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果 在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。
上述代码中,strlen()
返回字符串长度,但不包括末尾的空字符,因此构造函数将len
加1,使分配的内存能够存储包含空字符的字符串。
删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存。因此,必须使用析构函数。在析构函数中使用delete语句可确保对象过期 时,由构造函数使用new分配的内存被释放。
在构造函数中使用
new
来分配内存时,必须在相应的析构函数中使用delete
来释放内存。如果 使用new[]
(包括中括号)来分配内存,则应使用delete[]
(包括中括号)来释放内存。
StringBad
的第一个版本有许多故意留下的缺陷,是一个很糟糕的类(找到该类的错误之处,甚至可以作为一道困难的编程题),这些缺陷使得输出是不确定的。例如,有些 编译器无法编译它。虽然输出的具体内容有所差别,但基本问题和解决方法(稍后将介绍) 是相同的。
程序输出结果:
callme2()
按值(而不是按引用)传递 headline2
,结果表明这是一个严重的问题。
首先,将 headline2
作为函数参数来传递从而导致析构函数被调用。这是因为函数的参数 sb
是一个临时变量,当函数调用结束后会释放这个临时变量,从而导致析构函数被调用,糟糕的源头在于析构函数中恰巧就释放了字符串。 其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始 字符串无法识别,导致显示一些非标准字符(显示的文本取决于内存中 包含的内容)。
因为自动存储对象被删除的顺序与创建顺序相反,所以最先删除的 3个对象是knots
、sailor
和sport
。删除knots
和sailor
时是正常的,但在删 除sport
时,Dollars
变成了Doll8
(或其他)。对于sport
,程序只使用它来初始化 sailor
,但这种操作修改了 sports
(这又可以做一道题)。
具体而言,程序中 Stringbad sailor = sports;
这个语句既不是调用默认构造函数也不是调用参数为 const char*
的构造函数,而是等价于 StringBad sailor=StringBad(sports);
,又因为sports
的类型为StringBad
,因此与之相应的构造函数原型应该是 StringBad(const String &);
,但这个构造函数在StringBad
类中没有显式声明更没有定义。这时当我们使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。但自动生成的复制构造函数不知道需要更新静态变量num_string
,因此会将计数方案搞乱(这就是复制对象带来的问题)。实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的。
最后被删除的两个对象(headline2
和 headline1
)已经无法识别。
12.1.2 特殊成员函数#
StringBad
类的问题是由特殊成员函数引起的。这些成员函数是自动定义的,就StringBad
而言,这些函数的行为与类设计不符。具体地说, C++自动提供了下面这些成员函数:
- 默认构造函数;
- 默认析构函数;
- 复制构造函数;
- 复制预运算符;
- 地址运算符。
更准确地说,编译器将生成上述最后三个函数的定义——如果程序 使用对象的方式要求这样做。例如,如果您将一个对象赋给另一个对 象,编译器将提供赋值运算符的定义。
结果表明,StringBad类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。
1.默认构造函数
默认情况下,编译器将提供一个不接受任何参数,也不执行任何操作 的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用 构造函数。
如果定义了构造函数,C++将不会定义默认构造函数。
2.复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程(那赋值的时候怎么办?赋值靠赋值运算符=,见12.1.4),原型是:Class_name(const Class_name &)
。它接受一个指向类对象的常量引用作为参数。
对于复制构造函数,需要知道两点:何时调用和有何功能。
3.何时调用复制构造函数
新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。 最常见的情况是将新对象显式地初始化为现有的对象。
其中中间的2种声明可能会使用复制构造函数直接创建metoo和 also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的 内容赋给metoo和also,这取决于具体的实现。最后一种声明使用motto 初始化一个匿名对象,并将新对象的地址赋给pStringBad指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象(如程序清单12.3中的 callme2()
)或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。
何时生成临时对象随编译器而异,但无论是哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
4.默认的复制构造函数的功能
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。
12.1.3 回到 Stringbad:复制构造函数的哪里出了问题#
第一个异常,num_string是负值。当callme2()
被调用时,复制构造函数被用来初始化 callme2()
的形参,还被用来将对象 sailor
初始化为对象 sports
。默认的复 制构造函数不说明其行为,因此它不指出创建过程,也不增加计数器 num_strings的值。但析构函数更新了计数,并且在任何对象过期时都将 被调用,而不管对象是如何被创建的。程序的输出表明,析构函数的调用次数比构造函数的调用次数多2。
第二个异常之处更微妙,也更危险,其症状之一是字符串内容出现乱码。原因在于隐式复制构造函数是按值进行复制的。例如,对于程序清 单12.3,隐式复制构造函数的功能相当于:sailor.str = sport.str;
。这里复制的并不是字符串,而是一个指向字符串的指针。也就是 说,将sailor初始化为sports后,得到的是两个指向同一个字符串的指 针。当operator «()函数使用指针来显示字符串时,这并不会出现问 题。但当析构函数被调用时,这将引发问题。析构函数StringBad释放str 指针指向的内存,因此释放sailor的效果如下:
sports.str
指向的内存已经被 sailor
的析构函数释放,这将导致不确定 的、可能有害的后果。
1.定义一个显式复制构造函数以解决问题
解决类设计中这种问题的方法是进行深度复制(deep copy)。也就是说,复制构造函数应当复制字符串并将副本的地址赋给str
成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用 另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。可以这样编写String
的复制构造函数:
|
|
12.1.4 Stringbad的其他问题:赋值运算符#
并不是程序清单12.3的所有问题都可以归咎于默认的复制构造函 数,还需要看一看默认的赋值运算符。C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符 的原型如下:
Class_name & Class_name::operator=(const Class_name &);
它接受并返回一个指向类对象的引用。
1.赋值运算符的功能以及何时使用它
将已有的对象赋给另一个对象时,将使用重载的赋值运算符。初始化对象时,并不一定会使用赋值运算符,而更可能是调用复制构造函数。
|
|
这里,metoo
是一个新创建的对象,被初始化为 knot
的值,因此使用复制构造函数。然而,正如前面指出的,实现时也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时 对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用 =
运算符时也可能调用赋值运算符。
与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制,静态成员不受影响。
2.赋值的问题出在哪里
程序清单12.3将headline1赋给knot:knot=headline1;
。为knot调用析构函数时,正常。为Headline1调用析构函数时,异常。出现的问题与隐式复制构造函数相同:数据受损。这也是成员复制 的问题,即导致headline1.str和knot.str指向相同的地址。
- 解决赋值的问题
解决办法是提供赋 值运算符(进行深度复制)定义,这与复制构造函数相似,但也有 一些差别:
- 由于目标对象可能引用了以前分配的数据,所以函数应使用
delete []
来释放这些数据; - 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内 存操作可能删除对象的内容;
- 函数返回一个指向调用对象的引用(注意是引用,不是值也不是指针)。
|
|
赋值操作并不创建新的对象,因此不需要调整静态数据成员 num_strings
的值。
12.2 改进后的新String类#
首先,添加前面介绍过的复制构造函数和赋值运算符,使类 能够正确管理类对象使用的内存。其次,由于您已经知道对象何时被创 建和释放,因此可以让类构造函数和析构函数保持沉默,不再在每次被 调用时都显示消息。另外,也不用再监视构造函数的工作情况,因此可以简化默认构造函数,使之创建一个空字符串,而不是“C++”。
新默认构造函数中:
|
|
为什么代码为 str=new char[1];
而不是 str = new char;
,这两个方式相同,但区别在于前者和类析构函数兼容,而后者不兼容。这是析构函数:
|
|
对 operator>
的重载很妙啊,直接利用了 operator<
的重载结果:
12.2.3 使用中括号表示法访问字符#
在C++中,两个中括号组成一个运算符——中括号运算符,可以使 用方法operator 来重载该运算符。通常,二元C++运算符(带两个操作 数)位于两个操作数之间,例如2 +5。但对于中括号运算符,一个操作数位于第一个中括号的前面,另一个操作数位于两个中括号之间。例如:city[0]
中,city
是第一个操作数,[]
是运算符,0
是第二个操作数。
在重载时,C++将区分常量和非常量函数的特征标。
|
|
有了上述定义后,就可以读/写常规String对象了;而对于const String对象,则只能读取其数据。
重载要注意同时考虑对const 和非 const 变量进行。
也可以修改内容:
|
|
12.2.4 静态类成员函数#
首先,不能通过对象调用静态成员函数;实际上,静态成员函数甚 至不能使用this指针。
其次,由于静态成员函数不与特定的对象相关联,因此只能使用静 态数据成员。
12.2.5 进一步重载赋值运算符#
重载»运算符提供了一种将键盘输入行读入到String对象中的简单 方法。它假定输入的字符数不多于String::CINLIM的字符数,并丢弃多 余的字符。在if条件下,如果由于某种原因(如到达文件尾或get(char *, int)
读取的是一个空行)导致输入失败,istream对象的值将置为 false。
12.3 在构造函数中使用new时应注意的事项#
使用new初始化对象的指针成员时必须特别小心:
- 如果在构造函数中使用new来初始化指针成员,则应在析构函数中 使用delete;
- new和delete必须相互兼容。new对应于delete,new[ ]对应于delete[ ];
- 如果有多个构造函数,则必须以相同的方式使用new,要么都带中 括号,要么都不带。因为只有一个析构函数,所有的构造函数都必 须与它兼容。然而,可以在一个构造函数中使用new初始化指针, 而在另一个构造函数中将指针初始化为空(0或C++11中的 nullptr),这是因为delete(无论是带中括号还是不带中括号)可以用于空指针;
- 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一 个对象;
- 应当定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象;
具体地说,该方法应完成这些操作:检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用。
12.3.1 应该和不应该#
12.3.2 包含类成员的类的逐成员复制#
如果您将一个 Magazine对象复制或赋值给另一个Magazine对象,逐成员复制将使用成 员类型定义的复制构造函数和赋值运算符。也就是说,复制成员title 时,将使用String的复制构造函数,而将成员title赋给另一个Magazine对 象时,将使用String的赋值运算符,依此类推。
12.4 有关返回对象的说明#
当成员函数或独立的函数返回对象时,有几种返回方式可供选择。 可以返回指向对象的引用、指向对象的const引用或const对象。
12.4.1 返回指向const对象的引用#
这里有三点需要说明。首先,返回对象将调用复制构造函数,而返回引用不会,所以版本2效率更高。其次,引用指向的对象应该在调用函数执行时存在。第 三,v1和v2都被声明为const引用,因此返回类型必须为const,这样才匹配。
12.4.2 返回指向非const对象的引用#
两种常见的返回非 const
对象情形是,重载赋值运算符以及重载与 cout
一起使用的 <<
运算符。前者这样做旨在提高效率,而后者必须这样做。
Operator«()的返回值用于串接输出:
|
|
operator<<(cout, s1)
的返回值成为一个用于显示字符串“is coming!”的对象。返回类型必须是 ostream &
,而不能仅仅是 ostream
。如果使用返回类型ostream
,将要求调用ostream
类的复制构造 函数,而ostream
没有公有的复制构造函数。
12.4.3 返回对象#
如果被返回的对象是被调用函数中的局部变量,则不应按引用方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。因此,当控制权回到调用函数时,引用指向的对象将不再存在。在这种情况下,应返回对象而不是引用。
12.4.4 返回 const
对象#
前面的Vector::operator+( )定义有一个奇异的属性,它旨在让您能够 以下面这样的方式使用它:net = force1 + force2;
。
然而,这种定义也允许您这样使用它:
force1 + force2 = net;
cout << (force1 + force2 = net).magval() << endl;
这提出了三个问题。为何编写这样的语句?这些语句为何可行?这 些语句有何功能?首先,没有要编写这种语句的合理理由,但并非所有代码都是合理的。
这种代码之所以可行,是因为复制构造函数将创建一个临时 对象来表示返回值。因此,在前面的代码中,表达式force1 + force2的结 果为一个临时对象。在语句1中,该临时对象被赋给net;在语句2和3 中,net被赋给该临时对象。使用完临时对象后,将把它丢弃。
总之,如果方法或函数要返回局部对象,则应返回对象,而不是指 向对象的引用。在这种情况下,将使用复制构造函数来生成返回的对 象。如果方法或函数要返回一个没有公有复制构造函数的类(如ostream类)的对象,它必须返回一个指向这种对象的引用。最后,有些方法和 函数(如重载的赋值运算符)可以返回对象,也可以返回指向对象的引 用,在这种情况下,应首选引用,因为其效率更高。
12.5 使用指向对象的指针#
12.5.1 再谈new
和delete
#
12.5.2 指针和对象小结#
- 使用常规表示法来声明指向对象的指针;
- 可以将指针初始化为指向已有的对象;
- 可以使用new来初始化指针,这将创建一个新的对象;
- 对类使用new将调用相应的类构造函数来初始化新创建的对象。
- 可以使用
->
运算符通过指针访问类方法; - 可以对对象指针应用解除引用运算符
*
来获得对象。
12.5.3 再谈定位new运算符#
12.6 复习各种技术#
12.6.1 重载«运算符#
要重新定义 « 运算符,以便将它和cout一起用来显示对象的内 容,请定义下面的友元运算符函数:
12.6.2 转换函数#
要将单个值转换为类类型,需要创建原型如下所示的类构造函数:
|
|
要将类转换为其他类型,需要创建原型如下所示的类成员函数:
operator type_name();
虽然该函数没有声明返回类型,但应返回所需类型的值。
使用转换函数时要小心。可以在声明构造函数时使用关键字 explicit,以防止它被用于隐式转换。
12.6.3 其构造函数使用new的类#
12.7 队列模拟#
这里不记录了。
对于const
数据成员,必须在执行到构造函数体之前,即创建对象时进行初始化。C++提供了一种特殊的语法来完成上 述工作,它叫做成员初始化列表(member initializer list)。成员初始化 列表由逗号分隔的初始化列表组成(前面带冒号)。它位于参数列表的 右括号之后、函数体左括号之前。如果数据成员的名称为 mdata
,并需 要将它初始化为val
,则初始化器为mdata(val)
。
只有构造函数可以使用这种初始化列表语法。如上所示,对于const 类成员,必须使用这种语法。另外,对于被声明为引用的类成员,也必须使用这种语法:
这是因为引用与const数据类似,只能在被创建时进行初始化。对于 简单数据成员(例如front和items),使用成员初始化列表和在函数体中 使用赋值没有什么区别。
【注意】: 这种格式只能用于构造函数; 必须用这种格式来初始化非静态const数据成员; 必须用这种格式来初始化引用数据成员。
不能将成员初始化列表语法用于构造函数之外的其他类方法。
如果我们不希望复制构造函数被调用,也不允许赋值运算,可以这样做:
这是一种禁用方法的技巧,同时可以作为一种暂时不编写这两个函数的预防措施:与其将来面对无法预料的运行故障,不如得到一个易于跟踪的编译错误,指出这些方法是不可访问的。另外,在定义其对象不允许 被复制的类时,这种方法也很有用。
还有没有其他影响需要注意呢?当然有。当对象被按值传递(或返 回)时,复制构造函数将被调用。然而,如果遵循优先采用按引用传递 对象的惯例,将不会有任何问题。另外,复制构造函数还被用于创建其 他的临时对象,但Queue定义中并没有导致创建临时对象的操作,例如 重载加法运算符。
12.8 总结#
本章介绍了定义和使用类的许多重要方面。其中的一些方面是非常 微妙甚至很难理解的概念。
在类构造函数中使用new,也可能在对象过期 时引发问题。如果对象包含成员指针,同时它指向的内存是由new分配 的,则释放用于保存对象的内存并不会自动释放对象成员指针指向的内 存。因此在类构造函数中使用new类来分配内存时,应在类析构函数中 使用delete来释放分配的内存。这样,当对象过期时,将自动释放其指 针成员指向的内存。
如果对象包含指向new分配的内存的指针成员,则将一个对象初始 化为另一个对象,或将一个对象赋给另一个对象时,也会出现问题。
在 默认情况下,C++逐个对成员进行初始化和赋值,这意味着被初始化或 被赋值的对象的成员将与原始对象完全相同。如果原始对象的成员指向 一个数据块,则副本成员将指向同一个数据块。当程序最终删除这两个 对象时,类的析构函数将试图删除同一个内存数据块两次,这将出错。解决方法是:定义一个特殊的复制构造函数来重新定义初始化,并重载赋值运算符。
这样,旧对象和新对象都将引用独立 的、相同的数据,而不会重叠。由于同样的原因,必须定义赋值运算 符。对于每一种情况,最终目的都是执行深度复制,也就是说,复制实际的数据,而不仅仅是复制指向数据的指针。
C++允许在类中包含结构、类和枚举定义。这些嵌套类型的作用域为整个类,这意味着它们被局限于类中,不 会与其他地方定义的同名结构、类和枚举发生冲突。
C++为类构造函数提供了一种可用来初始化数据成员的特殊语法。 这种语法包括冒号和由逗号分隔的初始化列表,被放在构造函数参数的 右括号后,函数体的左括号之前。每一个初始化器都由被初始化的成员 的名称和包含初始值的括号组成。从概念上来说,这些初始化操作是在 对象创建时进行的,此时函数体中的语句还没有执行。语法如下:
queue(int qs): qsize(qs), items(0), front(NULL), rear(NULL) {}
如果数据成员是非静态const成员或引用,则必须采用这种格式,但 可将C++11新增的类内初始化用于非静态const成员。
第十三章 类继承#
本章内容包括:
- is-a 关系的继承;
- 如何以公有方式从一个类派生出另一个类;
- 保护访问;
- 构造函数成员初始化列表;
- 向上和向下强制转换;
- 虚成员函数;
- 早期(静态)联编与晚期(动态)联编;
- 抽象基类;
- 纯虚函数;
- 何时以及如何使用公有继承。
面向对象编程的主要目的之一是提供可重用的代码。开发新项目, 尤其是当项目十分庞大时,重用经过测试的代码比重新编写代码要好得多。另外,必须考虑的细节越少, 便越能专注于程序的整体策略。
继承是一种非常好的概念,其基本实现非常简单。
13.1 一个简单的基类#
从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。
13.1.1 派生一个类#
与其从零开始,不如从 TableTennisClass类派生出一个类。首先将RatedPlayer类声明为从 TableTennisClass类派生而来:
class RetedPlayer: public TableTennisPlayer {}
冒号指出RatedPlayer类的基类是TableTennisplayer类。上述特殊的 声明头表明TableTennisPlayer是一个公有基类,这被称为公有派生。
派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类 的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基 类的公有和保护方法访问。
上述代码完成了哪些工作呢?Ratedplayer对象将具有以下特征:
- 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
- 派生类对象可以使用基类的方法(派生类继承了基类的接口)。
需要在继承特性中添加什么呢?
- 派生类需要自己的构造函数。
- 派生类可以根据需要添加额外的数据成员和成员函数。
构造函数必须给新成员(如果有的话)和继承的成员提供数据。
在 第一个RatedPlayer构造函数中,每个成员对应一个形参;而第二个 Ratedplayer构造函数使用一个TableTennisPlayer参数,该参数包括 firstname、lastname和hasTable。
|
|
13.1.2 构造函数:访问权限的考虑#
派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。具体地说,派生类构造函数需要使用基类构造函数。
创建派生类对象时,程序首先创建基类对象。
从概念上说,这意味 着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员 初始化列表语法来完成这种工作。例如,下面是第一个RatedPlayer构造 函数的代码:
|
|
其中: TableTennisPlayer(fn,ln,ht)
是成员初始化列表。它是可执行的 代码,调用TableTennisPlayer
构造函数。
|
|
如果省略成员初始化列表,情况将如何呢?首先必须创建基类对象,如果不调用基类构造函数,程序将使用默 认的基类构造函数,因此上述代码与下面等效:
|
|
除非要使用默认构造函数,否则应显式调用正确的基类构造函数。
如果愿意,也可以对派生类成员使用成员初始化列表语法。在这种 情况下,应在列表中使用成员名,而不是类名。所以,第二个构造函数 可以按照下述方式编写:
|
|
有关派生类构造函数的要点如下:
- 首先创建基类对象;
- 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;
- 派生类构造函数应初始化派生类新增的数据成员。
派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。
13.1.3 使用派生类#
要使用派生类,程序必须要能够访问基类声明。既可以将两种类的声明置于同一个头文件中。也可以将每个类放在独立的头文件 中,如果两个类是相关的,把它们的类声明放在一起更合适。
13.1.4 派生类和基类之间的特殊关系#
第一,派生类对象可以使用基类的方法,条件是方法不是私有的; 第二,基类指针可以在不进行显式类型转换的情况下指向派生类对象(基类指针可以直接指向派生类,神奇); 第三,基类引用可以在不进行显式类型转换的情况下引用派生类对象(基类引用可以直接应用派生类,神奇)。
不过,基类指针或引用只能用于调用基类方法,因此,不能使用rt
或pt
来调用派生类的ResetRanking
方法。
通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对 继承来说是例外。
然而,这种例外只是单向的,不可以将基类对象和地 址赋给派生类引用和指针。
上述规则是有道理的。允许基类引用隐式地引用派生类对象,等于是可以使用基类引用为派生类对象调用基类的方法。因为派生类 继承了基类的方法,所以这样做不会出现问题。
而如果可以将基类对象赋 给派生类引用,将发生什么情况呢?派生类引用能够为基对象调用派生 类方法,这样做将出现问题。例如,将RatedPlayer::Rating( )方法用于 TableTennisPlayer对象是没有意义的,因为TableTennisPlayer对象没有 rating成员。
基类引用和指针可以指向派生类对象,将出现一些很有趣的结果。其中之一是基类中引用定义的函数或指针参数可用于派生类对象。
13.2 继承:is-a关系#
派生类和基类之间的特殊关系是基于C++继承的底层模型的。实际 上,C++有3种继承方式:公有继承、保护继承和私有继承。公有继承 是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,任何可以对基类对象执行的操作,也可以对派生类对象执行。
因为派生类可以添加特性,所以,将这种关系称为 is-a-kind-of(是一种)关系可能 更准确,但是通常使用术语 is-a。
13.3 多态公有继承#
派生类对象使用基类的方法,而未做 任何修改。然而,可能会遇到这样的情况,即希望同一个方法在派生类 和基类中的行为是不同的,这称为多态公有继承。有两种重要的机制可用于实现多态公有继承:
- 在派生类中重新定义基类的方法;
- 使用虚方法(关键字
virtual
),然后在各自类中对该函数编写相关定义即可。
虚函数的这种行为非常方便。方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。
第四点是,基类声明了一个虚析构函数。这样做是为了确保释放派 生对象时,按正确的顺序调用析构函数。
为何需要虚析构函数?
在程序清单13.10中,使用delete释放由new分配的对象的代码说明 了为何基类应包含一个虚析构函数。
如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。对于 程序清单13.10,这意味着只有Brass的析构函数被调用,即使指针指向 的是一个BrassPlus对象。如果析构函数是虚的,将调用相应对象类型的 析构函数。因此,如果指针指向的是BrassPlus对象,将调用BrassPlus的 析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以 确保正确的析构函数序列被调用。
对于程序清单13.10,这种正确的行为并不是很重要,因为析构函数没有执行任何操作。然而,如果 BrassPlus包含一个执行某些操作的析构函数,则Brass必须有一个虚析构函数,即使该析构函数不执行任何操作。
13.4 静态联编和动态联编#
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这 个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函 数名联编(binding)。在编译过程中进行联 编被称为静态联编(static binding),又称为早期联编(early binding)。然而,虚函数使这项工作变得更困难。正如在程序清单 13.10所示的那样,使用哪一个函数是不能在编译时确定的,因为编译 器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程 序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。
13.4.1 指针和引用类型的兼容性#
将派生类引用或指针转换为基类引用或指针被称为向上强制转换 (upcasting),这使公有继承不需要进行显式类型转换。该规则是is-a 关系的一部分。
相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换(downcasting),如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。派生类可以新增 数据成员,因此使用这些数据成员的类成员函数不能应用于基类。
对于使用基类引用或指针作为参数的函数调用,将进行向上转换。
13.4.2 虚成员函数和动态联编#
编译器对虚方法使用动态联编。在大多数情况下,动态联编很好,因为它让程序能够选择为特定类 型设计的方法。
1.为什么有两种类型的联编以及为什么默认为静态联编
如果动态联编让您能够重新定义类方法,而静态联编在这方面很 差,为何不摒弃静态联编呢?原因有两个——效率和概念模型。
首先来看效率。为使程序能够在运行阶段进行决策,必须采取一些 方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销 (稍后将介绍一种动态联编方法)。
接下来看概念模型。在设计类时,可能包含一些不在派生类重新定 义的成员函数。例如,Brass::Balance( )函数返回账户结余,不应该重新 定义。不将该函数设置为虚函数,有两方面的好处:首先效率更高;其次,指出不要重新定义该函数。这表明,仅将那些预期将被重新定义的 方法声明为虚的。
2.虚函数的工作原理
编译器处理虚函数的方法是:给每个对象添加一个隐藏成 员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚 函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行 声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类 中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指 针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地 址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地 址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl 中(参见图13.5)。注意,无论类中包含的虚函数是1个还是10个,都 只需要在对象中添加1个地址成员,只是表的大小不同而已。
调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相 应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使 用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声 明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:
- 每个对象都将增大,增大量为存储地址的空间;
- 对于每个类,编译器都创建一个虚函数地址表(数组);
- 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址;
13.4.3 有关虚函数注意事项#
- 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有 的派生类(包括从派生类派生出来的类)中是虚的;
- 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为 动态联编。这种行为非常重要,因为这样基类指针或引 用可以指向派生类对象;
- 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的 类方法声明为虚的;
- 构造函数不能是虚函数;
- 析构函数应当是虚函数,除非类不用做基类。这意味着,即使基类不需要显式析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作;
给类定义一个虚析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。
- 友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函 数。
- 如果派生类没有重新定义函数,将使用该函数的基类版本。如果派 生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。
13.5 访问控制:protected#
关键字protected
与 private
相似,在类外只能用公有类成员来访问protected
部分中的类成员。private
和protected
之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说, 保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。
13.6 抽象基类#
C++通过使用纯虚函数(pure virtual function) 提供未实现的函数。纯虚函数声明的结尾处为=0。
|
|
当类声明中包含纯虚函数时,则不能创建该类的对象。这里的理念 是,包含纯虚函数的类只用作基类。要成为真正的ABC,必须至少包含 一个纯虚函数。原型中的=0使虚函数成为纯虚函数。
在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数。
【ABC理念】如果要设计类继承层次,则只能将那些不会被用作基类的类设计 为具体的类。这种方法的设计更清晰,复杂程度更低。
可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖 其纯虚函数——迫使派生类遵循ABC设置的接口规则。这种模型在基于 组件的编程模式中很常见,在这种情况下,使用ABC使得组件设计人员 能够制定“接口约定”,这样确保了从ABC派生的所有组件都至少支持 ABC指定的功能。
13.7 继承和动态内存分配#
继承是怎样与动态内存分配(使用new和delete)进行互动的呢?例如,如果基类使用动态内存分配,并重新定义赋值和复制构造函数,这将怎样影响派生类的实现呢?
13.7.1 第一种情况:派生类不使用new#
假设基类使用了动态内存分配,基类中包含了构造函数使用new
时需要的特殊方法:析构函数、复 制构造函数和重载赋值运算符。如例子中的 baseDMA
。
现在,从 baseDMA
派生出 lackDMA
类,而后者不使用 new
,也未包含其他一些不常用的、需要特殊处理的设计特性。
|
|
是否需要为lackDMA类定义显式析构函数、复制构造函数和赋值运算符呢?不需要。
首先,来看是否需要析构函数。如果没有定义析构函数,编译器将 定义一个不执行任何操作的默认构造函数。实际上,派生类的默认构造 函数总是要进行一些操作:执行自身的代码后调用基类析构函数。因为 我们假设lackDMA成员不需执行任何特殊操作,所以默认析构函数是合 适的。
接着来看复制构造函数。第12章介绍过,默认复制构造函数执行成 员复制,这对于动态内存分配来说是不合适的,但对于新的lacksDMA 成员来说是合适的。因此只需考虑继承的baseDMA对象。要知道,成员 复制将根据数据类型采用相应的复制方式,因此,将long复制到long中 是通过使用常规赋值完成的;但复制类成员或继承的类组件时,则是使 用该类的复制构造函数完成的。所以,lacksDMA类的默认复制构造函 数使用显式baseDMA复制构造函数来复制lacksDMA对象的baseDMA部 分。因此,默认复制构造函数对于新的lacksDMA成员来说是合适的, 同时对于继承的baseDMA对象来说也是合适的。
对于赋值来说,也是如此。类的默认赋值运算符将自动使用基类的 赋值运算符来对基类组件进行赋值。因此,默认赋值运算符也是合适的。
13.7.2 第二种情况:派生类使用new#
在这种情况下,必须为派生类定义显式析构函数、复制构造函数和 赋值运算符。
总之,当基类和派生类都采用动态内存分配时,派生类的析构函 数、复制构造函数、赋值运算符都必须使用相应的基类方法来处理基类 元素。这种要求是通过三种不同的方式来满足的。对于析构函数,这是 自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的 复制构造函数来完成的;如果不这样做,将自动调用基类的默认构造函 数。对于赋值运算符,这是通过使用作用域解析运算符显式地调用基类 的赋值运算符来完成的。
13.7.3 使用动态内存分配和友元的继承示例#
13.8 类设计回顾#
13.8.1 编译器生成的成员函数#
编译器会自动生成一些公有成员函数——特殊成员 函数。这表明这些特殊成员函数很重要:
- 默认构造函数,要么没有参数,要么所有的参数都有默认值。如果没 有定义任何构造函数,编译器将定义默认构造函数,让我们能够创建对象;
- 复制构造函数,接受其所属类的对象作为参数。如果程序没有使用(显式或隐式)复制构造函数,编译器将提供原型,但不提供函数定义;否则,程序将定义一个执行成员初始化的复制构造函数;
- 赋值运算符,默认的赋值运算符用于处理同类对象之间的赋值。不要将赋值与初 始化混淆了。如果语句创建新的对象,则使用初始化;如果语句修改已有对象的值,则是赋值;
13.8.2 其他的类方法#
定义类时,还需要注意其他几点:
- 构造函数。构造函数不同于其他类方法,因为它创建新的对象,而其他类方法 只是被现有的对象调用。这是构造函数不被继承的原因之一。继承意味着派生类对象可以使用基类的方法,然而,构造函数在完成其工作之前,对象并不存在;
- 析构函数。一定要定义显式析构函数来释放类构造函数使用new分配的所有内 存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需 要析构函数,也应提供一个虚析构函数;
- 转换。使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换。将可转换的类型传递给以类为参数的函数时,将调用转换构造函数。在带一个参数的构造函数原型中使用
explicit
将禁止进行隐式转换, 但仍允许显式转换:
要将类对象转换为其他类型,应定义转换函数(参见第11章)。
- 按值传递对象与传递引用。编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。这样做的原因之一是为了提高效率。按值传递对象涉及到生成 临时拷贝,即调用复制构造函数,然后调用析构函数。调用这些函数需 要时间,复制大型对象比传递引用花费的时间要多得多。如果函数不修 改对象,应将参数声明为const引用。按引用传递对象的另外一个原因是,在继承使用虚函数时,被定义 为接受基类引用参数的函数可以接受派生类;
- 返回对象和返回引用。有些成员函数直接返回对 象,而另一些则返回引用。有时方法必须返回对象,但如果可以不返回对象,则应返回引用。如果函数返回在函数中创建的临时对象,则不要使用引用,而是返回该对象。如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象;
- 使用const。 使用const来确保方法不修改调用它的对象。如果函数将参数声明为指向const的引用或指针,则不能将该 参数传递给另一个函数,除非后者也确保了参数不会被修改(即,确保const对const)。
13.8.3 公有继承的考虑因素#
- is-a 关系。表示is-a关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。另外, 反过来是行不通的,即不能在不进行显式类型转换的情况下,将派生类指针或引用指向基类对象;
- 什么不能被继承:构造函数不能继承;析构函数不能继承;赋值运算符不能继承;
- 赋值运算符;
- 私有成员与保护成员;
- 虚方法。如果希望派生类 能够重新定义方法,则应在基类中将方法定义为虚的;如果不希望重新定义方法,则不必将其声明为虚的,这样虽然无法禁止他人重新定义方法,但表达了这样的意思:您不 希望它被重新定义;
- 析构函数;
- 友元函数。由于友元函数并非类成员,因此不能继承。然而,您可能希望派生 类的友元函数能够使用基类的友元函数。为此,可以通过强制类型转换将派生类引用或指针转换为基类引用或指针,然后使用转换后的指针 或引用来调用基类的友元函数:
- 关于使用基类方法的说明:
- 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定 义该方法;
- 派生类的构造函数自动调用基类的构造函数;
- 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数;
- 派生类构造函数显式地调用成员初始化列表中指定的基类构造函数;
- 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法;
- 派生类的友元函数可以通过强制类型转换,将派生类引用或指针转 换为基类引用或指针,然后使用该引用或指针来调用基类的友元函数。
13.8.4 类函数小结#
C++类函数有很多不同的变体,其中有些可以继承,有些不可以。 有些运算符函数既可以是成员函数,也可以是友元,而有些运算符函数 只能是成员函数。
13.9 总结#
继承通过使用已有的类(基类)定义新的类(派生类),使得能够 根据需要修改编程代码。基类的析构函数通常应当是虚的。
如果要将类用作基类,则可以将成员声明为保护的,而不是私有 的,这样,派生类将可以直接访问这些成员。
可以考虑定义一个ABC:只定义接口,而不涉及实现。例如,可以 定义抽象类Shape,然后使用它派生出具体的形状类,如Circle和 Square。ABC必须至少包含一个纯虚方法,可以在声明中的分号前面加 上=0来声明纯虚方法。
第十四章 C++中的代码重用#
本章内容包括:
- has-a 关系;
- 包含对象成员的类;
- 模板类valarray
- 私有和保护继承;
- 多重继承;
- 虚基类;
- 创建类模板;
- 使用类模板;
- 模板的具体化。
14.1 包含对象成员的类#
14.1.1 valarray类简介#
valarray类是由头文件 valarray
支持的。这个类用于处理数值,它支持诸如将数组中所有元素的值相加以及在数组中找出最大和最小的值等操作。valarray被定义为一个模板类,以便能够处理不同的数据类型。例子:
从中可知,可以创建长度为零的空数组、指定长度的空数组、所有元素度被初始化为指定值的数组、用常规数组中的值进行初始化的数组。
这个类有一些内置方法:
- operator:访问各个元素;
size()
: 返回包含的元素数;sum()
: 返回所有元素的总和;max()
: 返回最大的元素;min()
: 返回最小的元素。
等等,https://cplusplus.com/reference/valarray/valarray/ 。
14.1.2 Student类的设计#
14.2 私有继承#
C++还有另一种实现has-a关系的途径——私有继承。使用私有继 承,基类的公有成员和保护成员都将成为派生类的私有成员。这意味着 基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成 员函数中使用它们。
要进行私有继承,请使用关键字 private
而不是 public
来定义类(实 际上,private
是默认值,因此省略访问限定符也将导致私有继承)。
使用私有继承时,只能在派生类的方法中使用基类的方法。
用类名显式地限定函数名不适合于友元函数,这是因为友元不属于 类。然而,可以通过显式地转换为基类来调用正确的函数。
如果两个基类都提供了函数operator«( ),由于这个类使用的是多重继承,编译器将无法确定 应转换成哪个基类。
14.2.2 使用包含还是私有继承#
大多数C++程序员倾向于使用包含。首先,它易于理 解。类声明中包含表示被包含类的显式命名对象,代码可以通过名称引 用这些对象,而使用继承将使关系更抽象。其次,继承会引起很多问 题,尤其从多个基类继承时,可能必须处理很多问题,如包含同名方法 的独立的基类或共享祖先的独立基类。总之,使用包含不太可能遇到这 样的麻烦。另外,包含能够包括多个同类的子对象。如果某个类需要3 个string对象,可以使用包含声明3个独立的string成员。而继承则只能使 用一个这样的对象(当对象都没有名称时,将难以区分)。
然而,私有继承所提供的特性确实比包含多。例如,假设类包含保 护成员(可以是数据成员,也可以是成员函数),则这样的成员在派生 类中是可用的,但在继承层次结构外是不可用的。如果使用组合将这样 的类包含在另一个类中,则后者将不是派生类,而是位于继承层次结构 之外,因此不能访问保护成员。但通过继承得到的将是派生类,因此它 能够访问保护成员。
另一种需要使用私有继承的情况是需要重新定义虚函数。派生类可 以重新定义虚函数,但包含类不能。使用私有继承,重新定义的函数将 只能在类中使用,而不是公有的。
14.2.3 保护继承#
保护继承是私有继承的变体。保护继承在列出基类时使用关键字 protected
。使用保护继承时,基类的公有成员和保护成员都将成为派生类的保护成员。
14.2.4 使用using重新定义访问权限#
使用保护派生或私有派生时,基类的公有成员将成为保护成员或私 有成员。假设要让基类的方法在派生类外面可用,方法之一是定义一个使用该基类方法的派生类方法。
另一种方法是,将函数调用包装在另一个函数调用中,即使用一个 using声明(就像名称空间那样)来指出派生类可以使用特定的基类成 员,即使采用的是私有派生。
14.3 多重继承#
【短期内不会用到,多重继承、模板类和虚基类等没细看。】
14.5 总结#
C++提供了几种重用代码的手段。无论使用哪种继 承,基类的公有接口都将成为派生类的内部接口。这有时候被称为继承 实现,但并不继承接口,因为派生类对象不能显式地使用基类的接口。 因此,不能将派生对象看作是一种基类对象。
还可以通过开发包含对象成员的类来重用类代码。这种方法被称为 包含、层次化或组合,它建立的也是has-a关系。另一方面,如果需要使用某个类的几个对象,则用包含更适合。
多重继承(MI)使得能够在类设计中重用多个类的代码。
第十五章 友元、异常和其他#
本章内容包括:
- 友元类;
- 友元类方法;
- 嵌套类;
- 引发异常、
try
块和catch
块; - 异常类;
- 运行阶段类型识别(RTTI);
dynamic_cast
和typeid
;static_cast
、const_cast
和reiterpret_cast
。
2.bad_alloc异常和new
对于使用new导致的内存分配问题,C++的最新处理方式是让new引发bad_alloc异常。头文件new包含bad_alloc类的声明,它是从exception 类公有派生而来的。但在以前,当无法分配请求的内存量时,new返回 一个空指针,然后我们可以 exit(EXIT_FAILURE)
。
15.6 总结#
友元使得能够为类开发更灵活的接口。类可以将其他函数、其他类 和其他类的成员函数作为友元。在某些情况下,可能需要使用前向声 明,需要特别注意类和方法声明的顺序,以正确地组合友元。
嵌套类是在其他类中声明的类,它有助于设计这样的助手类,即实 现其他类,但不必是公有接口的组成部分。
第十六章 string类和标准模板库#
本章内容包括:
- 标准C++ string类;
- 模板auto_ptr、unique_ptr 和 shared_ptr;
- 标准模板库;
- 容器类;
- 迭代器
- 函数对象;
- STL算法
- 模板initializer_list
16.4.1 为何使用迭代器#
模板使得算法独立于存储的数据类型,而迭代器使算法独立于使用的容器类型。
第十七章 输入、输出和文件#
本章内容包括:
- C++ 角度的输入和输出;
- iostream 类系列;
- 重定向;
- ostream;
- 格式化输出;
- istream类方法;
- 流状态;
- 文件I/O;
- 使用 ifstream 类从文件输入;
- 使用 ofstream 类输出到文件;
- 使用 fstream 类进行文件输入和输出;
- 命令行处理;
- 二进制文件;
- 随机文件访问;
- 内核格式化。
17.1 C++输入和输出概述#
C++依赖于C++的I/O解决方案,而不是C语言的I/O解决方 案,前者是在头文件iostream(以前为iostream.h)和fstream(以前为 fstream.h)中定义一组类。
17.1.1 流和缓冲区#
C++程序把输入和输出看作字节流。输入时,程序从输入流中抽取字节;输出时,程序将字节插入到输出流中。
输入流中的字节可能来自键盘,也可能来自存储设备(如硬 盘)或其他程序。同样,输出流中的字节可以流向屏幕、打印机、存储 设备或其他程序。流充当了程序和流源或流目标之间的桥梁。
C++程序只是检查字节流,而不需要知道字节来自何方。同理,通过使 用流,C++程序处理输出的方式将独立于其去向。因此管理输入包含两 步:
- 将流与输入去向的程序关联起来;
- 将流与文件连接起来。
换句话说,输入流需要两个连接,每端各一个。文件端部连接提供了流的来源,程序端连接将流的流出部分转储到程序中(文件端连接可以是文件,也可以是设备,如键盘)。同样,对输出的管理包括将输出流连接到程序以及将输出目标与流关联起来。这就像将字节(而不是水)引入到水管中(参见图17.1)。
通常,通过使用缓冲区可以更高效地处理输入和输出。缓冲区是用作中介的内存块,它是将信息从设备传输到程序或从程序传输给设备的临时存储工具。
C++程序通常在用户按下回车 键时刷新输入缓冲区。对于屏幕输出,C++程序通 常在用户发送换行符时刷新输出缓冲区。程序也可能会在其他情况下刷新输入,例如输入即将到来时,这取决于实现。
17.1.2 流、缓冲区和iostream文件#
- cin对象对应于标准输入流;
- cout对象与标准输出流相对应;
- cerr对象与标准错误流相对应,可用于显示错误消息。这个流没有 被缓冲,这意味着信息将被直接发送给屏幕,而不会等到缓冲区填 满或新的换行符;
- clog对象也对应着标准错误流。在默认情况下,这个流被关联到标 准输出设备(通常为显示器),这个流被缓冲;
17.1.3 重定向#
标准输入和输出流通常连接着键盘和屏幕。重定向这个方法可以改变标准输入和标准输出。通过输入重定向(<
)和输出重定向(>
)可以将输入和输出重置为文件。在 UNIX和Linux中,运算符 2>
重定向标准错误。
17.2 使用cout进行输出#
17.2.2 其他ostream方法#
除了各种 operator<<()
函数外,ostream
类还提供了 put()
方法和write()
方法,前者用于显示字符,后者用于显示字符串。
在程序清单17.3的输出中各列并没有对齐,这是因为数字的字段宽度不相同。可以使用width成员函数将长度不同的数字放到宽度相同的字段中。
width()
方法只影响将显示的下一个项目,然后字段宽度将恢复为默认值:
|
|
输出:
12被放到宽度为12个字符的字段的最右边,这被称为右对齐。然后,字段宽度恢复为默认值,并将两个#符号以及24放在宽度与它们的长度相等的字段中。
- 设置填充字符
在默认情况下,cout用空格填充字段中未被使用的部分,可以用 fill( )成员函数来改变填充字符。例如,下面的函数调用将填充字符改为星号:
|
|
- 设置浮点数的显示精度
C++的默认精度为6位(但末尾的0将不显 示)。precision()
成员函数使得能够选择其他值。
- 在谈
setf()
17.3 使用 cin
进行输入#
17.3.3 其他istream类方法#
get( )
和 getline( )
方法:
- 函数
get(char&)
和get(void)
提供不跳过空白的单字符输入功能; - 函数
get(char*, int, char)
和getline(char*, int, char)
在默认情况下读取整行而不是一个单词。
这里的重点是,通过使用 get(ch)
,代码读取、显示并考虑空格和可打印字符。
假设程序使用的是 >>
,那么代码将跳过空格,因此最后的输出就压缩成了:
IC++clearly.
并且程序由于>>
运算符跳过了最后的换行符,所以 while
循环不会结束。
到达文件尾后(不管是真正的文件尾还是模拟的文件尾), cin.get(void)
都将返回值 EOF
——头文件 iostream
提供的一个符号常量。
这里应将 ch
的类型声明为 int
,而不是 char
,因为值 EOF
可能无法使 用 char
类型来表示。
2.采用哪种单字符输入形式
假设可以选择 >>
、get(char &)
或 get(void)
,应使用哪一个呢? 首先,应确定是否希望跳过空白。如果跳过空白更方便,则使用抽取运算符 >>
。
如果希望程序检查每个字符,则使用 get()
方法,例如,计算字数的程序可以使用空格来判断单词何时结束。
在 get()
方法中,get(char &)
的接口更佳。get(void)
的主要优点是,它与标准C语言中的 getchar()
函数极其类似,这意味着可以通过包含iostream
(而不是 stdio.h
),并用 cin.get()
替换所有的 getchar()
,用 cout.put(ch)
替换所有的 putchar(ch)
,来将C程序转换为C++程序。
3.字符串输入:getline()
、get()
和 ignore()
getline( )成员函 数和get( )的字符串读取版本都读取字符串,它们的函数特征标相同(这 是从更为通用的模板声明简化而来的):
第一个参数是用于放置输入字符串的内存单元的地址。第二个参数比要读取的最大字符数大1(额外的一个字符用于存储结尾的空字符, 以便将输入存储为一个字符串)。第三个参数指定用作分界符的字符, 只有两个参数的版本将换行符用作分界符。上述函数都在读取最大数目的字符或遇到换行符后为止。
get()
和 getline()
之间的主要区别在于,get()
将换行符留在输入流中,这样接下来的输入操作首先看到是将是换行符, 而 gerline()
抽取并丢弃输入流中的换行符。
第三个参数用于指定分界符,遇到分界字符后, 输入将停止,即使还未读取最大数目的字符。在默认情况下,如果在读取指定数目的字符之前到达行尾,这两种方法都将停止读取输 入。并且,get()
将分界字符留在输入队列中,而getline()
不保留。
ignore()
成员函数,该函数接受两个参数:一个是数字,指定要读取的最大字符数;另一个是字符,用作输入分界符。下面的函数调用读取并丢弃接下来的255个字符或直到到达第一个换行符:
|
|
原型为两个参数提供的默认值为1和 EOF
, 该函数的返回类型为 istream &
。
- 意外字符串输入
get(char *, int)
和 getline( )
的某些输入形式将影响流状态。与其他输 入函数一样,这两个函数在遇到文件尾时将设置 eofbit
,遇到流被破坏 (如设备故障)时将设置 badbit
。另外两种特殊情况是无输入以及输入到达或超过函数调用指定的最大字符数。
假设输入队列中的字符数等于或超过了输入方法指定的最大字 符数。首先,来看getline()
和下面的代码:
|
|
getline( )方法将从输入队列中读取字符,将它们放到temp数组的元 素中,直到(按测试顺序)到达文件尾、将要读取的字符是换行符或存 储了29个字符为止。如果遇到文件尾,则设置eofbit;如果将要读取的 字符是换行符,则该字符将被读取并丢弃;如果读取了29个字符,并且下一个字符不是换行符,则设置failbit。因此,包含30个或更多字符的输入行将终止输入。
get(char *, int)
方法首先测试字符数,然后测试是否为 文件尾以及下一个字符是否是换行符。如果它读取了最大数目的字符,则不设置 failbit
标记。然而,由此可以知道终止读取是否是由于输入字 符过多引起的。可以用 peek()
(下一节)来查看下一个输入字符。 如果它是换行符,则说明 get()
已读取了整行;如果不是换行符,则说明 get()
是在到达行尾前停止的。这种技术对getline()
不适用,因为 getline()
读取并丢弃换行符,因此查看下一个字符无法知道任何情况。然而, 如果使用的是 get()
,则可以知道是否读取了整个一行。
与 getline()
和 get()
不同的是,read()
不会在输入后加上空值字符, 因此不能将输入转换为字符串。read()
方法不是专为键盘输入设计的, 它最常与 ostream write()
函数结合使用,来完成文件输入和输出。该方法的返回类型为 istream &
。
17.4 文件输入和输出#
重定向虽然可以提供一些文件 支持,但它比显式程序中的文件I/O的局限性更大。另外,重定向来自操作系统,而非C++,因此并非所有系统都有这样的功能。
要写入文件,需要创建一个ofstream
对象,并使用 ostream
方法,如 <<
插入运算符或 write()
。要读取文件,需要创建一个 ifstream
对象,并使用 istream
方法,如 >>
抽取运算符或 get()
。
17.4.1 简单的文件 I/O#
程序写文件需要包含头文件 fstream
,并这样做:
- 声明并创建一个
ofstream
对象来管理输出流; - 将该对象和特定文件关联起来,
open
方法; - 以 cout 的方式使用该对象,区别是输出将进入文件,而不是屏幕。
ostream
是 ofstream
类的基类,因此可以使用所有的 ostream
方法,包括各种插入运算符定义、格式化方法和控制符。ofstream
类使用被缓冲的输出,因此程序在创建像 fout
这样的 ofstream
对象时,将为输出缓冲区分配空间。如果创建了两个 ofstream
对象,程序将创建两个缓冲区,每个对象各一个。
程序读取文件的要求与写入文件相似:
- 创建一个
ifstream
对象来管理输入流; - 将该对象与特定的文件关联起来,
open()
方法; - 以使用
cin
的方式使用该对象。
输入和输出一样,也是被缓冲的,因此创建ifstream对象与fin一 样,将创建一个由fin对象管理的输入缓冲区。与输出一样,通过缓冲, 传输数据的速度比逐字节传输要快得多。
当输入和输出流对象过期(如程序终止)时,到文件的连接将自动 关闭。另外,也可以使用 close()
方法来显式地关闭到文件的连接。
关闭这样的连接并不会删除流,而只是断开流到文件的连接,但流管理装置仍被保留。关闭文件将刷新缓冲区,从而确保文件被更新。
17.4.2 流状态检查和 is_open()
#
如果一切顺利,则流状态为零(没有消息就是好消息)。其他状态都是通过将特定位设置为1来记录的。试图打开一个不存在的文件进行 输入时,将设置failbit位,因此可以这样进行检查:
|
|
由于ifstream对象和istream对象一样,被放在需要bool类型的地方 时,将被转换为bool值,因此您也可以这样做:
|
|
较新的C++实现提供了一种更好的检查文件是否被打开的方 法——is_open()
方法:
|
|
这种方式之所以更好,是因为它能够检测出其他方式不能检测出的 微妙问题:
17.4.3 打开多个文件#
可能要依次处理一组文件。例如,可能要计算某个名称在10 个文件中出现的次数。在这种情况下,可以打开一个流,并将它依次关 联到各个文件。这在节省计算机资源方面,比为每个文件打开一个流的效率高。 使用这种方法,首先需要声明一个ifstream
对象(不对它进行初始化),然后使用 open()
方法将这个流与文件关联起来。例如,下面 是依次读取两个文件的代码:
17.4.4 命令行处理技术#
文件处理程序通常使用命令行参数来指定文件。命令行参数是用户 在输入命令时,在命令行中输入的参数。
C++有一种让在命令行环境中运行的程序能够访问命令行参数的机制,方法是使用下面的 main()
函数:
|
|
argc
为命令行中的参数个数,其中包括命令名本身。argv
变量为一个指针,它指向一个指向 char
的指针。例如:argv[0]
是一个指针,指向存储第一个命令行参数的字符串的第一个字符,依此类推。也就是说, argv[0]
是命令行中的第一个字符串,依此类推。例子:
|
|
则 argc
为4,argv[0]
为 wc
,argv[1]
为 report1
,依此类推。
17.4.5 文件模式#
文件模式描述的是文件将被如何使用:读、写、追加等。将流与文件关联时(无论是使用文件名初始化文件流对象,还是使用 open()
方法),都可以提供指定文件模式的第二个参数,下表给出了第二个参数的具体信息。
|
|
注意,C++ mode 是一个open mode值,如 ios_base::in
;而 c mode是相 应的 C模式字符串,如“r”。
17.4.6 随机存取#
随机存取指的是直接移 动(不是依次移动)到文件的任何位置。
使用临时文件#
开发应用程序时,经常需要使用临时文件,这种文件的存在是短暂的,必须受程序控制。创建临时文件、复制另一个文件的内容并 删除文件其实都很简单。首先,需要为临时文件制定一个命名方案,但如何确保每个文件都 被指定了独一无二的文件名呢?cstdio
中声明的 tmpnam()
标准函数可以帮助您。
|
|
tmpnam()
函数创建一个临时文件名,将它放在 pszName
指向的C-风格字符串中。常量 L_tmpnam
和 TMP_MAX
(二者都是在cstdio中定义的)限制了文件名包含的字符数以及在确保当前目录中不生成重复文件名的情况下 tmpnam()
可被调用的最多次数。下面是生成10个临时 文件名的代码:
|
|
更具体地说,使用tmpnam( )可以生成TMP_NAM个不同的文件名,其中每个文件名包含 的字符不超过L_tmpnam个。生成什么样的文件名取决于实现。
17.5 内核格式化#
17.6 总结#
流是进出程序的字节流。缓冲区是内存中的临时存储区域,是程序 与文件或其他I/O设备之间的桥梁。
istream类定义了多个版本的抽取 运算符(»),用于识别所有基本的C++类型,并将字符输入转换为这 些类型。get( )方法族和getline( )方法为单字符输入和字符串输入提供了 进一步的支持。同样,ostream类定义了多个版本的插入运算符 («),用于识别所有的C++基本类型,并将它们转换为相应的字符输 出。put( )方法对单字符输出提供了进一步的支持。wistream和wostream 类对宽字符提供了类似的支持。
fstream文件提供了将iostream方法扩展到文件I/O的类定义。ifstream 类是从istream类派生而来的。通过将ifstream对象与文件关联起来,可 以使用所有的istream方法来读取文件。同样,通过将ofstream对象与文 件关联起来,可以使用ostream方法来写文件;通过将fstream对象与文件 关联起来,可以将输入和输出方法用于文件。
要将文件与流关联起来,可以在初始化文件流对象时提供文件名, 也可以先创建一个文件流对象,然后用open( )方法将这个流与文件关联 起来。close( )方法终止流与文件之间的连接。类构造函数和open( )方法 接受可选的第二个参数,该参数提供文件模式。
seekg()
和 seekp()
函数提供对文件的随机存取。这些类方法使得能够将文件指针放置到相对于文件开头、文件尾和当前位置的某个位置。tellg()
和tellp()
方法报告当前的文件位置。
第十八章 探讨C++新标准#
(暂时不看)