C++ Primer Plus学习记录
Cleo Lv2

C++ Primer Plus

第1章 预备知识

1.1 C++简介

C++融合了3种编程方式:

  • 面向过程编程
  • 面向对象编程
  • 泛型编程

1.2 C++简史

1.2.1 C语言

汇编语言是一种低级语言,可以直接控制硬件,速度快,但依赖于特定的处理器所支持的指令集,可移植性差。高级语言致力于解决问题,而不针对特定的硬件。C语言将低级语言的效率、硬件访问能力和高级语言的通用性、可移植性融合在一起,依赖于不同平台的编译器将C语言源代码翻译为特定机器上的内部代码。

1.2.2 C语言编程原理

程序 = 数据 + 算法。

过程化编程、结构化编程、自顶向下的设计。

1.2.3 面向对象编程

OOP强调数据,其理念是设计与问题的本质特性相对应的数据格式,将数据和方法合并为类定义,有助于创建可重用的代码。

类、对象、抽象、封装、继承、多态。

1.2.4 C++和泛型编程

OOP强调的是编程的数据方面,而泛型编程强调的是独立于特定数据类型。

1.2.5 C++的起源

C++诞生于贝尔实验室,Bjarne Stroustrup于20世纪80年代开发出C++。

1.3 可移植性和标准

  • C++98
  • C++03
  • C++11

1.4 程序创建的技巧

image-20210322102326322

1.4.1 预处理

预处理用于处理宏定义指令、条件编译指令、头文件包含指令等,从源文件生成.i文件。

1
g++ -E hello.cpp -o hello.i

1.4.2 编译

编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。

1
g++ -S hello.i -o hello.s

1.4.3 汇编

汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个C/C++语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。

1
g++ -c hello.s -o hello.o

1.4.4 链接

由汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。所有的这些问题,都需要经链接程序的处理方能得以解决。

链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。

1
g++ hello.o -o hello(.exe)

1.5 总结

​ 随着计算机的功能越来越强大,计算机程序越来越庞大而复杂。为应对这种挑战,计算机语言也到了改进,以便编程过程更为简单。C语言新增了诸如控制结构和函数等特性,以便更好地控制程序流程,支持结构化和模块化程度更高的方法;而C++增加了对面向对象编程和泛型编程的支持,这有助于提高模块化和创建可重用代码,从而节省编程时间并提高程序的可靠性。

第2章 开始学习C++

2.1 进入C++

2.1.1 main函数

image-20210322105440372

main()函数被启动代码调用,而启动代码是由编译器添加到程序中的,是程序和操作系统之间的桥梁。main()函数的函数头描述的是main()和操作系统之间的接口。

1
main()     // original style

在C语言中,省略返回类型相当于说函数的类型为int,然而C++逐步淘汰了这种用法。

有些程序员使用下面的函数头,并省略返回语句:

1
void main()

这在逻辑上是一致的,因为void返回类型意味着函数不返回任何值。该变体适用于很多系统,但由于它不是当前标准强制的一个选项,因此在有些系统上不能工作。因此,应该避免使用这种格式,而应使用C++标准格式。

此外,如果编译器到达main()函数末尾时没有遇到返回语句,则认为main()函数以如下语句结尾:

1
return 0;

2.1.2 C++注释

  • 单行注释

    1
    // ....
  • 多行注释

    1
    /* ... */

2.1.3 #include \

预处理过程将iostream的文件内容替换这一行内容。

2.1.4 using namespace std

using编译指令使得命名空间std中的所有名称都可以直接使用,而不用加std::前缀。还可以有以下用法:

1
2
3
using std::cin;
using std::cout;
using std::endl;

2.1.5 cout

image-20210322111235070

2.2 C++语句

2.2.1 声明语句

1
int carrots;

声明语句提供两项信息:

  1. 需要的内存空间
  2. 给存储单元指定名称

程序中的声明语句叫做定义声明(defining declaration)语句,简称为定义(definition),这意味着它将导致编译器为变量分配内存空间。在较为复杂的情况下,还可能有引用声明(reference declaration),这些声明命令计算机使用在其他地方定义的变量。声明不一定是定义。

Hint: 对于声明变量,C++的做法是尽可能在首次使用变量前声明它。

2.2.2 赋值语句

1
carrots = 25;

=为赋值运算符,赋值语句将值赋给存储单元,赋值将从右向左进行。

2.3 其他C++语句

cin不读取空白字符,需要使用cin.get()处理回车。

cin是istream类的对象,cout是ostream类的对象。对象之于类,如同变量之于类型。类描述了一种数据类型的全部属性(包括可使用它执行的操作),对象是根据这些描述创建的实体。

image-20210322113721880

2.4 函数

image-20210322113749849

image-20210322113806265

2.4.1 函数原型

C++程序应当为程序中使用的每个函数提供原型。在使用函数之前,C++编译器必须知道函数的参数类型和返回值类型。也就是说,函数是返回整数、字符、小数、有罪裁决还是别的什么东西?如果缺少这些信息,编译器将不知道如何解释返回值。C++提供这种信息的方式就是使用函数原型语句。

2.4.2 自定义函数

image-20210322121803780

2.5 总结

C++多种类型的语句:

  • 声明语句:声明函数中使用的变量的名称和类型。
  • 赋值语句:使用赋值运算符=给变量赋值。
  • 消息语句:将消息发送给对象,激发某种行动。
  • 函数调用:执行函数。被执行的函数执行完毕后,程序返回到函数调用语句后面的语句。
  • 函数原型:声明函数的返回类型、函数接受的参数数量和类型。
  • 返回语句:将一个值从被调用的函数那里返回到调用函数中。

第3章 处理数据

本章介绍C++内置的数据类型。内置的C++类型分为两组:基本类型和复合类型。本章介绍基本类型,即整数和浮点数。

3.1 简单变量

为把信息存储在计算机中,程序必须记录3个基本属性:

  • 存储何种类型的信息
  • 信息将存储在哪里
  • 要存储什么值
1
2
int braincount;
braincount = 5;

这些语句告诉程序,它正在存储整数,并使用名称braincount来表示该整数的值(这里为5)。实际上,程序将找到一块能够存储整形的内存,将该内存单元 标记为braincount,并将5复制到该内存单元中,然后你可以通过braincount来访问该内存单元。

3.1.1 变量名

C++命名规则:

  • 在名称中只能使用数字、字母、下划线_。
  • 名称的第一个字母不能是数字。
  • 名称区分大小写。
  • 不能将C++关键字用作名称。
  • 以两个下划线或下划线和大写字母打头的名称被保留给实现(编译器以及其使用的资源)使用。以一个下划线开头的名称被保留给实现,用作全局标识符。
  • C++对于名称的长度没有限制,名称中的所有字符都有意义,但有些平台有长度限制。

关于倒数第二点,像__time_stop或_Donut这样的名称不会导致编译器错误,而会导致行为的不确定性。不出现编译器错误的原因是,这样的名称不是非法的,但要留给实现使用。

至于变量命名风格,由雇主决定。

3.1.2 整型

C++基本整型(按宽度递增的顺序排列)分别是char, short, int, long, long long。其中每种类型都有符号版本和无符号版本。char类型较为特殊,用于表示字符。

C++提供了一种灵活的标准,确保了最小长度,如下所示:

  • short 至少16位
  • int 至少与 short 一样长
  • long 至少32位,且至少与 int 一样长
  • long long 至少64位,且至少与 long 一样长

通常来讲,char为8位,short为16位,int为32位,long在windows上为32位,在Linux上跟随系统的位数,long long为64位。

以上均为符号类型,意味着可以取正,也可以取负。取值范围从$-2^{n-1}$到$2^{n-1}-1$,$n$为位数。无符号版本的取值范围为$0$到$2^{n}-1$。

头文件climits提供了关于整型限制信息,包括位数、取值范围。

符号常量 表示
CHAR_BIT char的位数
CHAR_MAX char的最大值
CHAR_MIN char的最小值
SCHAR_MAX signed char的最大值
SCHAR_MIN signed char的最小值
UCHAR_MAX unsigned char的最大值
SHRT_MAX short的最大值
SHRT_MIN short的最小值
USHRT_MAX unsigned short的最大值
INT_MAX int的最大值
INT_MIN int的最小值
UINT_MAX unsigned int的最大值
LONG_MAX long的最大值
LONG_MIN long的最小值
ULONG_MAX unsigned long的最大值
LLONG_MAX long long的最大值
LLONG_MIN long long的最小值
ULLONG_MAX unsigned long long的最大值

溢出行为:

image-20210322201737608

3.1.3 初始化

初始化将声明与赋值结合在一起。C++三种初始化语法:

  1. 等号初始化

    1
    2
    3
    4
    int n_int = INT_MAX;
    int uncles = 5;
    int aunts = uncles;
    int chairs = aunts + uncles + 4;
  2. 小括号初始化

    1
    int wrens(432);
  3. 大括号初始化器(C++11)

    1
    2
    3
    4
    int hamburgers = {24};  // set humbrugers to 24
    int emus{7}; // set emus to 7
    int rocs = {}; // set rocs to 0
    int psychics{}; // set psychics to 0

    大括号初始化器既可用于单值变量,也可用于数组、结构体,更广泛地讲,可以用于任何类型。可以使用等号,也可以不使用等号。

3.1.4 整型字面值

整形字面值(常量)是显示地书写地常量,如212或176。C++整型的三种不同的计数方式:

  • 十进制
  • 八进制,以数字0开头
  • 十六进制,以0X或0x开头

cout对象可以调整输出格式(默认情况下以十进制输出):

  • 十进制:dec
  • 八进制:oct
  • 十六进制:hex

3.1.5 C++如何确定常量的类型

通常情况下,一个整数如1492,编译器将其视为int类型。

特殊后缀:l或L表示long, ll或LL表示long long,ul或UL表示unsigned long, ull或ULL表示unsigned long long。

考察长度(对于以下规则,现在的编译器好像已经不太一样):

  • 对于不带后缀的十进制,使用下面几种类型中能够存储该数的最小类型来表示:int、long或long long。
  • 对于不带后缀的八进制或十六进制,使用下面几种类型中能够存储该数的最小类型来表示:int、unsigned int、long、unsigned long、long long或unsigned long long。

3.1.6 char类型

char类型归为整型中,占8位即1字节,存储的整型范围从$-128$到$127$,为符号类型,也有无符号类型,取值范围从$0$到$255$。char即可表现为signed char,也可表现为unsigned char,通常情况下表现为signed char,但具体取决于实现。char类型在内存中存储的值为整型,但输出时cout将其转换为ASCII码表中的对应字符。在标准输出输出一个字符使用cout.put()。

3.1.6.1 转义字符

image-20210322205656252

3.1.6.2 通用字符名

C++有一种表示特殊字符的机制,它独立于任何特定的键盘,使用的是通用字符名(universal character name)。通用字符名类似于转义序列,以\u或\U打头,\u后接4位16进制数,\U后接8位16进制数,这些位表示的是字符的ISO 10646码点(Unicode的一种实现)。

1
2
int k\u00F6rper;
cout << "Let them eat g\u00E2teau.\n";
3.1.6.3 wchar_t

程序需要处理的字符集可能无法用一个8位的字节表示,如日文汉字系统。对于这种情况,C++的处理方式有两种。

  • 如果大型字符集是实现的基本字符集,则编译器厂商可以将char定义为一个16位的字节或更长的字节。
  • 可以同时实现一个支持小型基本字符集和一个较大的扩展字符集。8位的char可以表示基本字符集,另一种类型wchar_t(宽字符类型)可以表示扩展字符集。

wchar_t类型是一种整数类型,它有足够的空间,可以表示系统使用的最大扩展字符集。这种类型与另一种整型(底层(underlying)类型)的长度和符号属性相同。对底层类型的选择取决于实现,因此在一个系统中,它可能是unsigned short,而在另一个系统中,则可能是int。

cin和cout将输入和输出看作是char流,因此不适于用来处理wchar_t类型。iostream头文件的最新版本提供了作用相似的工具——wcin和wcout,可以用于处理wchar_t流。另外,可以通过加上前缀L来知识宽字符常量和宽字符串。

1
2
wchar_t bob = L'P';           // a wide-character constant
wcout << L"tall" << endl; // outputting a wide-character string
3.1.6.4 char16_t和char32_t

wchar_t的长度和符号特征随实现而异。C++11新增了类型char16_t和char32_t,均为无符号的,长分别为16位和32位。C++11使用前缀u表示char16_t字符常量和字符串常量,如u'C'和u"be good";并使用前缀U表示char32_t常量,如U'R'和U"dirty rat"。类型char16_t与\u00F6形式的通用字符名匹配,而char32_t与\U0000222B形式的通用字符名匹配。

1
2
char16_t ch1 = u'q';
char32_t ch2 = U'\U0000222B';

3.1.7 bool类型

布尔类型的变量值可以为true或false。字面值true和false都可以通过提升转换为int类型,true被转换为1,而false被转换为0。

任何数字值或指针值都可以被隐式转换(即不用显示强制转换)为bool值,任何非零值都被转换为true,而零值被转换为false。

1
2
bool start = -100;   // start assigned true
bool stop = 0; // stop assigned false

3.2 const 限定符

除了#define预处理命令外,C++有一种更好的处理符号常量的方法,这种方法就是使用const关键字来修改变量声明和初始化。

1
const int Months = 12;

常量被初始化后,其值就被固定了,编译器将不允许修改该常量的值。关键字const叫做限定符,因为它限定了声明的含义。

使用const定义常量时必须在声明的同时进行初始化值。C++中可以使用const值来声明数组长度。

3.3 浮点数

3.3.1 浮点数的类型

C++有三种浮点类型:

  • float
  • double
  • long double

这些类型时按它们可以表示的有小数位和允许的指数最小范围来描述的。C和C++对于位数的要求是:float至少32位,double至少48位,且不少于float,long double至少和double一样多。这三种类型的位数可以一样多,然而,通常,float为32位,double为64位,long double为80、96或128位。另外,这三种类型的指数范围至少是-37到37。可以从头文件cfloat中找到系统的限制。float保证6位有效位数,double保证15位有效位数。

3.3.2 浮点数的优缺点

优点:

  • 能表示整数之间的数
  • 具有缩放因子,它们可以表示的范围大得多

缺点:

  • 运算速度比整数慢
  • 精度降低

3.3.3 类型分类

  • 算数类型
    • 整型
      • 有符号整形:signed char, short, int, long, long long,
      • 无符号整型:unsigned char, unsigned short, unsighed int, unsigned long, unsigned long long,char16_t,char32_t
      • 不带符号:bool, wchar_t
    • 浮点型:float, double, long double

3.4 C++算数运算符

5个基本的算术运算符:

  • +
  • -
  • *
  • \ :如果两个操作数都是整数,则结果位商的整数部分,小数部分被丢弃。
  • % :两个操作数必须为整数。结果的符号跟着左操作数。

优先级及结合性:

*、\、%位于同一优先级,+、-位于同一优先级且优先级更低,结合性均为从左往右。

3.5 类型转换

C++自动执行很多类型转换:

  • 将一种算术类型的值赋给另一种算术类型的变量时,C++将对值进行转换;
  • 表达式中包含不同的类型时,C+=将对值进行转换
  • 将参数传递给函数时,C++将对值进行转换

不同情况下的类型转换:

  1. 初始化和赋值进行的转换

    C++允许将一种类型的值赋给另一种类型的变量,这样做时,值将被转换为接受变量的类型。将一个值赋给取值范围更大的类型通常不会导致什么问题,只是占用更多的字节而已。然而,将一个很大的long值(如2111222333)赋给float变量将降低精度,因为float只有6位有效数字。因此有些转换是安全的,有些则会带来麻烦。

    | 转换 | 潜在的问题 |
    | —————————————————————————————- | —————————————————————————————— |
    | 将较大的浮点类型转换为较小的浮点类型,如将double转换为float | 精度(有效数位)降低,值可能超出目标类型的取值范围,在这种情况下,结果是不确定的 |
    | 将浮点类型转换为整型 | 小数部分丢失,原来的值可能超出目标类型的取值范围,在这种情况下,结果是不确定的 |
    | 将整型转换为浮点类型 | 可能会降低精度 |
    | 将较大的整型转换为较小的整型,如将long转换为short | 原来的值可能超出目标类型的取值范围,通常只赋值右边的字节 |

    将0赋给bool变量时,将被转换为false;而非零值将被转换为true。

  2. 以{}方式初始化时进行的转换(C++11)

    C++将使用大括号的初始化称为初始化列表(list-initialization),因为这种初始化常用于给复杂的数据类型提供值列表。

    列表初始化不允许缩窄(narrowing),即变量的类型可能无法表示赋给它的值。例如,不允许将浮点型转换为整型。在不同的整型之间的转换或将整型转换为浮点型可能被允许,条件是编译器知道目标变量能够正确地存储赋给它地值。例如,可将long变量初始化为int值,因为long总是至少与int一样长;相反方向的转换可能被允许。只要int能够存储赋给它的long常量。

    image-20210325110435608

  3. 表达式中的转换

    当一个表达式中包含两种不同的算数类型时:

    • 一些类型在出现时便会自动转换

      在计算表达式时,C++将bool、char、unsigned char、signed char 和 short 值转换为 int。这些转换被称为整型提升。

      1
      2
      3
      short chickens = 20;
      short ducks = 35;
      short fowl = chickens + ducks;

      第三条语句执行时,先将chickens和ducks提升为整型,做完加操作后转换为short类型赋给fowl。

      C++11版本校验表:

      (1) 如果有一个操作数的类型是long double,则将另一个操作数转换为long double。

      (2) 否则,如果有一个操作数的类型是double,则将另一个操作数转换为double。

      (3) 否则,如果有一个操作数类型是float,则将另一个操作数类型转换为float。

      (4) 否则,说明操作数都是整型,因此执行整型提升。

      (5) 在这种情况下,如果两个操作数都是有符号的或无符号的,且其中一个操作数的级别比另一个低,则转换为级别高的类型。

      (6) 如果一个操作数为有符号,另一个操作数为无符号的,且无符号操作数级别比有符号操作数级别高,则将有符号操作数转换为无符号操作数所属类型。

      (7) 否则,如果有符号类型可表示无符号类型的所有可能值,则将无符号操作数转换为有符号操作数所属类型。

      (8) 否则,将两个操作数都转换为有符号类型的无符号版本。

      所谓级别,对于有符号整型来讲从高到底以此为:long long, long, int, short, signed char。无符号与有符号的排列顺序相同。类型char、sighed char和unsigned char的级别相同。类型bool的级别最低。wchar_t、char16_t、char32_t的级别与其底层类型相同。

    • 有些类型在与其他类型同时出现在表达式中时将被转换

  4. 传递参数时的类型转换

    传递参数时的类型传递通常由C++函数原型控制。

  5. 强制类型转换

    1
    2
    typeName (value)  // C++
    (typeName) value // C

    C++的想法是,让强制类型转化像函数调用。

    此外,C++还引入了强制类型转换运算符,其中static_cast<>可用于将值从一种数值类型转换为另一种数值类型:

    1
    2
    3
    static_cast<long> (thorn)

    static_cast<typeName) (value) // converts value to typeName type

3.6 C++11 auto声明

C++11 新增语法,将auto重新定义,让编译器能够根据初始值的类型推断变量的类型。

第4章 复合类型

4.1 数组

声明数组的通用格式:

1
typeName arrayName[arraySize];

arraySize必须是整型常量或const值,也可以是常量表达式,即其中所有的值在编译时都是已知的,不能是变量,变量的值是在程序运行时设置的。

有效下标值的重要性:编译器不会检查使用的下标是否有效,但是在程序运行后,不合法的下标值将引发问题。

sizeof运算符用于数组名,得到的是整个数组中的字节数;用于数组元素,得到的是元素的长度(单位为字节)。

4.1.1 数组的初始化

1 - 初始化规则

数组名是一个只读变量,因此只能在定义数组时才能使用初始化,此后就不能用了,也不能将一个数组赋给另一个数组:

1
2
3
4
int cards[4] = {3, 6, 8, 10};   // okay
int hards[4]; // okay
hand[4] = {5, 6, 7, 9}; // not allowed, compile error
hand = cards; // not allowed, compile error

初始化数组时,如果提供的值少于数组的数目,则剩下的元素吧内编译器设置为0:

1
float hotelTips[5] = {5.0, 2.5};  // okay, the third and later are set to zero

如果初始化时不提供长度,编译器将自动计算长度。

1
short things[] = {1, 5, 3, 8};
2 - C++11初始化方法
  • 初始化数组时可以省略=。

  • 大括号内可以没有任何东西,这将把所有的元素设置为零。

  • 列表初始化禁止缩窄转换

    1
    2
    3
    long plifs[] = {25, 92, 3.0};               // not allowed, float -> int
    char slifs[4] = {'h', 'i', 1122011, '\0'}; // not allowed, int -> char
    char tlifs[4] = {'h', 'i', 112, '\0'}; // allowed

4.2 字符串

C++两种字符串:C风格字符串和string类库

C风格字符串使用字符数组存储,以\0标识字符串的结束。区分字符数组和字符串。处理字符串是根据空字符的位置,而不是数组的长度。

字符串常量实际表示的是字符串所在的内存地址,因此不能将字符串赋给字符变量。使用sizeof对字符串字面值进行运算将返回字符串所有字符的总字节数(包括末尾的\0),而不是一个指针的大小。

算字符串长度不把\0纳入长度计算中,数组的长度要大于等于strlen(str) + 1。

4.1 字符串输入

4.1.1 std::cin

cin读取输入时,以空白字符(空格、回车、换行等)为结束标志。当碰到空白字符时,cin停止读取,并将剩下的留在输入队列中,为之前读取的字符串加上\0。cin可以读取输入队列中留下的空字符并丢弃,cin.geline和cin.get做不到。

4.1.2 std::cin.getline()

cin.getline()函数读取整行,它使用通过回车键输入的换行符来确定输入结尾,然后丢弃换行符。该函数接受两个参数,一个是用来存储字符串的数组的名称,另一个是要读取的字符数,如果该参数为20,那最多读取19个字符,余下的空间用于存储自动在结尾处添加的空字符。函数在读取到指定数目的字符或遇到换行符时停止读取。

4.1.3 std::cin.get()

cin.get()是一个重载的函数,如果不提供任何参数,将读取一个字符并返回。如果第一个参数是字符串数组,第二个参数为读取的字符,则与cin.getline()一样,但是将回车保留在输入队列中,因此需要使用cin.get()进行处理。

4.1.4 空行和其他问题

getline遇到空行时当作空字符串处理,读取的字符串长度为0,第1个字符为\0。get()读取空行后将设置失效位(failbit),这意味着接下来的输入将被阻断,但可以使用cin.clear()来恢复。需要注意的是,即便恢复了输入,但是换行符还在队列中,如果不处理换行符,还是无法继续使用get(str, size)读取。

另一个潜在的问题是,输入字符串可能比分配的空间长。如果输入行包含的字符数比指定的多,则getline()和get()将把剩余的字符留在输入队列中,而getline()还会设置失效位,并关闭后面的输入。

4.3 string类简介

在很多方面,使用string对象的方式与使用C风格字符串的方式相同:

  • 可以使用C-风格字符串来初始化string对象
  • 可以使用cin来将标准输入存储到string对象中
  • 可以使用cout来显示string对象
  • 可以使用数组表示法来访问存储在string对象中的字符

string对象声明为简单变量,而不是数组。类设计让程序能够自动处理string的大小,使用string对象更方便,也更安全。

4.3.1 复制、拼接、附加和长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <cstring>
#include <string>

int main()
{
using namespace std;
char charr1[20];
char charr2[20] = "jaguar";
string str1;
string str2 = "panther"; // 赋值

str1 = str2; // 复制
strcpy(charr1, charr2);

str1 += " paste"; // 附加
strcat(charr1, " juice");

int len1 = str1.size(); // 长度
int len2 = strlen(charr1);

cout << "The string " << str1 << " contains " << len1 << " characters.\n";
cout << "The string " << charr1 << "contains " << len2 << " characters.\n";

return 0;
}

4.3.2 string的I/O操作

getline函数从输入流读取一行放入string对象中,且丢弃结尾的换行符。使用getline函数读取输入字符串到string对象无需担心长度的问题。

4.3.3 其他形式的字符串

  • wchar_t

  • char16_t

  • char32_t

    1
    2
    3
    wchar_t title[] = L"Chief Astrogator";
    char16_t name[] = u"Felonia Ripova";
    char32_t car[] = U"Humber Super Snipe";
  • Unicode字符编码方案UTF-8

    在这种方案中,根据编码的数字值,字符可能存储为1~4个八位组。C++使用前缀u8来表示这种类型的字符串字面值。

  • 原始(raw)字符串

    cout << R"(Jim "King" Tutt uses "\n" instead of endl.)" << '\n';将显示Jim "King" Tutt uses \n instead of endl.

    也就是说R"(......)"是原始字符串的标志,如果想在字符串中显示)",可以使用R"+*(......)+*"。这是自定义定界符,自定义定界符时,在默认定界符之间添加任意数量的基本字符,但空格、左括号、右括号、斜杠和控制字符(如制表符和换行符)除外。

    可将前缀R与其他字符串前缀结合使用,以标识wchar_t等类型的原始字符串,即RL或LR。

4.4 结构体简介

一个结构体可以存储多种类型的数据。C++允许在声明结构体变量时省略关键字struct。定义结构体是在创造一种新的数据类型。

定义结构体的描述可以在外部,也可以在函数内部,后者只能在该函数中使用该结构体。C++不提倡使用外部变量,但提倡使用外部结构声明。

4.4.1 结构体变量的初始化

使用初始化列表初始化:

1
2
3
4
5
6
7
8
9
10
struct inflatable
{
char name[20];
float volume;
double price;
};

inflatable a = {"Carlos Leo", 30.4f, 20.0};
inflatable b {"Van Gandi", 45, 23};
inflabable c {};

大括号内未包含任何东西时,各成员都将被设置为0,name的每个字节都将设置为0,volume和price都被设置为0。

4.4.2 结构属性

  • .访问结构体成员

  • =可以赋值,这样结构中的每个成员都将设置为另一个结构中相应成员的值,即使成员是数组。注意,它并不是简单的对每个成员赋值,而是深拷贝。

  • 参数传递

  • 返回值

  • 同时完成结构定义和创建结构变量以及结构变量初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct perks
    {
    int key_number;
    char car[12];
    }mr_glitz =
    {
    7,
    "Packard"
    };

C++结构体的特性相比于C结构体的特性要更多,如C++结构体除了成员变量之外,还可以有成员函数。

4.4.3 结构体内存对齐

结构体内存对齐的三条原则:

(1)结构体变量的起始地址能够被其最宽的成员大小整除。

(2)结构体每个成员相对于起始地址的偏移能够被其自身大小整除,如果不能则在前一个成员后面补充字节。

(3)结构体总体大小能够被最宽的成员的大小整除,如不能则在后面补充字节。

空结构体的占1个字节的空间。

4.4.4 结构体中的位字段

与C语言一样,C++也允许指定占用特定位数的结构成员,这使得创建与某个硬件设备上的寄存器对应的数据结构非常方便。字段的类型应为整型或枚举,接下来是冒号,冒号后面是一个数字,它指定了使用的位数。可以使用没有名称的字段来提供间距。每个成员都被称为位字段(bit field)。下面是一个例子:

1
2
3
4
5
6
7
struct torgle_register
{
unsigned int SN : 4;
unsigned int : 4;
bool goodIn : 1;
bool goodTorgle : 1;
};

可以像通常那样初始化这些字段,还可以使用标准的结构表示法来访问位字段:

1
2
3
4
torgle_register tr = { 14, true, false };
...
if (tr.goodIn)
...

位字段通常用在低级编程中。

4.5 共用体

共用体(union)是一种数据格式,它能够存储不同的数据类型,但只能同时存储其中一种类型。

1
2
3
4
5
6
union one4all
{
int int_val;
long long_val;
double double_val;
};

共用体每次只能存储一个值,因此它必须有足够的空间来存储最大的成员,所以,共用体的长度成为其最大成员的长度。

匿名共用体没有名称,通常定义在结构体内,由于共用体成员的地址相同,不需要使用中间标识符。共用体常用于节省内存。

4.6 枚举

枚举提供了一种定义符号常量的选择。定义枚举类型,是创建了一种新的类型,称为枚举类型,以及符号常量,称为枚举量。默认情况下,枚举量的值按照顺序从0开始,第一个枚举量的值为0,第二个枚举量的值为1,依此类推。也可以通过显示地指定整数值来覆盖默认值。可以使用枚举名作为类型名来声明变量:

1
2
3
enum spectrum {red, orange, yellow, green, blue, violet, indigo, ultraviolet};

spectrum band; // band is a variable of type spectrum

枚举变量具有一些特殊的性质:

  • 在不进行强制类型转换时,只能将定义枚举时使用的枚举常量赋值给这种枚举类型的变量

    1
    2
    band = orange;    // valid
    band = 2000; // invalid, compile error
  • 对于枚举,只定义了赋值运算符

    1
    2
    3
    band = orange;        // valid
    ++band; // invalid, compile error
    band = orange + red; // invalid, compile error
  • 枚举量是整型,因此可以提升为int类型,但int类型不能自动转换为枚举类型

    1
    2
    3
    4
    int color = blue;     // valid
    band = 3; // invalid, compile error
    color = 3 + red; // valid, red converted to int
    color = orange + red; // valid
  • 如果int的值是有效的,可以通过强制类型转换,将它赋给枚举变量。如果试图对一个不适当的值进行强制类型转换,结果是不确定的。

    1
    2
    band = spectrum(3);     // valid, typecast
    band = spectrum(40003); // undefined, maybe 40003

实际上,枚举更常用来定义相关的符号常量,而不是新类型。如果打算只使用常量,而不创建枚举类型的变量,则可以省略枚举类型的名称。

1
enum {red, orange, yellow, green, blue, violet, indigo, ultraviolet};

4.6.1 设置枚举量的值

可以使用赋值运算符来显示的设置枚举量的值,而且值可以是long或long long类型,但值只能是常量表达式,不能是变量。

1
enum bits {one = 1, two = 2, three, four = 4, eight = 8};

4.6.2 枚举的取值范围

每个枚举都有取值范围,通过强制类型转换,可以将取值范围中的任何整数值赋给枚举变量,即使这个值不是枚举量。给定如下定义:

1
enum bigstep { zero = -6, first, second = 100, third};

上限:大于枚举量最大值的最小的2的幂,再减去1。对于枚举类型bigstep,最大值为101(third),则上限为128 - 1,即127。

下限:如果最小枚举量的值大于或等于0,则下限为0。否则,取小于枚举量最小值的最大的2的幂,再加上1。对于枚举类型bigstep,最小值为-6(zero),则下限为-8 + 1,即-7。

至于使用sizeof运算符计算其内存空间大小,是根据最大值所对应的最小整型类型的字节数确定的。

4.7 指针和自由存储空间

Windows上,指针本身所占内存空间的大小取决于编译器是x86还是x64,二者分别为4字节和8字节。

警告:一定要在对指针应用解除引用运算符*之前,将指针初始化为一个确定的、适当的地址。编译器不会检查指针是否有被赋一个正确的地址。

指针不是整数!!!虽然计算机通常把地址当作整数来处理。从概念上看,指针与整数是截然不同的类型。指针描述的是位置,将两个地址相乘没有任何意义。因此,不能简单地将整数赋给指针。

1
2
3
int *pt;
pt = 0xB8000000; // 编译错误,类型不匹配
pt = (int *)0xB8000000; // 强制类型转换

4.7.1 new关键字

1
2
3
4
5
6
int main()
{
int *pi = new int;
double *pd = new double;
...
}

上面这段代码有几个要注意的点:

  • pi、pd这两个变量是局部变量,存储在栈上,而pi、pd所指向的内存空间存储在堆上。
  • pi和pd所占字节数均为指针所占字节数,即4字节或8字节,取决于编译器的架构。而pi和pd所指向的空间即*pi和*pd分别占4字节、8字节。

new关键字在内存中找到一段合适的内存空间并返回这段空间的首地址。

4.7.2 delete关键字

delete关键字用于释放使用new创建的内存空间,二者一定要配套使用,否则将发生内存泄漏。要注意一点:

1
2
int *pi = new int;
delete pi;

上面这段代码中delete只是释放了pi所指向的由new创建的内存空间,但pi这个变量本身并没有被删除。

几个注意点:

  • 不要尝试释放已经释放的内存块,C++标准指出,这样做的结果将是不确定的,这意味着什么情况都可能发生。
  • 不能使用delete来释放声明变量所获得的内存,只能释放使用new分配的内存。
  • 对空指针使用delete是安全的。

  • 使用delete的关键在于,将它用于new分配的内存块,也就该内存块的地址,并不是说一定要将delete用于使用new的指针。

    1
    2
    3
    int *ps = new int;
    int *pq = ps;
    delete pq; // delete with second pointer

4.7.3 使用new来创建动态数组

使用new来创建动态数组,不能使用sizeof来确定动态分配的数组包含的字节数。

数组名的值不能修改,(我个人猜测数组名实际上是一个const指针),但指针是变量,因此可以修改它的值。

4.8 指针、数组和指针算数

指针和数组基本等价的原因在于指针算数和C+=内部处理数组的方式。将整型变量加1,其值将加1。但指针变量加1后,增加的量等于它指向的类型的字节数。值得说明的一点是,C++将数组名解释为地址。

在多数情况下,C++将数组名解释为数组的第1个元素的地址。将指针变量加1后,其增加的值等于指向的类型占用的字节数。使用数组表示法表示数组元素时,C++都执行下面的转换:

1
arrayname[i] becomes *(arrayname + i)

使用指针而不是数组名访问数组元素时,C++也执行同样的转换:

1
pointername[i] becomes *(pointername + i)

数组名是常量。

使用sizeof对数组进行运算时,得到的是整个数组的内存大小,而使用sizeof对指针运算时,得到的是单个指针变量所占内存空间的大小。

4.8.1 数组的地址

对数组取地址时,数组名不会被解释为其地址。数组名被解释为第一个元素的地址,而对数组名运用地址运算符时,得到的时整个数组的地址:

1
2
3
short tell[10];
cout << tell << endl; // display &tell[0]
cout << &tell << endl; // display address of whole array

从数值上讲,这两个地址相同,但从概念上讲,&tell[0]是一个2字节内存块的地址,而&tell是一个20字节内存块的地址。因此tell = 1将地址值加2,而表达式&tell + 1将地址值加20。因此,tell是一个指向short的指针,而&tell是一个指向包含20个元素的数组的指针,而这样的指针必须使用下面的声明:

1
short (*pas)[20] = &tell;

如果省略括号,pas将先与[20]结合,导致pas成了一个数组,而数组的每个元素都是指向short的指针。

4.8.2 指针和C风格字符串

在cout和多数C++表达式中,char数组名、char指针以及用括号括起的字符串常量都被解释为字符串的第一个字符的地址。

使用cout输出字符数组、字符串常量、字符指针时,都将输出字符串的内容,为了输出字符串的地址,必须使用强制类型转换void *(int *)也可以。

将字符串读入程序时,应使用已分配的内存地址。该地址可以是数组名,也可以是使用new初始化过的指针。

4.8.3 自动存储、静态存储和动态存储

  • 自动存储

    在函数内部定义的常规变量使用自动存储空间,被称为自动变量(automatic variable),这意味着它们在所属的函数被调用时自动产生,在该函数结束时消亡。实际上,自动变量是一个局部变量,其作用域为包含它的代码块。

    自动变量通常存储在栈中。这意味着执行代码块时,其中的变量将依次加入到栈中,而在离开代码块时,将按相反的顺序释放这些变量,这被称为后进先出(LIFO)。因此,在程序执行的过程中,栈将不断地增大和缩小。

    栈帧的概念?

  • 静态存储

    静态存储是整个程序执行期间都存在的存储方式。使变量成为静态的方式有两种:一种是在函数外面定义它,另一种是在声明变量时使用关键字static。

  • 动态存储

    new和delete运算符提供了一种比自动变量和静态变量更灵活的方法。它们管理了一个内存池,这在C++中被称为自由存储空间(free space)或堆(heap)。该内存池同用于静态变量和自动变量的内存是分开的。数据的生命周期不完全受程序或函数的生存时间控制。

自动存储和静态存储的关键在于这些方法严格地限制了变量的寿命。变量可能存在于程序的整个生命周期,也可能只是在特定函数被执行时存在(自动变量)。new和delete让程序员对程序如何使用内存有更大的控制权。然而,内存管理也更复杂了。在栈中,自动添加和删除机制使得占用的内存总是连续的,但new和delete的相互影响可能导致占用的自由存储区不连续,这使得跟踪新分配内存的位置更困难。

image-20210327170624623

image-20210327170642931

4.8.4 类型组合

注意一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

struct antarctica_years_end
{
int year;
};

int main()
{
antarctica_years_end s01, s02, s03;
s01.year = 1998;
antarctica_years_end *pa = &s02;
pa->year = 1999;
antarctica_years_end trio[3];
trio[0].year = 2003;
cout << trio->year << endl;
const antarctica_years_end *arp[3] = {&s01, &s02, &s03}; // 指针数组
cout << arp[1]->year << endl;
const antarctica_years_end **ppa = arp; // 指向指针的指针
auto ppb = arp; // 自动类型推断
cout << (*ppa)->year << endl;
cout << (*(ppb + 1))->year << endl;
return 0;
}

4.9 数组的替代品

4.9.1 模板类vector

模板类vector类类似于string类,也是一种动态数组,可以在运行时阶段设置vector对象的长度,可在末尾附加新数据,还可以在中间插入新数据。实际上,vector使用new和delete来管理内存,但这种工作是自动完成的。

1
2
3
4
5
6
7
#include <vector>
using namespace std;
...
vector<int> vi; // create a zero-size array of int
int n;
cin >> n;
vector<double> vd(n); // create a n-size array of double

4.9.2 模板类array

如果您需要的是长度固定的数组,可以使用模板类array类。与数组一样,array对象的长度是固定,也使用栈,而不是自由存储区,因此效率与数组相同,但更方便,更安全。

1
2
3
4
5
#include <array>
using namespace std;
...
array<int, 5> ai; // create array object of 5 size
array<double, 4> ad = {1.2, 2.1, 3.43, 4.3};

数组长度不能是变量。

如果使用数组、array对象、vector对象都使用下标表示法取元素,则编译器均不检查错误的索引。但是array和vector提供了安全的访问访问方法:

1
ai.at(1) = 2.3;

使用at()时,将在运期间捕获非法引用,而程序默认中断。这种额外检查的代价是运行时间更长。

第5章 循环和关系表达式

5.1 表达式和语句

5.1.1 表达式

任何值或任有效的值和运算符的组合都是表达式,每个表达式都有值。C++将赋值表达式的值定义为左侧成员的值。当判定表达式的值这种操作改变了内存中的数据的值时,我们说表达式有副作用(side effect)。因此,判定赋值表达式会带来这样的副作用,即修改被赋值着的值。

5.1.2 语句

只要加上分号,所有的表达式都可以称为语句,但不一定有编程意义。例如rodents + 6;。

5.1.3 非表达式和语句

对任何表达式加上分号都可以称为语句,但是这句话反过来说就不对了。也就是说,从语句中删除分号,并不一定将它转换为表达式,如返回语句、声明语句和for语句。

1
2
3
int toad;
eggs = int toad * 1000; // invalid, not an expression
cin >> int toad; // can't combine declaration with cin

int toad不是表达式,因为它没有值。

5.1.4 顺序点

顺序点(sequence point)是程序执行过程中的一个点,在这里,进入下一步之前将确保对所有的副作用都进行了评估。如语句;就是一个顺序点。任何完整的表达式末尾都是一个顺序点。完整表达式是指不是另一个更大表达式的子表达式,如表达式语句中的表达式部分以及用作while循环中检测条件的表达式。

1
2
while (guests++ < 10)
cout << guests << endl;

由于guests++ < 10是一个完整的表达式,因此该表达式的末尾是一个顺序点,所以在循环判断结束guests就加了1。

1
y = (4 + x++) + (6 + x++);

由于4 + x++不是一个完整的表达式,C++不保证x的值在计算子表达式4 + x++后立刻增加1。整条赋值语句的表达式是一个完整的表达式,分号标示了顺序点,因此C++只保证执行到下一条语句之前,x自加两次。

cout在显示bool值之前将它们转换为int,但cout.setf(ios::boolalpha)函数调用设置了一个标记,该标记命令cout显示true和false,而不是1和0。

5.2 复合语句

代码块由一对花括号和它们包含的语句组成,被视为一条语句,称为复合语句。

如果在语句块中定义一个新的变量,则仅当程序执行该语句块中的语句时,该变量才存在。执行完该语句块后,变量将被释放。

如果在一个语句块中声明一个变量,而外部语句块中也有一个同名变量,则在声明位置到内部语句块结束的范围之内,新变量将隐藏旧变量。

5.3 递增、递减运算符

5.3.1 前缀格式和后缀格式

递增、递减运算符分为前缀和后缀版本。前缀版本如++x、--x的值为执行递增或递减后的值,而后缀版本如x++、x--的值为原始的值,并且执行递增或递减。

此外,前缀格式的效率比后缀版本高。这是因为,用于这样定义前缀函数:将值加1然后返回;但后缀版本首先复制一个副本,将其加1,然后将复制的副本返回。因此,对于类而言,前缀版本比后缀版本效率高。

5.3.2 递增/递减运算符和指针

前缀递增、递减和解引用运算符优先级相同,以从右到左进行结合。后缀递增和后缀递减的优先级相同,但比前缀运算符的优先级高,这两个运算符以从左到右的方式进行结合。

5.4 组合赋值运算符

每个算数运算符都有其对应的组合赋值运算符(有的叫增强赋值运算符)。

image-20210329103606086

5.5 逗号运算符

逗号并不是逗号运算符,例如,下面这个声明语句中的逗号将变量列表中相邻的名称分开:

1
int i, j;

C++的逗号运算符特性:

  • 逗号运算符是一个顺序点,确保先计算第一个表达式,然后计算第二个表达式

    1
    i = 20, j = 2 * i;
  • 逗号表达式的值是第二部分的值,如上面表达式的值为40,因为j = 2 * i的值为40。

在所有的运算符中,逗号运算符的优先级是最低的:

1
cats = 17, 240;

上面的语句被解释为(cats = 17), 240,240不起作用。可以使用括号改变这一点:cats = (17, 240);,这样cats的值就是第二部分的值240。

5.6 关系运算符和关系表达式

image-20210329105433501

关系运算符比算数运算符的优先级低。

关系表达式的结果要么为true,要么为false,因此整型提升就就是要么为1,要么为0。

5.7 for循环

1
2
for (initialization-expression; test-expression; update-expression)
body

for循环的组成部分:

  • 设置初始值
  • 执行测试,看看循环是否应当继续进行
  • 执行循环操作
  • 更新用于测试的值

C++语法将整个for看作一条语句,虽然循环体可以包含一条或多条语句。test-expression可以使用任意表达式,C++将把结果强制转换为bool类型。update-expression在每轮循环结束时执行,此时循环体已经执行完毕。

for循环是入口条件(entry-condition)循环,这意味着在每轮循环之前,都将计算测试表达式的值。

image-20210329093624225

5.7.1 声明语句表达式

for循环原始的语法为:

1
2
for (expression; expression; expression)
statement;

以前不可以在初始化部分声明变量,因为声明不是表达式。通过引入声明语句表达式(declaration-statement expression)来合法化。声明语句表达式不带分号声明,只能出现在for语句中。然而,这种调整已经被取消了,代之以将for语句的句法修改成下面这样:

1
2
for (for-init-statement condition; expression)
statement

for-init-statement后面没有分号,因为其自身就是带分号的语句。对于for-init-statement来说,它既可以是表达式语句,也可以是声明语句。而使用该方式声明的变量,只能在for循环中存在,当程序离开循环后,这种变量将消失。

5.7.2 基于范围的for循环(C++11)

C++11新增了一种循环:基于范围(range-based)的for循环。

1
2
3
double prices[5] = {4.99, 10.99, 6.87, 7.99, 8.49};
for (double x : prices)
cout << x << std::endl;

这里的x属于临时变量,要修改数组的元素,需要使用不同的循环变量语法:

1
2
for (double &x : prices)
x = x * 0.80;

还可结合使用基于范围的for循环和初始化列表:

1
2
3
for (int x : {3. 5, 2, 8, 6})
cout << x << " ";
cout << '\n';

5.8 while循环

1
2
while (test-condition)
body

image-20210329110716611

ctime头文件中提供了时间类型,常用的是time_t和clock_t。clock_t的单位是CPU的时钟周期。

5.8.1 typedef

typedef用于类类型建立别名,但是typedef不会创建新类型,而只是为已有的类型建立一个新名称。

5.9 do while循环

1
2
3
do
body
while (test-expression);

image-20210329112109266

do while不同于另外两种循环,因为它是出口条件(exit condition)循环,先执行循环体,然后再判定测试表达式。

5.10 文件尾条件

检测到EOF(end of file)后,cin将两位(eofbit和failbit)都设置为1。可以通过成员函数eof()来查看eofbit是否被设置。同样,如果eofbit和failbit被设置为1,fail()成员函数返回true,否则返回false。

5.10.1 常见的字符输入做法

每次读取一个字符,知道遇到EOF的输入循环的基本设计如下:

1
2
3
4
5
6
cin.get(ch);
while (cin.fail() != false)
{
...
cin.get(ch);
}

上述循环判断也可以改写为:

1
while (!cin.fail())

方法cin.get(char)的返回值是一个cin对象,然而,istream类提供了一个可以将istream对象转换为bool值得函数;当cin出现在需要bool值的地方,该转换函数将被调用。另外,如果最后一次读取成功了,则转换得到的bool值为true,否则为false。这意味着可以将上述while测试改写成这样:

1
while (cin)

这比!cin.fail()或!cin.eof()更通用,因为它可以检测到其他失败原因,如磁盘故障。最后,由于cin.get(char)返回cin,因此可以将循环简化成这种格式:

1
2
3
4
while (cin.get(ch))
{
...
}

另外,cin.get()还可以这么用:

1
char ch = cin.get();

因此,循环还可以这样写:

1
2
3
4
5
6
char ch = cin.get();
while (ch != EOF)
{
...
ch = cin.get();
}

EOF不表示输入中的字符,而是指出没有字符,其定义在stdio.h中,即#define EOF -1。

简化形式:

1
2
3
4
while ((ch = cin.get()) != EOF)
{
...
}

image-20210329124800907

5.11 嵌套循环和二维数组

image-20210329181811484

第6章 分支语句和逻辑运算符

6.1 if语句

1
2
if (test-condition)
statement

if测试条件也将被强制转换成bool值。整个if语句被视为一条语句。

通常情况下,测试条件都是关系表达式。

image-20210330080210982

6.1.1 if-else语句

image-20210330080328793

1
2
3
4
if (test-condition)
statement1
else
statement2

整个if-else结构被视为一条语句。

6.1.2 if-elseif-else结构

1
2
3
4
5
6
7
if (test-condition1)
statement1
else if (test-condition2)
statement2
...
else
statementn

6.2 逻辑表达式

C++提供3中逻辑运算符,来组合或修改已有的表达式。

  • 逻辑或||
  • 逻辑与&&
  • 逻辑非!

运算符优先级:

逻辑非!高于所有关系运算符和算数运算符,而逻辑与&&高于逻辑或||。

6.3 字符函数库cctype

image-20210330124442757

6.4 条件运算符?:

1
expression1 ? expression2 : expression3

6.5 switch语句

1
2
3
4
5
6
7
switch(integer-expression)
{
case label1: statement(s);
case label2: statement(s);
....
default: statement(s);
}

integer-expression必须是一个结果为整数值的表达式,另外,每个标签都必须是整数常量表达式,最常见的是int或char常量(如1或’q’),也可以是枚举量。

switch语句中的每一个case标签都必须是一个单独的值,另外,这个值必须是整数,因此switch无法处理浮点测试。另外,case标签值还必须是常量。

6.5.1 枚举量用作标签

当switch语句将int值和枚举常量标签进行比较时,将枚举量提升为int。另外,在while循环测试条件中,也会将枚举量提升为int类型。

6.6 break和continue

break使得程序结束循环,而continue使得程序跳过当前循环,进入下一轮循环。

6.7 读取数字的循环

输入错误和文件尾EOF都将导致cin返回false。对于下面的程序:

1
2
int n;
cin >> n;

如果用户输入一个单词,而不是一个数,则类型不匹配,此时:

  • n值保持不变
  • 不匹配的输入将被留在输入队列中
  • cin对象的一个错误标记被设置
  • 对cin方法的调用将返回false(如果被转换为bool类型)

方法返回false意味着可以使用非数字输入来结束读取数字的循环。非数字输入设置错误标志意味着必须重置该标记,程序才能继续读取输入。clear()方法重置错误输入标记,同时也重置文件尾(EOF条件)。

image-20210330130349296

这段代码就是错误处理,把本行剩下的输入都读完,且直到输入正确为止。

6.8 简单文件输入/输出

这里讨论的输入输出都是基于文本文件的输入输出,而非二进制文件。

6.8.1 文本I/O和文本文件

使用cin进行输入时,程序将输入视为一系列的字节,其中每个字节都被解释为字符编码。不管目标数据类型是什么,输入一开始都是字符数据——文本数据。cin负责将文本转换为其他类型。下面看示例:

假设有如下输入行:

1
38.5 19.2

下面看看使用不同的数据类型的变量来存储时,cin是如何处理的:

  • char

    1
    2
    char ch;
    cin >> ch;

    上面的代码使得cin看到第一个字符为3,并将其对应的字符编码存储在ch中。输入和目标变量都是字符,因此不需要任何转换。执行完上述步骤后,输入队列的下一个字符为8。

  • int

    1
    2
    int n;
    cin >> n;

    上面的代码使得cin将不断读取,直到遇到第一个非数字字符.,因此它将读取3和8,.成为输入队列中的下一个字符。cin通过计算发现,这两个字符对应数值38,因此将数值38的二进制编码赋值进变量n中。

  • double

    1
    2
    double x;
    cin >> x;

    上面的代码使得cin不断读取,知道遇到第一个不属于浮点数的字符。也就是说cin将读取3、8、.、5,空格成为输入队列的下一个字符。cin通过计算发现,这四个字符对应数值38.5,因此将38.5的二进制编码(浮点格式)复制到变量x中。

  • 字符数组

    1
    2
    char word[50];
    cin >> word;

    上面这段代码使得cin不断读取知道遇到空白字符,也就是说它将读取3、8、.、5,使得空格成为输入队列中的下一个字符。然后,cin将这4个字符的字符编码存储到word数组中,并在末尾加上一个\0。这里不需要进行任何的转换。

  • 输入一行到字符数组

    1
    2
    char word[50];
    cin.getline(word, 50);

    这里假设输入行少于50个字符。cin将不断读取知道遇到换行符,并将换行符丢弃,将换行符之前读取的所有字符复制到word数组中并在结尾加上\0。输入队列中的下一个字符是下一行中的第一个字符。

对于输出,将执行相反的转换。

6.8.2 输出文件流ofstream

头文件fstream提供了文件输出流类ofstream。通过open方法打开文件后就可以像使用cout一样向文件中写入数据。用完后记得使用close方法关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// outfile.cpp -- writing to a file
#include <iostream>
#include <fstream> // step1: include header file

int main() {
using namespace std;

char automobile[50];
int year;
double a_price;
double d_price;

// step2: declaration ofstream object
ofstream outFile;
// step3: open file
outFile.open("carinfo.txt");

cout << "Enter the make and model of automobile: ";
cin.getline(automobile, 50);
cout << "Enter the model year: ";
cin >> year;
cout << "Enter the original asking price: ";
cin >> a_price;
d_price = 0.913 * a_price;

// display information on screen with cout
cout << fixed;
cout.precision(2);
cout.setf(ios_base::showpoint);
cout << "Make and Model: " << automobile << endl;
cout << "Year: " << year << endl;
cout << "Was asking $" << a_price << endl;
cout << "Now asking $" << d_price << endl;

// step4: now do exact same things using outFile instead of cout
outFile << fixed;
outFile.precision(2);
outFile.setf(ios_base::showpoint);
outFile << "Make and Model: " << automobile << endl;
outFile << "Year: " << year << endl;
outFile << "Was asking $" << a_price << endl;
outFile << "Now asking $" << d_price << endl;

// step5: close the stream
outFile.close();

return 0;
}

这里需要注意的是,打开已有的文件,以接受输出时,默认将其长度阶段为零,因此原来的内容将丢失。

6.8.3 输入文件流ifstream

头文件fstream提供了文件输入流ifstream类,同样需要先声明一个ifstream对象,使用open函数打开文件,随后可以像使用cin一样使用它来从文件读入数据。

如果试图打开一个不存在的文件用于输入,将导致后面使用ifstream对象进行输入时失败。检查文件是否被成功打开的首先方法是使用is_open()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
#include <fstream> // step1: include header file "fstream"
#include <cstdlib>

const int SIZE = 60;

int main() {
using namespace std;
char filename[SIZE];
ifstream inFile; // step2: declare a ifstream object
cout << "Enter name of data file: ";
cin.getline(filename, SIZE);
inFile.open(filename); // step3: open file
if (!inFile.is_open()) { // step4: judge whether is opened successfully
cout << "Could not open the file " << filename << endl;
cout << "Program terminating.\n";
exit(EXIT_FAILURE);
}

double value;
double sum = 0.0;
int count = 0;

// step5: read stream and test error
inFile >> value;
// 没有任何错误发生,读成功时,good()返回true
while (inFile.good()) {
++count;
sum += value;
inFile >> value;
}
// 判断是否到了文件尾
if (inFile.eof())
cout << "End of file reached.\n";
// 判断是否时输入错误
else if (inFile.fail())
cout << "Input terminated by data mismatch.\n";
else
cout << "Input terminated for unknown reason.\n";
if (count == 0)
cout << "No data processed.\n";
else {
cout << "Items read: " << count << endl;
cout << "Sum: " << sum << endl;
cout << "Average: " << sum / count << endl;
}

// step6: close stream
inFile.close(); // 关闭打开的文件

return 0;
}

上述的good()循环可以简化为:

1
2
3
4
while (inFile >> value)
{
...
}

第7章 函数——C++的编程模块

7.1 复习函数基础知识

创建自己的函数时,必须自行处理3个方面:

  • 函数定义
  • 函数原型
  • 函数调用

对于有返回值的函数,通常,函数将返回值复制到指定的CPU寄存器或内存单元中来将其返回。随后,调用程序将查看该内存单元。返回函数和调用函数必须就该内存单元中存储的数据的类型达成一致。函数原型将返回值类型告知调用程序,而函数定义命令被调用函数应返回什么类型的数据。

image-20210331081144231

7.1.1 函数原型和函数定义

  1. 为什么需要函数原型?

    原型描述了函数到编译器的接口,它将函数返回值的类型以及参数的类型和数量告诉编译器。如果调用函数时提供的参数数量不对、类型不匹配(无法自动转换),原型使得编译器能捕获这些错误。当函数返回时,将值存储在CPU的寄存器或是内存中,由于原型提供了返回值类型,因此编译器知道应检索多少个字节以及如何解释它们。如果没有这些信息,编译器将只能进行猜测,而编译器是不会这样做的。

    避免使用函数原型的唯一方法,在首次使用函数之前定义它,但这并不是总是可行的。

  2. 原型的语法

    1
    returnType functionName(param1Type, param2Type, ...);

    函数原型是一条语句,因此必须以分号结束。获得原型的最简单方式是复制函数定义的函数头,并添加分号。然而,函数原型并不要求提供变量名,可以包括变量名,也可以不包括。原型中的变量名相当于占位符,因此不必与函数定义中的变量名相同。

  3. 原型的功能

    原型可以帮助编译器完成许多工作,也可以帮助程序员极大的降低程序出错的几率:

    • 编译器正确处理函数返回值。
    • 编译器检查使用的参数数目是否正确。
    • 编译器检查使用的参数类型是否正确。如果不正确,将转换为正确的类型(如果可能的话)。

    通常,原型自动将被传递的参数强制转换为期望的类型。自动类型转换并不能避免所有可能的错误。当较大的类型被自动转换为较小的类型时,有些编译器将发出警告,指出这可能会丢失数据。仅当有意义时,原型化才会导致类型转换。

    在编译阶段进行的原型化被称为静态类型检查(static type checking)。可以看出,静态类型检查可以捕获许多在运行阶段非常难以捕获的错误。

7.2 函数参数和按值传递

函数按值传递,不同函数函数中的局部变量互不影响。

7.3 函数和数组

1
int sum_arr(int arr[], int n);

上述的声明中,arr实际上并不是一个数组,而是一个指针。在C++中,当且仅当用于函数头或函数原型中,int *arr和int arr[]的含义是相同的,它们都意味着arr是一个指针。在其他上下文中,int *arr和int arr[]的含义并不相同。

将数组地址作为参数可以节省复制整个数组所需的时间和内存。如果数组很大,那么使用拷贝的系统开销将非常大。

7.3.1 指针和const

  • 让指针指向一个常量对象,防止使用该指针来修改所指向的值,简称指向const的指针。

    1
    2
    int age = 39;
    const int *pt = &age;

    该声明指出,pt指向一个const int,因此不能使用pt来修改这个值。换句话说,*pt的值为const,不能被修改:

    1
    2
    *pt += 1;     // INVALID
    cin >> *pt; // INVALID

    pt的声明并不意味着它指向的就是一个常量,而是只意味着对pt而言,这个值是常量。在这里,pt指向age,但age并不是常量,可以直接通过age变量修改age的值,但不能使用pt指针来修改它:

    1
    2
    *pt = 20;     // INVALID
    age = 20; // VALID

    但你可以修改指针的指向,将其指向另一个变量,但是修改指向后仍不能使用指针修改变量的值。

    四种情况:

    • 将常规变量的地址赋给常规指针
    • 将常规变量的地址赋给指向const的指针
    • 将const变量的地址赋给指向const的指针
    • 将const变量的地址赋给常规指针

    上述四种情况下,最后一种情况是不可行的。如果最后一种情况可行,那可以通过常规指针解引用来改变const变量的值,那这个const变量的const还有什么意义呢?(但可以使用强制类型转换类突破这种限制——const_cast)

    数组元素如果为const类型,则禁止将数组赋给非const指针:

    1
    2
    3
    const int arrays[5] = {1, 2, 3, 4, 5};
    int *pt = arrays; // INVALID
    const int *cpt = arrays; // Valid

    尽可能使用const

    将指针参数声明为指向常量数据的指针有2条理由:

    • 这样可以避免由于无意间修改数据而导致的编程错误;
    • 使用const使得函数能够处理const和非const实参,否则只能接受非const数据。

    如果条件允许,则应将指针形参声明为指向const的指针。

  • 将指针本身声明为常量,防止改变指针的位置,简称const指针

    1
    2
    3
    int sloth = 3;
    const int *ps = &sloth;
    int * const finger = &sloth;

    ps为指向const的指针,不能用来修改sloth的值,只能重新指向别的变量。而finger不能修改指向,但可以使用*finger修改sloth的值。

image-20210331151857454

  • 如果愿意,还可以声明指向const对象的const指针:

    1
    2
    double trouble = 2.0E30;
    const double * const stick = &trouble;

7.4 函数和二维数组

假设有如下代码:

1
2
int data[3][4] = {{1, 2, 3, 4}, {9, 8, 7, 6}, {2, 4, 6, 8}};
int total = sum(data, 3);

sum的原型中第一个参数应该如何声明?data为二维数组,该数组有3个元素,第一个元素本身是一个数组,有4个int值组成。因此data的类型是指向由4个int组成的数组的指针,因此正确的原型如下:

1
2
3
int sum(int (*ar2)[4], int size);
或
int sum(int ar2[][4], int size);

其中的括号必不可少。在前面几章出现过指向数组的指针,在这里是同样的道理。这两种原型含义完全相同,第二种原型的可读性更强。而这两个原型都指出,ar2是指针,而不是数组。还需注意的是,指针类型指出,它指向由4个int组成的数组。因此,指针类型执行了列数,这就是没有将列数作为独立的函数参数进行传递的原因。

7.5 函数和C风格字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// strgback.cpp -- a function that returns a pointer to char
#include <iostream>

const char *buildstr(char c, int n);

int main()
{
using namespace std;
int times;
char ch;

cout << "Enter a character: ";
cin >> ch;
cout << "Enter an integer: ";
cin >> times;
const char *ps = buildstr(ch, times);
cout << ps << endl;
delete[] ps;
ps = buildstr('+', 20);
cout << ps << "-DONE-" << ps << endl;
delete[] ps;

return 0;
}

const char *buildstr(char c, int n)
{
char *pstr = new char[n + 1];
pstr[n] = '\0';
while (n-- > 0)
pstr[n] = c;
return pstr;
}

这里省略所有的const也是可以的。加const是为了防止修改字符串的值。

7.6 递归

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ruler.cpp -- using recursion to subdivde a ruler
#include <iostream>

const int Len = 66;
const int Divs = 6;

void subdivide(char ar[], int low, int high, int level);

int main()
{
char ruler[Len];
int i;
for (i = 1; i < Len - 2; i++)
ruler[i] = ' ';
ruler[Len - 1] = '\0';
int max = Len - 2;
int min = 0;
ruler[min] = ruler[max] = '|';
std::cout << ruler << std::endl;
for (i = 0; i <= Divs; i++)
{
subdivide(ruler, min, max, i);
std::cout << ruler << std::endl;
for (int j = 1; j < Len - 2; j++)
ruler[j] = ' ';
}
return 0;
}

void subdivide(char ar[], int low, int high, int level)
{
if (level == 0) // 递归边界
return;
int mid = (low + high) / 2;
ar[mid] = '|';
subdivide(ar, low, mid, level - 1);
subdivide(ar, mid, high, level - 1);
}

输出:

1
2
3
4
5
6
7
8
|                                                               |
| |
| | |
| | | | |
| | | | | | | | |
| | | | | | | | | | | | | | | | |
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||

7.7 函数指针

与数据项相似,函数也有地址。函数的地址是存储在其机器语言代码的内存的开始地址。

7.10.1 函数指针的基础知识

  • 获取函数的地址

    获取函数的地址很简单:只要使用函数名即可。也就是说,如果think()是一个函数,则think就是该函数的地址。要将函数作为参数进行传递,必须传递函数名。

  • 声明函数指针

    声明指向某种数据类型的指针时,必须指定指针指向的类型。同样,声明指向函数的指针时,也必须指定指针指向的函数类型。这意味着声明应指定函数的返回类型以及函数的特征标(参数列表)。也就是说,声明应像函数原型那样指出有关函数的信息。

    通常,要声明指向特定类型的函数的指针,可以先编写这种函数的原型,然后用(*pf)替换函数名。这样,pf就是这类函数的指针。

    1
    2
    3
    4
    5
    // a function declaration
    double pam(int);

    // a pointer point to function pam
    double (*pf)(int) = pam;

    函数的特征标和返回类型必须与函数指针一致。如果不相同,编译器将拒绝这种赋值。

    1
    2
    3
    4
    5
    6
    7
    double ned(double);
    int ted(int);

    double (*pf)(int);

    pf = ned; // INVALID -- mismatched signature
    pf = ted; // INVALID -- mismatched return types
  • 使用指针来调用函数

    将函数指针看作函数名即可。

    1
    2
    3
    4
    5
    double pam(int);
    double (*pf)(int);
    pf = pam;
    double x = pam(4);
    double y = *(pf)(5) // calling by function pointer

    实际上,C++也允许像使用函数名那样使用pf:

    1
    double y = pf(5);

    image-20210331171355702

7.10.2 深入探讨函数指针

先看三个函数原型:

1
2
3
const double *f1(const double ar[], int n);
const double *f2(const double [], int);
const double *f3(const double *, int);

实际上,上面三个原型是一样的,特征表看似不同,实际上相同。接下来声明一个指针,它可指向这三个函数之一:

1
const double *(*p1)(const double *, int);

可在声明的同时进行初始化:

1
2
3
4
5
6
const double *(*p1)(const double *, int) = f1;
auto p2 = f2; // C++11 automatic type deduction

// using
cout << (*p1)(av, 3) << ": " << *(*p1)(av, 3) << endl;
cout << p2(av, 3) << ": " << *p2(av, 3) << endl;

函数返回const double *为指针类型,因此使用解引用符号取得值。

函数指针数组

1
2
3
4
5
6
7
8
const double *(*pa[3])(const double *, int) = {f1, f2, f3};
auto pb = pa;

const double *px = pa[0](av, 3);
const double *py = (*pb[1])(av, 3);

double x = *pa[0](av, 3);
double y = *(*pb[1])(av, 3);

指向函数指针数组的指针

1
2
3
auto pc = &pa;  // C++11

const double *(*(*pd)[3])(const double *, int) = &pa;

一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <iostream>

const double *f1(const double ar[], int n);
const double *f2(const double [], int);
const double *f3(const double *, int);

int main()
{
using namespace std;
double av[3] = {1112.3, 1542.6, 2227.9};

const double *(*p1)(const double *, int) = f1;
auto p2 = f2;
cout << "Using prointers to functions:\n";
cout << " Address Value\n";
cout << (*p1)(av, 3) << ": " << *(*p1)(av, 3) << endl;
cout << p2(av, 3) << ": " << *p2(av, 3) << endl;

// 函数指针数组
const double *(*pa[3])(const double *, int) = {f1, f2, f3};
auto pb = pa;
cout << "\nUsing an array of pointers to functions:\n";
cout << " Address Value\n";
for (int i = 0; i < 3; i++)
cout << pa[i](av, 3) << ": " << *pa[i](av, 3) << endl;
cout << "\nUsing a pointer to a pointer to a function:\n";
cout << " Address Value\n";
for (int i = 0; i < 3; i++)
cout << pb[i](av, 3) << ": " << *pb[i](av, 3) << endl;

cout << "\nUsing pointers to an array of pointers:\n";
cout << " Address Value\n";
// 指向函数指针数组的指针
auto pc = &pa;
cout << (*pc)[0](av, 3) << ": " << *(*pc)[0](av, 3) << endl;
const double *(*(*pd)[3])(const double *, int) = &pa;
const double *pdb = (*pd)[1](av, 3);
cout << pdb << ": " << *pdb << endl;
cout << (*(*pd)[2])(av, 3) << ": " << *((*pd)[2])(av, 3) << endl;

return 0;
}

const double *f1(const double *arr, int n)
{
return arr;
}

const double *f2(const double ar[], int n)
{
return ar + 1;
}

const double *f3(const double ar[], int n)
{
return ar + 2;
}

第8章 函数探幽

本章重点内容:

  • 内联函数
  • 引用
  • 函数的默认值参数
  • 函数重载
  • 函数模板
  • 函数模板具体化

8.1 C++内联函数

内联函数是C++为提高程序运行速度所做的一项改进。编译过程的最终产品是可执行程序——右一组机器语言指令组成。运行程序时,操作系统将这些指令载入到计算机内存中,因此每条指令都有特定的内存地址。计算机随后将逐步执行这些指令。有时,碰到循环或分支语句时,将跳过一些指令,想前或向后跳到特定的地址。常规函数调用也使程序跳到另一个地址——函数的地址,并在函数结束时返回。

执行到函数调用指令时,程序将在函数调用后立即存储该指令的内存地址,并将函数参数复制到堆栈,跳到标记函数起点的内存单元,执行函数代码,然后跳回到地址被保存的指令处。来回跳跃并记录跳跃位置意味着以前使用函数时,需要一定的开销。

C++内联函数提供了另一种选择,以空间换时间,内联函数不进行上述的跳跃过程,编译器将使用相应的函数代码替换函数调用,因此内联函数的运行速度比常规函数快,但代价是占用更多内存。如果程序在10个不同的地方调用同一个内联函数,则该程序将包含该函数代码的10个副本。

应该有选择地使用内联函数,如果执行函数代码的时间比处理函数调用机制的时间长,则节省的时间将只占整个过程的很小一部分。如果代码执行的时间很短,则内联调用就可以节省非内联调用使用的大部分时间。另一方面,由于这个过程相当快,因此尽管节省了该过程的大部分时间,但节省的时间绝对值并不大,除非该函数经常被调用。

image-20210402195829958

要使用这项特性,必须采取下述措施之一:

  • 在函数声明前加上关键字inline
  • 在函数定义前加上关键字inline

通常的做法是省略原型,将整个定义放在本应提供原型的地方。

内联函数不能为递归函数。程序员请求将函数作为内联函数时,编译器不一定会满足这种要求。它可能认为该函数过大或注意到函数调用了自己,因此不将它作为内联函数。有些编译器没有启用或实现这种特性。

image-20210402200839919

8.2 引用变量

引用是已定义的变量的别名。(引用的实现方式为const指针,且隐含着解引用)。引用变量的主要用途是用作函数的形参。通过将引用变量作为参数,函数将使用原始数据,而不是副本。

引用必须在其声明时初始化,而不像指针可以先声明后初始化。此后,每次对引用变量使用赋值运算符,仅仅是在赋值,而不是将引用重新指向一个新的变量。

使用引用作为形参时,由于参数为传进来的实参的引用,因此修改该参数的值,将修改实参的值。为了避免这种情况,可以使用const修饰符。如此一来,当编译器发现修改引用的值时,编译器将报错。

当参数是基本类型时,使用值传递;如果为结构体变量或类对象时,使用引用传递。

8.2.1 临时变量、引用参数和const

如果实参与引用参数不匹配,C++将生成临时变量。当前,仅当参数为const引用时,C++才允许这样做,但以前不这样。

如果引用参数是const,则编译器将在下面两种情况下生成临时变量:

  • 实参的类型正确,但不是左值;
  • 实参的类型不正确,但可以转换为正确的类型

左值:可被引用的数据对象,例如变量、数组元素、结构成员、引用和解引用的指针等。

非左值:字面常量、包含多项的表达式。

常规变量和const变量都可视为左值,因为可以通过地址访问它们。但常规变量属于可修改的左值,而const变量属于不可修改的左值。

1
2
3
4
double refcube(const double &ra)
{
return ra * ra * ra;
}
1
2
3
4
5
6
7
8
9
10
11
12
double side = 3.0;
double *pd = &side;
double &rd = side;
long edge = 5L;
double lens[4] = {2.0, 5.0, 10.0, 12.0};
double c1 = refcube(side); // ra is sice
double c2 = refcube(lens[2]); // ra is lens[2]
double c3 = refcube(rd); // ra is rd is side
double c4 = refcube(*pd); // ra is *pd is side
double c5 = refcube(edge); // ra is temporary variable, type is not match, but can be converted
double c6 = refcube(7.0); // ra is temporary variable, type is match, but is not left value
double c7 = refcube(side + 10.0); // ra is temporary variable, type is match, but is not left value

edge虽然是变量,是左值,但是一个long类型,类型不对;7.0和 side + 10.0类型都对,但不是左值,没有名称。在这些情况下,编译器都将生成一个临时匿名变量,并让ra指向该临时变量。这些临时变量只在函数调用期间存在,此后编译器变可以随意将其删除。

现在来看refcube()函数,该函数的目的只是使用传递的值,而不是修改它们,因此临时变量不会造成任何不利的影响,反而会使函数在可处理的参数种类方面更通用。因此,如果将引用声明为const类型,C++将在必要时产生临时变量。实际上,对于形参为const引用的C++参数,如果实参类型不匹配,则其行为类似于按值传递,为确保原始数据不被改变,将使用临时变量来存储值。

应尽可能使用const

  • 使用const可以避免无意中修改数据的编程错误;
  • 使用const使函数能够处理const和非const实参,否则只能接受非const数据;
  • 使用const引用使函数能够正确生成并使用临时变量。

8.2.2 右值引用

C++新增了另一种引用——右值引用(rvalue refrence)。这种引用可以指向右值,使用&&声明。

1
2
3
4
5
double &&rref = std::sqrt(36.00);
double j = 15.0;
double &&jref = 2.0 * j + 18.5;
std::cout << rref << std::endl;
std::cout << jref << std::endl;

8.2.3 返回引用

返回引用的函数实际上是被引用的变量的别名。

返回引用时最重要的一点,应避免返回函数终止时不再存在的内存单元引用。应该避免编写下面的代码:

1
2
3
4
5
6
const free_throws &clone2(free_throws &ft)
{
free_throws newguy;
newguy = ft;
return newguy;
}

该函数返回一个指向临时变量的引用,函数运行完毕后它将不复存在。为避免这种问题,最简单的方法是,返回一个作为参数传递给函数的引用。作为参数传递的引用将指向调用函数使用的数据,因此返回的引用也指向这些数据。另一种方法是使用new来分配新的存储空间。

返回const引用使得无法对函数调用语句进行赋值:

1
2
3
4
5
6
7
free_throws &accumulate(free_throws &target, const free_throws &source)
{
// ...
return target;
}

accumulate(dup, four) = three; // dup = three;

上面这条个函数原型指示返回值是一个free_throws的引用,因此可以对其赋值,因为返回的为左值;

1
const free_throws &accumulate(free_throws &target, const free_throws &source)

上面这条语句便无法对函数调用进行赋值,因为返回值不再是左值,而是const修饰的引用,无法修改值,为右值。

8.2.4 对象、继承和引用

基类引用可以指向派生类对象,而无需进行强制类型转换,常用于函数参数。举个简单的例子,ofstream类继承ostream类,因此指向ostream类的引用也可以指向ofstream类。

8.2.5 何时使用引用参数

使用引用参数的两个原因:

  • 程序员能够修改调用函数中的数据对象
  • 通过传递引用而不是整个对象,可以提高程序的运行速度

指导原则:

  1. 对于使用传递的值而不作修改的函数
    • 如果数据对象很小,如内置数据类型或小型结构,则按值传递
    • 如果数据对象是数组,则使用指针,因为这是唯一的选择,并将指针声明为指向const的指针
    • 如果数据对象是较大的结构,则使用指向const的指针或const引用
    • 如果数据对象是对象,则使用const引用
  2. 对于修改调用函数中数据的函数:
    • 如果数据对象是内置数据类型,则使用指针
    • 如果数据对象是数组,则使用指针
    • 如果数据都对象是结构,则使用引用或指针
    • 如果数据对象是类对象,则使用引用

8.3 默认参数

默认参数通过函数原型设置并告知程序。对于带参数列表的函数,必须从右向左添加默认值,也就是说,要为某个参数设置默认值,则必须为它右边的所有参数提供默认值。

8.4 函数重载

可以通过函数重载来设计一系列函数——它们完成相同的工作,但使用不同的参数列表。函数重载的关键是参数列表——也称为特征标。如果两个函数的参数数目和类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名和返回值类型是无关紧要的。

编译器在检查函数特征标时,将把类型和引用本身视为同一特征标。

8.4.1 重载引用参数

1
2
3
void stove(double &r1);
void stove(const double &r2);
void stove(double &&r3);

上面有3个函数原型,左值引用参数r1与可修改的左值参数匹配;const左值引用参数r2可与修改的左值、const左值参数和右值参数匹配;右值引用参数r3与右值匹配。如果重载使用这三种参数的函数,结果将如何?答案是将调用最匹配的版本:

1
2
3
4
5
double x = 55.5;
const double y = 32.0;
stove(x); // call stove(double &)
stove(y); // call stove(const double &)
stove(x + y); // call stove(double &&)

如果没有定义stove(double &&),stove(x + y)将调用stove(const double &)。

8.4.2 何时使用函数重载

仅当函数基本上执行相同的任务,但使用不同形式的数据时,才应采用函数重载。有时,默认参数可以达到相同的目的,且能少些一个函数。

8.4.3 名称修饰

编译器根据函数原型中指定的形参类型对每个函数名进行加密,如在64位Windows上,且编译器架构为x86_64,使用nm命令查看目标文件,可以看到函数经编译器进行名称修饰后的结果:

1
2
unsigned long left(unsigned long num, unsigned ct);
char *left(const char *str, int n = 1);

image-20210406205619342

8.5 函数模板

函数模板是通用的函数描述,也就是说,它们使用泛型来定义函数,其中的泛型可用具体的类型替换。通过将类型作为参数传递给模板,可使编译器生成该类型的函数。

模板并不创建任何函数,而是告诉编译器如何定义函数。当指定类型时,编译器将按模板创建对应类型的函数,这被称为模板实例化。

函数模板不能缩短可执行程序,最终编译器会根据模板创建出多个不同类型的函数定义,而最终的代码不包含任何模板。

8.5.1 重载的模板

需要多个对不同类型使用同一种算法的函数时,可使用模板。然而,并非所有的类型都使用相同的算法。为满足这种需求,可以像重载常规函数定义那样重载模板定义。和常规重载一样,被重载的模板的函数特征标必须不同。

8.5.2 模板的局限性

模板应配合运算符重载,才可将通用的操作用于不同的类型。或者,为特定类型提供具体化的模板定义。

8.5.3 显示具体化

可以提供一个具体化函数定义——显示具体化(explicit specialization),其中包含所需的代码。当编译器找到与函数调用匹配的具体化定义时,将使用该定义,而不再寻找模板。

  • 对于给定的函数名,可以有非模板函数、模板函数和显示具体化模板函数以及它们的重载版本。
  • 显示具体化的原型和定义应以template<>打头,并通过名称来指出类型。
  • 具体化优先于常规模板,而非模板函数优先于具体化和常规模板。
1
2
3
4
5
6
7
8
9
10
// 非模板函数
void Swap(job &, job&);

// 模板函数
template <typename T>
void Swap(T &, T &);

// 显示具体化
template <> void Swap<job>(job &, job &); // <job>可选,因为函数的参数类型表明,这是job的一个具体化
template <> void Swap(job &, job &); // 因此可以这样写

8.5.4 实例化和具体化

在代码中包含函数模板本身并不会生成函数定义,它只是一个用于生成函数定义的方案。编译器使用模板为特定类型生成函数定义时,得到的使模板示例(instantiation)。模板并非函数定义,但使用int的模板实例是函数定义。

  1. 实例化

    • 隐式实例化

      1
      2
      int a = 10, b = 20;
      Swap(a, b);
    • 显示实例化

      1
      2
      Swap<int>(a, b);
      template void Swap<int>(int, int);

      该声明的意思是,使用Swap模板生成int类型的函数定义。

  2. 具体化

    显示具体化使用下面两个等价的声明之一:

    1
    2
    template <> void Swap<int>(int &, int &);    // explicit specialization
    template <> void Swap(int &, int &); // explicit specialization

    这两个声明的意思是,不要使用Swap模板来生成函数定义,而应使用专门为int类型显式定义的函数定义。

警告:试图在同一个文件(或转换单元)中使用同一种类型的显示实例化和显示具体化将出错。

隐式实例化、显示实例化和显示具体化统称为具体化。它们的相同之处在于,它们表示的都是使用具体类型的函数定义,而不是通用描述。

一段代码总结隐式实例化、显示实例化和显示具体化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ...

/* template prototype */
template <typename T>
void Swap(T &, T&);

/* explicit specialization for job */
template <>
void Swap<job>(job &, job &);

int main()
{
// ...

/* explicit instantiation for char */
template void Swap<char>(char &, char &);

short a, b;
// ...
Swap(a, b); // implicit template instantiation for short

job n, m;
// ...
Swap(n, m); // use explicit specialization for job

char g, h;
// ...
Swap(g, h); // use explicit template instantiation for char
// ...
}

8.5.5 编译器选择使用哪个函数版本

对于函数重载、函数模板以及函数模板重载,C++需要一个定义良好的策略,来决定为函数调用使用哪一个函数定义,尤其是有多个参数时。这个过程称为重载解析(overloading resolution)。

  • 第1步:创建候选函数列表。其中包含与被调用函数的名称相同的函数和模板函数。
  • 第2步:使用候选函数列表创建可行函数列表。这些都是参数数目正确的函数,为此有一个隐式转换序列,其中包括实参类型与相应的形参类型完全匹配的情况。例如,使用float参数的函数调用可以将该参数转换为double,从而与double形参匹配,而模板可以为float生成一个实例。
  • 第3步:确定是否有最佳的可行函数。如果有,则使用它,否则该函数调用出错。
完全匹配和最佳匹配

完全匹配允许某些“无关紧要的转换”。

image-20210406224402978

如果有多个匹配的原型,则编译器将无法完成重载解析过程;如果没有最佳的可行函数,则编译器将生成一条错误信息,该消息可能会使用诸如“ambiguous(二义性)”这样的词语。

然而,有时候,即使两个函数都完全匹配,仍可完成重载解析:

  • 指向非const数据的指针和引用优先于指向const的指针和引用
  • 非模板函数将优先于模板函数(包括显示具体化)

术语最具体并不一定意味着显示具体化,而是指编译器推断使用哪种类型时执行的转换最少。

自己选择

使用less<>(m, n)显示指定编译器应使用模板,而不是非模板。

8.5.6 模板函数的发展

是什么类型

编写模板函数时,一个问题是并非总能知道应在声明中使用哪种类型:

1
2
3
4
5
6
7
tempalte <typename T1, typename T2>
void ft(T1 x, T2 y)
{
...
?type? xpy = x + y;
...
}

xpy应为什么类型?

关键字decltype(C++11)

语法:

1
decltype(expression) var;

使用decltype,可以解决上述问题:

1
2
3
4
5
6
7
tempalte <typename T1, typename T2>
void ft(T1 x, T2 y)
{
...
decltype(x + y) xpy = x + y;
...
}

decltype核对表:

  • 第1步:如果expression是一个没有括号括起的标识符,则var的类型与该标识符的类型相同,包括const等限定符:

    1
    2
    3
    4
    5
    6
    7
    double x = 5.5;
    double y = 7.9;
    double &rx = x;
    const double *pd;
    decltype(x) w; // w is type of double
    decltype(rx) u = y; // u is type of double &
    decltype(pd) v; // v is type of const double *
  • 第2步:如果expression是一个函数调用,则var的类型与函数的返回类型相同:

    1
    2
    long indeed(int);
    decltype(indeed(3)) m; // m is type of int

    注意,并不会实际调用函数。编译器通过查看函数的原型来获悉返回类型,而无需实际调用函数。

  • 第3步:如果expression是一个被括号括起的标识符,则var为指向其类型的引用:

    1
    2
    3
    double xx = 4.4;
    decltype((xx)) r2 = xx; // r2 is type of double &
    decltype(xx) w = xx; // w is type of double
  • 第4步:如果前面条件都不满足,则var的类型与expression的类型相同

    1
    2
    3
    4
    5
    6
    int j = 3;
    int &k = j;
    int &n = j;
    decltype(j + 6) i1; // i1 type int
    decltype(100L) i2; // i2 type long
    decltype(k + n) i3; // i3 type int

如果需要多次声明,可使用typedef定义别名:

1
2
3
4
5
6
7
8
9
10
tempalte <typename T1, typename T2>
void ft(T1 x, T2 y)
{
...
typedef decltype(x + y) xytype;
xytype xpy = x + y;
xytype arr[10];
xytype &rxy = arr[2];
...
}
另一种函数声明语法(C++11后置返回类型)

有一个问题是decltype本身无法解决的:

1
2
3
4
5
6
tempalte <typename T1, typename T2>
?type? ft(T1 x, T2 y)
{
...
return x + y;
}

声明返回值类型时,此时还未声明参数x和y,它们不在作用域内。使用后置返回类型可以解决:

1
2
3
4
5
6
7
8
tempalte <typename T1, typename T2>
auto ft(T1 x, T2 y) -> decltype(x + y)
{
...
return x + y;
}

auto h(int x, float y) -> double;

后置返回类型可用于原型和定义。

第9章 内存模型和命名空间

9.1 单独编译

头文件中常包含的内容:

  • 函数原型
  • 使用#define或const定义的符号常量
  • 结构声明
  • 类声明
  • 模板声明
  • 内联函数

被声明为const的数据和内联函数有特殊的链接属性,因此可以将其放在头文件中。

image-20210407201324520

上面这段文字也是为什么C++调用C的库函数时需要使用extern "C"的原因。

9.2 存储持续性、作用域和链接性

C++使用三种(在C++11中是四种)不同的方案来存储数据,这些方案的区别就在于数据保留在内存中的时间。

image-20210407202044977

9.2.1 作用域和链接

作用域(scope)描述了名称在文件(翻译单元)的多大范围内可见。

  • 局部代码块
  • 全局文件作用域
  • 函数原型作用域
  • 类作用域
  • 命名空间作用域

链接性(linkage)描述了名称如何在不同的翻译单元间共享。

  • 外部链接性:可在文件间共享
  • 内部链接性:只能在一个文件的函数共享
  • 无链接性:不能共享

9.2.2 自动存储持续性

默认情况下,在函数中声明的函数参数和变量的存储持续性为自动,作用域为局部,没有链接性。当程序开始执行这些变量所属的代码块时,将为其分配内存,但其作用域的起点为其声明位置;当函数结束时,这些变量都将消失。

1. 自动变量的初始化

只声明而不初始化,其值是垃圾数据。

2. 自动变量和栈

自动变量存储在栈上。栈的默认大小取决于具体的实现,编译器通常提供改变栈长度的选项。当函数被调用时,自动变量将被加入到栈中,栈顶指针指向变量后面的下一个可用的内存单元。函数结束后,栈顶指针被重置问函数被调用前的值,从而释放新变量使用的内存。新值没有被删除,但不再被标记。

image-20210407205054554

3. register

C++11之前,register关键字用来指示使用寄存器存储变量,目的在于提高访问变量的速度。C++11中,register关键字只是显式地指出变量是自动的。

9.2.3 静态存储持续性

静态存储持续性变量有3中链接性:

  • 外部链接性:全局变量
  • 内部链接性:static修饰的全局变量
  • 无链接性:static修饰的局部变量

由于静态存储持续性变量(简称静态变量)的数目在程序运行期间是不变的,因此程序不需要使用特殊的装置(如栈)来管理它们。编译器将分配固定的内存块来存储所有的静态变量,这些变量在整个程序执行期间一直存在。

image-20210407210020408

下面是三种静态变量的创建方式:

1
2
3
4
5
6
7
8
9
...
int global = 1000; // static duration, external linkage
static int one_file = 50; // static duration, internal linkage
...
void func(int n)
{
static int count = 0; // static duration, no linkage
...
}
1. 静态持续性变量的初始化规则

如果没有显示初始化静态变量,编译器将把它们所有位初始化为0,这种变量被称为零初始化的(zero-initialized)。默认情况下,静态数组和结构将每个元素或成员的所有位设置为0。对于指针而言,0初始化可能并不是将指针的值设置为0,而是初始化为空指针在系统的内部表示。

零初始化和表达式初始化统称为静态初始化。这意味着在编译器处理翻译单元时初始化变量。动态初始化意味着变量将在编译后初始化。

对于所有的静态变量,无论是否有显示初始化,先进行零初始化。接下来,如果使用常量表达式初始化了变量,且编译器仅根据文件内容(包括被包含的头文件)就可计算表达式,编译器将执行常量表达式初始化。必要时,编译器执行简单计算。如果没有足够的信息,变量将被动态初始化。

1
2
3
4
5
6
#include <cmath>
int x; // 零初始化
int y = 5; // 常量表达式初始化
long z = 13 * 13; // 常量表达式初始化
const double pi = 4.0 * atan(1.0); // 动态初始化
int enough = 2 * sizeof(long) + 1; // 常量表达式初始化
2. 静态持续性、外部链接性

单定义规则

一方面,在每个使用外部变量的文件中,都必须声明它;另一方面,C++有“单定义规则”,该规则指出,变量只能有一次定义。

  • 定义声明,简称定义,不给变量分配存储空间,因为它引用已有的变量。
  • 引用声明,简称声明,给变量分配存储空间。使用关键字extern,且不进行初始化;否则,声明为定义,导致分配存储空间。

image-20210407212956406

注意,单定义规则并非意味着不能有多个变量的名称相同。例如,在不同函数中声明的同名自动变量是彼此独立的,它们都有自己的地址。局部变量可能隐藏同名的全局变量。虽然程序中可包含多个同名的变量,但每个变量都只有一个定义。

局部变量隐藏同名的全局变量时,可使用作用域解析运算符::来访问全局变量。

3. 静态持续性,内部链接性

使用static修饰全局变量,其链接性由外部链接性变成了内部链接性,且不与其他文件中的同名外部链接性全局变量冲突。

4. 静态持续性,无链接性

使用static修饰局部变量,其链接性仍为无链接性,但其存储持续性由自动存储持续性变为静态存储持续性。

9.2.4 说明符和限定符

存储说明符(storage class specifier):

  • auto(在C++11中已不再是说明符)
  • register
  • static
  • extern
  • thread_local
  • mutable
1 - cv限定符
  • const
  • volatile

image-20210407221902734

2 - mutable

可以用mutable指出,即使结构或类变量为const,其某个成员也可以被修改。例如:

1
2
3
4
5
6
7
8
9
struct data
{
char name[30];
mutable int accesses;
...
}

const data veep = {"Claybourne Clodde", 0, ...};
veep.accesses++; // allowed
3 - 再谈const

const除了使被其修饰的变量不可修改,只可读不可写,还使全局变量的链接性变成内部链接性。因此可以在头文件中定义一组const变量,并在同一个程序的多个文件中包含该头文件。

出于某种原因,如果程序员希望某个全局const变量的链接性为外部的,可以使用extern关键字来覆盖默认的内部链接性:

1
extern const int states = 50; // 定义声明,外部链接性

9.2.5 函数和链接性

所有函数的存储持续性为静态存储持续性。

函数的链接性默认为外部链接性,同样遵循单定义原则,只能有一个定义,可以有多个声明。将函数原型写在头文件中,并在其他使用该函数的文件中包含该头文件,就是多个声明,一个定义。可以使用static关键字修饰函数,使得其链接性为内部链接性,在原型和定义上都加上static。

内联函数不受但定义规则的约束,可能是因为inline关键字与const关键字和static关键字一样,将外部链接性改为了内部链接性,因此内联函数可以防在头文件中。

image-20210407223616291

9.2.6 语言的链接性

语言的链接性就是我们之前提到的C++不能直接调用C语言的库,因为编译器的名称修饰不同。更广泛的说,不同编译器编译出的目标文件不能直接相互链接,因为一个编译器与另一个编译器的名称修饰不同,导致找不到函数定义。

image-20210407223848384

9.2.7 存储方案和动态分配

动态内存由运算符new和delete控制,而不是由作用域和链接性规则控制,内存分配和释放顺序取决于new和delete在何时以何种方式被使用。

通常,编译器使用三块内存:

  • 静态变量
  • 自动变量
  • 动态存储
1 - 使用new运算符初始化

C++98: 括号语法,也适用于有合适构造函数的类。

1
2
int *pi = new int (6);            // *pi is 6
double *pd = new double (99.99); // *pd set to 99.99

C++11:初始化列表

1
2
3
4
5
struct where { double x; double y; double z; };
where *one = new where {2.5, 5.3, 7.2};
int *ar = new int [4] {2, 4, 6, 7};
int *pin = new int {6};
int *pdo = new double {99.99};
2 - new失败时

在最初的十年中,C++在这种情况下让new返回空指针,但现在将引发异常std::bad_alloc。

3 - new: 运算符、函数和替换函数

运算符new和new[]分别调用如下函数:

1
2
void * operator new(std::size_t);
void * operator new[](std::size_t);

这些函数被称为分配函数,位于全局命名空间中。同样,也有由delete和delete[]调用的释放函数:

1
2
void operator delete(void *);
void operator delete[](void *);

int *pi = new int;将被转换成int *pi = new(sizeof(int));;而int *pa = new int [40];将被转换成int *pa = new(40 * sizeof(int));。

同样,对于delete pi;,将转换成函数调用delete(pi);。

4 - 定位new运算符

定位new运算符能够让程序员指定要使用的位置。要使用定位new运算符,首先需要包含头文件new,它提供了这种版本的new运算符的原型,然后将new运算符用于提供了所需地址的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// newplace.cpp -- using placement new
#include <iostream>
#include <new>

const int BUF = 512;
const int N = 5;
char buffer[BUF];

int main()
{
using namespace std;
double *pd1, *pd2;
int i;
cout << "Calling new and placement new:\n";
pd1 = new double[N];
pd2 = new (buffer) double[N];
for (i = 0; i < N; i++)
pd2[i] = pd1[i] = 1000 + 20.0 * i;
cout << "Memory addresses:\n" << " heap: " << pd1
<< " static: " << (void *)buffer << endl;
cout << "Memory contents:\n";
for (i = 0; i < N; i++)
{
cout << pd1[i] << " at " << &pd1[i] << "; ";
cout << pd2[i] << " at " << &pd2[i] << endl;
}

cout << "\nCalling new and placement new a second time:\n";
double *pd3, *pd4;
pd3 = new double[N]; // find new address
pd4 = new (buffer) double[N]; // overwrite old data
for (i = 0; i < N; i++)
pd4[i] = pd3[i] = 1000 + 40.0 * i;
cout << "Memory contents:\n";
for (i = 0; i < N; i++)
{
cout << pd3[i] << " at " << &pd3[i] << "; ";
cout << pd4[i] << " at " << &pd4[i] << endl;
}

cout << "\nCalling new and placement new a third time:\n";
delete[] pd1;
pd1 = new double[N];
pd2 = new (buffer + N * sizeof(double)) double[N];
for (i = 0; i < N; i++)
pd2[i] = pd1[i] = 1000 + 60.0 * i;
cout << "Memory contents:\n";
for (i = 0; i < N; i++)
{
cout << pd1[i] << " at " << &pd1[i] << "; ";
cout << pd2[i] << " at " << &pd2[i] << endl;
}

delete[] pd1;
delete[] pd3;
return 0;
}

上面是一段示例程序,输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Calling new and placement new:
Memory addresses:
heap: 0x1c1440 static: 0x408040
Memory contents:
1000 at 0x1c1440; 1000 at 0x408040
1020 at 0x1c1448; 1020 at 0x408048
1040 at 0x1c1450; 1040 at 0x408050
1060 at 0x1c1458; 1060 at 0x408058
1080 at 0x1c1460; 1080 at 0x408060

Calling new and placement new a second time:
Memory contents:
1000 at 0x1c1470; 1000 at 0x408040
1040 at 0x1c1478; 1040 at 0x408048
1080 at 0x1c1480; 1080 at 0x408050
1120 at 0x1c1488; 1120 at 0x408058
1160 at 0x1c1490; 1160 at 0x408060

Calling new and placement new a third time:
Memory contents:
1000 at 0x1c1440; 1000 at 0x408068
1060 at 0x1c1448; 1060 at 0x408070
1120 at 0x1c1450; 1120 at 0x408078
1180 at 0x1c1458; 1180 at 0x408080
1240 at 0x1c1460; 1240 at 0x408088

对于常规new运算符而言,如果不使用delete释放已分配的堆上内存,则再次使用new运算符分配内存时,将会在堆上寻找一块新的内存空间。使用常规new运算符分配的内存空间要使用delete释放,否则会发生内存泄漏。

对于定位new运算符而言,上面的代码所给出的地址为静态存储区的地址,因此不能使用delete运算符进行释放,delete只能用于指向常规new运算符分配的堆内存。另外,定位new运算符不会检查给它的地址空间已被使用,而是直接使用该地址进行覆盖分配。

定位new运算符可与初始化结合使用,从而将信息放在特定的硬件地址处。

默认的定位new运算符工作原理:它只是返回传递给它的地址,并将其强制转换为void *,以便能够赋给任何指针类型。当然,这只是默认定位new运算符,C++允许程序员重载定位new函数。

原型:

image-20210408101928437

9.3 名称空间

9.3.1 传统的C++名称空间

  • 声明区域(declaration region):声明区域是可以在其中进行声明的区域
  • 潜在作用域(potential scope):变量的潜在作用域从声明点开始,到其声明区域的结尾
  • 作用域(scope):变量对程序而言可见的范围被称为作用域

潜在作用域比声明区域小,这是由于变量必须定义后才能使用。变量并非在其潜在作用域内的任何位置都是可见的,例如,它可能被另一个在嵌套声明区域中声明的同名变量隐藏,或者在函数中声明的局部变量将隐藏在同一个文件中声明的全局变量。

image-20210408103129019

image-20210408103141409

C++关于全局变量和局部变量的规则定义了一种名称空间层次。每个声明区域都可以声明名称,这些名称独立于在其他声明区域中声明的名称。在一个函数中声明的局部变量不会与另一个函数中声明的局部变量发生冲突。

9.3.2 新的名称空间特性

C++新增了这样一种功能,即通过定义一种新的声明区域来创建命名的名称空间,这样做的目的之一是提供一个新的声明名称的区域。一个名称空间中的名称不会与另一个名称空间的相同名称发生冲突,同时允许程序的其他部分使用该名称空间中声明的东西。

名称空间可以是全局的,也可位于另一个名称空间中,但不能位于代码块中。默认情况下,在名称空间中声明的名称的链接性为外部的(除非它引用了常量)。

除了用户定义的名称空间外,还存在另一个名称空间——全局名称空间(global namespace)。它对应于文件级声明区域,因此前面所说的全局变量现在被描述为位于全局名称空间中。

任何名称空间中的名称都不会与其他名称空间中的名称发生冲突。

访问方式:作用域解析符::。

1 - using声明和using编译指令

C++提供两种机制(using声明和using编译指令)来简化对名称空间中名称的使用。

  • using声明使特定的标识符可用:using声明将特定的名称添加到它所属的声明区域中。using std::cin;。
  • using编译指令使整个名称空间可用。using namespace std;。
2 - using编译指令和using声明之比较

使用using编译指令导入一个名称空间中所有的名称与使用多个using声明是不一样的,using编译指令更像是大量使用作用域解析运算符。

使用using声明时,就好像声明了相应的名称一样,如果某个名称已经在函数中声明了,则不能使用using声明导入相同的名称。

然而,使用using编译指令时,将进行名称解析,就像在包含using声明和名称空间本身的最小声明区域中声明了名称一样。看下面的代码就能明白:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
namespace Jill {
double bucket(double n) { ... }
double fetch;
struct Hill { ... };
}

char fetch; // global namespace

int main()
{
using namespace Jill; // hide global fetch
Hill Thrill; // create a type Jill::Hill structure
double water = bucket(2); // use Jill::bucket();
double fetch; // not an error; hides Jill::fetch
cin >> fetch; // read a value into the local fetch
cin >> ::fetch; // read a value into the global fetch
cin >> Jill::fetch; // read a value into Jill::fetch
...
}

void foo()
{
Hill top; // error
Jill::Hill crest; // Valid
}

void bar()
{
double fetch;
using Jill::fetch; // error
}

在函数中使用using编译指令时,命名空间变量覆盖全局变量,而局部变量覆盖命名空间变量。

3 - 名称空间的其他特性

可以将名称空间声明进行嵌套:

1
2
3
4
5
6
7
8
9
namespace elements
{
namespece fire
{
int flame;
...
}
float water;
}

可以在名称空间中使用using编译指令和using声明:

1
2
3
4
5
6
7
namespace myth
{
using Jill::fetch;
using namespace elelemts;
using std::cout;
using std::cin;
}

uisng编译指令时可以传递的:

1
2
3
4
5
using namespace myth;

// 等价于
using namespace myth;
using namespace elements;

可以给名称空间创建别名:

1
2
3
namespace alias = myth;
namespace MEF = myth::elements::file;
using MEF::flame;
4 - 未命名的名称空间

可以通过省略名称空间的名称来创建未命名的名称空间:

1
2
3
4
5
namespace
{
int ice;
int bandycoot;
}

这就像后面跟着using编译指令一样,也就是说,在该名称空间声明的名称的潜在作用域为从声明点到该声明区域末尾。从这个方面看,它们与全局变量相似,但是,由于没有名称,因此不能显示使用using编译指令或using声明来使它们在其他位置都可用。具体地说,不能在未命名名称空间所属文件之外地其他文件中,使用该名称空间中的名称。这提供了链接性为内部的静态变量的替代品。

1
2
3
4
5
6
7
8
9
10
11
static int counts;
int other();
int main()
{
...
}

int other()
{
...
}

采用名称空间的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace
{
int counts;
}
int other();
int main()
{
...
}

int other()
{
...
}
5 - 命名空间及其用途

多个文件中,多个同名的名称空间实际上都是一个名称空间,这就是为什么名称空间可以写在头文件中的原因。

下面是当前的一些指导原则:

image-20210408121244544

老式头文件(如stdio.h)没有使用命名空间,但新头文件cstdio使用了名称空间。

第10章 对象和类

面向对象4大特性:

  • 抽象
  • 封装
  • 继承
  • 多态

10.1 抽象和类

10.1.1 - 类型是什么?

基本类型完成了三项定义工作:

  • 决定数据对象需要的内存数量
  • 决定如何解释内存中的位
  • 决定可使用数据对象执行的操作或方法

10.1.2 - C++中的类

类是一种将抽象转换为用户定义类型的C++工具,它将数据表示和操纵数据的方法组合成一个整洁的包。一般来说,类规范由两个部分组成:

  • 类声明:以数据成员的方式描述数据部分,以成员函数的方式描述共有接口
  • 类方法定义:描述如何实现类成员函数

简单地说,类声明提供了类的蓝图,而方法定义则提供了细节。通常,C++程序员将类定义放在头文件中,并将类方法的代码放在源代码中。

数据隐藏是一种封装,将实现的细节隐藏在私有部分中。将类函数定义和类声明放在不同的文件中也是封装。

C++类的成员访问控制权限默认为private。

image-20210409143519747

10.1.3 实现类的成员函数

成员函数定义与常规函数定义非常相似,它们有函数头和函数体,也可以有返回类型和参数,但它们还有两个特殊的特征:

  • 定义成员函数时,使用作用域解析运算符::来标识函数所属的类
  • 类的成员函数可以访问类的private组件
内联方法

定义位于类声明中的函数都将自动成为内联函数。类声明常将短小的成员函数作为内联函数。

如果愿意,也可以在类声明之外定义成员函数,并使其成为内联函数。为此,只需在类实现部分中定义函数时使用inline限定符即可。

根据改写规则(rewrite rule),在类声明中定义方法等同于用原型替换方法定义,然后再类声明的后面将定义改写为内联函数。也就是说,下面两段代码实际上是等价的:

1
2
3
4
5
6
7
8
9
10
11
12
// stock00.h
#pragma once
#ifndef STOCK00_H_
#define STOCK00_H_

class Stock
{
public:
void set_tot() { total_val = shares * share_val; }
};

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// stock00.h
#pragma once
#ifndef STOCK00_H_
#define STOCK00_H_

class Stock
{
public:
void set_tot();
};

#endif

// stock00.cpp
inline void Stock::set_tot()
{
total_val = shares * share_val;
}
类对象

所创建的每个新对象都有自己的存储空间,用于存储其内部变量和类成员;但同一个类的所有对象共享一组类方法,即每个方法只有一个副本。

10.3 类的构造函数和析构函数

10.3.1 声明和定义构造函数

构造函数没有返回类型,连void都不能带,函数名为类名。

10.3.2 使用构造函数

显示调用:

1
Stock food = Stock("World Cabbage", 250, 1.25);

隐式调用:

1
Stock food("World Cabbage", 250, 1.25);

显示调用和隐式调用二者是等价的。

创建指针变量:

1
Stock *pstock = new Stock("Electroshock Games", 18, 19.0);

无法使用对象来调用构造函数,因为在构造函数构造出对象之前,对象是不存在的。因此构造函数被用来创建对象,而不能通过对象来调用。

10.3.3 默认构造函数

默认构造函数是不带参数的构造函数。当且仅当没有显示提供任何构造函数,则C++将自动提供默认构造函数。而编译器提供的默认构造函数的函数体不做任何初始化工作:

1
Stock::Stock() { }

如果提供了非默认构造函数,则编译器不再提供默认构造函数,如果需要,需要自己动手写,否则,在未提供默认构造函数的情况下调用默认构造函数将出错。

定义默认构造函数的方式有两种:

  • 给已有的构造函数的所有参数提供默认值:

    1
    Stock(const string &co = "", int n = 0, double pr = 0.0) { ... }
  • 通过函数重载来定义另一个构造函数——一个没有参数的构造函数:

    1
    Stock() { ... }

由于只能有一个默认构造函数,因此不要同时采用这两种方式。

在设计类时,通常应提供对所有类成员做隐式初始化的默认构造函数。

1
2
3
4
5
6
Stock first;                  // OK, call default constructor implicitly
Stock second(); // Declare a function, a function prototype
Stock third = Stock(); // OK, call default constructor explicitly
Stock forth = Stock; // INVALID
Stock *prelief = new Stock; // OK, call default constructor implicitly
Stock *ps = new Stock(); // OK, call default constructor explicitly

10.3.4 析构函数

用构造函数创建过对象后,程序负责跟踪该对象,直到其过期为止。对象过期时,程序将自动调用一个特殊的成员函数,该函数叫做析构函数。析构函数完成清理工作,因此实际上很有用。例如,如果构造函数使用new来分配内存,则析构函数将使用delete来释放这些内存。

如果程序员没有提供析构函数,编译器将隐式地声明一个默认析构函数,并在发现导致对象被删除地代码后,提供默认析构函数的定义。

析构函数的名称为~加上类名,且不带任何参数,无返回类型:

1
2
3
4
5
// prototype
~Stock();

// definition
Stock::~Stock() { ... }

什么时候应调用析构函数呢?这由编译器决定。通常不应在代码中显示地调用析构函数(也有例外情况)。

  • 如果创建的是静态存储类对象,则其析构函数在程序结束时自动被调用。
  • 如果创建的是自动存储类对象,则其析构函数在程序执行完代码块(该对象是在其中定义的)时自动被调用。
  • 如果对象是通过new创建的,则它将驻留在栈内存或自由存储区中,当使用delete来释放内存时,其析构函数将自动被调用。
  • 程序可以创建临时对象来完成特定的操作,在这种情况下,程序将在结束对该对象的使用时自动调用其析构函数。
1
2
Stock stock1 = Stock("Boffo Objects", 2, 2.0);  // initialization, maybe create a temporary variable, maybe not
stock1 = Stock("Nifty Foods", 10, 50.0); // assignment, a temporary variable created.

10.3.5 C++11列表初始化

1
2
3
Stock hot_tip = {"Derivatives Plus Plus", 100, 45.0};  // 调用有参构造函数
Stock jock {"Sport Age Storage, Inc"}; // 调用有参构造函数
Stock temp {}; // 调用默认构造函数

10.3.6 const成员函数

const对象将无法调用共有的常规成员函数,因为常规成员函数无法保证const对象不被修改。为此,C++的解决方案是将const关键字放在函数的括号后面。

1
2
3
4
5
6
7
8
9
10
11
class Stock
{
public:
...
void show() const;
};

void Stock::show() const
{
...
}

以这种方式声明和定义的成员函数被称为const成员函数。只要成员方法不修改调用对象,就应该将其声明为const。

const对象只能调用const成员函数,而非const对象既可以调用非const成员函数,也可以调用const成员函数。该const实际上是将成员函数的this指针限定为const。

10.4 this指针

this指针指向用来调用成员函数的对象(this被作为隐藏参数传递给方法)。一般来讲,所有的类方法都将this指针设置为调用它的对象的地址。

每个成员函数(包括构造函数和析构函数)都有一个this指针。this指针指向调用对象。如果方法需要引用整个调用对象,则可以使用表达式*this。在函数的括号后面使用const限定符将this限定为const,这样将不能使用this来修改对象的值。

10.5 对象数组

要创建对象数组,这个类必须有默认构造函数。初始化对象数组的方案是,首先使用默认构造函数创建数组元素,如果有列表初始化,则花括号中的构造函数将创建临时对象,然后将临时对象的内容复制到相应的元素中。

1
2
3
4
5
6
7
Stock mystuff[4];   // create 4 Stock object by default constructor.
Stock stocks[4] = { // create 4 Stock object by default constructor and copy from temporary object
Stock("NanoSmart", 12.5, 20),
Stock(),
Stock("Monolithic Obelisks", 130, 3.25),
Stock("Fleep Enterprises", 60, 6.5)
};

10.6 类作用域

C++引入了一种新的作用域:类作用域。在类中定义的名称(如类数据成员名和类成员函数名)的作用域都为整个类,作用域为整个类的名称只会在该类中是已知的,在类外是不可知的。因此,可以在不同类中使用相同的类成员名而不会引起冲突。另外,类作用域意味着不能从外部直接访问类的成员,公有成员函数也是如此。要调用公有成员函数,必须通过对象。而在定义成员函数时,必须使用作用域解析运算符。

10.6.1 作用域为类的常量

在类中无法直接使用const声明常量。也就是说,下面的代码将会编译错误:

1
2
3
4
5
6
7
class Bakery
{
private:
const int Months = 12;
double costs[Months]; // compile error
...
};

错误信息为”非静态成员引用必须与特定对象相对”。声明类只是描述了对象的形式,并没有创建对象,在创建对象前,将没有用于存储值的空间。

在类中定义常量有两种方式:

  • 在类中声明一个枚举。在类声明中声明的枚举的作用域为整个类,因此可以用枚举为整型常量提供作用域为整个类的符号名称

    1
    2
    3
    4
    5
    6
    7
    class Bakery
    {
    private:
    enum { Months = 12 };
    double costs[Months];
    ...
    };

    注意,用这种方式声明枚举不会创建类数据成员,也就是说,所有对象中都不包含枚举。Months只是一个符号名称,在作用域为整个类的代码中遇到它时,编译器将用12来替换它。

  • 使用static关键字

    1
    2
    3
    4
    5
    6
    7
    class Bakery
    {
    private:
    static const int Months = 12;
    double costs[Months];
    ...
    };

    这将创建一个名为Months的常量,该常量将与其他静态变量存储在一起,而不是存储在对象中。因此,只有一个Months常量,被所有Bakery对象共享。在C++98中,只能使用这种技术声明值为整数或枚举的静态常量,而不能存储double常量。C++11消除了这种限制。

10.6.2 作用域内枚举(C++11)

传统枚举存在一些问题,其中之一是两个枚举定义中的枚举量可能发生冲突:

1
2
enum egg { Small, Medium, Large, Jumbo };
enum t_shirt { Small, Medium, Large, Xlarge };

这无法通过编译,因为egg的Small和t_shirt的Small位于同一作用域内,它们将发生冲突。作用域内枚举:

1
2
3
4
5
enum class egg { Small, Medium, Large, Jumbo };
enum class t_shirt { Small, Medium, Large, Xlarge };

egg choice = egg::Large;
t_shirt Floyd = t_shirt::Large;

作用域枚举消除了冲突,此外,还提高了枚举的类型安全,不能隐式转换为整型:

1
2
3
4
5
6
7
8
9
10
enum egg_old { Small, Medium, Large, Jumbo };
enum class t_shirt { Small, Medium, Large, Xlarge };
egg_old one = Medium;
t_shirt rolf = t_shirt::Large;
int king = one; // OK
int ring = rolf; // INVALID
if (king < Jumbo) // OK
...
if (king < t_shirt::Medium) // INVALID
...

但必要时,可执行强制类型转换:

1
int Frodo = int(t_shirt::Small);  // OK, Frodo set to 0

枚举用某种底层整型类型表示,在C++98中,如何选择取决于实现,因此包含枚举的结构的长度可能随系统而异。对于作用域内枚举,C++11消除了这种依赖性。默认情况下,C++11作用域内枚举的底层类型为int。另外,还提供了一种语法,可用于做出不同的选择:

1
2
// underlying type for pizza is short
enum class : short pizza { Small, Medium, Large, XLarge };

: short将底层类型指定为short。在C++11中,也可使用这种语法来指定常规枚举类型的底层类型。

10.7 抽象数据类型

抽象数据类型(abstract data type, ADT),以通用的方式描述数据类型,而没有引入语言或实现细节,是一种非常友好的方式。

第11章 使用类

11.1 运算符重载

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// mytime1.h
#pragma once
#ifndef MYTIME0_H_
#define MYTIME0_H_

class Time
{
public:
Time();
Time(int h, int m = 0);
...
Time operator+(const Time &t) const;
...
private:
int minutes;
int hours;
};


#endif


// mytime1.cpp
#include <iostream>
#include "mytime1.h"

...

Time Time::operator+(const Time &t) const
{
Time sum;
sum.minutes = minutes + t.minutes;
sum.hours = hours + t.hours + sum.minutes / 60;
sum.minutes %= 60;
return sum;
}

经过上面的函数声明和函数定义,可以对Time对象使用+运算符:

1
2
3
4
Time coding(2, 40);
Time fixing(5, 55);
Time total;
total = coding + fixing;

注意一个点:

1
2
Time t1, t2, t3, t4;
t4 = t1 + t2 + t3;

t4 = t1 + t2 + t3;等价于t4 = t1.operator+(t2 + t3);,进而等价于t4 = t1.operator+(t2.operator+(t3));。

1.11.1 重载限制

多数C++运算符都可以使用上述的方式重载。重载的运算符不必是成员函数(有些情况例外),但必须至少有一个操作数是用于定义的类型。

  • 重载后的运算符至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符。
  • 使用运算符时不能违反运算符原来的句法规则,也不能修改运算符的优先级。
  • 不能创建新的运算符。
  • 不能重载下面的运算符:
    • sizeof
    • .:成员运算符
    • .*:成员指针运算符
    • :::作用域解析运算符
    • ?::条件运算符
    • typeid:一个RTTI运算符
    • const_cast
    • dynamic_cast
    • reinterpret_cast
    • static_cast
  • 表11.1中大多数运算符都可以通过成员或非成员函数进行重载,但下面的运算符只能通过成员函数进行重载
    • =
    • ()
    • []
    • ->

image-20210412125511819

image-20210412125522863

11.2 友元

通常,只能通过类的公有方法访问类对象的私有部分,友元提供了访问类的私有成员的新方式。友元有3种:

  • 友元函数
  • 友元类
  • 友元成员函数

可以通过让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限。这里先介绍友元函数。

友元函数的应用场景大多在与二元运算符重载。如果使用成员函数进行运算符重载,那么只能是对象在前,如:

1
2
3
4
5
6
7
8
9
class Time
{
public:
...
Time operator*(double n);
...
};

A = B * 2.75;

上面的语句将被转换成下面的成员函数调用:

1
A = B.operator*(2.75);

但是,下面这样写就不行了:

1
A = 2.75 * B;

如此只能使用非成员函数来重载运算符,但是非成员函数无法访问类对象的私有成员,因此需要使用友元函数。

  • 创建友元函数的第一步是将函数原型放在类声明中,并在原型前加上关键字friend:

    1
    2
    3
    4
    5
    6
    7
    class Time
    {
    public:
    ...
    friend Time operator*(double n, const Time & t);
    ...
    };

    虽然operator*函数是在类中声明的,但它不是成员函数,因此不能使用成员运算符来调用。然而,虽然不是成员函数,但是访问权限同成员函数。

  • 第二步是编写函数定义,由于它不是成员函数,所以不要使用类作用域限定符

image-20210412195311312

实际上,也可以使用非友元函数调用成员函数:

1
2
3
4
Time operator*(double m, const Time &t)
{
return t * m; // use t.operator*(m);
}

11.2.1 常用的友元:重载<<运算符

重载<<运算符可直接使用cout作用于类对象以实现对类的信息的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Time
{
public:
...
friend ostream &operator<<(ostream &os, const Time &t);
...
};

ostream &operator<<(ostream &os, const Time &t)
{
os << t.hours << " hours, " << t.minutes << " minutes";
return os;
}

11.3 重载运算符:作为成员函数还是非成员函数

非成员函数版本的重载运算符函数所需的形参数目与运算符使用的操作数目相同;而成员版本所需的参数数目少一个,因为其中的一个操作数是被隐式地传递的调用对象(this指针)。

11.3.1 随机数

标准ANSI C库(C++也有)中有一个rand()函数,它返回一个从0到某个值(取决于实现)之间的随机整数。rand()函数将一种算法用于一个初始种子值来获得随机数,该随机值将用作下一个函数调用的种子,依此类推。srand()函数允许覆盖默认的种子值,重新启动另一个随机数序列。time(0)返回当前时间,通常为从1970年1月1日0点到当前时间的描述,返回值类型为time_t。使用srand(time(0))在每次程序运行时,都设置不同的种子。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <cstdlib>
#include <ctime>

int main()
{
srand(time(0));
for (int i = 0; i < 10; i++)
std::cout << rand() % 100 << std::endl;
return 0;
}

11.4 类的自动转换和强制类型转换

能接受一个参数的构造函数可以作为转换函数,为将类型与该参数类型相同的值转换为类提供了蓝图。可以使用explicit关键字关闭这种自动类型转换,但仍然允许强制类型转换。

只接受一个参数的构造函数定义了从参数类型到类类型的转换。如果使用关键字explicit限定了这种构造函数,则它只能用于显示转换,否则也可以用于隐式转换。

1
2
3
4
5
6
7
class Stonewt
{
public:
...
Stonewt(double lbs);
...
};
1
Stonewt st = 30.0;  // the same as Stonewt st(30.0);

在不使用explicit关闭自动类型转换时,将在以下时刻执行参数类型到类类型自动类型转换:

  • 将类类型对象初始化为参数类型值时

    1
    Stonewt st = 30.0;  // the same as Stonewt st(30.0);
  • 将参数类型值赋值给类类型对象时

    1
    st = 25.3;  // use Stonewt(25.3) to create a temporary object, then call operator =
  • 将参数类型值传递给接受类类型参数的函数时

    1
    2
    3
    void func(Stonewt st);

    func(25.3);
  • 返回值被声明为类类型的函数试图返回参数类型值时

    1
    2
    3
    4
    5
    6
    Stonewt func2(double n)
    {
    double temp;
    ...
    return temp;
    }
  • 在上述任意一种情况下,使用可转换为参数类型的内置类型时

    1
    2
    3
    Stonewt s1 = 30;
    s1 = 4;
    func(3);

    这3条语句将先将int类型转换为double类型,再调用Stonewt(double)构造函数。然而,当且仅当转换不存在二义性时,才会进行这种二步转换。也就是说,如果这个类还定义了构造函数Stonest(long),则编译器将拒绝这些语句,因为int可被转换为long或double,因此调用存在二义性。

11.4.1 转换函数

上面介绍了从参数类型到类类型的自动类型转换,那么是否可以执行相反的转换?可以,需要在类中定义转换函数。转换函数的原型如下:

1
operator typeName();

注意点:

  • 转换函数必须是类方法
  • 转换函数不能指定返回类型
  • 转换函数不能有参数

typeName指出了要转换成的类型,因此不需要指定返回类型。转换函数是类方法,意味着它需要通过对象来调用,从而告知函数要转换的值。因此,函数不需要参数。对转换函数的定义使用类作用域解析运算符时,是写在operator前:

1
2
3
4
className::operator typeName()
{
...
}

如果定义多个转换函数,在cout时如果不显示写出类型转换,将会出现二义性。只定义一个转换函数则不会。赋值情况也是如此,如果定义了对double和int的转换函数,那么赋给long类型将会出现二义性。

同样,转换函数在C++11中可对转换函数的原型使用explicit关键字取消自动类型转换。

11.4.2 转换函数和友元函数

一个简单的例子解释二义性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// stonewt.h
#pragma once
#ifndef __STONEWT_H__
#define __STONEWT_H__

class Stonewt
{
private:
enum { LBS_PER_STN = 14 };
int stone;
double pds_left;
double pounds;
public:
Stonewt() { stone = pds_left = pounds = 0; }
Stonewt(int stn, double lbs);
Stonewt(double lbs);
~Stonewt() {}
void show_lbs() const;
void show_stn() const;

// conversion functions
// operator int();
operator double();

// 运算符重载
// Stonewt operator+(const Stonewt &st) const; // 成员函数
friend Stonewt operator+(const Stonewt &st1, const Stonewt &st2); // 友元函数
};

#endif

// stonewt.cpp
#include "stonewt.h"
#include <iostream>
using std::cout;

Stonewt::Stonewt(int stn, double lbs)
{
stone = stn;
pds_left = lbs;
pounds = stone * LBS_PER_STN + pds_left;
}

Stonewt::Stonewt(double lbs)
{
cout << "call constructor with one argument\n";
stone = int(lbs) / LBS_PER_STN;
pds_left = int(lbs) % LBS_PER_STN + lbs - int(lbs);
pounds = lbs;
}

void Stonewt::show_lbs() const
{
cout << pounds << " pounds\n";
}

void Stonewt::show_stn() const
{
cout << stone << " stones, " << pds_left << " pounds\n";
}

Stonewt::operator int()
{
return int(pounds + 0.5); // 四舍五入
}

Stonewt::operator double()
{
return pounds;
}

// Stonewt Stonewt::operator+(const Stonewt &st) const
// {
// double pds = pounds + st.pounds;
// // Stonewt sum(pds);
// // return sum;
// return pds;
// }

Stonewt operator+(const Stonewt &st1, const Stonewt &st2)
{
double pds = st1.pounds + st2.pounds;
return pds;
}

// main.cpp
#include <iostream>
#include "stonewt.h"
using namespace std;

int main()
{
Stonewt st1 = 30.0;
Stonewt st2 = 30.0;
Stonewt sum = 20.0 + 30.0; // 生成临时对象,并调用拷贝构造函数
Stonewt sum2 = 20.0 + st1; // compile error
return 0;
}

上面的代码会发生编译错误,原因在于main函数中sum2所在行代码。这里,有两种解释:

  • 编译器检查是否对+做了运算符重载,代码中使用友元函数对+运算符进行了重载,于是看类型,20.0为double类型,而友元函数的第一个参数为const Stonewt类型的引用,于是检查是否定义了接受一个double类型参数的构造函数,确实定义了,因此将20.0通过该构造函数生成一个Stonewt类型的对象,进而执行对Stonewt类型的加法。
  • 由于类中定义了特殊的成员函数——转换函数,因此st1将转换为double类型,执行对double类型的加法。

于是乎,存在二义性,编译报错。

应谨慎地使用隐式转换函数。通常,最好选择仅在被显示调用时才会执行的函数。

第12章 类和动态内存分配

12.1 动态内存和类

12.1.1 类的静态成员

1
2
3
4
5
6
7
8
9
10
11
12
// stringbad.h
class StringBad
{
private:
static int num_strings;
...
};

// strinbad.cpp
#include "stringbad.h"

int StringBad::num_strings = 0;

静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类,且不需要加static关键字。但如果静态成员是const整型或枚举类型,则可以在类声明中初始化。

至于为什么不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。

类的静态成员为类所有,为所有对象共用,只有一份数据拷贝。

在构造函数中使用new类分配内存时,必须在相应的析构函数中使用delete释放内存。如果使用new[]来分配内存,则应使用delete[]来释放内存。

12.1.2 特殊成员函数

C++自动提供了下面这些成员函数:

  • 默认构造函数,如果没有定义构造函数
  • 默认析构函数,如果没有定义
  • 复制构造函数,如果没有定义
  • 赋值运算符,如果没有定义
  • 地址运算符,如果没有定义

更准确地说,编译器将生成上述最后三个函数的定义——如果程序使用对象的方式要求这样做。例如,如果您将一个对象赋给另一个对象,编译器将提供赋值运算符的定义。

隐式地址运算符返回调用对象的地址(即this指针的值)。

C++11提供了另外两个特殊成员函数:

  • 移动构造函数
  • 移动赋值运算符
1 - 默认构造函数

如果没有提供任何构造函数,C++将创建默认构造函数:

1
2
3
4
5
6
7
8
class ClassName
{
...
public:
ClassName() {}
...
...
};

如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显示地对它进行初始化,必须显示定义默认构造函数。

带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。但不能同时定义一个无参构造函数和所有参数都有默认值的构造函数,这会导致二义性。

2 - 复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程(包括按值传递参数),而不是常规的赋值过程。类的复制构造函数原型通常如下:

1
ClassName(const ClassName &);

对于复制构造函数,需要明确两点:何时调用、有何功能。

何时调用:

新建一个对象并将其初始化为同类现有对象时、按值传递函数参数时、返回对象时以及编译器生成临时对象时,复制构造函数都将被调用:

1
2
3
4
StringBad ditto(motto);
StringBad metoo = motto;
StringBad also = StringBad(motto);
String *psb = new StringBad(motto);

中间两种可能会使用复制构造函数直接创建metoo和also,也可能先使用复制构造函数生成临时对象,再调用赋值运算符,这两种情况将取决于具体的实现,通常现在的编译器采取第一种做法。

有何功能:

默认的复制构造函数逐个复制非静态成员,复制的是成员的值,为浅复制。如果成员本身就是类对象,则使用这个类的复制构造函数来复制成员对象。静态成员不受影响,因为它们属于整个类,而不是某个对象。

如果类中包含这样的静态数据成员,即其值将再新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理计数问题。

如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。

3 - 赋值运算符

ASCI C允许结构赋值,而C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下:

1
ClassName &operator=(const ClassName &);

它接受并返回一个指向类对象的引用。

赋值运算符的功能是将已有对象赋给另一个对象,即将已有的对象赋给另一个对象时,将使用重载的赋值运算符:

1
2
3
StringBad knot;
knot = headline1;
knot = StringBad(...);

初始化对象时,并不一定会使用赋值运算符。

与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个赋值,如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来赋值该成员,但静态数据成员不受影响。当然,默认的赋值运算符重载也是浅复制。

12.2 静态类成员函数、更多的运算符重载

12.2.1 静态类成员函数

可以将成员函数声明为静态的,函数声明必须包含关键字static,如果函数定义是独立的,则其中不能包含关键字static。

不能通过对象调用静态成员函数。实际上,静态成员函数甚至不能使用this指针。如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。

由于静态成员函数不与特定的对象关联,因此只能使用静态数据成员。

12.2.2 重载[]

可以对[]运算符进行重载,使得类对象能够像访问数组元素一样使用下标访问自身的数据。注意两种形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// String.h
class String
{
private:
char *str;
int len;
...
public:
...
char &operator[](int index); // 1
const char &opeartor[](int index) const; // 2
};

// String.cpp
char &String::operator[](int index)
{
return str[index];
}

const char &String::operator[](int index) const
{
return str[index];
}

方式1的重载可以使得String类的对象可以像数组一样使用下标访问和修改数据。而方式二可以使得const String类对象使用下标访问数据,但是不能修改数据。

12.2.3 进一步重载赋值运算符

对于String类来讲,如果经常使用st = "hello";这种语句,调用单参数构造函数生成临时对象,再调用赋值运算符重载进行拷贝,则不如定义一个针对const char*类型的赋值运算符重载:

1
2
3
4
5
6
7
8
9
10
11
String &operator=(const char *s);

String &String::operator=(const char *s)
{
delete [] str;
str = null;
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
return *this;
}

这样一来,可以省去调用一次构造函数生成临时对象,提高执行效率。

12.3 使用指向对象的指针

12.3.1 析构函数调用时机

在下述情况下析构函数将被调用:

  • 如果对象是自动变量,当执行完定义该对象的程序块时,将调用该对象的析构函数。
  • 如果对象是静态变量,则在程序结束时将调用对象的析构函数。
  • 如果对象是用new创建的,则仅当显示使用delete删除对象时,其析构函数才会被调用。

12.3.2 指针和对象小结

  • 使用常规表示法来声明指向对象的指针:

    1
    String *glamour;
  • 可以将指针初始化为指向已有对象:

    1
    String *first = &sayings[0];
  • 可以使用new来初始化指针,这将创建一个对象:

    1
    String *favorite = new String(sayings[choice]);
  • 对类使用new将调用相应的类构造函数来初始化新创建的对象:

    1
    2
    String *gleep = new String;             // 调用默认构造函数
    String *glop = new String("my my my"); // 调用String(const char *)构造函数
  • 可以使用->运算符通过指针访问类方法:

    1
    if (sayings[i].length() < shortest->length())
  • 可以对对象指针应用解除引用运算符*来获得对象:

    1
    2
    if (sayings[i] < *first)
    first = &sayings[i];

12.3.3 再谈定位new运算符

delete可与new运算符配套使用,但不能与定位new运算符配套使用。如果将对象使用定位new运算符创建在了某段内存上,将不能使用delete来释放它。如此,对象得不到释放,无法调用析构函数。因此,使用定位new运算符创建的对象需要显示调用析构函数,这是显示调用析构函数的一种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// placenew1.cpp -- new, placement new, no delete
#include <iostream>
#include <new>
#include <string>
using namespace std;

const int BUF = 512;

class JustTesting
{
private:
string words;
int number;

public:
JustTesting(const string &s = "Just Testing", int n = 0)
{
words = s;
number = n;
cout << words << " constructed\n";
}
~JustTesting() { cout << words << " destroyed\n"; }
void Show() const { cout << words << ", " << number << endl; }
};

int main()
{
char *buffer = new char[BUF]; // get a block of memory in heap

JustTesting *pc1, *pc2;

pc1 = new (buffer) JustTesting; // place object in buffer
pc2 = new JustTesting("Heap1", 20);

cout << "Memory block addresses:\n"
<< "buffer: "
<< (void *)buffer << " heap: " << pc2 << endl;
cout << "Memory contents:\n";
cout << pc1 << ": ";
pc1->Show();
cout << pc2 << ": ";
pc2->Show();

JustTesting *pc3, *pc4;
pc3 = new (buffer) JustTesting("Bad Idea", 6);
pc4 = new JustTesting("Heap2", 10);

cout << "Memory contents:\n";
cout << pc3 << ": ";
pc3->Show();
cout << pc4 << ": ";
pc4->Show();

delete pc2;
delete pc4;

delete[] buffer;

cout << "Done\n";

return 0;
}

上面的代码中,对pc3使用定位new运算符之前没有释放pc1指向的对象,因此,直接覆盖pc1所在的内存,程序运行后,pc1和pc3指向的对象都无法调用析构函数,下面是程序的运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Just Testing constructed
Heap1 constructed
Memory block addresses:
buffer: 0x6d1730 heap: 0x6d1940
Memory contents:
0x6d1730: Just Testing, 0
0x6d1940: Heap1, 20
Bad Idea constructed
Heap2 constructed
Memory contents:
0x6d1730: Bad Idea, 6
0x6d1970: Heap2, 10
Heap1 destroyed
Heap2 destroyed
Done

再看另一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// placenew1.cpp -- new, placement new, no delete
#include <iostream>
#include <new>
#include <string>
using namespace std;

const int BUF = 512;

class JustTesting
{
private:
string words;
int number;

public:
JustTesting(const string &s = "Just Testing", int n = 0)
{
words = s;
number = n;
cout << words << " constructed\n";
}
~JustTesting() { cout << words << " destroyed\n"; }
void Show() const { cout << words << ", " << number << endl; }
};

int main()
{
char *buffer = new char[BUF]; // get a block of memory in heap

JustTesting *pc1, *pc2;

pc1 = new (buffer) JustTesting; // place object in buffer
pc2 = new JustTesting("Heap1", 20);

cout << "Memory block addresses:\n"
<< "buffer: "
<< (void *)buffer << " heap: " << pc2 << endl;
cout << "Memory contents:\n";
cout << pc1 << ": ";
pc1->Show();
cout << pc2 << ": ";
pc2->Show();

JustTesting *pc3, *pc4;
pc3 = new (buffer + sizeof(JustTesting)) JustTesting("Better Idea", 6);
pc4 = new JustTesting("Heap2", 10);

cout << "Memory contents:\n";
cout << pc3 << ": ";
pc3->Show();
cout << pc4 << ": ";
pc4->Show();

delete pc2;
delete pc4;

// explicitly destroy placement new objects
pc3->~JustTesting();
pc1->~JustTesting();

delete[] buffer;

cout << "Done\n";

return 0;
}

这段代码的运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Just Testing constructed
Heap1 constructed
Memory block addresses:
buffer: 0x1081730 heap: 0x1081940
Memory contents:
0x1081730: Just Testing, 0
0x1081940: Heap1, 20
Better Idea constructed
Heap2 constructed
Memory contents:
0x1081758: Better Idea, 6
0x1081970: Heap2, 10
Heap1 destroyed
Heap2 destroyed
Better Idea destroyed
Just Testing destroyed
Done

所有对象都得到了释放,且buffer也被delete释放。这里需要注意的一点是,pc3位于pc1的后面,因此需要先释放,也就是说,释放的顺序与创建的顺序相反,原因在于晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区。

12.4 成员初始化列表

成员初始化列表由逗号分隔的初始化列表组成,前面带冒号,它位于参数列表的右括号之后、函数体左括号之前。

1
2
3
4
Queue::Queue(int qs) : qsize(qs)
{
...
}

只有构造函数可以使用这种初始化列表语法。此外,对于类中声明的const变量,之前我们说无法对其进行初始化,要么使用枚举,要么加上static关键字来在类中声明常量。现在,可以使用初始化列表在执行构造函数的函数体之前对const变量进行初始化。上述代码的qsize就是一个const变量。而对于被声明为引用的类成员,也必须使用这种语法进行初始化。

image-20210415111429511

12.5 C++11类内初始化

C++11允许您以更直观的方式进行初始化:

1
2
3
4
5
6
7
class Classy
{
// in-class initialization
int mem1 = 10;
const int mem2 = 20;
...
};

这与在构造函数中使用成员初始化列表等价:

1
Classy::Classy() : mem1(10), mem2(20) { ... }

除非调用了使用成员初始化列表的构造函数,在这种情况下,实际列表将覆盖这些默认初始值:

1
Classy::Classy(int n) : mem1(n) { ... }

在这里,构造函数将使用n来初始化mem1,但mem2仍被设置为20。

第13章 类继承

13.1 公有继承

公有继承,基类的公有成员将成为派生类的公有成员,基类的私有部分也成为派生类的一部分,但只能通过基类的公有和保护方法访问。派生类的对象有以下特征:

  • 派生类对象存储了基类的数据成员(派生类继承了基类的的实现)
  • 派生类对象可以使用基类的方法(派生类继承了基类的接口)

13.1.1 构造函数:访问权限的考虑

派生类不能直接访问基类的私有成员,而必须通过基类的方法进行访问。派生类构造函数必须使用基类构造函数。 创建派生类对象时,程序首先创建基类对象。从概念上说,这意味着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员初始化列表语法完成这项工作。

创建派生类对象时,程序首先调用基类的构造函数,然后再调用派生类构造函数。基类构造函数负责初始化继承的数据成员;派生类构造函数主要用于初始化新增的数据成员。派生类的构造函数总是调用一个基类构造函数。可以成员初始化列表指明要使用的构造函数,否则将使用默认的基类构造函数。派生类对象过期时,程序首先调用派生类析构函数,然后再调用基类析构函数。

13.1.2 派生类与基类之间的特殊关系

  • 派生类对象可以使用基类的方法,条件是方法不是私有的。

  • 基类指针可以在不显示类型转换的情况下指向派生类对象;基类引用可以在不显示类型转换的情况下引用派生类对象。通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外。然而,这种例外只是单向的,不可以将基类对象和地址赋给派生类引用和指针。利用这样的属性,可以带来一大好处:在函数参数中使用基类引用或指针,在调用时可以向其传递基类对象、地址或派生类的对象、地址,这就是多态的一种体现。

13.2 多态公有继承

13.2.1 virtual关键字和虚方法

如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚方法。这样,在使用引用或指针调用该方法时,将根据引用或指针指向的对象的类型来选择方法的版本,而不是根据引用或指针的类型来选择。如果声明为类型本身,而不是指针或引用,则按声明类型选择方法版本。

虚方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法,尽管如此,在派生类声明中使用关键字virtual来指出哪些函数时虚函数也不失为一个好办法。

13.2.2 虚析构函数

虚析构函数可确保释放派生对象时,按正确的顺序调用析构函数。如果有一个基类的指针指向派生类的对象,若析构函数不是虚函数,则按指针类型调用虚函数,即只调用基类的析构函数。如果派生类中有使用new分配的内存的数据成员,则派生类的析构函数无法调用,将无法释放该内存空间。使用虚析构函数,将根据指针或引用指向的对象的类型来选择函数版本,此时将会调用派生类的析构函数,然后自动调用基类的析构函数。因此,使用析构函数,可以保证正确的析构函数序列被调用。

13.3 静态联编和动态联编

将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在编译过程中进行这种联编被称为静态联编,又称早期联编。然而C++引入虚函数,导致使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。编译器必须生成能够在运行时选择正确的虚函数的代码,这被称为动态联编,又称为晚期联编。

13.3.1 指针和引用类型的兼容性

在C++中,动态联编与通过指针和引用调用方法相关。通常C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。然而,指向基类的引用或指针可以引用派生类对象,而不必进行强制类型转换。

将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting),这使公有继承不需要进行显示类型转换,该规则是is-a规则的一部分。

将基类指针或引用转换为派生类指针或引用——称为向下强制转换(downcasting)。如果不适用显示类型转换,则向下强制转换是不允许的。

13.3.2 虚函数和动态联编

如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。

1 - 虚函数工作原理

编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,tvbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也被添加到vtbl中。注意,无论时类中包含的虚函数是1个还是10个,都只需在对象中添加1个地址成员,只是表的大小不同而已。

image-20210419165628479

调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间;
  • 对于每个类,编译器都创建一个虚函数地址表(数组);
  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址
2 - 有关虚函数注意事项
  • 构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。

  • 析构函数应当是虚函数,除非不用做基类。通常应给基类提供一个虚析构函数,即使它并不需要析构函数。

  • 友元不能是虚函数。友元不是类成员,而只有成员才能是虚函数。

  • 如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。

  • 重新定义将隐藏方法。如果在派生类中重新定义函数(改变了特征标,即参数列表),不会生成函数的两个重载版本,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class Dwelling
    {
    public:
    virtual void showperks(int a) const;
    };

    class Hovel : public Dwelling
    {
    public:
    virtual void showperks() const;
    };

    int main()
    {
    Hovel trump;
    trump.showperks(); // valid
    trump.showperks(5); // invalid

    return 0;
    }

    如果仅仅是改变了返回类型,则要看是否协变:

    • 如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特性称为返回类型协变。如果不满足协变规则,将编译报错。所以,如果不改变参数列表,则返回类型要么一样,要么符合协变。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      class Dwelling
      {
      public:
      virtual Dwelling &build(int n);
      };

      class Hovel : public Dwelling
      {
      public:
      virtual Hovel &build(int n); // same function signature
      };
  • 如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。注意,如果不需要修改,则新定义只需调用基类版本即可。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Dwelling
    {
    public:
    virtual void showperks(int a) const;
    virtual void showperks(double x) const;
    virtual void showperks() const;
    };

    class Hovel : public Dwelling
    {
    public:
    virtual void showperks(int a) const;
    virtual void showperks(double x) const;
    virtual void showperks() const;
    };

13.4 访问控制:protected

protected成员对类外不可见,对类内可见,这一点与private是一致的。protected的特点在继承时体现:protected成员在派生类中可见。

13.5 抽象基类

C++通过使用纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾处为=0。当类声明中包含纯虚函数时,则不能创建该类的对象。包含纯虚函数的类只用作基类。要成为真正的抽象基类,必须至少包含一个纯虚函数。C++允许纯虚函数有定义。

13.6 继承和动态内存分配

13.6.1 基类使用动态内存分配,派生类不使用动态内存分配

1
2
3
4
5
6
7
8
9
10
11
12
class baseDMA
{
private:
char *label;
int rating;

public:
baseDMA(const char *l = "null", int r = 0);
baseDMA(const baseDMA &rs);
virtual ~baseDMA();
baseDMA &operator=(const baseDMA &rs);
};

baseDMA类声明中包含了构造函数使用new时需要的特殊方法:析构函数、复制构造函数和重载赋值运算符。现在,从baseDMA派生出lackDMA类,而后者不使用new,也未包含其他一些不常用的、需要特殊处理的设计特性:

1
2
3
4
5
6
7
class lacksDMA : public baseDMA
{
private:
char color[40];
public:
...
};

是否需要为lackDMA类定义显示析构函数、复制构造函数和赋值运算符呢?不需要。

首先,来看是否需要析构函数。如果没有定义析构函数,编译器将定义一个不执行任何操作的默认构造函数。实际上,派生类的默认构造函数总是要进行一些操作:执行自身的代码后调用基类析构函数。因为lackDMA成员不需执行特殊操作,所以默认析构函数是合适的。

接下来看复制构造函数。复制类成员或继承的类组件时,则是使用该类的复制构造函数完成的。所以,lacksDMA类的默认复制构造函数使用显式baseDMA复制构造函数来复制lacksDMA对象的baseDMA部分。

对于赋值来说,类的默认赋值运算符将自动使用基类的赋值运算符来对基类组件进行赋值。

13.6.2 基类使用动态内存分配,派生类也使用动态内存分配

1
2
3
4
5
6
7
class hasDMA : public baseDMA
{
private:
char *style;
public:
....
};

在这种情况下,必须为派生类定义显示析构函数、复制构造函数和赋值运算符。

首先看析构函数。派生类析构函数自动调用基类的析构函数,故其自身的职责是对派生类构造函数执行的动态内存分配进行清理。因此,hasDMA析构函数必须释放指针style管理的内存,并依赖于baseDMA的析构函数来释放指针label管理的内存。

1
2
3
4
5
6
7
8
9
baseDMA::~baseDMA()
{
delete [] label;
}

hasDMA::~hasDMA()
{
delete [] style;
}

接下来看复制构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
baseDMA::baseDMA(const baseDMA &rs)
{
label = new char[std::strlen(rs.label) + 1];
std::strcpy(label, rs.label);
rating = rs.rating;
}

hasDMA::hasDMA(const hasDMA &hs) : baseDMA(hs)
{
style = new char[std::strlen(hs.style) + 1];
std::strcpy(style, hs.style);
}

hasDMA复制构造函数只能访问hasDMA的数据,因此它必须调用baseDMA复制构造函数来处理共享的hasDMA数据。如果不这样做,它将自动调用基类的默认构造函数。

接下来看赋值运算符。baseDMA赋值运算符遵循下述常规模式:

1
2
3
4
5
6
7
8
9
10
baseDMA &baseDMA::operator=(const baseDMA &rs)
{
if (this == &rs)
return *this;
delete [] label;
lable = new char[std::strlen(rs.label) + 1];
std::strcpy(label, rs.label);
rating = rs.rating;
return *this;
}

由于hasDMA也使用动态内存分配,所以它也需要一个显式赋值运算符。作为hasDMA的方法,它只能直接访问hasDMA的数据。然而,派生类的显示赋值运算符必须负责所有继承的baseDMA基类对象的赋值,可以通过显示调用基类赋值运算符来完成这项工作:

1
2
3
4
5
6
7
8
9
10
hasDMA &hasDMA::operator=(const hasDMA &hs)
{
if (this == &hs)
return *this;
baseDMA::operator=(hs); // 显式赋值运算符,不能写成*this = hs;这件导致调用hasDMA::operator=(hs),进而递归调用
delete [] style;
style = new char[std::strlen(hs.style) + 1];
std::strcpy(style, hs.style);
return *this;
}

第14章 C++中的代码重用

14.1 三种继承

image-20210420190706580

此处,私有继承能否隐式向上转换有待查证。

14.1.1 使用using重新定义访问权限

使用保护派生或私有派生时,基类的公有成员将成为保护成员或私有成员。假设要让基类的方法在派生类外面可用,有两种方法:

  • 在派生类中定义一个使用该基类方法的派生类方法,相当于一个包装器,将基类方法包装在公有方法中。

    1
    2
    3
    4
    double Student::sum() const // public Student method
    {
    return std::valarray<double>::sum(); // use privately-inherited method
    }
  • 使用一个using声明来指出派生类可以使用特定的基类成员,即使采用私有派生。

    1
    2
    3
    4
    5
    6
    7
    8
    class Student : private std::string, private std::valarray<double>
    {
    ...
    public:
    using std::valarray<double>::min;
    using std::valarray<double>::max;
    ...
    };

    上述using声明使得valarray<double>::min()和valarray<double>::max()可用,就像它们是Student的公有方法一样。注意,using声明只使用成员名,没有圆括号、函数特征标和返回类型。

14.2 多重继承

14.2.1 多个拷贝

如果有以下继承层次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Worker
{
...
};

class Waiter : public Worker
{
...
};

class Singer : public Worker
{
...
};

class SingingWaiter : public Waiter, public Singer
{
...
};

上述是一个典型的菱形继承,SingingWaiter类的对象中将包含两个Worker对象的拷贝:

image-20210428130102630

使用指向Worker类的指针直接指向SingingWaiter的对象,将会出现二义性:

1
2
SingingWaiter ed;
Worker *pw = &ed; // compile error

需使用强制类型转换:

1
2
Worker *pw1 = (Waiter *)&ed;
Worker *pw2 = (Singer *)&ed;

14.2.2 虚基类

虚基类使得从多个类(它们的基类相同)派生出的对象只继承一个基类对象。

1
2
3
4
class Worker {...};
class Waiter : virtual public Worker {...};
class Singer : public virtual Worker {...};
class SingingWaiter : public Waiter, public Singer {...};

上述代码使得Worker类被用作Waiter和Singer的虚基类,virtual和public的次序无关紧要。而SingingWaiter对象将只包含Worker对象的一个副本。从本质上说,继承的Singer和Waiter对象共享一个Worker对象的一个副本:

image-20210428140005245

1 - 新的构造函数规则

对于非虚基类,唯一可以出现在初始化列表中的构造函数是即时基类构造函数(直接基类构造函数),而这些构造函数可能需要将信息传递给其基类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A
{
int a;

public:
A(int n = 0) : a(n) {}
};

class B : public A
{
int b;

public:
B(int m = 0, int n = 0) : A(m), b(n) {}
};

class C : public B
{
int c;

public:
C(int m = 0, int n = 0, int q = 0) : B(m, n), c(q) {}
};

C类的构造函数只能调用B类的构造函数,不能直接调用A类的构造函数,而B类的构造函数只能调用A类的构造函数。这里,C类的构造函数使用值q,并将m和n传递给B类的构造函数,而B类的构造函数使用n,并将m传递给A类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Worker
{
private:
string fullname;
int id;

public:
Worker() : fullname(""), id(-1) {}
Worker(const string &s, int n) : fullname(s), id(n) {}
};

class Waiter : virtual public Worker
{
private:
int panache;

public:
Waiter() : Worker(), panache(0) {}
Waiter(const string &s, int n, int p) : Worker(s, n), panache(p) {}
Waiter(const Worker &wk, int p) : Worker(wk), panache(p) {}
};

class Singer : virtual public Worker
{
private:
int voice;

public:
Singer() : Worker(), voice(0) {}
Singer(const string &s, int n, int v) : Worker(s, n), voice(v) {}
Singer(const Worker &wk, int v) : Worker(wk), voice(v) {}
};

class SingingWaiter : public Waiter, public Singer
{
...
};

如果Worker是虚基类,则这种信息自动传递将不起作用。例如,对于下面的MI构造函数:

1
SingingWaiter(const Worker &wk, int p, int v) : Waiter(wk, p), Singer(wk, v) {}

原因是,自动传递信息时,将通过两条不同的路径(Waiter和Singer)将wk传递给Worker对象。为避免这种冲突,C++在基类是虚的时,禁止信息通过中间类自动传递给基类。因此,上述构造函数将初始化panache和voice,但wk参数中的信息将不会传递给子对象Waiter。然而,编译器必须在构造派生对象之前构造基类对象组件,在上述情况下,编译器将使用Worker的默认构造函数。

如果不希望默认构造函数来构造虚基类对象,则需要显示地调用所需的基类构造函数:

1
SingingWaiter(const Worker &wk, int p, int v) : Worker(wk), Waiter(wk, p), Singer(wk, v) {}

上述代码对虚基类是合法的,但对非虚基类,则不合法。

如果类有间接虚基类,则除非只需使用该虚基类的默认构造函数,否则必须显示地调用该虚基类地某个构造函数。

2 - 哪个方法

单根继承中,如果子类没有重新实现父类的方法,那么子类的将使用最近祖先中的定义。而在多重继承中,可能导致函数调用的二义性,例如,SingingWaiter的父类Waiter和Singer中都有Show方法,而SingingWaiter没有对Show方法重新实现,此时如果使用SingingWaiter的对象调用Show方法,将会导致二义性:

1
2
SingingWaiter s;  // 假设已经有了对应的构造函数的正确定义
s.Show(); // compile error

有两种方式解决以上问题,一是使用类作用域解析运算符:

1
2
s.Singer::Show();
s.Waiter::Show();

另一种就是在SingingWaiter类中实现Show方法。

3 - 虚基类和支配

使用虚基类将改变C++解析二义性的方式。使用非虚基类时,规则很简单,如果类从不同的类那里继承了两个或更多的同名成员(数据或方法),则使用该成员名时,如果没有用类名进行限定,将导致二义性。但如果使用的是虚基类,则这样做不一定会导致二义性。在这种情况下,如果某个名称优先于其他所有名称,则使用它时,即便不使用限定符,也不会导致二义性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class B {
public:
short q();
};

class C : virtual public B {
public:
long q();
int omg();
};

class D : public C {
};

class E : virtual public B {
private:
int omg();
}

class F : public D, public E {
}

上述代码的继承树如下:

1
2
3
4
5
6
7
8
9
10
11
12
   B
/ \
/ \
/ \
C E
| |
D |
\ |
\ |
\ |
\ |
F

C中的q()优先级比B中的q()优先级高,因此F中的方法可以用q()来表示C::q()。另一方面,C中的omg()与E中的omg()谁也不比谁的优先级高,因此F中使用非限定的omg()将导致二义性。

虚二义性规则与访问规则无关,从上面的例子可以看到,C中的omg()访问权限为public,而E中的omg()访问权限为private,不能在F中访问E::omg(),但使用omg()仍将导致二义性。

14.3 类模板

14.3.1 类模板定义与使用

14.3.2 非类型参数

14.3.3 模板的多功能性

  • 递归使用模板
  • 使用多个类型参数

14.3.4 模板的具体化

  • 隐式实例化
  • 显示实例化
  • 显示具体化

  • 部分具体化

14.3.5 将模板用作参数

14.3.6 模板类和友元

  • 非模板友元
  • 约束模板友元
  • 非约束模板友元

14.4 C++定义别名

  • typedef
  • using