c++ primer 5th 要点记录

1. 开始

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

  • std::cin:标准输入(standard input)
  • std::cout:标准输出(standard output)
  • std::cerr:标准错误(standard error),通常用cerr来输出警告和错误消息
  • std::clog:clog用来输出程序运行时的一般性信息。

  std::endl:这是一个被称为操纵符(manipulator)的特殊值。写入endl的效果是结束当前行,并将与设备关联的缓冲区(buffer)中的内容刷到设备中。缓冲刷新操作可以保证到目前为止程序所产生的所有输出都真正写入输出流中,而不是仅停留在内存中等待写入流。

  前缀std::指出名字cout和endl是定义在名为std的命名空间(namespace)中的。命名空间可以帮助我们避免不经意的名字定义冲突,以及使用库中相同名字导致的冲突。标准库定义的所有名字都在命名空间std中。

第Ⅰ部分 C++基础

2. 变量和基本类型

2.1 基本内置类型

  C++是一种静态数据类型语言,它的类型检查发生在编译时。因此,编译器必须知道程序中每一个变量对应的数据类型。

  C++语言规定一个int至少和一个short一样大,一个long至少和一个int一样大,一个long long至少和一个long一样大。其中,数据类型long long是在C++11中新定义的。

  与其他整型不同,字符型被分为了三种:char、signed char和unsigned char。特别需要注意的是:类型char和类型signed char并不一样。尽管字符型有三种,但是字符的表现形式却只有两种:带符号的和无符号的。类型char实际上会表现为上述两种形式中的一种,具体是哪种由编译器决定。

  如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动地转换成无符号数。

提示:切勿混用带符号类型和无符号类型

进制:

1
2
3
20      //十进制
024     //八进制
0x14    //十六进制

字符串:

  编译器在每个字符串的结尾处添加一个空字符(′\0′),因此,字符串字面值的实际长度要比它的内容多1。

2.2 变量

初始值:

  • 当对象在创建时获得了一个特定的值,我们说这个对象被初始化(initialized)了。初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来替代。

列表初始化:

1
2
int a = {0};
int b{0};

  当用于内置类型的变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器将报错:

1
2
3
long double ld = 3.1415926536;
int a{ld}, b = {ld};    //错误:转换未执行,因为存在丢失信息的危险
int c = ld;      //正确:转换执行,且确实丢失了部分值

  使用long double的值初始化int变量时可能丢失数据,所以编译器拒绝了a和b的初始化请求。其中,至少ld的小数部分会丢失掉,而且int也可能存不下ld的整数部分。

默认初始化:

  如果是内置类型的变量未被显式初始化,它的值由定义的位置决定。

  • 定义于任何函数体之外的变量被初始化为0
  • 定义在函数体内部的内置类型变量将不被初始化(uninitialized)。
  • 每个类各自决定其初始化对象的方式。而且,是否允许不经初始化就定义对象也由类自己决定。如果类允许这种行为,它将决定对象的初始值到底是什么。

  定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。类的对象如果没有显式地初始化,则其值由类确定。C++11新标准规定,可以为数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化。

类初始值列表:

1
2
3
4
5
6
class A {
  public:
    A() :a(1),b(2) {};
    int a;
    int b;
};

类内初始化:

1
2
3
4
5
6
class A {
  public:
    A(){};
    int a = 1;
    int b{ 2 };
};

C++11的类内初始化允许非static成员的初始化,可以用{}或=号。

类类型的默认初始化:

  每个类各自决定其默认初始化对象的方式。而且,是否允许不经初始化就定义对象也由类自己决定。如果类允许这种行为,它将决定对象的初始值到底是什么:

  • 绝大多数类都支持无需显式初始化而定义对象,这样的类提供了一个合适的默认值,使用默认构造函数来初始化。例如,string 类规定如果没有指定初值则生成一个空串。
  • 但有一些类要求每个对象都显式初始化,此时如果创建了一个该类的对象而未对其做明确的初始化操作,将引发错误。

如果是自定义的(或自动生成的)不执行任何操作的默认构造函数,那么适用与内置数据类型相同的规则来初始化成员变量;且如果成员变量是类类型,则会调用相应的默认构造函数对其进行初始化。

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

// 合成的默认构造函数
class Worker {
public:
	int height;
};

// 有用户定义的默认构造函数
class Person {
public:
	int height;
	Person() {};       // 默认构造函数,不执行任何操作
};

Worker g_worker;
Person g_person;

int main()
{
	Worker localWorker;
	Person localPerson;

	std::cout << g_worker.height << std::endl;      //0
	std::cout << g_person.height << std::endl;      //0
	//std::cout << localWorker.height << std::endl;   //error C4700: 使用了未初始化的局部变量“localWorker”
	std::cout << localPerson.height << std::endl;   //随机值

	return 0;
}

  构造函数的初始化列表与类内成员初始化 没有谁好谁不好,谁来替代谁,两种方法可相互补充使用。类内初始化有一些好处:

  1. 当你有多个构造函数时,如果使用初始化列表,每个构造函数都要写一遍,烦人不说,同时产生重复代码,修改易漏。如果把这些成员都用类内初始化,初始化列表就不用再列出它们了。
  2. 类内初始化,成员之间的顺序是隐式的,会有些便利。如果使用初始化列表,它是有顺序之分的,顺序不对,编译器会警告。
  3. 对于简单的类或结构,没有构造函数的,可以直接用类内初始化在成员声明的同时直接初始化,方便。

  对于一些类类型的成员初始化要小心,如果成员之间有依赖关系,这时使用初始化列表显式的指明这些成员的构造(初始化)顺序是比较稳妥的。

  如果成员已经使用了类内初始化,但在构造函数的初始化列表又列出来,编译器以后者优先,类内初始化会被忽略如果某些成员使用不同构造函数时,会有不同的默认值,这种情况就要用初始化列表。同时,其它成员依然可以使用类内初始化。

  类内初始化绝对不是解决什么内置类型默认初始化时未定义问题。面向对象编程一个很重要的原则,程序员有责任要保证对象产生出来,它的每个成员都必须是初始化的,这是设计问题以及基本意识,无论是使用哪种方法初始化

一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式访问此类值将引发错误。

变量定义和声明的关系:

  为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译(separate compilation)机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。

  如果将程序分为多个文件,则需要有在文件间共享代码的方法。

  为了支持分离式编译,C++语言将声明和定义区分开来。声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义(definition)负责创建与名字关联的实体。

  如果想声明一个变量而非定义它,就在变量名前添加关键字extern,而且不要显式地初始化变量:extern int i;

  任何包含了显式初始化的声明即成为定义。我们能给由extern关键字标记的变量赋一个初始值,但是这么做也就抵消了extern的作用。

  extern语句如果包含初始值就不再是声明,而变成定义了:extern int j = 3;

  声明和定义的区别看起来也许微不足道,但实际上却非常重要。如果要在多个文件中使用同一个变量,就必须将声明和定义分离。此时,变量的定义必须出现在且只能出现在一个文件中,而其他用到该变量的文件必须对其进行声明,却绝对不能重复定义。

2.3 复合类型

引用:

  引用即别名,引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。定义了一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的。

  • 因为引用本身不是一个对象,所以不能定义引用的引用。
  • 引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起。

指针:

指针的值(即地址)应属下列4种状态之一:

  1. 指向一个对象。
  2. 指向紧邻对象所占空间的下一个位置。
  3. 空指针,意味着指针没有指向任何对象。
  4. 无效指针,也就是上述情况之外的其他值。

  试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器并不负责检查此类错误,这一点和试图使用未经初始化的变量是一样的。

解引用操作仅适用于那些确实指向了某个对象的有效指针。

以下列出几个生成空指针的方法:

  1. 得到空指针最直接的办法就是用字面值nullptr来初始化指针,这也是C++11新标准刚刚引入的一种方法。nullptr是一种特殊类型的字面值,它可以被转换成任意其他的指针类型。
  2. 通过将指针初始化为字面值0来生成空指针。
  3. 过去的程序还会用到一个名为NULL的预处理变量(preprocessor variable)来给指针赋值,这个变量在头文件cstdlib中定义,它的值就是0。

  预处理器是运行于编译过程之前的一段程序,预处理变量它由预处理器负责管理,因此我们可以直接使用预处理变量。当用到一个预处理变量时,预处理器会自动地将它替换为实际值,因此用NULL初始化指针和用0初始化指针是一样的。在新标准下,现在的C++程序最好使用nullptr,同时尽量避免使用NULL。

  void是一种特殊的指针类型,可用于存放任意对象的地址。一个void指针存放着一个地址,这一点和其他指针类似。不同的是,我们对该地址中到底是个什么类型的对象并不了解。概括说来,以void*的视角来看内存空间也就仅仅是内存空间,没办法访问内存空间中所存的对象。

  涉及指针或引用的声明,一般有两种写法。第一种把修饰符和变量标识符写在一起:这种形式着重强调变量具有的复合类型。第二种把修饰符和类型名写在一起,并且每条语句只定义一个变量:这种形式着重强调本次声明定义了一种复合类型。

着重强调变量具有的复合类型:

1
int *pl, int *p2;

着重强调本次声明定义了一种复合类型:

1
2
int* p1;
int* p2;

推荐采用第一种方式:着重强调变量具有的复合类型。

2.4 const限定符

  const对象一旦创建后其值就不能再改变,所以const对象必须初始化。一如既往,初始值可以是任意复杂的表达式:

1
2
const int i = get_size();   //正确:运行时初始化
const int j = 42;           //正确:编译时初始化

  默认状态下,const对象仅在文件内有效。解决的办法是,对于const变量不管是声明还是定义都添加extern关键字,这样只需定义一次就可以了:

1
2
3
4
5
// file_1.cc 定义并初始化了一个常量,该常量能被其他文件访问
extern const int bufSize = fcn();

// file_1.h
extern const int bufSize;   //与file_1.cc中定义的bufSize是一个

如果想在多个文件之间共享const对象,必须在变量的定义之前添加extern关键字。

const 的引用:

  可以把引用绑定到const对象上,就像绑定到其他对象上一样,我们称之为对常量的引用(reference to const)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象。

  只有const引用才能绑定一个临时量。所谓临时量对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。C++程序员们常常把临时量对象简称为临时量。

const指针:

  指针是对象而引用不是,因此就像其他对象类型一样,允许把指针本身定为常量。常量指针(const pointer)必须初始化,而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。把*放在const关键字之前用以说明指针是一个常量,这样的书写形式隐含着一层意味,即不变的是指针本身的值而非指向的那个值。

顶层const和底层const:

  如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题。用名词顶层const(top-level const)表示指针本身是个常量,而用名词底层const(low-level const)表示指针所指的对象是一个常量。

  更一般的,顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层const则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层const也可以是底层const,这一点和其他类型相比区别明显:

1
2
3
4
5
6
int i = 0;
int *const p1 = &i;         //不能改变p1的值,这是一个顶层const
const int ci = 42;          //不能改变ci的值,这是一个顶层const
const int *p2 = &ci;        //不能改变p2所指向的值,这是一个底层const
const int *const p3 = p2;   //靠右的const是顶层const,靠左的是底层const
const int &r = ci;          //用于声明引用的const都是底层const    

当执行对象的拷贝操作时,常量是顶层const还是底层const区别明显。其中,顶层const不受什么影响:

1
2
i = ci;                     //正确:拷贝ci的值,ci是一个顶层const,对此操作无影响
p2 = p3;                    //正确:p2和p3指向的对象类型相同,p3顶层const的部分不影响

执行拷贝操作并不会改变被拷贝对象的值,因此,拷入和拷出的对象是否是常量都没什么影响。

  另一方面,底层const的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行。

constexpr和常量表达式:

  常量表达式(const expression)是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。

一个对象(或表达式)是不是常量表达式由它的数据类型初始值共同决定,例如:

1
2
3
4
const int max_files = 20;           //max_files是常量表达式
const int limit = max_files + 1;    //limit是常量表达式
int staff_size = 27;                //staff_size不是常量表达式
const int sz = get_size();          //sz不是常量表达式
  • 尽管staff_size的初始值是个字面值常量,但由于它的数据类型只是一个普通int而非const int,所以它不属于常量表达式。
  • 另一方面,尽管sz本身是一个常量,但它的具体值直到运行时才能获取到,所以也不是常量表达式。

constexpr常量:

  C++11新标准规定,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。

一般来说,如果你认定变量是一个常量表达式,那就把它声明成constexpr类型。

字面值类型:

  常量表达式的值需要在编译时就得到计算,因此对声明constexpr时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把它们称为“字面值类型”(literal type)。

  尽管指针和引用都能定义成constexpr,但它们的初始值却受到严格限制。一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象。

指针和constexpr:

  必须明确一点,在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关:

1
2
const int *p = nullptr;         //p是一个指向整型常量的指针
constexpr int *q = nullptr;     //q是一个指向整数的常量指针
  • 其中的关键在于constexpr把它所定义的对象置为了顶层const

2.5 处理类型

类型别名:

  类型别名(type alias)是一个名字,它是某种类型的同义词。使用类型别名有很多好处,它让复杂的类型名字变得简单明了、易于理解和使用,还有助于程序员清楚地知道使用该类型的真实目的。

传统的方法是使用关键字typedef:

1
2
typedef double wages;       //wages是double的同义词
typedef wages base, *p;     //base是double的同义词,p是double*的同义词

新标准规定了一种新的方法,使用别名声明(alias declaration)来定义类型的别名:

1
using SI = Sales_item;      //SI是Sales_item的同义词

指针、常量和类型别名:

  如果某个类型别名指代的是复合类型或常量,那么把它用到声明语句里就会产生意想不到的后果。例如下面的声明语句用到了类型pstring,它实际上是类型char*的别名:

1
2
3
typedef char *pstring;
const pstring cstr = 0; //cstr是指向char的常量指针
const pstring *ps;      //ps是一个指针,它的对象是指向char的常量指针

  上述两条声明语句的基本数据类型都是const pstring,和过去一样,const是对给定类型的修饰。pstring实际上是指向char的指针,因此,const pstring就是指向char的常量指针,而非指向常量字符的指针。

  遇到一条使用了类型别名的声明语句时,人们往往会错误地尝试把类型别名替换成它本来的样子,以理解该语句的含义:

1
const char *cstr = 0;   //是对const pstring cstr的错误理解

  再强调一遍:这种理解是错误的。声明语句中用到pstring时,其基本数据类型是指针。可是用char*重写了声明语句后,数据类型就变成了char,*成为了声明符的一部分。这样改写的结果是,const char成了基本数据类型。前后两种声明含义截然不同,前者声明了一个指向char的常量指针,改写后的形式则声明了一个指向const char的指针。

auto类型说明符:

  编程时常常需要把表达式的值赋给变量,这就要求在声明变量的时候清楚地知道表达式的类型。然而要做到这一点并非那么容易,有时甚至根本做不到。为了解决这个问题,C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应一种特定类型的说明符(比如double)不同,auto让编译器通过初始值来推算变量的类型。

  • 显然,auto定义的变量必须有初始值。
  • 使用auto也能在一条语句中声明多个变量。因为一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型都必须一样。

  首先,正如我们所熟知的,使用引用其实是使用引用的对象,特别是当引用被用作初始值时,真正参与初始化的其实是引用对象的值。此时编译器以引用对象的类型作为auto的类型:

1
2
int i = 0, &r = i;
auto a = r;         //a是一个整数(r是i的别名,而i是一个整数)

其次,auto一般会忽略掉顶层const,同时底层const则会保留下来,比如当初始值是一个指向常量的指针时:

1
2
3
4
5
const int ci = i, &cr = ci;
auto b = ci;    //b是一个整数(ci的顶层const特性被忽略掉了)
auto c = cr;    //c是一个整数(cr是ci的别名,ci本身是一个顶层const)
auto d = &i;    //d是一个整型指针(整型的地址就是指向整数的指针)
auto c = &ci;   //e是一个指向整数常量的指针(对变量对象取地址是一种底层const)

如果希望推断出的auto类型是一个顶层const,需要明确指出:

1
const auto f = ci;  //ci的推演类型是int,f是const int

还可以将引用的类型设为auto,此时原来的初始化规则仍然适用:

1
2
3
auto &g = ci;           //g是一个整型常量引用,绑定到ci
auto &h = 42;           //错误:不能为非常量引用绑定字面值
const auto &j = 42;     //正确:可以为常量引用绑定字面值

  设置一个类型为auto的引用时,初始值中的顶层常量属性仍然保留。和往常一样,如果我们给初始值绑定一个引用,则此时的常量就不是顶层常量了。

decltype类型指示符:

  有时会遇到这种情况:希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。为了满足这一要求,C++11新标准引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值

1
2
3
4
5
6
7
int i = 42, *p = &i, &r = i;
decltype(r + 0) b;          //正确:加法的结果是int,因此b是一个(未初始化)的int
decltype(r) x;              //错误:x是int&,必须初始化
decltype(*p) c;             //错误:c是int&,必须初始化

decltype((i)) d;            //错误:d是int&,必须初始化
decltype(i) e;              //e是一个(未初始化)的int

切记:decltype(( variable))(注意是双层括号)的结果永远是引用,而decltype( variable)结果只有当 variable 本身就是一个引用时才是引用

2.6 自定义数据结构

  为了确保各个文件中类的定义一致,类通常被定义在头文件中,而且类所在头文件的名字应与类的名字一样。例如,库类型string在名为string的头文件中定义。

预处理器概述:

  确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor),它由C++语言从C语言继承而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。之前已经用到了一项预处理功能#include,当预处理器看到#include标记时就会用指定的头文件的内容代替#include。

  C++程序还会用到的一项预处理功能是头文件保护符(header guard),头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已 经定义:#ifdef当且仅当变量已定义时为真,#ifndef当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。

预处理变量无视C++语言中关于作用域的规则。

  整个程序中的预处理变量包括头文件保护符必须唯一,通常的做法是基于头文件中类的名字来构建保护符的名字,以确保其唯一性。为了避免与程序中的其他实体发生名字冲突,一般把预处理变量的名字全部大写。

3. 字符串、向量和数组

3.1 命名空间的using声明

  有了using声明就无须专门的前缀(形如命名空间::)也能使用所需的名字了。using声明具有的形式:using namespace::name;

1
2
3
4
5
6
7
8
#include <iostream>

using std::cout;

int main() {
    cout << "hello";
    return 0;
}

  头文件不应包含using声明,位于头文件的代码一般来说不应该使用using声明。这是因为头文件的内容会拷贝到所有引用它的文件中去,如果头文件里有某个using声明,那么每个使用了该头文件的文件就都会有这个声明。对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。

3.2 标准库类型string

  作为标准库的一部分,string定义在命名空间std中。

直接初始化和拷贝初始化:

  C++语言有几种不同的初始化方式,通过string我们可以清楚地看到在这些初始化方式之间到底有什么区别和联系。如果使用等号(=)初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。与之相反,如果不使用等号,则执行的是直接初始化(direct initialization)。

1
2
3
string s5 = "hiya";     //拷贝初始化
string s6("hiya");      //直接初始化
string s7(10, 'c');     //直接初始化,内容是cccccccccc  

读取string对象:

1
2
3
4
5
int main() {
    string s;           //空字符串
    cin >> s;           //将string对象读入s,遇到空白停下
    cout << s <<endl;   //输出s
}

  这段程序首先定义一个名为s的空string,然后将标准输入的内容读取到s中。在执行读取操作时,string对象会自动忽略开头的空白(即空格符、换行符、制表符等)并从第一个真正的字符开始读起,直到遇见下一处空白为止。

string与流:

1
2
3
4
//在读取时检测流的状态
while(std::cin >> word) {
    .....
}

  getline函数的参数是一个输入流和一个string对象,函数从给定的输入流中读入内容,直到遇到换行符为止(注意换行符也被读进来了),然后把所读的内容存入到那个string对象中去(注意不存换行符)。getline只要一遇到换行符就结束读取操作并返回结果,哪怕输入的一开始就是换行符也是如此。如果输入真的一开始就是换行符,那么所得的结果是个空string。

1
2
3
4
5
//和输入运算符一样,getline也会返回它的流参数。
//每次读取一整行,直至到达文件末尾
while(getline(std::cin, line)) {
    ....
}

触发getline函数返回的那个换行符实际上被丢弃掉了,得到的string对象中并不包含该换行符。

string::size_type类型:

  string类及其他大多数标准库类型都定义了几种配套的类型。这些配套类型体现了标准库类型与机器无关的特性,类型size_type即是其中的一种。在具体使用的时候,通过作用域操作符来表明名字size_type是在类string中定义的。

  string::size_type类型:它是一个无符号类型的值而且能足够存放下任何string对象的大小。所有用于存放string类的size函数返回值的变量,都应该是string::size_type类型的。

  表达式达式s.size() < n的判断结果几乎肯定是true。这是因为负值n会自动地转换成一个比较大的无符号值。

如果一条表达式中已经有了size()函数就不要再使用int了,这样可以避免混用int和unsigned可能带来的问题。

std::strig对象的比较,都是依照(大小写敏感的)字典顺序:

  1. 如果两个string对象的长度不同,而且较短string对象的每个字符都与较长string对象对应位置上的字符相同,就说较短string对象小于较长string对象。

  2. 如果两个string对象在某些对应的位置上不一致,则string对象比较的结果其实是string对象中第一对相异字符比较的结果。

下面是string对象比较的一个示例:

1
2
3
string str = "Hello";
string phrase = "Hello World";
string slang = "Hiya";

  根据规则1可判断,对象str小于对象phrase;根据规则2可判断,对象slang既大于str也大于phrase。

字符值和string对象相加:

  当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符(+)的两侧的运算对象至少有一个是string。

处理string对象中的字符:

  cctype头文件中包含了处理字符的一些函数。

3.3 标准库类型vector

  模板本身不是类或函数,相反可以将模板看作为编译器生成类或函数编写的一份说明。编译器根据模板创建类或函数的过程称为实例化(instantiation),当使用模板时,需要指出编译器应把类或函数实例化成何种类型。

某些编译器可能仍需以老式的声明语句来处理元素为vector的vector对象,如vector<vector >。必须在外层vector对象的右尖括号和其元素类型之间添加一个空格。

几种初始化方式:

  • 其一,使用拷贝初始化时(即使用=时),只能提供一个初始值;
  • 其二,如果提供的是一个类内初始值,则只能使用拷贝初始化或使用花括号的形式初始化。
  • 第三种特殊的要求是,如果提供的是初始元素值的列表,则只能把初始值都放在花括号里进行列表初始化,而不能放在圆括号里。
1
2
vector<string> v1{"a", "an", "the"};    //列表初始化
vector<string> v2("a", "an", "the");    //错误

值初始化:

  通常情况下,可以只提供vector对象容纳的元素数量而不用略去初始值。此时库会创建一个值初始化的(value-initialized)元素初值,并把它赋给容器中的所有元素。这个初值由vector对象中元素的类型决定。

  如果vector对象的元素是内置类型,比如int,则元素初始值自动设为0。如果元素是某种类类型,比如string,则元素由类默认初始化:

1
2
vector<int> ivec(10);       //10个元素,每个都初始化为0
vector<string> svec(10);    //10个元素,每个都是空string对象

如果vector对象中元素的类型不支持默认初始化,我们就必须提供初始的元素值。

列表初始值还是元素数量?

1
2
3
4
5
vector<int> v1(10);     //v1有10个元素,每个的值都是0
vector<int> v2{10};     //v2有1个元素,该元素的值都是10

vector<int> v3(10, 1);  //v3有10个元素,每个的值都是1
vector<int> v4{10, 1};  //v4有2个元素,值分别是10和1
  • 如果用的是圆括号,可以说提供的值是用来构造(construct)vector对象的。
  • 如果初始化时使用了花括号的形式但是提供的值又不能用来列表初始化,就要考虑用这样的值来构造vector对象了。

如果循环体内部包含有向vector对象添加元素的语句,则不能使用范围for循环。

C++标准要求vector应该能在运行时高效快速地添加元素。

vector对象的比较操作:

  各个相等性运算符和关系运算符也与string的相应运算符功能一致。两个vector对象相等当且仅当它们所含的元素个数相同,而且对应位置的元素值也相同。关系运算符依照字典顺序进行比较:如果两个vector对象的容量不同,但是在相同位置上的元素值都一样,则元素较少的vector对象小于元素较多的vector对象;若元素的值有区别,则vector对象的大小关系由第一对相异的元素值的大小关系决定

只有当元素的值可比较时,vector对象才能被比较。

3.4 迭代器介绍

迭代器类型:

  一般来说我们也不知道(其实是无须知道)迭代器的精确类型。而实际上,那些拥有迭代器的标准库类型使用iterator和const_iterator来表示迭代器的类型:

1
2
3
4
5
vector<int>::iterator it;       //it能读写vector<int>的元素
string::iterator it2;           //it2能读写string对象中的字符

vector<int>::const_iterator it3;//it3只能读元素,不能写元素
string::const_iterator it4;     //it4只能读字符,不能写字符

容器自己的begin和end运算符:

  begin和end返回的具体类型由对象是否是常量决定,如果对象是常量,begin和end返回const_iterator;如果对象不是常量,返回iterator。

  C++11新标准引入了两个新函数,分别是cbegin和cend;不论vector对象(或string对象)本身是否是常量,返回值都是const_iterator。

结合解引用和成员访问操作:

  解引用迭代器可获得迭代器所指的对象,C++语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起。

迭代器运算:

  只要两个迭代器指向的是同一个容器中的元素或者尾元素的下一位置,就能将其相减,所得结果是两个迭代器的距离。所谓距离指的是右侧的迭代器向前移动多少位置就能追上左侧的迭代器,其类型是名 为difference_type的带符号整型数。string和vector都定义了difference_type,因为这个距离可正可负,所以difference_type是带符号类型的。

3.5 数组

如果不清楚元素的确切个数,请使用vector。

  数组中元素的个数也属于数组类型的一部分,编译的时候维度应该是已知的。也就是说,维度必须是一个常量表达式

和内置类型的变量一样,如果在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值。

  定义数组的时候必须指定数组的类型,不允许用auto关键字由初始值的列表推断类型。另外和vector一样,数组的元素应为对象,因此不存在引用的数组。

显示初始化数组元素:

  可以对数组的元素进行列表初始化,此时允许忽略数组的维度。如果在声明时没有指明维度,编译器会根据初始值的数量计算并推测出来;相反,如果指明了维度,那么初始值的总数量不应该超出指定的大小。如果维度比提供的初始值数量大,则用提供的初始值初始化靠前的元素,剩下的元素被初始化成默认值。

不允许拷贝和赋值:

  不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值。

理解复杂的数组声明:

1
int *(&arry)[10] = ptrs;        //arry是数组的引用,该数组含有10个指针

  按照由内向外的顺序阅读上述语句,首先知道arry是一个引用,然后观察右边知道,arry引用的对象是一个大小为10的数组,最后观察左边知道,数组的元素类型是指向int的指针。这样,arry就是一个含有10个int型指针的数组的引用。

要想理解数组声明的含义,最好的办法是从数组的名字开始按照由内向外的顺序阅读。

访问数组元素:

  在使用数组下标的时候,通常将其定义为size_t类型。size_t是一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小。在cstddef头文件中定义了size_t类型,这个文件是C标准库stddef.h头文件的C++语言版本。

标准库函数begin和end:

  C++11新标准引入了两个名为begin和end(cbegin和cend)的函数。begin函数返回指向ia首元素的指针,end函数返回指向ia尾元素下一位置的指针,这两个函数定义在iterator头文件中。使用begin和end可以很容易地写出一个循环并处理数组中的元素。

指针运算:

  两个指针相减的结果的类型是一种名为ptrdiff_t的标准库类型,和size_t一样,ptrdiff_t也是一种定义在cstddef头文件中的机器相关的类型。因为差值可能为负值,所以ptrdiff_t是一种带符号类型。

下标和指针:

  标准库类型限定使用的下标必须是无符号类型,而内置的下标运算无此要求。内置的下标运算符可以处理负值,当然,结果地址必须指向原来的指针所指同一数组中的元素(或是同一数组尾元素的下一位置)。

1
2
3
4
const char *ptr1 = "1234";
const char* ptr2 = ptr1 + 2;

printf("%c", ptr2[-1]);	//输出:2

C风格字符串:

  C风格字符串,是以空字符结束的意思是在字符串最后一个字符后面跟着一个空字符(’\0’)。一般利用指针来操作这些字符串。

string对象和C风格字符串:

  如果程序的某处需要一个C风格字符串,string专门提供了一个名为c_str的成员函数:

1
2
std::string s = "hello";
char *str = s.c_str();

  c_str函数的返回值是一个C风格的字符串。也就是说,函数的返回结果是一个指针,该指针指向一个以空字符结束的字符数组,而这个数组所存的数据恰好与那个string对象的一样。结果指针的类型是const char*,从而确保我们不会改变字符数组的内容。

  我们无法保证c_str函数返回的数组一直有效,事实上,如果后续的操作改变了s的值就可能让之前返回的数组失去效用。如果执行完c_str()函数后程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。

使用数组初始化vector对象:

1
2
3
int int_arr[] = {0, 1, 2, 3, 4, 5};
// ivec有6个元素。分别是int_arr中对应元素的副本
vector<int> ivec(begin(int_arr), end(int_arr));

3.6 多维数组

  严格来说,C++语言中没有多维数组,通常所说的多维数组其实是数组的数组。

4. 表达式

sizeof运算符:

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

C++11新标准允许我们使用作用域运算符来获取类成员的大小。

显示转换:

static_cast:

  任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。

  当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时,强制类型转换告诉程序的读者和编译器:我们知道并且不在乎潜在的精度损失。一般来说,如果编译器发现一个较大的算术类型试图赋值给较小的类型,就会给出警告信息;但是当我们执行了显式的类型转换后,警告信息就会被关闭了。


const_cast:

  const_cast只能改变运算对象的底层const。

  对于将常量对象转换成非常量对象的行为,我们一般称其为“去掉const性质(cast away the const)”。一旦我们去掉了某个对象的const性质,编译器就不再阻止我们对该对象进行写操作了。如果对象本身不是一个常量,使用强制类型转换获得写权限是合法的行为。然而如果对象是一个常量,再使用const_cast执行写操作就会产生未定义的后果


reinterpret_cast:

  einterpret_cast通常为运算对象的位模式提供较低层次上的重新解释

举个例子,假设有如下的转换:

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

  我们必须牢记pc所指的真实对象是一个int而非字符,如果把pc当成普通的字符指针使用就可能在运行时发生错误。例如:

1
string str(pc);     //错误

  使用reinterpret_cast是非常危险的,用pc初始化str的例子很好地证明了这一点。其中的关键问题是类型改变了,但编译器没有给出任何警告或者错误的提示信息。当我们用一个int的地址初始化pc时,由于显式地声称这种转换合法,所以编译器不会发出任何警告或错误信息。接下来再使用pc时就会认定它的值是char*类型,编译器没法知道它实际存放的是指向int的指针。最终的结果就是,在上面的例子中虽然用pc初始化str没什么实际意义,甚至还可能引发更糟糕的后果,但仅从语法上而言这种操作无可指摘。查找这类问题的原因非常困难,如果将ip强制转换成pc的语句和用pc初始化string对象的语句分属不同文件就更是如此。

reinterpret_cast本质上依赖于机器。要想安全地使用reinterpret_cast必须对涉及的类型和编译器实现转换的过程都非常了解。

强制类型转换:

  根据所涉及的类型不同,旧式的强制类型转换分别具有与const_cast、static_cast或reinterpret_cast相似的行为。当我们在某处执行旧式的强制类型转换时,如果换成const_cast和static_cast也合法,则其行为与对应的命名转换一致。如果替换后不合法,则旧式强制类型转换执行与reinterpret_cast类似的功能:

1
2
//效果与使用reinterpret_cast一样。
char *pc = (char *)ip;  //ip是指向整数的指针

建议:避免强制类型转换。

5. 语句

空语句:

  最简单的语句是空语句(null statement),空语句中只含有一个单独的分号。如果在程序的某个地方,语法上需要一条语句但是逻辑上不需要,此时应该使用空语句。例如,我们想读取输入流的内容直到遇到一个特定的值为止,除此之外什么事情也不做:

1
2
3
4
//重复读入数据直至到达文件末尾或某次输入的值等于sought
while(cin >> s && s != sought) {
    ;
}

6. 函数

6.1 函数基础

局部静态对象:

  某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static类型从而获得这样的对象。局部静态对象(local static object)在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。

如果局部静态变量没有显式的初始值,它将执行值初始化。

函数声明:

  和其他名字一样,函数的名字也必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。

  函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作函数原型(function prototype)。

分离式编译:

  随着程序越来越复杂,我们希望把程序的各个部分分别存储在不同文件中。例如,可以把函数存在一个文件里,把使用这些函数的代码存在其他源文件中。为了允许编写程序时按照逻辑关系将其划分开来,C++语言支持所谓的分离式编译(separate compilation)。分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。

6.2 参数传递

指针形参:

  当形参是引用类型时,我们说它对应的实参被引用传递(passed by reference)或者函数被传引用调用(called by reference)。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。

  当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递(passed by value)或者函数被传值调用(called by value)。

熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参替代指针,使用引用避免拷贝。如果函数无须改变引用形参的值,最好将其声明为常量引用。

const形参和实参:

  和其他初始化过程一样,当用实参初始化形参时会忽略掉顶层const。换句话说,形参的顶层const被忽略掉了。当形参有顶层const时,传给它常量对象或者非常量对象都是可以的:

1
2
//fcn能读取i,但是不能向i写值
void fcn(const int i);

指针或引用形参与const:

  形参的初始化方式和变量的初始化方式是一样的。我们可以使用非常量初始化一个底层const对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。

尽量使用常量引用:

  把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型。

数组形参:

  数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组以及使用数组时(通常)会将其转换成指针。因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。

  尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:

1
2
3
4
5
// 尽管形参不同,但这三个print函数是等价的
// 每个函数都有一个const int*类型的参数
void print(const int *);
void print(const int[]);        //可以看出来,函数的意图是作用于一个数组
void print(const int[10])       //这里的维度表示我们期望数组含有多少元素,实际不一定

  尽管表现形式不同,但上面的三个函数是等价的:每个函数的唯一形参都是const int类型的。当编译器处理对print函数的调用时,只检查传入的参数是否是const int类型。

使用标记指定数组长度:

  • 使用标准库规范:
1
2
3
4
5
void print(const int *beg, const int *end) {
    while(beg != end) {
        // do something
    }
}
  • 显式传递一个表示数组大小的形参
1
2
3
4
5
void print(const int ia[], size_t size) {
    for(size_t i = 0; i != size; i++) {
        // do something
    }
}

数组引用形参:

  C++语言允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上:

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

&arr两端的括号必不可少:

1
2
f(int &arr[10])     // 错误:将arr声明成了引用的数组
f(int (&arr)[10])   // 正确:arr是具有10个整数的整型数组的引用

  因为数组的大小是构成数组类型的一部分,所以只要不超过维度,在函数体内就可以放心地使用数组。但是,这一用法也无形中限制了print函数的可用性,我们只能将函数作用于大小为10的数组:

1
2
3
4
5
int i= 0, j[2] = {0, 1};
int k[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
print(&i);  // 错误:实参不是含有10个整数的数组
print(j);   // 错误:实参不是含有10个整数的数组
print(k);   // 正确:实参是含有10个整数的数组    

main函数处理命令行选项:

1
2
3
4
5
int main(int argc, char *argv[]);

//or

int main(int argc, char **argv);

  第一个形参argc表示数组中字符串的数量。第二个形参argv是一个数组,它的元素是指向C风格字符串的指针;

当使用argv中的实参时,一定要记得可选的实参从argv[1]开始;argv[0]保存程序的名字,而非用户输入。

含有可变形参的函数:

  为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:如果所有的实参类型相同,可以传递一个名为initializer_list的标准库类型;如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板(属于高级部分,暂不介绍)。

  C++还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。本节将简要介绍省略符形参,不过需要注意的是,这种功能一般只用于与C函数交互的接口程序。

initializer_list形参:

  如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用initializer_list类型的形参。initializer_list是一种标准库类型,用于表示某种特定类型的值的数组。initializer_list类型定义在同名的头文件中

  和vector一样,initializer_list也是一种模板类型。定义initializer_list对象时,必须说明列表中所含元素的类型:和vector不一样的是,initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值。

省略符形参:

  省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。通常,省略符形参不应用于其他目的。你的C编译器文档会描述如何使用varargs。

  省略符形参应该仅仅用于C和C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。

可参考:C 库宏

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

static double sumOfFloat(int count, ...) {
  va_list ap;
  double sum = 0.0;
  va_start(ap, count);

  for (int i = 0; i < count; ++i) {
    sum += va_arg(ap, double);
  }

  va_end(ap);

  return sum;
}

int main() {

  std::cout << sumOfFloat(5, 1.1, 2.2, 3.3, 4.4, 5.5) << std::endl;

  return 0;
}

省略符形参只能出现在形参列表的最后一个位置。

6.3 返回类型和return语句

值是如何被返回的:

  返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。

不要返回局部对象的引用或指针:

  函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。

引用返回左值:

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

列表初始化返回值:

  C++11新标准规定,函数可以返回花括号包围的值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。

1
2
3
4
5
6
7
8
vector<string> process(bool flag) {
    if(flag) {
        return {"hello", "world"};  //返回列表初始化的vector对象
    }
    else {
        return {};  //返回一个空的vector对象
    }
}

主函数main的返回值:

  我们允许main函数没有return语句直接结束。如果控制到达了main函数的结尾处而且没有return语句,编译器将隐式地插入一条返回0的return语句。

  main函数的返回值可以看做是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量,我们可以使用这两个变量分别表示成功与失败:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int main() {
    bool flag = false;

    if(flag) {
        return EXIT_FAILURE;
    }
    else {
        return EXIT_SUCCESS;
    }
}

返回数组指针:

  因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化这一任务,其中最直接的方法是使用类型别名

1
2
3
int arr[10];            //arr是一个含有10个整数的数组
int *p1[2];             //p1是一个含有10个指针的数组
int (*p2)[10] = &arr;   //p2是一个指针,它指向含有10个整数的数组

  如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。然而,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。

  举个具体点的例子,下面这个func函数的声明没有使用类型别名:

1
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++11新标准中可以使用尾置返回类型(trailing return type)。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用。尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个auto:

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

  因为我们把函数的返回类型放在了形参列表之后,所以可以清楚地看到func函数返回的是一个指针,并且该指针指向了含有10个整数的数组。

使用decltype:

  还有一种情况,如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型。例如,下面的函数返回一个指针,该指针根据参数i的不同指向两个已知数组中的某一个:

1
2
3
4
5
6
7
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;  //返回一个指向数组的指针
}

  arrPtr使用关键字decltype表示它的返回类型是个指针,并且该指针所指的对象与odd的类型一致。因为odd是数组,所以arrPtr返回一个指向含有5个整数的数组的指针。有一个地方需要注意:decltype并不负责把数组类型转换成对应的指针,所以decltype的结果是个数组,要想表示arrPtr返回指针还必须在函数声明时加一个*符号。

6.4 函数重载

重载和const形参:

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

1
2
3
4
5
Record lookup(Phone);       
Record lookup(const Phone);     //重复声明了Record lookup(Phone)

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

  在这两组函数声明中,每一组的第二个声明和第一个声明是等价的。

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

1
2
3
4
5
6
7
// 对于接受引用或指针的函数来说,对象是变量还是非常量对应的形参不同
// 定义了4个独立的重载函数
Record lookup(Account&);        // 函数作用于Account的引用 
Record lookup(const Account&);  // 新函数,作用于常量引用

Record lookup(Account*);        // 新函数,作用于指向Account的指针 
Record lookup(const Account*);  // 新函数,作用于指向常量的指针

  在上面的例子中,编译器可以通过实参是否是常量来推断应该调用哪个函数。因为const不能转换成其他类型,所以我们只能把const对象(或指向const的指针)传递给const形参。相反的,因为非常量可以转换成const,所以上面的4个函数都能作用于非常量对象或者指向非常量对象的指针。不过,当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。

何时不应该重载函数:

  尽管函数重载能在一定程度上减轻我们为函数起名字、记名字的负担,但是最好只重载那些确实非常相似的操作。有些情况下,给函数起不同的名字能使得程序更易理解。举个例子,下面是几个负责移动屏幕光标的函数:

1
2
3
Screen &moveHome();
Screen &moveAbs(int,int);
Screen &moveRel(int,int,string direction);

  乍看上去,似乎可以把这组函数统一命名为move,从而实现函数的重载:

1
2
3
Screen &move();
Screen &move(int,int);
Screen &move(int,int,string direction);

  其实不然,重载之后这些函数失去了名字中本来拥有的信息。尽管这些函数确实都是在移动光标,但是具体移动的方式却各不相同。以moveHome为例,它表示的是移动光标的一种特殊实例。一般来说,是否重载函数要看哪个更容易理解:

1
2
3
//那种形式更容易理解呢?
myScreen.movehome();        // 我们认为应该是这一个
myScreen.move();

const_cast和重载:

const_cast在重载函数的情景中最有用:

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

  这个函数的参数和返回类型都是const string的引用。我们可以对两个非常量的string实参调用这个函数,但返回的结果仍然是const string的引用。

  当它的实参不是常量时,得到的结果是一个普通的引用,使用const_cast可以做到这一点:

1
2
3
4
5
strig &shorterString(string &s1, string &s2) {
    auto &r = shorterString(const_cast<const string&>(s1),
                            const_cast<const string&>(s2));
    return const_cast<string&>(r);
}

  在这个版本的函数中,首先将它的实参强制转换成对const的引用,然后调用了shorterString函数的const版本。const版本返回对const string的引用,这个引用事实上绑定在了某个初始的非常量实参上。因此,我们可以再将其转换回一个普通的string&,这显然是安全的。

调用重载的函数:

  定义了一组重载函数后,我们需要以合理的实参调用它们。函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫做重载确定(overload resolution)。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。

现在我们需要掌握的是,当调用重载函数时有三种可能的结果:

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

重载与作用域:

  如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名。

  当我们调用函数时,编译器首先寻找对该函数名的声明。一旦在当前作用域中找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体。剩下的工作就是检查函数调用是否有效了。

在C++语言中,名字查找发生在类型检查之前。

6.5 特殊用途语言特性

默认实参:

  某些函数有这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的默认实参(default argument)。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。

默认实参声明:

  对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。假如给定:

1
2
// 表示高度和宽度的形参没有默认值
string screen(sz, sz, char = ' ');

我们不能修改一个已经存在的默认值:

1
string screen(sz, sz, char = '*');  //错误重复声明

但是可以按照如下形式添加默认实参:

1
string screen(sz = 24, sz = 80, char);  //正确:添加默认实参

通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。

内联函数和constexpr函数:

  内联函数可避免函数调用的开销。将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。

内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。

constexpr函数:

  constexpr函数(constexpr function)是指能用于常量表达式的函数。定义constexpr函数的方法与其他函数类似,不过要遵循几项约定:函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条return语句。

  执行该初始化任务时,编译器把对constexpr函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr函数被隐式地指定为内联函数

我们允许constexpr函数的返回值并非一个常量 ????????????????

把内联函数和constexpr函数放在头文件内:

  和其他函数不一样,内联函数和constexpr函数可以在程序中多次定义。毕竟,编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过,对于某个给定的内联函数或者constexpr函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和constexpr函数通常定义在头文件中。

assert预处理宏:

  assert是一种预处理宏(preprocessor marco)。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏定义在cassert头文件中。assert宏常用于检查“不能发生”的条件。

NDEBUG预处理变量:

  assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。

  定义NDEBUG能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert应该仅用于验证那些确实不可能发生的事情。我们可以把assert当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查。

  除了用于assert外,也可以使用NDEBUG编写自己的条件调试代码。如果NDEBUG未定义,将执行#ifndef和#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略掉:

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

  在这段代码中,我们使用变量__func__输出当前调试的函数的名字。编译器为每个函数都定义了__func__,它是const char的一个静态数组,用于存放函数的名字。

除了C++编译器定义的__func__之外,预处理器还定义了另外4个对于程序调试很有用的名字: FILE 存放文件名的字符串字面值。 LINE 存放当前行号的整型字面值。 TIME 存放文件编译时间的字符串字面值。 DATE 存放文件编译日期的字符串字面值。

6.6 函数匹配

6.7 函数指针

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

1
2
3
4
bool lengthCompare(const string &, const string &);

//pf指向一个函数,该函数的参数是两个const string的引用,返回值是bool类型
bool (*pf)(const string &, const string &); //未初始化

  从我们声明的名字开始观察,pf前面有个*,因此pf是指针;右侧是形参列表,表示pf指向的是函数;再观察左侧,发现函数的返回类型是布尔值。因此,pf就是一个指向函数的指针,其中该函数的参数是两个const string的引用,返回值是bool类型。

  pf两端的括号必不可少。如果不写这对括号,则pf是一个返回值为bool指针的函数:

1
2
// 声明一个名为pf的函数,该函数返回bool*
bool *pf(const string &, const string &);

使用函数指针:

  当我们把函数名作为一个值使用时,该函数自动地转换成指针。此外,我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针。

返回指向函数的指针:

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

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

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

1
2
3
PF fl(int);     // 正确:PE 是指向函数的指针,f1 返回指向函数的指针
F fl(int);      // 错误:F是函数类型,f1 不能返回一个函数
F *fl(int);     // 正确:显式地指定返回类型是指向函数的指针

当然,我们也能用下面的形式直接声明f1:

1
int (*f1(int))(int*, int);

  按照由内向外的顺序阅读这条声明语句:我们看到f1有形参列表,所以f1是个函数;f1前面有*,所以f1返回一个指针;进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是int。

出于完整性的考虑,有必要提醒读者我们还可以使用尾置返回类型的方式声明一个返回函数指针的函数:

1
auto f1(int) -> int (*)(int*, int);

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

  如果我们明确知道返回的函数是哪一个,就能使用decltype简化书写函数指针返回类型的过程。例如假定有两个函数,它们的返回类型都是string::size_type,并且各有两个const string&类型的形参,此时我们可以编写第三个函数,它接受一个string类型的参数,返回一个指针,该指针指向前两个函数中的一个:

1
2
3
4
string::size type sumLength(const string&, const string&);
string::size type largerLength(const stringk, const string&);
//根据其形参的取值,getFcn 函数返回指向sumLength 或者largerLength 的指针
decltype(sumLength) *getFcn(const string &);

  声明getFcn唯一需要注意的地方是,牢记当我们将decltype作用于某个函数时,它返回函数类型而非指针类型。因此,我们显式地加上*以表明我们需要返回指针,而非函数本身。

7. 类

7.1 定义抽象数据类型

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

引入const成员函数:

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

在类的外部定义成员函数:

  像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名:

1
2
3
double Sales_data::avg_price() const {
    //...........
}

定义类相关的非成员函数:

  我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。

构造函数:

  每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

  构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。

  不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。

合成的默认构造函数:

  编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员。
  • 否则,默认初始化该成员。

对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:

  • 第一个原因也是最容易理解的一个原因就是编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这条规则的依据是,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。

    只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。

  • 第二个原因是对于某些类来说,合成的默认构造函数可能执行错误的操作。回忆我们之前介绍过的,如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。

    如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。

  • 第三个原因是有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。还有其他一些情况也会导致编译器无法生成一个正确的默认构造函数。

= default的含义:

  在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上= default来要求编译器生成构造函数。其中,= default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。

构造函数初始值列表:

1
Sales_data(const std::string &s) : bookNo(s){ };

  这两个定义中出现了新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了(空的)函数体。我们把新出现的部分称为构造函数初始值列表(constructor initialize list),它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。

7.2 访问控制与封装

在C++语言中,我们使用访问说明符(access specifiers)加强类的封装性:

  • 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
  • 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。

使用class或struct关键字:

  唯一的一点区别是,struct和class的默认访问权限不太一样。如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的;相反,如果我们使用class关键字,则这些成员是private的。

友元:

  类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Sales_data {
    // 为 Sales_data的非成员函数所做的友元声明
    friend Sales_data add(const Sales_data&, const Sales_data&);
    friend std::ostream &print(std::ostream&, const Sales_data&);
    friend std::istream &read(std::istream&, Sales_data&);

    // ...................
    // ...................
}

    //函数的声明
    // 为 Sales_data接口的非成员组成部分的声明
    Sales_data add(const Sales_data&, const Sales_data&);
    std::ostream &print(std::ostream&, const Sales_data&);
    std::istream &read(std::istream&, Sales_data&);

  友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束

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

友元的声明:

  友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。

  为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,我们的Sales_data头文件应该为read、print和add提供独立的声明(除了类内部的友元声明之外)。

7.3 类的其他特性

定义一个类型成员:

  除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种:

1
2
3
4
5
6
7
8
9
class Screen {
public:
    typedef std::string::size_type pos;

private:
    pos cursor = 0;
    pos height = 0, width = 0;
    std::string contents;
};

  我们在Screen的public部分定义了pos,这样用户就可以使用这个名字。Screen的用户不应该知道Screen使用了一个string对象来存放它的数据,因此通过把pos定义成public成员可以隐藏Screen实现的细节。

  关于pos的声明有两点需要注意。首先,我们使用了typedef,也可以等价地使用类型别名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Screen {
public:
    // 使用类型别名等价地声明一个新类名字
    using pos = std::string::size_type;

private:
    pos cursor = 0;
    pos height = 0, width = 0;
    std::string contents;
}

  其次,用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别。因此,类型成员通常出现在类开始的地方。

令成员作为内联函数:

  在类中,常有一些规模较小的函数适合于被声明成内联函数。我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义:虽然我们无须在声明和定义的地方同时说明inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline,这样可以使类更容易理解

和我们在头文件中定义inline函数的原因一样,inline成员函数也应该与相应的类定义在同一个头文件中。

可变数据成员:

  一个可变数据成员(mutable data member)永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。

类数据成员的初始值:

  在定义好Screen类之后,我们将继续定义一个窗口管理类并用它表示显示器上的一组Screen。这个类将包含一个Screen类型的vector,每个元素表示一个特定的Screen。默认情况下,我们希望Window_mgr类开始时总是拥有一个默认初始化的Screen。在C++11新标准中,最好的方式就是把这个默认值声明成一个类内初始值:

1
2
3
4
5
6
class Window_mgr {
private:
    // 这个Window_mgr追踪的Screen
    // 默认情况下,一个Window_mgr包含一个标准尺寸的空白Screen
    std::vector<Screen> screens{Screen(24, 80, ' ')};
}

  如我们之前所知的,类内初始值必须使用=的初始化形式(初始化Screen的数据成员时所用的)或者花括号括起来的直接初始化形式(初始化screens所用的)。

当我们提供一个类内初始值时,必须以符号=或者花括号表示。

从const成员函数返回*this:

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

基于const的重载:

  通过区分成员函数是否是const的,我们可以对其进行重载。具体说来,因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。

类类型:

  每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。例如:

类的声明:

  就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:

1
class Screen;       // Screen类的声明

  这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。

  不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。

  对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员。

  直到类被定义之后数据成员才能被声明成这种类类型。换句话说,我们必须首先完成类的定义,然后编译器才能知道存储该数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:

1
2
3
4
5
class Link_screen {
    screen window;
    Link_screen *next;
    Link_screen *prev;
}

友元再探:

  类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的

类之间的友元关系:

  如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。必须要注意的一点是,友元关系不存在传递性

每个类负责控制自己的友元类或友元函数。

令成员函数作为友元

  当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:

1
2
3
4
5
class Screen {
    // Window_mgr::clear必须在Screen类之前被声明
    friend void Windown_mgr::clear(ScreenIndex);
    // Screen类的剩余部分
}

要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:

  1. 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen。
  2. 接下来定义Screen,包括对于clear的友元声明。
  3. 最后定义clear,此时它才可以使用Screen的成员。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>

// 前置声明
class Screen;

class Windown_mgr {
public:
    void clear(Screen&);
};

class Screen {
	// Window_mgr::clear必须在Screen类之前被声明
	friend void Windown_mgr::clear(Screen&);

public:
    Screen(int val) : val_(val) { };

    int getVal() const {
        return val_;
    }

private:
    int val_;
};

void Windown_mgr::clear(Screen& screen) {
    screen.val_ = 0;
}

int main() {
    Screen screen(12);
    std::cout << screen.getVal() << std::endl;

    Windown_mgr mgr;
    mgr.clear(screen);

	std::cout << screen.getVal() << std::endl;

    return 0;
}

函数重载和友元:

  尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。

友元声明和作用域:

  类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。

  甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct X {
    // 友元函数可以定义在类的内部
    friend void f() {}
    X() { f(); }        //错误:f还没被声明
    void g();
    void h();
};

void X::g() { return f(); }     //错误:f还没被声明
void f();                       //声明那个定义在X中的函数
void X::h() { return f(); }     //正确:现在f的声明在作用域中了

7.4 类的作用域

  类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

7.5 构造函数再探

  如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

建议使用构造函数初始值:

  在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。除了效率问题外更重要的是,一些数据成员必须被初始化。建议读者养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。

成员初始化的顺序:

  构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

  一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了。考虑下面这个类:

1
2
3
4
5
6
7
8
class X {
    int i;
    int j;

public:
    //未定义的:i在j之前被初始化
    X(int val) : j(val), i(j) { };
};

  在此例中,从构造函数初始值的形式上来看仿佛是先用val初始化了j,然后再用j初始化i。实际上,i先被初始化,因此这个初始值的效果是试图使用未定义的值j初始化i

最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。

默认实参和构造函数:

  如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

委托构造函数:

  C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。

  和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。

举个例子,我们使用委托构造函数重写Sales_data类,重写后的形式如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Sales_data {
public:
    // 非委托构造函数使用对应的实参初始化成员
    Sales_data(std::string s, unsigned cnt, double price) : 
            bookNo(s), units_sold(cnt), revenue(cnt * price) { };

    // 其余构造函数全都委托给另一个构造函数
    Sales_data() : Sale_data("", 0, 0) { };
    Sales_data(std::string s) : Sales_data(s, 0, 0) { };
    Sales_data(std::istream &is) : Sales_data() { read(is, *this) };
}

值初始化和默认初始化:

  1. 值初始化 顾名思义,就是用数值初始化变量。如果没有给定一个初始值,就会根据变量或类对象的类型提供一个初始值。对于int类型其值初始化后的值为0。

  2. 默认初始化:如果定义变量时没有指定初值,则变量被默认初始化。其初始值和变量的类型以及变量定义的位置相关。默认初始化类对象和默认初始化内置类型变量有所不同。

对于默认初始化内置类型变量来说:

  1. 定义在函数体之外的变量是全局变量,一般存储在全局区,存储在全局区的变量一般会执行值初始化。此时,其初始值和变量的类型有关。对于int类型其初始值为0,对于char类型其默认初始值为’ ‘。
  2. 定义在函数体内部的是局部变量,其存储在栈区中,如果没有指定初值,那么该局部变量将不会被初始化,也就是说这个局部变量的值是未定义的,是个随机值。此时,如果不给这个局部变量赋值,那么就不能使用该局部变量,否则就会出错,注意这种情况是没有被初始化,既没有使用默认初始化也没有使用值初始化,没有初始化的值是不能使用的。

默认构造函数的作用:

  当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:

  • 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
  • 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
  • 当类类型的成员没有在构造函数初始值列表中显式地初始化时。

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

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
  • 当我们不使用初始值定义一个局部静态变量时。
  • 当我们通过书写形如T( )的表达式显式地请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。

隐式的类类型转换:

  编译器只会自动地执行一步类型转换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class X {
public:
	X(std::string str) : str_(str) { };

private:
	std::string str_;
};

int main() {
	X x1("hello");

    return 0;
}

抑制构造函数定义的隐式转换:

  在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <iostream>
#include <string>

class X {
public:
	explicit X(const std::string &str) : str_(str) { };

private:
	std::string str_;
};

  关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit的。只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。

  explicit构造函数只能用于直接初始化。发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用explicit构造函数:

1
2
X x1("hello");			//正确,直接初始化
X x2 = "hello";			//错误,不能将explicit构造函数用于拷贝形式的初始化过程

当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。

聚合类:

  聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有virtual函数。

例如,下面的类是一个聚合类:

1
2
3
4
struct Data {
    int ival;
    string s;
};

  我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:

1
Data val1 = {0, "Anna"};

  初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。

  如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。

可以把聚合类理解成C语言的结构体

字面值常量类:

  数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

  • 数据成员都必须是字面值类型。
  • 类必须至少含有一个constexpr构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

constexpr构造函数:

  尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。

7.6 类的静态成员

  当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句。

和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static关键字则只出现在类内部的声明语句中。

  因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。

要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

静态成员的类内初始化:

  即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

1
2
3
4
5
struct X {
	static int str_;
};

int X::str_ = 2;

第Ⅱ部分 C++标准库

8. IO库

8.1 IO类

定义在三个独立的头文件中:iostream定义了用于读写流的基本类型,fstream定义了读写命名文件的类型,sstream定义了读写内存string对象的类型

9. 顺序容器

一个容器就是一些特定类型对象的集合。顺序容器(sequential container)为程序员提供了控制元素存储和访问顺序的能力。这种顺序不依赖于元素的值,而是与元素加入容器时的位置相对应。

9.1 顺序容器概述

  • vector:可变大小数组。支持快速随机访问。在尾部之外的位置插入或删除元素可能很慢
  • deque:双端队列。支持快速随机访问。在头尾位置插入/删除速度很快
  • list:双向链表。只支持双向顺序访问。在list 中任何位置进行插入/删除操作速度都很快
  • forward_list:单向链表。只支持单向顺序访问。在链表任何位置进行插入/删除操作速度都很快
  • array:固定大小数组。支持快速随机访问。不能添加或删除元素
  • string:与vector 相似的容器,但专门用于保存字符。随机访问快。在尾部插入/副除速度快

这些容器在以下方面都有不同的性能折中:

  • 向容器添加或从容器中删除元素的代价
  • 非顺序访问容器中元素的代价

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

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

deque是一个更为复杂的数据结构。与string和vector类似,deque支持快速的随机访问。与string和vector一样,在deque的中间位置添加或删除元素的代价(可能)很高。但是,在deque的两端添加或删除元素都是很快的,与list或forward_list添加删除元素的速度相当。

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

9.2 容器库概览

一般来说,每个容器都定义在一个头文件中,文件名与类型名相同。即,deque定义在头文件deque中,list定义在头文件list中,以此类推。

容器均定义为模板类:

1
2
3
list<Sales_data> lss;
deque<double> dqs;
vector<vector<string>> lines;

较旧的编译器可能需要在两个尖括号之间键入空格,列如:vector<vector<string> >

与容器一样,迭代器有着公共的接口:如果一个迭代器提供某个操作,那么所有提供相同操作的迭代器对这个操作的实现方式都是相同的。例如,标准容器类型上的所有迭代器都允许我们访问容器中的元素,而所有迭代器都是通过解引用运算符来实现这个操作的。类似的,标准库容器的所有迭代器都定义了递增运算符,从当前元素移动到下一个元素。

一个迭代器范围(iterator range)由一对迭代器表示,两个迭代器分别指向同一个容器中的元素或者是尾元素之后的位置(one past the last element)。

这种元素范围被称为左闭合区间(left-inclusive interval),其标准数学描述为:[begin, end)

表示范围自begin开始,于end之前结束。迭代器begin和end必须指向相同的容器。end可以与begin指向相同的位置,但不能指向begin之前的位置。

当将一个容器初始化为另一个容器的拷贝时,两个容器的容器类型和元素类型都必须相同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
std::list<std::string> authors = { "Milton", "Shakespeare", "Austen" };
std::vector<const char*> articles = { "a", "an", "the" };

std::list<std::string> list2(authors);
// std::deque<std::string> authList(authors);	// 错误:容器类型不匹配
std::deque<std::string> authList(authors.begin(), authors.end());

// std::vector<std::string> words(articles);		// 错误:容器类型必须匹配
// 正确:可以将const char*元素转换为string
std::forward_list<std::string> words(articles.begin(), articles.end());

与内置数组一样,标准库array的大小也是类型的一部分。当定义一个array时,除了指定元素类型,还要指定容器大小:

1
2
std::array<int, 42>			// 类型为:保存42个int的数组
std::array<std::string, 10>	// 类型为:保存10个string的数组

array大小固定的特性也影响了它所定义的构造函数的行为。与其他容器不同,一个默认构造的array是非空的:它包含了与其大小一样多的元素。这些元素都被默认初始化,就像一个内置数组中的元素那样。如果我们对array进行列表初始化,初始值的数目必须等于或小于array的大小。如果初始值数目小于array的大小,则它们被用来初始化array中靠前的元素,所有剩余元素都会进行值初始化。在这两种情况下,如果元素类型是一个类类型,那么该类必须有一个默认构造函数,以使值初始化能够进行:

1
2
3
std::array<int, 10> ial;	// 10个默认初始化的int
std::array<int, 10> ia2 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};	// 列表初始化
std::array<int, 10> ia3 = {42};	// ia3[0]为42,剩余元素为0

值得注意的是,虽然我们不能对内置数组类型进行拷贝或对象赋值操作,但array并无此限制:

1
2
3
4
5
int digs[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
int cpy[10] = digs;	// 错误:内置数组不支持拷贝或赋值

std::array<int, 10> digits = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
std::array<int, 10> copy = digits;	// 正确:只要数组类型匹配及合法

与其他容器一样,array也要求初始值的类型必须与要创建的容器类型相同。此外,array还要求元素类型和大小也都一样,因为大小是array类型的一部分

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

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

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

使用assign(仅顺序容器)。赋值运算符要求左边和右边的运算对象具有相同的类型。它将右边运算对象中所有元素拷贝到左边运算对象中。顺序容器(array除外)还定义了一个名为assign的成员,允许我们从一个不同但相容的类型赋值,或者从容器的一个子序列赋值。assign操作用参数所指定的元素(的拷贝)替换左边容器中的所有元素。

例如,我们可以用assgin实现将一个vector中的一段char *值赋予一个list中的string:

1
2
3
4
5
6
std::list<std::string> names;
std::vector<const char*> oldstyle;
names = oldstyle;	// 错误:容器类型不匹配

// 正确:可以将const char*转换为string
names.assign(oldstyle.cbegin(), oldstyle.cend());

由于其旧元素被替换,因此传递给assign的迭代器不能指向调用assign的容器。

assign的第二个版本接受一个整型值和一个元素值。它用指定数目且具有相同给定值的元素替换容器中原有的元素:

1
2
3
4
// 等价于slist1.clear();
// 后跟slist1.insert(slist1.begin(), 10, "Hiya!");
std::list<std::string> slist1(1);	// 1个元素,为空的string
slist1.assign(10, "Hiya!");

使用swap

swap操作交换两个相同类型容器的内容。调用swap之后,两个容器中的元素将会交换:

1
2
3
std::vector<std::string> svec1(10);	// 10个元素的vector
std::vector<std::string> svec2(24);	// 10个元素的vector
std::swap(svec1, svec2);

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

除array外,swap不对任何元素进行拷贝、删除或插入操作,因此可以保证在常数时间内完成。

元素不会被移动的事实意味着,除string外,指向容器的迭代器、引用和指针在swap操作之后都不会失效。它们仍指向swap操作之前所指向的那些元素。但是,在swap之后,这些元素已经属于不同的容器了。例如,假定iter在swap之前指向svec1[3]的string,那么在swap之后它指向svec2[3]的元素。与其他容器不同,对一个string调用swap会导致迭代器、引用和指针失效

与其他容器不同,swap两个array会真正交换它们的元素。因此,交换两个array所需的时间与array中元素的数目成正比。因此,对于array,在swap操作之后,指针、引用和迭代器所绑定的元素保持不变,但元素值已经与另一个array中对应元素的值进行了交换。

在新标准库中,容器既提供成员函数版本的swap,也提供非成员版本的swap。而早期标准库版本只提供成员函数版本的swap。非成员版本的swap在泛型编程中是非常重要的。统一使用非成员版本的swap是一个好习惯

容器大小操作

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

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

  • 如果两个容器具有相同大小且所有元素都两两对应相等,则这两个容器相等;否则两个容器不等。
  • 如果两个容器大小不同,但较小容器中每个元素都等于较大容器中的对应元素,则较小容器小于较大容器。
  • 如果两个容器都不是另一个容器的前缀子序列,则它们的比较结果取决于第一个不相等的元素的比较结果。
1
2
3
4
5
6
7
8
std::vector<int> v1 = { 1, 3, 5, 7, 9, 12 };
std::vector<int> v2 = { 1, 3, 9 };
std::vector<int> v3 = { 1, 3, 5, 7 };
std::vector<int> v4 = { 1, 3, 5, 7, 9, 12 };
std::cout << (v1 < v2) << std::endl;	// true
std::cout << (v1 < v3) << std::endl;	// false
std::cout << (v1 == v4) << std::endl;	// true
std::cout << (v1 == v2) << std::endl;	// false

容器的相等运算符实际上是使用元素的==运算符实现比较的,而其他关系运算符是使用元素的<运算符。如果元素类型不支持所需运算符,那么保存这种元素的容器就不能使用相应的关系运算。

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

  • 当我们用一个对象来初始化容器时,或将一个对象插入到容器中时,实际上放入到容器中的是对象值的一个拷贝,而不是对象本身。就像我们将一个对象传递给非引用参数一样,容器中的元素与提供值的对象之间没有任何关联。随后对容器中元素的任何改变都不会影响到原始对象,反之亦然。

使用insert的返回值

1
2
3
4
5
6
7
std::list<std::string> lst;
auto iter = lst.begin();
std::string word;

while (std::cin >> word) {
	iter = lst.insert(iter, word);	// 等价于调用push_front
}

在循环之前,我们将iter初始化为lst.begin()。第一次调用insert会将我们刚刚读入的string插入到iter所指向的元素之前的位置。insert返回的迭代器恰好指向这个新元素。我们将此迭代器赋予iter并重复循环,读取下一个单词。只要继续有单词读入,每步while循环就会将一个新元素插入到iter之前,并将iter改变为新加入元素的位置。此元素为(新的)首元素。因此,每步循环将一个新元素插入到list首元素之前的位置。

使用emplace操作

新标准引入了三个新成员——emplace_front、emplace和emplace_back,这些操作构造而不是拷贝元素。这些操作分别对应push_front、insert和push_back,允许我们将元素放置在容器头部、一个指定位置之前或容器尾部。

当调用push或insert成员函数时,我们将元素类型的对象传递给它们,这些对象被拷贝到容器中。而当我们调用一个emplace成员函数时,则是将参数传递给元素类型的构造函数。emplace成员使用这些参数在容器管理的内存空间中直接构造元素。例如,假定c保存Sales_data元素:

1
2
3
4
5
6
7
8
9
// 在c的末尾构造一个Sales_data对象
// 使用三个参数的Sales_data构造函数
c.emplace_back("978-0590353403", 25, 15.99);

// 错误:没有接受三个参数的push_back版本
c.push_back("978-0590353403", 25, 15.99);

// 正确:创建一个临时的Sales_data对象传递给push_back
c.push_back(Sales_data("978-0590353403", 25, 15.99))

其中对emplace_back的调用和第二个push_back调用都会创建新的Sales_data对象。在调用emplace_back时,会在容器管理的内存空间中直接创建对象。而调用push_back则会创建一个局部临时对象,并将其压入容器中。

emplace函数的参数根据元素类型而变化,参数必须与元素类型的构造函数相匹配:

1
2
3
4
5
6
// iter指向c中一个元素,其中保存了Sales_data元素
c.emplace_back();	// 使用Sales_data的默认构造函数
c.emplace_back(iter, "999-9999999");	// 使用Sales_data(string)

// 使用Sales_data的接受一个ISBN、一个count和一个price的构造函数
c.emplace_front("978-0590353403", 25, 15.99);

emplace函数在容器中直接构造元素。传递给emplace函数的参数必须与元素类型的构造函数相匹配。

第Ⅲ部分 C++标准库

13. 拷贝控制

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

1
2
3
4
5
class Foo {
public:
	Foo();				// 默认构造函数
	Foo(const Foo&);	// 拷贝构造函数
};

拷贝构造函数的第一个参数必须是一个引用类型。虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit的。

合成拷贝构造函数:

如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数

一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

拷贝初始化:

1
2
3
4
5
std::string dots(10, ' ');					// 直接初始化
std::string s(dots);						// 直接初始化
std::string s2 = dots;						// 拷贝初始化
std::string null_book = "9-999-9999-9";		// 拷贝初始化,会转换到string类型,进行拷贝初始化
std::string nines = std::string(100, '9');	// 拷贝初始化

当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化(copy initialization)时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生:

  • 函数传参:将一个对象作为实参传递给一个非引用类型的形参
  • 函数返回:从一个返回类型为非引用类型的函数返回一个对象
  • 列表初始化:用花括号列表初始化一个数组中的元素或一个聚合类中的成员

某些类类型还会对它们所分配的对象使用拷贝初始化。例如,当我们初始化标准库容器或是调用其insert或push成员时,容器会对其元素进行拷贝初始化。与之相对,用emplace成员创建的元素都进行直接初始化

参数和返回值:

在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环

拷贝初始化的限制:

如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:

1
2
3
4
5
6
std::vector<int> v1(10);	// 正确,直接初始化
std::vector<int> v2 = 10;	// 错误,接受大小参数的构造函数explicit的

void f(std::vector<int>);	// f的参数进行拷贝初始化
f(10);						// 错误,不能用一个explicit的构造函数拷贝一个实参
f(std::vector<int>(10));	// 正确,从一个int直接构造一个临时vector

直接初始化v1是合法的,但看起来与之等价的拷贝初始化v2则是错误的,因为vector的接受单一大小参数的构造函数是explicit的。出于同样的原因,当传递一个实参或从函数返回一个值时,我们不能隐式使用一个explicit构造函数。如果我们希望使用一个explicit构造函数,就必须显式地使用,像此代码中最后一行那样。

编译器可以绕过拷贝构造函数:

在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即,编译器被允许将下面的代码:

1
std::string null_book = "9-999-9999-9";		// 拷贝初始化,会转换到string类型,进行拷贝初始化

改为:

1
std::string null_book("9-999-9999-9");		// 直接初始化,编译器略过了拷贝构造函数

但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是private的)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <string>
#include <vector>

class MyString {
public:
	MyString(int n, char c) {
		this->str_ = std::string(n, c);
		std::cout << "直接初始化" << std::endl;
	}

	MyString(const char* str) {
		this->str_ = str;
		std::cout << "直接初始化" << std::endl;
	}

	MyString(const MyString& myString) {
		this->str_ = myString.str_;
		std::cout << "拷贝初始化" << std::endl;
	}

private:
	std::string str_;
};


int main() {
	MyString str1(MyString("1111"));	// 直接初始化
	MyString str2 = MyString("22222");	// 直接初始化,(优化关了)
	MyString str2_ = str2;				// 拷贝初始化
	MyString str3("333");				// 直接初始化
	MyString str4(10, '4');				// 直接初始化

	return 0;
}

13.1.2 拷贝赋值运算符

与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:

1
2
Sales_data trans, accum;
trans = accum;  // 使用Sales_data的拷贝赋值运算符

与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

重载运算符:

重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。

重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

拷贝赋值运算符接受一个与其所在类相同类型的参数:

1
2
3
4
class Foo {
public:
	Foo& operator=(const Foo&);	// 赋值运算符
};

为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。另外值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。

赋值运算符通常应该返回一个指向其左侧运算对象的引用。

合成拷贝赋值运算符:

与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符(synthesizedcopy-assignment operator)。类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

作为一个例子,下面的代码等价于Sales_data的合成拷贝赋值运算符:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Sales_data {
public:
	Sales_data& operator=(const Sales_data&);	// 赋值运算符

private:
	std::string bookNo;
	int units_sold;
	double revenue;
};

// 等价于合成拷贝赋值运算符 
Sales_data& Sales_data::operator=(const Sales_data& rhs) {
	this->bookNo = rhs.bookNo;			// 调用std::string::operator=
	this->units_sold = rhs.units_sold;	// 使用内置int赋值
	this->revenue = rhs.revenue;		// 使用内置都变了赋值

	return *this;						// 返回一个此对象的引用
}

13.1.3 析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。

析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数:

1
2
3
4
class Foo {
public:
	~Foo();	// 析构函数
};

由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。

析构函数完成什么工作:

如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

在对象最后一次使用之后,析构函数的函数体可执行类设计者希望执行的任何收尾工作。通常,析构函数释放对象在生存期分配的所有资源。

在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。

隐式销毁一个内置指针类型的成员不会delete它所指向的对象。

与普通指针不同,智能指针是类类型,所以具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。

什么时候会调用析构函数:

无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

由于析构函数自动运行,我们的程序可以按需要分配资源,而(通常)无须担心何时释放这些资源。例如,下面代码片段定义了四个Sales_data对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 新的作用域
	{
		// p和p2指向动态分配的对象
		Sales_data* p = new Sales_data;				// p是一个内置指针
		auto p2 = std::make_shared<Sales_data>();	// p2是一个shared_ptr
		Sales_data item(*p);			// 拷贝构造函数将*p拷贝到item 
		std::vector<Sales_data> vec;	// 局部对象
		vec.push_back(*p2);				// 拷贝p2指向的对象
		double p;						// 对p指向的对象执行析构构造函数
	}
	// 退出局部作用域;对item、p2和vec调用析构函数
	// 销毁p2会递减其引用计数;如果引用计数变为0,对象析构、
	// 销毁vec会销毁它的元素

当程序块结束时,vec、p2和item都离开了作用域,意味着在这些对象上分别会执行vector、shared_ptr和Sales_data的析构函数。vector的析构函数会销毁我们添加到vec的元素。shared_ptr的析构函数会递减p2指向的对象的引用计数。在本例中,引用计数会变为0,因此shared_ptr的析构函数会delete p2分配的Sales_data对象。

当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

合成析构函数:

当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数(synthesizeddestructor)。类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。

例如,下面的代码片段等价于Sales_data的合成析构函数:

1
2
3
4
5
class Sales_data {
public:
	// 成员会被自动销毁,除此之外不需要做其他事情
	~Sales_data() {};
}

认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

13.1.4 三五法则

如前所述,有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。而且,在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。

C++语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。但是,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。

需要析构函数的类也需要拷贝和赋值操作:

当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符

需要拷贝操作的类也需要赋值操作,反之亦然:

虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。

作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的序号。这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是,这个类不需要自定义析构函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Studen {
public:
	// 直接初始化
	Studen(unsigned int id) {
		this->id = id;
	}

	// 拷贝构造函数
	Studen(const Studen& student) {
		this->id = student.id + 1;
	}

	// 拷贝赋值操作
	Studen& operator=(const Studen& student) {
		this->id = student.id + 1;
		return *this;
	}

private:
	unsigned int id;
};

这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

13.1.5 使用=default

我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Sales_data {
public:
	// 拷贝控制成员,使用default
	Sales_data() = default;
	Sales_data(const Sales_data&) = default;
	Sales_data& operator=(const Sales_data&);
	~Sales_data() = default;
};

Sales_data& Sales_data::operator=(const Sales_data&) = default;

当我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default,就像对拷贝赋值运算符所做的那样。

我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)。

13.1.6 阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。虽然大多数类应该定义(而且通常也的确定义了)拷贝构造函数和拷贝赋值运算符,但对某些类来说,这些操作没有合理的意义。在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。

定义删除的函数:

在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的:

1
2
3
4
5
6
class NoCopy {
	NoCopy() = default;							// 使用合成的默认构造函数
	NoCopy(const NoCopy&) = delete;				// 阻止拷贝
	NoCopy& operator=(const NoCopy&) = delete;	// 阻止赋值
	~NoCopy() = default;						// 使用合成的析构函数
};

=delete通知编译器(以及我们代码的读者),我们不希望定义这些成员。

与=default不同,=delete必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此=default直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。

与=default的另一个不同之处是,我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。

析构函数不能是删除的成员:

值得注意的是,我们不能删除析构函数。如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。而且,如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象。因为如果一个成员的析构函数是删除的,则该成员无法被销毁。而如果一个成员无法被销毁,则对象整体也就无法被销毁了。

对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象。对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。

1
2
3
4
5
6
7
8
struct NoDtor {
	NoDtor() = default;
	~NoDtor() = delete;	// 不能销毁NoDtor对象
};

NoDtor nd;				// 错误,NoDtor的析构函数是删除的
NoDtor* p = new NoDtor;	// 正确,但无法删除NoDtor对象
delete p;				// 错误,NoDtor的析构函数是删除的

合成的拷贝控制成员可能是删除的:

如前所述,如果我们未定义拷贝控制成员,编译器会为我们定义合成的版本。类似的,如果一个类未定义构造函数,编译器会为其合成一个默认构造函数。对某些类来说,编译器将这些合成的成员定义为删除的函数:

  • 如果类的某个成员的析构函数是删除的或不可访问的(例如,是private的),则类的合成析构函数被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。

本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,这看起来可能有些奇怪。其原因是,如果没有这条规则,我们可能会创建出无法销毁的对象。

对于具有引用成员或无法默认构造的const成员的类,编译器不会为其合成默认构造函数,这应该不奇怪。同样不出人意料的规则是:如果一个类有const成员,则它不能使用合成的拷贝赋值运算符。毕竟,此运算符试图赋值所有成员,而将一个新值赋予一个const对象是不可能的。

虽然我们可以将一个新值赋予一个引用成员,但这样做改变的是引用指向的对象的值,而不是引用本身。如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧运算对象指向相同的对象。由于这种行为看起来并不是我们所期望的,因此对于有引用成员的类,合成拷贝赋值运算符被定义为删除的。

本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。

private拷贝控制:

在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝。

由于析构函数是public的,用户可以定义类型的对象。但是,由于拷贝构造函数和拷贝赋值运算符是private的,用户代码将不能拷贝这个类型的对象。但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为private的,但并不定义它们。

声明但不定义一个成员函数是合法的,对此只有一个例外,我们将在15.2.1节中介绍。试图访问一个未定义的成员将导致一个链接时错误。通过声明(但不定义)private的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。

希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。

13.2 拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。

为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。

类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。

行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。

在我们使用过的标准库类中,标准库容器和string类的行为像一个值。而不出意外的,shared_ptr类提供类似指针的行为,IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像指针。

13.2.1 行为像值的类

为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。这意味着对于ps指向的string,每个HasPtr对象都必须有自己的拷贝。为了实现类值行为,HasPtr需要:

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
  • 定义一个析构函数来释放string
  • 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class HasPtr {
public:
    // 构造函数
	HasPtr(const std::string& s = std::string()) :
		ps(new std::string(s)), i(0) {
		;
	}

    // 拷贝构造
	HasPtr(const HasPtr& p) :
		ps(new std::string(*p.ps)), i(p.i) {
		;
	}

    // 赋值拷贝
	HasPtr& operator=(const HasPtr& p);

    // 析构
	~HasPtr() {
		delete ps;
	}

private:
	std::string* ps;
	int i;
};

赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。但是,非常重要的一点是,这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,我们编写的赋值运算符还应该是异常安全的——当异常发生时能将左侧运算对象置于一个有意义的状态。

在本例中,通过先拷贝右侧运算对象,我们可以处理自赋值情况,并能保证在异常发生时代码也是安全的。在完成拷贝后,我们释放左侧运算对象的资源,并更新指针指向新分配的string:

1
2
3
4
5
6
7
8
9
HasPtr& HasPtr::operator=(const HasPtr& rhs) {
	// 拷贝底层的string
	auto newp = new std::string(*rhs.ps);
	delete this->ps;	// 释放旧内存
	this->ps = newp;
	this->i = rhs.i;

	return *this;
}

关键概念:赋值运算符

当你编写赋值运算符时,有两点需要记住:

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。

对于一个赋值运算符来说,正确工作是非常重要的,即使是将一个对象赋予它自身,也要能正确工作。一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象。

13.2.2 定义行为像指针的类

对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。我们的类仍然需要自己的析构函数来释放接受string参数的构造函数分配的内存。但是,在本例中,析构函数不能单方面地释放关联的string。只有当最后一个指向string的HasPtr销毁时,它才可以释放string。

令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。拷贝(或赋值)一个shared_ptr会拷贝(赋值)shared_ptr所指向的指针。shared_ptr类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr类负责释放资源。

但是,有时我们希望直接管理资源。在这种情况下,使用引用计数(reference count)就很有用了。为了说明引用计数如何工作,我们将重新定义HasPtr,令其行为像指针一样,但我们不使用shared_ptr,而是设计自己的引用计数。

引用计数的工作方式如下:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
  • 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。

唯一的难题是确定在哪里存放引用计数。计数器不能直接作为HasPtr对象的成员。下面的例子说明了原因:

1
2
3
HasPtr p1("Hiya!");
HasPtr p2(p1);	// p1和p2指向相同的string
HasPtr p3(p1);	// p1、p2和p3指向相同的string

如果引用计数保存在每个对象中,当创建p3时我们应该如何正确更新它呢?可以递增p1中的计数器并将其拷贝到p3中,但如何更新p2中的计数器呢?

解决此问题的一种方法是将计数器保存在动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器。

当拷贝或赋值一个HasPtr对象时,我们希望副本和原对象都指向相同的string。即,当拷贝一个HasPtr时,我们将拷贝ps本身,而不是ps指向的string。当我们进行拷贝时,还会递增该string关联的计数器。

(我们在类内定义的)拷贝构造函数拷贝给定HasPtr的所有三个数据成员。这个构造函数还递增use成员,指出ps和p.ps指向的string又有了一个新的用户。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class HasPtr {
public:
	friend void swap(HasPtr&, HasPtr&);

	// 构造函数
	HasPtr(const std::string& s = std::string()) :
		ps(new std::string(s)), i(0), use(new std::size_t(1)) {
		;
	}

	// 拷贝构造
	HasPtr(const HasPtr& p) :
		ps(new std::string(*p.ps)), i(p.i), use(p.use) {
		++*use;
	}

	// 赋值拷贝
	HasPtr& operator=(const HasPtr& p);

	// 析构
	~HasPtr() {
		delete ps;
	}

private:
	std::string* ps;
	int i;
	std::size_t* use;	// 用来记录有多少个对象共享*ps的成员
};

析构函数不能无条件地delete ps——可能还有其他对象指向这块内存。析构函数应该递减引用计数,指出共享string的对象少了一个。如果计数器变为0,则析构函数释放ps和use指向的内存:

1
2
3
4
5
6
HasPtr::~HasPtr() {
	if (--*this->use == 0) {
		delete this->use;
		delete this->ps;
	}
}

拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作。即,它必须递增右侧运算对象的引用计数(即,拷贝构造函数的工作),并递减左侧运算对象的引用计数,在必要时释放使用的内存(即,析构函数的工作)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
HasPtr& HasPtr::operator=(const HasPtr& rhs) {
	++* rhs.use;	// 增加右侧对象的引用计数

	// 递减本对象的引用计数
	if (--*this->use == 0) {
		// 本对象已经没有其他用户了
		delete this->ps;	
		delete this->use;
	}

	// 拷贝rhs的数据
	this->ps = rhs.ps;
	this->i = rhs.i;
	this->use = rhs.use;

	return *this;
}

13.3 交换操作

除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。对于那些与重排元素顺序的算法一起使用的类,定义swap是非常重要的。这类算法在需要交换两个元素时会调用swap。如果一个类定义了自己的swap,那么算法将使用类自定义版本。否则,算法将使用标准库定义的swap。

可以在我们的类上定义一个自己版本的swap来重载swap的默认行为。swap的典型实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class HasPtr {
public:
	friend void swap(HasPtr&, HasPtr&);

	// 其他成员等定义

private:
	std::string* ps;
	int i;
};

inline 
void swap(HasPtr& lhs, HasPtr& rhs) {
	using std::swap;
	swap(lhs.ps, rhs.ps);	// 指针交换
	swap(lhs.i, rhs.i);		// int 成员交换
}

我们首先将swap定义为friend,以便能访问HasPtr的(private的)数据成员。由于swap的存在就是为了优化代码,我们将其声明为inline函数。swap的函数体对给定对象的每个数据成员调用swap。我们首先swap绑定到rhs和lhs的对象的指针成员,然后是int成员。

与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。

swap函数应该调用swap,而不是std::swap:

1
2
3
4
5
void swap(Foo& lhs, Foo& rhs) {
	using std::swap;
	swap(lhs.h, rhs.h);	// 使用HasPtr版本的swap
	// 交换类型Foo的其他成员
}

每个swap调用应该都是未加限定的。即,每个调用都应该是swap,而不是std::swap。如果存在类型特定的swap版本,其匹配程度会优于std中定义的版本。因此,如果存在类型特定的swap版本,swap调用会与之匹配。如果不存在类型特定的版本,则会使用std中的版本(假定作用域中有using声明)。

非常仔细的读者可能会奇怪为什么swap函数中的using声明没有隐藏HasPtr版本swap的声明。我们将在后面解释为什么这段代码能正常工作。

定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换(copy and swap)的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:

1
2
3
4
5
6
// 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs) {
	// 交换左侧运算对象和局部变量rhs的内容
	swap(*this, rhs);	// rhs现在执行本对象曾经使用的内存
	return *this;		// rhs被销毁,从而delete了rhs中的指针
}

在这个版本的赋值运算符中,参数并不是一个引用,我们将右侧运算对象以传值方式传递给了赋值运算符。因此,rhs是右侧运算对象的一个副本。参数传递时拷贝HasPtr的操作会分配该对象的string的一个新副本。

在赋值运算符的函数体中,我们调用swap来交换rhs和*this中的数据成员。这个调用将左侧运算对象中原来保存的指针存入rhs中,并将rhs中原来的指针存入*this中。因此,在swap调用之后,*this中的指针成员将指向新分配的string——右侧运算对象中string的一个副本。当赋值运算符结束时,rhs被销毁,HasPtr的析构函数将执行。此析构函数delete rhs现在指向的内存,即,释放掉左侧运算对象中原来的内存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class HasPtr {
public:
	friend void swap(HasPtr&, HasPtr&);

	// 构造函数
	HasPtr(const std::string& s = std::string()) :
		ps(new std::string(s)), i(0), use(new std::size_t(1)) {
		;
	}

	// 拷贝构造
	HasPtr(const HasPtr& p) :
		ps(new std::string(*p.ps)), i(p.i), use(p.use) {
		++*use;
	}

	// 赋值拷贝
	HasPtr& operator=(HasPtr p);

	// 析构
	~HasPtr();

private:
	std::string* ps;
	int i;
	std::size_t* use;	// 用来记录有多少个对象共享*ps的成员
};

inline 
void swap(HasPtr& lhs, HasPtr& rhs) {
	using std::swap;
	swap(lhs.ps, rhs.ps);	// 指针交换
	swap(lhs.i, rhs.i);		// int 成员交换
	swap(lhs.use, rhs.use);	// int 成员交换
}

// 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs) {
	using std::swap;
	// 交换左侧运算对象和局部变量rhs的内容
	swap(*this, rhs);	// rhs现在执行本对象曾经使用的内存
	return *this;		// rhs被销毁,从而delete了rhs中的指针
}

HasPtr::~HasPtr() {
	if (--*this->use == 0) {
		delete this->use;
		delete this->ps;
	}
}

这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的。它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确,这与我们在原来的赋值运算符中使用的方法是一致的。它保证异常安全的方法也与原来的赋值运算符实现一样。代码中唯一可能抛出异常的是拷贝构造函数中的new表达式。如果真发生了异常,它也会在我们改变左侧运算对象之前发生。使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

13.4 拷贝控制示例

作为类需要拷贝控制来进行簿记操作的例子,我们将概述两个类的设计,这两个类可能用于邮件处理应用中。两个类命名为Message和Folder,分别表示电子邮件(或者其他类型的)消息和消息目录。每个Message对象可以出现在多个Folder中。但是,任意给定的Message的内容只有一个副本。这样,如果一条Message的内容被改变,则我们从它所在的任何Folder来浏览此Message时,都会看到改变后的内容。为了记录Message位于哪些Folder中,每个Message都会保存一个它所在Folder的指针的set,同样的,每个Folder都保存一个它包含的Message的指针的set。

  • 我们的Message类会提供save和remove操作,来向一个给定Folder添加一条Message或是从中删除一条Message。为了创建一个新的Message,我们会指明消息内容,但不会指出Folder。为了将一条Message放到一个特定Folder中,我们必须调用save。
  • 当我们拷贝一个Message时,副本和原对象将是不同的Message对象,但两个Message都出现在相同的Folder中。因此,拷贝Message的操作包括消息内容和Folder指针set的拷贝。而且,我们必须在每个包含此消息的Folder中都添加一个指向新创建的Message的指针。
  • 当我们销毁一个Message时,它将不复存在。因此,我们必须从包含此消息的所有Folder中删除指向此Message的指针。
  • 当我们将一个Message对象赋予另一个Message对象时,左侧Message的内容会被右侧Message的内容所替代。我们还必须更新Folder集合,从原来包含左侧Message的Folder中将它删除,并将它添加到包含右侧Message的Folder中。

观察这些操作,我们可以看到,析构函数和拷贝赋值运算符都必须从包含一条Message的所有Folder中删除它。类似的,拷贝构造函数和拷贝赋值运算符都要将一个Message添加到给定的一组Folder中。我们将定义两个private的工具函数来完成这些工作。

拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private的工具函数中完成。

Message类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Folder;

class Message {
	friend void swap(Message&, Message&);
	friend class Folder;
public:
    // folders is implicitly initialized to the empty set 
    explicit Message(const std::string &str = ""): 
		contents(str) { }  

    // copy control to manage pointers to this Message
    Message(const Message&);             // copy constructor
    Message& operator=(const Message&);  // copy assignment
    ~Message();                          // destructor
    Message(Message&&);            // move constructor
    Message& operator=(Message&&); // move assignment

    // add/remove this Message from the specified Folder's set of messages
    void save(Folder&);   
    void remove(Folder&); 
    void debug_print(); // print contents and it's list of Folders, 
                        // printing each Folder as well
private:
    std::string contents;      // actual message text
    std::set<Folder*> folders; // Folders that have this Message

    // utility functions used by copy constructor, assignment, and destructor
    // add this Message to the Folders that point to the parameter
    void add_to_Folders(const Message&);
	void move_Folders(Message*);
    // remove this Message from every Folder in folders
    void remove_from_Folders(); 

    // used by Folder class to add self to this Message's set of Folder's
    void addFldr(Folder *f) { folders.insert(f); }
    void remFldr(Folder *f) { folders.erase(f); }
};
// declaration for swap should be in the same header as Message itself
void swap(Message&, Message&);

Folder类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Folder {
	friend void swap(Message&, Message&);
	friend class Message;
public:
    ~Folder(); // remove self from Messages in msgs
    Folder(const Folder&); // add new folder to each Message in msgs
    Folder& operator=(const Folder&); // delete Folder from lhs messages
                                      // add Folder to rhs messages
    Folder(Folder&&);   // move Messages to this Folder 
    Folder& operator=(Folder&&); // delete Folder from lhs messages
                                 // add Folder to rhs messages
    Folder() = default; // defaults ok

    void save(Message&);   // add this message to folder
    void remove(Message&); // remove this message from this folder
    
    void debug_print(); // print contents and it's list of Folders, 
private:
    std::set<Message*> msgs;  // messages in this folder

    void add_to_Messages(const Folder&);// add this Folder to each Message
    void remove_from_Msgs();     // remove this Folder from each Message
    void addMsg(Message *m) { msgs.insert(m); }
    void remMsg(Message *m) { msgs.erase(m); }
	void move_Messages(Folder*); // move Message pointers to point to this Folder
};

13.5 动态内存管理类

13.6 对象移动

0%