开场白
一想到这期的封面能放我的早苗,还是尽快赶出来了。
第三周的内容堪比前两周的总和,记录了三个章节的内容,类是 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 | Sales_data total; //保存当前求和结果和变量 |
定义改进的Sales_data类
定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,例如 add
、read
和 print
等,它们的定义和声明都在类的外部。
avg_price
函数用于求出售书籍的平均价格,目的并非通用,所以属于类的实现的一部分,而非接口的一部分。
1 | struct 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 | double Sales_data::avg_price() const { |
函数名Sales data::avg price
使用作用域运算符来说明如下的事实:
我们定义了一个名为avg_price
的函数,并且该函数被声明在类Sales_data
的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的。因此,当avg_price
使用 revenue
和 units _sold
时,实际上它隐式地使用了Sales_data
的成员。
定义一个返回this对象的函数
函数combine
的设计初衷类似于复合赋值运算符 += ,调用该函数的对象代表左侧的运算对象,右侧的运算对象则通过显式的实参被传入函数:
1 | Sales_data& Sales_data::combine(const Sales_data &rhs){ |
当我们的交易处理程序调用如下的函数时:
1 | total.combine(trans); //更新变量total当前的值 |
total
的地址被绑定到隐式的this
参数上,而rhs
绑定到trans
上。
该函数一个值得关注的部分是它的返回类型和返回语句。一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine
函数必须返回引用类型。因为此时的左侧运算对象是一个Sales_data
的对象,所以返回类型应该是 Sales_data&
。
1 | return *this; //返回调用该函数的对象 |
其中,return
语句解引用this
指针以获得执行该函数的对象,也即返回total
的引用。
定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如add
、read
和print
等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。
我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。
Tip: 如果非成员函数是类接口的组成部分,应该把这些函数的声明与类放在同一个头文件内。
定义read和print函数
1 | //输入的交易信息包括ISBN、售出总数和售出价格 |
read
函数从给定流中将数据读到给定的对象里,print
函数则负责将给定对象的内容打印到给定的流中。
除此之外,关于上面的函数还有两点是非常重要的:
第一点,read
和 print
分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。
第二点,print
函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。
定义add函数
add
函数接受两个Sales_data
对象作为其参数,返回值是一个新的Sales_data
,用于表示前两个对象的和:
1 | Sales_data add(const Sales_data &lhs, const Sales_data &rhs){ |
默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。
构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
不同于其他成员函数,构造函数不能被声明成const
的,当我们创建类的一个const
对象时,直到构造函数完成初始化过程,对象才能真正取其“常量”属性。因此,构造函数在const
对象的构造过程中可以向其写值。
合成的默认构造函数
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。
默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员。
- 否则,默认初始化该成员。
因为Sales_data
为units_sold
和revenue
提供了初始值,所以合成的默认构造函数将使用这些值来初始化对应的成员;同时,它把 bookNo
默认初始化成一个空字符串。
只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。
定义Sales_data的构造函数
对于我们的Sales_data
类来说,我们将使用下面的参数定义4个不同的构造函数:
- 一个
istream&
,从中读取一条交易信息。 - 一个
const string&
,表示ISBN编号;一个unsigned
,表示售出的图书数量;以及一个double
,表示图书的售出价格。 - 一个
const string&
,表示ISBN编号;编译器将赋予其他成员默认值。 - 一个空参数列表(即默认构造函数),正如刚刚介绍的,既然我们已经定义了其他构造函数,那么也必须定义一个默认构造函数。
给类添加了这些成员之后,将得到:
1 | struct Sales_data { |
= default 的含义
以上的默认构造函数:
1 | Sales_data() = default; |
我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。
在 C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default
来要求编译器生成构造函数。其中,= default
既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果 = default
在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
Warning: 上面的默认构造函数之所以对Sales_data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表(下面介绍)来初始化类的每个成员。
构造函数初始值列表
接下来我们介绍类中定义的另外两个构造函数:
1 | Sales_data(const std::string &s) : bookNo(s) { } |
这两个定义中出现了新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了(空的)函数体。我们把新出现的部分称为构造函数初始值列表(constructor initialize list),它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。
含有三个参数的构造函数分别使用它的前两个参数初始化成员bookNo
和units_sold
,revenue
的初始值则通过将售出图书总数和每本书单价相乘计算得到。
只有一个string
类型参数的构造函数使用这个string
对象初始化bookNo
,对于units_sold
和revenue
则没有显式地初始化。当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。在此例中,这样的成员使用类内初始值初始化,因此只接受一个string
参数的构造函数等价于:
1 | //与上面定义的那个构造函数效果相同 |
通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。
不过,如果使用的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
有一点需要注意,在上面的两个构造函数中函数体都是空的。这是因为这些构造函数的唯一目的就是为数据成员赋初值,一旦没有其他任务需要执行,函数体也就为空了。
在类的外部定义函数
与其他几个构造函数不同,以istream
为参数的构造函数需要执行一些实际的操作在它的函数体内,调用了read
函数以给数据成员赋以初值:
1 | Sale_data::Sales_data (std::istream &is){ |
这个构造函数没有构造函数初始值列表,或者讲得更准确一点,它的构造函数初始值列表是空的。尽管构造函数初始值列表是空的,但是由于执行了构造函数体,所以对象的成员仍然能被初始化。
访问控制与封装
到目前为止,我们已经为类定义了接口,但并没有任何机制强制用户使用这些接口。我们的类还没有封装,也就是说,用户可以直达Sales_data
对象的内部并且控制它的具体实现细节。在 C++ 语言中,我们使用访问说明符(access specifiers)加强类的封装性:
- 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
- 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。
再一次定义Sales_data
类,其新形式如下所示:
1 | class Sales_data { |
作为接口的一部分,构造函数和部分成员函数(即isbn
和combine
)紧跟在public
说明符之后;而数据成员和作为实现部分的函数则跟在private
说明符后面。
一个类可以包含0个或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下个访问说明符或者到达类的结尾处为止。
使用class或struct关键字
在上面的定义中我们还做了一个微妙的变化:我们使用了class
关键字而非struct
开始类的定义。这种变化仅仅是形式上有所不同,实际上我们可以使用这两个关键字中的任何一个定义类。唯一的区别是,struct
和class
的默认访问权限不太一样。
类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用struct
关键字,则定义在第一个访问说明符之前的成员是public
的;相反,如果我们使用class
关键字,则这些成员是private
的。
使用class和struct定义类唯一的区别就是默认的访问权限。
友元
既然Sales_data
的数据成员是private
的,我们的read
、print
和add
函数也就无法正常编译了,这是因为尽管这几个函数是类的接口的一部分,但它们不是类的成员。
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以friend
关键字开始的函数声明语句即可:
1 | class Sales_data { |
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
类调用友元
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,我们的Sales_data头文件应该为read、print和add提供额外独立的声明(除了类内部的友元声明之外)。
类的其他特性
以下是Sales_data
没有体现出来的一些类的特性。为了展示这些新的特性,我们需要定义一对相互关联的类,它们分别是Screen
和Window_mgr
。
定义类型成员
Screen
表示显示器中的一个窗口。每个Screen
包含一个用于保存Screen
内容的string
成员和三个string::size_type
类型的成员,它们分别表示光标的位置以及屏幕的高和宽。
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public
或者private
中的一种:
1 | class Screen { |
我们在Screen
的public
部分定义了pos
,这样用户就可以使用这个名字。Screen
的用户不应该知道Screen
使用了一个string
对象来存放它的数据,因此通过把pos
定义成public
成员可以隐藏Screen
实现的细节。
关于pos
的声明有两点需要注意。首先,我们使用了typedef
,也可以等价地使用类型别名:
1 | class Screen { |
其次,用来定义类型的成员必须先定义后使用。因此,类型成员通常出现在类开始的地方。
令成员作为内联函数
在类中,常有一些规模较小的函数适合于被声明成内联函数。如我们之前所见的,定义在类内部的成员函数是自动inline的。因此,Screen
的构造函数和返回光标所指字符的get
函数默认是inline
函数。
我们可以在类的内部把inline
作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline
关键字修饰函数的定义:
1 | class Screen { |
在声明和定义处同时使用inline是合法的,但最好只在类的外部定义处说明inline,方便理解。
和我们在头文件中定义inline函数的原因一样,inline成员函数也应该与相应的类定义在同一个头文件中。
可变数据成员
有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。
一个可变数据成员(mutable data member)永远不会是const
,即使它是const
对象的成员。因此,一个const
成员函数可以改变一个可变成员的值。举个例子,我们将给Screen
添加一个名为 access_ctr
的可变成员,通过它我们可以追踪每个Screen
的成员函数被调用了多少次:
1 | class Screen { |
尽管some_member
是一个const
成员函数,它仍然能够改变access_ctr
的值。该成员是个可变成员,因此任何成员函数,包括const
函数在内都能改变它的值。
类数据成员的初始值
在定义好Screen
类之后,我们将继续定义一个窗口管理类并用它表示显示器上的一组Screen
。这个类将包含一个Screen
类型的vector
,每个元素表示一个特定的Screen
。默认情况下,我们希望window_mgr
类开始时总是拥有一个默认初始化的Screen
。在 C++11 新标准中,最好的方式就是把这个默认值声明成一个类内初始值:
1 | class Window_mgr { |
当我们初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。在此例中,我们使用一个单独的元素值对vector
成员执行了列表初始化,这个Screen
的值被传递给vector<Screen>
的构造函数,从而创建了一个单元素的vector
对象。具体地说,Screen
的构造函数接受两个尺寸参数和一个字符值,创建了一个给定大小的空白屏幕对象。
如我们之前所知的,类内初始值必须使用 = 的初始化形式(初始化Screen
的数据成员时所用的)或者花括号括起来的直接初始化形式(初始化Screens
所用的)。
连续执行的操作
可以通过返回*this
(返回引用),把一系列操作连接在一条表达式中:
1 | class Screen { |
基于const的重载
接下来,我们继续添加一个名为display
的操作,它负责打印Screen
的内容。我们希望这个函数能和move
以及set
出现在同一序列中,因此类似于move
和set
,display
函数也应该返回执行它的对象的引用。
从逻辑上来说,显示一个Screen
并不需要改变它的内容,因此我们令display
为一个const
成员,此时,this
将是一个指向const
的指针而*this
是const
对象。由此推断,display
的返回类型应该是const Sales_data&
。然而,如果真的令display
返回一个const
的引用,则我们将不能把display
嵌入到一组动作的序列中去:
1 | Screen myScreen; |
即使myScreen
是个非常量对象,对set
的调用也无法通过编译。问题在于display
的const
版本返回的是常量引用,而我们显然无权set
一个常量对象。
通过区分成员函数是否是const
的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const
而重载函数的原因差不多。因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一一个常量对象上调用const
成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。
在下面的这个例子中,我们将定义一个名为do_display
的私有成员,由它负责打印Screen
的实际工作。所有的display
操作都将调用这个函数,然后返回执行操作的对象:
1 | class Screen { |
和我们之前所学的一样,当一个成员调用另外一个成员时,this
指针在其中隐式地传递。因此,当display
调用do_display
时,它的this
指针隐式地传递给do_display
。而当display
的非常量版本调用do_display
时,它的this
指针将隐式地从指向非常量的指针转换成指向常量的指针。
当do_display
完成后,display
函数各自返回解引用this
所得的对象。在非常量版本中,this
指向一个非常量对象,因此display
返回一个普通的(非常量)引用;而const
成员则返回一个常量引用。
当我们在某个对象上调用display
时,该对象是否是const
决定了应该调用display
的哪个版本:
1 | Screen myScreen(5, 3); |
类的声明(不完全类型)
就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:
1 | class Screen; //Screen类的声明 |
这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字Screen
并且指明Screen
是一种类类型。对于类型Screen
来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen
是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义然后才能用引用或者指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员。
一种例外的情况:直到类被定义之后数据成员才能被声明成这种类类型。换句话说,我们必须首先完成类的定义,然后编译器才能知道存储该数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。
然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:
1 | class Link_screen { |
友元再探
我们的Sales_data
类把三个普通的非成员函数定义成了友元。类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。
此外,友元函数能定义在类的内部,这样的函数是隐式内联的。但这里的水很深,别这么干。
类之间的友元关系
举个友元类的例子,我们的Window_mgr
的某些成员可能需要访问它管理的Screen
类的内部数据。例如,假设我们需要为Window_mgr
添加一个名为clear
的成员,它负责把一个指定的Screen
的内容都设为空白。为了完成这一任务,clear
需要访问Screen
的私有成员;而要想令这种访问合法,Screen
需要把Window_mgr
指定成它的友元:
1 | class Screen { |
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
友元关系不具有传递性。每个类负责控制自己的友元类或友元函数。
令成员函数作为友元
除了令整个Window_mgr
作为友元之外,Screen
还可以只为clear
提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:
1 | class Screen { |
要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:
- 首先定义
Window_mgr
类,其中声明clear
函数,但是不能定义它。在clear
使用Screen
的成员之前必须先声明Screen
。 - 接下来定义
Screen
,包括对于clear
的友元声明。 - 最后定义
clear
,此时它才可以使用Screen
的成员。
类和非成员函数的声明不是必须在它们的友元声明之前,当一个名字的第一次出现是在友元声明中时,我们假定其在当前作用域可见。但是,友元声明不是声明,当出现了需要事先声明时却仅采用了友元声明的情况,程序可能出错(由编译器决定)。
类的作用域
指明返回类型的归属
一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员的名字被隐藏起来了。
一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权了。
而函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。例如,我们可能向Window_mgr
类添加一个新的名为addScreen
的函数,它负责向显示器添加一个新的屏幕。这个成员的返回类型将是ScreenIndex
,用户可以通过它定位到指定的Screen
:
1 | class Window_mgr { |
因为返回类型出现在类名之前,所以事实上它是位于 Window_mgr
类的作用域之外的。在这种情况下,要想使用ScreenIndex
作为返回类型,我们必须明确指定哪个类定义了它。
类型名的特殊处理
编译器处理完类中的全部声明后才会处理成员函数的定义。
但是,这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。例如:
1 | typedef double Money; |
当编译器看到balance
函数的声明语句时,它将在Account
类的范围内寻找对Money
的声明。编译器只考虑Account
中在使用Money
前出现的声明,因为没找到匹配的成员,所以编译器会接着到Account
的外层作用域中查找。在这个例子中,编译器会找到Money
的typedef
语句,该类型被用作balance
函数的返回类型以及数据成员bal
的类型。另一方面,balance
函数体在整个类可见后才被处理,因此,该函数的return
语句返回名为bal
的成员,而非外层作用域的string
对象。
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表种类型,则类不能在之后重新定义该名字:
1 | typedef double Money; |
Tip: 类型名的定义应该放在类的开始处,确保所有使用该类型的成员都出现在这之后。
区分成员名字和参数名字
不建议使用其他成员的名字作为某个成员函数的参数。
1 | int height; //定义了一个名字,在本小节末尾用到 |
当编译器处理dummy_fcn
中的乘法表达式时,它首先在函数作用域内查找表达式中用到的名字。函数的参数位于函数作用域内,因此dummy_fcn
函数体内用到的名字height
指的是参数声明。
在此例中,height
参数隐藏了同名的成员。如果想绕开上面的查找规则,应该将代码变为:
1 | class Screen { |
尽管类的成员被隐藏了,但我们仍然可以通过加上类的名字或显式地使用this指针来强制访问成员。
但尽量规避这种情况,成员函数中的名字不要隐藏同名的成员。
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。在我们的例子中,名字height定义在外层作用域中,且位于screen的定义之前。然而,外层作用域中的对象被名为height的成员隐藏掉了。因此,如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求:
1
2
3 void Screen::dummy_fcn(pos height) {
cursor = width * ::height; //对应全局的height
}但尽量规避这种情况,不要隐藏外层作用域可能用到的名字。
考虑成员函数定义之前的全局作用域
当成员定义在类的外部时,名字查找不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。例如:
1 | class Screen { |
请注意,全局函数verify
的声明在Screen
类的定义之前是不可见的。然而,名字查找包括了成员函数出现之前的全局作用域。在此例中,verify
的声明位于setHeight
的定义之前,因此可以被正常使用。
构造函数再探
初始值列表
如果成员是const
、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
成员初始化顺序
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了:
1 | class X { |
最好令构造函数初始值与成员声明顺序保持一致。
默认实参
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
1 | class Sales_data { |
委托构造函数
C++11 新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
举个例子,我们使用委托构造函数重写Sales_data
类,重写后的形式如下所示:
1 | class Sales_data { |
在这个Sales_data
类中,除了一个构造函数外其他的都委托了它们的工作。第一个构造函数接受三个实参,使用这些实参初始化数据成员,然后结束工作。
我们定义默认构造函数,其委托三参数的构造函数完成初始化过程,它也无须执行其他任务,这一点从空的构造函数体能看得出来。
接受一个string
的构造函数同样委托给了三参数的版本。
接受istream&
的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。当这些受委托的构造函数执行完后,接着执行istream&
构造函数体的内容。它的构造函数体调用read
函数读取给定的istream
。
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在Sales_data类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。
使用默认构造函数
下面的obj
的声明可以正常编译通过:
1 | Sales_data obj(); //正确,定义了一个函数而非对象 |
但当我们试图使用obj
时,编译器将报错,提示我们不能对函数使用成员访问运算符问题在于,尽管我们想声明一个默认初始化的对象,obj
实际的含义却是一个不接受任何参数的函数并且其返回值是Sales_data
类型的对象。
如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对:
1 | Sales_data obj; //正确,obj是个默认初始化的对象 |
隐式的类型转换
之前曾经介绍过 C++ 语言在内置类型之间定义了几种自动转换规则。同样的,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。
即能通过一个实参调用的构造函数定义一条从该参数类型向类类型隐式转换的规则。
在Sales_data
类中,接受string
的构造函数和接受istream
的构造函数分别定义了从这两种类型向Sales_data
隐式转换的规则。也就是说,在需要使用Sales_data
的地方,我们可以使用string
或者istream
作为替代:
1 | string null_book = "9-999-99999-9"; |
在这里我们用一个string
实参调用了Sales_data
的combine
成员。该调用是合法的,编译器用给定的string
自动创建了一个Sales_data
对象。新生成的这个(临时)Sales_data
对象被传递给combine
。因为combine
的参数是一个常量引用,所以我们可以给该参数传递一个临时量。
Note: Luv实测,类似combine函数,如果使用的参数是引用,则必须指定为const,否则报错。
只允许一步类类型转换
因为历史原因以及为了与C语言兼容,字符串字面值与标准库string类型不是同一种类型。
编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的:
1 | //错误:需要用户定义的两种转换 |
如果我们想完成上述调用,可以显式地把字符串转换成string
或者Sales_data
对象:
1 | //正确,显式地转换成string,隐式地转换成Sales_data |
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:
1 | class Sales_data { |
此时,没有任何构造函数能用于隐式地创建Sales_data
对象,之前的用法都无法通过编译:
1 | item.combine(null_book); //错误,string构造函数是explicit的 |
关键字explicit
只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit
的。只能在类内声明构造函数时使用explicit
关键字,在类外部定义时不应重复:
1 | //错误,explicit关键字只允许出现在类内的构造函数声明处 |
explicit构造函数只能用于直接初始化
发生隐式转换的一种情况是执行拷贝形式的初始化(使用 = 初始化)。此时,我们只能使用直接初始化:
1
2
3 Sales_data item1(null_book); //正确,直接初始化
//错误,不能将explicit构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;
显式地使用构造函数进行转换
尽管编译器不会将explicit
的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:
1 | //正确,实参是一个显式构造的Sales_data对象 |
在第一个调用中,我们直接使用Sales_data
的构造函数,该调用通过接受string
的构造函数创建了一个临时的Sales_data
对象。在第二个调用中,我们使用static_cast
执行了显式的而非隐式的转换。其中static_cast
使用istream
构造函数创建了一个临时的Sales_data
对象。
标准库中含有显式构造函数的类
我们用过的一些标准库中的类含有单参数的构造函数:
- 接受一个单参数的const char*的string构造函数不是explicit的。
- 接受一个容量参数的vector构造函数是explicit的。
聚合类
聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有virtual函数(在很后面介绍)。
例如,下面的类是一个聚合类:
1 | struct Data { |
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:
1 | //vall.ival = 0; vall.s = string("Anna") |
初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。下面的例子是错误的:
1 | //错误,不能使用"anna"初始化ival,也不能使用1024初始化s |
与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。
值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:
- 要求类的所有成员都是public的。
- 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
- 添加或删除一个成员之后,所有的初始化语句都需要更新。
字面值常量类
我们提到过constexpr
函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr
函数成员。这样的成员必须符合constexpr
函数的所有要求,它们是隐式const
的。
数据成员都是字面值类型的聚合类是字面值常量类。
而如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型。
- 类必须至少含有一个
constexpr
构造函数。 - 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的
constexpr
构造函数。 - 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
constexpr构造函数
尽管构造函数不能是const
的,但是字面值常量类的构造函数可以是constexpr
函数。事实上,一个字面值常量类必须至少提供一个constexpr
构造函数。
constexpr
构造函数可以声明成 = default
的形式或者是删除函数的形式(这个后面讲)。否则,constexpr
构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr
函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr
构造函数体一般来说应该是空的。我们通过前置关键字constexpr
就可以声明一个constexpr
构造函数了:
1 | class Debug { |
constexpr
构造函数必须初始化所有数据成员,初始值或者使用constexpr
构造函数,或者是一条常量表达式。
constexpr
构造函数用于生成constexpr
对象以及constexpr
函数的参数或返回类型:
1 | constexpr Debug io_sub(false, true, false); //调试IO |
类的静态成员
有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
声明静态成员
我们通过在成员的声明之前加上关键字static
使得其与类关联在一起。和其他成员一样,静态成员可以是public
的或private
的。静态数据成员的类型可以是常量、引用、指针、类类型等。
举个例子,我们定义一个类,用它表示银行的账户记录:
1 | class Account { |
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,每个Account
对象将包含两个数据成员:owner
和amount
。只存在一个interestRate
对象而且它被所有Account
对象共享。
类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this
指针。作为结果,静态成员函数不能声明成const
的,而且我们也不能在static
函数体内使用this
指针。这一限制既适用于this
的显式使用,也对调用非静态成员的隐式使用有效。
使用类的静态成员
我们使用作用域运算符直接访问静态成员:
1 | double r; |
虽然静态成员不属于类的某个对象,但是我们仍然能够使用类的对象、引用或指针来访问静态成员:
1 | Account ac1; |
成员函数使用静态成员不需要通过作用域运算符:
1 | class Account { |
定义静态成员
和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static
关键字,该关键字只出现在类内部的声明语句:
1 | void Account::rate(double newRate) |
和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static关键字则只出现在类内部的声明语句中。
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
我们定义静态数据成员的方式和在类的外部定义成员函数差不多。我们需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字:
1 | //定义并初始化一个静态成员 |
这条语句定义了名为 interestRate
的对象,该对象是类Account
的静态成员,其类型是double
。从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,我们可以直接使用initRate
函数。注意,虽然initRate
是私有的,我们也能用它初始化interestRate
。和其他成员的定义一样,interestRate
的定义也可以访问类的私有成员。
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供const
整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
(或const
)。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。例如,我们可以用一个初始化了的静态数据成员指定数组成员的维度:
1 | class Account { |
Note: LUV测试,如果此时period改为在外部定义、内部声明,编译会出错。
如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的const
和constexpr static
不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。
例如,如果period
的唯一用途就是定义daily_tbl
的维度,则不需要在Account
外面专门定义period
。此时,如果我们忽略了这个限制,那么对程序非常微小的改动也可能造成编译错误,因为程序找不到该成员的定义语句。举个例子,当需要把Account::period
传递给一个接受const int&
的函数时,必须定义period
。
如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了:
1 | constexpr int Acocunt::period; //初始值在类的定义内提供 |
Tip: 即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
静态成员的运用场景
如我们所见,静态成员独立于任何对象。因此,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
1 | class Bar { |
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参:
1 | class Screen { |
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。
IO库
IO类
到目前为止,我们已经使用过的IO类型和对象都是操纵char
数据的。默认情况下这些对象都是关联到用户的控制台窗口的。当然,我们不能限制实际应用程序仅从控制窗口进行IO操作,应用程序常常需要读写命名文件。而且,使用IO操作处理string
的字符会很方便。此外,应用程序还可能读写需要宽字符支持的语言。
为了支持这些不同种类的IO处理操作,在istream
和ostream
之外,标准库还定义了其他一些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)实现的。利用模板,我们可以使用具有继承关系的类,而不必解继承机制如何工作的细节。
简单地说,继承机制使我们可以声明一个特定的类继承自另一个类。我们通常可以将一个派生类(继承类)对象当作其基类(所继承的类)对象来使用。
类型ifstream
和 istringstream
都继承自istream
。因此,我们可以像使用istream
对象一样来使用ifstream
和istringstream
对象。也就是说,我们是如何使用cin
的,就可以同样地使用这些类型的对象。例如,可以对一个ifstream
和istringstream
对象调用getline
,也可以使用 >> 从一个ifstream
或istringstream
对象中读取数据。类似的,类型ofstream
和ostringstream
都继承自ostream
。因此,我们是如何使用cout
的,就可以同样地使用这些类型的对象。
以下的标准库流特性都可以无差别地应用于普通流、文件流和string流,以及char或宽字符流版本。
IO对象无拷贝和赋值
不能拷贝或对IO对象赋值:
1 | ofstream out1, 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库定义了4个iostate
类型的constexpr
值表示特定的位模式。这些值用来表示特定类型的IO条件,可以与位运算符一起使用来一次性检测或设置多个标志位。
badbit
表示系统级错误,如不可恢复的读写错误。通常情况下,一旦badbit
被置位,流就无法再使用了。
在发生可恢复错误后,failbit
被置位,如期望读取数值却读出一个字符等错误。这种问题通常是可以修正的,流还可以继续使用。
如果到达文件结束位置,eofbit
和failbit
都会被置位。goodbit
的值为0,表示流未发生错误。
如果badbit
、failbit
和 eofbit
任一个被置位,则检测流状态的条件会失败。
标准库还定义了一组函数来查询这些标志位的状态。操作good
在所有错误位均未置位的情况下返回true
,而bad
、fail
和eof
则在对应错误位被置位时返回true
。此外,在 badbit
被置位时,fail
也会返回true
。这意味着,使用good
或fail
是确定流的总体状态的正确方法。实际上,我们将流当作条件使用的代码就等价于 !fail()
。而eof
和bad
操作只能表示特定的错误。
管理流的状态
流对象的rdstate
成员返回一个iostate
值,对应流的当前状态。setstate
操作将给定条件位置位,表示发生了对应错误。clear
成员是一个重载的成员:它有一个不接受参数的版本,而另一个版本接受一个iostate
类型的参数。
clear
不接受参数的版本清除(复位)所有错误标志位。执行clear()
后,调用good
会返回true
。我们可以这样使用这些成员:
1 | //记住cin的当前状态 |
带参数的clear
版本接受一个iostate
值,表示流的新状态。为了复位单一的条件状态位,我们首先用rdstate
读出当前条件状态,然后用位操作将所需位复位来生成新的状态。例如,下面的代码将failbit
和 badbit
复位,但保持eofbit
不变:
1 | //复位failbit和badbit,保持其他标志位不变 |
Luv的补充:
看完书中这一段,并没有完全明白这一块的具体实现原理。再详细补充一些内容:
第一,是
failbit
,badbit
,eofbit
三个标记位组成了流状态,而非failbit
、badbit
、eofbit
、goodbit
这四个标记位。第二,
strm::failbit
、strm::badbit
、strm::eofbit
、strm::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
的内容都是立即刷新的。 - 一个输出流可能被关联到另一个流。在这种情况下,当读写被关联的流时,关联到的流的缓冲区会被刷新。例如,默认情况下,
cin
和cerr
都关联到cout
。因此,读cin
或写cerr
都会导致cout
的缓冲区被刷新。
刷新输出缓冲区
我们已经使用过操纵符endl
,它完成换行并刷新缓冲区的工作。IO库中还有两个类似的操纵符:flush
和ends
。flush
刷新缓冲区,但不输出任何额外的字符;ends
向缓冲区插入一个空字符,然后刷新缓冲区:
1 | cout << "hi!" << endl; //输出hi和一个换行,然后刷新缓冲区 |
unitbuf操纵符
如果想在每次输出操作后都刷新缓冲区,我们可以使用unitbuf
操纵符。它告诉流在接下来的每次写操作之后都进行一次flush
操作。而nounitbuf
操纵符则重置流,使其恢复使用正常的系统管理的缓冲区刷新机制:
1 | cout << unitbuf; //所有输出操作后都会立即刷新缓冲区 |
如果程序崩溃,输出缓冲区不会被刷新。
关联输入流和输出流
当一个输入流被关联到一个输出流时,任何试图从输入流读取数据的操作都会先刷新关联的输出流。标准库将cout
和cin
关联在一起,因此下面语句:
1 | cin >> ival; |
导致cout
缓冲区被刷新。
一个流对象使用成员函数tie
,可以用来为其绑定输出流,有两个重载的版本:
- 不带参数,返回指向输出流的指针(当该对象未关联到流,返回空指针)。
- 接受一个指向
ostream
的指针,将自己关联到此ostream
。即,x.tie(&o)
将流x
关联到输出流o
。
1 | //old_tie指向当前关联到cin的流(如果有的话) |
文件输入输出
除了继承自iostream
类型的行为之外,fstream
中定义的类型还增加了一些新成员来管理与流关联的文件。在下表中列出了这些操作,我们可以对fstream
,ifstream
和ofstream
对象调用这些操作,但不能对其他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 | ifstream in(ifile); //构造一个 |
这段代码定义了一个输入流in
,它被初始化为从文件读取数据,文件名由string
类型的参数ifile
指定。第二条语句定义了一个输出流out
,未与任何文件关联。在新 C++ 标准中,文件名既可以是库类型string
对象,也可以是C风格字符数组。旧版本的标准库只允许C风格字符数组。
用fstream代替iostream&
在要求使用基类型对象的地方,我们可以用继承类型的对象来替代。这意味着,接受一个iostream
类型引用(或指针)参数的函数,可以用一个对应的fstream
(或sstream
)类型来调用。也就是说,如果有一个函数接受一个ostream&
参数,我们在调用这个函数时,可以传递给它一个ofstream
对象,对istream&
和ifstream
也是类似的。
例如,我们可以用前面定义的的read
和print
函数来读写命名文件。在本例中,我们假定输入和输出文件的名字是通过传递给main
函数的参数来指定的:
1 | ifstream input(argv[1]); //打开销售记录文件 |
重要的部分是对read
和print
的调用。虽然两个函数定义时指定的形参分别是istream&
和ostream&
,但我们可以向它们传递fstream
对象。
成员函数open和close
如果我们定义了一个空文件流对象,可以随后调用open
来将它与文件关联起来:
1 | ifstream in(ifile); //构筑一个ifstream并打开给定文件 |
如果调用open
失败,failbit
会被置位。因为调用open
可能失败,进行open
是否成功的检测通常是一个好习惯:
1 | if (out) //检查open是否成功 |
这个条件判断与我们之前将cin
用作条件相似。
一旦一个文件流已经打开,它就保持与对应文件的关联。实际上,对一个已经打开的文件流调用open
会失败,并会导致failbit
被置位。随后的试图使用文件流的操作都会失败。为了将文件流关联到另外一个文件,必须首先关闭已经关联的文件。一旦文件成功关闭,我们可以打开新的文件:
1 | in.close(); //关闭文件 |
如果open
成功,则open
会设置流的状态,使得good()
为true
。
自动构造和析构
考虑这样一个程序,它的main
函数接受一个要处理的文件列表。这种程序可能会有如下的循环:
1 | //对每个传递给程序的文件执行循环操作 |
每个循环步构造一个新的名为input
的ifstream
对象,并打开它来读取给定的文件。像之前一样,我们检查open
是否成功。如果成功,将文件传递给一个函数,该函数负责读取并处理输入数据。如果open
失败,打印一条错误信息并继续处理下一个文件。
因为input
是while
循环的局部变量,它在每个循环步中都要创建和销毁一次。当一个fstream
对象离开其作用域时,与之关联的文件会自动关闭。在下一步循环中,input
会再次被创建。
当一个fstream对象被销毁时,close会被自动调用。
文件模式
每个流都有一个关联的文件模式(file mode),用来指出如何使用文件。下表列出文件模式和它们的含义:
文件模式 | |
---|---|
in | 以读方式打开 |
out | 以写方式打开 |
app | 每次写操作前均定位到文件末尾 |
ate | 打开文件后立即定位到文件末尾 |
trunc | 截断文件 |
binary | 以二进制方式进行IO |
无论用哪种方式打开文件,我们都可以指定文件模式,调用open
打开文件时可用一个文件名初始化流来隐式打开文件时也可以。指定文件模式有如下限制:
- 只可以对
ofstream
或fstream
对象设定out
模式。 - 只可以对
ifstream
或fstream
对象设定in
模式。 - 只有当
out
也被设定时才可设定trunc
模式。 - 只要
trunc
没被设定,就可以设定app
模式。在app
模式下,即使没有显式指定out
模式,文件也总是以输出方式被打开。 - 默认情况下,即使我们没有指定
trunc
,以out
模式打开的文件也会被截断。为了保留以out
模式打开的文件的内容,我们必须同时指定app
模式,这样只会将数据追加写到文件末尾;或者同时指定in
模式,即打开文件同时进行读写操作(很后面将介绍对同一个文件既进行输入又进行输出的方法)。 ate
和binary
模式可用于任何类型的文件流对象,且可以与其他任何文件模式组合使用。
每个文件流类型都定义了一个默认的文件模式,当我们未指定文件模式时,就使用此默认模式。与ifstream
关联的文件默认以in
模式打开;与ofstream
关联的文件默认以out
模式打开;与fstream
关联的文件默认以in
和out
模式打开。
以out模式打开文件会丢失已有数据
默认情况下,当我们打开一个ofstream
时,文件的内容会被丢弃。阻止一个ofstream
清空给定文件内容的方法是同时指定app
模式:
1 | //在如下语句中,file1都被截断 |
保留被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
外,其他容器都提供高效、灵活的内存管理。我们可以添加和删除元素,扩张和收缩容器的大小。
string
和vector
将元素保存在连续的内存空间中。由于元素是连续存储的,由元素的下标来计算其地址是非常快速的。但是,在这两种容器的中间位置添加或删除元素就会非常耗时:在一次插入或删除操作后,需要移动插入/删除位置之后的所有元素,来保持连续存储。而且,添加一个元素有时可能还需要分配额外的存储空间。在这种情况下,每个元素都必须移动到新的存储空间中。
list
和forward_list
两个容器的设计目的是令容器任何位置的添加和删除操作都很快速。作为代价,这两个容器不支持元素的随机访问:为了访问一个元素,我们只能遍历整个容器。而且,与vector
、deque
和array
相比,这两个容器的额外内存开销也很大。
deque
是一个更为复杂的数据结构。与string
和vector
类似,deque
支持快速的随机访问,且在deque
的中间位置添加或删除元素的代价(可能)很高。但是,在deque
的两端添加或删除元素都是很快的,与list
或forward_list
添加删除元素的速度相当。
forward_list
和array
是新C++标准增加的类型。与内置数组相比,array
是一种更安全、更容易使用的数组类型。与内置数组类似,array
对象的大小是固定的。因此,array
不支持添加和删除元素以及改变容器大小的操作。forward_list
的设计目标是达到与最好的手写的单向链表数据结构相当的性能。因此,forward_list
没有size
操作,因为保存或计算其大小就会比手写链表多出额外的开销。对其他容器而言,size
保证是一个快速的常量时间的操作。
如果你不确定应该使用哪种容器,那么可以在程序中只使用vector和list公共的操作:使用迭代器,不使用下标操作,避免随机访问。这样,在必要时选择使用vector或list都很方便。
容器库共有的操作概述
在本节中将介绍所有容器(顺序容器、关联容器、无序容器)都适用的操作。
一般来说,每个容器都定义在一个头文件中,文件名与类型名相同。即,deque
定义在头文件 deque
中,list
定义在头文件list
中,以此类推。容器均定义为模板类。例如对vector
,我们必须提供额外信息来生成特定的容器类型。对大多数,但不是所有容器,我们还需要额外提供元素类型信息:
1 | list<Sales_data>; //保存Sales_data对象的list |
对容器可以保存的元素类型的限制
虽然我们可以在容器中保存几乎任何类型,但某些容器操作对元素类型有其自己的特殊要求。我们可以为不支持特定操作需求的类型定义容器,但这种情况下就只能使用那些没有特殊要求的容器操作了。
例如,顺序容器构造函数的一个版本接受容器大小参数,它使用了元素类型的默认构造函数。但某些类没有默认构造函数。我们可以定义一个保存这种类型对象的容器,但我们在构造这种容器时不能只传递给它一个元素数目参数:
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) | 交换a 和b 的元素 |
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
),这些运算只能应用于string
、vector
、deque
和array
的迭代器。我们不能将它们用于其他任何容器类型的迭代器。
迭代器范围
一个迭代器范围(iterator range)由一对迭代器表示,两个迭代器分别指向同一个容器中的元素或者是尾元素之后的位置(one past the last element)。这两个迭代器通常被称为begin
和end
,或者是first
和last
,它们标记了容器中元素的一个范围。
这种元素范围被称为左闭合区间(left-inclusive interval),其标准数学描述为[begin, end)
。
表示范围自begin
开始,于end
之前结束。迭代器begin
和end
必须指向相同的容器。end
可以与begin
指向相同的位置,但不能指向begin
之前的位置。
容器类型成员
除了已经使用过的迭代器类型,大多数容器还提供反向迭代器。简单地说,反向迭代器就是一种反向遍历容器的迭代器,与正向迭代器相比,各种操作的含义也都发生了颠例。例如,对一个反向迭代器执行 ++ 操作,会得到上一个元素。
剩下的就是类型别名了,通过类型别名,我们可以在不了解容器中元素类型的情况下使用它。如果需要元素类型,可以使用容器的value_type
。如果需要元素类型的一个引用,可以使用reference
或const_reference
。这些元素相关的类型别名在泛型编程中非常有用。
为了使用这些类型,我们必须显式使用其类名:
1 | //iter是通过list<string>定义的一个迭代器类型 |
这些声明语句使用了作用域运算符来说明我们希望使用list<string>
类的iterator
成员及vector<int>
类定义的difference_type
。
重载过的begin和end
1 | list<string> a = { ... }; |
不以c
开头的函数都是被重载过的。也就是说,实际上有两个名为begin
的成员函数。一是const
成员,返回容器的const_iterator
类型。另一个是非常量成员,返回容器的iterator
类型。rbegin
、end
和rend
的情况类似。当我们对一个非常量对象调用这些成员时,得到的是返回 iterator
的版本。只有在对个别const
对象调用这些函数时,才会得到一个const
版本。与const
指针和引用类似,可以将一个普通的iterator
转换为对应的const_iterator
,但反之不行。
以c
开头的版本是 C++ 新标准引入的,用以支持auto
与begin
和end
函数结合使用。
容器定义和初始化
只有顺序容器(不包括array
)的构造函数才能接受大小参数:
接受大小参数的初始化 | |
---|---|
C seq(n) | seq 包含n 个元素,这些元素进行了值初始化;此构造函数是explicit 的(不适用于string ) |
C seq(n, t) | seq 包含n 个初始化为值t 的元素 |
将一个容器初始化为另一个容器的拷贝
将一个新容器创建为另一个容器的考贝的方法有两种:可以直接拷贝整个容器,或者(array
除外)拷贝由一个迭代器对指定的元素范围。
为了创建一个容器为另一个容器的拷贝,两个容器的类型及其元素类型必须匹配。不过,当传递迭代器参数来拷贝一个范围时,就不要求容器类型是相同的了。而且,新容器和原容器中的元素类型也可以不同,只要能将要拷贝的元素转换为要初始化的容器的元素类型即可:
1 | //每个容器有三个元素,用给定的初始化器进行初始化 |
标准库array具有固定大小
与内置数组一样,标准库array
的大小也是类型的一部分。当定义一个array
时,除了指定元素类型,还要指定容器大小:
1 | array<string, 10> s; //类型为保存10个string的数组 |
array
大小固定的特性也影响了它所定义的构造函数的行为。与其他容器不同,一个默认构造的array
是非空的:它包含了与其大小一样多的元素,而这些元素都被默认初始化,就像一个内置数组中的元素那样。如果我们对array
进行列表初始化,初始值的数目必须等于或小于array
的大小。如果初始值数目小于array
的大小,则它们被用来初始化array
中靠前的元素,所有剩余元素都会进行值初始化。在这两种情况下,如果元素类型是一个类类型,那么该类必须有一个默认构造函数,以使值初始化能够进行:
1 | array<int, 10>ia1; //10个默认初始化的int |
值得注意的是,虽然我们不能对内置数组类型进行拷贝或对象赋值操作,但array
并无此限制:
1 | int digs[10] = {0,1,2,3,4,5,6,7,8,9}; |
与其他容器一样,array
也要求初始值的类型必须与要创建的容器类型相同。此外,array
还要求元素类型和大小也都一样,因为大小是array
类型的一部分。
使用assign(仅顺序容器)
assign操作 | 不适用于关联容器和array |
---|---|
seq.assign(b, e) | 将seq 中的元素替换为迭代器b 和e 所表示的范围中的元素。迭代器b 和e 不能指向seq 中的元素 |
seq.assign(il) | 将seq 中的元素替换为初始化列表il 中的元素 |
seq.assign(n, t) | 将seq 中的元素替换为n 个值为t 的元素 |
赋值运算符要求左边和右边的运算对象具有相同的类型。它将右边运算对象中所有元素拷贝到左边运算对象中。顺序容器(array
除外)还定义了一个名为assign
的成员,允许我们从一个不同但相容的类型赋值,或者从容器的一个子序列赋值。assign
操作用参数所指定的元素(的拷贝)替换左边容器中的所有元素。例如,我们可以用assgin
实现将一个vector
中的一段char*
值赋予一个list
中的string
:
1 | list<string> names; |
使用swap
swap
操作交换两个相同类型容器的内容。调用swap
之后,两个容器中的元素将交换:
1 | vector<string> svec1(10); //10个元素的vector |
调用swap
后,svec1
将包含24个string
元素,svec2
将包含10个string
。除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 | vector<int> v1 = {1, 3, 5, 7, 9, 12}; |
容器的关系运算符使用元素的关系运算符完成比较
只有当其元素类型也定义了相应的比较运算符时,我们才可以使用关系运算符来比较两个容器。
顺序容器操作
顺序容器和关联容器的不同之处在于两者组织元素的方式。这些不同之处直接关系到了元素如何存储、访问、添加以及删除。
向顺序容器添加元素
除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 的元素。返回指向新添加的第一个元素的迭代器;若n 为0,则返回p |
c.insert(p, b, e) | 将迭代器b 和e 指定的范围内的元素插入到迭代器p 指向的元素之前。b 和e 不能指向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 | vector<string> svec; |
将元素插入到vector、deque和string中的任何位置都是合法的。然而,这样做可能很耗时。
使用insert的返回值
通过使用insert
的返回值,可以在容器中一个特定位置反复插入元素:
1 | list<string> lst; |
使用emplace操作
当调用push
或insert
成员函数时,我们将元素类型的对象传递给它们,这些对象被拷贝到容器中。而当我们调用一个emplace
成员函数时,则是将参数传递给元素类型的构造函数。emplace
成员使用这些参数在容器管理的内存空间中直接构造元素。例如,假定c
保存Sales_data
元素:
1 | //正确,创建一个临时的Sales_data对象传递给push_back |
访问元素
下表列出了我们可以用来在顺序容器中访问元素的操作。如果容器中没有元素,访问操作的结果是未定义的。
在顺序容器中访问元素的操作 | |
---|---|
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
和下标操作只适用于string
、vector
、deque
和array
。
安全的随机访问和at成员函数
如果我们希望确保下标是合法的,可以使用at
成员函数。at
成员函数类似下标运算符,但如果下标越界,at
会抛出一个out_of_range
异常:
1 | vector<string> svec; |
删除元素
顺序容器的删除操作(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) | 删除迭代器b 和e 所指定范围内的元素。返回一个指向最后一个被删元素之后元素的迭代器,若e 本身就是尾后迭代器,则函数也返回尾后迭代器 |
c.clear() | 删除c 中的所有元素。返回void |
forward_list有自己专属版本的erase,且不支持pop_back。
vector和string不支持pop_front。
特殊的forward_list操作
forward_list
是单向链表。在一个单向链表中,没有简单的方法来获取一个元素的前驱。出于这个原因,在一个 forward_list
中添加或删除元素的操作是通过改变给定元素之后的元素来完成的。这样,我们总是可以访问到被添加或删除操作所影响的元素。
由于这些操作与其他容器上的操作的实现方式不同,forward_list
并未定义insert
、emplace
和erase
,而是定义了名为insert_after
、emplace_after
和erase_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 是数量,b 和e 是表示范围的一对迭代器(b 和e 不能指向lst 内),il 是一个花括号列表。返回一个指向最后一个插入元素的迭代器。如果范围为空,则返回p 。若p 为尾后迭代器,则函数行为未定义 |
lst.emplace_after(p, args) | 使用args在p 指定的位置之后创建一个元素。返回一个指向这个新元素的迭代器。若p 为尾后迭代器,则函数行为未定义 |
lst.erase_after(p) lst.erase_after(b, e) |
删除p 指向的位置之后的元素,或删除从b 之后直到(但不包含)e 之间的元素。返回一个指向被删元素之后元素的迭代器,若不存在这样的元素,则返回尾后迭代器。如果p 指向lst 的尾元素或者是一个尾后迭代器,则函数行为未定义 |
改变容器大小
我们可以用resize
来增大或缩小容器,与往常一样,array
不支持resize
。如果当前大小大于所要求的大小,容器后部的元素会被删除;如果当前大小小于新大小,会将新元素添加到容器后部:
1 | list<int> ilist(10, 42); //10个int,每个值都为42 |
resize
操作接受一个可选的元素值参数,用来初始化添加到容器中的元素。如果调用者未提供此参数,新元素进行值初始化。如果容器保存的是类类型元素,且resize
向容器添加新元素,则我们必须提供初始值,或者元素类型必须提供一个默认构造函数。
resize操作极可能导致迭代器、指针和引用失效。
容器操作可能使迭代器失效
向容器中添加元素和从容器中删除元素的操作可能会使指向容器元素的指针、引用或迭代器失效。一个失效的指针、引用或迭代器将不再表示任何元素。使用失效的指针、引用或迭代器是一种严重的程序设计错误,很可能引起与使用未初始化指针一样的问题。
在向容器添加元素后:
如果容器是vector或string,且存储空间被重新分配,则指向容器的迭代器指针和引用都会失效。如果存储空间未重新分配,指向插入位置之前的元素的迭代器、指针和引用仍有效,但指向插入位置之后元素的迭代器、指针和引用将会失效
对于deque,插入到除首尾位置之外的任何位置都会导致迭代器、指针和引用失效。如果在首尾位置添加元素,迭代器会失效,但指向存在的元素的引用和指针不会失效。
对于list和forward_list,指向容器的迭代器(包括尾后迭代器和首前迭代器)、指针和引用仍有效。
当我们删除一个元素后:
对于list和forward_list,指向容器其他位置的迭代器(包括尾后迭代器和首前迭代器)、引用和指针仍有效。
对于deque,如果在首尾之外的任何位置删除元素,那么指向被删除元素外其他元素的迭代器、引用或指针也会失效。如果是删除deque的尾元素,则尾后迭代器也会失效,但其他迭代器、引用和指针不受影响;如果是删除首元素,这些也会受影响。
对于vector和string,指向被删元素之前元素的迭代器、引用和指针仍有效。
注意:当我们删除元素时,尾后迭代器总是会失效。
由于向迭代器添加元素和从迭代器删除元素的代码可能会使迭代器失效,因此必须保证每次改变容器的操作之后都正确地重新定位迭代器。这个建议对vector
、string
和deque
尤为重要。
vector容器空间
为了支持快速随机访问,vector
将元素连续存储。通常情况下,我们不必关心一个标准库类型是如何实现的,而只需关心它如何使用。然而,对于vector
和string
,其部分实现渗透到了接口中。
标准库实现者采用了可以减少容器空间重新分配次数的策略。当不得不获取新的内存空间时,vector
和string
的实现通常会分配比新的空间需求更大的内存空间。容器预留这些空间作为备用,可用来保存更多的新元素。这样,就不需要每次添加新元素都重新分配容器的内存空间了。
如下表所示,vector
和string
类型提供了一些成员函数,允许我们与它的实现中内存分配部分互动。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
来要求deque
、vector
或string
退回不需要的内存空间。此函数指出我们不再需要任何多余的内存空间。但是,具体的实现可以选择忽略此请求。也就是说,调用shrink_to_fit
也并不保证一定退回内存空间。
额外的string操作
除了顺序容器共同的操作之外,string
类型还提供了一些额外的操作。这些操作中的大部分要么是提供string
类和C风格字符数组之间的相互转换,要么是增加了允许我们用下标代替迭代器的版本。
构造string的其他方法
构造string的其他方法 | |
---|---|
string s(cp, n) | s 是cp 指向的字符数组中前n 个字符的铂贝。此数组至少应该包含n 个字符 |
string s(s2, pos2) | s 是string s2 从下标pos2 开始的字符的拷贝。若pos2>s2.size() ,构造函数的行为未定义 |
string s(s2, pos2, len2) | s 是string s2 从下标pos2 开始len2 个字符的拷贝。若pos2>s2.size() ,构造函数的行为未定义。不管len2 的值是多少,构造函数至多拷贝s2.size()-pos2 个字符 |
这些构造函数接受一个string
或一个const char*
参数,还接受(可选的)指定拷贝多少个字符的参数。当我们传递给它们的是一个string
时,还可以给定一个下标来指出从哪里开始拷贝:
1 | const char *cp = "Hello World!!!"; //以空字符结束的数组 |
通常当我们从一个const char*
创建string
时,指针指向的数组必须以空字符结尾,拷贝操作遇到空字符时停止。如果我们还传递给构造函数一个计数值,数组就不必以空字符结尾。如果我们未传递计数值且数组也未以空字符结尾,或者给定计数值大于数组大小,则构造函数的行为是未定义的。
当从一个string
拷贝字符时,我们可以提供一个可选的开始位置和一个计数值。开始位置必须小于或等于给定的string
的大小。如果位置大于size
,则构造函数抛出个out_of_range
异常。如果我们传递了一个计数值,则从给定位置开始拷贝这么多个字符。不管我们要求拷贝多少个字符,标准库最多拷贝到string
结尾,不会更多。
substr操作
substr
操作返回一个string
,它是原始string
的一部分或全部的拷贝。可以传递给substr
一个可选的开始位置和计数值:
1 | string s("Hello World"); |
如果开始位置超过了string
的大小,则substr
函数抛出一个out_of_range
异常。如果开始位置加上计数值大于string
的大小,则substr
会调整计数值,只拷贝到string
的末尾。
改变string的其他方法
额外的insert、assign和erase版本
除了接受迭代器的insert
和erase
版本外,string
还提供了接受下标的版本。下标指出了开始删除的位置,或是insert
到给定值之前的位置:
1 | s.insert(s.size(), 5, '!'); //在s末尾插入5个感叹号 |
标准库string
类型还提供了接受C风格字符数组的insert
和assign
版本。例如,我们可以将以空字符结尾的字符数组insert
到或assign
给一个string
:
1 | const char *cp = "Stately, plump Buck"; |
此处我们首先通过调用assign
替换s
的内容。我们赋予s
的是从cp
指向的地址开始的7个字符。要求赋值的字符数必须小于或等于cp 指向的数组中的字符数(不包括结尾的空字符)。
接下来在s
上调用insert
,我们的意图是将字符插入到s[size()]
处(不存在的)元素之前的位置。在此例中,我们将从cp
的第7个字符开始(至多到结尾空字符之前)拷贝到s
中。
但是,我们不能对insert函数同时使用迭代器定位以及用字符指针指定新字符来源。
我们也可以指定将来自其他string
或子字符串的字符插入到当前string
中或赋予当前string
:
1 | string s = "some string", s2 = "some other string"; |
append和relapce函数
string
类定义了两个额外的成员函数:append
和replace
,这两个函数可以改变string
的内容。
append
操作是在string
末尾进行插入操作的一种简写形式:
1 | string s("C++ Primer"), s2 = s; //将s和s2初始化为"C++ Primer" |
replace
操作是调用erase
和insert
的一种简写形式:
1 | //将"4th"替换为"5th"的等价方法 |
此例中调用
replace
时,插入的文本恰好与删除的文本一样长。这不是必须的,可以插入一个更长或更短的string
。
string搜索操作
string
类提供了6个不同的搜索函数,每个函数都有4个重载版本。下表描述了这些搜索成员函数及其参数。每个搜索操作都返回一个string::size_type
值,表示匹配发生位置的下标。如果搜索失败,则返回一个名为string::npos
的static
成员。标准库将npos
定义为一个const string:.size_type
类型,并初始化为值**-1**。由于npos
是一个unsigned
类型,此初始值意味着npos
等于任何string
最大的可能大小。
string搜索操作 | |
---|---|
s.find(args) | 查找s 中args第一次出现的位置 |
s.rfind(args) | 查找s 中args最后一次出现的位置 |
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
开始查找字符c
。pos
默认为0s2, pos 从 s
中位置pos
开始查找字符串s2
。pos
默认为0cp, pos 从 s
中位置pos
开始查找指针cp
指向的以空字符结尾的C风格字符串。pos
默认为0cp, pos, n 从 s
中位置pos
开始查找指针cp
指向的数组的前n
个字符。pos
和n
无默认值
find
函数完成最简单的搜索。它查找参数指定的字符串,若找到,则返回第一个配位置的下标,否则返回npos
:
1 | string name("AnnaBelle"); |
这段程序返回0,即子字符串"Anna"
在"AnnaBelle"
中第一次出现的下标。
搜索(以及其他string
操作)是大小写敏感的。当在string
中查找子字符串时要注意大小写:
1 | string name("annaBelle"); |
这段代码会将pos1
置为npos
,因为Anna
与anna
不匹配。
一个更复杂一些的问题是查找与给定字符串中任何一个字符匹配的位置。例如,下面代码定位name
中的第一个数字:
1 | string numbers("0123456789"), name("r2d2"); |
如果是要搜索第一个不在参数中的字符,我们应该调用find_first_not_of
。例如,为了搜索一个string
中第一个非数字字符,可以这样做:
1 | string dept("03714p3"); |
移动检索
我们可以传递给find
操作一个可选的开始位置。这个可选的参数指出从哪个位置开始进行搜索。默认情况下,此位置被置为0。一种常见的程序设计模式是用这个可选参数在字符串中循环地搜索子字符串出现的所有位置:
1 | string::size_type pos = 0; |
while
的循环条件将pos
重置为从pos
开始遇到的第一个数字的下标。只要find_first_of
返回一个合法下标,我们就打印当前结果并递增pos
。
compare函数
除了关系运算符外,标准库string
类型还提供了一组compare
函数,这些函数与C标准库的strcmp
函数很相似。类似strcmp
,根据s
是等于、大于还是小于参数指定的字符串,s.compare
返回0、正数或负数。
如下表所示,compare
有6个版本。根据我们是要比较两个string
还是一个string
与一个字符数组,参数各有不同。在这两种情况下,都可以比较整个或一部分字符串。
s.compare的几种参数形式 | |
---|---|
s2 | 比较s 和s2 |
pos1, n1, s2 | 将s 中从pos1 开始的n1 个字符与s2 进行比较 |
pos1, n1, s2, pos2, n2 | 将s 中从pos1 开始的n1 个字符与s2 中从pos2 开始的n2 个字符进行比较 |
cp | 比较s 与cp 指向的以空字符结尾的字符数组 |
pos1, n1, cp | 将s 中从pos1 开始的n1 个字符与cp 指向的以空字符结尾的字符数组进行比较 |
pos1, n1, cp, n2 | 将s 中从pos1 开始的n1 个字符与指针cp 指向的地址开始的n2 个字符进行比较 |
数值转换
新标准引入了多个函数,可以实现数值数据与标准库string
之间的转换:
1 | int i = 42; |
要转换为数值的string
中第一个非空白符必须是数值中可能出现的字符:
1 | string s2 = "pi = 3.14"; |
在这个stod
调用中,我们调用了find_first_of
来获得中第一个可能是数值的一部分的字符的位置。我们将s
中从此位置开始的子串传递stod
。stod
函数读取此参数,处理其中的字符,直至遇到不可能是数值的一部分的字符然后它就将找到的这个数值的字符串表示形式转换为对应的双精度浮点值。
string
参数中第一个非空白符必须是符号( + )或( - )或数字。它可以以0x或0X开头来表示十六进制数。对那些将字符串转换为浮点值的函数,string
参数也可以以数点( . )开头,并可以包含e或E来表示指数部分。对于那些将字符串转换为整型值的函数,根据基数不同,string
参数可以包含字母字符,对应大于数字9的数。
如果string不能转换为一个数值,这些函数抛出一个invalid_argument异常。如果转换得到的数值无法用任何类型来表示则抛出一个out_of_range异常。
string和数值之间的转换 | |
---|---|
to_string(val) | 一组重载函数,返回数值val 的string 表示。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 的起始子串(表示整数内容)的数值,返回值类型分别是int 、long 、unsigned long 、long long 、unsigned long long 。 b 表示转换所用的基数,默认值为10。p 是size_t 指针,用来保存s 中第一个非数值字符的下标,p 默认为0,即函数不保存下标 |
stof(s, p) stod(s, p) stold(s, p) |
返回s 的起始子串(表示浮点数内容)的数值,返回值类型分别是float 、double 或long double 。参数p 的作用与整数转换函数中一样 |
容器适配器
除了顺序容器外,标准库还定义了三个顺序容器适配器:stack
、queue
和priority_queue
。适配器(adaptor)是标准库中的一个通用概念。容器、迭代器和函数都有适配器。本质上,一个适配器是一种机制,能使某种事物的行为看起来像另外一种事物一样。一个容器适配器接受一种已有的容器类型,使其行为看起来像一种不同的类型。例如,stack
适配器接受一个顺序容器(除array
或forward_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) |
交换a 和b 的内容,a 和b 必须有相同类型,包括底层容器类型也必须相同 |
定义一个适配器
每个适配器都定义两个构造函数:默认构造函数创建一个空对象,接受一个容器的构造函数拷贝该容器来初始化适配器。例如,假定deq
是一个deque<int>
,我们可以用deq
来初始化一个新的stack
,如下所示:
1 | stack<int> stk(deq); //从deq拷贝元素到stk |
默认情况下,stack
和queue
是基于deque
实现的,priority_queue
是在vector
之上实现的。我们可以在创建一个适配器时将一个顺序容器名作为第二个类型参数,来重载默认容器类型:
1 | //在vector上实现的空栈 |
对于一个给定的适配器,可以使用哪些容器是有限制的。stack只要求push_back
、pop_back
和back
操作,因此可以使用除array
和forward_list
之外的任何容器类型来构造stack
。queue适配器要求back
、push_back
、front
和push_front
,因此它可以构造于vector
、list
或deque
之上。priority_queue除了front
、push_back
和pop_back
操作之外还要求随机访问能力,因此它可以构造于vector
或deque
之上,但不能基于list
构造。
Note: 书中有关queue能否用vector构造的阐述前后矛盾,Luv实测是可以的。
栈适配器
stack
类型定义在stack
头文件中。下表列出了stack
所支持的操作:
栈的特殊操作 | |
---|---|
s.pop() | 删除栈顶元素,但不返回该元素值 |
s.push(item) | 创建一个新元素压入栈顶,该元素通过拷贝item 而来 |
s.emplace(args) | 创建一个新元素压入栈顶,该元素通过args构造 |
s.top() | 返回栈顶元素,但不将元素弹出栈 |
每个容器适配器都基于底层容器类型的操作定义了自己的特殊操作。我们只可以使用适配器操作,而不能使用底层容器类型的操作。例如:
1 | stack<int> intStack; //空栈 |
队列适配器
queue
和priority_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:默认情况下,标准库在元素类型上使用 < 运算符来确定相对优先级。我们将在后面学习如何重载这个默认设置。