少女祈祷中...

开场白

一想到这期的封面能放我的早苗,还是尽快赶出来了。

第三周的内容堪比前两周的总和,记录了三个章节的内容,是 C++ 学习很重要的一个part,所以篇幅的开销是巨大的(不过后面的章节也不会太少啦)。

从进度看来,42天完成整本书的任务应是太难,需要更多的时间。

最近一直在单曲循环一首写王安石王相公的歌曲感动至极,也在这里分享。

《诗出有名·王安石荆公篇》

世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。

(👉 ^ v ^ )👉

继续加油吧~

Week 1: 潜龙勿用

点击跳转第一期内容:潜龙勿用

Week 2: 见龙在田

点击跳转第二期内容:见龙在田

Week 3: 君子终日乾乾

类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。

定义抽象数据类型

这里以Sales_data类为例。

设计Sales_data

Sales_data的接口应该包含以下操作:

  • 一个isbn成员函数,用于返回对象的ISBN编号。
  • 一个combine成员函数,用于将一个Sales_data对象加到另一个对象上。
  • 一个名为add的函数,执行两个Sales_data对象的加法。
  • 一个read 函数,将数据从istream读入到Sales_data对象中。
  • 一个print函数,将Sales_data对象的值输出到ostream

预期使用这些接口函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Sales_data total;								//保存当前求和结果和变量
if (read(cin, total)) { //读入第一笔交易
Sales_data trans; //保存下一条交易数据的变量
while (read(cin, trans)) { //读入剩余的交易
if (total.isbn() == trans.isbn()) //检查isbn
total.combine(trans); //更新变量total当前的值
else {
print(cout, total) << endl; //输出结果
total = trans; //处理下一本书
}
}
print(cout, total) << endl; //输出最后一笔交易
} else { //没有任何输入信息
cerr << "No data?!" << endl; //通知用户
}

定义改进的Sales_data类

定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,例如 addreadprint等,它们的定义和声明都在类的外部。

avg_price函数用于求出售书籍的平均价格,目的并非通用,所以属于类的实现的一部分,而非接口的一部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Sales_data {
//关于Sales_data对象的操作
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const; //这个const保证不改变类的成员
//数据成员
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
//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&);

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

定义成员函数

尽管所有成员必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。

引入this

成员函数通过一个名为this的额外的隐式参数来访问调用它的对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。例如,如果调用total.isbn(),则编译器负责把total的地址传给isbn的隐式形参this

在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this所指的正是这个对象。任何对类成员的直接访问都被看作this 的隐式引用,也就是说,当isbn使用bookNo时,它隐式地使用this指向的成员,就像我们书写了 this->bookNo 一样。

对于我们来说,this形参是隐式定义的。实际上,任何自定义名为this的参数或变量的行为都是非法的。我们可以在成员函数体内部使用this,因此尽管没有必要,但我们还是能把isbn定义成如下的形式:

1
std::string isbn() const { return this->bookNo; }
引入const成员函数

isbn函数的另一个关键之处是紧随参数列表之后的 const 关键字,这里,const的作用是修改隐式this指针的类型。

默认情况下,this的类型是指向类类型非常量对象的常量指针。尽管this是隐式的,仍遵循初始化规则,意味着我们不能把一个指向非常量的指针this绑定到一个常量对象上。也即不能再一个常量对象上调用普通的成员函数。

然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题。C++ 语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示 this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数(const member function)。

因为this是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。在上例中,isbn可以读取调用它的对象的数据成员,但是不能写入新值。

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

类作用域和成员函数

编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

在类的外部定义成员函数

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

1
2
3
4
5
6
double Sales_data::avg_price() const {
if (units_sold)
return revenue / units_sold;
else
return 0;
}

函数名Sales data::avg price使用作用域运算符来说明如下的事实:

我们定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的。因此,当avg_price使用 revenue 和 units _sold 时,实际上它隐式地使用了Sales_data的成员。

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

函数combine的设计初衷类似于复合赋值运算符 += ,调用该函数的对象代表左侧的运算对象,右侧的运算对象则通过显式的实参被传入函数:

1
2
3
4
5
Sales_data& Sales_data::combine(const Sales_data &rhs){
units_sold += rhs.units_sold; //把rhs的成员加到this对象的成员上
revenue += rhs.revenue;
return *this; //返回调用该函数的对象
}

当我们的交易处理程序调用如下的函数时:

1
total.combine(trans);		//更新变量total当前的值

total的地址被绑定到隐式的this参数上,而rhs绑定到trans上。

该函数一个值得关注的部分是它的返回类型和返回语句。一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。因为此时的左侧运算对象是一个Sales_data的对象,所以返回类型应该是 Sales_data&

1
return *this;			//返回调用该函数的对象

其中,return语句解引用this指针以获得执行该函数的对象,也即返回total的引用。

定义类相关的非成员函数

类的作者常常需要定义一些辅助函数,比如addreadprint等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。

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

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

定义read和print函数
1
2
3
4
5
6
7
8
9
10
11
12
//输入的交易信息包括ISBN、售出总数和售出价格
istream &read(istream &is, Sales_data &item){
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item){
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}

read函数从给定流中将数据读到给定的对象里,print函数则负责将给定对象的内容打印到给定的流中。

除此之外,关于上面的函数还有两点是非常重要的:

第一点,readprint 分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。

第二点,print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。

定义add函数

add函数接受两个Sales_data对象作为其参数,返回值是一个新的Sales_data,用于表示前两个对象的和:

1
2
3
4
5
Sales_data add(const Sales_data &lhs, const Sales_data &rhs){
Sales_data sum = lhs; //把lhs的数据成员拷贝给sum
sum.combine(rhs); //把rhs的数据成员加到sum当中
return sum; //返回sum的副本
}

默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。

构造函数

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

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

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

合成的默认构造函数

类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。

默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。

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

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

因为Sales_dataunits_soldrevenue提供了初始值,所以合成的默认构造函数将使用这些值来初始化对应的成员;同时,它把 bookNo默认初始化成一个空字符串。

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

有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。

定义Sales_data的构造函数

对于我们的Sales_data类来说,我们将使用下面的参数定义4个不同的构造函数:

  • 一个istream&,从中读取一条交易信息。
  • 一个const string&,表示ISBN编号;一个unsigned,表示售出的图书数量;以及一个double,表示图书的售出价格。
  • 一个const string&,表示ISBN编号;编译器将赋予其他成员默认值。
  • 一个空参数列表(即默认构造函数),正如刚刚介绍的,既然我们已经定义了其他构造函数,那么也必须定义一个默认构造函数。

给类添加了这些成员之后,将得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Sales_data {
//新增的构造函数
Sales_data() = 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) { }
Sales_data(std::istream &);
//之前已有的其他成员
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
= default 的含义

以上的默认构造函数:

1
Sales_data() = default;

我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。

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

Warning: 上面的默认构造函数之所以对Sales_data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表(下面介绍)来初始化类的每个成员。

构造函数初始值列表

接下来我们介绍类中定义的另外两个构造函数:

1
2
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),它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。

含有三个参数的构造函数分别使用它的前两个参数初始化成员bookNounits_soldrevenue的初始值则通过将售出图书总数和每本书单价相乘计算得到。

只有一个string类型参数的构造函数使用这个string对象初始化bookNo,对于units_soldrevenue则没有显式地初始化。当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。在此例中,这样的成员使用类内初始值初始化,因此只接受一个string参数的构造函数等价于:

1
2
//与上面定义的那个构造函数效果相同
Sales_data(const std::string &s) : bookNo(s), units_sold(0), revenue(0) { }

通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。

不过,如果使用的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

有一点需要注意,在上面的两个构造函数中函数体都是空的。这是因为这些构造函数的唯一目的就是为数据成员赋初值,一旦没有其他任务需要执行,函数体也就为空了。

在类的外部定义函数

与其他几个构造函数不同,以istream为参数的构造函数需要执行一些实际的操作在它的函数体内,调用了read函数以给数据成员赋以初值:

1
2
3
Sale_data::Sales_data (std::istream &is){
read(is, *this); //read函数的作用是从is中读取一条交易信息并存入this对象
}

这个构造函数没有构造函数初始值列表,或者讲得更准确一点,它的构造函数初始值列表是空的。尽管构造函数初始值列表是空的,但是由于执行了构造函数体,所以对象的成员仍然能被初始化。

访问控制与封装

到目前为止,我们已经为类定义了接口,但并没有任何机制强制用户使用这些接口。我们的类还没有封装,也就是说,用户可以直达Sales_data对象的内部并且控制它的具体实现细节。在 C++ 语言中,我们使用访问说明符(access specifiers)加强类的封装性:

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

再一次定义Sales_data类,其新形式如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Sales_data {
public: //添加了访问说明符
Sales_data() = 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) { }
Sales_data(std::istream &);
Sales_data& combine(const Sales_data&);
std::string isbn() const { return bookNo; }
private: //添加了访问说明符
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};

作为接口的一部分,构造函数和部分成员函数(即isbncombine)紧跟在public说明符之后;而数据成员和作为实现部分的函数则跟在private说明符后面。

一个类可以包含0个或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下个访问说明符或者到达类的结尾处为止。

使用class或struct关键字

在上面的定义中我们还做了一个微妙的变化:我们使用了class关键字而非struct开始类的定义。这种变化仅仅是形式上有所不同,实际上我们可以使用这两个关键字中的任何一个定义类。唯一的区别是,structclass 的默认访问权限不太一样。

类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的;相反,如果我们使用class关键字,则这些成员是private的。

使用class和struct定义类唯一的区别就是默认的访问权限。

友元

既然Sales_data的数据成员是private的,我们的readprintadd函数也就无法正常编译了,这是因为尽管这几个函数是类的接口的一部分,但它们不是类的成员。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Sales_data {
//为Sales_data的非成员函数所做的友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
friend std::istream &read(std::istream&, Sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&);
//其他成员及访问说明符不变
public:
Sales_data() = 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) { }
Sales_data(std::istream &);
Sales_data& combine(const Sales_data&);
std::string isbn() const { return bookNo; }
private:
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
//Sales_data接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);

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

类调用友元

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

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

类的其他特性

以下是Sales_data没有体现出来的一些类的特性。为了展示这些新的特性,我们需要定义一对相互关联的类,它们分别是ScreenWindow_mgr

定义类型成员

Screen表示显示器中的一个窗口。每个Screen包含一个用于保存Screen内容的string成员和三个string::size_type类型的成员,它们分别表示光标的位置以及屏幕的高和宽。

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

1
2
3
4
5
6
7
8
class Screen {
public:
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};

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

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

1
2
3
4
5
6
class Screen {
public:
//使用类型别名等价地声明一个类型名字
typedef std::string::size_type pos;
//其他成员与之前一致
};

其次,用来定义类型的成员必须先定义后使用。因此,类型成员通常出现在类开始的地方。

令成员作为内联函数

在类中,常有一些规模较小的函数适合于被声明成内联函数。如我们之前所见的,定义在类内部的成员函数是自动inline的。因此,Screen的构造函数和返回光标所指字符的get函数默认是inline函数。

我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义:

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
class Screen {
public:
typedef std::string::size_type pos;
Screen() = default; //因为有另一个构造函数,所以需要写此函数
//cursor被其类内初始值初始化为0
Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) { }
char get() const { return contents[cursor]; } //隐式内联,读取光标处的字符
inline char get(pos ht, pos wd) const; //显式内联
Screen &move(pos r, pos c); //在外部被设为内联
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
char Screen:get(pos r, pos c) const //在类的内部声明成内联
{
pos row = r * width; //计算行的位置
return contents[row + c]; //返回给定列的字符
}
inline Screen &Screen::move(pos r, pos c) //在函数的定义处指定inline
{
pos row = r * width; //计算行的位置
cursor = row + c; //在行内将光标移动到指定的列
return *this; //以左值形式返回对象
}

在声明和定义处同时使用inline是合法的,但最好只在类的外部定义处说明inline,方便理解。

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

可变数据成员

有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。

一个可变数据成员(mutable data member)永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。举个例子,我们将给Screen添加一个名为 access_ctr的可变成员,通过它我们可以追踪每个Screen的成员函数被调用了多少次:

1
2
3
4
5
6
7
8
9
10
11
12
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; //即使在一个const对象内也能被修改
//其他成员与之前的版本一致
};
void Screen::some_member() const
{
++access_ctr; //保存一个计数值,用于记录成员函数被调用的次数
//该成员需要完成的其他工作
}

尽管some_member是一个const成员函数,它仍然能够改变access_ctr的值。该成员是个可变成员,因此任何成员函数,包括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, ' ')};
};

当我们初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。在此例中,我们使用一个单独的元素值对vector成员执行了列表初始化,这个Screen的值被传递给vector<Screen>的构造函数,从而创建了一个单元素的vector对象。具体地说,Screen的构造函数接受两个尺寸参数和一个字符值,创建了一个给定大小的空白屏幕对象。

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

连续执行的操作

可以通过返回*this(返回引用),把一系列操作连接在一条表达式中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Screen {
public:
Screen &set(char);
Screen &set(pos, pos, char);
//其他成员和之前版本一致
};
inline Screen &Screen::set(char c) {
contents[cursor] = c; //设置当前光标所在位置的新值
return *this; //将this对象作为左值返回
}
inline Screen &Screen::set(pos r, pos col, char ch){
contents[ r * width + col ] = ch; //设置给定位置的新值
return *this; //将this对象作为左值返回
}

//给光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4,0).set('#');

基于const的重载

接下来,我们继续添加一个名为display的操作,它负责打印Screen的内容。我们希望这个函数能和move以及set出现在同一序列中,因此类似于movesetdisplay函数也应该返回执行它的对象的引用。

从逻辑上来说,显示一个Screen并不需要改变它的内容,因此我们令display为一个const成员,此时,this将是一个指向const的指针而*thisconst对象。由此推断,display的返回类型应该是const Sales_data&。然而,如果真的令display返回一个const的引用,则我们将不能把display嵌入到一组动作的序列中去:

1
2
3
Screen myScreen;
//如果display返回常量引用,则调用set将引发错误
myScreen.display(cout).set('*');

即使myScreen是个非常量对象,对set的调用也无法通过编译。问题在于displayconst版本返回的是常量引用,而我们显然无权set一个常量对象。

通过区分成员函数是否是const的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const而重载函数的原因差不多。因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。

在下面的这个例子中,我们将定义一个名为do_display的私有成员,由它负责打印Screen的实际工作。所有的display操作都将调用这个函数,然后返回执行操作的对象:

1
2
3
4
5
6
7
8
9
10
11
12
class Screen {
public:
//根据对象是否是const重载了display函数
Screen &display(std::ostream &os)
{ do_display(os); return *this; }
const Screen &display(std::ostream &os) const
{ do_display(os); return *this; }
private:
//该函数负责显示Screen的内容
void do_display(std::ostream &os) const { os << contents; }
//其他函数与之前一致
};

和我们之前所学的一样,当一个成员调用另外一个成员时,this指针在其中隐式地传递。因此,当display调用do_display 时,它的this指针隐式地传递给do_display。而当display的非常量版本调用do_display 时,它的this指针将隐式地从指向非常量的指针转换成指向常量的指针。

do_display 完成后,display函数各自返回解引用this所得的对象。在非常量版本中,this指向一个非常量对象,因此display返回一个普通的(非常量)引用;而const成员则返回一个常量引用。

当我们在某个对象上调用display时,该对象是否是const决定了应该调用display的哪个版本:

1
2
3
4
Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); //调用非常量版本
blank.display(cout); //调用常量版本

类的声明(不完全类型)

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

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;
};

友元再探

我们的Sales_data类把三个普通的非成员函数定义成了友元。类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。

此外,友元函数能定义在类的内部,这样的函数是隐式内联的。但这里的水很深,别这么干。

类之间的友元关系

举个友元类的例子,我们的Window_mgr的某些成员可能需要访问它管理的Screen类的内部数据。例如,假设我们需要为Window_mgr添加一个名为clear的成员,它负责把一个指定的Screen的内容都设为空白。为了完成这一任务,clear需要访问Screen的私有成员;而要想令这种访问合法,Screen需要把Window_mgr指定成它的友元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Screen {
//Window_mgr的成员可以访问Screen类的私有部分
friend class Window_mgr;
//Screen类的剩余部分
};

class Window_mgr {
public:
//窗口中每个屏幕的编号
using ScreenIndex = std::vector<Screen>::size_type;
//按照编号将指定的Screen重置为空白
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
void Window_mgr::clear(ScreenIndex i){
//s是一个Screen的引用,指向我们想清空的那个屏幕
Screen &s = screens[i];
//计算出一个新的string对象,将选定的屏幕重置为空白
s.contents = string(s.height * s.width, ' ');
}

如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。

友元关系不具有传递性。每个类负责控制自己的友元类或友元函数。

令成员函数作为友元

除了令整个Window_mgr作为友元之外,Screen还可以只为clear提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:

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

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

  • 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen
  • 接下来定义Screen,包括对于clear的友元声明。
  • 最后定义clear,此时它才可以使用Screen的成员。

类和非成员函数的声明不是必须在它们的友元声明之前,当一个名字的第一次出现是在友元声明中时,我们假定其在当前作用域可见。但是,友元声明不是声明,当出现了需要事先声明时却仅采用了友元声明的情况,程序可能出错(由编译器决定)。

类的作用域

指明返回类型的归属

一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员的名字被隐藏起来了。

一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权了。

函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。例如,我们可能向Window_mgr类添加一个新的名为addScreen的函数,它负责向显示器添加一个新的屏幕。这个成员的返回类型将是ScreenIndex,用户可以通过它定位到指定的Screen

1
2
3
4
5
6
7
8
9
10
11
12
class Window_mgr {
public:
//向窗口添加一个Screen,返回它的编号
ScreenIndex addScreen(const Screen&);
//其他成员与之前一致
};
//首先处理返回类型,之后我们才进入Window_mgr的作用域
Window_mgr::ScreenIndex
Window_mgr::addScreen(const Screen &s){
screens.push_back(s);
return screens.size() - 1;
}

因为返回类型出现在类名之前,所以事实上它是位于 Window_mgr类的作用域之外的。在这种情况下,要想使用ScreenIndex作为返回类型,我们必须明确指定哪个类定义了它。

类型名的特殊处理

编译器处理完类中的全部声明后才会处理成员函数的定义。

但是,这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。例如:

1
2
3
4
5
6
7
8
typedef double Money;
string bal;
class Account {
public:
Money balance() { return bal; }
private:
Money bal;
};

当编译器看到balance函数的声明语句时,它将在Account类的范围内寻找对Money的声明。编译器只考虑Account中在使用Money前出现的声明,因为没找到匹配的成员,所以编译器会接着到Account的外层作用域中查找。在这个例子中,编译器会找到Moneytypedef语句,该类型被用作balance函数的返回类型以及数据成员bal的类型。另一方面,balance函数体在整个类可见后才被处理,因此,该函数的return语句返回名为bal的成员,而非外层作用域的string对象。

一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表种类型,则类不能在之后重新定义该名字:

1
2
3
4
5
6
7
8
typedef double Money;
class Account {
public:
Money balance() { return bal; } //使用外层作用域的Money
private:
typedef double Money; //错误,不能重新定义Money
Money bal;
};

Tip: 类型名的定义应该放在类的开始处,确保所有使用该类型的成员都出现在这之后。

区分成员名字和参数名字

不建议使用其他成员的名字作为某个成员函数的参数。

1
2
3
4
5
6
7
8
9
10
11
int height;								//定义了一个名字,在本小节末尾用到
class Screen {
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height) {
cursor = width * height; //对应哪个height? 其实是参数height
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};

当编译器处理dummy_fcn中的乘法表达式时,它首先在函数作用域内查找表达式中用到的名字。函数的参数位于函数作用域内,因此dummy_fcn函数体内用到的名字height指的是参数声明。

在此例中,height参数隐藏了同名的成员。如果想绕开上面的查找规则,应该将代码变为:

1
2
3
4
5
6
7
8
9
10
11
12
class Screen {
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height) {
cursor = width * this->height; //对应成员height
//另一种表示该成员方式
curosr = width * Screen::height; //对应成员height
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};

尽管类的成员被隐藏了,但我们仍然可以通过加上类的名字或显式地使用this指针来强制访问成员。

但尽量规避这种情况,成员函数中的名字不要隐藏同名的成员。

如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。在我们的例子中,名字height定义在外层作用域中,且位于screen的定义之前。然而,外层作用域中的对象被名为height的成员隐藏掉了。因此,如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求:

1
2
3
void Screen::dummy_fcn(pos height) {
cursor = width * ::height; //对应全局的height
}

但尽量规避这种情况,不要隐藏外层作用域可能用到的名字。

考虑成员函数定义之前的全局作用域

当成员定义在类的外部时,名字查找不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。例如:

1
2
3
4
5
6
7
8
9
class Screen {
public:
typedef std::string::size_type pos;
void setHeight(pos);
};
Screen::pos verify(Screen::pos); //这是个全局函数的声明
void Screen::setHeight(pos var) {
height = verify(var);
}

请注意,全局函数verify的声明在Screen类的定义之前是不可见的。然而,名字查找包括了成员函数出现之前的全局作用域。在此例中,verify的声明位于setHeight的定义之前,因此可以被正常使用。

构造函数再探

初始值列表

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

成员初始化顺序

构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。

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

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

1
2
3
4
5
6
7
class X {
int i;
int j;
public:
//未定义的:i在j之前被初始化
X(int val) : j(val), i(j) { }
};

最好令构造函数初始值与成员声明顺序保持一致。

默认实参

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

1
2
3
4
5
class Sales_data {
public:
//定义默认构造函数,令其与只接受一个string实参的构造函数功能相同
Sales_data(std::string s = "") : bookNo(s) { }
};

委托构造函数

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() : Sales_data("", 0, 0) { }
Sales_data(std::string s) : Sales_data(s, 0, 0) { }
Sales_data(std::istream &is) : Sales_data() { read(is, *this); }
//其他成员与之前一致
};

在这个Sales_data类中,除了一个构造函数外其他的都委托了它们的工作。第一个构造函数接受三个实参,使用这些实参初始化数据成员,然后结束工作。

我们定义默认构造函数,其委托三参数的构造函数完成初始化过程,它也无须执行其他任务,这一点从空的构造函数体能看得出来。

接受一个string的构造函数同样委托给了三参数的版本。

接受istream&的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。当这些受委托的构造函数执行完后,接着执行istream&构造函数体的内容。它的构造函数体调用read函数读取给定的istream

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在Sales_data类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。

使用默认构造函数

下面的obj的声明可以正常编译通过:

1
2
Sales_data obj();							//正确,定义了一个函数而非对象
if (obj.isbn() == Primer_5th_ed.isbn() ) //错误,obj是一个函数

但当我们试图使用obj时,编译器将报错,提示我们不能对函数使用成员访问运算符问题在于,尽管我们想声明一个默认初始化的对象,obj实际的含义却是一个不接受任何参数的函数并且其返回值是Sales_data类型的对象。

如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对:

1
Sales_data obj;					//正确,obj是个默认初始化的对象

隐式的类型转换

之前曾经介绍过 C++ 语言在内置类型之间定义了几种自动转换规则。同样的,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。

即能通过一个实参调用的构造函数定义一条从该参数类型向类类型隐式转换的规则。

Sales_data类中,接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,我们可以使用string或者istream作为替代:

1
2
3
4
string null_book = "9-999-99999-9";
//构造一个临时的Sales_data对象
//该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(null_book);

在这里我们用一个string实参调用了Sales_datacombine成员。该调用是合法的,编译器用给定的string自动创建了一个Sales_data对象。新生成的这个(临时)Sales_data对象被传递给combine。因为combine的参数是一个常量引用,所以我们可以给该参数传递一个临时量。

Note: Luv实测,类似combine函数,如果使用的参数是引用,则必须指定为const,否则报错。

只允许一步类类型转换

因为历史原因以及为了与C语言兼容,字符串字面值与标准库string类型不是同一种类型。

编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的:

1
2
3
4
//错误:需要用户定义的两种转换
//(1)把"9-999-99999-9"转换成string
//(2)再把这个(临时的)string转换成Sales_data
item.combine("9-999-99999-9");

如果我们想完成上述调用,可以显式地把字符串转换成string或者Sales_data对象:

1
2
3
4
//正确,显式地转换成string,隐式地转换成Sales_data
item.combine(string("9-999-99999-9"));
//正确,隐式地转换成string,显式地转换成Sales_data
item.combine(Sales_data("9-999-99999-9"));
抑制构造函数定义的隐式转换

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

1
2
3
4
5
6
7
8
class Sales_data {
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p*n) { }
explicit Sales_data(const std::string &s) : bookNo(s) { }
explicit Sales_data(std::istream&);
//其他成员与之前一致
};

此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的用法都无法通过编译:

1
2
item.combine(null_book);		//错误,string构造函数是explicit的
item.combine(cin); //错误,istream构造函数是explicit的

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

1
2
3
4
//错误,explicit关键字只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is){
read(is, *this);
}

explicit构造函数只能用于直接初始化

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

1
2
3
Sales_data item1(null_book);	//正确,直接初始化
//错误,不能将explicit构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;
显式地使用构造函数进行转换

尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:

1
2
3
4
//正确,实参是一个显式构造的Sales_data对象
item.combine(Sales_data(null_book));
//正确,static_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(cin));

在第一个调用中,我们直接使用Sales_data的构造函数,该调用通过接受string的构造函数创建了一个临时的Sales_data对象。在第二个调用中,我们使用static_cast执行了显式的而非隐式的转换。其中static_cast使用istream构造函数创建了一个临时的Sales_data对象。

标准库中含有显式构造函数的类

我们用过的一些标准库中的类含有单参数的构造函数:

  • 接受一个单参数的const char*的string构造函数不是explicit的。
  • 接受一个容量参数的vector构造函数是explicit的。

聚合类

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

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有virtual函数(在很后面介绍)。

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

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

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

1
2
//vall.ival = 0; vall.s = string("Anna")
Data val1 = { 0, "Anna" };

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

1
2
//错误,不能使用"anna"初始化ival,也不能使用1024初始化s
Data val2 = { "Anna", 1024 };

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

值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:

  • 要求类的所有成员都是public的。
  • 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
  • 添加或删除一个成员之后,所有的初始化语句都需要更新。

字面值常量类

我们提到过constexpr函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。

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

而如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

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

constexpr构造函数

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

constexpr构造函数可以声明成 = default 的形式或者是删除函数的形式(这个后面讲)。否则,constexpr构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr构造函数体一般来说应该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Debug {
public:
constexpr Debug(bool b = true) : hw(b), io(b), other(b) { }
constexpr Debug(bool h, bool i, bool o) :
hw(h), io(i), other(o) { }
constexpr bool any() { return hw || io || other; }
void set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { other = b; }
private:
bool hw; //硬件错误,而非IO错误
bool io; //IO错误
bool other; //其他错误
};

constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式。

constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型:

1
2
3
4
5
6
constexpr Debug io_sub(false, true, false);			//调试IO
if (io_sub.any()) //等价于if(true)
cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false); //无调试
if (io_sub.any()) //等价于if(false)
cerr << "print an error message" << endl;

类的静态成员

有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。

声明静态成员

我们通过在成员的声明之前加上关键字static使得其与类关联在一起。和其他成员一样,静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。

举个例子,我们定义一个类,用它表示银行的账户记录:

1
2
3
4
5
6
7
8
9
10
11
class Account {
public:
void calculate() { amount += amount * interestRate; }
static double rate() { return interestRate; }
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};

类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,每个Account对象将包含两个数据成员:owneramount。只存在一个interestRate对象而且它被所有Account对象共享。

类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针。这一限制既适用于this的显式使用,也对调用非静态成员的隐式使用有效。

使用类的静态成员

我们使用作用域运算符直接访问静态成员:

1
2
double r;
r = Account::rate(); //使用作用域运算符访问静态成员

虽然静态成员不属于类的某个对象,但是我们仍然能够使用类的对象、引用或指针来访问静态成员:

1
2
3
4
5
Account ac1;
Account *ac2 = &ac1;
//调用静态成员函数rate的等价形式
r = ac1.rate(); //通过Account的对象或引用
r = ac2->rate(); //通过指向Account对象的指针

成员函数使用静态成员不需要通过作用域运算符:

1
2
3
4
5
6
7
class Account {
public:
void calculate() { amount == amount * interestRate; }
private:
static double interestRate;
//其他成员与之前一致
};

定义静态成员

和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句:

1
2
3
4
void Account::rate(double newRate)
{
interestRate = newRate;
}

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

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

类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。

我们定义静态数据成员的方式和在类的外部定义成员函数差不多。我们需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字:

1
2
//定义并初始化一个静态成员
double Account::interestRate = initRate();

这条语句定义了名为 interestRate的对象,该对象是类Account的静态成员,其类型是double。从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,我们可以直接使用initRate函数。注意,虽然initRate是私有的,我们也能用它初始化interestRate。和其他成员的定义一样,interestRate的定义也可以访问类的私有成员。

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

静态成员的类内初始化

通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr(或const)。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。例如,我们可以用一个初始化了的静态数据成员指定数组成员的维度:

1
2
3
4
5
6
7
8
class Account {
public:
static double rate() { return interstRate; }
static void rate(double);
private:
static constexpr int period = 30; //period是常量表达式
double daily_tbl[period];
};

Note: LUV测试,如果此时period改为在外部定义、内部声明,编译会出错。

如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的constconstexpr static不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。

例如,如果period的唯一用途就是定义daily_tbl的维度,则不需要在Account外面专门定义period。此时,如果我们忽略了这个限制,那么对程序非常微小的改动也可能造成编译错误,因为程序找不到该成员的定义语句。举个例子,当需要把Account::period传递给一个接受const int&的函数时,必须定义period

如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了:

1
constexpr int Acocunt::period;			//初始值在类的定义内提供

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

静态成员的运用场景

如我们所见,静态成员独立于任何对象。因此,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:

1
2
3
4
5
6
7
8
class Bar {
public:
//...
private:
static Bar mem1; //正确,静态成员可以是不完全类型
Bar *mem2; //正确,指针成员可以是不完全类型
Bar mem3; //错误,数据成员必须是完全类型
};

静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参:

1
2
3
4
5
6
7
8
9
class Screen {
public:
//bkground表示一个稍后定义(可以在类内也可以在类外)的静态成员
Screen& clear(char = bkground);
private:
static const char bkground;
};

const int Screen::bkground = 'a';

非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。

IO库

IO类

到目前为止,我们已经使用过的IO类型和对象都是操纵char数据的。默认情况下这些对象都是关联到用户的控制台窗口的。当然,我们不能限制实际应用程序仅从控制窗口进行IO操作,应用程序常常需要读写命名文件。而且,使用IO操作处理string的字符会很方便。此外,应用程序还可能读写需要宽字符支持的语言。

为了支持这些不同种类的IO处理操作,在istreamostream之外,标准库还定义了其他一些IO类型,我们之前都已经使用过了。下表列出了这些类型,分别定义三个独立的头文件中:iostream定义了用于读写流的基本类型,fstream定义了读命名文件的类型,sstream定义了读写内存string对象的类型。

头文件 类型 用途
iostream istream, wistream 从流读取数据
iostream ostream, wostream 向流写入数据
iostream iostream, wiostream 读写流
fstream ifstream, wifstream 从文件读取数据
fstream ofstream, wofstream 向文件写入数据
fstream fstream, wfstream 读写文件
sstream istringstream, wistringstream string读取数据
sstream ostringstream, wostringstream string写入数据
sstream stringstream, wstringstream 读写string

IO类型间的关系

概念上,设备类型和字符大小都不会影响我们要执行的IO操作。例如,我们可以用 >> 读取数据,而不用管是从一个控制台窗口,一个磁盘文件,还是一个string读取。类似的,我们也不用管读取的字符能否存入一个char对象内,还是需要一个wchar_t对象来存储。

标准库使我们能忽略这些不同类型的流之间的差异,这是通过继承机制(inheritance)实现的。利用模板,我们可以使用具有继承关系的类,而不必解继承机制如何工作的细节。

简单地说,继承机制使我们可以声明一个特定的类继承自另一个类。我们通常可以将一个派生类(继承类)对象当作其基类(所继承的类)对象来使用

类型ifstreamistringstream都继承自istream。因此,我们可以像使用istream对象一样来使用ifstreamistringstream对象。也就是说,我们是如何使用cin的,就可以同样地使用这些类型的对象。例如,可以对一个ifstreamistringstream对象调用getline,也可以使用 >> 从一个ifstreamistringstream对象中读取数据。类似的,类型ofstreamostringstream都继承自ostream。因此,我们是如何使用cout的,就可以同样地使用这些类型的对象。

以下的标准库流特性都可以无差别地应用于普通流、文件流和string流,以及char或宽字符流版本。

IO对象无拷贝和赋值

不能拷贝或对IO对象赋值:

1
2
3
4
ofstream out1, out2;
out1 = out2; //错误,不能对流对象赋值
ofstream print(ofstream); //错误,不能将流直接设为函数的返回类型或参数(引用可以)
out2 = print(out2); //错误,不能拷贝流对象

由于不能拷贝IO对象,因此我们也不能将形参或返回类型设置为流类型。进行IO操作的函数通常以引用方式传递和返回流。读写一个IO对象会改变其状态,因此传递和返回的引用不能是const的。

条件状态

IO操作一个与生俱来的问题就是可能发生错误。一些错误是可恢复的,而其他错误则发生在系统深处,已经超出了应用程序可以修正的范围。下表列出了IO类所定义的一些函数和标志,可以帮助我们访问和操纵流的条件状态(condition state)。

strm是前一个表格中的某种IO类型。

IO库条件状态
strm :: iostate iostate是一种机器相关的类型,提供了表达条件状态的完整功能
strm :: badbit 用来指出流已崩溃
strm :: failbit 用来指出一个IO操作失败了
strm :: eofbit 用来指出流到达了文件结束
strm :: goodbit 用来指出流未处于错误状态(此时为0)
s.eof() 若流s的eofbit置位(置为1),则返回true
s.fail() 若流s的failbit或badbit置位,则返回true
s.bad() 若流s的badbit置位,则返回true
s.good() 若流处于有效状态,则返回true
s.clear() 将流中所有条件状态位复位,将流的状态设置为有效。返回void
s.clear(flags) 根据给定的flags标志位,将流s中对应的条件状态位复位。flags的类型为strm::iostate。返回void
s.setstate(flags) 根据给定的flags标志位,将流s中对应的条件状态位置位。flags的类型为strm::iostate。返回void
s.rdstate() 返回流s的当前条件状态,返回值类型为strm::iostate

一个流一旦发生错误,其后续的IO操作都会失败。

查询流的状态

IO库定义了一个与机器无关的iostate类型,它提供了表达流状态的完整功能。这个类型应作为一个位集合来使用。IO库定义了4iostate类型的constexpr值表示特定的位模式。这些值用来表示特定类型的IO条件,可以与位运算符一起使用来一次性检测或设置多个标志位。

badbit表示系统级错误,如不可恢复的读写错误。通常情况下,一旦badbit被置位,流就无法再使用了。

在发生可恢复错误后,failbit被置位,如期望读取数值却读出一个字符等错误。这种问题通常是可以修正的,流还可以继续使用。

如果到达文件结束位置,eofbitfailbit都会被置位。goodbit的值为0,表示流未发生错误。

如果badbitfailbiteofbit任一个被置位,则检测流状态的条件会失败。

标准库还定义了一组函数来查询这些标志位的状态。操作good在所有错误位均未置位的情况下返回true,而badfaileof则在对应错误位被置位时返回true。此外,在 badbit被置位时,fail也会返回true。这意味着,使用goodfail确定流的总体状态的正确方法。实际上,我们将流当作条件使用的代码就等价于 !fail() 。而eofbad操作只能表示特定的错误。

管理流的状态

流对象的rdstate成员返回一个iostate值,对应流的当前状态。setstate操作将给定条件位置位,表示发生了对应错误。clear成员是一个重载的成员:它有一个不接受参数的版本,而另一个版本接受一个iostate类型的参数。

clear不接受参数的版本清除(复位)所有错误标志位。执行clear()后,调用good会返回true。我们可以这样使用这些成员:

1
2
3
4
5
//记住cin的当前状态
auto old_state = cin.rdstate(); //记住cin的当前状态
cin.clear(); //使cin有效
process_input(cin); //使用cin
cin.setstate(old_state); //将cin置为原有状态

带参数的clear版本接受一个iostate值,表示流的新状态。为了复位单一的条件状态位,我们首先用rdstate读出当前条件状态,然后用位操作将所需位复位来生成新的状态。例如,下面的代码将failbitbadbit复位,但保持eofbit不变:

1
2
//复位failbit和badbit,保持其他标志位不变
cin.clear(cin.rdstate() & ~cin.failbit & ~cin.badbit);

Luv的补充:

看完书中这一段,并没有完全明白这一块的具体实现原理。再详细补充一些内容:

第一,是failbitbadbiteofbit三个标记位组成了流状态,而非failbitbadbiteofbitgoodbit这四个标记位。

第二,strm::failbitstrm::badbitstrm::eofbitstrm::goodbit (strm表示某个IO类,可以用ios替代)均为常量,各自表示一种流状态,称为“状态标记位常量”。

以上两点可结合为下表:

常量 含义 failbit标记位的值 eofbit标记位的值 badbit标记位的值 转化为10进制
strm::failbit 输入(输出)流出现非致命错误,可挽回 1 0 0 4
strm::eofbit 已经到达文件尾 0 1 0 2
strm::badbit 输入(输出)流出现致命错误,不可挽回 0 0 1 1
strm::goodbit 流状态完全正常 0 0 0 0

clear()函数作用是将流状态设置成括号内参数所代表的状态,强制覆盖掉流的原状态。

setstate()函数并不强制覆盖流的原状态,而是将括号内参数所代表的状态叠加到原始状态上。

管理输出缓冲

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

1
os << "please enter a value: ";

文本串可能立即打印出来,但也有可能被操作系统保存在缓冲区中,随后再打印。有了缓冲机制,操作系统就可以将程序的多个输出操作组合成单一的系统级写操作。由于设备的写操作可能很耗时,允许操作系统将多个输出操作组合为单一的设备写操作可以带来很大的性能提升。

导致缓冲刷新(即数据真正写到输出设备或文件)的原因有很多:

  • 程序正常结束,作为main函数的return操作的一部分,缓冲刷新被执行。
  • 缓冲区满时,需要刷新缓冲,而后新的数据才能继续写入缓冲区。
  • 我们可以使用操纵符如endl来显式刷新缓冲区。
  • 在每个输出操作之后,我们可以用操纵符unitbuf设置流的内部状态,来清空缓冲区。默认情况下,对cerr是设置unitbuf的,因此写到cerr的内容都是立即刷新的。
  • 一个输出流可能被关联到另一个流。在这种情况下,当读写被关联的流时,关联到的流的缓冲区会被刷新。例如,默认情况下,cincerr都关联到cout。因此,读cin或写cerr都会导致cout的缓冲区被刷新。
刷新输出缓冲区

我们已经使用过操纵符endl,它完成换行并刷新缓冲区的工作。IO库中还有两个类似的操纵符:flushendsflush刷新缓冲区,但不输出任何额外的字符;ends向缓冲区插入一个空字符,然后刷新缓冲区:

1
2
3
cout << "hi!" << endl;			//输出hi和一个换行,然后刷新缓冲区
cout << "hi!" << flush; //输出hi,然后刷新缓冲区,不附加任何额外字符
cout << "hi!" << ends; //输出hi和一个空字符,然后刷新缓冲区
unitbuf操纵符

如果想在每次输出操作后都刷新缓冲区,我们可以使用unitbuf操纵符。它告诉流在接下来的每次写操作之后都进行一次flush操作。而nounitbuf操纵符则重置流,使其恢复使用正常的系统管理的缓冲区刷新机制:

1
2
3
cout << unitbuf;				//所有输出操作后都会立即刷新缓冲区
//任何输出都立即刷新,无缓冲
cout << nonunitbuf; //回到正常的缓冲方式

如果程序崩溃,输出缓冲区不会被刷新。

关联输入流和输出流

当一个输入流被关联到一个输出流时,任何试图从输入流读取数据的操作都会先刷新关联的输出流。标准库将coutcin关联在一起,因此下面语句:

1
cin >> ival;

导致cout缓冲区被刷新。

一个流对象使用成员函数tie,可以用来为其绑定输出流,有两个重载的版本:

  • 不带参数,返回指向输出流的指针(当该对象未关联到流,返回空指针)。
  • 接受一个指向ostream的指针,将自己关联到此ostream。即,x.tie(&o)将流x关联到输出流o
1
2
3
4
5
//old_tie指向当前关联到cin的流(如果有的话)
ostream *old_tie = cin.tie(nullptr); //cin不再与其他流关联
//将cin与cerr关联
cin.tie(&cerr); //读取cin会刷新cerr而不是cout
cin.tie(old_tie); //重建cin和cout间的正常关联

文件输入输出

除了继承自iostream类型的行为之外,fstream中定义的类型还增加了一些新成员来管理与流关联的文件。在下表中列出了这些操作,我们可以对fstreamifstreamofstream对象调用这些操作,但不能对其他IO类型调用这些操作。

fstream特有的操作
fstream fstrm; 创建一个未绑定的文件流。fstream是头文件fstream中定义的一个类型
fstream fstrm(s); 创建一个fstream,并打开名为s的文件。s可以是string类型,或者是一个指向C风格字符串的指针。这些构造函数都是explicit的。默认的文件模式mode依赖于 fstream的类型
fstream fstrm(s, mode); 与前一个构造函数类似,但按指定的mode打开文件
fstrm.open(s) 打开名为s的文件,并将文件与fstrm绑定。s可以是一个string或一个指向C风格字符串的指针。默认的文件mode依赖于fstream的类型。返回void
fstrm.close() 关闭与fstrm绑定的文件。返回void
fstrm.is_open() 返回一个bool值,指出与fstrm关联的文件是否成功打开且尚未关闭

使用文件流对象

当我们想要读写一个文件时,可以定义一个文件流对象,并将对象与文件关联起来。每个文件流类都定义了一个名为open的成员函数,它完成一些系统相关的操作,来定位给定的文件,并视情况打开为读或写模式。

创建文件流对象时,我们可以提供文件名(可选的)。如果提供了一个文件名,则open会自动被调用:

1
2
ifstream in(ifile);				//构造一个
ostream out; //输出文件流未关联到任何文件

这段代码定义了一个输入流in,它被初始化为从文件读取数据,文件名由string类型的参数ifile指定。第二条语句定义了一个输出流out,未与任何文件关联。在新 C++ 标准中,文件名既可以是库类型string对象,也可以是C风格字符数组。旧版本的标准库只允许C风格字符数组。

用fstream代替iostream&

在要求使用基类型对象的地方,我们可以用继承类型的对象来替代。这意味着,接受一个iostream类型引用(或指针)参数的函数,可以用一个对应的fstream(或sstream)类型来调用。也就是说,如果有一个函数接受一个ostream&参数,我们在调用这个函数时,可以传递给它一个ofstream对象,对istream&ifstream也是类似的。

例如,我们可以用前面定义的的readprint函数来读写命名文件。在本例中,我们假定输入和输出文件的名字是通过传递给main函数的参数来指定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ifstream input(argv[1]);			//打开销售记录文件
ofstream output(argv[2]); //打开输出文件
Sales_data total; //保存销售总额的变量
if (read(input, total)) { //读取第一条销售记录
Sales_data trans; //保存下一条销售记录的变量
while(read(input, trans)) { //读取剩余记录
if (total.isbn() == trans.isbn()) //检查isbn
total.combine(trans); //更新销售总额
else {
print(output, total) << endl; //打印结果
total = trans; //处理下一本书
}
}
print(output, total) << endl; //打印最后一本书的销售额
} else //文件中无输入数据
cerr << "No data?!" << endl;

重要的部分是对readprint的调用。虽然两个函数定义时指定的形参分别是istream&ostream&,但我们可以向它们传递fstream对象。

成员函数open和close

如果我们定义了一个空文件流对象,可以随后调用open来将它与文件关联起来:

1
2
3
ifstream in(ifile);				//构筑一个ifstream并打开给定文件
ofstream out; //输出文件流未与任何文件相关联
out.open(ifile + ".copy"); //打开指定文件

如果调用open失败,failbit会被置位。因为调用open可能失败,进行open是否成功的检测通常是一个好习惯:

1
2
if (out)			//检查open是否成功
//open成功,则可以使用文件了

这个条件判断与我们之前将cin用作条件相似。

一旦一个文件流已经打开,它就保持与对应文件的关联。实际上,对一个已经打开的文件流调用open会失败,并会导致failbit被置位。随后的试图使用文件流的操作都会失败。为了将文件流关联到另外一个文件,必须首先关闭已经关联的文件。一旦文件成功关闭,我们可以打开新的文件:

1
2
in.close();				//关闭文件
in.open(ifile + "2"); //打开另一个文件

如果open成功,则open会设置流的状态,使得good()true

自动构造和析构

考虑这样一个程序,它的main函数接受一个要处理的文件列表。这种程序可能会有如下的循环:

1
2
3
4
5
6
7
8
//对每个传递给程序的文件执行循环操作
for (auto p = argv + 1; p != argv + argc; ++p ) {
ifstream inout(*p); //创建输出流并打开文件
if (input) { //如果文件打开成功,“处理”此文件
process(input);
} else
cerr << "couldn't open: " + string(*p);
} //每个循环步input都会离开作用域,因此会被销毁

每个循环步构造一个新的名为inputifstream对象,并打开它来读取给定的文件。像之前一样,我们检查open是否成功。如果成功,将文件传递给一个函数,该函数负责读取并处理输入数据。如果open失败,打印一条错误信息并继续处理下一个文件。

因为inputwhile循环的局部变量,它在每个循环步中都要创建和销毁一次。当一个fstream对象离开其作用域时,与之关联的文件会自动关闭。在下一步循环中,input会再次被创建。

当一个fstream对象被销毁时,close会被自动调用。

文件模式

每个流都有一个关联的文件模式(file mode),用来指出如何使用文件。下表列出文件模式和它们的含义:

文件模式
in 以读方式打开
out 以写方式打开
app 每次写操作前均定位到文件末尾
ate 打开文件后立即定位到文件末尾
trunc 截断文件
binary 以二进制方式进行IO

无论用哪种方式打开文件,我们都可以指定文件模式,调用open打开文件时可用一个文件名初始化流来隐式打开文件时也可以。指定文件模式有如下限制:

  • 只可以对ofstreamfstream对象设定out模式。
  • 只可以对ifstreamfstream对象设定in模式。
  • 只有当out也被设定时才可设定trunc模式。
  • 只要trunc没被设定,就可以设定app模式。在app模式下,即使没有显式指定out模式,文件也总是以输出方式被打开。
  • 默认情况下,即使我们没有指定trunc,以out模式打开的文件也会被截断。为了保留以out模式打开的文件的内容,我们必须同时指定app模式,这样只会将数据追加写到文件末尾;或者同时指定in模式,即打开文件同时进行读写操作(很后面将介绍对同一个文件既进行输入又进行输出的方法)。
  • atebinary模式可用于任何类型的文件流对象,且可以与其他任何文件模式组合使用。

每个文件流类型都定义了一个默认的文件模式,当我们未指定文件模式时,就使用此默认模式。与ifstream关联的文件默认以in模式打开;与ofstream关联的文件默认以out模式打开;与fstream关联的文件默认以inout模式打开。

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

默认情况下,当我们打开一个ofstream时,文件的内容会被丢弃。阻止一个ofstream清空给定文件内容的方法是同时指定app模式:

1
2
3
4
5
6
7
//在如下语句中,file1都被截断
ofstream out("file1"); //隐含以输出模式打开文件并截断文件
ofstream out2("file1", ofstream::out); //隐含地截断文件
ofstream out3("file1", ofstream::out | ofstream::trunc);
//为了保留文件内容,我们必须显式指定app模式
ofstream app("file2", ofstream::app); //隐含为输出模式
ofstream app2("file2", ofstream::out | ofstream::app);

保留被ofstream打开的文件中已有数据的唯一方法是显式指定app或in模式。

Tip: 每次想要更改文件模式时,先关闭文件,再以目标文件模式打开。

string流

除了继承得来的操作,sstream中定义的类型还增加了一些成员来管理与流相关联的string。下表列出了这些操作,可以对stringstream对象调用这些操作,但不能对其他IO类型调用这些操作。

stringstream特有的操作
sstream strm; strm是一个未绑定的stringstream对象。sstream是头文件sstream中定义的一个类型
sstream strm(s); strm是一个sstream对象,保存string s的一个拷贝。此构造函数是explicit
strm.str() 返回strm所保存的string的拷贝
strm.str(s) string s拷贝到strm中。返回void

可以用ostringstream的对象(假设命名为formatted)暂时存储要输出的字符串,然后在使用 os << formatted.str() << endl 一次性输出结果。

顺序容器

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

标准库还提供了三种容器适配器,分别为容器操作定义了不同的接口,来与容器类型适配。我们将在本章末尾介绍适配器。

顺序容器概述

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

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

除了固定大小的array外,其他容器都提供高效、灵活的内存管理。我们可以添加和删除元素,扩张和收缩容器的大小。

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

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

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

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

如果你不确定应该使用哪种容器,那么可以在程序中只使用vector和list公共的操作:使用迭代器,不使用下标操作,避免随机访问。这样,在必要时选择使用vector或list都很方便。

容器库共有的操作概述

在本节中将介绍所有容器(顺序容器、关联容器、无序容器)都适用的操作。

一般来说,每个容器都定义在一个头文件中,文件名与类型名相同。即,deque定义在头文件 deque中,list定义在头文件list中,以此类推。容器均定义为模板类。例如对vector,我们必须提供额外信息来生成特定的容器类型。对大多数,但不是所有容器,我们还需要额外提供元素类型信息:

1
2
list<Sales_data>;			//保存Sales_data对象的list
deque<double>; //保存double的deque

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

虽然我们可以在容器中保存几乎任何类型,但某些容器操作对元素类型有其自己的特殊要求。我们可以为不支持特定操作需求的类型定义容器,但这种情况下就只能使用那些没有特殊要求的容器操作了。

例如,顺序容器构造函数的一个版本接受容器大小参数,它使用了元素类型的默认构造函数。但某些类没有默认构造函数。我们可以定义一个保存这种类型对象的容器,但我们在构造这种容器时不能只传递给它一个元素数目参数:

1
2
3
//假定noDefault是一个没有默认构造函数的类型
vector<noDefault> v1(10, init); //正确,提供了元素初始化条件
vector<noDefault> v2(10); //错误,必须提供一个元素初始化条件

以下是通用的容器操作:

类型别名
iterator 此容器类型的迭代器类型
const_iterator 可以读取元素,但不能修改元素的迭代器类型
size_type 无符号整数类型,足够保存此种容器类型最大可能容器的大小
difference_type 带符号整数类型,足够保存两个迭代器之间的距离
value_type 元素类型
reference 元素的左值类型;与value_type&含义相同
const_reference 元素的const左值类型(即const value_type&)
构造函数
C c; 默认构造函数,构造空容器(array需要特殊处理)
C c1(c2);
C c1 = c2;
构造c2的拷贝c1
C c(b, e); 构造c,将迭代器b和e指定的范围内的元素拷贝到c(array不支持)
C c{a, b, c…}
C c = {a, b, c…}
列表初始化c
赋值与swap
c1 = c2 c1中的元素替换为c2中元素
c1 = {a, b, c…} c1中的元素替换为列表中元素(不适用于array)
a.swap(b) 交换ab的元素
swap(a, b) a.swap(b)等价
大小
c.size() c中元素的数目(不支持forward_list)
c.max_size() c可保存的最大元素数目
c.empty() c中存储了元素,返回false,否则返回true
添加/删除元素(不适用与array) 注:这些操作的接口在不同容器中不同
c.insert(args) args中的元素拷贝进c
c.emplace(inits) 使用inits构造c中的一个元素
c.erase(args) 删除args指定的元素
c.clear() 删除c中的所有元素,返回void
关系运算符
==, != 所有容器都支持相等(不等)
<, <=, >, >= 运算符关系运算符(无序、关联容器不支持)
获取迭代器
c.begin(), c.end() 返回指向c的首元素以及尾后元素位置的迭代器
c.cbegin(), c.cend() 返回const_iterator
反向容器的额外成员(不支持forward_list)
reverse_iterator 按逆序寻址元素的迭代器
const_reverse_iterator 不能修改元素的逆序迭代器
c.rbegin(), c.rend() 返回指向c的尾元素和首元素之前位置的迭代器
c.crbegin(), c,crend() 返回const_reverse_iterator

迭代器

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

有个例外,forward_list迭代器不支持递减运算符(–)。

迭代器支持算术运算(+n、-n、+=n、-=n、iter1 - iter2),这些运算只能应用于stringvectordequearray的迭代器。我们不能将它们用于其他任何容器类型的迭代器。

迭代器范围

一个迭代器范围(iterator range)由一对迭代器表示,两个迭代器分别指向同一个容器中的元素或者是尾元素之后的位置(one past the last element)。这两个迭代器通常被称为beginend,或者是firstlast,它们标记了容器中元素的一个范围。

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

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

容器类型成员

除了已经使用过的迭代器类型,大多数容器还提供反向迭代器。简单地说,反向迭代器就是一种反向遍历容器的迭代器,与正向迭代器相比,各种操作的含义也都发生了颠例。例如,对一个反向迭代器执行 ++ 操作,会得到上一个元素。

剩下的就是类型别名了,通过类型别名,我们可以在不了解容器中元素类型的情况下使用它。如果需要元素类型,可以使用容器的value_type。如果需要元素类型的一个引用,可以使用referenceconst_reference。这些元素相关的类型别名在泛型编程中非常有用。

为了使用这些类型,我们必须显式使用其类名:

1
2
3
4
//iter是通过list<string>定义的一个迭代器类型
list<string>::iterator iter;
//count是通过vector<int>定义的一个difference_type类型
vector<int>::difference_type count;

这些声明语句使用了作用域运算符来说明我们希望使用list<string>类的iterator成员及vector<int>类定义的difference_type

重载过的begin和end

1
2
3
4
5
list<string> a = { ... };
auto it1 = a.begin(); //list<string>::iterator
auto it2 = a.rbegin(); //list<string>::reverse_iterator
auto it3 = a.cbegin(); //list<string>::const_iterator
auto it4 = a.crbegin(); //list<string>::const_reverse_iterator

不以c开头的函数都是被重载过的。也就是说,实际上有两个名为begin的成员函数。一是const成员,返回容器的const_iterator类型。另一个是非常量成员,返回容器的iterator类型。rbeginendrend的情况类似。当我们对一个非常量对象调用这些成员时,得到的是返回 iterator的版本。只有在对个别const对象调用这些函数时,才会得到一个const版本。与const指针和引用类似,可以将一个普通的iterator转换为对应的const_iterator,但反之不行。

c开头的版本是 C++ 新标准引入的,用以支持autobeginend函数结合使用。

容器定义和初始化

只有顺序容器(不包括array)的构造函数才能接受大小参数:

接受大小参数的初始化
C seq(n) seq包含n个元素,这些元素进行了值初始化;此构造函数是explicit的(不适用于string)
C seq(n, t) seq包含n个初始化为值t的元素
将一个容器初始化为另一个容器的拷贝

将一个新容器创建为另一个容器的考贝的方法有两种:可以直接拷贝整个容器,或者(array除外)拷贝由一个迭代器对指定的元素范围。

为了创建一个容器为另一个容器的拷贝,两个容器的类型及其元素类型必须匹配。不过,当传递迭代器参数来拷贝一个范围时,就不要求容器类型是相同的了。而且,新容器和原容器中的元素类型也可以不同,只要能将要拷贝的元素转换为要初始化的容器的元素类型即可:

1
2
3
4
5
6
7
8
9
10
//每个容器有三个元素,用给定的初始化器进行初始化
list<string> authors = {"Milton", "Shakespeare", "Austen"};
vector<const char*> articles = {"a", "an", "the"};

list<string> list2(authors); //正确,类型匹配
deque<string> authList(authors); //错误,容器类型必须匹配
vector<string> words(articles); //错误,容器的元素类型必须匹配

//正确,可以将const char*元素转换为string
forward_list<string> words(articles.begin(), articles.end());
标准库array具有固定大小

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

1
2
3
array<string, 10> s;				//类型为保存10个string的数组
array<int, 10>::size_type i; //数组类型包括元素类型和大小
array<int>::size_type j; //错误,array<int>不是一个类型

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

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

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

1
2
3
4
int digs[10] = {0,1,2,3,4,5,6,7,8,9};
int cpy[10] = digs; //错误,内置数组不支持拷贝或赋值
array<int, 10> digits = {0,1,2,3,4,5,6,7,8,9};
array<int, 10> copy = digits; //正确,只要数组类型匹配即合法

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

使用assign(仅顺序容器)
assign操作 不适用于关联容器和array
seq.assign(b, e) seq中的元素替换为迭代器be所表示的范围中的元素。迭代器be不能指向seq中的元素
seq.assign(il) seq中的元素替换为初始化列表il中的元素
seq.assign(n, t) seq中的元素替换为n个值为t的元素

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

1
2
3
4
5
list<string> names;
vector<const char*> oldstyle;
names = oldstyle; //错误,容器类型不匹配
//正确,可以将const char*转换为string
names.assign(oldstyle.cbegin(), oldstyle.cend());
使用swap

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

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

调用swap后,svec1将包含24string元素,svec2将包含10string。除array外,交换两个容器内容的操作保证会很快——元素本身并未交换,swap只是交换了两个容器的内部数据结构。

元素不会被移动的事实意味着,除string外,指向容器的迭代器、引用和指针swap操作之后都不会失效。它们仍指向swap操作之前所指向的那些元素。但是,在swap之后,这些元素已经属于不同的容器了。例如,假定iterswap之前指向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
vector<int> v1 = {1, 3, 5, 7, 9, 12};
vector<int> v2 = {1, 3, 9};
vector<int> v3 = {1, 3, 5, 7};
vector<int> v4 = {1, 3, 5, 7, 9, 12};
v1 < v2 //true
v1 < v3 //false
v1 == v4 //true
v1 == v2 //false

容器的关系运算符使用元素的关系运算符完成比较

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

顺序容器操作

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

向顺序容器添加元素

array外,所有标准库容器都提供灵活的内存管理。在运行时可以动态添加或删除元素来改变容器大小。下表列出了向顺序容器(非array)添加元素的操作。

向顺序容器添加元素的操作
c.push_back(t)
c.emplace_back(args)
c的尾部创建一个值为t或由args创建的元素。返回void
c.push_front(t)
c.emplace_front(args)
c的头部创建一个值为t或由args创建的元素。返回void
c.insert(p, t)
c.emplace(p, args)
在迭代器p指向的元素之前创建一个值为t或由args创建的元素。返回指向新添加的元素的迭代器
c.insert(p, n, t) 在迭代器p指向的元素之前插入n个值为t的元素。返回指向新添加的第一个元素的迭代器;若n0,则返回p
c.insert(p, b, e) 将迭代器be指定的范围内的元素插入到迭代器p指向的元素之前。be不能指向c中的元素。返回指向新添加的第一个元素的迭代器;若范围为空,则返回p
c.insert(p, il) il是一个花括号包围的元素值列表。将这些给定值插入到迭代器p指向的元素之前。返回指向新添加的第一个元素的迭代器;若列表为空,则返回p

forward_list有自己专属版本的insert和emplace,且不支持push_back和emplace_back。

vector和string不支持push_front和emplace_front。

在容器中的特定位置添加元素

虽然某些容器不支持push_front操作,但它们对于insert操作并无类似的限制(插入开始位置)。因此我们可以将元素插入到容器的开始位置,而不必担心容器是否支持push_front

1
2
3
vector<string> svec;
svec.insert(svec.begin(), "Hello!");
//vector不支持push_front,但我们可以插入到begin()之前

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

使用insert的返回值

通过使用insert的返回值,可以在容器中一个特定位置反复插入元素:

1
2
3
4
list<string> lst;
auto iter = lst.begin();
while (cin >> word)
iter = lst.insert(iter, word); //等价于调用push_front
使用emplace操作

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

1
2
3
4
5
6
7
//正确,创建一个临时的Sales_data对象传递给push_back
c.push_back(Sales_data("978-0590353403", 25, 15.99));
c.emplace_back(); //使用默认的Sales_data的默认构造函数
//iter指向c中某一个元素
c.emplace(iter, "999-99999999"); //使用Sales_data(string)
//使用Sales_data的接受一个ISBN、一个count和一个price的构造函数
c.emplace_front("978-0590353403", 25, 15.99);

访问元素

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

在顺序容器中访问元素的操作
c.back() 返回c中尾元素的引用。若c为空,函数行为未定义
c.front() 返回c中首元素的引用。若c为空,函数行为未定义
c[n] 返回c中尾元素的引用。若c为空,函数行为未定义返回c中首元素的引用。若c为空,函数行为未定义
c.at(n) 返回下标为n的元素的引用。如果下标越界,则抛出一个out_of_range异常

包括array在内的每个顺序容器都有一个front成员函数,而除forward_list之外的所有顺序容器都有一个back成员函数。

at和下标操作只适用于stringvectordequearray

安全的随机访问和at成员函数

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

1
2
3
vector<string> svec;
cout << svec[0]; //运行时错误,svec中没有元素
cout << svec.at(0); //抛出一个out_of_range异常

删除元素

顺序容器的删除操作(array不支持)
c.pop_back() 删除c中尾元素。若c为空,则函数行为未定义。函数返回void
c.pop_front() 删除c中首元素。若c为空,则函数行为未定义。函数返回void
c.erase(p) 删除迭代器p所指定的元素,返回一个指向被删元素之后元素的迭代器,若p指向尾元素,则返回尾后(off-the-end)迭代器。若p是尾后迭代器,则函数行为未定义
c.erase(b, e) 删除迭代器be所指定范围内的元素。返回一个指向最后一个被删元素之后元素的迭代器,若e本身就是尾后迭代器,则函数也返回尾后迭代器
c.clear() 删除c中的所有元素。返回void

forward_list有自己专属版本的erase,且不支持pop_back。

vector和string不支持pop_front。

特殊的forward_list操作

forward_list是单向链表。在一个单向链表中,没有简单的方法来获取一个元素的前驱。出于这个原因,在一个 forward_list中添加或删除元素的操作是通过改变给定元素之后的元素来完成的。这样,我们总是可以访问到被添加或删除操作所影响的元素。

由于这些操作与其他容器上的操作的实现方式不同,forward_list并未定义insertemplaceerase,而是定义了名为insert_afteremplace_aftererase_after的操作。forward_list也定义了before_begin,它返回一个首前(off-the-beginning)迭代器。这个迭代器允许我们在链表首元素之前并不存在的元素“之后”添加或删除元素(亦即在链表首元素之前添加删除元素)。

forward_list中插入或删除操作
lst.before_begin()
lst.cbefore_begin()
返回指向链表首元素之前不存在的元素的迭代器。此迭代器不能解引用。cbefore_begin()返回一个const_iterator
lst.insert_after(p, t)
lst.insert_after(p, n, t)
lst.insert_after(p, b, e)
lst.insert_after(p, il)
在迭代器p之后的位置插入元素。t是一个对象,n是数量,be是表示范围的一对迭代器(be不能指向lst内),il是一个花括号列表。返回一个指向最后一个插入元素的迭代器。如果范围为空,则返回p。若p为尾后迭代器,则函数行为未定义
lst.emplace_after(p, args) 使用argsp指定的位置之后创建一个元素。返回一个指向这个新元素的迭代器。若p为尾后迭代器,则函数行为未定义
lst.erase_after(p)
lst.erase_after(b, e)
删除p指向的位置之后的元素,或删除从b之后直到(但不包含)e之间的元素。返回一个指向被删元素之后元素的迭代器,若不存在这样的元素,则返回尾后迭代器。如果p指向lst的尾元素或者是一个尾后迭代器,则函数行为未定义

改变容器大小

我们可以用resize来增大或缩小容器,与往常一样,array不支持resize。如果当前大小大于所要求的大小,容器后部的元素会被删除;如果当前大小小于新大小,会将新元素添加到容器后部:

1
2
3
4
list<int> ilist(10, 42);		//10个int,每个值都为42
ilist.resize(15); //将5个值为0的元素添加到ilist末尾
ilist.resize(25, -1); //将10个值为-1的元素添加到ilist末尾
ilist.resize(5); //从ilist末尾删除20个元素

resize操作接受一个可选的元素值参数,用来初始化添加到容器中的元素。如果调用者未提供此参数,新元素进行值初始化。如果容器保存的是类类型元素,且resize向容器添加新元素,则我们必须提供初始值,或者元素类型必须提供一个默认构造函数。

resize操作极可能导致迭代器、指针和引用失效。

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

向容器中添加元素和从容器中删除元素的操作可能会使指向容器元素的指针、引用或迭代器失效。一个失效的指针、引用或迭代器将不再表示任何元素。使用失效的指针、引用或迭代器是一种严重的程序设计错误,很可能引起与使用未初始化指针一样的问题。

在向容器添加元素后:

如果容器是vector或string,且存储空间被重新分配,则指向容器的迭代器指针和引用都会失效。如果存储空间未重新分配,指向插入位置之前的元素的迭代器、指针和引用仍有效,但指向插入位置之后元素的迭代器、指针和引用将会失效

对于deque,插入到除首尾位置之外的任何位置都会导致迭代器、指针和引用失效。如果在首尾位置添加元素,迭代器会失效,但指向存在的元素的引用和指针不会失效。

对于list和forward_list,指向容器的迭代器(包括尾后迭代器和首前迭代器)、指针和引用仍有效。

当我们删除一个元素后:

对于list和forward_list,指向容器其他位置的迭代器(包括尾后迭代器和首前迭代器)、引用和指针仍有效。

对于deque,如果在首尾之外的任何位置删除元素,那么指向被删除元素外其他元素的迭代器、引用或指针也会失效。如果是删除deque的尾元素,则尾后迭代器也会失效,但其他迭代器、引用和指针不受影响;如果是删除首元素,这些也会受影响。

对于vector和string,指向被删元素之前元素的迭代器、引用和指针仍有效。

注意:当我们删除元素时,尾后迭代器总是会失效。

由于向迭代器添加元素和从迭代器删除元素的代码可能会使迭代器失效,因此必须保证每次改变容器的操作之后都正确地重新定位迭代器。这个建议对vectorstringdeque尤为重要。

vector容器空间

为了支持快速随机访问,vector将元素连续存储。通常情况下,我们不必关心一个标准库类型是如何实现的,而只需关心它如何使用。然而,对于vectorstring,其部分实现渗透到了接口中。

标准库实现者采用了可以减少容器空间重新分配次数的策略。当不得不获取新的内存空间时,vectorstring的实现通常会分配比新的空间需求更大的内存空间。容器预留这些空间作为备用,可用来保存更多的新元素。这样,就不需要每次添加新元素都重新分配容器的内存空间了。

如下表所示,vectorstring类型提供了一些成员函数,允许我们与它的实现中内存分配部分互动。capacity操作告诉我们容器在不扩张内存空间的情况下可以容纳多少个元素。reserve操作允许我们通知容器它应该准备保存多少个元素。

容器大小管理操作
c.shrink_to_fit() 发送一个请求,将capacity减少为size相同大小
c.capacity() 不重新分配空间的情况下,c可以保存多少元素
c.reserve(n) 分配至少能容纳n个元素的内存空间

shrink_to_fit只适用于vector、string和deque;

capacity和reserve只适用于vector和string。

reserve并不改变容器中元素的数量,它仅影响vector预先分配多大的内存空间。

只有当需要的内存空间超过当前容量时,reserve调用才会改变vector的容量。如果需求大小大于当前容量,reserve至少分配与需求一样大的内存空间(可能更大)。

如果需求大小小于或等于当前容量,reserve 什么也不做。特别是,当需求大小小于当前容量时,容器不会退回内存空间。因此,在调用reserve之后,capacity将会大于或等于传递给reserve的参数。这样,调用reserve永远也不会减少容器占用的内存空间。类似的,resize成员函数只改变容器中元素的数目,而不是容器的容量。我们同样不能使用resize来减少容器预留的内存空间。

在新标准库中,我们可以调用shrink_to_fit来要求dequevectorstring退回不需要的内存空间。此函数指出我们不再需要任何多余的内存空间。但是,具体的实现可以选择忽略此请求。也就是说,调用shrink_to_fit也并不保证一定退回内存空间。

额外的string操作

除了顺序容器共同的操作之外,string类型还提供了一些额外的操作。这些操作中的大部分要么是提供string类和C风格字符数组之间的相互转换,要么是增加了允许我们用下标代替迭代器的版本。

构造string的其他方法

构造string的其他方法
string s(cp, n) scp指向的字符数组中前n个字符的铂贝。此数组至少应该包含n个字符
string s(s2, pos2) sstring s2从下标pos2开始的字符的拷贝。若pos2>s2.size(),构造函数的行为未定义
string s(s2, pos2, len2) sstring s2从下标pos2开始len2个字符的拷贝。若pos2>s2.size(),构造函数的行为未定义。不管len2的值是多少,构造函数至多拷贝s2.size()-pos2个字符

这些构造函数接受一个string或一个const char*参数,还接受(可选的)指定拷贝多少个字符的参数。当我们传递给它们的是一个string时,还可以给定一个下标来指出从哪里开始拷贝:

1
2
3
4
5
6
7
8
9
10
const char *cp = "Hello World!!!";	//以空字符结束的数组
char noBull[] = {'H', 'i'}; //不以空字符结束
string s1(cp); //拷贝cp中的字符直到遇到空字符,s1 == "Hello World!!!"
string s2(noNull, 2); //从noNull拷贝两个字符,s2 == "Hi"
string s3(nuNull); //未定义,noNull不是以空字符结束
string s4(cp + 6, 5); //从cp[6]开始拷贝5个字符,s4 == "World"
string s5(s1, 6, 5); //从s1[6]开始拷贝5个字符,s5 == "World"
string s6(s1, 6); //从s1[6]开始拷贝,直至s1末尾,s6 == "World!!!"
string s7(s1, 6, 20); //正确,只拷贝到s1末尾,s7 == "World!!!"
string s8(s1, 16); //抛出一个out_of_range异常

通常当我们从一个const char*创建string时,指针指向的数组必须以空字符结尾,拷贝操作遇到空字符时停止。如果我们还传递给构造函数一个计数值,数组就不必以空字符结尾。如果我们未传递计数值且数组也未以空字符结尾,或者给定计数值大于数组大小,则构造函数的行为是未定义的。

当从一个string拷贝字符时,我们可以提供一个可选的开始位置和一个计数值。开始位置必须小于或等于给定的string的大小。如果位置大于size,则构造函数抛出个out_of_range异常。如果我们传递了一个计数值,则从给定位置开始拷贝这么多个字符。不管我们要求拷贝多少个字符,标准库最多拷贝到string结尾,不会更多。

substr操作

substr操作返回一个string,它是原始string的一部分或全部的拷贝。可以传递给substr一个可选的开始位置和计数值:

1
2
3
4
5
string s("Hello World");
string s2 = s.substr(0, 5); //s2 == "Hello"
string s3 = s.substr(6); //s3 == "World"
string s4 = s.substr(6, 11); //s4 == "World"
string s5 = s.substr(12); //抛出一个out_of_range异常

如果开始位置超过了string的大小,则substr函数抛出一个out_of_range异常。如果开始位置加上计数值大于string的大小,则substr会调整计数值,只拷贝到string的末尾。

改变string的其他方法

额外的insert、assign和erase版本

除了接受迭代器的inserterase版本外,string还提供了接受下标的版本。下标指出了开始删除的位置,或是insert到给定值之前的位置:

1
2
s.insert(s.size(), 5, '!');		//在s末尾插入5个感叹号
s.erase(s.size() - 5, 5); //从s删除最后5个字符

标准库string类型还提供了接受C风格字符数组的insertassign版本。例如,我们可以将以空字符结尾的字符数组insert到或assign给一个string

1
2
3
const char *cp = "Stately, plump Buck";
s.assign(cp, 7); //s == "Stately"
s.insert(s.size(), cp + 7); //s == "Stately, plump Buck"

此处我们首先通过调用assign替换s的内容。我们赋予s的是从cp指向的地址开始的7个字符。要求赋值的字符数必须小于或等于cp 指向的数组中的字符数(不包括结尾的空字符)。

接下来在s上调用insert,我们的意图是将字符插入到s[size()]处(不存在的)元素之前的位置。在此例中,我们将从cp的第7个字符开始(至多到结尾空字符之前)拷贝到s中。

但是,我们不能对insert函数同时使用迭代器定位以及用字符指针指定新字符来源。

我们也可以指定将来自其他string或子字符串的字符插入到当前string中或赋予当前string

1
2
3
4
string s = "some string", s2 = "some other string";
s.insert(0, s2);
//在s[0]之前插入s2中s2[0]开始的s2.size()个字符
s.insert(0, s2, 0, s2.size());
append和relapce函数

string类定义了两个额外的成员函数:appendreplace,这两个函数可以改变string的内容。

append操作是在string末尾进行插入操作的一种简写形式:

1
2
3
string s("C++ Primer"), s2 = s;	//将s和s2初始化为"C++ Primer"
s.insert(s.size(), " 4th Ed."); //s == "C++ Primer 4th Ed."
s2.append(" 4th Ed."); //等价方法,将" 4th Ed."追加到s2; s == s2

replace操作是调用eraseinsert的一种简写形式:

1
2
3
4
5
//将"4th"替换为"5th"的等价方法
s.erase(11, 3); //s == "C++ Primer Ed."
s.insert(11, "5th"); //s == "C++ Primer 5th Ed."
//从位置11开始,删除3个字符并插入"5th"
s2.replace(11, 3, "5th"); //s2 == s

此例中调用replace时,插入的文本恰好与删除的文本一样长。这不是必须的,可以插入一个更长或更短的string

string搜索操作

string类提供了6个不同的搜索函数,每个函数都有4个重载版本。下表描述了这些搜索成员函数及其参数。每个搜索操作都返回一个string::size_type值,表示匹配发生位置的下标。如果搜索失败,则返回一个名为string::nposstatic成员。标准库将npos定义为一个const string:.size_type类型,并初始化为值**-1**。由于npos是一个unsigned类型,此初始值意味着npos等于任何string最大的可能大小。

string搜索操作
s.find(args) 查找sargs第一次出现的位置
s.rfind(args) 查找sargs最后一次出现的位置
s.find_first_of(args) s中查找args中任何一个字符第一次出现的位置
s.find_last_of(args) s中查找args中任何一个字符最后一次出现的位置
s.find_first_not_of(args) s中查找第一个不在args中的字符
s.find_last_not_of(args) s中查找最后一个不在args中的字符

搜索操作返回指定字符出现的下标,如果未找到则返回npos

args必须是以下形式之一 且都适用于以上6个搜索函数
c, pos s中位置pos开始查找字符cpos默认为0
s2, pos s中位置pos开始查找字符串s2pos默认为0
cp, pos s中位置pos开始查找指针 cp指向的以空字符结尾的C风格字符串。pos默认为0
cp, pos, n s中位置pos开始查找指针cp指向的数组的前n个字符。posn无默认值

find函数完成最简单的搜索。它查找参数指定的字符串,若找到,则返回第一个配位置的下标,否则返回npos

1
2
string name("AnnaBelle");
auto pos1 = name.find("Anna"); //pos1 == 0

这段程序返回0,即子字符串"Anna""AnnaBelle"中第一次出现的下标。

搜索(以及其他string操作)是大小写敏感的。当在string中查找子字符串时要注意大小写:

1
2
string name("annaBelle");
auto pos1 = name.find("Anna"); //pos1 == npos

这段代码会将pos1置为npos,因为Annaanna不匹配。

一个更复杂一些的问题是查找与给定字符串中任何一个字符匹配的位置。例如,下面代码定位name中的第一个数字:

1
2
3
string numbers("0123456789"), name("r2d2");
//返回1,即name中第一个数字的下标
auto pos = name.find_first_of(numbers);

如果是要搜索第一个不在参数中的字符,我们应该调用find_first_not_of。例如,为了搜索一个string中第一个非数字字符,可以这样做:

1
2
3
string dept("03714p3");
//返回5——字符'p'的下标
auto pos = dept.find_first_not_of(numbers);

移动检索

我们可以传递给find操作一个可选的开始位置。这个可选的参数指出从哪个位置开始进行搜索。默认情况下,此位置被置为0。一种常见的程序设计模式是用这个可选参数在字符串中循环地搜索子字符串出现的所有位置:

1
2
3
4
5
6
7
string::size_type pos = 0;
//每步循环查找name中下一个数
while ((pos = name.find_first_of(numbers, pos)) != string::npos) {
cout << "found number at index: " << pos
<< " element is " << name[pos] << endl;
++pos; //移动到下一个字符
}

while的循环条件将pos重置为从pos开始遇到的第一个数字的下标。只要find_first_of返回一个合法下标,我们就打印当前结果并递增pos

compare函数

除了关系运算符外,标准库string类型还提供了一组compare函数,这些函数与C标准库的strcmp函数很相似。类似strcmp,根据s是等于、大于还是小于参数指定的字符串,s.compare返回0、正数或负数。

如下表所示,compare6个版本。根据我们是要比较两个string还是一个string与一个字符数组,参数各有不同。在这两种情况下,都可以比较整个或一部分字符串。

s.compare的几种参数形式
s2 比较ss2
pos1, n1, s2 s中从pos1开始的n1个字符与s2进行比较
pos1, n1, s2, pos2, n2 s中从pos1开始的n1个字符与s2中从pos2开始的n2个字符进行比较
cp 比较scp指向的以空字符结尾的字符数组
pos1, n1, cp s中从pos1开始的n1个字符与cp指向的以空字符结尾的字符数组进行比较
pos1, n1, cp, n2 s中从pos1开始的n1个字符与指针cp指向的地址开始的n2个字符进行比较
数值转换

新标准引入了多个函数,可以实现数值数据与标准库string之间的转换:

1
2
3
int i = 42;
string s = to_string(i); //将整数i转换为字符表示形式
double d = stod(s); //将字符串s转换为浮点数

要转换为数值的string中第一个非空白符必须是数值中可能出现的字符:

1
2
3
string s2 = "pi = 3.14";
//转换s中数字开始的第一个子串,结果d = 3.14
d = stod(s2.substr(s2.find_first_of("+-.0123456789")));

在这个stod调用中,我们调用了find_first_of来获得中第一个可能是数值的一部分的字符的位置。我们将s中从此位置开始的子串传递stodstod函数读取此参数,处理其中的字符,直至遇到不可能是数值的一部分的字符然后它就将找到的这个数值的字符串表示形式转换为对应的双精度浮点值。

string参数中第一个非空白符必须是符号( + )或( - )或数字。它可以以0x0X开头来表示十六进制数。对那些将字符串转换为浮点值的函数,string参数也可以以数点( . )开头,并可以包含eE来表示指数部分。对于那些将字符串转换为整型值的函数,根据基数不同,string参数可以包含字母字符,对应大于数字9的数。

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

string和数值之间的转换
to_string(val) 一组重载函数,返回数值valstring表示。val可以是任何算术类型。对每个浮点类型和int或更大的整型,都有相应版本的to_string。与往常一样,小整型会被提升
stoi(s, p, b)
stol(s, p, b)
stoul(s, p, b)
stoll(s, p, b)
stoull(s, p, b)
返回s的起始子串(表示整数内容)的数值,返回值类型分别是intlongunsigned longlong longunsigned long longb表示转换所用的基数,默认值为10psize_t指针,用来保存s中第一个非数值字符的下标,p默认为0,即函数不保存下标
stof(s, p)
stod(s, p)
stold(s, p)
返回s的起始子串(表示浮点数内容)的数值,返回值类型分别是floatdoublelong double。参数p的作用与整数转换函数中一样

容器适配器

除了顺序容器外,标准库还定义了三个顺序容器适配器:stackqueuepriority_queue适配器(adaptor)是标准库中的一个通用概念。容器、迭代器和函数都有适配器。本质上,一个适配器是一种机制,能使某种事物的行为看起来像另外一种事物一样。一个容器适配器接受一种已有的容器类型,使其行为看起来像一种不同的类型。例如,stack适配器接受一个顺序容器(除arrayforward_list外),并使其操作起来像一个stack一样。下表列出了所有容器适配器都支持的操作和类型。

所有容器适配器都支持的操作和类型
size_type 一种类型,足以保存当前类型的最大对象的大小
value_type 元素类型
container_type 实现适配器的底层容器类型
A a; 创建一个名为a的空适配器
A a©; 创建一个名为a的适配器,带有容器c的一个拷贝
关系运算符 每个适配器都支持所有关系运算符:==、!=、<、<=、>和>=
这些运算符返回底层容器的比较结果
a.empty() a包含任何元素,返回false,否则返回true
a.size() 返回a中的元素数目
swap(a, b)
a.swap(b)
交换ab的内容,ab必须有相同类型,包括底层容器类型也必须相同

定义一个适配器

每个适配器都定义两个构造函数:默认构造函数创建一个空对象,接受一个容器的构造函数拷贝该容器来初始化适配器。例如,假定deq是一个deque<int>,我们可以用deq来初始化一个新的stack,如下所示:

1
stack<int> stk(deq);		//从deq拷贝元素到stk

默认情况下,stackqueue是基于deque实现的,priority_queue是在vector之上实现的。我们可以在创建一个适配器时将一个顺序容器名作为第二个类型参数,来重载默认容器类型:

1
2
3
4
//在vector上实现的空栈
stack<string, vector<string>> str_stk;
//str_stk2在vector上实现,初始化时保存sevc的拷贝
stack<string, vector<string>> str_stk2(sevc);

对于一个给定的适配器,可以使用哪些容器是有限制的。stack只要求push_backpop_backback操作,因此可以使用除arrayforward_list之外的任何容器类型来构造stackqueue适配器要求backpush_backfrontpush_front,因此它可以构造于vectorlistdeque之上。priority_queue除了frontpush_backpop_back操作之外还要求随机访问能力,因此它可以构造于vectordeque之上,但不能基于list构造。

Note: 书中有关queue能否用vector构造的阐述前后矛盾,Luv实测是可以的。

栈适配器

stack类型定义在stack头文件中。下表列出了stack所支持的操作:

栈的特殊操作
s.pop() 删除栈顶元素,但不返回该元素值
s.push(item) 创建一个新元素压入栈顶,该元素通过拷贝item而来
s.emplace(args) 创建一个新元素压入栈顶,该元素通过args构造
s.top() 返回栈顶元素,但不将元素弹出栈

每个容器适配器都基于底层容器类型的操作定义了自己的特殊操作。我们只可以使用适配器操作,而不能使用底层容器类型的操作。例如:

1
2
stack<int> intStack;		//空栈
intStack.push_back(ix); //错误,不能直接使用deque操作

队列适配器

queuepriority_queue适配器定义在queue头文件中。下表列出它们所支持的操作:

队列的特殊操作
q.pop() 返回queue的首元素或priority_queue的最高优先级的元素,但不删除此元素
q.front() 返回首元素,但不删除此元素(只适用于queue)
q.back() 返回尾元素,但不删除此元素(只适用于queue)
q.top() 返回最高优先级元素,但不删除该元素(只适用于priority_queue)
q.push(item) queue末尾或priority_queue中恰当的位置创建一个元素,其值为item
q.emplace(args) queue末尾或priority_queue中恰当的位置创建一个由args构造的元素

关于priority_queue:默认情况下,标准库在元素类型上使用 < 运算符来确定相对优先级。我们将在后面学习如何重载这个默认设置。