C++学习笔记

开始

学习一门新的程序设计语言的最好方法就是练习编写程序。更多的内容可以查看我的github主页C++课后习题集

习题集目录

编写一个简单的C++程序

每个C++程序都包含一个或多个函数(function),其中一个必须命名为main。操作系统通过调用main来运行C++程序。

下面是一个简单的main函数,它什么都不做,只是返回给操作系统一个值:

int main()
{
    return 0;
}

main函数的返回类型必须为int。int类型是一种内置类型(built-in type),即语言自身定义的类型。

函数体是一个以左花括号(curly brace)开始,以右花括号结束的语句块(block of statements)。

return语句结束函数的执行。main的返回值被用来指示状态。返回值0表明成功,非0的返回值的含义由系统定义,通常用来指出错误类型。

重要概念:类型

一种类型不仅定义了数据元素的内容,还定义了这类数据上可以进行的运算。程序所处理的数据都保存在变量中,而每个变量都拥有自己的类型。

编译、运行程序

编写好程序后,我们就需要编译它,这依赖于操作系统和编译器。

程序源文件命名约定

程序文件通常被称为源文件(source file)。它以一个后缀为结尾,告诉系统这个文件是一个C++程序,比如.cpp。

初识输入输出

C++包含了一个全面的标准库(standard library)来提供IO机制(以及很多其他设施)。

iostream库包含两个基础类型istream和ostream,分别表示输入流和输出流。一个流就是一个字符序列,是从IO设备读出或写入IO设备的。

标准输入输出对象

标准库定义了4个IO对象。为了处理输入,使用名为cin的istream类型的对象。这个对象被称为标准输入(standard input)。对于输出,使用名为cout的ostream类型的对象。这个对象被称为标准输出(standard output)。另外还有其他两个ostream对象,名为cerr和clog,cerr通常用来输出警告和错误消息,因此被称为标准错误(standard error)。clog用来输出程序运行时的一般性消息。

系统通常将程序所运行的窗口与这些对象关联起来。因此,当读取cin,数据将从程序正在运行的窗口读入,当向cout写入数据时,将会写到同一个窗口。

!!!note
clog关联到标准错误,默认情况下,写到clog的数据是被缓冲的。写到cerr的数据是不缓冲的。

一个使用IO库的程序

#include 
int main()
{
        std::cout << "Enter two numbers:" << std::endl;
        int v1 = 0, v2 = 0;
        std::cin >> v1 >> v2;
        std::cout << "The sum of " << v1 << " and " << v2 << " is "
                  << v1 + v2 << std::endl;
        return 0;
}

#include <iostream>告诉编译器我们想要使用iostream库。尖括号中的名字指出了一个头文件(header)。每个使用标准库设施的程序都必须包含相关的头文件。#include指令和头文件的名字必须写在同一行中。#include指令一般出现在所有函数之外,源文件的开始位置。

向流写入数据

std::cout << "Enter two numbers" << std::endl;

这条语句执行了一个表达式(expression)。在C++中,一个表达式产生一个计算结果,它由一个或多个运算对象和(通常是)一个运算符组成。这条语句中的表达式使用了输出运算符(<<)。

<<运算符接受两个运算对象:左侧必须是一个ostream对象;右侧是要打印的值。此运算符将给定的值写到给定的ostream对象中。计算结果是左侧的ostream对象。

“Enter two numbers”是一个字符串字面值常量(string literal),它是用一对双引号包围的字符序列。

endl是一个被称为操纵符(manipulator)的特殊值。写入endl的效果是结束当前行,并将与设备关联的缓冲区(buffer)中的内容刷到设备中。

!!!note
一个表达式接一个分号就是一条语句。

使用标准库中的名字

前缀std::指出名字cout和endl是定义在名为std命名空间(namespace)中的。

命名空间可以帮助我们避免不经意的名字定义冲突。标准库定义的所有名字都在命名空间std中。

从流读取数据

首先定义两个名为v1和v2的变量(variable)来保存输入:

int v1 = 0, v2 = 0;

这两个变量被定义为int类型,并初始化(initialize)为0。初始化一个变量,就是在变量创建的同时为它赋予一个值。

std::cin >> v1 >> v2;

这条语句读入输入数据。输入运算符(>>)接受一个istream作为其左侧运算对象,接受一个对象作为其右侧运算对象。它从给定的istream读入数据,并存入给定对象中。输入运算符返回其左侧运算对象作为计算结果。

注释简介

注释(comments)通常用于概述算法,确定变量的用途,或者解释晦涩难懂的代码段。编译器会忽略掉注释,因此注释对程序的行为和性能不会有任何影响。

C++中注释的种类

C++中有两种注释:单行注释和界定符注释。

注释界定符不能嵌套

界定符对形式的注释是以/*开始,以*/结束的。因此,一个注释不能嵌套在另一个注释之内。

如果在调试期间要注释掉包含界定符对形式注释的代码,最好的方式是用单行注释方式注释掉代码段的每一行。

// /*
//  * comments
//  */

控制流

语句一般是顺序执行的,语句块的第一条语句首先执行,然后是第二条,以此类推。但程序设计语言提供了多种不同的控制语句,允许我们写出更为复杂的执行路径。

while语句

while语句反复执行一段代码,直到给定条件为假为止。

while语句的形式为:

while (condition)
    statement

while语句的执行过程是交替地检测condition条件和执行关联的语句statement,直至condition为假时停止。所谓条件就是一个产生真或假的结果的表达式。

statement可以是语句块,也叫循环体。所谓语句块(block),就是用花括号包围的语句序列。语句块也是语句的一种,在任何要求使用语句的地方都可以使用语句块。

for语句

每个for语句都包含两部分:循环头和循环体。循环头控制循环体的执行次数,它由三部分组成:一个初始化语句(init-statement)、一个循环条件(condition)以及一个表达式(expression)。

比如:

for (int val = 1; val <= 10; ++val)
    sum += val;

for循环的总体执行流程:

  1. 创建变量val,将其初始化为1,它仅在循环内部存在。

  2. 检测val是否小于等于10。如果检测成功,执行循环体。若失败,退出循环。

  3. 将val的值增加1。

  4. 重复第二个步骤。

读取数量不定的输入数据

使用这样的方法连续读入数据,直到遇到文件尾(Linux下,输入CTRL+D):

while (std::cin >> value)
    statement

输入运算符返回其左侧对象,因此,此循环条件检测的是std::cin。

当使用一个istream对象作为条件时,其效果是检测流的状态。如果流是有效的,那么检测成功。当遇到文件结束符(end-of-file),或遇到一个无效输入时,istream对象的状态会变为无效。

if语句

if也对一个条件求值,书本里有一个完整的例子。

语法大致如此:

if (condition) {
    statements
} else {
    statements
}

类简介

在C++中,通过定义一个(class)来定义自己的数据结构。一个类定义了一个类型,以及与其关联的一组操作。

类似使用标准库设施,我们也需要使用头文件来自己的类。习惯上,头文件根据类名来命名,使用.h作为头文件的后缀。标准库的头文件通常不带后缀。

初识成员函数

什么是成员函数?

有这样的检测条件:

item1.isbn() == item2.isbn()

调用名为isbn的成员函数(member function)。成员函数是定义为类的一部分的函数。

使用点运算符(.)来表达我们需要“名为item1的对象的isbn成员”。点运算符只能用于类类型的对象。其左侧运算对象必须是一个类类型的对象,右侧运算对象必须是该类型的一个成员名,运算结果为右侧运算对象指定的成员。

我们使用调用运算符(())来调用一个函数,它是一对圆括号,里面放实参列表(可能为空)。# 第二章

  1. ‘void*’
    是一种特殊的指针类,可以存放任意对象的地址。
  2. const 对象必须初始化。 const int buf =10
  3. const 指针,其值不能被改变,所有必须初始化。
  4. 顶层const和底层const,顶层const表示指针本身是一个常量,底层const表示指针所指对象是一个常量。更一般的,顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用。底层const则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层const也可以是底层const。
int i = 0;
int *const p1 = &i;    // 不能改变p1的值,p1是一个顶层const
const int ci = 42;    // 不能改变ci的值,ci是一个顶层const
const int *p2 = &ci;   // 允许改变p2的值,p2是一个底层const
const int *const p3 = p2; // 靠右的const是顶层const,靠左的是底层const
const int &r = ci;     // 用于声明引用的const都是底层const

C++新标准引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值:

decltype(f()) sum = x; // sum的类型就是函数f的返回类型

编译器并不实际调用f,而是使用当调用发生时f的返回值的类型作为sum的类型。

如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内):

const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x的类型是const int
decltype(cj) y = x; // y的类型是const int&, y绑定到x

decltype和引用

如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。如果表达式向decltype返回一个引用类型,一般来说,意味着该表达式的结果对象能作为一条赋值语句的左值:

// decltype的结果可以是引用类型
int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // 正确,加法的结果是int,因此b是一个int
decltype(*p) c; // 错误,c是int&,必须初始化

如果表达式的内容是解引用操作,则decltype将得到引用类型。

有一种情况需要特别注意:对于decltype所用的表达式来说,如果变量名加上了一对括号,编译器就会把它当成一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype就会得到引用类型:

// decltype的表达式如果是加上了括号的变量,结果是引用
decltype((i)) d; // 错误,d是int&,必须初始化
decltype(i) e; // 正确,e是一个int。

auto 和decltype的区别主要有三个方面
第一,auto类型说明符用编译器计算变量的初始值来推断其类型,而decltype虽然也让编译器分析表达式并得到它的类型,但是不实际计算表达式的值。
第二,编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。例如,auto一般会忽略掉顶层const,而把底层const保留下来。与之相反,decltype会保留变量的顶层const。
第三,与auto不同,decltype的结果类型与表达式形式密切相关,如果变量名加上了一对括号,则得到的类型与不加括号时会有不同。如果decltype使用的是一个不加括号的变量,则得到的结果就是该变量的类型;如果给变量加上了一层或多层括号,则编译器将推断得到引用类型。

字符串、向量和数组

String

  1. 头文件不应包含using声明
  2. 初始化string对象的方式:
    string s1;           // 默认初始化,s1是一个空串
    string s2(s1);       // s2是s1的副本
    string s2 = s1;      // 等价于s2(s1)
    string s3("value");  // s3是字面值"value"的副本,不包括最后的空字符
    string s3 = "value"; // 等价于s3("value")
    string s4(n, 'c');   // 初始化为由n个字符c组成的串
  3. 直接初始化和拷贝初始化

如果使用等号(=)初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的对象初始值拷贝到新创建的对象中去。如果不适用等号,则执行的是直接初始化(direct initialization)。

string s5 = "hiya";  // 拷贝初始化
string s6("hiya");   // 直接初始化
string s7(10, 'c');  // 直接初始化
  1. getline函数会读取换行符,但不会把它存入字符串中。getline返回输入流。

string::size_type类型

size函数返回的是一个string::size_type类型的值。这是一个无符号的整数。

  1. 处理每个字符?使用基于范围的for语句

如果想对string对象中的每个字符做点儿什么操作,目前最好的办法是使用C++11新标准提供的一种语句:范围for(range for)语句。这种语句遍历序列中的每个元素并对序列中的每个值执行某种操作,其语法格式是:

for (declaration : expression)
    statement

如:

for (auto c : str)
    // do something

使用下标运算符

[ ]符号叫做下标运算符,范围是[0, s.size()),越界的结果是UB(undefined behavior,未定义行为)。

Vector

  1. vector是对象的集合,也叫容器(container)。集合中的每个对象都有一个索引,索引用于访问对象。
  2. vector是一个类模板。模板是为编译器提供的一份生成类或函数的说明。
  3. vector是模板而非类型,由vector生成的类型必须包含元素的类型,如:
    vector v;
  4. vector中存放的是对象,而引用不是对象,故不能存储引用。

    定义和初始化vector对象

    vector模板控制着初始化向量的方法。定义vector对象的方法有:
  • vector<T> v1,默认初始化,v1是一个空的vector
  • vector<T> v2(v1),v2中包含v1所有元素的副本
  • vector<T> v2 = v1,等价于v2(v1)
  • vector<T> v3(n, val),v3包含了n个重复的元素,每个元素的值都是val
  • vector<T> v4(n),v4包含了n个执行了值初始化的对象
  • vector<T> v5{a,b,c...},v5里包含了用a,b,c…初始化的元素
  • vector<T> v5 = {a,b,c...},等价于vector<T> v5{a,b,c...}

值初始化
值初始化(value initialize),是指如果是内置类型,则初始值为0;如果是类类型,执行类默认初始化。
vector<T>(n)中,所有元素将执行值初始化。

向vector中添加元素

push_back函数把一个元素压入vector对象的尾端。
vector的对象能高效地增长,因此更常见的情况是:创建一个空vector,然后在运行时再利用vector的成员函数push_back向其中添加元素。
一定不能在遍历vector的时候改变vector对象的大小。
C++标准要求vector应该能在运行时高效快速地添加元素。因此既然vector对象能高效地增长,那么在定义vector对象的时候设定其大小就没有什么必要了,只有一种例外,即当所有元素的值都一样。一旦元素的值有所不同,更有效的办法是先定义一个空的vector对象,再在运行时向其中添加具体值。

其它vector操作

如(很多和string类似):

  • v.empty(),如果v不含有任何元素,返回true
  • v.size(),返回v中的元素个数
  • v[n],返回v中第n个位置上元素的引用
  • v1 = v2,v2中的元素将拷贝替换v1的
  • v1 = {a,b,c...},列表中的元素将拷贝替换v1中的
  • v1 == v2, v1 != v2,元素数量相同,对应位置的元素也相等,则相等
  • <,<=,>,>=,比首个相异元素的大小,如都一样,比长度,即字典顺序
    size返回的类型由vector定义的size_type类型。
    vector::size_type    // 正确
    vector::size_type         // 错误

只有当元素的值可比较时,vector对象才能被比较。只能对确已存在的元素执行下标操作。

迭代器介绍

使用迭代器(iterator)是一种通用的访问容器中元素的方法。
迭代器有有效和无效之分。有效的迭代器指向某个元素,或指向尾元素的下一个位置,其它情况都属于无效。

使用迭代器

有迭代器的类型同时拥有返回迭代器的成员。
标准库容器都拥有名为begin和end的成员(函数)。其中begin成员负责返回指向第一个元素的迭代器。
end成员负责返回指向容器“尾元素的下一个位置”的迭代器。叫尾后迭代器(off-the-end iterator)
如果容器为空,begin和end都返回尾后迭代器。即:v.begin() == v.end()
如:

auto b = v.begin();
auto e = v.end();

迭代器运算符
标准容器迭代器的运算符:

  • *iter,返回迭代器所指对象的引用(解引用)
  • iter->mem,解引用iter,并获取其成员mem,等价于(*iter).mem
  • ++iter,令iter指示容器中的下一个元素
  • --iter,令iter指示容器中的上一个元素
  • iter1 == iter2,如果两个迭代器指示的是同一个元素,或者它们都是尾后迭代器,则相等,反之不相等
    迭代器指示一个元素时,才可对其解引用。对尾后迭代器或者无效迭代器解引用的结果是UB。

迭代器类型
标准库类型使用iterator和const_iterator来表示迭代器类型。
如:

vector::iterator it1;
vector::const_iterator it2;

it1能读写元素,而it2只能读。
认定某个类型是迭代器类型当且仅当它支持一套操作,这套操作使得我们能访问容器的元素,或者从某个元素移动到另外一个元素。
begin和end运算符begin和end返回的具体类型由对象是否是常量决定。如果对象是常量,返回const_iterator,否则返回iterator。
为了专门得到const_iterator类型的迭代器,C++11中可以使用cbegin和cend:

auto it = v.cbegin();

箭头运算符->,它把解引用和成员访问两个操作结合在一起。即:(*iter).mem等价于iter->mem
某些对vector对象的操作会使迭代器失效任何一种可能改变vector对象容量的操作,比如push_back,都会使该vector对象的迭代器失效。

迭代器运算

递增运算令迭代器每次移动一个元素,所有的标准库容器的迭代器都支持递增运算,也支持==和!=运算。
string和vector的迭代器提供了额外的运算符,有:

  • iter + n,新迭代器向前移动若干个元素,它指向容器的一个元素,或是尾后迭代器
  • iter - n,新迭代器向后移动若干个元素,它指向容器的一个元素,或是尾后迭代器
  • iter1 - iter2,得到迭代器之间的距离,参与计算的迭代器必须是指向同一个容器中的元素或者尾元素的下一个位置
  • >,>=,<,<=,比较迭代器所处的位置,前面的小于后面的,参与计算的迭代器必须是指向同一个容器中的元素或者尾元素的下一个位置

迭代器的算数运算
迭代器相减的结果的类型是difference_type,表示右侧的迭代器要移动多少个位置才能到达左侧的。
difference_type是一个带符号的整数,string和vector都定义了这个类型。
迭代器相加没有意义

auto mid = (beg) +(end-beg)/2;//正确
auto mid =(beg+end)/2;//错误 迭代器加法不存在

string类本身接受无参数的初始化方式,无论数组定义在函数体内部还是外部都被默认初始化为空串,对于内置类型int,数组定义在函数体外部时默认初始化为0,在main函数内部时,将不被初始化。

数组

  1. 数组是存放相同类型的对象的容器,这些对象是匿名的。
  2. 数组的大小确定不变。
  3. 数组是一种内置类型。

    定义和初始化内置数组

    数组是一种复合类型,其声明形如a[N] 。N叫维度,说明了数组中元素的个数,必须大于0,且必须是一个常量表达式,即其值在编译期间已知。
    默认情况下,数组的元素执行默认初始化,这意味着在函数块内定义的执行默认初始化的含内置类型元素的数组,其元素的值未定义。
    定义数组的时候必须指定数组的类型,不允许用auto关键字由初始值的列表推断类型。数组的元素应为对象,所以不存在存储引用的数组。

显式初始化数组元素
即列表初始化,此时可以忽略数组的维度,维度由编译器推断出来。如:

int a1[10] = {0}; // 剩下的元素执行值初始化,即为0
int a2[] = {1, 2, 3};

字符数组的特殊性可以用字符串字面值对此类数组进行初始化。如:

char s[] = "hello";

这样初始化的数组包含结尾的空字符。
不允许拷贝和赋值
这样的操作是非法的:

int a1[] = {1, 2, 3};
int a2[] = a1; // 非法

理解复杂的数组声明

  1. 定义一个指针数组
    int* a[10] = {};
  2. 定义一个指向数组的指针:
    int (*ptr)[10] = &a;
  3. 定义一个绑定到数组的引用:
    int (&a_ref)[10] = a;
    默认情况下,类型修饰符从右向左依次绑定。不过理解数组的复杂声明时,应该由内向外理解。即从数组的名字开始按照由内向外的顺序阅读。

    访问数组元素

    使用数组下标的时候,通常将其定义为size_t类型,这是一种机器相关的无符号类型。定义在cstddef头文件中,是C标准库stddef.h头文件的C++版本。可以使用范围for语句来遍历数组。
    for (auto i : arr)
     cout << i << " ";
    cout << endl;

得到数组的大小

sizeof(array/sizeof(array[0];

检查下标的值
与string和vector一样,数组的下标是否在合理范围之内由程序员负责检查。

指针和数组

在很多用到数组名字的地方,编译器都会自动地将其替换为一个指向数组首元素的指针
decltype
下面得到一个数组类型:

int a1[10] = {};
decltype(a1) a2;

auto
下面得到一个整型指针:

int a1[10] = {};
auto a2(a1);

指针也是迭代器
string和vector的迭代器支持的运算,指针都支持。使用递增运算符既可以让指向数组元素的指针向前移动到下一个位置上。这样可以获取数组尾元素的下一个位置的指针:

int *end = &a[N];

不过C++11提供了begin和end函数,可以获取数组首元素的指针和尾后指针:

int a[10] = {};
int *beg_p = begin(a);
int *end_p = end(a);

这俩函数定义在头文件iterator.h中。尾后指针不能解引用和递增操作。和迭代器一样,两个指针相减的结果是它们之间的距离。参与运算的两个指针必须指向同一个数组当中的元素。
下标和指针
对数组执行下标运算其实是对指向数组元素的指针执行下标运算:

int i = ia[2];    // ia转换成指向数组首元素的指针
                  // ia[2]得到(ia + 2)所指的元素
int *p = ia;      // p指向ia的首元素
i = *(p + 2);     // 等价于i = ia[2]

只要指针指向的是数组中的元素,都可以执行下标运算。
内置的下标运算符可以处理负值,这和标准库类型的下标不一样(必须是无符号的)。

C风格字符串

C风格的字符串即是字符串字面量,也是一种字符数组,并以空字符结尾(null terminated)。
p109列举了C语言标准库提供的一组函数,可以操作C风格字符串,他们定义在cstring头文件中。
c_str函数
string可使用c_str函数返回其C风格的字符串,如:

string s("hello");
const char *c_s = s.c_str();

无法保证返回的C风格字符串一直有效,因此通常在返回后再把它拷贝到另一个地方
使用数组初始化vector对象如:

int a[] = {1, 2, 3};
vector vec(begin(a), end(a));

多维数组

多维数组,实际上是数组的数组。
如:int a[3][4],可由内而外理解,a是一个含有3个元素的数组,每个元素又是一个含有4个元素的数组。
对于二维数组,常把第一个维度看作行,第二个维度看作列。
多维数组的初始化如:

int a[3][4] = {
    {0, 1, 2, 3},
    {4, 5, 6, 7},
    {8, 9, 10, 11}
};

列表初始化中未列出的元素执行值初始化。
多维数组的下标引用
如果表达式含有的下标运算符数量和维度一样多,该表达式的结果将是给定类型的元素;否则表达式的结果是内层数组

int a[3][4] = {};
int (&row)[4] = a[2]; // row绑定到a的第二个数组上

使用范围for语句处理多维数组
如果是外层循环,控制变量将得到数组类型。
除了最内层的循环外,其他所有循环控制变量都应该是引用类型(因为若不是引用,编译器会认为外层控制变量是指针类型,而无法遍历一个指针)。
指针和多维数组
当程序使用多维数组名字时,也会自动将其转换成指向数组首元素的指针。
多维数组的首元素是一个内层数组,故使用多维数组名将得到一个指向内层数组的指针。
即:

int a[2][3] = {};
int (*p)[3] = a;

还可以使用auto或者begin来得到指向内层数组的指针。
类型别名简化多维数组的指针
可以这样定义一个数组类型:

using int_arr = int[4]; // C++11
typedef int int_arr[4];

指针

  1. 指针本身的值(value);
  2. 指针所指的对象(content);
  3. 指针本身在内存中的储存位置(address)

表达式

  1. *iter.empty()(*iter).empty()iter->empty()的区别
  2. somevalue ? ++x,++y:--x,--y;等价于(somevalue ? ++x,++y:--x),--y;

    位运算符

位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。
|运算符|功能|用法|
|-|-|-|
||位求反|expr|
|<<|左移|expr1 << expr2|
|>>|右移|expr1 >> expr2|
|&|位与|expr1 & expr2|
|^|位异或|expr1 ^ expr2|
|||位或|expr1 | expr2|

一般来说,如果运算对象是“小整型”,则它的值会被自动提升成较大的整数类型。运算对象可以是带符号的,也可以是无符号的。如果运算对象是带符号的且它的值为负,那么位运算如何处理运算对象的“符号位”依赖于机器。
强烈建议将位运算符用于处理无符号类型。
一个提升例子就是,如果对char做位运算,它会先被提升为int。
移位运算符
<<和>>
运算符的内置含义是对其运算对象执行基于二进制位的移动操作。首先令左侧运算对象的内容按照右侧运算对象的要求移动指定位数,然后将经过移动的(可能还进行了提升)左侧运算对象的拷贝作为求值结果。其中,右侧的运算对象一定不能为负,而且必须严格小于结果的位数,否则就会产生未定义的行为。移出边界之外的位数被舍弃掉了。
左移运算符<<在右侧插入值为0的二进制位。右移运算符>>的行为依赖于左侧运算对象的类型:如果是无符号的,在左侧插入值为0的二进制位;如果是带符号的,在左侧插入符号位的副本或值为0的二进制位,如何选择视具体环境而定。

sizeof运算符

sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof运算符满足右结合律,其所得的值是一个size_t类型的常量表达式。它有两种形式:

  • sizeof(type)
  • sizeof?expr
    常量表达式意味着在编译期间就能得到计算。
    第二种形式中,sizeof返回的是表达式结果类型的大小。
    sizeof运算符的结果部分地依赖于其作用的类型:
  • 对char或者类型为char的表达式执行sizeof运算,结果得1。
  • 对引用类型执行sizeof运算得到被引用对象所占空间大小。
  • 对指针执行sizeof运算得到指针本身所占空间的大小。
  • 对解引用指针执行sizeof运算得到指针指向对象所占空间的大小,指针不需要有效。
  • 对数组执行sizeof运算得到整个数组所占空间大小。
  • 对string对象或vector执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。

    ?显式转换

命名的强制类型转换
一个命名的强制类型转换有如下形式:
cast-name(expression);
其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果是左值。cast-name是static_cast,?dynamic_cast,?const_cast和reinterpret_cast中的一种。dynamic_cast支持运行时识别,直到19章(p730)才会讲解。
static_cast
任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。

//?进行强制类型转换以便执行浮点数除法
double?slope?=?static_cast<double>(j)?/?i;

当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时,强制类型转换表示,我们知道并且不在乎潜在的精度损失。
static_cast对于编译器无法自动执行的类型转换也非常有用。例如,我们可以使用static_cast找回存在于void*的指针中的值:

void?*p?=?&d;????//?正确,任何非常量对象的地址都能存入void*

//?正确,将void*转换回初始的指针类型
double?*dp?=?static_cast<double*>(p);

必须确保转换后所得的类型就是指针所指的类型。类型一旦不符,将产生未定义的后果。
const_cast
const_cast只能改变运算对象的底层const:

const?char?*pc;
char?*p?=?const_cast<char*>(pc);????//?正确,但是通过p写值是未定义的行为

如果对象本身是一个非常量,使用强制类型转换获得写权限是合法的行为。然而如果对象是一个常量,执行写操作就会产生未定义的后果。
const_cast常常用于有函数重载的上下文中,这将在第6章介绍(p208)。
reinterpret_cast
reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。比如:

int?*ip;
char?*pc?=?reinterpret_cast<char*>(ip);

我们必须牢记pc所指的真实对象是一个int而非字符。
reinterpret_cast非常危险,书中建议尽量避免使用。因为它本质上依赖于机器。且没有介绍应用场景。另外,书中也建议尽量避免其他的强制类型转换,强制类型转换应当在其合适的应用场景中使用。
旧式的强制类型转换
在早期版本的C++语言中,显式地进行强制类型转换包含两种形式:

type(expr);???//?函数形式的强制类型转换
(type)expr;???//?C语言风格的强制类型转换

根据所涉及的类型不同,旧式的强制类型转换分别具有const_cast,?static_cast或reinterpret_cast相似的行为。
与命名的强制类型转换相比,旧式的强制类型转换从表现形式上来说不那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。# try语句块
try语句块的通用语法形式是:

try {
    program-statements
} catch (exception-declaration) {
    handler-statements
} // ...

当选中了某个catch子句处理异常之后,执行与之对应的块。catch一旦完成,程序跳转到try语句块最后一个catch子句之后的那条语句继续执行。
try语句块内声明的变量在catch子句内无法访问。
一个简要的例子:

while (cin >> item1 >> item2) {
    try {
        // ... 可能抛出一个异常的代码
    } catch (runtime_error err) {
        cout << err.what() << "\nTry Again? Enter y or n" << endl;
        char c;
        cin >> c;
        if (!cin || c == 'n')
            break; // 跳出while循环
    }
}

throw表达式

抛出异常的一个例子是:

throw runtime_error("Data must refer to same ISBN");

该异常是类型runtime_error的对象。抛出异常将终止当前的函数,并把控制权转移给能处理该异常的代码。

  • throw 表达式(throw expression),异常检测部分使用throw表达式来表示它遇到了无法处理的问题。我们说throw引发(raise)了异常。
  • try语句块(try block),异常处理部分使用try语句块处理异常。try语句块以关键字try开始,并以一个或多个catch子句(catch clause)结束。try语句块中代码抛出的异常通常会被某个catch子句处理。
  • 一套异常类(exception class),用于在throw表达式和相关的catch子句之间传递异常的具体信息。

    简单语句

    C++语言中的大多数语句都以分号结束,一个表达式,比如ival+5,末尾加上分号就变成了表达式语句(expression statement),表达式语句的作用是执行表达式并丢弃掉求值结果:
    ival + 5;    // 无意义的表达式语句
    cout << ival;// 有意义的表达式语句

空语句
最简单的语句是空语句(null statement),它只有一个分号:

; // 空语句

如果在程序的某个地方,语法上需要一条语句但是逻辑上不需要,此时应该使用空语句。
复合语句(块)
复合语句(compound statement)是指用花括号括起来的语句和声明的序列,复合语句也被称作(block)。一个块就是一个作用域。
如果在程序的某个地方,语法上需要一条语句,但是逻辑上需要多条语句,则应该使用复合语句。
所谓空块,是指内部没有任何语句的一对花括号。空块的作用等价于空语句:

while (cin >> s && s != sought)
{}  // 空块

语句作用域

可以在if、switch、while和for语句的控制结构内定义变量。定义在控制结构当中的变量只在相应语句的内部可见,一旦语句结束,变量也就超出其作用范围了:

while (int i = get_num()) // 每次迭代时创建并初始化
    cout << i << endl;
i = 0;    // 错误,在循环外部无法访问

条件语句

C++语言提供了两种按条件执行的语句。一种是if语句,它根据条件决定控制流;另一种是switch语句,它计算一个整型表达式的值,然后根据这个值从几条执行路径中选择一条。

if语句

if语句的作用是:判断一个指定的条件是否为真,根据判断结果决定是否执行另外一条语句。

switch语句

switch语句提供了一条便利的途径使得我们能够在若干固定选项中做出选择。

迭代语句

迭代语句通常称之为循环,它重复执行操作直到满足某个条件才停下来。while和for语句在执行循环体之前检查条件,do while语句先执行循环体,然后再检查条件。

while语句

语法格式是:

while (condition)
    statement

只要condition的求值结果为真就一直执行statement。如果condition第一次求值就是false,statement一次都不执行。
while的条件部分可以是一个表达式或者是一个带初始化的变量声明。
使用while循环
当不确定到底要迭代多少次时,使用while循环比较合适。还有一种情况也应该使用while循环,这就是我们想在循环结束后访问循环控制变量。

传统的for语句

for语句的语法形式是

for (init-statement: condition; expression)
    statement

范围for语句

C++11新标准引入了一种更简单的for语句,这种语句可以遍历容器或其他序列的所有元素。范围for语句(range for statement)的语法形式是:

for (declaration : expression)
    statement

expression必须是一个序列,比如用花括号括起来的初始值列表、数组、或者vector或string等类型的对象,这些类型的共同特点是拥有能返回迭代器的begin和end成员。
declaration定义一个变量,序列中的每个元素都能转换成该变量的类型。
每次迭代都会重新定义循环控制变量,并将其初始化成序列中的下一个值,之后才会执行statement。
在范围for语句中,预存了end()的值。一旦在序列中添加(删除)元素,end函数的值就可能变得无效了。因此不能通过范围for语句增加vector对象的元素。

do while语句

do while语句和while语句非常相似,唯一的区别是,do while语句先执行循环体后检查条件。不管条件的值如何,我们都至少会执行一次循环。do while语句的语法形式如下:

do
    statement
while (condition);

跳转语句

跳转语句中断当前的执行过程。C++语言提供了4种跳转语句:break, continue, goto和return。本章介绍前三种,return在第六章介绍(p199页)。

break语句

break语句负责终止离它最近的while, do while, for或switch语句,并从这些语句之后的第一条语句开始执行。

continue语句

continue语句终止最近的循环中的当前迭代并立即开始下一次迭代。continue语句只能出现在for, while和do while循环的内部。

goto语句

goto语句(goto statement)的作用是从goto语句无条件跳转到同一函数内的另一条语句。

函数

函数是一个命名了的代码块,我们通过调用函数执行相应的代码。函数可以有0个或多个参数,而且(通常)会产生一个结果。

函数基础

一个典型的函数(function)定义包括以下部分:返回类型(return type)、函数名字、由0个或多个形参(parameter)组成的列表以及函数体。
我们通过调用运算符(call operator)来执行函数。调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针;圆括号内是一个用逗号隔开的实参列表,我们用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。
函数的调用完成两项工作:一是用实参初始化函数对应的形参,二是将控制权转移给被调用函数。此时,主调函数(calling function)的执行暂时被中断,被调函数(called function)开始执行。
当遇到一条return语句时函数结束执行过程。函数的返回值用于初始化调用表达式的结果。
函数返回类型
大多数类型都能用作函数的返回类型。一种特殊的返回类型是void,它表示函数不返回任何类型。函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。

局部对象

在C++语言中,名字有作用域,对象有生命周期(lifetime),理解这两个概念非常重要:

  • 名字的作用域是程序文本的一部分,名字在其中可见。
  • 对象的生命周期是程序执行过程中该对象存在的一段时间。
    形参和函数体内部定义的变量统称为局部变量(local variable)。它们仅在函数的作用域内可见。
    在所有函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁。局部变量的生命周期依赖于定义的方式。

自动对象
对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。我们把只存在于块执行期间的对象称为自动对象(automatic object)
形参是一种自动对象。函数开始时为形参申请存储空间,函数一旦终止,形参就被销毁。
对于局部变量对应的自动对象来说,如果变量定义本身含有初始值,就用这个初始值进行初始化;否则执行默认初始化(内置类型产生未定义的值)。
局部静态对象(local static object)在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁。例:

// 统计函数被调用了多少次
size_t count_calls()
{
    static size_t ctr = 0; // 调用结束后,这个值仍然有效
    return ++ctr;
}

函数声明

函数的名字必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。
函数的声明和定义唯一的区别是声明无须函数体,用一个分号替代即可。
函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作函数原型(function prototype)
在头文件中进行函数声明
我们建议函数在头文件中声明,在源文件中定义。
这是因为如果把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。而且一旦我们想改变函数的接口,只需改变一条声明即可。
定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。

分离式编译

C++语言支持所谓的分离式编译(separate compilation)。分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。

参数传递

如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参。
当形参是引用类型时,我们说它对应的实参被引用传递(passed by reference)或者函数被传引用调用(called by reference)。
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递(passed by value)或函数被传值调用(called by value)。

传值参数

当初始化一个非引用类型的变量时,初始值被拷贝给变量。此时,对变量的改动不会影响初始值。
指针形参
当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接地访问它所指的对象,所以通过指针可以修改它所指对象的值。
熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参代替指针。

传引用参数

对于引用的操作实际上是作用于引用所引的对象上,引用形参也是如此。通过使用引用形参,允许函数改变一个或多个实参的值。
使用引用避免拷贝
拷贝大的类类型对象或者容器对象比较低效。甚至有的类型根本就不支持拷贝操作。此时应该使用引用形参访问该类型的对象。
如果函数无须改变引用形参的值,最好将其声明为常量引用。
使用引用形参返回额外信息
一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效的途径。那就是通过引用形参并修改它(也就是修改了其引用的对象),从而作为结果传出。

const形参和实参

当形参是const时,必须注意关于顶层const的讨论(p57)。
当用实参初始化形参时会忽略形参的顶层const。即当形参有顶层const时,传递给它常量对象或者非常量对象都是可以的。
忽略形参的顶层const可能产生意想不到的结果:

void fcn(const int i) {}
void fcn(int i) {}    // 错误:重复定义

在C++中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表有明显的区别。因为顶层const被忽略了,所以在上面的代码中传入两个fcn函数的参数可以完全一样(从而编译器不知道该调用哪一个)。
指针或引用形参与const
我们可以使用非常量初始化一个底层const,但是反过来不行(不能用一个常量初始化一个非底层const);同时一个普通的引用必须用同类型的对象初始化。
尽量使用常量引用
把函数不会改变的形参定义成(普通的)引用是一种常见错误,这么做给函数的调用者一种误导,即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型(比如无法传入一个常量对象了)。
比如下面这个例子将导致编译错误(p192):

// 不良设计,第一个形参的类型应该是const string&
string::size_type find_char(string &s, char c, string::size_type &occurs);
//...
find_char("Hello World", 'o', ctr); // 无法编译通过

数组形参

当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。
尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:

// 每个函数都有一个const int*类型的形参
void print(const int*);
void print(const int[]);    // 可以看出来,函数的意图是作用于一个数组
void print(const int[10]);  // 这里的维度表示我们期望数组含有多少元素,实际不一定
和其他使用数组的代码一样,以数组作为形参的函数也必须确保使用数组时不会越界。  

因为数组是以指针的形式传递给函数的,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息。管理指针形参有三种常用技术。

  1. 使用标记指定数组长度,如C风格字符串。
  2. 使用标准库规范,如传递首元素和尾后元素的指针,来表示一个范围。
  3. 显示传递一个表示数组大小的形参。

数组形参和const
当函数不需要对数组元素执行写操作的时候,数组形参应该是指向const的指针。只有当函数确实要改变元素值的时候,才把形参定义成指向非常量的指针。
数组引用形参
C++语言允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上。

// 正确,形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10])
{
    for (auto elem : arr)
        cout << elem << endl;
}

但这一用法也限制了print函数的可用性,我们只能将函数作用于大小为10的数组。
传递多维数组
和所有数组一样,当将多维数组传递给函数时,真正传递的是指向数组首元素的指针,也就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略:

// matrix指向数组的首元素,该数组的元素是由10个整数构成的数组
void print(int (*matrix)[10], int rowSize) { /* ... */ }
`*matrix`两端的括号必不可少:`int *matrix[10]//10个指针构成的数组`;`int (*matrix)[10]//指向含有10个整数的数组的指针`。

也可以使用数组的语法定义函数,此时编译器会一如既往地忽略掉第一个维度:

// 等价定义
void print(int matrix[][10], int rowSize) { /* ... */ }

matrix的声明看起来是一个二维数组,实际上形参是指向含有10个整数的数组的指针。

main: 处理命令行选项

有时候我们需要给main函数传递实参。一种常见的情况是用户通过设置一组选项来确定函数所要执行的操作。例如:

prog -d -o ofile data0

这些命令行选项通过两个(可选的)形参传递给main函数。

int main(int argc, char *argv[]) { ... }

第二个形参argv是一个数组,它的元素是指向C风格字符串的指针;第一个参数argc表示数组中字符串的数量;argc至少为1。
当实参传给main函数之后,argv的第一个元素指向程序的名字或者一个空字符串,接下来的元素依次传递命令行提供的实参。最后一个指针之后的元素值保证为0。
以上面的为例,argc应该等于5,argv应该包含如下的C风格字符串:

argv[0] = "prog";    // 或者argv[0]也可以指向一个空字符串
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;
当使用argv中的实参时,一定要记得可选的实参从`argv[1]`开始;`argv[0]`保存程序的名字,而非用户的输入。

含有可变形参的函数

为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:

  1. 如果所有的实参类型相同,可以传递一个名为initializer_list的标准库类型;
  2. 如果实参的类型不同,我们可以编写一种可变参数模板,其细节将在16.4节介绍(p618)。
    C++还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。这种功能一般只用于与C函数交互的接口程序。

initializer_list形参
initializer_list是一种标准库类型,用于表示某种特定类型的值的数组。initializer_list类型定义在同名的头文件中。
与vector不一样的是,initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值。
省略符形参
省略符形参是为了便于C++程序访问某些特殊的C代码而设置的。
省略符形参应该仅仅用于C和C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。
省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:

void foo(param_list, ...);
void foo(...);

返回类型和return语句

return语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。
return语句有两种形式:

return;
return expression;

无返回值函数

没有返回值的return语句只能用在返回类型是void的函数中。返回void的函数不要求非得有return语句,因为在这类函数的最后一句后面会隐式地执行return。

有返回值函数

只要函数的返回类型不是void,则该函数内的每条return语句必须返回一个值。return语句返回值的类型必须与函数的返回类型相同,或者能隐式地转换成函数的返回类型。
值是如何被返回的
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
如果函数返回引用,则该引用仅是它所引对象的一个别名。
不要返回局部对象的引用或指针
函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。
返回类类型的函数和调用运算符
调用运算符的优先级和点运算符、箭头运算符相同,并且符合左结合律。

//调用string对象的size成员,该string对象有shorterstring函数返回
auto sz = shorterstring(s1,s2).size();

引用返回左值
函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型得到右值。

char &get_val(string &str,string::size_type ix){
    return str[ix];
}
int main(){
    string s("a value");
    cout<

列表初始化返回值
C++11新标准规定,函数可以返回花括号包围的值的列表。此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。
主函数main的返回值
我们允许main函数没有return语句直接结束,这样编译器将隐式地插入一条返回0的return语句,表示执行成功。
为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量,可以用来表示成功与失败:

int main()
{
    if (some_failure)
        return EXIT_FAILURE;
    else
        return EXIT_SUCCESS;
}

递归
如果函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数(recursive function)。
在递归函数中,一定有某条路径是不包含递归调用的;否则,函数将“永远”递归下去,换句话说,函数将不断地调用它自身直到程序栈空间耗尽为止。
main函数不能调用它自己。

返回数组指针

因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。使用类型别名(p60)可以简化这种返回类型:

typedef int arrT[10];    // arrT是一个类型别名,表示含有10个整数的数组
using arrT = int[10];    // arrT的等价声明
arrT* func(int i);       // func返回一个指向含有10个整数的数组的指针

声明一个返回数组指针的函数
返回数组指针的函数形式如下:

Type (*function(param_list))[dimension]

类似于其他数组的声明,Type表示元素的类型,dimension表示数组的大小。(*表示返回的是一个指针。)例:

int (*func(int i))[10];

可以按照以下的顺序来逐层理解该声明的含义:

  • func(int i)表示调用func函数时需要一个int类型的实参。
  • (*func(int i))意味着我们可以对函数的调用结果执行解引用操作。
  • (*func(int i))[10]表示解引用func的调用将得到一个大小是10的数组。
  • int (*func(int i))[10]表示数组中的元素是int类型。

使用尾置返回类型
C++新标准提供了另一种简化上述func声明的方法,就是使用尾置返回类型(trailing return type)。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效。
尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个auto

// func接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i) -> int(*)[10];

使用decltype
如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型(即获得一个数组类型)。例:

int odd[] = {1, 3, 5, 7, 9};
int even[] = {0, 2, 4, 6, 8};
// 返回一个指针,该指针指向含有5个整数的数组
decltype(odd) *arrPtr(int i)
{
    return (i % 2) ? &odd : &even;
}
 decltype并不负责把数组类型转换成对应的指针,所以decltype的结果只是一个数组,要想表示arrptr返回指针还必须在函数声明时加一个`*`的符号。

函数重载

如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载(overload)函数。比如:

void print(const char *cp);
void print(const int *beg, const int *end);
void print(const int ia[], size_t size);

这些函数接受的形参类型不一样,但是执行的操作非常类似。当调用这些函数时,编译器会根据传递的实参类型推断想要的是哪个函数。
函数的名字仅仅是让编译器知道它调用的是哪个函数,而函数重载可以在一定程度上减轻程序员起名字、记名字的负担。
main函数不能重载。
不允许两个函数除了返回类型以外其他所有的要素都相同。比如:

Record lookup(const Account&);
bool lookup(const Account&);    // 错误,与上一个函数相比只有返回类型不同
 my note: 返回类型不同的函数,也可以是重载的。只要函数名相同而形参有明显的不同。  

重载和const形参
顶层const不影响传入函数的对象。一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来:

Record lookup(Phone);
Record lookup(const Phone);    // 重复声明

如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的const是底层的:

Record lookup(Account&);       // 此函数作用于Account的引用
Record lookup(const Account&); // 新函数,作用于常量引用

这种情况下,当我们传递一个非常量对象时,编译器会优先选用非常量版本的函数(尽管传给常量版本的也可以)。
const_cast和重载
const_cast在重载函数的情境中最有用。比如这两个重载函数:

// 比较两个string对象的长度,返回较短的那个引用
const string &shorterString(const string &s1, const string &s2)
{
    return s1.size() <= s2.size() ? s1 : s2; 
}

// 重载
string &shorterString(string &s1, string &s2)
{
    auto &r = shorterString(const_cast(s1), const_cast(s2));
    return const_cast(r);
}

下面重载的版本中,首先将它的实参强制转换成了对const的引用,然后调用了shorterString函数的const版本。const版本返回对const string的引用,这个引用事实上绑定在一个非常量实参上。因此,可以再将其转换回普通的const&,这显然是安全的。
传入非常量的实参将调用非常量的版本。
调用重载的函数
定义了一组重载函数后,我们需要以合理的实参调用它们。函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来。编译器首先将调用的实参与重载集合中的每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
当调用重载函数时有三种可能的结果:

  • 编译器找到一个与实参最佳匹配(best match)的函数,并生成调用该函数的代码。
  • 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配(no match)的错误信息。
  • 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性调用(ambiguous call)

    重载与作用域

    一般来说,将函数声明置于局部作用域内不是一个明智的选择。
    如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。对于函数而言也是如此。如果在内层作用域声明了一个函数,那么外层的同名的函数都将变得不可见,因此无法找到外层的重载版本。

    特殊用途语言特性

    默认实参

    这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的默认实参(default argument)。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。如:
    typedef string::size_type sz;
    string screen(sz ht = 24, sz wid = 80, char backrnd = ' ');
    一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。

使用默认实参调用函数
如果我们想使用默认实参,只要在调用函数的时候省略该实参就可以了。如:

string window;
window = screen();    // 等价于screen(24, 80, ' ');
window = stcreen(66); // 等价于screen(66, 80, ' ');

函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参。
当设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。
默认实参初始值
局部变量不可以作为默认实参。另外只要表达式的类型可以转换成形参类型,该表达式就可以作为默认实参。
如:

int g_a = 0;
void f(int a = g_a);

内联函数和constexpr函数

调用普通函数比直接写其语句要慢,这是因为调用函数包含一些额外的工作。
内联函数可以避免函数调用的开销
将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。
内联说明只是向编译器发出一个请求,编译器可以选择忽略这个请求。
内联机制用于优化规模小,流程直接,频繁调用的函数。
constexpr函数
是指能用于常量表达式的函数。
函数的返回类型及所有形参都得是字面值类型,且函数体内必须有且只有一条return语句。如:

constexpr int new_sz() { return 8; }
constexpr int foo = new_sz();

constexpr函数被隐式地指定为内联函数。
把内联函数和constexpr函数放在头文件内
这是因为内联函数和constexpr函数可以多次定义,且必须完全一致。所以把它们都定义在头文件内。

调试帮助

程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能:assert和NDEBUG。
assert预处理宏
assert是一种预处理宏(preprocessor macro)。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件:

assert(expr);

首先对expr求值,如果表达式为假(即0),assert输出信息并终止程序的执行。如果表达式为真(即非0),assert什么也不做。
assert宏定义在cassert头文件中。预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无需提供using声明。
assert宏常用于检查“不能发生”的条件。
NDEBUG预处理变量
assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。
我们可以使用一个#define语句定义NDEBUG,从而关闭调试状态。或者使用编译器提供的命令行选项定义预处理变量:

$ CC -D NDEBUG main.c

这条命令的作用等价于在main.c文件的一开始写#define NDEBUG。
我们可以把assert当成调试程序的一种辅助手段,但是不能用它代替真正的运行时逻辑检查,也不能代替程序本身应该包含的错误检查。
除了用于assert,也可以使用NDEBUG编写自己的调试代码。
比如:

void print(const int ia[], size_t size)
{
#ifndef NDEBUG
    // __func__是编译器定义的一个局部静态变量,用于存放函数的名字
    cerr << __func__ << "": array size is: " << size << endl;
#endif

// ...
}

编译器为每个函数都定义了__func__,除此之外,预处理器还定义了4个对于调试程序很有用的名字:

  • __FILE__, 存放文件名的字符串字面值。
  • __LINE__, 存放当前行号的整型字面值。
  • __TIME__, 存放文件编译时间的字符串字面值。
  • __DATA__, 存放文件编译日期的字符串字面值。

    函数匹配

    以下述这组函数及其调用为例,讲述编译器如何确定调用哪个重载函数:
    void f();
    void f(int);
    void f(int, int);
    void f(double, double = 3.14);
    f(5.6);    // 调用void f(double, double);

确定候选函数和可行函数
函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数成为候选函数(candidate function)。候选函数具备两个特征:

  1. 与被调用函数同名。
  2. 其声明在调用点可见。
    第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数(viable function)。可行函数也有两个特征:
  3. 其形参数量与本次调用提供的实参数量相等。
  4. 每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。
    如果没有找到可行函数,编译器将报告无匹配函数的错误。

寻找最佳匹配(如果有的话)
第三步是从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。
如果有且只有一个函数满足下列条件,则匹配成功:

  • 该函数每个实参的匹配都不劣于其他可行函数需要的匹配。
  • 至少有一个实参的匹配优于其他可行函数提供的匹配。
    如果编译器检查了每一个可行函数,没有一个能脱颖而出,则会报告二义性调用错误。

    实参类型转换

    为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下所示:
  1. 精确匹配,包括以下情况:
  • 实参类型和形参类型相同。
  • 实参从数组类型或函数类型转换成对应的指针类型。
  • 向实参添加顶层const或者从实参中删除顶层const。
  1. 通过const转换实现的匹配(p143)。
  2. 通过类型提升实现的匹配(p142)。
  3. 通过算数类型转换或指针转换实现的匹配(p142)。
  4. 通过类类型转换实现的匹配(参见14.9节,p514)。

需要类型提升和算术类型转换的匹配
函数匹配和const实参

int calc(char*,char*)
int calc(const char*,const char*)
//区别是他们的指针类型的形参是否指向了常量,属于底层const,合法定义
int calc(char*,char*)
int calc(char* const,char* const)
//区别是他们的指针类型的形参是否是常量,属于顶层const,非法定义

函数指针

函数指针指向的是函数而非对象。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。例如:

bool lengthCompare(const string&, const string&);

该函数的类型是:bool (const string&, const string&);
要想声明一个指向该函数的指针,只需要将函数名替换成指针即可:

bool (*pf)(const string&, const string&);

使用函数指针
当我们把函数名作为一个值使用的时候,该函数名自动转换成指针(指向该函数的)。
例如,可以这样给把函数地址赋值给指针:

pf = lengthCompare; // pf指向名为lengthCompare的函数
pf = &lengthCompare; // 等价的赋值语句,取地址符是可选的

可以直接对指向函数的指针调用该函数,无须解引用指针:

bool b1 = pf("Hello", "Hi");
bool b2 = (*pf)("Hello", "Hi"); // 等价调用
bool b3 = lengthCompare("Hello", "Hi"); // 等价调用

可以给函数指针赋一个nullptr或0,表示没有指向任何函数。
重载函数的指针
当使用了重载函数时,编译器必须确定一个能和指针类型精确匹配的函数,即返回类型和形参列表都要一样。
函数指针形参
不能定义函数类型的形参,但是形参可以是指向函数的指针。
当把函数名作为实参使用,它会自动转换成指针。
定义一个函数(以及指针)类型的方法有:

  • typedef
    typedef bool Func(int); // Func是函数类型
    typedef bool (*FuncP)(int); // FuncP是函数指针类型
  • decltype
    假如已经有了一个函数:bool Foo(int);
    decltype(Foo) Func;
    decltype(Foo) *FuncP;
  • using
    using Func = bool(int);
    using FuncP = bool(*)(int);

返回指针函数的指针
和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。与往常一样,要想声明一个返回函数指针的函数,最简单的办法是使用类型别名:

using F=int(int*,int);//F是函数类型,不是指针
using PF=int(*)(int*,int);//PF是指针类型

其中我们使用类型别名将F定义成函数类型,将PF定义成指向函数类型的指针。必须时刻注意的是,和函数类型的形参不一样,返回类型不会自动地转换成指针。我们必须显式地将返回类型指定为指针:

PF f1(int);//正确:PF是指向函数的指针,f1返回指向函数的指针
F f1(int);//错误:F是函数类型,f1不能返回一个函数
F *f1(int);//正确:显式地指定返回类型是指向函数的指针
出于完整性的考虑,有必要提醒读者我们还可以使用尾置返回类型的方式(参见6.3.3节,第206页)声明一个返回函数指针的函数:
auto fl(int)->int(*)(int*,int);

将auto和decltype用于函数指针类型

类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。

数据抽象就是接口(interface)与实现(implementation)分离的技术。

接口就是暴露给用户的操作,比如公有的成员函数。

实现就是数据成员、接口的实现、私有的成员函数。

通过抽象数据类型(abstract data type),来实现数据抽象和封装。

定义抽象数据类型

封装就是隐藏,抽象数据类型隐藏了自己的成员变量,外部只能使用其接口来间接访问其成员。

定义成员函数

类内的所有成员必须声明在类的内部。

类的成员函数可以定义在类的内部,也可以定义在类的外部。

定义在类内部的函数是隐式的inline函数。

引入this

当调用一个成员函数时,实际上是替某个对象调用它。

成员函数通过名为this的隐式参数来访问此对象。this指向了此对象的地址。

在成员函数内部,可以省略this来访问成员。

this是一个常量指针,不能够修改其值。

当成员函数中调用另一个成员函数时,将隐式传递this指针。

std::string isbn() const {return this->bookNo;}

引入const成员函数

参数列表之后,添加const关键字,表明传入的this指针是一个指向常量对象的指针。故此成员函数内,不能修改成员变量的内容。

const对象只能调用const版本的成员函数(因此如果函数不修改成员变量,那么为了提高灵活性,应该把函数声明成const版本的)。

C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数(const member function)。

常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

类作用域和成员函数

类本身就是一个作用域。

成员函数的定义必须包含其所属的类名(使用作用域运算符)。

如果成员函数声明为const版本的,其定义时,也要在参数列表后加const。

成员函数体可以随意使用类中的成员,无须在意成员出现的顺序,这是因为编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体。

定义一个返回this对象的函数

可以使用如下语句返回this对象:

return *this;

返回类型使用引用类型,表明返回的就是this所指的对象。

一般来说,当我们定义的函数类似于某个内置运算符时,应该令函数的行为尽量模仿这个运算符。比如说内置的赋值运算符把它的左侧运算对象当成左值返回,这种情况下,函数就可以返回this对象的引用。

定义类相关的非成员函数

有些函数也提供了操作类对象的方法,但他们不属于类的成员函数。

可以把这些函数放到类的头文件中声明。这些函数也可以看成是类的接口。

有可能会把这些函数声明称友元,从而方便它们直接操作成员变量。

构造函数

类通过一个或几个特殊的成员函数初始化其成员变量,这些函数叫构造函数(constructor)

每当类对象被创建,构造函数就会被执行。

构造函数名和类名一致,无返回类型,可能有多个(参数个数差异),不能是const的。

对于const对象,构造函数执行完毕后,它才获得const属性。

合成的默认构造函数

如果对象没有初始值,它将执行默认初始化。

类通过默认构造函数(default constructor)来执行默认初始化。如果没有显示定义过构造函数,编译器就会自动生成一个,叫做合成的默认构造函数。

合成的默认构造函数根据如下规则初始化类成员:

  • 如果存在类内初始值,使用它来初始化成员

  • 否则,对成员执行默认初始化

某些类不能依赖合成的默认构造函数

所谓不能依赖,就是不可以让编译器生成默认构造函数,要自己定义一个。其原因可能是:

  • 如果定义了自己的构造函数,那么编译器就不会生成默认的构造函数,此类就没有了默认构造函数。

  • 默认构造函数可能执行的是错误的操作,比如内置类型若没有类内初始值,则进行默认初始化,其值未定义。

  • 有时候,编译器无法生成默认构造函数,比如类成员中有类,而此类有可能没有默认构造函数。

=default的含义

C++11中,使用这种语句来让编译器生成一个默认构造函数:

SalesData() = default;
这种情况下,应当对内置类型的数据成员提供类内初始值,否则应当使用构造函数初始值列表形式的默认构造函数。

构造函数初始值列表

Sales_data(const std::string &s):
                        bookNo(s){}
Sales_data(const std::string &s,unsigned n,double p):
                        bookNo(s),units_sold(n),revenue(p*n){}

参数列表后,函数体前的一部分内容叫构造函数初始值列表(constructor initialize list)。

它负责为对象的成员变量赋初值。

如果成员不在初始化列表中,它用类内初始值初始化(如果存在),否则执行默认初始化。

构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

在类的外部定义构造函数

Sales data::Sales data(std::istream&is)
{
read(is,*this);//read函数的作用是从is中读取一条交易信息然后
        //存入this对象中
}

为了更好地理解调用函数 read的意义,要特别注意read的第二个参数是一个Sales data对象的引用。在7.1.2节(第232页)中曾经提到过,使用this来把对象当成一个整体访问,而非直接访问对象的某个成员。因此在此例中,我们使用*this将
“this”对象作为实参传递给read函数。

拷贝、赋值和析构

拷贝构造函数,当初始化变量时以值传递或函数返回一个对象时,会发生拷贝。

赋值运算,当使用了赋值运算符时,会发生对象的赋值操作。

析构函数,当一个变量不在存在时,会执行析构。

这些操作如果不显示定义,编译器就会合成一个,合成的拷贝赋值版本只是做了浅拷贝操作。

某些类不能依赖合成的版本

如果类中有成员绑定了外部的对象(比如动态内存),那么就不可依赖合成的版本。

可使用容器管理必要的存储空间,当发生拷贝等操作时,容器也会执行正确的拷贝。

访问控制与封装

使用访问说明符(access specifiers)加强类的封装性。

  • public说明符之后的成员对外可见,外部可访问,public成员定义类的接口。

  • private说明符之后的成员对内可见,外部无法访问,即隐藏了实现细节。

class和struct

其区别仅仅在于默认的访问权限。class默认为private,struct默认是public。

作为接口,应当是public的,而实现细节(数据成员或相关函数)应当为private的。

友元

类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。即在函数或类前面加friend关键字。

友元声明只能出现在类的内部。它并非函数声明,函数声明还要在别的地方声明。

一般来说,最好在类定义的开始或结束前的位置集中声明友元。

“封装的益处”
封装有两个重要的优点:

- 确保用户代码不会无意间破坏封装对象的状态。

- 被封装的类的具体实现可以随时改变,而无须调整用户级别的代码。

类的其它特性

类成员再探

定义一个类型成员

可以在类的内部定义一个类型(使用typedef或using),这个类型也有访问限制。

通常放在类的开头位置。

令成员作为内联函数

规模较小的成员函数适合声明成内联函数(定义时在前面加inline即可)。

如果定义在类内的函数,默认就是inline的。

inline成员函数通常定义到类的头文件中,即声明和定义在同一个文件中。

重载成员函数

和普通函数的重载规则一样。只要参数的数量or类型有区别,就可以重载。

如果是const版本的成员函数(传入const this),那么也可以重载。因为本质上,其隐式参数this的类型改变了。

类数据成员的初始值

可以给类数据成员一个类内初始值。使用等号或者花括号。

返回*this的成员函数

返回引用的函数是左值的,意味着这些函数(返回*this)返回的是对象本身而非对象的副本。

一个const成员函数如果以引用的形式返回\*this,那么它的返回类型将是常量引用。

但是如此一来(const成员函数返回const引用),就无法继续让返回的对象调用非常量版本的成员函数。一个解决的办法就是重载一个非常量版本的接口,定义一个私有的常量版本的函数,负责具体工作,而非常量版本的接口负责调用它,并返回非常量引用。

建议:对于公共代码使用私有功能函数。

类类型

每个类是一个唯一的类型,即使其内容完全一样。

类的声明

可以暂时声明类而不定义它,这叫前置声明(forward declaration)。

这种类型,在没有定义前是一个不完全类型(incomplete type)。这种类型只能在有限的情况下使用:

  • 定义指向这种类型的指针or引用

  • 声明以不完全类型为参数or返回值的函数

要创建一个类的对象,则必须已经定义好了这个类,这是因为编译器需要知道类的存储空间大小。

只有被定义,才能访问其成员。

声明一个前置类型的方法:

class A;
struct B;
namespace game
{
    class C;    // 前置声明一个在命名空间中的类
}

友元再探

类可以把普通函数定义成友元,也可以把类,类的成员函数定义成友元。

友元类有权访问本类的非公有成员。

类的作用域

一个类就是一个作用域。

类的作用域之外,普通的成员只能通过对象、引用or指针访问。对于类型成员的访问,需要使用域运算符::来访问。

名字查找与类的作用域

编译器处理完类的全部声明后,才会处理成员函数的定义。因此成员函数体中可以使用类中定义的任何位置的名字。

成员函数中的名字查找

按如下方式解析:

  • 在块内查找声明

  • 在类内查找,所有成员都可以被考虑

  • 在类的外围作用域中查找

构造函数再探

构造函数初始值列表

如果没有在构造函数的初始值列表中显示初始化成员,那么该成员将执行默认初始化。

如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初始值。
class ConstRef{
public:
    ConstRef(int ii);
private:
    int i;
    const int ci;
    int &i;
};
ConstRef::ConstRef(int ii){
    i = ii;// 正确
    ci = ii; //错误,不能给const赋值
    ri = i; //错误:ri未被初始化
}
//正确形式
ConstRef::ConstRef(int ii)::i(ii),ci(ii),ri(i){}

成员初始化的顺序

成员的初始化顺序和它们在类内的定义顺序一致。

而非其在初始值列表中的顺序,初始值列表只是做了初始化的工作。所以要让初始值列表中的成员顺序与定义顺序一致。

最好使构造函数初始值的顺序与成员声明的顺序一致,尽量避免用某些成员初始化其他成员。

有默认实参的构造函数

如果构造函数的所有实参都有默认实参,那么它实际上也同时定义了默认构造函数。

委托构造函数

C++11可以定义委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行他自己的初始化过程,或者说它把它自己的一些职责委托给了其他构造函数。

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。

 即先执行受委托的构造函数内容,再执行自己的。

默认构造函数的作用

当对象被默认初始化或值初始化时,自动执行默认构造函数。

默认构造函数在以下情况发生:

  • 不使用初始值定义一个非静态变量或者数组时

  • 当类含有类类型的成员且使用合成的默认构造函数时

  • 当类类型的成员没有在构造函数初始值列表中显式初始化时

值初始化在以下情况下发生:

  • 数组初始化时,若提供的初始值少于数组大小时

  • 不使用初始值定义一个局部静态变量时

  • 书写形如T()的表达式显式请求值初始化时

隐式的类类型转换

如果构造函数只接受一个实参,则它实际上定义了转换构造函数(converting constructor)

即定义了一个隐式转换机制。如string的接受一个const char*版本的构造函数。

使用explicit阻止这种隐式转换机制,explicit只能放到类内声明构造函数里。

只允许一步类类型转换

聚合类

聚合类(aggregate class)使得用户可以直接访问其成员。当类满足如下条件时,是聚合的:

  • 所有成员都是public的

  • 没有定义任何构造函数

  • 没有类内初始值

  • 没有基类,没有virtual函数

可以使用花括号括起来的成员初始值列表来初始化聚合类对象。

字面值常量类( Literal Classes)

类也可以是字面值类型。

这样的类可以含有constexpr函数成员,且符合constexpr函数的所有要求,且是隐式const的。

数据成员都是字面值类型的聚合类是字面值常量类。

如果不是聚合类,满足如下条件也是一个字面值常量类:

  • 数据成员都是字面值类型

  • 至少含有一个constexpr构造函数

  • 如果数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;类类型成员必须使用自己的constexpr构造函数

  • 类必须使用析构函数的默认定义

声明静态成员

在声明前加static关键字。

静态成员可以是public或private。数据成员可以是常量,引用,指针,类类型等。

对象不包含与静态数据成员有关的数据。

静态函数不包含this指针。

使用类的静态成员

使用作用域运算符访问静态成员。

类的对象、引用或指针可以访问静态成员。

类的成员函数可以直接访问静态成员。

定义静态成员

static只能出现在类的内部,不能出现在外部。

静态数据成员不属于类的对象,不是有构造函数初始化的。静态数据成员定义在函数体之外,一旦定义,就一直存在于程序的整个生命周期中。

double T::a = 1; // 定义并初始化一个静态成员

静态成员的类内初始化

通常,不应该在类内初始化静态数据成员。

不过,可以为静态成员提供const整数类型的类内初始值,且要求静态成员必须是字面值常量类型。

IO库

IO类

为了支持不同种类的IO处理操作,标准库定义了这几种类型:

  • iostream 定义了用于读写流的基本类型

  • fstream 定义了读写命名文件的类型

  • sstream 定义了读写内存string对象的类型

它们分别定义在同名的头文件中。

IO类型间的关系

类型ifstream和istringstream都继承自istream。我们可以像使用istream对象一样来使用它们。对于ostream也是如此。

IO对象无拷贝或赋值

由于不能拷贝IO对象,因此也不能将形参或返回类型设置为流类型。进行IO操作的函数通常以引用方式传递或返回流。

读写一个IO对象会改变其状态,因此传递和返回的引用不能是const的。

条件状态

IO类定义了一些函数和标志,可以帮助我们访问和操纵流的条件状态。见p279。
一个IO错误的例子:

int ival;
cin >> ival;

如果试图在标准输入上键入Boo,读操作就会失败,cin进入错误状态。

如果输入一个文件结束符标识,cin也会进入错误状态。

一个流一旦发生错误,其上后续的IO操作都会失败。确定一个流对象的状态的最简单的方法是将它当作一个条件来使用:

while (cin >> word)
    // ok

管理输出缓冲

每个输出流都管理一个缓冲区,用来保存程序读写的数据。如果执行下面的代码:

os << "please enter a value: ";

文本串可能立即打印出来,但也有可能被操作系统保存在缓冲区中,随后再打印。这样可以带来很大的性能提升。

导致缓冲区刷新的原因有:

  • 程序正常结束

  • 缓冲区满时

  • 使用操纵符,如endl,来显式刷新缓冲区

  • 读cin或写cerr,都会导致cout的缓冲区被刷新

刷新输出缓冲区

IO库还提供了两个操纵符用于刷新缓冲区:

  • flush 刷新缓冲区,但不输出任何额外字符

  • ends 向缓冲区插入一个空字符,然后刷新缓冲区

unitbuf操纵符

如果想在每次输出操作后都刷新缓冲区,我们可以使用unitbuf操纵符。

cout << unitbuf;    // 所有输出操作后都会立即刷新缓冲区
cout << nounitbuf;  // 回到正常的缓冲方式
如果程序崩溃,输出缓冲区不会刷新

文件输入输出

除了继承自iostream类型的行为之外,fstream中定义的类型还增加了一些新的成员来管理与流关联的文件。见p283。

使用文件流对象

当想要读写一个文件时,可以定义一个文件流对象,并将对象与文件关联起来。

每个文件流类都定义了一个名为open的成员函数,它完成一些系统相关的操作,来定位给定的文件,并视情况打开为读或写模式。

创建文件流对象时,如果提供了一个文件名,则open会被自动调用:

ifstream in(file);    // 构造一个ifstream并打开给定的文件
ofstream out;         // 输出文件流未关联到任何文件
当一个fstream对象被销毁时,close会自动被调用。

文件模式

每个流都有一个关联的文件模式,用来指出如何使用文件。见p286。

每个文件流类型都定义了一个默认的文件模式,当未指定文件模式时,就使用此默认模式。

  • 与ifstream关联的文件默认以in模式打开;

  • 与ofstream关联的文件默认以out模式打开;

  • 与fstream关联的文件默认以in和out模式打开。

以out模式打开文件会丢失已有数据

默认情况下,当我们打开一个ofstream时,文件的内容会被丢弃。

阻止丢弃的方法是同时指定app模式:

ofstream out("file1");    // 文件被截断
ofstream app("file2", ofstream::app);    // 保留文件内容,写操作在文件末尾进行

string流

sstream头文件定义了三个类型来支持内存IO:

  • istringstream从string读取数据。

  • ostringstream向string写入数据。

  • stringstream既可以从string读数据,也可以向string写数据。

sstream增加了一些成员来管理与流相关联的string。见p287。

使用istringstream

当我们的某些工作是对整行文本进行处理,而其他一些工作是处理行内的单个单词时,通常可以使用istringstream。

使用ostringstream

当我们逐步构造输出,希望最后一期打印时,ostringstream是很有用的。

顺序容器

顺序容器概述

所有顺序容器都提供了快速顺序访问元素的能力。但是,这些容器在以下方面都有不同的性能折中:

-. 向容器添加或从容器中删除元素的代价

-. 非顺序访问容器中元素的代价

顺序容器有:vector, deque, list, forward_list, array, string。

string和vector将元素保存在连续的内存空间中。由于元素是连续存储的,由元素的下标来计算其地址是非常快速的。但是,在其中间添加或删除元素就会非常耗时,因为这需要移动插入或删除位置之后的所有元素。而且,添加元素可能导致分配额外的存储空间,这种情况下,每个元素都会移动到新的存储空间中。

list和forward_list两个容器添加和删除操作都很快速。作为代价,它们不支持元素的随机访问,为了访问一个元素,只能遍历整个容器。与vector、deque和array相比,这两个容器的额外内存开销也很大。

deque支持快速随机访问,在deque的中间位置插入或删除元素的代价(可能)很高。但是,在deque的两端添加或删除元素都是很快的。

forward_1ist和array是新C++标准增加的类型。与内置数组相比,array是一种更安全、更容易使用的数组类型。与内置数组类似,array对象的大小是固定的。因此,array不支持添加和删除元素以及改变容器大小的操作。forward_list的设计目标是达到与最好的手写的单向链表数据结构相当的性能。因此,forward_list 没有size操作,因为保存或计算其大小就会比手写链表多出额外的开销。对其他容器而言,size保证是一个快速的常量时间的操作。

确定使用哪种容器

通常,使用vector是最好的选择,除非你有很好的理由选择其他容器。

容器库概览

对容器可以保存的元素类型的限制

顺序容器几乎可以保存任意类型的元素。

迭代器

迭代器有着公共的接口:如果一个迭代器提供某个操作,那么所有提供相同操作的迭代器对这个操作的实现方式都是相同的。比如解引用操作。

表3.6(96页)列出了容器迭代器支持的所有操作。表3.7(99页)列出了迭代器支持的算术运算,这些运算只能应用于string、vector、deque和array。

迭代器范围

迭代器范围由一对迭代器表示,通常被称为begin和end,它们标记了容器中元素的一个范围。这个范围被称为左闭合区间:[begin, end)

使用左闭合区间蕴含的编程假定

假定begin和end构成一个合法的迭代器范围,则:

  • 如果begin与end相等,则范围为空

  • 如果begin与end不等,则范围至少包含一个元素,且begin指向该范围中的第一个元素

  • 我们可以对begin递增若干次,使得begin == end

容器定义和初始化

每个容器类型都定义了一个默认构造函数。除array之外,其他容器的默认构造函数都会创建一个指定类型的空容器,且都可以指定容器大小和元素初始值的参数。

将一个容器初始化为另一个容器的拷贝

方法有两种:

  • 直接拷贝整个容器,两个容器的类型和元素的类型都必须匹配。

  • 拷贝一个迭代器范围,容器类型不一定匹配,且元素类型只要能够转换即可。

//每个容器有三个元素,用给定的初始化器进行初始化
list authors={"Milton","Shakespeare","Austen"};
vector articles={"a","an","the"};
1ist1ist2(authors);//正确:类型匹配
dequeauthList(authors);//错误:容器类型不匹配
vectorwords(articles);//错误:容器类型必须匹配
//正确:可以将const char*元素转换为
string forward_list words(articles.begin(),articles.end());

列表初始化

list articles = {"a","an","the"};

标准库array具有固定大小

为了使用array类型,我们必须同时指定元素类型和大小,

array::size_type i;//数组类型包括元素类型和大小;

赋值和swap

赋值运算符将其左边容器中的全部元素替换为右边容器中的元素的拷贝。

c1 = c2;
ca = {a,b,c};

与内置数组不同,标准库array类型允许赋值。赋值号左右两边的运算对象必须具有相同的类型:

arrayal={0,1,2,3,4,5,6,7,8,9};
arraya2={0};//所有元素值均为0
al=a2;//替换a1中的元素
a2={0};//错误:不能将一个花括号列表赋予数组

由于右边运算对象的大小可能与左边运算对象的大小不同,因此array类型不支持asign,也不允许用花括号包围的值列表进行赋值。

使用assign(仅顺序容器)

赋值运算要求两边容器类型和元素类型相同。顺序容器(除了array)还定义了一个名为assign的成员,允许从一个相容的序列中赋值。

使用swap

调用swap操作后,两个容器中的元素将会交换。

除了array,交换两个容器的操作保证会很快,因为元素本身并未交换,swap只是交换了两个容器的内部数据结构。

容器大小操作

每个容器都支持这些大小相关的操作:

  • 成员函数size,返回容器中元素的数目,forward_list不支持;

  • empty,当size为0时返回true,否则返回false;

  • max_size,返回一个大于或等于该容器所能容纳的最大元素数的值,这是一个很大的值。

关系运算符

每个容器都支持相等运算符(==和!=),除了无序关联容器外的所有容器都支持关系运算符(>, >=, <, <=)。关系运算符左右两边的运算对象必须是相同类型的容器,且必须保存相同类型的元素。

比较两个容器实际上是进行元素的逐对比较。

只有当元素类型定义了相应的比较运算符时,才可以使用关系运算符比较两个容器。

顺序容器操作

顺序容器和关联容器的不同之处在于两者组织元素的方式。这些不同之处直接关系到了元素如何存储、访问、添加及删除。

向顺序容器添加元素

标准库容器提供了灵活的内存管理。在运行时可以动态添加或删除元素来改变容器大小。表9.5,p305。

这些操作会改变容器的大小;array不支持这些操作。
forward_list有自己专有版本的insert和emplace;参见9.3.4节(第312页)。
forward_1ist不支持 push_back和emplace_back。
vector和string不支持push front和emplace front。
c. push back(t);
c. emplace back(args);
c. push_front(t);
c. emplace_front(args);
c. insert(p,t);
c. emplace(p, args);
c. insert(p,n,t)c. insert(p,b,e);
c. insert(p, il);
向一个deque、string或vector插入元素会使所有指向容器的迭代器、引用和指针失效。

将元素插入到deque、string或vector中的任何位置都是合法的。然而,这样做可能很耗时。

关键概念:容器元素是拷贝

当我们用一个对象来初始化容器时,或将一个对象插入到容器中时,实际上放入到容器中的是对象值的一个拷贝。

访问元素

表9.6(p310)列出了我们可以用来在顺序容器中访问元素的操作。如果容器中没有元素,访问操作的结果是未定义的。

访问成员函数返回的是引用

在容器中访问元素的成员函数(即,front、back、下标和at)返回的都是引用。

如果容器是一个const对象,则返回值是const的引用。如果容器不是const的,则返回值是普通引用,我们可以用来改变元素的值:

if(!c.empty()){
C.front()=42;//将42赋予c中的第一个元素
auto&v=c.back();//获得指向最后一个元素的引用
v=1024;//改变c中的元素
auto v2=c.back();//v2不是一个引用,它是c.back()的一个拷贝
v2=0;//未改变c中的元素

下标操作和安全的随机访问

提供快速随机访问的容器(string、vector、deque和array)也都提供下标运算符。保证下标合法是程序员的责任,编译器不检查越界错误。

如果想确保下标是合法的,可以使用at成员函数。at成员函数类似下标运算符,如果下标越界,at会抛出一个out_of_range异常。

删除元素

删除deque中除首尾之外的任何元素都会使所有迭代器、引用、指针失效。指向vector或string中删除点之后位置的迭代器、引用和指针都会失效。

删除元素之前,程序员必须确保它们是存在的。
这些操作会改变容器的大小,所以不适用于array。
forward list 有特殊版本的erase,参见9.3.4节(第312页)。
forward_list 不支持 popback;vector和string不支持pop_front。
c.pop_back()
c.pop_front()
c.erase(p)
c.erase(b,e)
c.clear()

改变容器大小

可以使用resize来增大或缩小容器。如果当前大小大于所要求的大小,容器后部的元素会被删除;如果当前大小小于新大小,会将新元素添加到容器后部。

resize接受一个可选的元素指参数,用来初始化新添加的元素。如果未提供,新元素进行值初始化。

c.resize(n);
c.resize(n,t);

容器操作可能使迭代器失效

使用失效的迭代器、引用、或指针是一种严重的错误。

向容器添加元素后:

  • 如果容器是vector或string,且存储空间被重新分配,那么所有的迭代器都会失效。如果空间未重新分配,指向插入位置之前的元素的迭代器仍有效,但之后的迭代器会失效。

  • 对于list和forward_list,指向容器的迭代器仍有效。

当从容器中删除元素后:

  • 对于list和forward_list,指向容器其他位置的迭代器仍有效。

  • 对于string和vector,被删除元素之前的元素的迭代器仍有效。

vector对象是如何增长的

管理容量的成员函数

//shrink to_fit 只适用于vector、string 和deque。
//capacity和 reserve 只适用于vector和string。
c.shrink_to_fit();//请将 capacity()减少为与size()相同大小
c.capacity();//不重新分配内存空间的话,c可以保存多少元素
C.reserve(n);//分配至少能容纳n个元素的内存空间

额外的 string 操作

除了顺序容器共同的操作之外, string 类型还提供了一些额外的操作。

构造 string 的其他方法

使用下面这些方法可以构造 string :

以下 n, len2, pos2 都是无符号值。

方法 说明
string s(cp, n) s是cp指向的数组中前n个字符的拷贝
string s(s2, pos2) s是 string s2 从下标 pos2 开始的字符拷贝
string s(s2, pos2, len2) s是 string s2 从下标 pos2 开始 len2 个字符的拷贝,不管 len2 的值是多少,构造函数至多拷贝 s2.size() - pos2 个字符
substr 操作

substr 返回一个 string ,它是原始 string 的一部分或全部的拷贝。

s.substr(pos, n) 返回一个 string ,包含s中从pos开始的n个字符的拷贝。pos默认为0,n默认为 s.size() - pos ,即拷贝从 pos 开始的所有字符。

改变 string 的其他方法

string 类型支持顺序容器的赋值运算符以及 assign, insert, erase 操作。除此之外,它还定义了额外的 insert 和 erase 版本。即使用下标的版本。

s.insert(s.size(),5,"!");//在s末尾插入五个!

这些函数都拥有许多重载的版本。

assign 版本还接受C风格字符串:需要以空格结尾

append 和 replace 是额外的成员函数, append 在 string 末尾进行插入操作, replace 替换内容,它是调用 erase 和 insert 的一种简写形式:

string s("C++ Primer 4th Ed.");
//从位置11开始,删除三个字符并插入Fifth;
s.replace(11,3,"Fifth)

string 搜索操作

string 提供了6个搜索函数,它们都有4个重载版本。它们都返回一个 string::size_type 的值作为匹配位置(下标)。如果搜索失败,返回 string::npos ,其值为 -1 。

可以给函数一个搜索的起始位置 pos ,它默认值是0:
auto pos = s.find_first_of(numbers, pos);

string name("guohaoxin01236578");
auto pos1 = name.find("guo");//pos1==0返回字符串guo第一次出现的位置
numbers = "0123456789";
auto pos2 = name.find_first_of(numbers);//寻找numbers字符串中任意字符出现的位置,find_first_not_of

指定从哪里开始搜索

string size_type pos = 0;
while((pos=name.find_first_of(numbers,pos))!=string::npos){
    cout<<"found number at index:"<

compare 函数

这是字符串比较函数,和C标准库的 strcmp 很相似。

数值转换

标准库提供了数值转换的函数。

to_string(val)
stoi/l/ul/ll/ull/f/d/ld//转换成int、double、float

如果 string 不能转换成一个数值,那么会抛出一个 invalid_argument 的异常。如果转换得到的数值无法用任何类型来表示,则抛出一个 out_of_range 异常。

容器适配器

三个容器适配器:stack(栈适配器),queue,priority_queue(队列适配器)。

定义一个适配器

stack stk;

泛型算法

标准库并未给每个容器都定义成员函数来实现一些特殊的操作,如查找元素、替换或删除元素、重排元素等。而是定义了一组泛型算法。它们实现了一些经典算法的公共接口,可以用于不同类型的元素和多种容器类型,包括内置的数组类型。


概述

大多数算法定义在头文件algorithm中,头文件numeric中定义了一组数值泛型算法。

通常,算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围来进行操作。

算法不依赖于容器,但依赖于元素类型的操作。比如,find用元素类型的==运算符完成序列中的元素与给定值的比较。大多数算法提供了一种方法,允许我们使用自定义的操作来代替默认的运算符(即使用谓词)。

迭代器令算法不依赖于容器,但算法依赖于元素类型的操作。

初识泛型算法

附录A按照操作方式列出了所有的算法。

除了少数例外,标准库算法都对一个范围内的元素进行操作。我们将此元素范围称为“输入范围”。

理解算法的最基本的方法就是了解它们是否读取元素、改变元素或是重排元素顺序。

只读算法

一些算法只会读取其输入范围内的元素,而从不改变元素。比如find、accumulate。

int sum = accumulate(vec.cbegin(),vec.cend(),0);//求和,和的初值为0;
string sum =accumulate(v.cbegin(),v.cend(),string(""));//string定义了字符串的“+”法,
//错误,const char *上没有定义+运算符
string sum =accumulate(v.cbegin(),v.cend(),"");

操作两个序列的算法

举一个列子:equal算法,它比较两个序列中的元素。此算法接受三个迭代器:前两个表示第一个序列中的元素的范围,第三个表示第二个序列的首元素:

// roster2中的元素数目应该至少与roster1一样多
equal(roster1.cbegin(), roster1.cend(), roster2.cbegin());

这样的算法基于一个非常重要的假设:它假定第二个序列至少与第一个序列一样长。

写容器元素的算法

一些算法将新值赋予序列中的元素。当我们使用这类算法时,必须注意确保序列原大小至少不小于我们要求算法写入元素数目(note:如容器大小足够)。

这样的算法比如fill。

介绍back_inserter

一种保证算法有足够元素空间来容纳输出数据的方法是使用插入迭代器(insert iterator)。插入迭代器是一种向容器中添加元素的迭代器。当我们通过一个插入迭代器赋值时,一个与赋值号右侧值相等的元素被添加到容器中。

拷贝算法

拷贝(copy)算法是另一个向目的位置迭代器指向的输出序列中的元素写入数据的算法。此算法接受三个迭代器,前两个表示一个输入范围,第三个表示目的序列的起始位置。此算法将输入范围中的元素拷贝到目的序列中。传递给copy的目的序列至少要包含与输入序列一样多的元素,这一点很重要。

我们可以用copy实现内置数组的拷贝,如下面代码所示:

int al[]={0,1,2,3,4,5,6,7,8,9};
int a2[sizeof(a1)/sizeof(*al)];//a2与al大小一样
//ret指向拷贝到a2的尾元素之后的位置
auto ret=copy(begin(a1),end(al),a2);//把a1的内容拷贝给a2

copy返回的是其目的位置迭代器(递增后)的值。即,ret恰好指向拷贝到a2的尾元素之后的位置。

重排元素的算法

某些算法会重排容器中元素的顺序,比如sort,它利用元素类型的<运算符来实现排序。

定义操作

很多算法都会比较输入序列中的元素。默认情况下,这类算法使用元素类型的<或==运算符完成比较。标准库为这些算法定义了额外的版本,允许我们提供自己定义的操作来替代默认运算符。

向算法传递函数

sort接受第三个参数,此参数是一个谓词(predicate)。

谓词

谓词是一个可调用的表达式,其调用结果是一个能用作条件的值。标准库算法使用的谓词分为两类:

  • 一元谓词,意味着它们只接受单一参数

  • 二元谓词,意味着它们有两个参数

接受谓词的算法对输入序列中的元素调用谓词。

lambda表达式

我们传递给算法的谓词必须严格接受一个或两个参数,但是有时我们希望进行的操作需要更多的参数,超出了算法对谓词的限制。

介绍lambda

我们可以向一个算法传递任何类别的可调用对象,对于一个对象或一个表达式,如果可以对其使用可调用运算符,则称它为可调用的。

一个lambda表达式表示一个可调用的代码单元。可以将其理解为一个未命名的内联函数。一个lambda表达式具有一个返回类型、一个参数列表和一个函数体。但与函数不同,lambda可以定义在函数内部。

一个lambda表达式具有如下形式:

[capture list](parameter list) -> return type { function body }

其中,capture list是一个lambda所在函数中定义的局部变量的列表。

可以忽略返回类型,这时会自动推断返回类型。

auto func = [](){ return 42; };

lambda捕获和返回

当定义一个lambda时,编译器生成一个与lambda对应的新的(未命名的)类类型。当向一个函数传递一个lambda时,同时定义了一个新类型和该类型的一个对象。类似地,当使用auto定义一个用lambda初始化的变量时,定义了一个从lambda生成的类型的对象。

默认情况下,从lambda生成的类都包含一个对应该lambda所捕获的变量的数据成员。类似任何普通类的数据成员,lambda的数据成员也在lambda对象创建时被初始化。

变量捕获的方式可以是值或引用。值捕获是变量的拷贝,引用捕获是变量的引用。

当以引用方式捕获一个变量时,必须保证在lambda执行时变量是存在的。

建议:

尽量保持lambda的变量捕获简单化。如果可能的话,应该避免捕获指针或引用。见p351。

隐式捕获

可以让编译器根据lambda体中的代码来推断要使用哪些变量。为了指示编译器推断捕获列表,应在捕获列表中写一个&或=。&告诉编译器采用捕获引用方式,=则表示采用值捕获方式。

如:

// sz为隐式捕获,值捕获方式
wc = find_if(words.begin(), words.end(),
             [=](const string &s) { return s.size() >= sz; } );

详见lambda捕获列表,p352。

可变lambda

默认情况下,对于一个值拷贝的变量,lambda不会改变其值。如果希望改变,必须在参数列表后加上关键字mutable。

void fcn3()
{
    size_t v1 = 42;
    // f可以改变它捕获的变量的值
    auto f = [v1]() mutable { return ++v1; };
    v1 = 0;
    auto j = f(); // j为43
}

参数绑定

对于那种只在一两个地方使用的简单操作,lambda表达式是最有用的。如果需要在很多地方使用相同的操作,或者一个操作需要很多语句完成,通常应该定义一个函数。

如果lambda的捕获列表为空,通常可以用函数来代替它。但如果捕获列表不为空就不能直接代替了。

标准库bind函数

为了解决这个问题,可以使用一个新的名为bind的标准库函数,它定义在头文件functional中。它接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表。

auto newCallable = bind(callable, arg_list);

newCallable本身是一个可调用对象,arg_list是一个逗号分隔的参数列表,对应给定的callable参数。即,当我们调用newCallable时,newCallable会调用callable,并传递给它arg_list中的参数。

arg_list中的参数可能包含形如_n的名字,这些参数是“占位符”,表示newCallable的参数。比如:_1为newCallable的第一个参数,_2为第二个参数。

使用placeholders名字

名字_n都定义在一个名为placeholders的命名空间中,这个命名空间本身定义在std命名空间中。

一种简单的using语句是:

using namespace namespace_name;

这种形式说明希望所有来自namespace_name的名字都可以在我们的程序中直接使用。如:

using namespace std::placeholders;

这使得placeholders定义的所有名字都可用。

再探迭代器

除了每个容器的迭代器,标准库在头文件iterator中还定义了额外几种迭代器。

  • 插入迭代器:这些迭代器被绑定到一个容器上,可以用来向容器插入元素。

  • 流迭代器:这些迭代器被绑定到输入或输出流上,可以来遍历所关联的IO流。

  • 反向迭代器:这些迭代器向后而不是向前移动。

  • 移动迭代器:不拷贝其中的元素,而是移动它们。将在13.6.2节(p480页)介绍。

插入迭代器

插入器是一种迭代器适配器,它接受一个容器,生成一个迭代器,能实现向给定容器添加元素。当我们通过一个插入迭代器进行赋值时,该迭代器调用容器操作来向给定容器的指定位置插入一个元素。

it = t; // 在it指定的当前位置插入值t。

插入迭代器有三种类型,差异在于元素插入的位置:

  • back_inserter,创建一个使用push_back的迭代器。

  • front_inserter,创建一个使用push_front的迭代器。

  • inserter,创建一个使用insert的迭代器。此函数接受第二个参数,这个参数必须是一个指向给定容器的迭代器。元素将被插入到给定迭代器所表示的元素之前。

list lst = {1,2,3,4};
liatlst2,lst3;//空的list
//copy 完成后lst2包含4 3 2 1
copy(lst.begin(),lst.end(),front_inserter(lst2));
//opy 完成后lst3包含1 2 3 4
copy(lst.begin(),lst.end(),inserter(lst3,lst3.begin()));

iostream迭代器

istream_iterator读取输入流,ostream_iterator向一个输出流写数据。这些迭代器将它们对应的流当作一个特定类型的元素序列来处理。

通过使用流迭代器,我们可以使用泛型算法从流对象读取数据以及向其写入数据。

详细操作见p359。

反向迭代器

反向迭代器就是在容器中从尾元素向首元素反向移动的迭代器。对于反向迭代器,递增(以及递减)操作的含义会颠倒过来。

可以通过rbegin, rend, crbegin, crend成员函数来获得反向迭代器。这些成员函数返回指向容器尾元素和首元素之前一个位置的迭代器。

泛型算法结构

任何算法的最基本的特性是它要求其迭代器提供哪些操作。算法所要求的迭代器操作可以分为5个迭代器类别。

迭代器 要求
输入迭代器 只读,不写;单遍扫描,只能递增
输出迭代器 只写,不读;单遍扫描,只能递增
前向迭代器 可读写;多遍扫描,只能递增
双向迭代器 可读写;多遍扫描,可递增递减
随机访问迭代器 可读写,多遍扫描,支持全部迭代器运算

5类迭代器

类似容器,迭代器也定义了一组公共操作。一些操作所有迭代器都支持,另一些只有特定类别的迭代器才支持。

如ostream_iterator只支持递增、解引用和赋值。vector、string、deque的迭代器除了这些操作,还支持递减、关系和算术运算。

除了输出迭代器之外,一个高层类别的迭代器支持低层类别迭代器的所有操作。

算法的形参模式

大多数算法具有如下4种形式之一:

alg(beg, end, other args);
alg(beg, end, dest, other args);
alg(beg, end, beg2, other args);
alg(beg, end, beg2, end2, other args);

其中,alg是算法名字,beg和end表述输入范围。几乎所有算法都有一个输入范围。

接受单个目标迭代器的算法

dest参数是一个表示算法可以写入目的位置的迭代器。算法假定(assume):按其需要写入数据,不管写入多少个元素都是安全的。

一般dest被绑定到一个插入迭代器或是一个ostream_iterator。插入迭代器会将新元素添加到容器中,因为保证空间是足够的。

接受第二个输入序列的算法

接受beg2或beg2和end2的算法用这些迭代器表示第二个输入范围。

接受单独beg2的算法假定从beg2开始的序列与beg和end所表示的范围至少一样大。

算法命名规范

除了参数规范,算法还遵循一套命名和重载规范。

一些算法使用重载形式传递一个谓词

函数的一个版本用元素类型的运算符来比较元素;另一个版本接受一个额外的谓词参数,来代替<或==:

unique(beg, end);
unique(beg, end, comp);

_if版本的算法

接受一个元素值的算法通常有另一个不同名的(不是重载的)版本,该版本接受一个谓词代替元素值。接受谓词参数的算法都有附加的_if前缀:

find(beg, end, val);
find_if(beg, end, pred);

区分拷贝元素的版本和不拷贝的版本

默认情况下,重排元素的算法将重排后的元素写回给定的输入序列中。这些算法还提供另一个版本,将元素写到一个指定的输出目的位置。

reverse(beg, end);
reverse_copy(beg, end, dest);

特定容器的算法

链表类型list定义了几个成员函数形式的算法。通用版本的sort要求随机访问迭代器,因此不能用于list。

链表类型定义的其他算法的通用版本可以用于链表,但代价太高。这些算法需要交换输入序列中的元素。一个链表可以通过改变元素间的链接而不是真的交换它们的值来快速“交换”元素。因此,这些链表版本的算法的性能比对应的通用版本好得多。

这些算法见p369。

链表特有的操作会改变容器

多数链表特有的算法与通用版本的很相似,但不完全相同,其中一个至关重要的区别是链表版本的会修改底层的容器。例如, remove 的链表版本会删除指定的元素, unique 的链表版本会删除第二个和后继的重复元素。

对于通用版本的,如 std::remove ,不会删除容器的元素。它只会迁移元素。之后需要调用 erase 才能执行确切的删除动作。

关联容器

关联容器与顺序容器有着根本的不同:

  • 关联容器中的元素是按关键字来保存和访问的。

  • 顺序容器中的元素是按它们在容器中的位置来顺序保存和访问的。

关联容器支持高效的关键字查找和访问,有两个主要的关联容器:

  • map,其元素是一些关键字-值对,关键字起到索引作用,值则表示与之相关的数据。

  • set,每个元素只包含一个关键字。

标准库提供8个关联容器,如表11.1所示。这8个容器间的不同体现在三个维度上:每个容器

  1. 或者是一个set,或者是一个map;
  2. 或者要求不重复的关键字,或者允许重复关键字;
  3. 按顺序保存元素,或无序保存。

允许重复关键字的容器的名字中都包含单词multi;不保持关键字按顺序存储的容器的名字都以单词unordered开头。因此一个unordered multi set是一个允许重复关键字,元素无序保存的集合,而一个set则是一个要求不重复关键字,有序存储的集合。无序容器使用哈希函数来组织元素。

使用关联容器

map是关键字-值对的集合,通常被称为关联数组。关联数组与“正常”数组类似,不同之处在于其下标不必是整数。我们通过一个关键字而不是位置来查找值。与之相对,set就是关键字的简单集合。

//统计每个单词在输入中出现的次数
map<string,size_t) word_count;
string word;
while(cin>>word)
    ++word_count[word];
for(const auto &a:word_count)
//打印结果
    cout<<a.first<<"occurs"<<w.second<<((w.second>1)?"times":"time"<<endl;

使用set
可以使用set忽略我们想要忽略的单词,例如:

    // 统计每个单词在输入中出现的次数
    map word_count;    // string到size_t的空map
    set exclude = {"The", "the", "And", "and"};

    string word;
    while (cin >> word) {
        // 只统计不在exclude中的单词
        if (exclude.find(word) == exclude.end())
            ++word_count[word];    // 提取word的计数器并将其加1
    }

    for (const auto &w : word_count) // 对map中的每个元素
        // 打印结果
        cout << w.first << " occurs time: " << w.second << endl;

关联容器概述

关联容器(有序的和无序的)都支持9.2节(第294页)中介绍的普通容器操作。关联容器不支持顺序容器的位置相关的操作,例如push_front。

除了与顺序容器相同的操作之外,关联容器还支持一些顺序容器不支持的操作(见p388)和类型别名(见p381)。

关联容器的迭代器都是双向的。

定义关联容器

map word_count; // 空容器
set exclude = {"the", "but", "and"}; // 列表初始化

// 三个元素;authors将姓映射为名
map authors = {
    {"Joyce", "James"},
    {"Austen", "Jane"},
    {"Dickens", "Charles"}
};

初始化multimap或multiset

一个map或set中的关键字必须是唯一的,即,对于一个给定的关键字,只能有一个元素的关键字等于它。

multimap和multiset没有此限制,它们都允许多个元素具有相同的关键字(这些元素会相邻存储)。

关键字类型的要求

对于有序容器,关键字类型必须定义元素比较的方法,默认情况下,标准库使用关键字类型的<运算符来比较两个关键字。

使用关键字类型的比较函数

用来组织一个容器中元素的操作的类型也是该容器类型的一部分。为了指定使用自定义的操作,必须在定义关联容器类型时提供此操作的类型(比如一个函数指针类型)。

bool compareIsbn(const Sales_data &lhs, const Sales_data &rhs)
{
    return lhs.isbn() < rhs.isbn();
}

multiset bookstore(compareIsbn);

pair类型

pair类型定义在头文件utility中。

一个pair保存两个数据成员,pair是一个用来生成特定类型的模板。

pair anon; // 保存两个string
pair> line; // 保存string和vector

pair的默认构造函数对数据成员进行值初始化。也可以为每个成员提供初始化器:

pair author{"James", "Joyce"};

pair的数据成员是public的,两个成员分别是first,second。

创建pair对象的函数

pair
process(vector &v)
{
    // 处理v
    if (!v.empty())
        return {v.back(), v.back().size()}; // 列表初始化
    else
        return pair(); // 隐式构造返回值
}

关联容器操作

除了表9.2(第295页)中列出的类型,关联容器还定义了这些类型:

  • key_type, 此容器类型的关键字类型

  • mapped_type, 每个关键字关联的类型,只适用于map

  • value_type, 对于set,与key_type相同,对于map, 为pair<const key_type, mapped_type>

关联容器迭代器

当解引用一个关联容器迭代器时,我们会得到一个类型为容器的value_type的值的引用。对map而言,value_type是一个pair类型。

必须记住,一个map的value_type是一个pair,我们可以改变pair的值,但不能改变关键字成员的值。

set的迭代器是const的

与不能改名map元素的关键字一样,一个set中的关键字也是const的。可以用一个set迭代器来读取元素的值,但不能修改。

遍历关联容器

map和set类型都支持begin和end操作,我们可以利用这些函数获取迭代器,然后用迭代器来遍历容器。

auto map_it = word_count.cbegin();
while (map_it != word_count.cend()) {
    // ...
    ++map_it; // 递增迭代器,移动到下一个元素
}

!!!note
当使用一个迭代器遍历一个map、multimap、set或multiset时,迭代器按关键字升序遍历元素。

关联容器和算法

我们通常不对关联容器使用泛型算法。更多讨论见书本p383。

添加元素

关联容器的insert成员向容器中添加一个元素或一个元素范围。由于map和set包含不重复的关键字,因此插入一个已存在的元素对容器没有任何影响。

向map添加元素

对一个map进行insert操作时,必须记住元素类型是pair。

word_count.insert({word, 1});
word_count.insert(make_pair(word, 1));
word_count.insert(pair(word, 1));
word_count.insert(map::value_type(word, 1));
word_count. insert(map:: value_type(word,1));

检测insert的返回值

insert(或emplace)返回的值依赖于容器类型和参数。对于不包含重复关键字的容器,添加单一元素的insert和emplace版本返回一个pair,告诉我们插入操作是否成功。pair的first成员是一个迭代器,指向具有给定关键字的元素;second成员是一个boo1值,指出元素是插入成功还是已经存在于容器中。如果关键字已在容器中,则insert什么事情也不做,且返回值中的boo1部分为false。如果关键字不存在,元素被插入容器中,且boo1值为true。

展开递增语句

++ret.first->second;
`++((ret.first)->second)`//等价的表达式

-. ret保存insert返回的值,是一个pair。
-. ret.first是pair的第一个成员,是一个map迭代器,指向具有给定关键字的元素。
-. ret.first->解引用此迭代器,提取map中的元素,元素也是一个pair。
-. ret.first->second map中元素的值部分。
-. ++ret.first->second 递增此值。

向multiset或multimap添加元素

由于一个multi容器中的关键字不必唯一,在这些类型上调用insert总会插入一个元素:

multimap authors;
// 插入第一个元素
authors.insert({"Barth, John", "Sot-Weed Factor"});
// 正确,添加第二个元素
authors.insert({"Barth, John"}, "Lost in the Funhouse");

对允许重复关键字的容器,接受单个元素的insert操作返回一个指向新元素的迭代器。

删除元素

关联容器定义了三个版本的erase:

  • 与顺序容器一样,传递给erase一个迭代器或一个迭代器范围来删除一个元素或一个元素范围。

  • 接受一个key_type参数,删除所有匹配给定关键字的元素(如果存在的话),返回实际删除的元素的数量。

对于保存不重复关键字的容器,erase的返回值总是0或1。

对允许重复关键词的容器,删除的元素的数量可能大于1。

map的下标操作

map和unordered_map容器提供了下标运算符和一个对应的at函数。

set类型不支持下标操作,不能对一个multimap或一个unordered_multimap进行下标操作,因为这些容器中可能有多个值与一个关键字相关联。

map下标运算符接受一个索引获取与此关键字相关联的值,如果关键字不在map中,会为它创建一个元素并插入到map中,关联值将进行值初始化。

使用下标操作的返回值

当对一个map进行下标操作时,会获得一个mapped_type对象。

当解引用一个map迭代器时,会得到一个value_type对象。

!!!note
与vector与string不同,map的下标运算符返回的类型与解引用map迭代器得到的类型不同。

访问元素

如果我们关心的只不过是一个特定元素是否已在容器中,使用find比较好。

对于不允许重复关键字的容器,可能使用find还是count没什么区别。

c.find(k)返回一个迭代器,c.count(k)返回关键词等于k的元素的数量。

对于允许重复关键字的容器,count会统计有多少个元素有相同的关键字。

c.count(k);
c.lower_bound(k);//返回一个迭代器,指向第一个关键词不小于k的元素
c.upper_bound(k);//返回一个迭代器,指向第一个关键词大于k的元素
c.equal_bound(k);//返回一个迭代器pair

无序容器

无序容器不是使用比较运算符来组织元素,而是使用一个哈希函数和关键字类型的==运算符。

在关键字类型的元素没有明显的序关系的情况下,无序容器是非常有用的。

动态内存

我们的程序到目前为止只使用过静态内存或栈内存。

  • 静态内存用来保存局部static对象、类static数据成员以及定义在任何函数之外的变量。

  • 栈内存用来保存定义在函数内的非static对象。

分配在静态或栈内存中的对象由编译器自动创建和销毁。

  • 对于栈对象,仅在其定义的程序块运行时才存在。

  • static对象在使用之前分配,在程序结束时销毁。

除了静态内存和栈内存,每个程序还拥有一个内存池,这部分内存被称作自由空间或堆(heap)。程序用堆来存储动态分配(dynamically allocate)的对象。

动态对象的生存周期由程序来控制,当动态对象不再使用时,我们的代码必须显示地销毁它们。

动态内存与智能指针

C++中,动态内存的管理是通过一对运算符来完成的:

  • new,在动态内存中为对象分配空间并返回一个指向该对象的指针。

  • delete,接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。

为了更容易(同时也更安全)地使用动态内存,新的标准提供了两种智能指针(smart pointer)类型来管理动态对象。

智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。两种智能指针的区别在于管理底层指针的方式:

  • shared_ptr允许多个指针指向同一个对象;

  • unique_ptr则“独占”所指向的对象。

  • 标准库还定义了一个名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象。

这些类型定义在memory头文件中。

shared_ptr类

智能指针也是模板,当创建一个智能指针时,必须提供指向的类型:

shared_ptr p1; // shared_ptr, 可以指向string

默认初始化的智能指针中保存着一个空指针。

解引用一个智能指针返回它指向的对象。如果在一个条件判断中使用智能指针,效果就是检测它是否为空:

if (p1) *p1 = "hi";

make_shared函数

最安全的分配和使用动态内存的方法是调用标准库函数make_shared。此函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr。

// 指向一个值为42的int的shared_ptr
shared_ptr p3 = make_shared(42);

// p6指向一个动态分配的空vector
auto p6 = make_shared>();

类似顺序容器的emplace成员,make_shared用其参数来构造给定类型的对象。如果我们不传递任何参数,对象就会进行值初始化。

shared_ptr的拷贝和赋值

每个shared_ptr都会记录有多少个其他shared_ptr指向相同的对象:

auto p = make_shared(42); // p指向的对象只有p一个引用者
auto q(p); // p和q指向相同的对象,此对象有两个引用者

可以认为每个shared_ptr都有一个关联的计数器,通常称其为引用计数(reference count)。无论何时我们拷贝一个shared_ptr,计数器都会递增。当我们给shared_ptr赋予一个新值或是shared_ptr被销毁时,计数器就会递减。

一旦一个shared_ptr的计数器变为0,它就会自动释放自己所管理的对象。

!!!note
到底是由一个计数器还是其他数据结构来记录有多少指针共享对象,完全由标准库的具体实现决定。关键是智能指针类能记录有多少个shared_ptr指向相同的对象,并能在恰当的时候自动释放对象。

使用了动态生存期的资源的类

程序使用动态内存出于以下三种原因之一:

  1. 程序不知道自己需要多少对象

  2. 程序不知道所需对象的准确类型

  3. 程序需要在多个对象间共享数据

容器类是出于第一种原因而使用动态内存的典型例子,我们将在第15章看到出于第二种原因的例子。本章介绍出于第三种原因的例子。

直接管理内存

C++提供了new运算符分配内存,delete运算符释放new分配的内存。

相对于智能指针,使用这两个运算符管理内存非常容易出错。

使用new动态分配和初始化对象

在自由空间分配的内存是无名的,因此new无法为其分配的对象命名,而是返回一个指向该对象的指针:

int *pi = new int; // pi指向一个动态分配的、未初始化的无名对象

默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值将是未定义的,而类类型将使用默认构造函数进行初始化。

可以使用直接初始化方式来初始化一个动态分配的对象:

int *pi = new int(1024);

vector *pv = new vector{1, 2, 3};

动态分配的const对象

用new分配const对象是合法的:

const int *pci = new const int(1024);

类似其他任何const对象,一个动态分配的const对象必须进行初始化。

内存耗尽

一旦一个程序用光了它所有可用的内存,new表达式就会失败(并返回一个空指针)。默认情况下,如果new不能分配所要求的内存空间,它会抛出一个类型为bad_alloc的异常。

我们可以改变使用new的方式来阻止它抛出异常:

// 如果分配失败,new返回一个空指针
int *p1 = new int; // 如果分配失败,new抛出std::bad_alloc
int *p2 = new (nothrow) int; // 如果分配失败,new返回一个空指针

释放动态内存

为了防止内存耗尽,在动态内存使用完毕后,必须将其归还给系统。我们通过delete表达式(delete expression)来将动态内存归还给系统。

delete p; // p必须指向一个动态分配的对象或是一个空指针

指针值和delete
释放一块并非new分配的内存,或者将相同的指针值释放多次,其行为是未定义的。

int i,*pil=&i,*pi2=nullptr;
double*pd = new double(33),*pd2=pd;
delete i;//错误:i不是一个指针
delete pi1;//未定义;pi1指向一个局部变量
delete pd;//正确
delete pd2;//未定义:pd2指向的内存已经被释放了
delete pi2;//正确:释放一个空指针总是没有错误的

动态对象的生存期直到被释放时为止

如12.1.1节(第402页)所述,由shared_ptr管理的内存在最后一个shared_ptr销毁时会被自动释放。但对于通过内置指针类型来管理的内存,就不是这样了。对于一个由内置指针管理的动态对象,直到被显式释放之前它都是存在的。

返回指向动态内存的指针(而不是智能指针)的函数给其调用者增加了一个额外负担—调用者必须记得释放内存:

//factory返回一个指针,指向一个动态分配的对象
Foo* factory(T arg){
    //视情况处理arg
    return new Foo(arg);//调用者负责释放此内存
}
使用new和delete管理动态内存存在三个常见问题:
1.忘记delete内存。忘记释放动态内存会导致人们常说的“内存泄漏”问题,因为这种内存永远不可能被归还给自由空间了。查找内存泄露错误是非常困难的,因为通常应用程序运行很长时间后,真正耗尽内存时,才能检测到这种错误。
2.使用已经释放掉的对象。通过在释放内存后将指针置为空,有时可以检测出这种错误。
3.同一块内存释放两次。当有两个指针指向相同的动态分配对象时,可能发生这种错误。如果对其中一个指针进行了delete操作,对象的内存就被归还给自由空间了。如果我们随后又delete第二个指针,自由空间就可能被破坏。相对于查找和修正这些错误来说,制造出这些错误要简单得多。

shared_ptr和new结合使用

如果不初始化一个智能指针,它就会被初始化为一个空指针。还可以用new返回的指针来初始化智能指针:

shared_ptr p2(new int(42)); // p2指向一个值为42的int

接受指针参数的智能指针构造函数是explicit的,因此必须使用直接初始化形式来初始化一个智能指针:

shared_ptr p1 = new int(1024); // 错误:必须使用直接初始化形式
shared_ptr p2(new int(1024));  // 正确:使用了直接初始化形式

p1的初始化隐式地要求编译器用一个new返回的int*来创建一个shared_ptr。由于我们不能进行内置指针到智能指针间的隐式转换,因此这条初始化语句是错误的。出于相同的原因,一个返回 shared_ptr的函数不能在其返回语句中隐式转换一个普通指针:

shared ptrclone(int p){
return new int(p);//错误:隐式转换为shared ptr
}

我们必须将shared_ptr显式绑定到一个想要返回的指针上:

shared ptrclone(int p){
//正确:显式地用int*创建 shared ptr
return shared_ptr(new int(p));
}

默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用delete释放它所关联的对象(可以提供自己的操作来替代delete)。

更多关于智能指针使用的讨论见p412。

智能指针和异常

程序需要确保在异常发生后资源能被正确地释放。一个简单的确保资源被释放的方法是使用智能指针:

void f()
{
    shared_ptr sp(new int(42)); // 分配一个对象
    // 这段代码抛出一个异常,且在f中未被捕获
} // 函数结束时shared_ptr自动释放内存

无论是否发生了异常,局部对象都会被销毁,sp是指向这块内存的唯一指针,因此内存会被释放掉。

如果使用了内置指针管理内存,且在new之后在对应的delete之前发生了异常,则内存不会被释放:

void f()
{
    int *ip = new int(42); // 动态分配一个新对象
    // 这段代码抛出一个异常,且在f中未被捕获
    delete ip; // 在退出以前释放内存
}

如果在new和delete之间发生了异常,且异常未在f中被捕获,则内存就永远不会被释放了。

使用我们自己的释放操作

这里给一个简单的定义删除器的例子,而具体的讨论见书本p416。

auto deleter = [](int* p)
{   
    std::cout << "delete data: " << *p << std::endl;
    delete p;
};  

std::shared_ptr p(new int(42), deleter);

智能指针可以提供对动态分配的内存安全而又方便的管理,但这建立在正确使用的前提下。为了正确使用智能指针,我们必须坚持一些基本规范:

-. 不使用相同的内置指针值初始化(或reset)多个智能指针。
-. 不delete get()返回的指针。
-. 不使用get()初始化或reset另一个智能指针。
-. 如果你使用get()返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了。
-. 如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器(参见12.1.4节,第415页和12.1.5节,第419页)。

unique_ptr

与shared_ptr不同,某个时刻只能有一个unique_ptr指向一个给定对象。当unique_ptr被销毁时,它所指向的对象也被销毁。

与shared_ptr不同,没有类似make_shared的标准库函数返回一个unique_ptr。当我们定义一个unique_ptr时,需要将其绑定到一个new返回的指针上。

unique_ptr p1; // 可以指向一个double的unique_ptr
unique_ptr p2(new int(42)); // p2指向一个值为42的int

由于一个unique_ptr拥有它指向的对象,因此unique_ptr不支持普通的拷贝或赋值操作。

更多有关unique_ptr操作的讨论见p418。

weak_ptr

weak_ptr是一种不控制所指对象生存期的智能指针,它指向一个shared_ptr管理的对象。将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放。即使有weak_ptr指向对象,对象还是会被释放。

当我们创建一个weak_ptr时,要用一个shared_ptr来初始化它:

auto p = make_shared(42);
weak_ptr wp(p); // wp若共享p;p的引用计数未改变

由于对象可能不存在,我们不能使用weak_ptr直接访问对象,而必须调用lock。如果存在,lock返回一个指向共享对象的shared_ptr。否则返回一个空shared_ptr。

if (shared_ptr np = wp.lock()) { // 如果np不为空则条件成立
    // 在if中,np与p共享对象
}

动态数组

C++语言和标准库提供了两种一次分配一个对象数组的方法:

  • 一种new表达式语法,可以分配并初始化一个对象数组。

  • 标准库中包含一个名为allocator的类,允许我们将分配和初始化分离。使用allocator通常会提供更好的性能和更灵活的内存管理能力。

!!!note
大多数应用应该使用标准库容器而不是动态分配的数组。使用容器更为简单、更不容易出现内存管理错误并且可能有更好的性能。

new和数组

为了让new分配一个对象数组,我们要在类型名之后跟一对方括号,在其中指明要分配的对象的数目:

// 调用get_size确定分配多少个int
int *pia = new int[get_size()]; // pia指向第一个int

方括号中的大小必须是整型,但不必是常量。

分配一个数组会得到一个元素类型的指针

当用new分配一个数组时,我们并未得到一个数组类型的对象,而是得到一个数组元素类型的指针。

!!!note
要记住我们所说的动态数组并不是数组类型,这是很重要的。

初始化动态分配对象的数组

默认情况下,new分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。可以对数组中的元素进行值初始化,方法是在大小之后跟一对空括号:

int *pia = new int[10]; // 10个未初始化的int
int *pia2 = new int[10](); // 10个值初始化为0的int

新标准中,我们还可以提供一个元素初始化器的花括号列表:

// 10个int分别用列表中对应的初始化器初始化
int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};

释放动态数组

为了释放动态数组,我们使用一种特殊形式的delete——在指针前加上一个空方括号对:

delete p; // p必须指向一个动态分配的对象或为空
delete [] pa; // pa必须指向一个动态分配的数组或为空

数组的元素按逆序销毁,即,最后一个元素首先被销毁,然后是倒数第二个,依此类推。

智能指针和动态数组

标准库提供了一个可以管理new分配的数组的unique_ptr版本:

// up指向一个包含10个未初始化int的数组
unique_ptr up(new int[10]);
up.release(); // 自动用delete[]销毁其指针

my note: 这里似乎有错误,release方法据p418介绍,是放弃对指针的控制权,返回指针。并不销毁原来指向的对象。另一个事例见:http://zh.cppreference.com/w/cpp/memory/unique_ptr/release

当unique_ptr销毁时,会自动销毁其指向的对象。

allocator类

new和delete有一些灵活性上的局限:

  • new将内存分配和对象构造组合在了一起。

  • delete将对象析构和内存释放组合在了一起。

当分配一大块内存时,我们通常计划在这块内存上按需构造对象。在此情况下,我们希望将内存分配和对象构造分离。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作。

allocator类

标准库allocator类定义在头文件memory中,它帮助我们将内存分配和对象构造分离开来。它分配的内存是原始的、未构造的。

allocator也是模板,为了定义一个allocator对象,我们必须指明这个allocator可以分配的对象类型。当一个allocator对象分配内存时,它会根据给定对象类型来确定恰当的内存大小和对齐位置:

allocator alloc; // 可以分配string的allocator对象
auto const p = alloc.allocate(n); // 分配n个未初始化的string

allocator分配未构造的内存

allocator分配的内存是未构造的(unconstructed)。我们按需要在此内存中构造对象。

auto q = p; // q指向最后构造元素之后的位置
alloc.construct(q++); // *q为空字符串
alloc.construct(q++, "hi"); // *q为hi!

还未构造对象的情况下就使用原始内存是错误的。

当我们用完对象后,必须对每个构造的元素调用destroy来销毁它们。

while (q != p)
    alloc.destroy(--q); // 释放我们真正构造的string

一旦元素被销毁后,就可以重新用这部分内存来保存其他string,也可以将其归还给系统。释放内存通过调用deallocate来完成:

alloc.deallocate(p, n);

我们传递给deallocate的指针不能为空,它必须指向由allocate分配的内存。而且,传递给deallocate的大小参数必须与调用allocated分配内存时提供的大小参数具有一样的值。

拷贝和填充未初始化内存的算法

标准库为allocator类定义了两个伴随算法,可以在未初始化内存中创建对象。见p429。

拷贝控制

一个类通过定义五种特殊的成员函数来控制拷贝、移动、复制、销毁这些操作,包括:拷贝构造函数、拷贝赋值函数、移动赋值函数、移动构造函数和析构函数。

拷贝、赋值、销毁

拷贝构造函数

拷贝构造函数的第一个参数是自身的应用,且额外参数都有默认值。

拷贝构造函数的第一个参数必须是一个引用类型。

class Foo{
public:
    Foo();//默认构造函数
    Foo(const Foo&);//拷贝构造函数
};

拷贝构造函数通常不应该是explicit的。

合成拷贝构造函数

对某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。

class Sales_data{
public:
    Sales_data(const Sales_data&)//合成拷贝构造函数
private:
    string BookNo;
    int units_sold = 0;
    double revenue = 0.0;
};
//与Sales_data的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig):
                                                BookNo(orig.BookNo),
                                                units_sold(orig.units_sold),
                                                revenue(orig.revenue)
{}

拷贝初始化

拷贝初始化通常使用拷贝构造函数完成(有时候为移动构造函数)。

拷贝初始化不仅在我们用=定义变量时发生,在下列情况下也会发生:

*. 将一个对象作为实参传递给一个非引用类型的形参

*. 从一个返回类型为非引用类型的函数返回一个对象

*. 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

参数和返回值

当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。

拷贝初始化的限制

vector v1(10)//正确:直接初始化
vector v2=10;//错误,接受大小参数的构造函数是显式的
void f(vector);//f的参数进行拷贝初始化
f(10);//错误;不能用一个显式的构造函数拷贝一个实参
f(vector(10));//正确,从一个int直接构造一个临时的vector

编译器可以绕过构造函数

在拷贝初始化的过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。例如:

string null_book="9-999-999999-9";//拷贝初始化
string null_book("9-999-999999-9");//编译器绕过了拷贝构造函数

拷贝赋值运算符

重载赋值运算符

重载赋值运算符本质是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。

class Foo{
public:
    Foo& operator=(const Foo&);//赋值运算符
//...
}

为了与内置运算符保持一致性,赋值运算符通常返回一个指向其左侧运算对象的引用。

合成拷贝赋值运算符

如果一个类未定义自己的拷贝赋值运算符,编译器会为它合成一个。合成的版本会将右侧运算对象的每个非static成员赋予左侧运算符对象的对应成员。对于数组类型的成员,逐个赋值数组元素。

Sales_data&
Sales_data::operator=(const Sales_data &rhs){
    BookNo = rhs.BookNo;
    units_sold = rhs.units_sold;
    revenue = rhs.revenue;
    return *this;
}

析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他的工作,析构函数释放对象使用的资源,并销毁对象的非static数据成员。

析构函数没有返回值,也不接受参数。

析构函数完成什么工作

在一个人构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化的逆序执行。

通常,析构函数释放对象在生存期分配的所有资源。

隐式销毁一个内置指针类型的成员不会delete它所指向的对象。

什么时候会调用析构函数

无论何时一个对象被销毁,就会自动调用其析构函数:

  1. 变量离开其作用域时被销毁

  2. 当一个对象被销毁时,其成员被销毁

  3. 容器被销毁时,其元素被销毁

  4. 对于动态分配的对象,当指向它的指针应用delete运算符时被销毁

  5. 对于临时对象,当创建它的完整表达式时被销毁

    当一个指向对象的引用或者指针离开作用域时,析构函数不会执行。

合成析构函数

当一个类未定义自己的析构函数时,编译器便会自动为它自动定义一个合成析构函数。

三/五法则

如果一个函数需要自定义析构函数,几乎可以肯定他也需要自定义拷贝赋值运算符合拷贝构造函数。

需要拷贝操作的类也需要赋值操作,反之亦然

使用default

我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。

阻止拷贝

大多数类应该定义拷贝构造函数、构造函数和拷贝赋值运算符,无论是显式地还是隐式的。

定义删除的函数

我们可以通过将拷贝构造函数和拷贝赋值函数定义为删除的函数来阻止拷贝。删除的函数是这样的函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的:

struct NoCopy{
NoCopy()=default;
NoCopy(const NoCopy&)=delete;//阻止拷贝;
}

还可以对任何函数指定=delete。

析构函数不能是删除的函数

如果析构函数被删除,就无法销毁此类对象。对于一个删除了析构函数的类型(或者其某个成员删除了析构函数),编译器将不允许定义该类型的变量或创建该类型的临时对象。

合成的拷贝控制成员可能是删除的

对于某些类来说,编译器会把一些合成的成员定义为删除的函数。其规则是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

private拷贝控制

在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝。为了阻止友元和成员函数访问私有成员,就不定义这些成员。

拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。

为了定义这些成员,必须先确定类对象的拷贝语义。一般有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。

类的行为像一个值,意味着它应该有自己的状态。当拷贝一个对象时,副本和原对象是完全独立的。改变副本不会影响原对象,反之亦然。

类的行为像一个指针,意味着拷贝一个对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。

行为像值的类

对于类管理的资源,每个对象都应该拥有一份自己的拷贝。

为了实现类值的行为,HasPtr需要:

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针

  • 定义一个析构函数来释放string

  • 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string

类值拷贝赋值运算符

一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧对象的现有成员就是安全的了。接着再将数据从临时对象拷贝到左侧运算对象的成员中。

这样就可以正确进行自赋值操作。

定义行为像指针的类

这种情况下,HasPtr仍然需要通过析构函数来释放string。但只有当最后一个指向string的HasPtr对象销毁时,它才可以释放string。

令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。shared_ptr类自己会记录有多少用户共享它所指向的对象,当没有用户使用对象时,shared_ptr类负责释放资源。

但是有时候我们希望直接管理资源,这种情况下,可以使用引用计数(reference count)

引用计数

引用计数的工作方式如下:

  • 除了初始化对象外,每个构造函数(除了拷贝构造函数)还要创建一个引用计数,用来记录有多少个对象与正在创建的对象共享状态。计数器初始化为1。

  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器。

  • 析构函数递减计数器,如果变为0,则析构函数释放状态。

  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,则销毁状态。

引用计数应该保存在动态内存中,这样才能保证共享引用计数。

交换操作

管理资源的类通常还定义一个名为swap的函数。一些算法会在需要交换两个元素时调用swap。

如果一个类定义了自己的swap,那么算法将使用类自定义的版本。否则算法将使用标准库定义的swap。标准库定义的版本可能像这样:

HasPtr temp = v1;
v1 = v2;
v2 = temp;

但对于HasPtr这样管理外部资源的类,可以直接交换指针,而不是分配多一个副本。

string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

swap函数的存在是为了优化代码。详细定义方法见书本。

在赋值运算符中使用swap

定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换(copy and swap)的技术。

HasPtr &HasPtr::operator=(HasPtr rhs)
{
    swap(*this, rhs);
    return *this;
}

rhs是右侧运算对象的一个副本,它会在赋值运算符结束时被自动销毁。

这种技术自动处理了自赋值的情况且天然就是异常安全的。

拷贝控制示例

见习题练习13.33-13.38。

动态内存管理类

再看看!

对象移动

新标准的一个最主要的特性是可以移动而非拷贝对象的能力。在很多情况下,对象拷贝后就立即销毁了,这种情况下,使用移动而非拷贝会大幅提升性能。

使用移动而不是拷贝的另一个原因源于IO类或unique_ptr这样的类。这些类都包含不能被共享的资源(指针或IO缓冲)。因此,这些类的对象不能拷贝但可以移动。

右值引用

为了支持移动操作,新标准引入了一种新的类型——右值引用(rvalue reference)。右值引用必须绑定到右值——一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源移动到另一个对象中。

int i = 42;
int &r = i;    // 正确:r引用i
int &&rr = i;  // 错误:不能将一个右值引用绑定到一个左值上
const int &r3 = i*42;//正确:我们可以将一个const的引用绑定到一个右值上
int &&r2 = i * 42; // 正确:将rr2绑定到乘法结果上

左值持久,右值短暂

左值与右值的区别:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

由于右值引用只能绑定到临时对象,我们得知:

*. 所引用的对象将要被销毁

*. 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由的接管所引用的对象的资源。

变量是左值

变量可以看作只有一个运算对象而没有运算符的表达式。类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。

因此,我们不能将一个右值引用绑定到一个右值引用类型的变量上:

int &&rr1 = 42;     // 正确:字面常量是右值
int &&rr2 = rr1;    // 错误:表达式rr1是左值!
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行,

标准库move函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。方法是通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用。

int &&rr3 = std::move(i); // OK

move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。调用move就意味着承诺:除了对i赋值或销毁它外,我们将不再使用它。

我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
使用move的代码应该是std::move而不是move。

移动构造函数和移动赋值运算符

移动的版本从给定对象“窃取”资源而不是拷贝资源。

移动构造函数的第一个参数是该类型的一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。

除了完成资源的移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。

StrVec::StrVec(StrVec &&s) noexcpet // 移动构造函数不应抛出异常
 // 成员初始化器接管s中的资源
 : elements(s.elements), first_free(s.first_free), cap(s.cap)
{
    // 令s进入这样的状态——对其运行析构函数是安全的
    s.elements = s.first_free = s.cap = nullptr;
}

移动操作、标准库容器和异常

移动操作通常不分配资源,因此通常不会抛出异常,我们应当将此事通知标准库。除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。

通知的方法是在构造函数中指明noexcept。

class StrVec{
public:
    StrVec(StrVec&& ) noexcept;//移动构造函数 
};
StrVec::StrVec(StrVec &&s) noexcept:/*成员初始化器*/
{/*构造函数体*/}

移动赋值运算符

移动赋值运算符执行与析构函数和移动构造函数相同的工作。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值:

StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
    // 直接检测自赋值
    if (this != &rhs) {
        free();    // 释放已有资源
        elements = rhs.elements; // 接管资源
        first_free = rhs.first_free;
        cap = rhs.cap;
        // 将rhs置于可析构状态
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}

移后源对象必须可析构

从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。

除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍然是有效的,即可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。但是移动操作对移后源对象中留下的值没有任何要求。因此,我们的程序不应该依赖于移后源对象中的数据。

在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。

合成的移动操作

如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。

如果类定义了一个移动构造函数或一个移动赋值运算符,则该类的拷贝版本会被定义为删除的。

移动右值,拷贝左值

如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。

StrVec v1, v2;
v1 = v2;                    // v2是左值,使用拷贝赋值
StrVec getVec(istream&);    // getVec返回一个右值
v2 = getVec(cin);           // getVec(cin)是一个右值;使用移动赋值

更新三/五法则

所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。

右值引用和成员函数

除了构造函数和赋值运算符之外,成员函数也可以提供拷贝和移动的版本:一个版本有一个右值引用参数,而另一个版本有一个const左值引用。

void push_back(const X&);   // 拷贝:绑定到任意类型的X
void push_back(X&&);        // 移动:只能绑定到类型X的可修改的右值

右值和左值引用成员函数

我们可以强制左侧运算对象是一个左值。

我们指出this的左值/右值属性的方式与定义const成员函数相同,即,在参数列表后放置一个引用限定符(reference qualifier)

class Foo {
public:
    Foo &operator=(const Foo&) &;   // 只能向可修改的左值赋值
};

引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。如果存在const限定符,引用限定符必须跟随在const限定符之后。

重载和引用函数

我们可以综合引用限定符和const来区分一个成员函数的重载版本。

class Foo{
public:
    Foo sorted() &&;//可用于可改变的右值
    Foo sorted() const &;//可用于任何类型的Foo
private:
    vector data;
};
//本对象为右值,因此可以原址排序
Foo Foo::sorted() &&
{
    sort(data.begin(),data.end());
    return *this;
}
//本对象是一个左值或者一个const,哪种情况我们都不能对其进行原址排序
Foo Foo::sorted() const &{
    Foo ret(*this);
    sort(ret.data.begin(),ret.data.end());
    return ret;
}
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。

重载运算和类型转换

基本概念

重载运算符是具有特殊名字的函数:他们的名字由关键字operator和其后要定义的运算符号组成。重载运算符也包含返回类型,参数列表以及函数体。

当一个运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。

对于一个运算符函数来是哦,它或者是类的成员,或者至少含有一个类型的参数:

//错误,不能为int重定义内置的运算符
int operator+(int,int);

不能被重载的运算符: .* :: . ? :

直接调用一个重载的运算符函数

调用方法如下:

//一个非成员运算符的等价调用
data1+data2;//普通的表达式
operator+(data1,data2);//等价的函数调用
data1 += data2;//基于调用的表达式
data1.operator+=(data2);//对成员运算符函数的等价调用

将this绑定到data1的位置,将data2作为实参传入函数。

某些运算符不应该被重载

通常情况下不应该重载逗号,取地址,逻辑与和逻辑或运算符。

使用与内置类型一致的含义

选择作为成员或者非成员函数

有的运算符必须作为成员,另一些情况下,运算符作为非成员更好:

  1. 赋值(=)、下标([])、调用(())和成员访问箭头(->)运算符必须作为成员;

  2. 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值略有不同;

  3. 递增、递减和解引用运算符,一般应该是成员;

  4. 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该时普通的非成员函数。

string s = "world";
string t = s + "!";//正确,我们能把一个const char*加到一个string中等价于s.operator+("!")
string u = "Hi" + s;//错误,等价于:"Hi".operator+(s),hi的类型是const char* ,这是一种内置类型,没有成员函数

输入和输出运算符

重载输出运算符<<

第一个形参通常是一个非常量的ostream对象的引用,之所以ostream是非常量是因为向流写入内容会改变其状态,而引用该形参是因为我们没法直接复制一个ostream对象。

第二个形参通常是一个常量的引用,该常量是我们要输出的类类型。

为了与其他输出运算符保持一致,operator<<通常返回它的ostream形参。例如:

class Salas_data{
friend std::ostream& operator<<(ostream&, const Salas_data&);
friend std::ostream& operator>>(istream&, const Salas_data&);
};
通常,输出运算符不应该打印换行符,主要负责输出打印的对象而非格式。

输出输出运算符必须是非成员函数

重载输入运算符>>

通常情况下,输入运算符的第一个实参为将要读取的流的引用,第二个形参为将要读入到的(非常量)对象的引用。该运算符会返回某个流的引用。

输入运算符必须处理输入失败的问题,而输出运算符不需要。

算术和关系运算符

通常我们把算术和关系运算符作为非成员函数以允许对左侧和右侧的运算对象进行转换。形参都是常量的引用。

Salas_data operator+(const Salas_data &lhs,const Salas_data &rhs){
    Salas_data sum = lhs;
    sum += rhs;
    return sum;
}
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值运算符来实现。

相等运算符

bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
    return lhs.isbn() == rhs.isbn() &&
           lhs.units_sold == rhs.units_sold &&
           lhs.revenue == rhs.revenue;
}

bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
    return !(lhs == rhs);
}

关系运算符

如果一个类不存在一种逻辑可靠的<定义,这个类不定义<运算符也许更好。

赋值运算符

之前介绍了拷贝赋值和移动赋值运算符,它们可以把类的一个对象赋值给类的另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。

比如:

vector v;
v = {"a", "b", "c"};
class StrVec {
public:
    StrVec& operator=(std::initializer_list)
    {
        // ...
    }
};
赋值运算符必须为成员函数。

复合赋值运算符

赋值运算符必须为成员函数,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。
// 作为成员的二元运算符:左侧运算对象绑定到隐式的this指针
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this;
}

下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]。

下标运算符必须是成员函数。

为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。最好同时定义下标运算符的常量版本和非常量版本,当用作于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。

class StrVec {
public:
    std::string& operator[](std::size_t n) { return elements[n]; }

    const std::string& operator[](std::size_t n) const { return elements[n]; }

private:
    std::string *elements;    // 指向数组首元素的指针
};

递增递减运算符

在迭代器类中通常会实现递增运算符++和递减运算符–,这两种运算符使得类可以在元素的序列中前后移动。

对于内置类型来说,递增和递减运算符既有前置版本也有后置版本。同样,我们也应该为类定义两个版本的递增和递减运算符。

class StrBlobPtr {
public:
    StrBlobPtr& operator++();        // 前置运算符
    StrBlobPtr& operator--();
};
//前置版本:返回递增递减对象的引用
StrBlobPtr& StrBlobPtr::operator++(){
//如果curr已经指向了容器的尾后位置,则无法递增它
    check(curr, "increment past end of StrBlobPtr ")
    ++curr;    //将curr在当前状态下向前移动一个元素
    return *this;
}

StrBlobPtr& StrBlobPtr::operator--(){
    //如果curr是0,则继续递减它将产生一个无效下标
    --curr;        //将curr在当前状态下向后移动一个元素
    check(curr, "decrement past begin of StrBlobPtr");
    return *this;
}

区分前置和后置运算符

后置版本接受一个额外的(不被使用的)int类型的形参,这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。

class StrBlobPtr {
public:
    StrBlobPtr operator++(int);        // 后置运算符
    StrBlobPtr operator--(int);
};

!!!note
为了与内置版本保持一致,后置运算符应该返回对象的原值,返回的形式是一个值而非引用。

//后置版本:递增递减对象的值但是返回原值
StrBlobPtr StrBlobPtr::operator++(int){
    //此处无须检查有效性,调用前置递增运算符时才需要检查
    StrBlobPtr ret = *this;//记录当前的值
    ++*this;//向前移动一个元素,前置++需要检查递增的有效性
    return ret;//返回之前记录的状态
}

StrBlobPtr StrBlobPtr::operator--(int){
    //此处无须检查有效性,调用前置递减运算符时才需要检查
    StrBlobPtr ret = *this    //记录当前的值
    --*this //向后移动一个元素,前置--需要检查递减的有效性
    return ret;
}

显式的调用后置运算符

StrBlobPtr p(a1);//p指向a1中的vector
p.operator++(0);//调用后置版本的operator++
p.operator++();//调用前置版本的operator++

成员访问运算符

在迭代器类及智能指针类中常常用到解引用运算符和箭头运算符。

class StrBlobPtr {
public:
    std::string& operator*() const;
    {
        auto p = check(curr,"dereference past end");
        return (*p)[curr];
    }
    std::string* operator->() const
    {
        // 将实际工作委托给解引用运算符
        return & this->operator*();
    }
};

对箭头运算符返回值的限定

对于形如point->mem的表达式来说,point必须是指向类对象的指针或者是一个重载了operator->类的对象。根据point类型的不同,point->分别等价于:

(*point).mem;        //point是一个内置的指针类型
point.operator()->mem;        //point是一个类对象

除此之外,代码都将发生错误。

函数调用运算符

如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更灵活。

struct absInt {
    int operator()(int val) const {
        return val < 0 ? -val : val;
    }
};

这个类只定义了一种操作:函数调用运算符,它负责接受一个int类型的实参,然后返回该实参的绝对值。

使用调用运算符的方式是令一个absInt对象作用于一个实参列表,这一过程看起来非常像调用函数的过程:

int i = -42;
absInt absObj;
int ui = absObj(i);        // 将i传递给absObj.operator()

函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。

如果类定义了调用运算符,则该类的对象称作函数对象(function object)。因为可以调用这种对象,所以我们说这些对象的行为像函数一样。

lambda是函数对象

当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda表达式产生的类中含有一个重载的函数调用运算符,默认情况下,它是一个const成员函数。

当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引用的对象确实存在。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。

通过值捕获的变量被拷贝到lambda中。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。

标准库定义的函数对象

标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如,plus类定义了一个函数调用运算符用于对一对运算对象执行+操作。

这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。例如,plus<string>令string的加法运算符作用于string对象。

plus intAdd;            // 可执行int加法的函数对象
int sum = intAdd(10, 20);    // 使用intAdd::operator(int, int)求10和20的和

在算法中使用标准库函数对象

表示运算符的函数对象类常用来替换算法中的默认运算符。比如,默认情况下排序算法使用operator<将序列按照升序排列。如果要执行降序排列的话,我们可以传入一个greater类型的对象。

// 传入一个临时的函数对象用于执行两个string对象的>比较运算
sort(svec.begin(), svec.end(), greater());

可调用对象与function

C++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。

和其他对象一样,可调用对象也有类型。lambda有它自己唯一的未命名的类类型;函数及函数指针的类型由其返回值和实参类型决定。

然而,两个不同类型的可调用对象却可能共享同一种调用形式(call signature)。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:

int(int, int)

标准库function函数

function是一个模板,当创建一个具体的function类型时我们必须提供额外的信息,此额外信息是指该function类型能够表示的对象的调用形式:

function

这里声明的function类型,表示接受两个int、返回一个int的可调用对象:

function f1 = add;            // 函数指针
function f2 = divide();        // 函数对象类的对象
function f3 = [](int i, int j) { return i * j; };    // lambda

cout << f1(4, 2) << endl;
cout << f2(4, 2) << endl;
cout << f3(4, 2) << endl;

重载、类型转换与运算符

在263页中我们看到由一个实参调用的非显示构造函数定义了一种隐式的类型转换,这种构造函数将实参类型的对象转换成类类型。我们同样能定义对于类类型的类型转换,通过定义类型转换运算符可以做到这一点。

转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions)

类型转换运算符

类型转换运算符(conversion operator)是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。其一般形式如下:

operator type() const;

其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型,因此我们不允许转换成数组或者函数类型,但允许转换成指针或者引用类型。

类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,一般被定义成const成员。

定义含有类型转换运算符的类

举个例子,我们定义一个比较简单的类,令其表示0到255之间的一个整数:

class SmallInt {
public:
    SmallInt(int i = 0) : val(i)
    {
        if (i < 0 || i > 255)
            throw std::out_of_range("Bad SmallInt value");
    }

    operator int() const { return val; }
private:
    std::size_t val;
};

SmallInt类的构造函数将算数类型的值转换成SmallInt对象,而类型转换运算符将SmallInt对象转换成int:

SmallInt si;
si = 4;            // 4 -> SmallInt, 然后调用赋值运算符
si + 3;            // si -> int,然后执行整数的加法

类型转换运算符可能产生意外结果

在实践中,类很少提供类型转换运算符。但有一种例外:对于类来说,定义向bool的类型转换还是比较普遍的现象,但这会遇到一个问题:因为bool是一种算术类型,所以类类型的对象转换成bool后就能被用在任何需要算术类型的上下文中(这不是期望的)。

显式的类型转换运算符

为了防止上述异常情况的发生,C++新标准引入了显式的类型转换运算符(explicit conversion operator):

class SmallInt {
public:
    // 编译器不会自动执行这一类型转换
    explicit operator int() const { return val; }
};

编译器不会将一个显式的类型转换运算符用于隐式类型转换:

SmallInt si = 3;            // 正确:SmallInt的构造函数不是显式的
si + 3;                        // 错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast(si) + 3;    // 正确:显式地请求类型转换

但,如果表达式被用作条件,则编译器会将显示的类型转换自动应用于它。

向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。

避免有二义性的类型转换

通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。

实参匹配和相同的类型转换

我们无法通过强制类型转换来解决二义性问题,因为强制类型转换也面临二义性问题。

函数匹配和重载运算符

面向对象程序设计

面向对象程序设计基于三个基本概念:数据抽象、继承、动态绑定。


OOP概述

面向对象程序设计(object-oriented programming)的核心思想时数据抽象,继承和动态绑定。通过数据抽象我们可以将类的接口和实现分开,使用继承,我们可以定义相似的类型并对其相似关系建模;使用动态绑定,我们可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

继承

通过继承(inheritance);联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类(base class),其他类则间接或直接地从基类继承而来,这些继承而来的类称为派生类(derived class)。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。

class Quote{
public:
    std::string isbn() const;
    virtual double net_price(std::size_t n) const;
};

class Bulk_Quote:public Quote{
public:
    double net_price(std::size_t n) const override;
};

动态绑定

通过使用动态绑定(dynamic binding),我们能用用一段代码分别处理Quote和Bulk_Quote的对象。

//计算并打印销售给定数量的某种书籍所得的费用
double print_total(ostream &os,const Quote &item,size_t n){
    //根据传入item的形参的对象调用Quote::net_price()
    //或者Bulk_Quote::net_price
    double ret = item.net_price(n);
    os<<"ISBN: "<
当我们使用基类的引用或者指针调用一个虚函数时将发生动态绑定。

定义基类和派生类

定义基类

class Quote{
public:
    Quote() = default;
    Quote(std::string &book,double sales_price):bookNo(book),price(sales_price){}
    std::string isbn() const {return bookNo;};
    virtual double net_price(size_t n) const {return n*price;}
    virtual ~Quote()
private:
    std::string bookNo;
protected:
    double pricr = 0.0;
};
通常,基类都应该定义一个虚析构函数,即使该函数在不执行任何实际操作也是如此。

成员函数和继承

成员函数如果没有被定义为虚函数,则其解析过程在发生编译时而不是运行时。

基类通过在其成员函数的声明语句之前加上关键词virtual使得该函数执行动态绑定。

访问控制和继承

派生类可以继承定义在基类的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。派生类可以访问公有成员,而不能访问私有成员。不过,如果基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的(protected)访问运算符说明这样的成员。

定义派生类

派生类必须使用类派生列表(class derivation list)明确指出它是从哪个基类继承而来的。

派生类中的虚函数

C++新标准允许派生类显式的注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在形参列表后面、或者在const成员函数的const关键词后面,或者在引用函数的引用限定符后面添加一个关键词override。

派生类对象及派生类向基类的类型转换

因为在派生类对象中含有基类对应的组成部分,所以我们能够把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象的基类部分上。

Quote item;        //基类对象
Bulk_Quote bulk;        //派生类对象
Quote *p = &item;        //p指向Quote对象
p = &bulk;        //p指向bulk的Quote部分
Quote &r = bulk;        r绑定到bulk的Quote部分

派生类构造函数

Bulk_Quote(const string &book,double p,size_t qty,double disc):
            Quote(book,p),min_qty(qty),discount(dis){}
首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

派生类使用基类的成员

派生类可以使用基类的公有和保护成员。

继承与静态成员

静态成员遵循通用的访问规则。

被用作基类的类

如果我们想将某个类作为基类,则该类必须已经定义而非仅仅声明:

class Quote;        //声明但未定义;
//错误:quote必须被定义
class Bulk_Quote:public Quote{...};

防止继承的发生

在类名后加一个final可以防止继承的发生。

class NoDerived final{};

类型转换与继承

可以将基类的指针或者引用绑定到派生类的对象上有一层极为重要的含义:当使用基类的引用或指针时,实际上我们不知道该引用(指针)所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。

动态类型与静态类型

我们必须把一个变量或者表达式的静态类型与该表达式表示对象的动态类型区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或者表达式生成的类型;动态类型则是变量或者表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

例如:当print_total调用net_price时double ret = item.net_price(n);

我们知道item的静态类型是Quote&,它的动态类型则依赖于item绑定的实参,动态类型直到运行时才可知。

不存在从基类向派生类的隐式类型转换……

因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类到派生类的自动类型转换。

Quote base;
Bulk_Quote* bulkp = &base;        //错误:不能将基类转换成派生类
Bulk_Quote& bulkref = base;        //错误:不能将基类转换成派生类
Bulk_Quote bulk;
Quote *itemp = &bulk;        //正确:动态类型是Bulk_Quote
Bulk_Quote *bulkp = itemp;        //错误:不能将基类转换成派生类

……在对象之间不存在类型转换

当我们用一个派生类为一个基类对象赋值时,只有该派生类对象中的基类部分会被拷贝,移动和赋值,它的派生类部分会被忽略掉。

虚函数

我们必须为每个虚函数提供定义。

对虚函数的调用可能在运行时才被解析

动态绑定只有当我们通过指针或者引用调用虚函数时才会发生。

派生类中的虚函数

一旦某个函数被声明为虚函数,则在所有的派生类中它都是虚函数。

基类中的虚函数在派生了中隐含地也是一个虚函数。
当派生类覆盖某个虚函数时,该函数的形参必须与派生类的形参严格匹配。
返回类型在返回的是类的指针或者引用时可不一样,但是要求从基类到派生类的类型转换是可访问的。

final和override说明符

final和override说明符出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后。

虚函数和默认实参

如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

回避虚函数机制

在某些情况下,我们希望对虚函数的调用不要动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的:

//强行调用基类中定义的函数版本而不管baseP的动态类型如何
double undiscount = baseP->Quote::net_price(42);

抽象基类

纯虚函数

纯虚函数无需定义、我们在函数体的位置书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处。

double net_price(size_t) const =0;

含有纯虚函数的类是抽象基类

抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能创建一个抽象基类的对象。因为Disc_quoto将net_price定义为纯虚函数,所以我们不能定义Disc_quote的对象。我们可以定义Disc_quote的派生类的对象。前提是这些类覆盖了net_price函数。

我们不能创建抽象基类的对象。

派生类构造函数只初始化它的直接基类

访问控制与继承

受保护的成员

protected说明符可以看做是public和private中和后的产物:

  1. 和私有成员类似,受保护的成员对于类的用户来说是不可访问的

  2. 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的

  3. 派生类的成员只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中受保护的成员没有任何访问特权

class Base{
protected:
    int prot_mem;
};

class Sneaky:public Base{
    friend void clobber(Sneaky&);//能访问Sneaky::prot_mem
    friend void clobber(Base&);//不能访问Base::prot_mem
    int j;
};

//正确,clobber能够访问Sneaky对象的private和protected成员
void clobber(Snaaky &s){s.j=s.prot_mem=0;}
//错误;clobber不能访问Base的protected成员
void clobber(base &s){s.prot_mem=0;}

公有,私有和受保护继承

某个类对其继承而来的成员的访问权限受到两个因素的影响:

  1. 在基类中该成员的访问说明符

  2. 在派生类的派生列表中的访问说明符

派生类说明符的目的是控制派生类影虎对于基类成员的访问权限,还可以控制继承自派生类的新类的访问权限。

派生类向基类转换的可访问性

派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:

  • 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。

  • 不论以什么方式继承B,D的成员和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说都是可访问的。

  • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的转换;反之,如果D继承B的方式是私有的,则不能使用。

    对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。

友元与继承

友元关系不能继承,基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。

改变个别成员的可访问性

有时候我们需要改变派生类继承的某个名字的访问级别,通过using声明可以达到这一目的:

class Base {
public:
    std::size_t size() const { return n; }
protected:
    std::size_t n;
};

class Derived : private Base {
public:
    // 令size成员保持public访问级别
    using Base::size;
protected:
    using Base::n;
};

因为Derived使用了私有继承,所以继承而来的成员size和n默认情况下是Derived的私有成员。然而,我们使用using声明语句改变了这些成员的可访问性。

通过在类的内部使用using声明语句,我们可以将该类的直接或间接基类中任何可访问成员标记出来。using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。

派生类只能为那些它可以访问的名字提供using声明。

默认的继承保护级别

默认情况下,使用class关键词定义的派生类是私有继承,而使用struct关键字定义的派生类为公有继承。

继承中的类作用域

每个类定义自己的作用域,在这个作用域内我们定义类的成员。

当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内,所以派生类才能像使用自己的成员一样使用基类的成员。

在编译时进行名字查找

一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。

名字冲突和继承

和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。

派生类将隐藏同名的基类成员。

通过作用域运算符来使用隐藏的成员

作用域运算符将覆盖掉原有的查找规则。

除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。

名字查找先去类型检查

struct Base{
    Base():mem(0){};
    int memfcn();
protected:
    int mem;
};

struct Derived:Base{
    Derived(int i):mem(i){};
    int memfcn(int);
    int get_mem(){return mem;};
protected:
    int mem;
};

Derived d;
Base b;
b.memfcn();        //调用Base::memfcn
d.memfcn(10);        //调用Derived::memfcn
d.memfcn();        //错误:参数列表为空的memfcn被隐藏了
d.Base::memfcn();        //正确:调用Base::memfcn

虚函数和作用域

加入基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数。

通过基类调用隐藏的虚函数

构造函数与拷贝控制

和其他类一样,位于继承体系中的类也需要控制当其对象执行一系列操作时发送什么样的行为,这些操作包括创建、拷贝、移动、赋值和销毁。

虚析构函数

当我们delete一个动态分配的对象的指针时,将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。我们通过在基类中奖析构函数定义成虚析构函数以确保执行正确的析构函数版本。

class Quote {
public:
    // 如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
    virtual ~Quote() = default;    // 动态绑定析构函数
};
如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。

之前介绍的经验准则说,如果一个类需要析构函数,那么它同样需要拷贝和赋值操作。但这里基类的析构函数并不遵顼这个准则,它是一个重要的例外。

虚析构函数还将阻止合成移动操作。

合成拷贝控制与继承

基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁操作。此外,合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。例如:

  • 合成的Bulk_quote默认构造函数运行Disc_quote的默认构造函数,后者又运行Quote的默认构造函数。

  • Quote的默认构造函数将bookNo成员默认初始化为空子行串,同时便用类内例始值将price初始化为0。

  • Quote的构造函数完成后,继续执行Disc_quote的构造函数,它使用类内初始值初始化qty和discount。

  • Disc quote的构造函数完成后,继续执行Bu1k_quote的构造函数,但是它什么具体工作也不做。

对于派生类的析构函数来说,它除了销毁派生类自己的成员外,还负责销毁派生类的直接基类;该直接基类又销毁它自己的直接基类,以此类推直至继承链的顶端。

派生类中删除的拷贝控制与基类的关系

某些定义为基类的方式也可能导致有的派生类成员成为删除的函数:

-. 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问(参见15.5节,第543页),则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。

-. 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。

-. 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=defau1t请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。

例如:

class B{
public:
    B();
    B(const B&)=delete;
};

class D:public B{
    //没有声明任何构造函数
};

D d;        //正确,D的合成默认构造函数使用B的默认构造函数
D d2(d);        //错误:D的合成拷贝构造函数是被删除的
D d3(std::move(d));        //错误:隐式的使用D的被删除的拷贝构造函数

移动操作和继承

因为基类缺少移动操作会阻止派生类拥有自己的移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。

class Quote{
public:
    Quote()=default;//对成员依次进行默认初始化
    Quote(const Quote&)=default;//对成员依次拷贝
    Quote(Quote&&)=default;//对成员依次拷贝
    Quote& operator=(const Quote&)=default;//拷贝赋值
    Quote& operator=(Quote&&)=default;//拷贝赋值
    virtual ~Quote()=default;//析构函数
}

派生类的拷贝控制成员

派生类的构造函数在其初始化阶段中不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要移动和拷贝基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。

和构造函数及赋值运算符不同的是,析构函数只负责销毁自己分配的资源。

当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。

定义派生类的拷贝和移动构造函数

当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分:

class Base{/* */};
class D:public Base{
public:
    //默认情况下,基类的默认构造函数初始化对象的基类部分
    //要想使用拷贝或移动构造函数,我们必须在构造函数初始列表中
    //显式的调用该构造函数
    D(const D&):Base(d)        //拷贝基类成员
            /*D的成员的初始值*/{ /* */}
    D(D&& d):Base(std::move(d))        //移动基类成员
            /*D的成员的初始值*/{ /* */}
};
在默认情况下,其基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝或者移动基类部分,则必须在派生类的构造函数初始值列表中显式的使用基类的拷贝构造函数。

派生类赋值运算符

派生类的赋值运算符也必须显式地为其基类部分赋值。

//Base::operator=(const Base&)不会被自动调用
D &D::operator=(const D &rhs){
    Base::operator=(rhs);//为基类部分赋值
    //按照过去的方式为派生类的成员赋值
    //酌情处理自赋值及释放已有资源等情况
    return *this;
}

派生类析构函数

派生类析构函数只负责销毁由派生类自己分配的资源。

在构造函数和析构函数中调用虚函数

继承的构造函数

类不能继承默认、拷贝、移动构造函数,如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。

在C++11新标准中,派生类能够重用其直接基类定义的构造函数。

一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。

派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。

class Bulk_quote : public Disc_quote {
public:
    using Disc_quote::Disc_quote; // 继承Disc_quote的构造函数
    double net_price(std::size_t) const;
};

通常情况下,using声明语句只是令某个名字在当前作用域内可见。当当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。

在Bulk_quote类中,继承的构造函数等价于:

Bulk_quote(const std::string &book, double price, std::size_t qty, double disc) :
    Disc_quote(book, price, qty, disc) {}

如果派生类含有自己的数据成员,则这些成员将被默认初始化。

继承的构造函数的特点

一个构造函数的using声明不会改变该构造函数的访问级别。不能指定explicit和constexpr。

容器与继承

模板和泛型编程

定义模板

函数模板

一个函数模板就是一个公式,可用来生成针对特定类型的函数版本。compare的模板函数可以像这样:

template
int compare(const T &v1,const T &v2){
    return  v1 > v2 ? 1 : -1;
}

模板定义以一个关键词template开始,后跟一个模板参数列表,这是一个逗号分隔或多个模板参数的列表,用< 和 > 包围起来。

在模板定义中,模板参数列表不能为空。

实例化参数模板

‘’’c++
cout<<compare(1,0)<<endl;//T为int
‘’’

模板类型参数

类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或者类型转换。

非类型模板参数

一个非类型模板参数表示一个值而非一个类型。我们通过一个特定的类型名而非关键字class或者typename来指定非类型参数。

非类型模板参数的模板实参必须是常亮表达式。

inline和constexpr的函数模板

inline或者constexpr说明符放在参数列表之后,返回类型之前:

template
inline T min(const T &a,const T &b)

编写类型无关的代码

编写泛型代码的两个重要的原则:

  1. 模板中的函数参数是const的引用;

  2. 函数体的条件判断仅使用<比较运算。

模板编译

类模板

标准库特殊设施

tuple类型

tuple是类似pair的模板。每个pair的成员类型都不相同,但是每个pair都恰好有两个成员。不同tuple类型的成员类型也不相同,但是每个tuple可以有任意数量的成员。每个确定的tuple类型的成员数目是固定的,但一个tuple类型的成员数目可以与另一个tuple类型不同。

tuple支持的操作
tuple t;
tuple t(v1,v2,v3,v4,...,vn);
make_tuple(v1,v2,...,vn);
t1==t2;
t1 relop t2;
get(t);

定义和初始化tuple

当我们定义一个tuple时,需要指出每个成员的类型:

tuple threeD;//三个成员都设置为0
tuple,int,list) someVal("contents",{3.14,2.718},42,{0,1,2,3,4,5})

访问tuple的成员

要访问一个tuple的成员,就要使用一个名为get的标准函数模板。

auto book = get<0>(item);//返回item成员的第一个成员
auto cnt = get<1>(item);//返回item成员的第二个成员

如果不知道一个tuple准确的类型细节信息,可以使用辅助类模板来查询tuple成员的类型和数量:

typedef decltype(item) trans;//trans是item的类型
//返回item类型对象中成员的数量
size_t sz = tuple_size::value;//返回3
//cnt类型与item中第二个成员相同
tuple_element<1,trans>::type cnt = get<1>(item);//cnt为int型

使用tuple返回多个值

bitset类型

标准库定义了bitset类,使得位运算的使用更为容易,并且能够处理超过最长整型类型大小的位集合。bitset类定义在头文件bitset中。

定义和初始化bitset

bitset类是一个类模板,它类似array类,具有固定的大小。当我们定义一个bitset时,需要声明它包含多少个二进制位。

bitset<32> bitvec(1U);    // 32位;低位为1,其他位为0

二进制位的位置是从0开始编号的,因此,bitvec包含编号从0到31的32个二进制位。编号从0开始的二进制位被称为低位(low-order),编号到31结束的二进制位被称为高位(high-order)。

使用unsigned值初始化bitset

当我们使用一个整型值来初始化bitset时,此值将被转换为unsigned long long类型并被当作位模式来处理。bitset中的二进制位将是此模式的一个副本。如果bitset的大小大于unsigned long long的位数,则剩余高位被置为0。如果小于,则只使用给定值中的低位,超出bitset大小的高位被丢弃。

从一个string初始化bitset

我们可以从一个string或一个字符数组指针来初始化bitset。两种情况下,字符都直接表示位模式。当我们使用字符串表示数时,字符串中下标最小的字符对应高位:

bitset<32> bitvec4("1100");    // 2、3两位为1,剩余两位为0

如果string包含的字符数比bitset少,则bitset的高位被置为0。

string的下标编号习惯于bitset恰好相反:string中下标最大的字符(最右)用来初始化bitset中的低位。

bitset操作

bitset操作定义了多种检测或设置一个或多个二进制位的方法。

bitset<32> bitvec(1U);
bool is_set = bitvec.any();            // true,因为有1位置位
bool is_not_set = bitvec.none();    // false,因为有1位置位了
bool all_set = bitvec.all();        // false,因为只有1位置位了
size_t onBits = bitvec.count();        // 返回1
size_t sz = bitvec.size();            // 返回32
bitvec.flip();                        // 翻转bitvec中的所有位
bitvec.reset();                        // 将所有位复位
bitvec.set();                        // 将所有位置位

bitvec.flip(0);                        // 翻转第一位
bitvec.set(0);                        // 置位第一位
bitvec.reset(i);                    // 复位第i位
bitvec.test(0);                        // 返回false,因为第一位已复位

bitvec[0] = 0;                        // 将第一位复位
bitvec[31] = bitvec[0];                // 将最后一位设置为与第一位一样
~bitvec[0];                            // 翻转第一位

提取bitset的值

to_ulong和to_ullong操作都返回一个值,保存了与bitset对象相同的位模式,只有当bitset的大小小于等于对应的大小时,我们才能使用这两个操作,否则将会抛出overflow_error异常。

unsigned long ulong = bitvec3.to_ulong();
cout << "ulong = " << ulong << endl;

bitset的IO运算符

输入运算符从一个输入流读取字符,保存到一个临时的string对象中。直到读取的字符数达到对应bitset的大小时,或是遇到不是1或0的字符时,或是遇到文件尾或输入错误时,读取过程才停止。随即用临时string对象来初始化bitset。如果读取的字符数小于bitset的大小,高位被置为0。

bitset<16> bits;
cin >> bits;        // 从cin读取最多16个0或1
cout << "bits: " << bits << endl;

正则表达式

正则表达式是一种描述字符序列的方法,是一种及其强大的计算工具。它定义在头文件regex中,它包含多个组件:

组件 说明
regex 表示有一个正则表达式的类
regex_match 将一个字符序列与一个正则表达式匹配
regex_search 寻找第一个与正则表达式匹配的子序列
regex_replace 使用给定格式替换一个正则表达式
sregex_iterator 迭代器适配器,调用regex_search来遍历一个string中所有匹配的子串
smatch 容器类,保存在string中搜索的结果
ssub_match string中匹配的子表达式的结果

regex类表示一个正则表达式。

函数regex_match和regex_search确定一个给定字符序列与一个给定regex是否匹配。如果整个输入序列与表达式匹配,则regex_match返回true;如果输入一个序列中一个子串与表达式匹配,则regex_search返回true。

随机数

程序通常需要一个随机数源。在新标准出现之前,C和C++都依赖于一个简单的C库函数rand来生成随机数。此函数生成均匀分布的伪随机整数,每个随机数的范围在0和一个系统相关的最大值(至少为32767)之间。

rand函数有一些问题:即使不是大多数,也有很多程序员需要不同范围的随机数。一些应用需要随机浮点数。一些程序需要非均匀分布的数。而程序员为了解决这些问题而试图转换rand生成的随机数的范围、类型或分布时,常常会引入非随机性。

定义在头文件random中的随机数库通过一组协作的类来解决这些问题:随机数引擎(random-number engines)和随机数分布类(random-number distribution)。

组件 说明
引擎 类型,生成随机unsigned整数序列
分布 类型,使用引擎返回服从特定概率分布的随机数

!!!note
C++程序不应该使用库函数rand,而应使用default_random_engine类和恰当的分布类对象。

随机数引擎和分布

随机数引擎是函数对象类,它们定义了一个调用运算符,该运算符不接受参数并返回一个随机unsigned整数。我们可以通过调用一个随机数引擎对象来生成原始随机数。

default_random_engine e;
cout << e() << endl;        // 生成一个随机无符号数

标准库定义了多个随机数引擎类,区别在于性能和随机质量不同。

分布类型和引擎

为了得到一个指定范围内的数,我们使用一个分布类型的对象:

// 生成0到9之间(包含)均匀分布的随机数
uniform_int_distribution u(0, 9);
default_random_engine e;
cout << u(e) << endl;

分布类型也是函数对象类。分布类型定义了一个调用运算符,它接受一个随机数引擎作为参数。分布对象使用它的引擎参数生成随机数,并将其映射到指定的分布。

当我们说随机数发生器时,是指分布对象和引擎对象的组合。

引擎生成一个数值序列

随机数发生器有一个特性经常会使新手迷惑:即使生成的数看起来是随机的,但对于一个给定的发生器,每次运行程序它都会返回相同的数值序列。一个函数如果定义了局部的随机数发生器,应该将其(包括引擎和分布对象)定义为static的。否则,每次调用函数都会生成相同的序列。

设置随机数发生器种子

我们通常希望每次运行程序都会生成不同的随机结果,可以通过提供一个种子(seed)来达到这一目的。种子就是一个数值,引擎可以利用它从序列中一个新位置重新开始生成随机数。

default_random_engine e1;        // 使用默认种子
e1.seed(42);                    // 调用seed设置一个种子值
default_random_engine e2(42);    // 使用给定的种子值

如果引擎种子相同,将生成相同的序列。

选择一个好种子,是极其困难的,可能最常用的方法是调用系统函数time。它定义在头文件ctime中,它返回从一个特定时刻到当前经过了多少秒。

default_random_engine e(time(0));    // 稍微随机些的种子

my note: 使用random_device引擎为另一个引擎创建一个种子也是一种方法。

后续内容讨论了其他随机数的分布,比如:生成随机实数、生成非均匀分布随机数等。

IO库再探

格式化输入与输出

除了条件状态外,每个iostream对象还维护一个格式状态来控制IO如何格式化的细节。格式状态控制格式化的某些方面,如整型是几进制、浮点值的精度、一个输出元素的宽度等。

标准库定义了一组操纵符来修改流的格式状态。一个操纵符是一个函数或是一个对象,会影响流的状态。

!!!warning
当操纵符改变流的格式状态时,通常改变后的状态对所有后续IO都生效。

控制布尔值的格式

默认情况下,bool值打印为1或0,通过对流使用boolalpha操纵符来修改原有格式:

cout << boolalpha << true << " " << false << endl; // 输出:true false

一旦向cout“写入”了boolalpha,我们就改变了cout打印bool值的方式。后续打印bool值的操作都会打印true或false,为了取消格式的改变,noboolalpha:

cout << noboolalpha;

指定整型值的进制

默认情况下,整型值的输入输出使用十进制。我们可以使用操纵符hex、oct、dec将其改为十六进制、八进制或是改回十进制。

cout << "default: " << 20 << endl;
cout << "octal: " << oct << 20 << endl;
cout << "hex: " << hex << 20 << endl;
cout << "decimal: " << dec << 20 << endl; 

在输出中指出进制

当对流应用showbase操纵符时,会在输出结果中显示进制:

  • 前导0x表示十六进制。

  • 前导0表示八进制。

  • 无前导字符串表示十进制。

cout << showbase;    // 打印整型值时显示进制
cout << "default: " << 20 << endl;
cout << "octal: " << oct << 20 << endl;
cout << "hex: " << hex << 20 << endl;
cout << "decimal: " << dec << 20 << endl;
cout << noshowbase; // 恢复流状态 

指定打印精度

setprecision操纵符接受一个参数,用来设置精度。它定义在头文件iomanip中。

cout << setprecision(3);
cout << sqrt(2.0) << endl;    // 输出:1.41

更多操纵符见p669。

未格式化的输入/输出操作

标准库提供了一组低层操作,支持未格式化IO(unformatted IO)。这组操作允许我们将一个流当作一个无解释的字节序列来处理。

单字节操作

有几个未格式化操作每次一个字节地处理流,它们会读取而不是忽略空白符。

// 读写一个字符
char ch;
while (cin.get(ch))
    cout.put(ch);
操作 说明
is.get(ch) 从istream is读取下一个字节存入字符ch中。返回is
os.put(ch) 将字符ch输出到ostream os。返回os
is.get() 将is的下一个字节作为int返回
is.putback(ch) 将字符ch放回is。返回is
is.unget() 将is向后移动一个字节。返回is
is.peek() 将下一个字节作为int返回,但不从流中删除它

详细讨论见p673。

多字节操作

一些未格式化IO操作一次处理大块数据。如果速度是要考虑的重点问题的话,这些操作是很重要的,这些操作要求我们自己分配并管理用来保存和提取数据的字符数组。

书中未给出代码案例讲解,具体操作见p674。

流随机访问

标准库提供了一对函数,来定位(seek)到流中给定的位置,以及告诉(tell)我们当前的位置。

在大多数系统中,绑定到cin、cout、cerr和clog的流不支持随机访问,因为这种操作对它们没有意义。对这些流调用seek和tell会导致运行时出错,将流置于一个无效状态。

seek和tell函数

为了支持随机访问,IO类型维护一个标记来确定下一个读写操作要在哪里进行。标准库实际上定义了两对seek和tell函数,差别在于名字的后缀是g还是p,g版本表示我们正在读取数据,而p版本表示我们正在写入数据。

操作 说明
tellg() tellp() 返回一个输入流中(tellg)或输出流中(tellp)标记的当前位置
seekg(pos) seekp(pos) 在一个输入流或输出流中奖标记重定位到给定的绝对地址。pos通常是前一个tell返回的值
seekp(off, from) seekg(off, from) 在一个输入流或输出流中,奖标记定位到from之前或之后off个字符,from可以是:beg(流开始位置), cur(流当前位置), end(流结尾位置)

从逻辑上讲,我们只能对istream使用g版本,对ostream使用p版本。iostream则可以使用g版本又可以使用p版本。


  目录