注:该教程建立在学习过 C 语言的基础上,因此很多提过的细节会忽略,主要学习一些 C 语言没有或不同的特性,建议先学习 C 语言基础教程
本文中没有特殊重申的,大多语句和特性都与 C 语言相同,C++是 C 的超集,兼容了 C 的大多数特性
开始
章节概要:编写一个简单的 C++程序;初识输入输出;使用 C++ 版本的 C 标准库头文件;类简介
编写一个简单的 C++程序
简单示例
// 相比C代码,可以省略main(void)的void int main() { return 0; }
初识输入输出
程序示例
#include <iostream> int main() { std::cout << "Enter two numbers!" << std::endl; int v1, v2; std::cin >> v1 >> v2; std::cout << "The sum of them is " << v1 + v2; return 0; }
C++的 IO 机制
1、C++包含了一个全面的标准库来提供IO 机制。其中有从 C 语言延续而来的
cstdio
库和新的iostream
库
2、iostream
库包含两个基础类型:istream
和ostream
,分别表示输入流和输出流
3、一个流(stream)就是一个字符序列,是从 IO 设备读出或写入 IO 设备的标准输入输出对象
1、标准库定义了 4 个IO 对象
2、为了处理输入,我们使用名为cin
的istream
类型对象,这个对象也被称为标准输入
3、对于处理输出,我们使用名为cout
的ostream
类型对象,这个对象也被称为标准输出
4、此外还有其他两个ostream
类型对象,名为cerr
和clog
。其中cerr
通常用来输出警告和错误信息,clog
用来输出程序运行时的一般性信息向流写入写出
1、如果需要使用
iostream
库中的对象进行输入输出,则需要用到流插入符将内容传输给流
2、<<
为输出运算符。其接受两个运算对象,左侧必须是一个ostream
对象,右侧的运算对象是要打印的值。此运算符将给定的值写入给定的ostream
对象中
3、>>
为输入运算符。其与>>
类型,它左侧接受一个istream
对象,右侧接受一个运算对象。它从给定的istream
中读入数据,并存入给定的对象中endl 操纵符
1、endl是一个被称为操纵符的特殊值
2、endl的效果是结束当前行(有换行效果),并将与设备关联的缓冲区的内容刷到设备中
3、缓冲刷新操作可以保证到目前为止程序所产生的所有输出都真正写入流中,而不是仅停留在内存中等待写入到流中命名空间
1、示例程序中使用了
std::cout
和std::endl
,而不是直接的cout
和endl
。其前缀std::
指出名字cout
和endl
是定义在名为std
的命名空间中的
2、命名空间可以帮助我们避免不经意的名字定义冲突以及使用库中相同名字导致的冲突。标准库定义的所有名字都在命名空间std
中
3、通过命名空间使用标准库有一个副作用:当使用标准库中的一个名字时,必须通过作用域运算符::
来显式声明我们想使用来自std
中的名字,如示例std::cout
那样(后续将给出一个更简单的访问标准库名字的方法)
使用 C++版本的 C 标准库头文件
建议使用 C++版本标准库
1、C++标准库中兼容了 C 语言的标准库,其按照如下命名规则命名
2、C 语言的头文件形如name.h
,C++将其命名为cname
。即去掉.h
后缀,文件名前添加字母c
3、因此stdio.h
和cstdio
内容是一样的,而且stdio.h
也能在 C++调用,但从命名规范上来讲,cstdio
更符合 C++的要求
4、特别的,在名为cname
的头文件定义的名字从属于命名空间std
,而原本.h
的则不然
5、一般来说,C++程序应使用名为cname
的头文件,因为这样标准库的名字总能在std
找到。如果使用.h
形式,那么程序员不得不时刻牢记哪些是从 C 语言继承过来的,哪些又是 C++独有的
类简介
我们将在之后详细学习类相关的知识,在此只简单介绍
什么是类
1、在 C++中,我们通过定义一个类来定义自己的数据结构
2、一个类定义了一个类型,以及与之相关的一组操作
3、类机制是 C++最重要的特性之一。实际上,C++最初的设计焦点就是能定义使用上像内置类型一样自然的类类型如果要使用一个类,我们需要了解三件事情:
1、类名是什么?
2、它在哪里定义的?
3、它支持什么操作?
变量和基本类型
章节概要:基本内置类型;指定字面量类型;变量;对象;列表初始化;C++关键字;复合类型;引用;指针;void* 指针;
const
限定符;const
的引用;constexpr
和常量表达式;处理类型;类型别名;auto
类型说明符;decltype
类型指示符;自定义数据结构;定义类;使用类
基本内置类型
基本数据类型
1、C++定义了一套包括算术类型和空类型在内的基本数据类型
2、其中算术类型包含了整型、浮点型、字符型、布尔型
3、空类型 void不对应具体的值,仅用于一些特殊的场合C++的基本数据类型与 C 语言规则相同,但 C++中直接支持了bool 类型,且
iostream
中直接支持了拓展类型(如int32_t
等)指定字面量类型
通过添加前缀或后缀,可以改变不同类型字面量的默认类型
指定字符和字符串字面量
前缀 类型 含义 u char16_t Unicode 16 字符 U char32_t Unicode 32 字符 L wchar_t 宽字符 u8 char UTF-8(仅用于字符串字面常量) 指定整型字面量
后缀 最小匹配类型 u 或 U unsigned l 或 L long ll 或 LL long long 指定浮点型字面量
后缀 类型 f 或 F float l 或 L long double
变量
变量
1、变量提供一个具名的、可供程序操作的存储空间
2、C++中的每个变量都有其数据类型,数据类型决定着变量所占内存空间的大小及布局方式、该空间能存储值的范围,以及变量能参与的运算
3、对于 C++程序员来说,变量和对象一般可以互换使用对象
1、C++程序员在很多场合都会使用对象这个名词。通常情况下,对象指一块能存储数据并具有某种类型的内存空间
2、一部分人对对象的定义并不相同,比如:一些人仅在与类有关的场景才使用对象这个词;另一些人把命名了的对象叫做变量;还有一些人把对象和值区分开来,对象指能被程序修改的数据,而值指只读的数据列表初始化
C++定义了初始化的好几种不同形式,如下:
int units_sold = 0; int units_sold(0); int units_sold = {0}; // 列表初始化 int units_sold{0}; // 列表初始化
列表初始化
1、作为C++11 新标准的一部分,用花括号来初始化变量得到了全面应用。这种初始化形式被称为列表初始化
2、现在,无论是初始化对象还是某些时候为对象赋新值,都可以使用这样一组由花括号括起来的初始值了
3、当用于内置类型变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化且初始值存在丢失信息的风险,编译器将报错(如下例)
4、这样的介绍看似无关紧要,因为我们不会故意用 long double 值初始化 int 变量,然而这种初始化可能在不经意间发生,因此这种赋值更加保守安全long double ld = 3.1415926536; int a{ld}, b = {ld}; // 报错:转换未执行,因为存在丢失信息的风险(丢失浮点精度) int c(ld), d = ld; // 正确:转换执行,且确实丢失了部分值(丢失了小数点后的浮点部分)
C++关键字
关键字 关键字 关键字 关键字 关键字 alignas continue friend register true alignof decltype goto reinterpret_cast try asm default if return typedef auto delete inline short typeid bool do int signed typename break double long sizeof union case dynamic_cast mutable static unsigned catch else namespace static_assert using char enum new static_cast virtual char16_t explicit noexcept struct void char32_t export nullptr switch volatile class extern operator template wchar_t const false private this while constexpr float protected thread_local const_cast for public throw
复合类型
复合类型是指基于其他类型定义的类型。C++有几种复合类型,在此主要了解其中两种:引用和指针
引用
示例
int ival = 1024; int &refVal = ival; // 定义引用,refVal 指向 ival int &refVal2; // 报错:引用必须被初始化 /*------------------------------------------------------------------*/ refVal = 2; // 实际赋值给 refVal 指向的 ival int &refVal3 = refVal; // refVal3 指向 refVal 指向的 ival /*------------------------------------------------------------------*/ double dval = 10; int &refVal4 = dval; // 报错:refVal4 为int类型,其指向对象必须为int类型 int &refVal5 = 10; // 报错:引用只能绑定在对象上
引用介绍
1、引用为对象起了另外一个名字,通过将声明符写成
&变量名
来定义引用类型
2、定义引用时,程序把引用和它的初始值绑定在一起。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定另外一个对象,所以引用必须初始化
3、引用并非对象,它只是为一个已经存在的对象所起的另外一个名字。定义一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的
4、所有引用的类型都要和与之绑定的对象严格匹配,且引用只能绑定在对象上
指针
C++的指针整体与 C 语言的指针相同,在此写出一些差异
空指针
1、空指针不指向任何对象,下为三种生成空指针的方法
2、第一种方法是使用字面值nullptr
来初始化指针。nullptr
是一种特殊类型的字面值,它可以被转换成任意其他的指针类型
3、第二种方法是使用字面值 0来生成空指针(注意不是 int 值)
4、第三种方法是使用NULL 预处理器变量,这个变量在头文件cstdlib
中定义,预处理变量不属于命名空间std
。在新标准下,现在的 C++程序最好使用nullptr
,同时尽量避免使用NULL
int *p1 = nullptr; int *pt = 0; // 使用 NULL 需要调用 cstdlib 头文件 int *p3 = NULL;
void* 指针
1、void* 是一种特殊的指针类型,可用于存放任意对象的地址。不同的是,我们对该地址指向对象的类型并不了解
2、利用 void* 指针能做的事有限,不能直接操作其所指的对象,因为并不知道对象是什么类型
3、概括来说,以 void* 视角来看,内存空间也就仅仅是内存空间,没办法访问内存空间中的对象
const 限定符
简述
1、有时我们希望定义一种值不能被改变的变量(或直接称为常量),可以用
const
关键字加以修饰
2、因为const
对象一旦创建,值就不能改变,所以const
对象必须初始化
3、const
的用法整体与 C 语言中类似const
的引用1、可以把引用绑定到
const
对象上,我们称之为对常量的引用(或常量引用)
2、常量引用仅对引用本身作出限定,对于引用的对象并未限定。如果对象本身不是常量,那么允许通过其他途径修改它的值const int i = 1024; const int &r1 = i; // 正确:引用及其对应对象都是常量 r1 = 42; // 错误:r1是对常量的引用 int &r2 = i; // 错误:r2是对非常量的引用,不能指向常量对象 /*------------------------------------------------------------*/ int n = 30; const int &r1 = n; r1 = 40; // 错误:r1是对常量的引用 n = 40; // 正确:n本身不是常量
constexpr
和常量表达式常量表达式
1、常量表达式指值不会改变且在编译过程中就能确定值的表达式
2、一个对象是不是常量表达式,由它的数据类型和初始值共同决定const int max_file = 20; // 是常量表达式 const int limit = mix_file + 1; // 是常量表达式 int staff_size = 27; // 不是常量表达式:数据类型只是普通 int const int sz = get_size(); // 不是常量表达式:不能在编译过程中确定值
constexpr
变量1、在一个复杂系统中,很难(甚至几乎肯定不能)分辨一个初始值是不是常量表达式
2、C++11规定,允许将变量声明为constexpr
类型,由编译器来验证变量的值是否为常量表达式
3、声明为constexpr
的变量一定是一个常量,而且必须用常量表达式初始化
4、不能使用普通函数作为constexpr
初始值,允许定义一种特殊的constexpr
函数,后续介绍constexpr int mf = 20; // 编译通过 constexpr int limit = mf + 1; // 编译通过 constexpr int sz = size(); // 编译错误:除非 size() 是 constexpr 函数
字面值类型
1、常量表达式的值需要在编译时就得到计算,这些类型一般比较简单,值也显而易见,就把它们称为字面值类型
2、目前为止,算术类型、引用、指针都是字面值类型。自定义类、IO 库、string 类型都不是字面值类型,也就不能被定义为constexpr
3、尽管指针和引用都能被定义成constexpr
,但它们的初始值受到严格限制。如constexpr
指针的初始值只能是nullptr
或0,或存储于某个固定地址中的对象
处理类型
类型别名
1、有两种方法可用于定义类型别名,第一种是使用
typedef
关键字,与 C 语言用法相同
2、新标准规定了一种新方法:使用别名声明。这种方法使用using
关键字作为别名声明的开始,其后紧跟别名和等号,作用是把等号左侧的名字规定成等号右侧的类型的别名typedef double wages; // wages 是 double 的别名 typedef wages base, *p; // base 是 wages(即double)的别名,p 是 double* 的别名 using SI = Sales_item; // SI 是 Sales_item 的别名
auto
类型说明符1、编程时常常需要把表达式的值赋给变量,这就要求在声明变量时清楚地知道表达式的类型,然而做到这一点并不容易,甚至有时根本做不到
2、C++11引入了auto
类型说明符,能让编译器替我们去分析表达式的类型
3、和原来只对应一种特定类型的说明符(如 double)不同,auto
能让编译器通过初始值推算变量的类型。显然,auto
定义的变量必须有初始值
4、使用auto
也能在一条语句声明多个变量,但由于一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型必须都一样auto item = val1 + val2; // auto 自动判断表达式类型 auto i = 0, *p = &i; // 正确:i 是整数,p 是整型指针 auto sz = 0, pi = 3.14; // 错误:sz 和 pi 类型不一致
decltype
类型指示符1、有时我们希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。为此,C++11引入了
decltype
说明符
2、delctype
可以返回操作数的数据类型,在此过程中,编译器分析表达式并得到它的类型,但并不实际计算表达式的值
3、注意delctype
表达式的结果如果是加上括号的变量,则结果将是引用。即decltype(())
(注意是双层括号)的结果永远是引用int ci = 1; decltype(ci) x = 0; // x 的类型就是 ci 的类型,即 int,值为 0 decltype(def()) sum = x; // sum的类型就是函数 def() 的返回类型,值为 x 的值 /*--------------------------------------------------------------------*/ int i = 1; decltype((i)) d; // 错误:d 是 int& 引用类型,必须初始化 decltype(i) e; // 正确:e 是一个未初始化的 int 类型
自定义数据结构
什么是数据结构
1、从最基本的层面理解,数据结构是把一组相关的数据元素组织起来然后使用它们的策略和方法
2、举一个例子,我们想要创建一个Sales_items
类把书本的ISBN 编号、售出量、销售收入等数据组织在一起,并提供诸如isbn()
等函数,<<
、>>
、+
、+=
等运算在内的一系列操作,Sales_item
类就是一个数据结构
3、C++允许用户以类的形式自定义数据类型,而库类型string
、istream
、ostream
等也是以类的形式定义的定义类
简单的不含运算功能的类的定义实际就是C 的结构体定义,使用
struct
定义此外,C++提供另外一个关键字
class
定义自己的数据结构,后续介绍
使用类:简单的类的使用与C 的结构体使用规则相似,但定义类对象(结构变量)时不需要再使用
struct
再次声明
字符串、向量和数组
章节概要:命名空间的
using
声明;标准库类型string
;定义和初始化string
对象;string
对象的操作;处理string
对象中的字符;范围 for 语句;标准库类型vector
;模板;定义和初始化vector
对象;向vector
对象添加元素;其他vector
操作;vector
的索引;迭代器介绍;使用迭代器;泛型编程;迭代器运算;数组;auto
与数组;指针也是迭代器;与旧代码的接口
命名空间的 using 声明
引入
1、目前为止,我们用到的库函数基本上都属于命名空间
std
,而程序也显式地将这一点标示了出来,如std::cin
2、用这种方法显得比较繁琐,然而幸运的是,通过更简单的途径也能使用到命名空间的成员
3、本节将介绍其中一种最安全的方法,也就是使用using
声明,后续会介绍另一种方法using
声明1、有了
using
声明就无须专门的前缀,也能使用所需的名字了
2、using
声明具有如下形式using 命名空间名::名字
,如后示例
3、按照规定,每个using
声明只能引入命名空间中的一个成员,因此每个名字都需要独立声明
4、位于头文件的代码不应该使用using
声明,因为引用该头文件的源代码也会引入这个声明,对于某些程序,可能会产生名字冲突#include <iostream> using std::cin; int main() { int i; cin >> i; std::cout << i; return 0; }
标准库类型 string
介绍
1、标准库类型
string
表示可变长的字符序列(字符串),使用string
类型必须先包含string
头文件
2、作为标准库的一部分,string
定义在命名空间std
中
3、接下来的示例都假定已包含了下述代码#include <string> using std::string
定义和初始化 string 对象
1、如何初始化类的对象是由类本身决定的,一个类可以定义很多种初始化对象的方式,但这些方式之间必须有所区别:或者是初始值数量不同,或者是初始值类型不同
2、下为初始化string
对象的方式和示例方式 说明 string s1 默认初始化,s1 是一个空串 string s2(s1) 直接初始化,s2 是 s1 的副本 string s2 = s1 拷贝初始化,s2 是 s1 的副本 string s3(“value”) 直接初始化,s3 是字符串字面值”value”的副本,空字符除外 string s3 = “value” 拷贝初始化,s3 是字符串字面值”value”的副本,空字符除外 string s4(n, ‘c’) 直接初始化,把 s4 初始化为连续 n 个字符 c 组成的字符串 string s1; // 默认初始化,s1是空字符串 string s2 = s1; // s2是s1的副本 string s3("value"); // s3是字面值"value"的副本 string s3 = "value"; // s3是字面值"value"的副本 string s4(10, 'c'); // 直接初始化,s4内容是 cccccccccc string s4 = string(10, 'c'); // 拷贝初始化,s4内容是 cccccccccc
string 对象的操作
总览
1、一个类除了要规定初始化其对象的方式外,还要定义对象上能执行的操作。其中,类既能定义通过函数名调用的操作,也能定义各种运算符在该类对象上的新含义
2、下表列举了大部分string
类的操作操作 含义 os<<s 将 s 写入到输出流 os 中,返回 os is>>s 从 is 中读取字符串赋给 s,字符串以空格分隔,返回 is getline(is, s) 从 is 中读取一行赋给 s,返回 is s.empty() s 为空返回 true,否则返回 false s.size() 返回 s 中字符个数 s[n] 返回 s 中第 n 个字符的引用,位置 n 从 0 计起 s1+s2 返回 s1 和 s2 连接后的结果 s1=s2 用 s2 的副本代替 s1 中原来的字符 s1==s2 如果 s1 和 s2 中的字符完全一样,则它们相等,对字母的判断依据是 ASCII 码 s1!=s2 如果 s1 和 s2 中有一个字符不一样,则它们不相等,对字母的判断依据是 ASCII 码 <,<=,>,>= 对字符串从前向后依次比较字符的 ASCII 码,成立与否的依据是首个不相等字符的 ASCII 大小关系 读写操作
// 基本读写 int main() { string s; cin >> s; // 遇到空格停止 cout << s; return 0; }
// getline整行读取 int main() { string line; getline(cin, line); // 遇到换行符停止 cout << line << endl; return 0; }
// C语言风格EOF读写 int main() { string word; // while(getline(cin, line)) while(cin >> word) cout << word << endl; return 0; }
empty
和size
操作1、
empty
函数根据string
对象是否为空返回一个布尔值,size
函数返回string
对象的长度
2、这两个函数都是string
的成员函数。调用该类函数的方法是,使用点操作符指明是哪个对象执行成员函数即可(对象.成员函数()
)int main() { string line; while(getline(cin, line)) // 如果字符串不为空 且 字符串长度大于10 if(!line.empty() && line.size() > 10) cout << line << endl; return 0; }
string::size_type
类型1、实际上,
size
函数的返回值是一个string::size_type
类型的值
2、尽管我们不太清楚string::size_type
类型的具体细节,但有一点是肯定的,它是一个无符号类型值,且足够存放任何string
对象的大小
3、所有用于存放string
类的size
函数返回值的变量,都应该是该类型(auto len = line.size();
中line
的类型也自动设为该类型)
4、由于该类型是无符号整型,因此切记,如果在表达式中混用了无符号数和有符号数会产生意想不到的错误。如变量 n是一个具有负值的 int,则表达式s.size() < n
几乎肯定是true,因为负值 n会自动转换成一个较大的无符号值字面值和
string
对象相加1、即使一种类型并非所需,我们也可以使用它,不过前提是这种类型能自动转换成所需类型
2、因为标准库允许把字符字面值和字符串字面值转换成string
对象,所以在需要string
对象的地方就可以用这两种字面值代替
3、注意:当把string
对象和这两种字面值混在一条语句中使用时,必须确保每个加法运算符的两侧的运算对象至少有一个是string
string s1 = "hello", s2 = "world"; string s3 = s1 + "," + s2 + '\n'; /*------------------------------*/ // 注意按顺序结合后结果的类型 string s4 = s1 + ","; // 正确 string s5 = "hello" + ","; // 错误 string s6 = s1 + "," + "world"; // 正确 string s7 = "hello" + "," + s2; // 错误
处理 string 对象中的字符
引入
1、我们经常需要单独处理
string
对象中的字符,这类处理的关键问题是如何获取字符本身,另一个关键问题是要知道能改变某个字符的特性
2、对于后者,我们可以使用C 语言继承而来的cctype
(即ctype.h
),对于后者,我们可以通过范围 for 语句实现范围 for 语句
1、如果想对
string
对象中的每个字符做点什么,目前最好的操作是使用C++11 标准提供的范围 for 语句(语法形式如后附)
2、其中变量将被用于访问序列中的基础元素。每次迭代,变量都会被初始化为对象序列的下一个元素值for (变量 : 对象序列) { 循环体; }
示例:统计标点数并输出标点
#include <cctype> #include <iostream> #include <string> using std::cin; using std::cout; using std::endl; using std::getline; using std::string; int main() { int ct = 0; string s; getline(cin, s); for (auto c : s) { if (ispunct(c)) { ct++; cout << c << ' '; } } cout << endl << "ct: " << ct << endl; return 0; }
使用范围 for 改变字符
1、如果想要改变
string
对象中字符的值,只需要把循环变量定义成引用类型即可
2、记住,所谓引用只是给定对象的一个别名,因此当引用作为循环控制变量时,这个变量实际被依次绑定到序列的每个元素上
3、下示例将整个字符串中的小写字母转换成大写字母#include <cctype> #include <iostream> #include <string> using std::cin; using std::cout; using std::endl; using std::getline; using std::string; int main() { string s; getline(cin, s); for (auto &c : s) { c = toupper(c); cout << c << ' '; } cout << endl << s << endl; }
只处理一部分字符
1、如果只想处理一部分字符,那么范围 for 语句则不能很好的胜任了。通常访问
string
对象中的单个字符有两种方式:一种是使用下标,另一种是使用迭代器(后续介绍)
2、string
对象的下标使用与C 语言字符数组规则类似,使用for 循环遍历处理。对象的下标必须大于等于 0且小于s.size()
标准库类型 vector
介绍
1、标准库类型
vector
表示对象的集合,其中所有对象的类型都相同
2、集合中的每个对象都有一个与之对应的索引,索引用于访问对象
3、因为vector
容纳着其他对象,所以它也常常被称作容器
4、要想使用vector
,必须包含适当的头文件。后续的例子中,都假设包含以下声明#include <vector> using std::vector;
模板
模板简介
1、C++既有类模板,也有函数模板,其中
vector
是一个类模板。只要对 C++有深入了解后才能写出模板,我们将在 16 章介绍。但即使不会创建模板,我们也可以试着使用模板
2、模板本身不是类或函数,相反可以将模板看做编译器生成类或函数编写的一份说明。编译器根据模板创建类或函数的过程称为实例化,当使用模板时,需要指出编译器应把类或函数实例化成何种类型
3、对于类模板来说,我们通过提供一些额外信息来指定模板应实例化成什么样的类,需要提供哪些信息由模板决定。提供信息的方式总是这样:在模板名字后面跟一对尖括号,在括号内放上信息,模板名<信息>;
vector 模板
1、例如
vector
模板,需要提供的信息是vector
内存放的对象类型(示例如后附代码)
2、vector
是模板而非类型,由vector
生成的类型必须包含vector
中所存放元素的类型,例如vector<int>
3、vector
能容纳绝大多数类型的对象作为其元素,但是由于引用不是对象,所以不存在包含引用的vector
4、早期版本 C++中,如果vector
的元素还是vector
,必须在外层的右尖括号和其元素类型之间添加一个空格。比如应该写成vector<vector<int> >
vector<int> ivec; // ivec保存int类型的对象 vector<Sales_item> Sales_vec; // 保存Sales_item类型的对象 vector<vector<string>> file; // 该向量的元素是vector对象
定义和初始化 vector 对象
下表列出了初始化
vector
对象的方法方法 说明 vector<T> v1 v1 是一个空 vector,潜在元素为 T 类型,执行默认初始化 vector<T> v2(v1) v2 中包含有 v1 所有元素的副本 vector<T> v2 = v1 同上等价 vector<T> v3(n,val) v3 包含 n 个重复的元素,每个元素值都是 val vector<T> v4(n) v4 包含 n 个重复执行了值初始化的对象 vector<T> v5{a,b,c,…} v5 包含了初始值个数的元素,每个元素被赋予了相应的初始值 vector<T> v5 = {a,b,c,…} 同上等价 区分
()
值初始化和{}
列表初始化的含义1、
vector<int> ivec(3,-1)
含义是ivec存放了3 个值为 -1 的元素;而vector<int> ivec{3,-1}
含义是ivec存放了两个元素,值分别为3 和 -1
2、vector<int> ivec(10)
是指ivec创建了10 个执行了值初始化的元素(如 int 类型会被初始化为 0)
3、简单地说,想要初始化多个相同值就使用()
值初始化,想要初始化多个不同值就使用{}
列表初始化
向 vector 对象添加元素
1、经常我们会遇到,创建一个
vector
时并不清楚实际所需元素个数,元素的值也无法确定;还有些时候元素初值已知,但这些值总量较大且各不相同
2、例如我们需要vector
对象存储 1-100 的数字,对此更好的办法是先创建一个空vector
,然后在运行时利用vector
的成员函数push_back
来添加数据
3、push_back
负责把一个值当成vector
对象的尾元素,添加到对象的尾端,示例如下
4、注意:如果循环体内部含有向vector
添加元素的语句,则不能使用范围 for 语句vector<int> ivec; for (int i = 1; i <= 100; i++) ivec.push_back(i);
其他 vector 操作
vector
提供了一些其他操作,下表列出其中比较重要的一些操作 含义 v.empty() 如果 v 不含有任何元素,返回 true,否则返回 false v.size() 返回 v 中元素的个数,返回值类型是 vector\<T>::size_type
类型v.push_back(t) 将值为 t 的元素添加到 v 的尾端 v[n] 返回 v 中第 n 个位置上元素的引用 v1 = v2 用 v2 中的元素拷贝替换 v1 中的元素 v1 = {a,b,c,…} 用列表中的元素拷贝替换 v1 中的元素 ==,!=,<,<=,>,>= 与 string 相同,比较字典序
vector 的索引
1、和
string
类似,如果需要逐个访问全部vector
元素,也可以使用范围 for 语句+引用
2、对于需要非顺序访问,可以使用下标访问,下标同样从 0 开始计算
3、注意:只能对确知已存在的元素使用下标操作,因此不能使用下标添加元素,添加元素只能使用push.back
迭代器介绍
介绍
1、我们已经知道可以使用下标访问
string
对象的字符和vector
对象的元素,还有另一种更通用的机制也能实现,就是迭代器
2、除了vector
外,标准库还定义了其他几种容器。所有标准库容器都能使用迭代器,但只有少数几种才同时支持下标操作
3、类似于指针类型,迭代器也提供对对象的间接访问,同时迭代器也有有效和无效之分。有效的迭代器指向某个元素或指向容器尾元素的下一位置,其余都属于无效迭代器使用迭代器
引入
1、和指针不同的是,获取迭代器不是使用取地址符,有迭代器的类型同时拥有返回迭代器的成员
2、比如这些类型都拥有名为begin和end的成员,其中begin 成员负责返回指向第一个元素的迭代器,end 成员负责返回指向容器尾元素下一位置的迭代器
3、end 成员返回的迭代器常被称作尾后迭代器,或简称为尾迭代器。这样的迭代器没什么实际含义,只是个标记而已,表示我们已经处理完了容器中所有元素。特殊情况下,如果容器为空,则begin和end返回的是同一个迭代器
4、一般来说,我们不清楚(也不需要在意)迭代器的准确类型是什么,通常使用auto
关键字定义变量来自动确定类型// b 表示 v 的第一个元素,e 表示 v 尾元素的下一位置 auto b = v.begin(), e = v.end();
迭代器运算符
1、下表列举了部分迭代器支持的运算
2、和指针类似,也能通过解引用迭代器来获取它所指示的元素,执行解引用的迭代器必须合法并确实指示着某个元素
3、举个例子,使用迭代器访问string
对象,将字符串的首字母大写(如下例)运算 含义 *iter 返回迭代器 iter 所指元素的引用 iter->mem 解引用 iter 并获取该元素的名为 mem 的成员,等价于 (*iter).mem
++iter 或 iter++ 令 iter 指示容器中的下一个元素 –iter 或 iter– 令 iter 指示容器中的上一个元素 iter1 == iter2 判断两个迭代器是否相等,如果两个迭代器指示同一个元素或它们是同一个容器的尾迭代器,则相等,否则不相等 iter1 != iter2 判断两个迭代器是否相等,如果两个迭代器指示同一个元素或它们是同一个容器的尾迭代器,则相等,否则不相等 string s("some thing"); if (s.begin() != s.end()) // 确保 s 为非空字符串 { auto it = s.begin(); // it 表示 s 的第一个字符 *it = toupper(*it); // 改为大写 }
将迭代器移动到另外一个元素
1、迭代器使用递增运算符
++
来移动到下一个元素,使用递减运算符--
来移动到上一个元素
2、因为end 返回的迭代器并不实际指示某个元素,所以不能对其进行递增或解引用
3、下例为使用迭代器依次访问string
对象的字符,将整个字符串的字母大写string s("hello world"); for (auto it = s.begin(); it != s.end(); it++) *it = toupper(*it);
迭代器类型
1、就像不知道
string
和vector
的size_type
成员到底是什么类型一样,一般来说我们也不知道(也无需知道)迭代器的精确类型
2、实际上,那些拥有迭代器的标准库类型使用iterator
和const_iterator
来表示迭代器的类型,其中const_iterator
和常量指针差不多vector<int>::iterator it1; // it1 能读写 vector<int> 的元素 string::iterator it2; // it2 能读写 string 对象中的字符 vector<int>::const_iterator it3; // it3 只能读元素,不能写元素 string::const_iterator it4; // it4 只能读字符,不能写字符
cbegin
和cend
函数1、
begin
和end
返回的具体类型由对象是否是常量决定。如果对象是常量则返回const_iterator
,如果不是常量在返回iterator
2、有时候这种默认的行为并非我们所要,如果对象只需读无须写最好使用常量类型
3、为了便于我们得到const_iterator
类型的返回值,C++11 引入了两个新函数:cbegin
和cend
,其返回值一定是const_iterator
类型组合解引用和成员访问操作
1、解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是类,就有可能希望进一步访问它的成员
2、例如,对于一个由字符串组成的vector
对象,要想检查其元素是否为空,令it是该vector
的迭代器,只要检查it 所指字符串是否为空即可:(*it).empty()
(注意前面的括号不可省略,因为关系到运算符优先级问题)
3、为了简化上述表达式,C++定义了箭头运算符->
。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem
等效于(*it).mem
某些对
vector
对象的操作会使迭代器失效1、虽然
vector
对象可以动态地增长,但是也会有一些副作用
2、已知的一个限制是不能在范围 for 语句内向vector
添加元素。另外一个限制是任何一种可能改变vector
对象容量的操作,比如push_back
,都会使其迭代器失效(后续解释)
3、谨记,但凡是使用了迭代器的循环体,都不要向迭代器所属容器添加元素
泛型编程
1、原来使用 C 或 Java 的程序员转而使用 C++ 后,会对for 循环中大量使用
!=
而不是<
进行判断感到奇怪
2、C++程序员习惯性使用!=
,其原因和他们更愿意用迭代器而非下标的原因一样:这种编程风格在标准库提供的所有容器上都有效
3、与之类似,所有标准库容器的迭代器都定义了!=
和==
,但它们中的大多数没有定义<
运算符
4、因此,只要我们养成使用迭代器和!=
的习惯,就不用太在意用的到底是哪种类型迭代器运算
1、
string
和vector
的迭代器提供了更多额外的运算符,一方面可使得迭代器每次移动过多个元素,另外也支持迭代器进行关系运算。
2、所有这些运算都被称为迭代器运算,如下表运算 含义 iter + n 迭代器加上一个整数值结果仍为迭代器,迭代器指示的新位置与原来相比向前移动了若干个元素。结果迭代器或指示容器内的一个元素,或指示 end 位置 iter - n 迭代器减去一个整数值结果仍为迭代器,迭代器指示的新位置与原来相比向后移动了若干个元素。结果迭代器或指示容器内的一个元素,或指示 end 位置 iter += n 迭代器加法的复合赋值语句,将 iter 加 n 的结果赋给 iter iter -= n 迭代器减法的复合赋值语句,将 iter 减 n 的结果赋给 iter iter1 - iter2 两个迭代器相减的结果是它们之间的距离,参与运算的两个迭代器必须指向同一个容器中的元素或 end 位置 >,>=,<,<= 迭代器关系运算符,如果某迭代器指向的容器位置在另一个迭代器所指位置之前,则前者小于后者,参与运算的两个迭代器必须指向同一个容器中的元素或 end 位置,类型是 difference_type
的带符号整型使用迭代器运算
使用迭代器运算的一个经典算法是二分搜索,其从有序序列中寻找某个给定值
// text 必须是有序的,sought 是要搜索的给定值 auto beg = text.begin(), end = text.end(); // beg 和 end 表示搜索的范围 auto mid = text.begin() + (end - bag) / 2; // 初始中间点 while (mid != end && *mid != sought) { if (sought < *mid) // 如果在前半部分 end = mid; // 忽略后半部分 else // 如果在后半部分 beg = mid + 1; // 在mid之后寻找 mid = beg + (end - beg) / 2; // 新的中间点 }
数组
数组的大部分使用规则与 C 语言相同
auto 与数组
1、由于数组名是数组的首元素地址,所以使用
auto
关键字时,auto ia2(ia)
等同于auto ia2(&ia[0])
2、但如果使用decltype
关键字,上述转换不会发生,decltype(ia)
返回类型是由10 个整数构成的数组int ia[] = {0,1,2,3,4,5,6,7,8,9}; // ia2是一个整型指针,指向ia的首元素 auto ia2(ia); // ia3是一个含有10个整数的数组 decltype(ia) ia3 = {0,1,2,3,4,5,6,7,8,9}
指针也是迭代器
1、就像使用迭代器遍历
vector
对象那样,使用指针也能遍历数组中的元素。不过前提是先获取到指向数组首元素的指针和指向数组尾元素下一位的指针
2、获取首元素地址可以使用数组名,而尾后指针可以获取尾元素后那个并不存在的元素的地址。如假设定义int arr[10];
,则可以int *end = &arr[10]
3、尽管通过计算可以得到尾后指针,但这种方法极易出错。为了让指针的使用更简单,C++11 新增了两个函数:begin
函数和end
函数
4、这两个函数与容器中的两个同名成员功能类似,不过数组不是类类型,所以这两个函数不是成员函数。正确使用的方法是将数组作为它们的参数,如下int ia[] = {0,1,2,3,4,5,6,7,8,9}; int *begin = begin(ia); int *end = end(ia);
与旧代码的接口
引入
1、很多 C++程序在标准库出现之前就已经写成了,它们肯定没用到
string
和vector
类型;而且有一些 C++程序实际上是与 C 语言或其他语言的接口程序,当然也无法使用C++标准库
2、但是,现代的 C++ 程序不得不与那些充满了数组或C 风格字符串的代码衔接。为了使这一工作简单易行,C++专门提供了一组功能混用
string
对象和 C 风格字符串1、允许使用以
\0
结束的字符数组(即 C 风格字符串)来对string
对象进行初始化和赋值
2、在string
对象的加法运算中允许使用C 风格字符串作为其中一个运算对象(不能两个都是);在string
对象的复合赋值运算中允许使用C 风格字符串作为右侧运算对象
3、上述性质反过来就不成立了:如果程序某处需要一个C 风格字符串,不能用string
对象代替它,例如不能用string
对象初始化一个指向 char 的指针
4、为了完成该功能,string
专门提供了一个名为c_str
的成员函数:其返回一个指针,指向一个C 风格字符串,该字符串内容与string
对象一样;指针的类型是const *char
,确保不会改变字符数组的内容char *str = s; // 错误,不能用string对象初始化指向char的指针 const char *str = s.c_str(); // 正确
用数组初始化
vector
对象允许使用数组来初始化
vector
对象。要实现这一目的,只需指明要拷贝区域的首元素地址和尾后地址即可(最简单的方式是用begin
和end
函数)int int_arr[] = {0,1,2,3,4,5}; vector<int> ivec(begin(int_arr), end(int_arr));
表达式
章节概要:特性补充;sizeof运算符;强制类型转换
特性补充
C++的表达式和运算符与 C 大部分相同,在此补充或强调一些特性
sizeof 运算符
1、对
string
或vector
对象执行sizeof
运算符只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间
2、因为sizeof
的返回值是一个常量表达式constexpr size_t
,因此可以用sizeof
的结果声明数组的大小强制类型转换
一个命名的强制类型转换具有如下形式:
转换模式<转换类型>(表达式)
其中,转换模式是
static_cast
、dynamic_cast
、const_cast
、reinterpret_cast
中的一种static_cast
模式1、任何具有明确定义的类型转换,只要不包含底层
const
,都可以使用static_cast
,例如:double slope = static_cast<double>(i);
2、static_cast
对于编译器无法自动执行的类型转换也非常有用,例如可以找回存在于void*
的指针,转换回原始的指针类型,示例如后
3、但是对于存在于void*
的指针,应确保指针的值保持不变,且转换后所得类型就是指针所指类型// 任何非常量对象的地址都能存入void* void* p = &d; //将void*转换回原始的指针类型(指向double的指针) double *dp = static_cast<double*>(p);
const_cast
模式1、
const_cast
只能改变运算对象的底层const
,对于将常量对象转换成非常量对象的行为,称其为去掉const
性质
2、一旦去掉了某个对象的const
性质,编译器将不再阻止我们对该对象进行写入操作
3、如果对象本身不是一个常量,那么强制类型转换获得写权限是合法的行为;但如果对象本身是一个常量,则再使用const_cast
执行写操作将产生未定义的后果const char *pc; char *p = const_cast<char*>(pc); // 正确:但是写操作是未定义的行为 /*-----------------------------*/ const char *cp; char *q = static_cast<char*>(cp); // 错误:static_cast不能转换掉const性质 const_cast<string>(cp); // 错误,const_cast只能改变常量属性 static_cast<string>(cp); // 正确
reinterpret_cast
模式1、
reinterpret_cast
通常为运算对象的位模式提供较低层次上的重新解释
2、如下例,我们必须牢记pc所指的真实对象是 int 而非字符,如果把pc当成普通的字符指针使用就可以在运行时发生错误
3、使用reinterpret_cast
是非常危险的,其中关键问题是类型改变了,但编译器没有给出任何警告或错误提示。下面使用 pc 时就会认定它的值是char*类型,编译器没法知道它实际存放的是指向 int 的指针
4、reinterpret_cast
本质上依赖于机器。想要安全地使用,必须对涉及的类型和编译器实现转换的过程都相当了解int *ip; char *pc = reinterpret_cast<char*>(ip); string str(pc); // 错误:可能导致异常的运行时行为
dynamic_cast
模式:支持运行时类型识别,后续介绍此外,C 语言的旧式强制类型转换仍然支持,但与 C++ 的相比,表现形式上不那么清晰明了,出错追踪比较困难,建议使用 C++ 的转换方式
语句
章节概要:
try
语句块和异常处理;C++的异常处理;throw
表达式;try
语句块;编写处理代码;标准异常
try 语句块和异常处理
引入
1、异常是指存在于运行时的反常行为,这些行为超出了函数正常功能的范畴,典型的异常包括失去数据库连接以及遇到意外输入等。处理反常行为可能是设计所有系统最难的一部分
2、当程序的某部分检测到一个让它无法处理的问题时,就需要用到异常处理。如果程序中含有可能引发异常的代码,那么通常也会有专门的代码处理问题
3、异常处理机制为程序中异常检测和异常处理这两部分的协作提供支持。C++中,异常处理包括以下部分C++的异常处理
1、
throw
表达式:异常检测部分使用throw
表达式来表示它遇到了无法处理的问题,我们说throw
引发了异常
2、try
语句块:异常处理部分使用try
语句块处理异常。try
中包含一个或多个catch
子句,抛出的异常通常会被某个catch
子句处理。因为catch
子句处理异常,所以它们也被称为异常处理代码
3、一套类异常:用于在throw
表达式和相关的catch
子句之间传递异常的具体信息throw 表达式
1、程序的异常检测部分使用
throw
表达式引发一个异常,形式如throw 表达式;
,其中的表达式类型就是抛出的异常类型
2、如下示例,如果书籍的 ISBN 不一样就抛出异常,该异常类型是runtime_error
对象。抛出异常将终止当前的函数,并把控制权转移给能处理该异常的代码
3、类型runtime_error
是标准库异常类型的一种,定义在stdexcept
头文件中,后面将介绍其他的异常类型
4、我们必须初始化runtime_error
对象,方式是给它提供一个string
对象或一个C 风格字符串,这个字符串中有一些关于异常的辅助信息if(item1.isbn() != item2.isbn()) throw runtime_error("数据必须具有相同的ISBN"); cout << item1 + item2 << endl;
try 语句块
1、
try
语句块的通用语法形式如下
2、跟在try
块之后的是一个或多个catch
子句。catch
子句包括三部分:关键字catch
、括号内一个(可能未命名的)对象的声明(称作异常声明)、以及一个块
3、当选中了某个catch
子句异常处理之后,执行与之对应的块。catch
一旦执行完成,会跳转到try
块中最后一个catch
子句之后的语句继续执行
4、try
块中的程序语句组成程序的正常逻辑,像其他任何块一样,可以有包括声明在内的任意 C++ 语句try { 程序语句 } catch(异常声明) { 处理语句 } catch(异常声明) { 处理语句 } // ...
编写处理代码
1、编写异常处理代码,上面的程序可以按如下示例处理
2、将程序本来要执行的任务放在try
语句块中,因为这段代码可能抛出异常
3、try
对应一个catch
子句,该子句负责处理runtime_error
类型的异常。如果try
中抛出了此类异常,则会执行该catch
内的语句
4、catch
中输出给用户的信息中,输出了err.what()
的返回值。从catch
小括号的定义能得到err是一个runtime_error
类的对象,what
则是该类的一个成员函数
5、what
成员函数没有参数,返回值是C 风格字符串(即const char*
)。其中,runtime_error
的what
成员返回的是初始化一个具体对象时所用的string
对象的副本(即try
中初始化的字符串)while (cin >> item1 >> item2) { try { if(item1.isbn() != item2.isbn()) throw runtime_error("数据必须具有相同的ISBN"); cout << item1 + item2; } catch(runtime_error err) { cout << err.what() << "\n 再次尝试?输入y或n"<< endl; char c; cin >> c; if(!cin || c == 'n') { cout << "程序退出" << endl; break; // 跳出 while 循环 } } }
标准异常
标准异常类
1、C++标准库中定义了一组类,用于报告标准库函数遇到的问题。这些异常类也可以在用户编写的程序中使用,分别定义在4 个头文件
2、exception
:定义了最通用的异常类exception
,它只报告异常的发生,不提供任何额外信息
3、stdexcept
:定义了几种常用的异常类,详细信息见下表
4、new
:定义了bad_alloc
异常类型,后续介绍
5、type_info
:定义了bad_cast
异常类型,后续介绍stdexcept 定义的异常类 含义 exception 最常见的错误 runtime_error 只有在运行时才能检测出的任务 range_error 运行时错误:生成的结果超出了有意义的值域范围 overflow_error 运行时错误:计算上溢 underflow_error 运行时错误:计算下溢 logic_error 程序逻辑错误 domain_error 逻辑错误:参数对应的结果值不存在 invalid_argument 逻辑错误:无效参数 length_error 逻辑错误:试图创建一个超出该类型最大长度的对象 out_of_range 逻辑错误:使用一个超出有效范围的值 运算与成员
1、标准库异常类只定义了几种运算,包括创建或拷贝异常类型的对象,以及为异常类型的对象赋值
2、我们只能以默认初始化的方式初始化exception
、bad_alloc
、bad_cast
对象,不允许为这些对象提供初始值
3、其他异常类型的行为恰好相反,应该使用string
对象或者C 风格字符串初始化这些类型的对象,不允许使用默认初始化的方式。创建此类对象时,必须提供初始值,该值含有错误相关信息
4、异常类型之定义了一个名为what
的成员函数,返回值是C 风格字符串,字符串的目的是提供关于异常的一些文本信息。对于其他无初始值的异常类型,what
返回的内容由编译器决定
函数
章节概要:含有可变形参的函数;
initializer_list
形参;函数的返回值;不要返回局部对象的引用或指针;返回数组指针的函数;函数重载;定义重载函数;重载和const
形参;const_cast
和重载;特殊用途语言特性;默认实参;内联函数;constexpr
函数
含有可变形参的函数
简介
1、有时我们无法提前预知应该向函数传递几个实参。为了编写能处理不同数量实参的函数,C++11 提供了以下几种方法
2、如果所有的实参类型相同,可以传递一个名为initializer_list
的标准库类型
3、如果实参类型不同,可以编写一种特殊的函数——可变参数模板,将在后续介绍
4、此外还有一种从C 标准库stdarg.h
继承而来的省略符形参...
,但有了一些限制initializer_list 形参
简介
1、
initializer_list
是一种标准库类型,用于表示某种特定类型的值的数组,其定义在initializer_list
头文件中
2、和vector
一样,该类型也是一种模板类型,这意味着定义对象时,也必须说明列表中所含元素的类型:initializer_list<int> li;
3、和vector
不同的是,该类型对象中的元素永远是常量值,我们无法改变该类型对象中元素的值使用
1、我们可以使用该类型作为函数的形参,使用迭代器访问列表中的元素
2、使用函数时,如果想向initializer_list
形参中传递一个值的序列,则必须把序列放在一对花括号内
3、含有initializer_list
形参的函数也可以同时拥有其他形参// 定义函数 void error_msg(int error_code, initializer_list<string> i1) { cout << "error code:" << error_code; for(auto beg = i1.begin(); beg != i1.end(); beg++) cout << *beg << ' '; cout << endl; } // 传参使用 int error_number = 15; string s1("error1"), s2("error2"); error_msg(error_number, {"function", s1, s2});
省略号形参
...
1、省略号形参
...
沿用自C 语言的stdarg.h
库,为了便于C++程序访问某些特殊的C 代码
2、但省略符形参应该仅仅用于 C 和 C++ 通用的类型。特别应该注意,C++的大多数类类型的对象传递时都无法正确拷贝
函数的返回值
不要返回局部对象的引用或指针
1、函数完成后,所占用的内存空间也会随之释放。由此,局部变量的引用和指向局部变量的指针将指向不再有效的内存区域
2、如下示例,对于两条return
语句,都将返回未定义的值// 函数返回值类型是const string &,即一个引用 const string &manip() { string ret; if (!ret.empty()) return ret; // 错误:返回了局部对象的引用,返回的引用将指向无效区域 return "Empty"; // 错误:Empty也是一个局部临时量,返回的引用将指向无效区域 }
返回数组指针的函数
直接声明
1、如果我们想定义一个返回数组指针的函数,则数组的下标数必须跟在函数名后。而函数的形参列表也跟在函数名后,且应该先于数组的下标数
2、因此,返回数组指针的函数形式如:类型 (* 函数名(形参列表))[数组下标数]
,具体的例子和解读如下(可以参考类型声明黄金法则)int (*func(int i))[10]; /*---------------------*/ /* 解读: func(int i) :表示函数需要一个int类型形参 (*func(int i)) :返回类型是一个指针 (*func(int i))[10] :指针指向一个大小是10的数组 int (*func(int i))[10] :指向的是一个含有10个int值的数组 */
使用尾置返回类型
1、在 C++11 新标准中还有一种可以简化上述声明的方法,就是使用尾置返回类型
2、任何函数的定义都能使用尾置返回,但这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用
3、尾置返回类型跟在形参列表后面并以一个->
符号开头。为了表示函数真正的返回类型跟在形参列表后,原先标识返回值的地方改为auto
// 函数返回一个指针,指向含有10个整数的数组 auto func(int i) -> int(*)[10];
使用
decltype
获取类型1、还有一种情况,如果知道函数返回的指针将指向哪个数组,可以使用
decltype
来声明返回类型,如下例
2、decltype(odd)
标识了返回类型是一个含有 5 个 int 值的数组,*
标识了返回类型是一个指针
3、有一个地方要注意,decltype
并不负责把数组类型转换成相应的指针,所以如果需要返回指向数组的指针的函数,需要像下例中声明时加一个*
int odd[] = {1, 3, 5, 7, 9}; int even[] = {0, 2, 4, 6, 8}; // 使用 decltype(odd) 获取类型 decltype(odd) *arrPtr(int i) { return (i % 2) ? &odd : &even; // 返回一个指向数组的指针 }
函数重载
简介
1、如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载函数。如下例
2、这些函数接受的形参类型不同,但是执行的操作非常相似,使用函数重载可以在一定程度上减轻起名记名的负担
3、当调用这些函数时,编译器会根据传递的实参判断具体想要执行的是哪个函数// 函数原型 void print(char *cp); // 1 void print(int *beg, int *end); // 2 void print(int ia[], size_t size); // 3 /*--------------------------------------*/ // 函数调用 int j[2] = {0, 1}; print("Hello World"); // 调用 1 print(j, end(j) - begin(j)); // 调用 3 print(begin(j), end(j)); // 调用 2
定义重载函数
1、对于定义的重载函数来说,它们至少应该在形参数量或形参类型上有所不同
2、假设有两个函数,他们的返回类型不同但形参列表相同,这种声明不是重载函数,是错误的
3、有时候两个形参列表看起来不一样,但实际是相同的。比如省略了形参名,或使用类型别名
4、不能重载main()
函数// 不允许两个函数除了返回类型不同其余都相同 void lookup(Account&); bool lookup(Account&); // 错误 /*--------------------------------*/ // 形参列表相同 void lookup(Account &acct); void lookup(Account&); // 相同,省略了形参名 typedef Phone Telno; void lookup(Phone&); void lookup(Telno&); // 相同,使用了类型别名
重载和 const 形参
1、顶层
const
不影响传入函数的对象,一个拥有顶层const
的形参无法和另一个没有顶层const
的形参区分开来
2、如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,注意此时的const
是底层的void lookup(Phone); void lookup(const Phone); // 重复声明了void lookup(Phone) void lookup(Phone*); void lookup(Phone* const); // 重复声明了void lookup(Phone*),顶层const /*------------------------------------------------------*/ void lookup(Account&); // 作用于Account的引用 void lookup(const Account&); // 新函数,作用于常量引用 void lookup(Account*); // 作用于指向Account的指针 void lookup(const Account*); // 新函数,作用于指向常量的指针,底层const
const_cast 和重载
1、
const_cast
在重载函数的情境中最有用,如下例
2、原函数的参数和返回类型都是const string
的引用。虽然我们可以对两个非常量的string
调用这个函数,但返回结果仍是const string
,我们并不希望返回的是常量
3、此时我们需要一种新的函数,当它的实参不是常量时,得到的结果是一个非常量引用,使用const_cast
可以很方便做到这一点
4、新函数内通过const_cast
把参数先转为常量,调用原函数,再将原函数结果转为非常量// 原函数,返回 const string 的引用 const string &shorterString(const string &s1, const string &s2) { return s1.size() <= s2.size() ? s1 : s2; } // 重载函数,返回非 const 的引用 string &shorterString(string &s1, string &s2) { auto &r = shorterString(const_cast<const string&>(s1), const_cast<const string&>(s2)); return const_cast<string&>(r); }
特殊用途语言特性
默认实参
1、可以将函数的形参列表中的值赋予初始值,这便是默认实参。当函数调用时,如果没有给定这个值的实参,便会使用默认实参
2、我们可以为一个或多个形参赋予默认值。但要注意,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值
3、函数调用时,实参按照位置顺序解析,默认实参负责填补函数调用缺少的尾部实参(即靠右侧位置的形参)。即不能省略前面的参数值,只能省略尾部的参数值// 默认实参函数定义 typedef string::size_type sz; string screen(sz ht = 24, sz wid = 24, char background = ' '); // 函数调用 string window; window = screen(); // screen(24, 80, ' ') window = screen(66); // screen(66, 80, ' ') window = screen(66,256,'#') // screen(66, 256, '#') // 错误调用 window = screen(, , '?'); // 错误 window = screen('?'); // 错误,screen('?', 80, ' ')
内联函数
1、使用
inline
将函数指定为内联函数,通常就是将它在每个调用点上内联地展开,可以避免函数调用的开销。说明如下示例
2、内联只是向编译器发出的一个请求,编译器可以选择忽略这个请求
3、一般来说,内联机制用于优化规模较小、流程直接、调用频繁的函数。很多编译器不支持内联递归函数,而且一个大于 75 行的函数也不大可能在调用点内联展开// 原函数即程序调用 inline string shorterString(string &s1, string &s2) { return s1.size() <= s2.size() ? s1 : s2; } string s1 = "abcde", s2 = "abcdef"; cout << shorterString(s1, s2); // 在编译过程中,函数调用将直接展开成下面形式,从而不经过shorterString,减少了函数调用的开销 cout << (s1.size() <= s2.size() ? s1 : s2);
constexpr 函数
1、
constexpr
函数是指能用于常量表达式的函数。定义的方法与其他函数类似,但有如下规定:函数的返回类型及所有形参都必须是字面值类型,而且函数体中必须有且仅有一条return
语句
2、执行初始化任务时,编译器把对constexpr
函数的调用都替换成其结果值。为了能在编译过程中随时展开,constexpr
函数被隐式指定为内联函数
3、constexpr
函数不一定返回常量表达式constexpr size_t scale(size_t ct) { return 3 * ct; } int arr[scale(2)]; // 正确,编译时 scale(2) 将被内联展开为 6 int i = 2; int a2[scale(i)]; // 错误,i不是常量表达式
类
章节概要:定义抽象数据类型;设计
Sales_data
类;定义Sales_data
类;分析与设计;this
参数;const
成员函数;返回this
对象的函数;该类相关的非成员函数;构造函数;合成的默认构造函数;合成的默认构造函数的局限性;定义构造函数;= default
的含义;构造函数初始值列表;类外定义构造函数;拷贝、赋值和析构;访问控制与封装;public
和private
;class
和struct
;友元;类的其他特性;类成员再探;mutable
可变数据成员;返回*this
的成员函数;从const
成员函数返回*this
;基于const
的重载;友元再探;类之间的友元关系;令函数成员作为友元;友元函数重载和作用域;类的作用域;名字查找与类作用域;构造函数再探;委托构造函数;隐式类类型转换;explicit
抑制构造函数隐式转换;聚合类;字面值常量类;constexpr
构造函数;类的静态成员;声明、使用、定义静态成员;静态成员的类内初始化;静态成员与普通成员的区别
定义抽象数据类型
抽象数据类型
1、如果一个类,我们可以通过它的接口(例如描述的操作)来使用该类的对象,但不能访问该类的数据成员(甚至不知道该类有哪些数据成员),我们称这样的类为抽象数据类型
2、反之,如果一个类允许用户直接访问它的数据成员,并要求用户来编写操作,这样的类不是一个抽象数据类型
3、对于一个普通的数据类型(非抽象数据类型),如果想把它变成抽象数据类型,我们需要定义一些操作供类的用户使用。一旦定义了自己的操作,我们就可以封装它的数据成员了设计 Sales_data 类
引入
1、为了方便图书管理,我们需要设计一个类便于信息存储与操作,需要先分析满足下列操作:
2、类中需要有能读写的数据对象,需要对这些对象支持一些操作
3、操作包括一个名为isbn
的成员函数,并且支持+
、=
、+=
、<<
、>>
运算符
4、我们将在后续学习重载运算符,现在对于运算符运算,我们先定义为普通函数的形式
5、由于特殊的原因(后续重载运算符介绍),执行加法和IO 的函数不作为类的成员,我们将其定义成普通函数该类应包含的操作
1、一个
isbn
成员函数,返回对象的 ISBN 编号
2、一个combine
成员函数,用于将一个Sales_data
对象加到另一个对象上
3、一个add
函数,执行两个Sales_data
对象的加法
4、一个read
函数,将数据从istream
读入到Sales_data
对象中
5、一个print
函数,将Sales_data
对象的值输出到ostream
中使用类的接口(先不考虑如何实现该类,首先看看应该如何使用这些接口函数)
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 类
分析与设计
1、该类的数据成员有:
string
类型的bookNo,表示 ISBN 编号;unsigned
类型的units_sold,表示某书的销量;double
类型的revenue,表示这本书的总销售收入
2、类中将包含combine
和isbn
两个成员函数,此外,我们还需要另一个成员函数avg_price
返回售出书籍的平均价格。由于avg_price
的目的并非通用,所以它应该属于类的实现的一部分,而不是接口的一部分
3、定义和声明一个成员函数的方法与普通函数差不多。成员函数的声明必须在类的内部,而它的定义可以在外部。作为接口组成部分的非成员函数add
、read
、print
等,它们的定义和声明都在类的外部
4、定义在类内部的函数是隐式的内联函数struct Sales_data { // 数据成员 std::string bookNo; unsigned units_sold = 0; double revenue = 0.0; // 成员函数 std::string isbn() const { return bookNo; } Sales_data &combine(const Sales_data &); // 将在类外定义 double avg_price() const; // 将在类外定义 }; // 非成员接口函数 Sales_data add(const Sales_data &, const Sales_data &); std::ostream &print(std::ostream &, const Sales_data &); std::istream &read(std::istream &, Sales_data &);
this
参数1、
isbn
成员函数是如何获得bookNo 成员所依赖的对象的呢?
2、观察对isbn
的调用:total.isbn()
。我们使用了点运算符来访问total 对象中的isbn
成员函数并调用它
3、还有一种例外的形式,当我们调用成员函数时,实际上是在替某个对象调用它。如果isbn
指向Sales_data
的成员(如 bookNo),则它隐式地指向调用该函数的对象的成员。在上例中,当isbn
返回bookNo时,实际上它隐式地返回total.bookNo
4、成员函数通过名为this
的额外的隐式参数来访问和调用它的那个对象,当我们调用成员函数时,会用请求该函数的对象地址初始化this
。例如调用total.isbn()
时,编译器负责把total 的地址传递给isbn
的隐式形参this
5、在成员函数体内部,也可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符,因为this
所指的正是这个对象。任何对类成员的直接访问都被看作this
的隐式引用
6、对于我们来说,this
形参是隐式定义的,我们可以在成员函数体内部使用this
。尽管没有必要,但我们还是可以把isbn
的函数体写为return this->bookNo;
7、因为this
总是指向这个对象,所以this
是一个常量指针,不允许修改this
中保存的地址const
成员函数1、
isbn
函数的另一个关键之处是参数列表后的const
,这里,const
的作用是修改隐式this
指针的类型
2、默认情况下,this
的类型是指向类类型的非常量版本的常量指针,例如Sales_data
成员函数中,this
的类型是Sales_data *const
。这意味着,我们不能把this
绑定到一个常量对象上,这使得不能在一个常量对象上调用普通的成员函数
3、如果isbn
是一个普通函数且this
是一个普通指针形参,则我们应该把this
声明成const Sales_data *const
类型,毕竟isbn
函数体内不会改变this
所指的对象
4、然而,this
是隐式的而且不会出现在形参列表中,在哪将this
声明成指向常量的指针?C++的做法是把const
放在成员函数的参数列表后
5、紧跟在参数列表后的const
表示this
是一个指向常量的指针,而像这样使用const
的成员函数称作常量成员函数在类的外部定义成员函数
1、前面提过成员函数的定义可以在类外,我们的
avg_price
和combine
成员函数就打算通过这种方式定义
2、要注意,类外部定义的成员的名字必须包含所属的类名,通过作用域运算符来告知编译器定义的这个函数是类内的成员函数double Sales_data::avg_price() const { if (units_sold) return revenue / units_sold; else return 0; }
返回
this
对象的函数1、函数
combine
的设计初衷类似于复合赋值运算符+=
,其定义如下
2、当程序调用total.combine(trans);
时,this
指针绑定了total 的地址,rhs 引用绑定了实参 trans
3、该函数值得关注的是它的返回类型和返回语句。模仿标准的赋值运算符把它的左侧运算对象当做左值返回,因此combine
必须返回引用,而左侧运算对象又是Sales_data
对象,因此返回类型是Sales_data&
4、如前所述,我们需要把该对象当做左值返回。我们无须使用隐式的this
指针访问函数调用者的某个具体成员,而是要把调用函数的对象当做一个整体来访问,即return *this;
,该调用返回 total 的引用Sales_data &Sales_data::combine(const Sales_data &rhs) { // 把 rhs 的成员加到 this 的成员上 units_sold += rhs.units_sold; revenue += rhs.revenue; // 返回调用该函数的对象 return *this; }
该类相关的非成员函数
read
和print
函数1、
read
和print
函数分别接受一个各自 IO 类型的引用作为其参数,是因为IO 类属于不能被拷贝的类型,因此只能通过引用传递。而且,读取和写入的操作会改变流的内容,所以使用普通引用而不是const
引用
2、print
函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码决定是否换行// 输入的交易信息包括 ISBN、售出总数、售出单价 std::istream &read(std::istream &is, Sales_data &item) { double price = 0; is >> item.bookNo >> item.units_sold >> price; item.revenue = price * item.units_sold; return is; } std::ostream &print(std::ostream &os, const Sales_data &item) { os << item.isbn() << ' ' << item.units_sold << ' ' << item.revenue << ' ' << item.avg_price(); return os; }
add
函数Sales_data add(const Sales_data &lhs, const Sales_data &rsh) { Sales_data sum = lhs; // 把 lhs 的数据成员拷贝给 sum sum.combine(rsh); // 把 rsh 的数据成员添加到 sum return sum; // 返回新的 Sales_data 对象 }
构造函数
介绍
1、每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数控制其对象的初始化过程,这些函数被称为构造函数
2、构造函数的任务是初始化类对象的数据成员,无论何时只要有类的对象被创建,就会执行构造函数
3、构造函数的名字和类名相同。和其他函数不同的是,构造函数没有返回类型,且不能被声明成const
。类可以包含多个构造函数,和其他重载函数类似
4、当我们创建类的一个const
对象时,直到构造函数完成初始化过程,对象才真正取得了常量属性。因此构造函数在const
对象的构造过程中可以向其写值合成的默认构造函数
1、我们的
Sales_data
类并没有定义任何构造函数,可是之前使用了Sales_data
对象的程序却没有异常,它们是如何初始化的?
2、我们没有为这些对象提供初始值,它们执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数,其无须任何实参
3、如我们所见,默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式定义构造函数,编译器会为我们隐式定义一个默认构造函数,这样的函数有称为合成的默认构造函数
4、对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化数据:使用类内的初始值(如果有的话)初始化成员,否则默认初始化该成员合成的默认构造函数的局限性
1、合成的默认构造函数只适合非常简单的类,对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
2、首先,编译器只有在发现类不包含任何构造函数时才会替我们生成默认构造函数,一旦我们定义了一些其他构造函数,除非我们再定义一个默认生成函数,否则类将没有默认构造函数。如果一个类需要在某种情况下控制初始化,那么该类很可能在所有情况下都需要控制
3、其次,对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或数组指针这类的复合类型的对象被默认初始化,它们的值是未定义的。因此这样的类应该在类内初始化这些成员,或定义一个自己的默认构造函数
4、最后,有时候编译器不能为某些类合成默认构造函数。对这样的类来说,必须自定义默认构造函数,否则该类将没有可用的默认构造函数定义构造函数
示例
1、对于我们的
Sales_data
类,我们将使用下面的参数定义 4 个不同的构造函数
2、一个istream&
,从中读取一条交易信息
3、一个const string&
,表示 ISBN 编号;一个unsigned
,表示图书数量;一个double
,表示售出单价
4、一个const string&
,表示 ISBN 编号,编译器将赋予其他成员默认值
5、一个空参数列表(即默认构造函数),如刚刚介绍,既然已定义其他构造函数,那么也必须定义一个默认构造函数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 bookNo; unsigned units_sold = 0; double revenue = 0.0; // 成员函数 std::string isbn() const { return bookNo; } Sales_data &combine(const Sales_data &); double avg_price() const; };
= default
的含义1、我们首先解释默认构造函数:
Sales_data() = default;
2、首先请明确:因为该构造函数不接受任何实参,所以它是默认构造函数。我们定义它只是因为我们既需要其他形式的构造函数,又需要默认的构造函数,我们希望它的作用完全等同于合成的默认构造函数
3、在 C++11 中,如果我们需要默认的行为,可以通过在参数列表后写上= default
来要求编译器生成构造函数。其中= default
既可以和声明一起出现在类内,也可以作为定义出现在类外
4、和其他函数一样,如果= default
在类内,则默认构造函数是内联的;反之则不是内联的构造函数初始值列表
1、之后的两个构造函数中,出现了新的部分,即冒号及冒号和花括号之间的代码。其中花括号定义了空的函数体,我们把新出现的部分称为构造函数初始值列表
2、这部分负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后紧跟括号括起来的或者在花括号内的成员初始值。不同成员初始化通过逗号分隔
3、例如,上例含有三个参数的构造函数分别用前两个参数初始化了成员bookNo和units_sold,而revenue的初始值则通过revenue(p * n)
使用计算后的值作为初始值
4、需要注意,这两个构造函数的函数体是空的是因为这些构造函数的唯一目的就是为成员赋值,没有其他任务需要执行类外定义构造函数
1、我们将在类外定义以
istream
为参数的构造函数。该函数需要执行一些实际操作,所以在函数体内,调用了之前定义的read
函数给数据成员赋值
2、Sales_data::Sales_data
的含义是,我们定义的是Sales_data
类的成员,其成员名是Sales_data
3、这个构造函数的初始值列表为空,但是由于执行了构造函数体,所以对象的成员依然能被初始化Sales_data::Sales_data(std::istream &is) { read(is, *this); }
拷贝、赋值和析构
1、除了定义类的对象如何初始化外,类还需要控制拷贝、赋值和销毁对象时发生的行为
2、如果我们不主动定义这些操作,编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作
3、我们将在 13 章介绍如何自定义上述操作完整的类与使用
#include <iostream> #include <string> 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 bookNo; unsigned units_sold = 0; double revenue = 0.0; // 成员函数 std::string isbn() const { return bookNo; } Sales_data &combine(const Sales_data &); double avg_price() const; }; // 非成员接口函数 Sales_data add(const Sales_data &, const Sales_data &); std::ostream &print(std::ostream &, const Sales_data &); std::istream &read(std::istream &, Sales_data &); double Sales_data::avg_price() const { if (units_sold) return revenue / units_sold; else return 0; } Sales_data &Sales_data::combine(const Sales_data &rhs) { // 把 rhs 的成员加到 this 的成员上 units_sold += rhs.units_sold; revenue += rhs.revenue; // 返回调用该函数的对象 return *this; } // 输入的交易信息包括 ISBN、售出总数、售出单价 std::istream &read(std::istream &is, Sales_data &item) { double price = 0; is >> item.bookNo >> item.units_sold >> price; item.revenue = price * item.units_sold; return is; } std::ostream &print(std::ostream &os, const Sales_data &item) { os << item.isbn() << ' ' << item.units_sold << ' ' << item.revenue << ' ' << item.avg_price(); return os; } Sales_data add(const Sales_data &lhs, const Sales_data &rsh) { Sales_data sum = lhs; // 把 lhs 的数据成员拷贝给 sum sum.combine(rsh); // 把 rsh 的数据成员添加到 sum return sum; // 返回新的 Sales_data 对象 } // 构造函数 Sales_data::Sales_data(std::istream &is) { read(is, *this); } int main() { Sales_data total; // 保存当前求和结果的变量 if (read(std::cin, total)) // 读入第一笔交易 { Sales_data trans; // 保存下一条交易数据的变量 while (read(std::cin, trans)) // 读入剩余的交易 { if (total.isbn() == trans.isbn()) // 检查 isbn total.combine(trans); // 更新变量 total 当前的值 else { print(std::cout, total) << std::endl; // 输出结果 total = trans; // 处理下一本书 } } print(std::cout, total) << std::endl; // 输出最后一条交易 } else // 如果没有输入 { std::cerr << "No data?" << std::endl; // 通知用户 } return 0; }
访问控制与封装
public 和 private
1、对目前为止,我们已经为类定义了接口,但没有任何机制强制用户使用这些接口。我们的类还没有封装,也就是说,用户可以直达
Sales_data
对象内部并控制它的具体细节
2、C++中,我们使用访问说明符来加强类的封装性,如下说明。我们可以使用这些说明符再次定义Sales_data
类,如后示例程序
3、public
说明符:定义在publib
后的成员可以在整个程序内被访问,public
成员定义类的接口
4、private
说明符:定义在private
后的成员只可以被类的成员函数访问,不能被使用该类的代码访问,private
部分封装了类的实现细节
5、通常构造函数和部分成员函数跟在public
后,而数据成员和作为实现部分的函数跟在private
后
6、一个类可以包含任意数量的访问说明符,每个访问说明符指定了接下来的成员访问级别,其有效范围直到出现下一个访问说明符或类的结尾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) { } Sales_data(const std::string &s) : bookNo(s) { } Sales_data(std::istream &); std::string isbn() const { return bookNo; } Sales_data &combine(const Sales_data &); private: double avg_price() const { return units_sold ? revenue / units_sold : 0; } unsigned units_sold = 0; std::string bookNo; double revenue = 0.0; };
class 和 struct
1、上例我们使用了
class
而非struct
定义类,这种变化只是形式上有所不同,可以任意选择。唯一区别是它们的默认访问权限不一样
2、类可以在它第一个访问说明符前定义成员,这种成员的访问权限依赖于类定义的方式
3、使用struct
则这种成员默认是public
的,而class
这种成员默认是private
的友元
1、既然
Sales_data
的数据成员是private
的,那么我们的add
、print
、add
函数就无法正常编译了。这是因为这几个函数虽然是类接口的一部分,但不是类的成员
2、类可以允许其他类或函数访问它的非公有成员,方法是令它们成为该类的友元。对于函数,只需要增加一条friend
关键字开头的函数声明即可
3、友元声明只能出现在类定义内部,但友元不是类的成员也不受访问控制的约束
4、友元声明仅仅指定了访问权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,就必须在友元声明外再次专门进行一次函数声明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 &);
类的其他特性
类成员再探
为了展示这些新特性,我们需要定义一对相互关联的类:
Screen
和Window_mgr
定义类型成员
1、假设
Screen
表示显示器中的一个窗口,该类中包含一个用于保存内容的string
成员和分别用于表示光标位置、屏幕的高和宽的string::size_type
成员
2、除了定义数据和函数成员外,类还可以自定义某种类型在类中的别名。这种类型别名与其他成员一样存在访问限制,可以是public
或private
的一种
3、如下我们在public
部分定义了pos
别名,这样用户就可以使用这个名字。Screen
的用户不应该知道该类使用string
对象来存放数据,因此通过把pos
定义成public
可以隐藏Screen
的实现细节class Screen { public: typedef std::string::size_type pos; private: pos cursor = 0; pos height = 0, width = 0; std::string contents; };
Screen
类的成员函数class Screen { public: typedef std::string::size_type pos; Screen() = default; // 默认构造函数 Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) // 构造函数,cursor 会被类内初始值初始化为 0 { } char get() const // 读取光标处的字符 { return contents[cursor]; // 隐式内联 } // 重载成员函数 get 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; }; // 在类内声明为 inline char Screen::get(pos r, pos c) const { pos row = r * width; // 计算行的位置 return contents[row + c]; // 返回给定列的字符 } // 在定义处指定 inline inline Screen &Screen::move(pos r, pos c) { pos row = r * width; // 计算行的位置 cursor = row + c; // 在行内将光标移动到指定列 return *this; // 以左值形式返回对象 }
mutable
可变数据成员1、有时我们希望能修改类的某个数据成员,即使是在一个
const
成员函数里,对此可以通过在变量的声明中加入mutable
关键字声明一个可变数据成员
2、一个可变数据成员永远不会是const
,即使它是const
对象的成员。因此,一个const
成员函数可以改变一个可变成员的值
3、例如,我们将给Screen
添加一个access_ctr 可变成员,以跟纵成员函数被调用了多少次,如下例。尽管some_member
是一个const
成员函数,但依然可以改变access_ctr的值public: void some_member() const { ++access_ctr; // 保存一个计数值,用于记录成员函数被调用的次数 } private: mutable size_t access_ctr; // 即使在一个 const 对象内也能被修改
类数据成员的初始值
1、定义好
Screen
后,我们将继续定义一个窗口管理类并用它来表示显示器上的一组Screen
。这个类将包含一个Screen
类型的vector
,每个元素表示一个特定的Screen
2、默认情况下,我们希望Window_mgr
类开始时总是拥有一个默认初始化的Screen
。C++11 中,最好的办法是把这个默认值声明成一个类内初始值,由Screen
的构造函数初始化class Window_mgr { private: // 默认情况下,一个 Window_mgr 包含一个标准尺寸的空白 Screen,通过类内初始值和 Screen 的构造函数实现 std::vector<Screen> screens{Screen(24, 80, ' ')}; };
返回 *this 的成员函数
继续改进
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 作为左值返回 }
返回
*this
的意义1、和
move
操作一样,set
的返回值也是调用set
对象的引用,意味着这些函数返回的是对象本身而不是对象的副本
2、如果我们把一系列操作连在一条表达式:myScreen.move(4,0).set('#');
,这些操作将会在同一个对象上执行
3、更直观的,我们将这条语句的含义拆解,如下:// 如果返回 Screen &,其等价于: myScreen.move(4,0); myScreen.set('#'); // 如果返回 Screen,其等价于: Screen temp = myScreen.move(4,0); // 对返回值进行拷贝 temp.set('#'); // 不会改变 myScreen 的 contents
从
const
成员函数返回*this
1、下面我们要添加一个
display
操作,负责打印Screen
的内容。我们希望这个函数能和move
、set
出现在同一序列,因此该函数也应该返回它的对象的引用
2、从逻辑上说,显示并不需要改变对象内容,因此我们令display
是一个const
成员。此时this
将是一个指向const
的指针,而*this
就是const
对象
3、由此推断,display
的返回类型是const Screen&
。然而,如果真的返回一个const
的引用,那我们就不能将其嵌入到一组动作的序列里(如后示例)
4、即使myScreen是个非常量对象,对set
的调用也不能通过编译。问题在于display
的const
版本返回了常量引用,我们无权set
一个常量对象Screen myScreen; // 如果 display 返回常量引用,set 将无权更改常量对象的数据,因而报错 myScreen.display(cout).set('*');
基于
const
的重载1、通过区分成员函数是否是
const
的,我们可以对其进行重载,其原因主要如下
2、首先,非常量版本的函数对于常量对象是不可用的,所以我们只能在常量对象上调用const
成员函数。其次,虽然可以在非常量对象上使用常量版本的函数,但显然此时非常量版本是一个更好的匹配
3、如下例,我们将声明一个do_display
私有成员,用于负责打印。所有的display
操作都将调用这个函数,然后返回自己的类型的对象
4、当do_display
执行完成后,这非常量版本的display
函数返回普通引用,而常量版本的display
函数返回常量引用class Screen { public: // 普通版本 display Screen &display(std::ostream &os) { do_display(os); return *this; } // const 版本 display const Screen &display(std::ostream &os) const { do_display(os); return *this; } private: void do_display(std::ostream &os) const { os << contents; } };
完整的 Screen 类
#include <iostream> #include <string> #include <vector> class Screen { public: typedef std::string::size_type pos; Screen() = default; // 默认构造函数 Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) // 构造函数,cursor 会被类内初始值初始化为 0 { } char get() const // 读取光标处的字符 { return contents[cursor]; // 隐式内联 } inline char get(pos ht, pos wd) const; // 显式内联 Screen &move(pos r, pos c); // 能在之后被设置为内联 void some_member() const { ++access_ctr; // 保存一个计数值,用于记录成员函数被调用的次数 } Screen &set(char); Screen &set(pos, pos, char); // 普通版本 display Screen &display(std::ostream &os) { do_display(os); return *this; } // const 版本 display const Screen &display(std::ostream &os) const { do_display(os); return *this; } private: mutable size_t access_ctr; // 即使在一个 const 对象内也能被修改 pos cursor = 0; pos height = 0, width = 0; std::string contents; void do_display(std::ostream &os) const { os << contents; } }; // 在类内声明为 inline char Screen::get(pos r, pos c) const { pos row = r * width; // 计算行的位置 return contents[row + c]; // 返回给定列的字符 } // 在定义处指定 inline inline Screen &Screen::move(pos r, pos c) { pos row = r * width; // 计算行的位置 cursor = row + c; // 在行内将光标移动到指定列 return *this; // 以左值形式返回对象 } 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 作为左值返回 }
友元再探
先前我们的
Sales_data
类把三个普通的非成员函数定义成了友元,类还可以把其他的类定义成友元,还可以把其他类的成员函数定义成友元类之间的友元关系
1、假设我们的
Window_mgr
类的某些成员可能需要访问它管理的Screen
类的内部数据。例如我们需要为Window_mgr
添加一个clear 成员,用于把指定的Screen
内容设为空白
2、此时clear需要访问Screen
的私有成员,而要使这种访问合法,Screen
需要把Window_mgr
指定成它的友元
3、如果一个类指定了友元类,则友元类的成员函数可以访问此类包括私有成员在内的所有成员
4、需要注意的是,友元关系不存在传递性。即如果Window_mgr
有它自己的友元,这些友元不能理所当然地访问Screen
class Screen { friend class Window_mgr; // 此处省略先前的类对象 }; class Window_mgr { public: // 窗口中每个屏幕的编号 using ScreenIndex = std::vector<Screen>::size_type; // 按照编号将指定的 Screen 重置为空白 void clear(ScreenIndex i) { // s 是一个 Screen 的引用,指向我们想清空的屏幕 Screen &s = screens[i]; s.contents = std::string(s.height * s.width, ' '); } private: std::vector<Screen> screens{Screen(24, 80, ' ')}; };
令函数成员作为友元
1、除了令整个类作为友元外,还可以只为 clear 提供访问权限。当把一个成员函数声明称友元时,必须明确指出成员函数属于哪个类
2、但要想令某个成员函数作为友元,必须要按照如下方式设计程序:
3、首先定义Window_mgr
类,其中声明clear函数,但不能定义它。在clear使用Screen
成员之前必须先声明Screen
;接下来定义Screen
,包含对于clear的友元声明;最后定义clear,此时它才可以使用Screen
成员class Screen { friend void Window_mgr::clear(ScreenIndex); }
友元函数重载和作用域
1、尽管重载函数的名字相同,但它们仍是不同的函数。因此如果一个类想把一组重载函数声明成友元,则需要对每一个函数分别声明
2、类和非成员函数的声明不是必须在它们的友元声明之前,当一个名字第一个出现在一个友元声明中,我们隐式假定该名字在当前作用域中可见,然而友元本身不一定真的声明在当前作用域。甚至就算在类内定义该函数,我们也必须在类外提供相应的声明,从而使得函数可见
3、重点在于理解友元声明本身的作用是影响访问权限,而不是普通意义上的声明struct X { // 友元函数可以定义在类内,但不是真正的定义 friend void f(); X() { f(); // 调用 f() 函数,错误:f 还没有被声明 } void g(); void h(); }; void X::g() { return f(); // 调用 f() 函数,错误:f 还没有被声明 } void f(); // 声明定义在 X 中的函数 f,从此时 f 才被真正声明可用 void X::h() { return f(); // 正确:现在 f 的声明在作用域中了 }
类的作用域
类的作用域
引入
1、每个类都会定义它自己的作用域。在类的作用域外,普通数据和函数成员只能由对象、引用、指针使用成员访问运算符来访问;对于类类型成员则使用作用域运算符来访问
2、不论哪种情况,跟在运算符后的名字都必须是对应类的成员Screen::pos ht = 24, wd = 80; // 使用 Screen 定义的 pos 类型 Screen scr(ht, wd, ' '); Screen *p = &scr; char c = scr.get(); // 访问 scr 对象的 get 成员 c = p -> get(); // 访问 p 所指向的 scr 的 get 成员
作用域和定义在类外的成员
1、一个类就是一个作用域很好的说明了为什么我们在类外定义成员函数时需要提供
类名::函数名
。在类外,成员的名字被隐藏了
2、一旦遇到了类名,定义的剩余部分就在类的作用域内了,这里的剩余部分包括参数列表和函数体,因而我们可以直接使用类的其他成员而无须再次授权
3、Window_mgr
类的clear 成员用到了该类中定义的ScreenIndex
类型:void Window_mgr::clear(ScreenIndex i);
。这里使用ScreenIndex
时已经在该类的作用域下(因为在Window_mgr::
后),所以不需要额外说明
4、有时由于返回类型出现在类名前,此时想要使用ScreenIndex
作为返回类型,必须明确指定哪个类定义了它,如下// 作用域::返回类型 类名::函数名(形参列表) window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s)
名字查找与类作用域
用于类成员声明的名字查找
typedef double Money; std::string bal; class Account { public: Money balance() { return bal; } private: Money bal; };
1、编译器看到
balance
函数的声明语句时,将先在Account
类内查找 Money 的声明,没找到就到类外的作用域去查找,找到Money的typedef
语句
2、balance
函数体由于在整个类可见后才被处理,因此其return
语句返回名为 bal 的成员而不是外层的string
对象类型名要特殊处理
typedef double Money; class Account { public: Money balance() { return bal; // 已经使用了外层作用域的 Money } private: typedef double Money; // 错误:不能重新定义 Money Money bal; };
1、一般来说,内层作用域可以重新定义外层作用域的名字,即使该名字已经在内层作用域中使用过
2、然而在类中,如果成员使用了外层作用域中的名字,而该名字代表一种类型,则类不能在之后重新定义该名字
3、尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责,一些编译器将顺利通过这样的代码成员定义中的普通块作用域的名字查找
int height; // 定义了一个名字,稍后将在 Screen 中使用 class Screen { public: typedef std::string::size_type pos; void dummy_fcn(pos height) { cursor = width * height; // 使用的是函数的形参 height } private: pos cursor = 0; pos height = 0, width = 0; };
1、该例中,编译器处理函数中的乘法表达式时,它首先在函数作用域内查找名字,即先查找形参列表的同名形参。因此该例使用的是形参 height而非成员 height或全局 height
2、如果要在这种情况下单独使用其他作用域的名字,可以使用::height
调用全局 height,使用this -> height
或Screen::height
调用成员 height
构造函数再探
委托构造函数
1、C++11 新标准拓展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数,使用它所属类的其他构造函数执行它自己的初始化过程
2、和其他构造函数一样,一个委托构造函数也有一个成员初始值列表和一个函数体。与其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,列表必须与类中另一个构造函数匹配class Sales_data { public: // 非委托构造函数使用对应的实参初始化成员 Sales_data(std::string s, unsigned ct, double price) : bookNo(s), units_sold(ct), revenue(ct * 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); // 之前上文类中定义过的函数 } private: std::string bookNo; unsigned units_sold = 0; double revenue = 0.0; };
隐式类类型转换
介绍
1、在类中,如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称为转换构造函数
2、在先前的Sales_data
类中,接受string
的和接受istream
的构造函数分别定义了这两种类型向Sales_data
隐式转换的规则。也就是说,在需要使用Sales_data
的地方,可以使用string
或istream
代替
3、如下例,我们用一个string
实参调用了combine
成员函数。该调用合法,编译器用给定的string
自动创建了一个临时Sales_data
对象,这个新生成的临时对象被传递给了combine
std::string null_book = "9-999-99999-9"; // 构造一个临时的 Sales_data 对象,该对象的 units_sold 和 revenue 都为 0,bookNo 等于 null_book item.combine(null_book);
只允许一步类类型转换
1、编译器只会自动地执行一步类型转换,因此下面代码隐式使用了两种转换规则是错误的
2、如果我们想完成上述调用,可以主动进行一步显式转换,如后例// 错误:需要用户定义的两种转换,但编译器只转换一次 // 1 把 字符串字面量 转换成 string // 2 把 string 转换成 Sales_data item.combine("9-999-99999-9");
// 正确:显式转换成 string,隐式转换成 Sales_data item.combine(string("9-999-99999-9")); // 正确:隐式转换成 string,显式转换成 Sales_data item.combine(Sales_data("9-999-99999-9"));
explicit
抑制构造函数隐式转换1、我们可以通过将只有一个实参的构造函数声明为
explicit
来阻止隐式转换(多个实参的构造函数不能用于执行隐式转换,所以无须指定),此时将不能通过指定了explicit
的构造函数来隐式创建类对象
2、只能在类内声明构造函数时使用explicit
,在类外定义时不应重复
3、explicit
构造函数只能用于直接初始化,当我们执行拷贝初始化(=
)不能使用explicit
构造函数class Sales_data { public: explicit Sales_data(std::istream&): bookNo(s) { } }; // 错误:类外定义时不要重复使用 explicit explicit Sales_data::Sales_data(std::istream &is) { read(is, *this); } // 错误:不能使用拷贝初始化 Sales_data item = null_book;
聚合类
1、聚合类使得用户可以直接访问其成员,当一个类满足如下条件时,我们说它是聚合的:
2、所有成员都是public
的;没有定义任何构造函数;没有类内初始值;没有基类,也没有virtual
函数(这部分后续介绍)
3、我们可以提供一个花括号括起来的成员初始值列表来初始化聚合类的数据成员,顺序必须与声明的顺序一致字面值常量类
介绍
1、之前我们提到过
constexpr
函数的参数和返回值都必须是字面值类型。除了算术类型、引用、指针外,某些类也是字面值类型
2、和其他类不同,这样的类可能含有constexpr
构造函数,这样的成员必须符合constexpr
函数的所有要求,它们是隐式const
的
3、数据成员都是字面值类型的聚合类都是字面值常量类,此外,如果一个类满足以下要求,也是一个字面值常量类:
4、数据成员都必须是字面值类型;类必须包含至少一个constexpr
函数;数据成员的类内初始值必须是常量表达式,如果成员属于某种类类型,初始值必须使用成员自己的constexpr
构造函数;类必须使用析构函数的默认定义,该成员负责销毁类的对象constexpr
构造函数1、
constexpr
构造函数可以声明成=default
。否则,该函数就必须既满足构造函数的要求(不包含返回语句),又满足constexpr
函数的要求(唯一可执行的语句就是返回语句)
2、综合这两点,constexpr
构造函数体一般来说是空的
3、constexpr
构造函数必须初始化所有数据成员,初始值要么使用constexpr
构造函数,要么是一条常量表达式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; } private: bool hw; bool io; bool other; };
类的静态成员
引入
1、有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联
2、例如,一个银行账户类可能需要一个数据成员来表示当前的基准利率,我们希望利率与类关联,没必要每个对象都存储利率信息
3、一旦利率浮动,我们希望所有的对象都能使用新值声明静态成员
1、我们通过在成员的声明前加上关键字
static
使其与类关联在一起。下例为我们模拟实现的银行账户类
2、静态成员存在于任何对象之外,对象中不包含任何与数据成员有关的数据
3、静态成员函数也不与任何对象绑定在一起,它们不包含this
指针,且静态成员函数不能声明成const
class Account { public: void calculate() { amount += amount * interesRate; } static double rate() { return interesRate; } static void rate(double); private: std::string owner; double amount; static double interesRate; static double initRate(); };
使用静态成员
// 使用作用域运算符直接访问静态成员 double r; r = Account::rate(); // 使用类的对象、引用或指针来访问静态成员 Account ac1; Account *ac2 = &ac1; r = ac1.rate(); r = ac2 -> rate(); // 成员函数不通过作用域运算符就能直接使用静态成员 class Account { public: void calculate() { ammout += amount * interestRate; // 直接使用 } private: static double interestRate; };
定义静态成员
1、和其他成员函数一样,我们在类内和类外都可以定义静态成员函数。当在类外定义时,不要重复
static
关键字
2、因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的时候被定义的,这意味着它们不是由构造函数初始化的
3、我们不能在类内初始化静态成员,相反,我们必须在类外定义和初始化每个静态成员// 类外定义静态成员函数 void Account::rate(double newRate) { interestRate = newRate; } // 定义并初始化一个静态成员 double Account::interestRate = initRate();
静态成员的类内初始化
1、通常情况下,类的静态成员不应该在类内初始化
2、然而,我们可以为静态成员提供const
整型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
3、如果在类内提供了一个初始值,则静态成员的定义不能再指定一个初始值class Account { public: static double rate() { return interestRate; } static void rate(double); private: static constexpr int period = 30; // period 是常量表达式 double daily_tbl[period]; };
静态成员与普通成员的区别
1、静态成员能用于某些场景,而普通成员不能。静态成员独立于任何对象,因此,在某些普通成员可能非法的场合,静态成员可以正常使用。比如静态数据成员可以是不完全类型(后续介绍)
2、特别的,静态数据成员的类型可以是它所属的类类型,而普通成员只能声明成它所属类的指针或引用
3、此外,我们可以使用静态成员作为默认实参,而普通成员不能,这是因为普通成员的值本身属于对象的一部分
IO 库
章节概要:
IO
类;IO
库类型;IO
类型间的关系;IO
对象;无拷贝或赋值;条件状态;查询流的状态;管理条件状态;管理输出缓冲;缓冲刷新的原因;刷新输出缓冲区;unitbuf
操作符;tie
关联输入和输出流;文件输入输出;fstream
操作;fstream
对象;open
和close
;文件模式;指定文件模式的限制;阻止丢弃已有数据;string
流;stringstream
操作;使用istringstream
;使用ostringstream
IO 类
IO 库类型
1、目前为止,我们已使用过的
IO
类型和对象都是操纵char数据的。但现实场景下,我们不能限制实际应用程序仅从控制台窗口进行IO 操作
2、应用程序常常需要读写命名文件,而且使用IO
操作处理string
中的字符很方便。此外,还可能读写需要宽字符支持的语言
3、为了支持这些不同种类的IO 操作,在istream
和ostream
之外,标准库还定义了其他一些IO
库类型,下表列出其中部分
4、iostream
定义了用于读写流的基本类型;fstream
定义了读写命名文件的类型;sstream
定义了读写内存string
对象的类型
5、为了支持使用宽字符的语言,标准库定义了一组类型和对象来操纵wchat_t
类型数据。宽字符版本的类型和函数名以w
开头,如wcin
、wcout
、wcerr
等头文件 类型 描述 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 类型间的关系
1、概念上,设备类型和字符大小都不会影响我们要执行的IO 操作。比如我们可以使用
>>
读取数据,却不必管是从控制台,磁盘文件,或是string
中读取;也不必管读取的字符能存入一个char 对象内,还是需要一个wchar_t
对象
2、标准库使我们能忽略这些不同类型流之间的差异,是通过继承机制实现的。我们可以使用具有继承关系的类,而不必了解继承机制如何工作的细节
3、简单地说,继承机制使我们可以声明一个特定的类继承自另一个类。我们通常可以将一个派生类对象(继承类对象)当做其基类对象(被继承的类对象)来使用
4、比如,ifstream
和istringstream
都继承自istream
。因此,我们可以像使用istream
对象那样,使用ifstream
和istringstream
对象。这意味着,我们如何使用cin
的,就可以同样使用这些类型的对象IO 对象
无拷贝或赋值
1、我们不能拷贝或对
IO
对象赋值
2、由于不能拷贝,因此我们不能将形参或返回类型设置为流类型。进行IO 操作的函数通常以引用的方式传递和返回流
3、由于读写一个 IO 对象会改变其状态,所以传递和返回的引用也不能是const
的#include <fstream> std::ofstream out1, out2; out1 = out2; // 错误:不能对流对象赋值 std::ofstream print(std::ofstream); // 错误:不能初始化 ofstream 参数 out2 = print(out2); // 错误:不能拷贝流对象
条件状态
1、IO 操作一个与生俱来的问题是可能发生错误。一些错误是可恢复的,而其他错误可能发生在系统深处,已经超出了应用程序可修正的范围
2、下表列出了IO
类所定义的一些函数和标志,可以帮助我们访问和操纵流的条件状态函数和标志 描述 strm::iostate 是一种机器相关的类型,提供了表达条件状态的完整功能 strm::badbit 用来指出流已崩溃 strm::failbit 用来指出一个 IO 操作失败了 strm::eofbit 用来指出流到达了文件结束 strm::goodbit 用来指出流未处于错误状态,此值保证为 0 s.eof() 若流 s 的 eofbit 置位,则返回 true s.fail() 若流 s 的 failbit 或 badbit 置位,则返回 true s.bad() 如流 s 的 badbit 置位,则返回 true s.good() 若流 s 处于有效状态,则返回 true s.clear() 将流 s 中所有条件状态位复位,将流的状态设置为有效,返回 void s.clear(flag) 根据给定的 flag 标志位,将流 s 中对应条件状态位复位,其中 flag 的类型为 strm::iostate
,返回 voids.setstate(flag) 根据给定的 flag 标志位,将流 s 中对应条件状态位置位,其中 flag 的类型为 strm::iostate
,返回 voids.rdstate() 返回流 s 的当前条件状态,返回值类型为 strm::iostate
查询流的状态
1、
IO
库定义了与机器无关的iostate
类型,它提供了表达流状态的完整功能,该类型应作为一个位集合来使用
2、该类型包含如上表中的四个constexpr
值,这些值用来表示特定类型的 IO 条件,可以与位运算符一起使用来一次性检测或设置多个标志位
3、badbit
表示系统级错误,如不可恢复的读写操作,通常如果badbit
被置位,流就无法再使用了;failbit
在发生可恢复错误后被置位,如读取类型不对应等错误,通常这种问题是可修正的,流还可以继续使用;如果到达文件结束位置,eofbit
和failbit
都会被置位;goodbit
值为 0表示流未发生错误
4、同样,如上表所列,标准库还定义了一组函数来查询标志位的状态管理条件状态
1、如上表,标准库中还有一些用来管理条件状态的函数
2、流对象的成员rdstate
返回一个iostate
值,对应当前流的状态;setstate
操作将给定的条件位置位,表示发生了对应错误;clear
是一个重载成员函数,它分别有一个不接受参数的版本和接受一个iostate
参数的版本,用于将条件位复位auto old_state = cin.rdstate(); // 记住 cin 的当前状态 cin.clear(); // 复位,使 cin 有效 process_input(cin); // 使用 cin cin.setstate(old_state); // 将 cin 置为原有状态
管理输出缓冲
每个输出流都管理一个缓冲区,用来保存程序读写的数据。如果需要打印一串文本,文本串可能会立即打印出来,也可能被操作系统保存在缓冲区中,之后再打印
缓冲刷新的原因
1、程序正常结束,作为
main
函数的return
操作的一部分,缓冲刷新被执行
2、缓冲区满时,需要刷新缓冲,而后新的数据才能继续写入缓冲区
3、我们可以使用操纵符(如endl
)来显式刷新缓冲区
4、在每个输出操作之后,我们可以用操纵符unitbuf
设置流的内部状态,来清空缓冲区。默认情况下,对cerr
是设置unitbuf
的,因此写到cerr
的内容都是立即刷新的
5、一个输出流可能被关联到另一个流,这种情况下,当读写被关联的流时,关联到的流的缓冲区会被刷新。例如默认情况下,cin
和cerr
都关联到cout
,因此,读cin
或写cerr
都会导致cout
的缓冲区被刷新刷新输出缓冲区
1、我们已经使用过操纵符
endl
,它完成换行并刷新缓冲区的工作
2、IO
库中还有两个类似的操纵符:flush
和ends
。其中flush
只刷新缓冲区,不输出任何额外字符;ends
向缓冲区插入一个空字符,然后刷新缓冲区cout << "hi!" << endl; cout << "hi!" << flush; cout << "hi!" << ends;
unitbuf
操作符1、如果想在接下来每次输出操作后都刷新缓冲区,我们可以使用
unitbuf
操纵符
2、它告诉流在接下来的每次写操作之后都执行一次flush
操作
3、而使用nounitbuf
可以重置流,使其恢复使用正常的系统管理的缓冲区刷新机制cout << unitbuf; // 此后所有输出操作后都会立即刷新缓冲区 cout << nounitbuf; // 此后回到正常的缓冲机制
tie
关联输入和输出流1、当一个输入流被关联到一个输出流时,任何试图从输入流读取数据的操作将会导致关联的输出流被刷新
2、tie
有两个重载的版本。不带参数的tie
返回指向输出流的指针。如果本对象当前关联到一个输出流,则返回的就是这个流的指针;如果对象未关联到其他流,则返回空指针
3、接受一个指向ostream
的指针作为参数的tie
,将自己关联到此ostream
。即x.tie(&o)
返回指向原输出流的指针,并将流x
关联到新输出流o
4、我们既可以将istream
关联到ostream
,也可以将ostream
关联到ostream
。每个流同时最多关联到一个流,但多个流可以同时关联到同一个ostream
cin.tie(&cout); // 将 cin 和 cout 关联在一起 // old_tie 指向当前关联到 cin 的流(如果有的话) ostream *old_tie = cin.tie(nullptr); // cin 不再与其他流关联 // 将 cin 和 cerr 关联,这不是一个好主意,因为 cin 应该关联到 cout cin.tie(&cerr); // 读取 cin 会刷新 cerr 而不是 cout cin.tie(old_tie); // 重建 cin 和 cout 间的正常关联
文件输入输出
fstream
操作1、头文件
fstream
定义了三个类型来支持文件 IO:ifstream
、ofstream
、fstream
,这些类型提供的操作与我们之前是用过的对象cin
和cout
操作一样
2、特别是,我们可以用IO
运算符(流插入符<<
和>>
)来读写文件,可以用getline
从一个ifstream
读取数据
3、除了继承自iostream
类型的行为外,fstream
还增加了一些新成员来管理与流关联的文件,如下表fstream 操作 描述 fstream fstrm 创建一个未绑定的文件流,此处的 fstream 是头文件 fstream 中定义的一个类型 fstream fstrm(s) 创建一个 fstream,并打开名为 s 的文件。s 可以是 string 类型或指向字符数组形式字符串的指针。这些构造函数都是 explicit 的,默认文件模式 mode 依赖于 fstream 的类型 fstream fstrm(s, mode) 与前一个构造函数类似,但按照指定模式 mode 打开文件 fstrm.open(s) 打开名为 s 的文件,并将文件与 fstrm 绑定,返回 void。默认文件模式 mode 依赖于 fstream 的类型 fstrm.close() 关闭与 fstrm 绑定的文件,返回 void fstrm.is_open() 返回一个 bool 值,指出与 fstrm 关联的文件是否成功打开且尚未关闭 fstream
对象创建对象
1、当我们想要读写一个文件时,可以定义一个文件流对象,并将对象与文件关联起来
2、每个文件流都定义了一个名为open
的成员函数,它完成一些系统相关的操作,来定位给定的文件,并视情况打开为读或写模式
3、创建文件流对象时,可以直接提供文件名(可选),如果直接提供了文件名,则open
会被自动调用ifstream in(ifile); // 构造一个 ifstream 并打开 ifile 文件 ofstream out; // 输出文件流未关联到任何文件
可以用
fstream
替代iostream&
1、之前提到过,在要求使用基类类型对象的地方,我们可以使用继承类型对象来替代
2、这意味着,接受一个iostream
类型引用或指针的参数的函数,也可以用一个对应的fstream
或sstream
类型来调用open
和close
1、如果我们定义了一个空文件流对象,可以随后调用
open
来将它与文件关联起来,如下示例
2、如果调用open
失败,failbit
将被置位。由于调用可能失败,所以使用类似if(out)
的方式进行检测是好习惯
3、一旦一个文件流已经打开,它就会一直保持关联。如果对一个已打开的文件流调用open
会失败,并会导致failbit
被置位,随后试图使用该文件流的操作都会失败
4、为了将文件流关联到另一个文件,必须先close
关闭已关联的文件,成功关闭后才可以打开新的文件。如果open
成功,则open
会设置流的状态,使得good()
为trueifstream in(ifile); // 构造一个 ifstream 并打开 ifile 文件 ofstream out; // 输出文件流未关联到任何文件 out.open(ifile + ".copy"); // 打开指定文件 in.close(); // 关闭文件 in.open(ifile + "2"); // 打开另一个文件
文件模式
文件模式
1、每个流都有一个关联的文件模式,用来指出如何使用文件,如下表
2、无论用哪种方法打开文件(调用open
或文件名初始化流等),都可以指定文件模式
3、默认情况下,ifstream
以in
打开,ofstream
以out
打开,fstream
以in
和out
打开文件模式 含义 in 只读(输入)模式 out 只写(输出)模式 app 只写,每次写操作前均定位到文件末尾 ate 打开文件后立刻定位到文件末尾 trunc 阶段文件 binary 二进制模式 指定文件模式的限制
1、只可以对
ofstream
或fstream
设定out
模式
2、只可以对ifstream
或fstream
设定in
模式
3、只有当out
被设定时才能设定trunc
模式
4、只要trunc
没被设定,就可以设定app
模式
5、默认情况下,即使我们没有设定trunc
,以out
打开的文件也会被阶段。为了保留以out
打开的文件的内容,我们必须同时指定app
模式,这样只会将数据追加到文件末尾;或者同时指定in
模式,即打开文件同时进行读写操作
6、ate
和binary
可用于任何类型的文件流对象,且可以与其他任何类型模式组合使用阻止丢弃已有数据
1、默认情况下,当我们打开一个
ofstream
时,文件的内容会被丢弃
2、阻止清空ostream
给定文件的方式是同时指定app
模式// 下列语句中,file 都会被截断 ofstream out1("file"); ofstream out2("file", ofstream::out); ofstream out3("file", ofstream::out | ofstream::trunc); // 下列语句,通过 app 保留了文件内容 ofstream app1("file", ofstream::app); ofstream app2("file", ofstream::out | ofstream::app);
string 流
stringstream
操作1、头文件
sstream
定义了三个类型来支持内存 IO:istringstream
、ostringstream
、stringstream
2、与fstream
类似,sstream
定义的类型也都继承自iostream
3、除了继承得来的操作,sstream
还定义了一些成员来管理与流相关联的string
,如下表stringstream 操作 描述 sstream strm 创建一个未绑定的 stringstream 对象,此处的 sstream 是头文件 sstream 中的一个类型 sstream strm(s) 创建一个 sstream,保存字符串 s 的一个拷贝。此构造函数是 explicit 的 strm.str() 返回 strm 保存的 string 的拷贝 strm.str(s) 将字符串 s 拷贝到 strm 中,返回 void 使用
istringstream
当我们的某些工作是对整行文本进行处理,而其他一些工作是处理行内的单个单词,通常可以使用
istringstream
(在理解上,可以认为该类型对象类似于一个单独的string
输入缓冲区)示例
1、假定有一个文件,列出了一些人和他们的电话号码,某些人只有一个号码,另一些人有多个号码,文件中每条记录都以人名开始,后面跟随一个或多个电话号码,不同记录之间换行。通过程序实现这些内容的存储
2、我们在程序中使用一个istringstream
与读入的文本行绑定,这样就可以在该类型对象上使用输入运算符>>
来读取每个元素#include <iostream> #include <sstream> #include <string> #include <vector> struct PersonInfo { public: std::string name; std::vector<std::string> phones; }; int main() { std::string line, word; // 分别保存来自输入的一行和单词 std::vector<PersonInfo> people; // 保存来自输入的所有记录 // 逐行从输入读取数据 while (std::getline(std::cin, line)) { PersonInfo info; // 创建一个保存此记录数据的对象 std::istringstream record(line); // 将记录绑定到刚读入的行 record >> info.name; // 读取名字(到空格停止) while (record >> word) // 读取电话号码(到空格停止并再次循环) info.phones.push_back(word); // 保持它们 people.push_back(info); // 将此记录追加到 people 末尾 } return 0; }
使用
ostringstream
当我们逐步构造输出,希望最后一起打印时,
ostringstream
是很有用的(在理解上,可以认为该类型对象类似于一个单独的string
输出缓冲区)示例
1、假定对于
istringstream
的上例,我们想逐个验证电话再改变其格式输出,对于有无效号码的人,我们不会将其输出,而是打印一条包含人名和无效号码的错误信息。通过程序实现这些内容的格式化与转存
2、由于我们不希望输出有无效号码的人,因此对每个人,直到验证完所有号码才能进行输出。这种情况下,我们可以将输出内容先写入到一个ostringstream
中更加方便#include <iostream> #include <sstream> #include <string> #include <vector> struct PersonInfo { public: std::string name; std::vector<std::string> phones; }; int main() { std::string line, word; // 分别保存来自输入的一行和单词 std::vector<PersonInfo> people; // 保存来自输入的所有记录 // 逐行从输入读取数据 while (std::getline(std::cin, line)) { PersonInfo info; // 创建一个保存此记录数据的对象 std::istringstream record(line); // 将记录绑定到刚读入的行 record >> info.name; // 读取名字 while (record >> word) // 读取电话号码 info.phones.push_back(word); // 保持它们 people.push_back(info); // 将此记录追加到 people 末尾 } // 假定已有两个函数: // valid() 完成电话号码验证;format() 完成格式化 for (const auto &entry : people) // 对于 people 的每一项 { std::ostringstream formatted, badNums; // 每个循环创建的对象 for (const auto &nums : entry.phones) // 对每个数 { if (!valid(nums)) // 如果无效 badNums << " " << nums; // 将数的字符存入 badNums else // 如果有效 formatted << " " << format(nums); // 进行格式化 } if (badNums.str().empty()) // 如果没有错误的数,打印名字和格式化的数 std::cout << entry.name << " " << formatted.str() << std::endl; else // 如果有错误,打印错误信息 std::cerr << "input error: " << entry.name << " invalid number(s): " << badNums.str() << std::endl; } return 0; }
顺序容器
章节概要:顺序容器概述;顺序容器类型;选择容器;容器库概览;容器操作;迭代器范围;容器类型成员;
begin
和end
成员;容器定义与初始化;assign
和swap
;顺序容器操作;添加元素;添加元素的操作;使用insert
添加元素;使用insert
的返回值;使用emplace
;访问元素;删除元素;特殊的forward_list
操作;改变容器大小;有关迭代器失效;vector
对象如何增长;操作原理描述;管理容量的成员函数;额外的string
操作;构造string
的其他方法;其他构造函数;substr
操作;改变string
的其他方法;特殊版本函数;append
和replace
函数;string
搜索操作;compare
函数;数值转换;容器适配器;适配器类型;定义适配器;栈适配器;队列适配器
顺序容器概述
顺序容器类型
1、下表中列出了标准库中的顺序容器,其中大部分类型都提供高效灵活的内存管理,我们可以添加删除元素、扩张收缩容器大小
2、容器保存元素的策略对容器操作有着固有且重大的影响。某些情况下,存储策略还会影响特定容器是否支持特定操作
3、forward_list
和array
是C++新标准增加的类型。与内置数组相比,array
是一种更安全易用的数组类型
4、新标准库的容器比旧版本快得多,其性能几乎与最精心优化过的同类数据结构一样好,甚至过之
5、现代C++程序应该尽可能多使用标准库容器,而不是像内置数组这样的原始数据结构顺序容器类型 描述 vector 可变大小数组,支持快速随机访问,在尾部之外的位置插入或删除元素可能很慢 deque 双端队列,支持快速随机访问,在头尾插入删除速度很快 list 双向链表,只支持双向顺序访问,在任何位置插入删除速度都很快 forward_list 单向链表,只支持单向顺序访问,在任何位置插入删除速度都很快 array 固定大小数组,支持快速随机访问,不能添加删除元素 string 与 vector 类似,专门用来保存字符,随机访问快,尾部插入删除速度快 选择容器
1、通常,使用
vector
是最好的选择,除非你有很好的理由选择其他容器
2、如果你的程序有很多小的元素,且空间的额外开销很重要,则不要使用list
或forward_list
3、如果程序要求随机访问元素,应使用vector
或deque
4、如果程序要求在容器的中间插入或删除元素,应使用list
或forward_list
5、如果程序需要在头尾插入或删除元素,但不会在中间插入或删除,则使用deque
6、如果程序只在输入时才在容器中间位置插入元素,随后需要随机访问元素,则最好在输入阶段使用list
,完成后拷贝到vector
(但最好确定是否真的一定要在中间位置插入元素,大多情况可以输入到vector
再通过sort
函数排序)
容器库概览
一般来说,每个容器都定义在一个头文件中,头文件名与类型名相同
容器操作
类型别名
类型别名 描述 iterator 此容器类型的迭代器类型 const_iterator 可以读取元素但不能修改元素的迭代器类型 size_type 无符号整数类型,足够保存此种容器类型最大可能容器的大小 difference_type 带符号整数类型,足够保存两个迭代器之间的距离 value_type 元素类型 reference 元素的左值类型,与 value_type&含义相同 const_reference 元素的 const 左值类型,即 cosnt value_type& 构造函数
构造函数 描述 C c 默认构造函数,构造空容器 C c1(c2) 构造 c2 的拷贝 c1 C c(b,e) 构造 c,将迭代器 b 和 e 指定的范围内的元素拷贝到 c,但不适用于 array C c{a,b,c,…} 列表初始化 c 赋值和 swap(assign 不适用于关联容器和 array)
赋值和 swap 描述 c1 = c2 将 c1 的元素替换为 c2 的元素 c1 = {a,b,c,…} 将 c1 的元素替换为列表中的元素,但不适用于 array a.swap(b) 交换 a 和 b 的元素 swap(a,b) 交换 a 和 b 的元素 seq.assign(b,e) 将 seq 中的元素替换为迭代器 b 和 e 所表示范围中的元素,b 和 e 不能指向 seq 中的元素 seq.assign(il) 将 seq 中的元素替换为初始化列表 il 中的元素 seq.assign(n,t) 将 seq 中的元素替换为 n 个值为 t 的元素 大小
大小 描述 c.size() c 中元素的数目,但不支持 forward_list c.max_size() c 可保存的最大元素数目 c.empty() 若 c 为空,返回 true,否则返回 false 增删元素(不适用于 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_interator 反向容器的额外成员(不支持 forward_list)
反向容器的额外成员 描述 reverse_iterator 按逆序寻址元素的迭代器 const_reverse_interator 不能修改元素的逆序迭代器 c.rbegin(),c.rend() 返回指向 c 的尾元素与首元素之前位置的迭代器 c.crbegin(),c.crend() 返回 const_reverse_interator
迭代器范围
1、一个迭代器范围由一对迭代器表示,两个迭代器分别指向同一个容器中的元素位置或尾元素之后位置,这两个迭代器通常被称为begin和end
2、这种元素范围被称为左闭合区间,数学描述为[begin, end)
,表示自 begin 开始,于 end 前结束
3、标准库使用左闭合范围是因为这种范围有三种方便的特性,假定begin和end构成合法的迭代器范围,则有:
4、如果begin和end相等,则范围为空;如果begin和end不等,则范围至少包含一个元素,且begin指向首元素;我们可以对begin递增若干次,使begin==end
,过程中进行迭代容器类型成员
1、每个容器都定义了多个类型,我们已经使用过其中三种:
size_type
、iterator
、const_iterator
2、除了已经使用过的迭代器类型,大多容器还提供反向迭代器,简单说,反向迭代器就是一种反向遍历容器的迭代器
3、剩下还有一些类型别名,通过别名我们可以在不了解容器中元素类型的情况下使用它。比如元素类型value_type
;元素类型引用reference
或const_reference
begin 和 end 成员
1、
begin
和end
操作生成指向容器首元素位置和尾后位置的迭代器,这两个迭代器最常见的用途是形成一个包含容器所有元素的迭代器范围
2、begin
和end
有多个版本,带r
的版本(rbegin
)返回反向迭代器,带c
的版本(cbegin
)返回const
迭代器,二者可叠加(crbegin
)容器定义与初始化
将一个容器初始化为另一个容器的拷贝
1、将一个新容器创建为另一个容器的拷贝有两种方法:可以直接拷贝整个容器,或者可以拷贝由一个迭代器对指定的元素范围
2、为了创建一个容器为另一个容器的拷贝(即第一种方法),两个容器的类型及其元素的类型必须匹配
3、而使用迭代器对(即第二种方法)本质上是拷贝迭代器指向的数据,就不要求容器类型相同了,但元素类型需要能转换成相应的类型array
具有固定大小1、与内置数组一样,
array
的大小也是类型的一部分,即定义一个array
时,除了指定元素类型,还要指定容器大小:array<int, 42>
、array<int, 42>::size_type
2、由于大小是array
类型的一部分,所以array
不支持普通的容器构造函数,array
大小固定的特性也影响了它所定义的构造函数的行为
3、一个默认构造的array
是非空的,它包含与其大小一样多的元素,这些元素都被默认初始化
4、虽然我们不能对内置数组进行拷贝或对象赋值操作,但对于array
来说并无此限制
assign 和 swap
使用
assgin
(仅顺序容器)1、使用赋值运算符赋值要求左边和右边的运算对象类型相同,而除
array
外的顺序容器还定义了一个assign
成员函数,允许我们从不同但相容的类型赋值,或从容器的一个子序列赋值
2、assign
操作用迭代器参数所指定的元素替换左边容器的所有元素,比如我们可以用assign
实现将vector
中的一段char*
赋值给一个list
中的string
,如下例list<string> names; vector<const char*> oldstyle; names = oldstyle; // 错误:容器类型不匹配 names.assign(oldstyle.cbegin(), oldstyle.cend()); // 正确:可以将 const char* 转换成 string
使用
swap
1、
swap
操作交换两个相同类型容器的内存,调用swap
后,两个容器中的元素将会交换
2、除array
外,swap
不对任何元素进行拷贝、删除、插入操作,不会真正移动元素,因此可以保证在O(1)
常数时间内完成;而array
会真正交换它们的元素,因此所需时间是O(n)
级别的
3、元素不会被移动意味着除string
外,指向容器的迭代器、引用、指针在swap
之后不会失效,它们仍指向swap
之前所指向的那些元素,只是swap
之后这些元素已经属于不同的容器了(即swap
前指向vec1[3]
,swap
后指向vec2[3]
,而vec2
实际就是交换前的vec1
)。不同的是,对一个string
调用swap
会导致迭代器、引用、指针失效
顺序容器操作
添加元素
添加元素的操作
1、除
array
外,所有标准库容器都提供灵活的内存管理,在运行时可以动态添加或删除元素来改变容器大小,如下表
2、这些操作会改变容器大小,因此array
不支持这些操作
3、forward_list
有自己专有版本的insert
和emplace
,且不支持push_back
和emplace_back
4、vector
和string
不支持push_front
和emplace_front
添加元素的操作 描述 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 的元素,返回指向新添加的第一个元素的迭代器 c.insert(p,b,e) 将迭代器 b 和 e 指定的范围内的元素插入到迭代器 p 指向的元素之前,b 和 e 不能指向 c 中的元素,返回指向新添加的第一个元素的迭代器,若范围为空,则返回 p c.insert(p,il) il 是一个花括号包裹的元素值列表列表,将这些给定值插入到迭代器 p 指向的元素之前,返回指向新添加的第一个元素的迭代器,若列表为空,则返回 p 使用
insert
添加元素1、
push_back
和push_front
操作能够快捷添加元素到容器头尾,而insert
允许我们在容器任意位置插入元素
2、每个insert
函数都接受一个迭代器作为第一个参数,指出在容器什么位置放置新元素,它可以指向容器任何位置,包括尾后位置,insert
会将元素插入到这个位置之前
3、虽然某些容器不支持push_front
,但对insert
操作插入开始位置并无限制,因此我们可以将元素插入到容器开始位置,而不必关心容器是否支持push_front
,但这样操作需要注意运行效率
4、此外insert
还可以接受更多参数来添加多个相同值或添加一个迭代器范围内的值,使用方法见上表格vector<string> svec; list<string> slist; // 等价于调用 slist.push_front("Hello!"); slist.insert(slist.begin(), "Hello!"); // vector 不支持 push_front,但可以 insert 到 begin() 之前 // 警告:插入到 vector 末尾之外的任何位置都可能很慢 svec.insert(svec.begin(), "Hello!");
使用
insert
的返回值1、
insert
返回指向新添加的第一个元素的迭代器
2、通过使用insert
的返回值,可以在容器中一个特定位置反复插入元素,如下例string word; list<string> lst; auto iter = lst.begin(); while(cin >> word) iter = list.insert(iter, word); // 每次执行后 iter 都赋值为返回值,即每次 iter 都指向开头位置
使用
emplace
1、新标准引入了三个新成员:
emplace_front
、emplace
、emplace_back
,这些操作将会构造元素(而不是拷贝元素),分别对应push_front
、insert
、push_back
2、当调用push
或insert
成员函数时,我们将元素类型的对象传递给它们,这些对象被拷贝到容器中。而当我们调用emplace
时,则是将参数传递给元素类型的构造函数,emplace
使用这些参数在容器管理的内存空间中直接构造元素
3、emplace
函数在容器中直接构造元素,所以传递给emplace
函数的参数必须与元素类型的构造函数相匹配
访问元素
1、下表列出了顺序容器中访问元素的操作,如果容器没有元素,则访问操作是未定义的
2、at
和下标操作只适用于string
、vector
、deque
、array
,此外back
不适用于forward_list
3、此外,使用时有一些注意事项,如下示例访问元素的操作 描述 c.back() 返回 c 中尾元素的引用 c.front() 返回 c 中首元素的引用 c[n] 返回 c 中下标为 n 的元素的引用,n 是一个无符号整数。如 n>=c.size(),则函数行为未定义 c.at(n) 返回 c 中下标为 n 的元素的引用。如果下标越界,则抛出一个 out_of_range
异常// 在解引用一个迭代器或调用 front 或 back 之前检查是否有元素 if(!c.empty()) { // val1 和 val2 是 c 中第一个元素值的拷贝 auto val1 = *c.begin(); // 解引用迭代器 auto val2 = c.front(); // front 获取首元素的引用 // val3 和 val4 是 c 中最后一个元素值的拷贝(但此处操作对 forward_list 不适用) auto last = c.end(); // 注意迭代器指向尾后位置 auto val3 = *(--last); // 注意不能递减 forward_list auto val4 = c.back(); // forward_list 不支持 }
删除元素
1、与添加元素的操作类似,非
array
的容器也有多种删除元素的方式,如下表。需要注意,删除元素的函数不检查其参数,程序员必须确保待删除的元素存在
2、这些操作会改变容器大小,所以不适用于array
3、forward_list
有特殊版本的erase
,且不支持pop_back
4、vector
和string
不支持pop_front
删除元素的操作 描述 c.pop_back() 删除 c 中尾元素,函数返回 void c.pop_front() 删除 c 中首元素,函数返回 void c.erase(p) 删除迭代器 p 所指定的元素,返回一个指向被删除元素之后元素的迭代器。若 p 是尾后迭代器,则函数行为未定义 c.erase(b,e) 删除迭代器 b 和 e 所指范围内的元素,返回一个指向最后一个被删元素之后元素的迭代器。若 e 本身是尾后迭代器,返回尾后迭代器 c.clear() 删除 c 中所有元素,返回 void 特殊的 forward_list 操作
单向链表的操作
1、为了理解
forward_list
为什么有特殊版本的添加和删除操作,考虑当我们从一个单向链表删除一个元素会发生什么。当删除一个元素时,会改变序列中的链接,如删除elem3
会改变elem2
指向elem4
2、当添加或删除一个元素时,该元素之前的元素的后继会发生改变。为了执行这一操作,我们需要访问其前驱,以改变前驱的链接。但是forward_list
是单向链表,没有简单的办法获取一个元素的前驱
3、由于这个原因,在一个forward_list
中的增删操作是通过改变给定元素后的元素来完成的,这样,我们总是可以访问到被增删操作所影响的元素forward_list
增删操作1、由于
forward_list
操作与其他容器上的操作的实现方式不同,所以定义了一套特殊的函数,如下表
2、为了支持这些操作,forward_list
也定义了before_begin
,它是一个首前迭代器,它将允许我们在链表首元素之前的不存在的元素之后执行增删操作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 是表示范围的一对迭代器(但不能指向 lst 内),il 是一个花括号列表。返回一个指向最后一个插入元素的迭代器。如果 b 和 e 的范围为空,则返回 p。若 p 为尾后迭代器,则函数行为未定义 emplace_after(p,args) 使用 args 在 p 指定的位置之后创建一个元素,返回一个指向这个新元素的迭代器。若 p 为尾后迭代器,则函数行为未定义 lst.erase_after(p),lst.erase_after(b,e) 删除 p 指向的位置之后的元素,或删除[b,e)之间的元素。返回一个指向被删元素之后元素的迭代器或尾后迭代器。如果 p 指向 lst 的尾元素或是一个尾后迭代器,则函数行为未定义 示例程序
forward_list<int> flst = {0,1,2,3,4,5,6,7,8,9}; auto prev = flst.before_begin(); // 表示 flst 的首前元素 auto curr = flst.begin(); // 表示 flst 的第一个元素 while(curr != flst.end()) // 表示仍有元素要处理 { if(*curr % 2) // 若元素为奇数 curr = flst.erase_after(prev); // 删除它并移动 curr else { prev = curr; // prev 指向 curr,即移动 prev 指向了下一个元素 ++curr; // 移动迭代器 curr,指向 curr 下一个元素 } }
改变容器大小
1、下标列出了改变容器大小的操作,但不适用于
array
2、注意:如果resize
缩小容器,则指向被删除元素的迭代器、引用、指针都会失效;对vector
、string
、deque
进行resize
可能导致迭代器、指针、引用失效改变容器大小的操作 描述 c.resize(n) 调整 c 的大小为 n 个元素,如 n < c.size(),则多出的元素被丢弃;若必须添加新元素,则对新元素进行值初始化 c.resize(n,t) 调整 c 的大小为 n 个元素,任何新添加的元素都初始化值为 t 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 个元素
有关迭代器失效
迭代器失效
1、向容器中添加元素和删除元素的操作可能使指向容器元素的指针、引用、迭代器失效
2、一个失效的指针、引用、迭代器将不再表示任何元素,使用它们是一种严重的程序设计错误,可能引起与使用未初始化指针一样的问题
3、有关迭代器失效的具体说明如下向容器添加元素时
1、如果容器是
vector
或string
,且存储空间被重新分配,则上述三者都会失效;如果存储空间未重新分配,则指向插入位置之前的三者仍然有效,但指向插入位置之后的三者将失效
2、对于deque
,插入到除首尾位置之外的任何位置都会导致三者失效;如果在首尾插入元素,迭代器会失效,但指针引用不会失效
3、对于list
和forward_list
,指向容器的三者仍然有效从容器删除元素时
1、对于
vector
和string
,指向被删元素之前元素的三者仍然有效,之后部分的三者将失效
2、对于deque
,如果在首尾之外的任何位置删除元素,那么指向被删除元素外的其他元素的三者也会失效;如果删除尾元素,则尾后迭代器也会失效,其他位置三者不受影响;如果删除首元素,三者也不会受影响
3、对于list
和forward_list
,指向容器其他位置的三者仍然有效
4、注意,当我们删除元素时,被删除元素的三者一定失效,尾后迭代器也一定失效编写改变容器的循环程序
1、添加删除
vector
、string
、deque
元素的循环程序必须考虑上述三者可能失效的问题。程序必须保证每个循环步中都更新三者
2、如果循环中调用的是insert
或erase
,那么更新迭代器很容易,我们可以利用这些操作的返回值来更新,如下示例vector<int> vi = {0,1,2,3,4,5,6,7,8,9}; auto iter = vi.begin(); while(iter != vi.end()) { if(*iter % 2) { iter = vi.insert(iter, *iter); // 复制当前元素 iter += 2; // 向前移动迭代器,跳过当前元素以及插入到它之前的元素 } else iter = vi.erase(iter); // 删除偶数元素 // 不应向前移动迭代器,iter 指向我们删除的元素之后的元素 }
不要保存
end
返回的迭代器1、当我们在
vector
、string
或在deque
首元素之外的任何位置增删元素时,原来end
返回的迭代器总是会失效的
2、因此,增删元素的循环程序必须反复调用end
,而不能在循环之前保存end
返回的迭代器来一直当做容器末尾使用
3、通常C++标准库的实现中end()
都很快,部分原因就是因为需要经常反复调用
vector 对象如何增长
操作原理描述
1、为了支持快速随机访问,
vector
将元素连续存储(每个元素紧挨着前一个元素存储)。通常情况下,我们不关心一个标准库类型如何实现,只需要关心它如何使用。然而对于vector
和string
,其部分实现渗透到了接口上
2、假定容器中元素是连续存储的,且容器大小可变,考虑向vector
或string
添加元素会发生什么:如果没有空间容纳新元素,容器不可能简单将它添加到内存中其他位置,因为元素必须连续存储
3、假如如此,容器必须分配新的内存空间来保存已有元素和新元素,将已有元素移动到新空间,然后添加新元素,再释放旧空间。如果我们每添加一个新元素就执行一次这样的操作,性能会慢到不可接受
4、为了避免这种代价,标准库实现者采用了可以减少容器空间重新分配次数的策略。当不得不重新获取新的空间时,vector
和string
通常会分配比新的空间需求更大的空间。容器预留这些空间作为备用,可用来保存更多新元素,并减少重新分配次数管理容量的成员函数
1、
vector
和string
类型提供了一些成员函数,它们允许我们与实现中内存分配的部分互动,如下表
2、shrink_to_fit
只适用于vector
、string
、deque
。新标准中可以使用shrink_to_fit
要求容器退回不需要的内存空间,但具体的实现可以选择忽略此请求
3、capacity
和reserve
只适用于vector
和string
。注意reserve
并不改变元素的数量,它仅影响预先分配多大的内存容器大小管理操作 描述 c.shrink_to_fit() 将 capacity() 减少为与 size() 相同大小 c.capacity() 返回不重新分配内存空间的情况下,c 还能保存多少元素 c.reserve(n) 分配至少能容纳 n 个元素的内存空间
额外的 string 操作
构造 string 的其他方法
其他构造函数
1、除了之前介绍过的构造函数,以及与其他顺序容器相同的构造函数,
string
还支持另外三个构造函数,如下表
2、n、len2、pos2都是无符号值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 个字符 substr
操作1、
s.substr(pos,n)
:返回一个string
,包含s中从pos开始的n个字符的拷贝。pos默认为0,n默认为s.size() - pos
,即所有字符
2、substr
返回一个string
,它是原始string
的一部分或全部的拷贝,可以传递给substr
一个可选的开始位置、计数值作为参数
3、如果开始位置超过了string
大小,则会抛出一个out_of_range
异常;如果开始位置+计数值大于string
大小,则函数会自动调整计数值,只拷贝到string
末尾string s("hello world"); string s2 = s.substr(0, 5); // hello string s3 = s.substr(6); // world string s4 = s.substr(6,11); // 自动调整 11,拷贝到末尾,world string s5 = s.substr(12); // 抛出 out_of_range 异常
改变 string 的其他方法
特殊版本函数
1、
string
类型支持顺序容器的赋值运算符、assign
、insert
、erase
操作。此外它还定义了这些函数的一些特殊版本
2、除了接受迭代器的insert
和erase
外,string
还提供了接受下标的版本,如后例
3、标准库string
还提供了接受 C 风格字符数组的insert
和assign
,如后例修改 string 的操作 描述 s.insert(pos,args) 在 pos 之前插入 args 指定的字符。pos 可以是一个下标或迭代器。接受下标的版本返回指向 s 的引用;接受迭代器的版本返回指向第一个插入字符的迭代器 s.erase(pos,len) 删除从 pos 开始的 len 个字符。如果 len 被省略,则删除 pos 开始直至末尾的所有字符。返回指向 s 的引用 s.assign(args) 将 s 中的字符替换为 args 指定的字符。返回指向 s 的引用 s.insert(s.size(), 5, '!'); // 在 s 末尾插入 5 个 ! s.erase(s.size() - 5, 5); // 从 s 删除最后的 5 个字符 const char *cp = "Stately, plump Buck"; s.assign(cp, 7); // 调用 assign 替换 s 的内容为 "Stately" s.insert(s.size(), cp + 7); // s = "Stately, plump duck";
append
和replace
函数1、
string
类定义了两个额外的成员函数:append
和replace
,这两个函数可以改变string
内容,如下表
2、append
是在string
末尾进行插入操作的一种简写;replace
是调用erase
和insert
的一种简写修改 string 的操作 描述 s.append(args) 将 args 追加到 s。返回指向 s 的引用 s.replace(range,args) 删除 s 中范围 range 内的字符,替换为 args 指定的字符。range 或者是一个下标和一个长度,或者是一对指向 s 的迭代器。返回指向 s 的引用
string 搜索操作
1、
string
类提供了一些6 种搜索函数,每种都有4 种重载函数,下表中描述了这些成员函数及其参数
2、每个搜索操作都返回一个string::size_type
值,表示匹配发生位置的下标。如果搜索失败,则返回一个名为string::npos
的static
成员
3、标准库将string::npos
定义为const string::size_type
类型,并初始化为-1。由于npos是unsigned
类型,初始值-1意味着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_first_not_of(args) 在 s 中查找最后一个不在 args 中的字符 args 的重载形式 描述 c,pos 从 s 中位置 pos 开始查找字符 c 。pos 默认为 0 s2,pos 从 s 中位置 pos 开始查找字符串 s2 。pos 默认为 0 cp,pos 从 s 中位置 pos 开始查找指针 cp 指向的 C 风格字符串。pos 默认为 0 cp,pos,n 从 s 中位置 pos 开始查找指针 cp 指向的数组的前 n 个字符。pos 和 n 无默认值 compare
函数1、除了关系运算符外,标准库
string
还提供了一组compare
函数,其与C 标准库的strcmp
很相似,如下表
2、类似strcmp
,根据s是等于、大于、小于参数指定的字符串,s.compare
分别返回 0、正数、负数compare 函数 描述 s.compare(s2) 比较 s 和 s2 s.compare(pos1,n1,s2) 将 s 中从 pos1 开始的 n1 个字符与 s2 进行比较 s.compare(pos1,n1,s2,pos2,n2) 将 s 中从 pos1 开始的 n1 个字符与 s2 中从 pos2 开始的 n2 个字符进行比较 s.compare(cp) 比较 s 与 cp 指向的 C 风格字符串 s.compare(pos1,n1,cp) 将 s 中从 pos1 开始的 n1 个字符与 cp 指向的 C 风格字符串进行比较 s.compare(pos1,n1,cp,pos2) 将 s 中从 pos1 开始的 n1 个字符与 cp 指向的地址开始的 n2 个字符进行比较 数值转换
1、字符串中常常包含表示数值的字符,有时我们需要将其转换为数值。新标准引入了多个函数,可以实现数值数据与
string
之间的转换,如下表
2、string
参数中第一个非空白符必须是正负符号或数字,它可以以 0x 或 0X 开头表示十六进制数;对那些将字符串转换成浮点值的函数,参数还可以以小数点开头,并可以包含 e 或 E表示指数部分;对那些将字符串转换为整型值的函数,根据基数不同,string
参数还可以包含字母字符对于大于数字 9 的数
3、如果string
不能转换为一个数值,函数会抛出invalid_argument
异常;如果转换得到的数值不能用任何类型表示,函数会抛出out_of_range
异常数值转换函数 描述 to_string(val) 一组重载函数,返回数值 val 的 string 表示 stoi(s,p,b),stol(s,p,b),stoul(s,p,b),stoll(s,p,b),stoull(s,p,b) 返回 s 的起始子串(表示整数内容)的数值,返回值类型对应函数名 sto 后的类型名缩写,如 i 为 int,ul 为 unsigned long。b 表示转换所用的基数(进制),默认为 10。p 是 size_t 指针,用来保存 s 中第一个非数值字符的下标,默认为 0,即不保存下标 stof(s,p),stod(s,p),stold(s,p) 返回 s 的起始子串(表示浮点数内容)的数值,返回值类型对应函数名 sto 后的类型名缩写。p 是 size_t 指针,用来保存 s 中第一个非数值字符的下标,默认为 0,即不保存下标 string s2 = "pi = 3.14"; // 转换 s 中以数字开始的第一个子串,结果 d = 3.14 // 先使用 find_first_of 找到数值部分,再用 substr 截取子串,再用 stod 转换 double d = stod(s2.substr(s2.find_first_of("+-.0123456789")));
容器适配器
适配器类型
1、除了顺序容器外,标准库还定义了三个顺序容器适配器:
stack
、queue
、priority_queue
,此外下表列出了它们支持的操作
2、适配器是标准库中的一个通用概念,容器、迭代器、函数都有适配器。本质上,一个适配器是一种机制,能使某种事物的行为看起来像另外一种事物一样
3、一个容器适配器接受一种已有的容器类型,使其行为看起来像一种不同的类型。例如stack
接受一个顺序容器,使其操作看起来像一个stack
适配器操作 描述 size_type 一种类型,足以保存当前类型的最大对象的大小 value_type 元素类型 container_type 实现适配器的底层容器类型 A a; 创建一个名为 a 的空适配器 A a(c) 创建一个名为 a 的适配器,带有容器 c 的一个拷贝 关系运算符 每个适配器都支持所有关系运算符 a.empty() 若 a 为空,则返回 true,否则返回 false a.size() 返回 a 中的元素数目 a.swap(b),swap(a,b) 交换 a 和 b 的内容,a 和 b 定义适配器
1、每个适配器都定义了两个构造函数:默认构造函数创建一个空对象,接受一个容器的构造函数拷贝该容器来初始化适配器
2、默认情况下,stack
和queue
是基于deque
实现的,priority_queue
是基于vector
实现的。我们可以在创建适配器时将一个命名的顺序容器作为第二个类型参数,来重载默认容器类型
3、对于一个给定的适配器,可以使用哪些容器是有限制的。所有适配器都要求容器具有增删元素和访问尾元素的能力,因此不能构造在array
和forward_list
上;此外queue
不能构造在vector
上,priority_queue
不能构造在list
上stack<int> stk(deq); // 从 deq 拷贝元素到 stk stack<string, vector<string>> str_stk; // 在 vector 上实现的空栈 stack<string, vector<string>> str_stk2(svec); // 在 vector 上实现,初始化时保存 svec 的拷贝
栈适配器
1、
stack
类型定义在stack
头文件中,下表列出了栈特有的操作,并举出了一个示例
2、栈默认基于deque
实现,也可以在list
或vector
上实现
3、每个容器适配器都基于底层容器类型的操作定义了自己的特殊操作,但我们只能使用适配器操作,而不能使用底层容器类型的操作栈特有的操作 描述 s.pop() 删除栈顶元素,但不返回元素值 s.push(item),s.emplace(args) 创建一个新元素压入栈顶,该元素通过拷贝或移动 item 而来,或者由 args 构造 s.top() 返回栈顶元素,但不将元素弹出栈 stack<int> intStack; for(size_t ix = 0; ix != 10; ix++) intStack.push(ix); while(!intStack.empty()) { int value = intStack.top(); intStack.pop(); }
队列适配器
1、
queue
和priority_queue
类型定义在queue
头文件中,下标列出了队列特有的操作
2、queue
默认基于deque
实现,priority_queue
默认基于vector
实现。queue
也可以用list
或vector
实现,priority_queue
也可以用deque
实现
3、priority_queue
允许我们为队列中的元素建立优先级,新加入的元素会排在所有优先级比他低的已有元素之前。饭店按照客人预定时间而不是到店时间来安排座位就是一个例子队列特有的操作 描述 q.pop() 返回 queue 的首元素或 priority_queue 的最高优先级的元素,但不返回元素值 q.front(),q.back() 返回首元素或尾元素,但不删除此元素,只适用于 queue q.top() 返回最高优先级的元素,但不删除此元素,只适用于 priority_queue q.push(item),q.emplace(args) 在 queue 末尾或 priority_queue 中恰当位置创建一个元素,其值为 item,或由 args 构造
泛型算法
章节概要:概述;算法如何工作;算法不执行容器操作;初识泛型算法;只读算法;
accumulate
;算法和元素类型;操作两个序列的equal
;写容器元素的算法;fill
;算法不检查写操作;介绍back_inserter
;copy
;replace
;重排容器元素的算法;sort
和unique
;定制操作;谓词与向算法传参;lambda
表达式;向lambda
传参;使用捕获列表;lambda
捕获和返回;lambda
捕获列表;可变lambda
;指定lambda
返回类型;参数绑定;bind
;使用placeholders
名字;bind
重排参数顺序;绑定引用参数;再探迭代器;标准库迭代器;插入迭代器;iostream
迭代器;istream_iterator
操作;使用算法操作流迭代器;ostream_iterator
操作;反向迭代器;泛型算法结构;五类迭代器;算法参数规范;算法命名规范;特定容器算法;链表类型的成员算法;splice
成员
概述
引入
1、大多数算法都定义在头文件
algorithm
中。标准库还在头文件numeric
中定义了一组数值泛型算法
2、一般情况下,这些算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围来进行操作。通常情况下,算法遍历范围,对其中每个元素进行操作
3、例如,假设我们有一个int的vector
,希望知道其中是否包含某个特定值,可以使用find
算法,如下int val = 42; // 要查找的值 // 如果在 vec 中找到想要的元素,则返回结果指向它,否则返回结果为 vec.cend() auto result = find(vec.cbegin(), vec.cend(), val); // 报告结果 cout << "The value" << val << (result == vec.cend() ? "is not present" : "is present") << endl;
算法如何工作
1、为了弄清这些算法如何用于不同类型的容器,让我们更近距离观察一下
find
如何工作。概念上,find
应执行如下步骤
2、访问序列中的首元素;比较此元素与我们要查找的值匹配;如果此元素与我们要查找的值匹配,就返回标识此元素的值;否则find
前进到下一元素,重复步骤 2 和 3;如果到达序列尾,find
应停止;如果到达序列末尾,返回一个指出元素未找到的值
3、迭代器令算法不依赖于容器,但算法依赖于元素类型的操作算法不执行容器操作
1、泛型算法本身不会执行容器操作,它们只会运行于迭代器之上,执行迭代器的操作,该特性说明算法永远不会改变底层容器的大小
2、算法可能改变容器中保存的元素的值,也可能在容器内移动元素,但永远不会直接增删元素
3、标准库定义了一类特殊的迭代器,称为插入器。与普通迭代器只能遍历所绑定的容器相比,插入器能做更多的事情,后续介绍
初识泛型算法
介绍
1、标准库提供了超过 100 个算法,幸运的是,与容器类似,这些算法有一致的结构,附录中按操作方式列出了所有算法
2、除了少数例外,标准库算法都对一个范围内的元素进行操作。我们将此元素范围称为输入范围,接受输入范围的算法总是使用前两个参数来表示此范围(范围[a, b)
)
3、虽然大多算法遍历输入范围的方式相似,但它们使用范围内的元素的方式不同。理解算法最基本的方法就是了解它们是否读取元素、改变元素或重排元素顺序只读算法
accumulate
1、一些算法只会读取输入范围内的元素,而不改变元素,称为只读算法,
find
就是这样一种算法
2、另一个只读算法是accumulate
,它定义在头文件numeric
中。该函数接受三个参数,前两个表示范围,第三个参数是和的初值
3、假定vec是一个整数序列,则有下例对 vec 中元素求和
4、accumulate
的第三个参数的类型决定了函数中使用哪个加法运算符以及返回值的类型// 对 vec 中元素求和,和初始值为 0 int sum = accumulate(vec.cbegin(), vec.cend(), 0);
算法和元素类型
1、
accumulate
将第三个参数作为求和起点,这蕴含着一个编程假定:将元素类型加到和的类型上的操作必须是可行的
2、即,序列中元素的类型必须与第三个参数匹配,或者能转换为第三个参数的类型
3、下例中,由于string
定义了+
运算符,所以我们可以通过调用accumulate
将vector
中所有string
元素连接起来。但注意,必须显式创建一个string
,不可以将空串当成第三个参数传给函数,原因在于空串表示保存和的类型将是const char*
,而此类型没有定义+
运算符string sum = accumulate(v.cbegin(), v.cend(), string("")); // 错误:const char* 没有定义 + 运算符 string sum = accumulate(v.cbegin(), v.cend(), "");
操作两个序列的
equal
1、另一个只读算法是
equal
,用于确定两个序列是否保存相同的值。它将第一个序列中的每个元素与第二个序列中的对应元素进行比较,如果对应元素都相等,则返回true,否则返回false
2、此算法接受三个迭代器,前两个表示第一个序列中的元素范围,第三个表示第二个序列的首元素。但要注意,equal
假定第二个序列至少与第一个序列一样长,且默认只接受一个单一迭代器来表示第二个序列的其他算法,也都有此假定
3、由于equal
利用迭代器完成操作,因此可以调用equal
比较两个不同类型容器中的元素,而且元素类型也不必一样,只要能用==
来比较两个元素类型即可
4、如下例,roster1
可以是vector<string>
,而roster2
可以是list<const char*>
// roster2 中元素数目应至少与 roster1 中一样多 equal(roster1.cbegin(), roster1.cend(), roster2.cbegin());
写容器元素的算法
fill
1、一些算法将新值赋予序列中的元素。当我们使用这类算法时,必须注意确保序列原大小大于等于我们要写入的元素数目,这是因为算法不会执行容器操作,因此它们自己不能改变容器大小
2、一些算法会自己向输入范围写入元素,这些算法本质上并不危险,它们最多写入与给定序列一样多的元素
3、例如算法fill
接受一对迭代器表示一个范围,还接受一个值作为第三个参数,fill
将给定的这个值赋予序列中的每个元素。由于fill
向给定输入序列中写入数据,所以只要传递一个有效的输入序列,写入操作就是安全的fill(vec.begin(), vec.end(), 0); // 将每个元素重置为 0 fill(vec.begin(), vec.begin() + vec.size()/2, 10); // 将容器的一个子序列设置为 10
算法不检查写操作
1、一些算法接受一个迭代器来指出一个单独的目的位置,这些算法将新值赋予一个序列中的元素,该序列从目的位置迭代器指向的元素开始
2、例如,函数fill_n
接受一个单迭代器、一个计数值、一个值。它将给定值赋予迭代器指向元素开始的指定个元素。我们可以用fill_n
将一个新值赋予vector
中的元素,如下例
3、函数fill_n
会假定写入指定个元素是安全的,即假定从指定元素起有指定个元素,如果越界,会出现严重错误,行为未定义vector<int> vec; // 空 vector fill_n(dest.begin(), vec.size(), 0); // 将所有元素重置为 0 fill_n(dest.begin(), 10, 50); // 错误:vec 为空,没有 10 个元素,行为未定义
介绍
back_inserter
1、一种保证算法有足够元素空间来容纳输出数据的方法是使用插入迭代器,这是一种向容器中添加元素的迭代器
2、通常情况,我们通过一个普通迭代器向容器元素赋值时,值被赋予迭代器指向的元素;而通过插入迭代器赋值时,一个与=
右侧值相等的元素被添加到容器中
3、后续还会再详细介绍插入迭代器,现在我们简要介绍插入迭代器中的back_inserter
,它定义在iterator
头文件中
4、back_inserter
接受一个指向容器的引用,返回一个与该容器绑定的插入迭代器。我们常常使用back_inserter
创建迭代器作为算法的目的位置来使用,如下例vector<int> vec; // 空 vector auto it = back_inserter(vec); // 创建插入迭代器,通过 it 赋值会将元素添加到 vec 中 *it = 42; // vec 中现在有一个元素,值为 42 fill_n(back_inserter(vec), 10, 0); // 添加 10 个 0 到 vec 中
copy
1、
copy
算法是另一个向目的位置迭代器指定的输出序列中的元素写入数据的算法。它接受三个迭代器,前两个表示一个输入范围,第三个表示目的序列的起始位置
2、此算法将输入范围中的元素拷贝到目的序列中,注意传递的目的序列至少要包含与输入序列一样多的元素。我们可以用copy
实现内置数组的拷贝,如下例
3、copy
返回的是其目的位置迭代器(递增后)的值,即ret指向拷贝到a2的尾后位置
4、多个算法都提供所谓的拷贝版本,这些算法计算新元素的值,但不会将它们放在输入序列的末尾,而是创建一个新序列保存这些结果
5、例如replace
读取一个序列(前两个参数迭代器表示),将其中等于给定值(第三个参数)的值更改为另一个值(第四个参数)。如果我们希望保留原序列不变,可以使用replace_copy
,它接受额外的第三个参数表示调整后序列的保存位置,如下例int a1[] = {0,1,2,3,4,5,6,7,8,9}; int a2[sizeof(a1) / sizeof(*a1)]; // a2 与 a1 大小一样 // ret 指向拷贝到 a2 的尾元素之后的位置 auto ret = copy(begin(a1), end(a1), a2); // 把 a1 的内容拷贝给 a2
// 将所有值为 0 的元素改为 42 replace(ilst.begin(), ilst.end(), 0, 42); // 使用 back_inserter 按需要增长目标序列,序列保存至 back_inserter(ivec) replace_copy(ilst.begin(), ilst.cend(), back_inserter(ivec), 0, 42)
重排容器元素的算法
引入
1、某些算法会重排容器中元素的顺序,比如
sort
。调用sort
会重排输入序列中的元素,使之有序,它是利用元素类型的<
运算符来实现排序的
2、现在假定我们要分析一段文章所用的词汇。假定已有一个vector
保存了文本,我们希望简化vector
使每个单词只出现一次sort
和unique
1、为了消除重复单词,首先使用
sort
将vector
排序,使重复单词相邻出现
2、排序完成后,可以使用标准库算法unique
来重排vector
,使不重复的元素出现在vector
的开始部分
3、由于算法不能执行容器的操作,我们将使用vector
的erase
来真正完成删除操作void elimDups(vector<string> &words) { // 按字典序排序 words,以便查找重复单词 sort(words.begin(), words.end()); // unique 重排输入范围,使每个单词只出现一次,重复的单词置后 // 返回指向不重复区域之后一个位置的迭代器 auto end_unique = unique(words.begin(), words.end()); // 使用向量操作 erase 删除重复单词 words.erase(end_unique, words.end()); }
定制操作
引入
1、很多算法都会比较输入序列中的元素,默认情况下,这类算法使用元素类型的
<
或==
运算符来完成比较
2、标准库还为这些算法定义了额外的版本,允许我们提供自定义的操作来代替默认运算符
3、例如sort
默认使用元素类型的<
来比较,但可能我们希望的顺序与<
定义的顺序不同,或者序列保存的是未定义<
运算符的元素类型,这些情况都需要重载sort
的默认行为谓词与向算法传参
1、谓词是一个可调用的表达式,其返回结果是一个能用作条件的值,标准库所使用的谓词分两类:一元谓词和二元谓词
2、接受谓词参数的算法对输入序列中的元素调用谓词,因此,元素类型必须能转换为谓词的参数类型
3、例如,接受一个二元谓词参数的sort
版本,用这个谓词来代替<
比较元素。我们可以按长度排序单词,如下例
4、在我们将words按长短排序后,还希望相同长度的元素按字典序排序,可以使用stable_sort
,如下vector<string> words{"abc", "yy", "bbbb", "zzz", "aaaaa", "za"}; // 比较函数 bool isShorter(const string &s1, const string &s2) { return s1.size() < s2.size(); } // 按长度由短至长排序 sort(words.begin(), words.end(), isShorter); for (auto it : words) cout << it << ' '; // 按长度排序,且相同长度按字典序排序 stable_sort(words.begin(), words.end(), isShorter); for (auto it : words) cout << it << ' ';
lambda 表达式
引入
1、根据算法接受一元谓词还是二元谓词,我们传递给算法的谓词也必须严格接受一个或两个参数,但有时我们希望进行的操作需要更多参数,会超出算法对谓词的限制
2、例如我们要计算大于等于一个给定长度的单词有多少,且只打印这些单词,我们能设计出一个大体框架,如下
3、我们可以使用标准库算法find_if
来查找。其接受一对迭代器,表示范围,第三个参数接受一个一元谓词。返回第一个使谓词返回非 0 值的元素,如果不存在则返回尾后迭代器
4、但是,find_if
只接受一元谓词,我们会使用一个参数表示当前元素,没有任何办法能传递第二个参数表示长度。为此,需要使用另外一些语言特性void biggie(vector<string> &words, vector<string>::size_type sz) { // 之前自定义的函数,将 words 按字典序排序,删除重复单词 elimDups(words); // 先前定义的 isShorter stable_sort(words.begin(), words.end(), isShorter); // 获取一个迭代器,指向第一个满足 size() >= sz 的元素,即此后的都符合条件 // 计算数目 // 输出 }
lambda
介绍1、我们可以向一个算法传递任何类别的可调用对象。对于一个对象或表达式,如果可以对其使用调用运算法
()
,则称它为可调用的
2、到目前为止,我们使用过的两种可调用对象:函数和函数指针,此外还有两种可调用对象:重载了函数调用运算符的类(后续介绍)和lambda
表达式
3、一个lambda
表达式表示一个可调用的代码单元,我们可以将其理解为一个未命名的内联函数。与任何函数类似,一个lambda
具有一个返回类型、形参列表和函数体;但与函数不同的是,lambda
可能定义在函数内部
4、一个lambda
形式如:[捕获列表](参数列表) -> 返回类型{函数体}
。其中,捕获列表是一个lambda
所在函数中定义的局部变量的列表(通常为空),其余三者与函数一样,但是lambda
必须使用尾置返回来指定返回类型
5、我们可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体,如auto f = []{ return 42; };
。当忽略返回类型时,如果函数体只有return
语句,则自动推断返回类型,否则返回void
向
lambda
传参1、可以像给普通函数传参那样给
lambda
传参,但注意不同的是,lambda
不能有默认形参
2、如下例,我们可以编写一个与isShorter()
函数功能相同的lambda
vector<string> words{"abc", "yy", "bbbb", "zzz", "aaaaa", "za"}; auto lam = [](const string &a, const string &b) { return a.size() < b.size(); }; // 此处 lam 也可以直接写上述 lambda 表达式 stable_sort(words.begin(), words.end(), lam); for (auto it : words) cout << it << ' ';
使用捕获列表
1、虽然一个
lambda
可以出现在一个函数中,使用其局部变量,但只能使用那些明确指明的变量
2、一个lambda
通过将局部变量包含在其捕获列表中来指出将使用这些变量,捕获列表指引lambda
在其内部访问局部变量所需的信息
3、如下例,该lambda
会捕获 sz,并只有单一的string
参数[sz](const string &a) { return a.size() >= sz; };
完整的
biggies()
void biggies(vector<string> &words, vector<string>::size_type sz) { // 自定义的 elimDups,按字典序排序并删除重复单词 elimDups(words); // 按长度排序 auto sort_length = [](const string &a, const string &b) { return a.size() < b.size(); }; stable_sort(words.begin(), words.end(), sort_length); // 获取一个迭代器,指向第一个满足 size() >= sz 的元素 auto find_first = [sz](const string &a) { return a.size() >= sz; }; auto wc = find_if(words.begin(), words.end(), find_first); // 计算数目 auto count = words.end() - wc; cout << count << endl; // 打印单词 auto print = [](const string &s) { cout << s << ' '; }; for_each(wc, words.end(), print); // 标准库算法,接受一个可调用对象,并对输入序列中每个元素调用此对象 }
lambda 捕获和返回
lambda
对象1、当定义一个
lambda
时,编译器生成一个与lambda
对应的新的类类型,后续会介绍这种类如何生成
2、目前,可以认为当向一个函数传递lambda
时,同时定义了一个新类型和该类型的一个对象,传递的参数就是编译器生成的类类型的未命名对象
3、类似的,当使用auto
定义一个用lambda
初始化的变量时,定义了一个从lambda
生成的类型的对象lambda
捕获列表捕获列表 描述 [ ] 空捕获列表,lambda 不能使用所在函数中的变量 [names] names 是一个逗号分隔的名字列表,这些名字都是 lambda 所在函数的局部变量。默认情况下,捕获列表中的变量都被拷贝,名字前如果使用了&,则采用引用捕获方式 [&] 隐式捕获列表,采用引用捕获方式。lambda 中所使用的来自所在函数的实体都采用引用方式使用 [=] 隐式捕获列表,采用值捕获方式。lambda 将拷贝所使用的来自所在函数的实体的值 [&, identifier_list] identifier_list 是一个逗号分隔的列表,包含 0 个或多个来自所在函数的变量。这些变量采用值捕获方式,而任何隐式捕获的变量都采用引用方式捕获。identifier_list 名字前不能使用& [=, identifier_list] identifier_list 是一个逗号分隔的列表,包含 0 个或多个来自所在函数的变量。这些变量采用引用方式捕获,而任何隐式捕获的变量都采用值方式捕获。identifier_list 名字不能包括 this,而名字前必须使用& 可变
lambda
1、默认情况下,对于一个值被拷贝的变量,
lambda
不会改变其值
2、如果我们希望能改变一个被捕获的变量的值,就必须在参数列表首加上关键字mutable
。因此,可变lambda
可以省略参数列表void func() { size_t v1 = 42; // f 可以改变它所捕获的变量的值 auto f = [v1] () mutable { return ++v1; }; v1 = 0; auto j = f(); // j 为 43 }
指定
lambda
返回类型1、默认情况下,如果一个
lambda
包含return
之外的任何语句,则编译器将假定其返回void
2、如下例,我们可以用标准库算法transform
和一个lambda
表达式来实现转换数字为其绝对值,transform
第三个参数表示目的位置
3、使用三目运算符的版本由于是只有一条return
,被解析为返回int
;而使用if
的版本则被解析为void
因此编译错误,必须显式指定返回类型
4、当我们需要为一个lambda
定义返回类型时,必须使用尾置返回类型// 三目运算符版本,正确 transform(vi.begin(), vi.end(), vi.begin(), [](int i) { return i < 0 ? -i : i; }); // if 版本,错误 transform(vi.begin(), vi.end(), vi.begin(), [](int i) { if (i < 0) return -i; else return i; }); // if 版本,正确 transform(vi.begin(), vi.end(), vi.begin(), [](int i) -> int { if (i < 0) return -i; else return i; });
参数绑定
引入
1、对于那种只在一两个地方使用的简单操作,
lambda
表达式是最有用的。如果我们需要在很多地方使用相同的操作,通常应该定义一个函数,而不是多次编写相同的lambda
。类似的,如果一个操作需要很多语句才能完成,通常使用函数更好
2、如果lambda
捕获列表为空,通常可以用函数来代替,既可以用lambda
也可以用函数来实现
3、但是对于捕获局部变量的lambda
,用函数代替就没那么容易了。例如如果调用的一个函数接受一个一元谓词,lambda
可以通过捕获列表从形参获取额外信息,而函数必须解决如何作为一元谓词的问题bind
1、为了解决向作为一元谓词的函数传递多个参数的问题,方法是使用一个名为
bind
的标准库函数,它定义在头文件functional
中
2、可以将bind
看作一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对象来适应原对象的形参列表
3、调用bind
的一般形式如:auto newCallable = bind(callable, arg_list)
。其中,newCallable
本身是一个可调用对象,arg_list
是形参列表。当调用newCallable
时,会调用callable
,并使用arg_list
作为callable
的形参
4、arg_list
中可能出现形如_n
的名字,其中n是一个整数,这些参数是占位符,它们占据了传递给newCallable
的参数的位置:_1
为newCallable
第一个参数,_2
为第二个参数,以此类推使用
bind
绑定参数1、一个简单的例子,我们将使用
bind
生成一个调用check_size
的对象,如下
2、此bind
调用只有一个占位符,表示check6
接受一个参数;占位符出现在arg_list
第一个位置上,表示check6
的此参数对应check_size
的第一个参数// 原函数 bool check_size(const string &s, string::size_type sz) { return s.size() >= sz; } // 绑定参数 auto check6 = bind(check_size, _1, 6); // 调用 string s = "hello"; bool b1 = check6(s); // 即调用 check_size(s, 6) // 作为一元谓词 auto wc = find_if(words.begin(), words.end(), check6); auto wc = find_if(words.begin(), words.end(), bind(check_size, _1, sz));
使用
placeholders
名字1、名字
_n
都定义在命名空间placeholders
中(定义在头文件functional
中),而这个命名空间定义在std
命名空间中,因此为了使用这些名字,两个命名空间都要写上(如std::placeholders::_1
)
2、为了更方便的使用,我们通常使用using
声明:using std::placeholders::_1
。但这样对于每个占位符名字都要单独声明,不仅繁琐还易出错
3、我们可以使用另一种using
声明形式:using namespace namespace_name
,表示希望所有来自namespace_name
的名字都可以在我们的程序中直接使用,如此处可写作using namespace std::placeholders
bind
重排参数顺序1、如前文所述,我们可以用
bind
修正参数的值,更一般的,可以用bind
绑定给定可调用对象中的参数或是重新安排其顺序
2、例如,func
是一个可调用对象,它有5 个参数,则允许下面的调用auto g = bind(func, a, b, _2, c, _1); g(d, e); // 等同于 func(a, b, e, c, d)
绑定引用参数
1、默认情况下,
bind
的那些不是占位符的参数被拷贝到返回的可调用对象中。但与lambda
类似,有时对于有些绑定的参数我们希望以引用方式传递,或是希望绑定参数的类型无法拷贝
2、如下例,为了替换一个引用方式捕获ostream
的lambda
,可以很容易编写一个函数完成相同工作,但不能直接用bind
来代替对os
的捕获。原因在于bind
拷贝其参数,而我们不能拷贝一个ostream
3、如果我们希望传给bind
一个对象而不拷贝它,必须使用标准库函数ref
,它定义在头文件functional
中。ref
返回一个对象,包含给定的引用,此对象是可以拷贝的;标准库还有一个cref
,生成一个保存const
引用的类// os 是一个局部变量,引用一个输出流 // c 是一个局部变量,类型为 char for_each(words.begin(), words.end(), [&os, c](const string &s) { os << s << c; }); // 同功能函数 ostream &print(ostream &os, char c, const string &s) { return os << s << c; } // 错误:不能拷贝 os for_each(words.begin(), words.end(), bind(print, os, ' ', _1)); // 使用 ref for_each(words.begin(), words.end(), bind(print, ref(os), ' ', _1));
再探迭代器
标准库迭代器(头文件
iterator
)迭代器类型 描述 插入迭代器 这些迭代器被绑定到一个容器上,可用来向容器插入元素 流迭代器 这些迭代器被绑定到输入或输出流上,可用来遍历所有关联的 IO 流 反向迭代器 这些迭代器向后而不是向前移动。除了 forward_list 外标准库容器都有反向迭代器 移动迭代器 这些专用的迭代器不是拷贝其中的元素,而是移动它们(将在后续介绍) 插入迭代器
插入迭代器操作
1、插入器是一种迭代器适配器,它接受一个容器,生成一个迭代器
2、当我们通过一个插入迭代器进行赋值时,该迭代器调用容器操作来向给定容器的指定位置插入一个元素,下表列出了插入迭代器支持的操作操作 描述 it = t 在 it 指定的当前位置插入值 t。假定 c 是 it 绑定的容器,依赖于插入迭代器不同种类,赋值分别调用 c.push_back(t)、c.push_front(t)、c.insert(t,p) *it,++it,it++ 虽然这些操作存在,但不会对 it 做任何事情,每个操作都返回 it 插入迭代器类型
1、插入迭代器有三种类型,差异在于元素插入的位置
2、只有在容器支持push_front
操作时才能使用front_inserter
;只有在容器支持push_back
操作时才能使用back_inserter
类型 描述 back_inserter 创建一个使用 push_back 的迭代器,元素插入到容器末尾 front_inserter 创建一个使用 push_front 的迭代器,元素插入到容器首端 inserter 创建一个使用 insert 的迭代器,此函数接受第二个参数,其必须是指向给定容器的一个迭代器,表示插入位置,元素将插入到给定迭代器表示的元素之前
iostream 迭代器
引入
1、虽然
iostream
类型不是容器,但标准库定义了可以用于这些IO 类型对象的迭代器
2、istream_iterator
读取输入流,ostream_iterator
向一个输出流写数据
3、这些迭代器将它们对应的流当做一个特定类型的元素序列来处理,通过使用流迭代器,我们可以用泛型算法从流对象中读写数据istream_iterator
操作1、当创建一个流迭代器时,必须指定迭代器将要读写的对象类型。一个
istream_iterator
使用>>
来读取流,所以要读取的类型必须定义了>>
运算符。下表列出了istream_iterator
的操作
2、当创建一个istream_iterator
时,我们可以将它绑定到一个流。此外还可以默认初始化迭代器,这样就创建了一个可以当做尾后值使用的迭代器,如下
3、如下例,使用istream_iterator
从标准输入读取数据,存入一个vector
。此外我们还可以重写这部分代码,以体现流迭代器更有用的地方,即构造容器istream_iterator 操作 描述 istream_iterator<T> in(is) in 从输入流 is 读取类型为 T 的值 istream_iterator<T> end 读取类型为 T 的值的 istream_iterator 迭代器,表示尾后位置 in1 == in2,in1 != in2 in1 和 in2 必须读取相同类型。如果它们都是尾后迭代器,或绑定到相同输入,则两者相等 *in 返回从流中读取的值 in->mem 与(*in).mem 含义相同 ++in,in++ 使用元素类型所定义的>>运算符从输入流中读取下一个值。前置版本返回一个指向递增后迭代器的引用,后置版本返回旧值 // 使用示例 istream_iterator<int> int_it(cin); // 从 cin 读取 int istream_iterator<int> int_eof; // 尾后迭代器 ifstream in("afile"); // 创建一个文件输入流与 afile 绑定 istream_iterator<string> str_it(in); // 从 afile 读取字符串 // 从 istream_iterator 读入数据存入 vector istream_iterator<int> in_iter<cin>; // 从 cin 读取 int istream_iterator<int> eof; // istream 尾后迭代器 while(in_iter != eof) // 当有数据可供读取时 // 后值递增运算读取流,返回迭代器的旧值 // 解引用迭代器,获得从流读取的前一个值 vec.push_back(*in_iter++); // 重写优化 istream_iterator<int> in_iter(cin), eof; // 从 cin 读取 int vector<int> vec(in_iter, eof); // 从迭代器范围构造 vec
使用算法操作流迭代器
1、由于算法使用迭代器来处理数据,而流迭代器又至少支持某些迭代器操作,因此我们可以用某些算法来操作流迭代器
2、如下例,我们可以用一对istream_iterator
来调用accumulate
,该调用会计算从标准输入读取的值的和istream_iterator<int> in(cin), eof; cout << accumulate(in, eof, 0) << endl;
ostream_iterator
操作1、我们可以对任何具有
<<
运算符的类型定义ostream_iterator
,下表列出了ostream_iterator
的操作
2、当创建一个ostream_iterator
时,可以提供可选的第二参数,它是一个C 风格字符串,在输出每个元素后都会打印此字符串
3、必须将ostream_iterator
绑定到一个指定流,不允许空的或表示尾后位置的ostream_iterator
4、如下例,我们可以用ostream_iterator
来输出值的序列。由于解引用和递增运算没有实意,可以省略。我们还可以调用copy
算法更方便地打印元素ostream_iterator 操作 描述 ostream_iterator<T> out(os) out 将 类型为 T 的值写到输出流 os 中 ostream_iterator<T> out(os, d) out 将 类型为 T 的值写到输出流 os 中,每个值后都输出一个 d,d 必须是 C 风格字符串 out = val 用 << 运算符将 val 写入到 out 所绑定的 ostream 中,val 的类型必须与 out 可写的类型兼容 *out,++out,out++ 虽然存在这些运算符,但不对 out 做任何事情,每个运算符都返回 out ostream_iterator<int> out_iter(cout, " "); for(auto e : vec) // 也可以直接写为: // out_iter = e; *out_iter++ = e; // 使用算法操作 copy(vec.begin(), vec.end(), out_iter);
反向迭代器
1、反向迭代器就是在容器中从尾元素向首元素反向移动的迭代器。对于反向迭代器,递增(或递减)操作的含义会颠倒过来(即
++
移动到前一个元素)
2、除了forward_list
外,其他容器都支持反向迭代器,我们可以通过调用rbegin
、rend
、crbegin
、crend
成员函数来获得反向迭代器,它们返回指向容器尾元素和首前元素位置的迭代器
泛型算法结构
引入
1、任何算法最基本的特性是它要求其迭代器提供哪些操作。算法要求的迭代器操作可以分为五个迭代器类型,每个算法都会对它的每个迭代器参数指明须提供哪类迭代器
2、第二种算法分类的方式是按照是否读写或重排序列元素
3、算法还共享一组参数传递规范和一组命名规范,我们将在后续介绍五类迭代器
介绍
1、类似容器,迭代器也定义了一组公共操作。一些操作所有迭代器都支持,另外一些只有特定迭代器才支持
2、迭代器是按照它们所提供的操作来分类的,分为五类迭代器,如下表。这种分类形成了一种层次,除了输出迭代器外,一个高层类型的迭代器能完全支持低层类别的迭代器的所有操作
3、C++标准指明了泛型和数值算法的每个迭代器参数的最小类别,即对每个迭代器参数来说,其能力必须与规定的最小类别至少相当。向算法传递一个能力更差的迭代器会产生错误,对于这类错误,很多编译器不会给出任何警告迭代器类别 描述 输入迭代器 只读不写,单遍扫描,只能递增 输出迭代器 只写不读,单遍扫描,只能递增 前向迭代器 可读写,多遍扫描,只能递增 双向迭代器 可读写,多遍扫描,可递增递减 随机访问迭代器 可读写,多遍扫描,支持全部迭代器运算 输入迭代器
介绍
1、输入迭代器可以读取序列的元素,且输入迭代器只用于顺序访问
2、对于一个输入迭代器,*it++
保证是有效的,但递增它可能导致所有其他指向流的迭代器失效,其结果就是不能保证输入迭代器的状态可以保存并用来访问元素。因此,输入迭代器只能用于单遍扫描算法
3、istream_iterator
是一种输入迭代器输入迭代器必须支持的操作
1、用于比较两个迭代器的
==
和!=
运算符
2、用于推进迭代器的前置和后置递增运算符++
3、用于读取元素的解引用运算符*
,解引用只会出现在赋值运算符右侧
4、箭头运算符->
,等价于(*it).mem
输出迭代器
介绍
1、输出迭代器的特点是只写不读元素,且我们只能向输出迭代器赋值一次
2、类似输入迭代器,输出迭代器也只能用于单遍扫描算法。用作目的位置的迭代器通常都是输出迭代器,如copy
第三个表示目的位置的迭代器参数就是输出迭代器
3、ostream_iterator
是一种输出迭代器输出迭代器必须支持的操作
1、用于推进迭代器的前置和后置递增运算符
++
2、解引用运算符*
,解引用只会出现在赋值运算符左侧(向一个已经解引用的输出迭代器赋值,就是将值写入它所指的元素)
前向迭代器
1、前向迭代器可以读写元素,这类迭代器只能在序列中沿一个方向移动
2、前向迭代器支持所有输入输出迭代器的操作,而且可以多次读写同一个元素,因此我们可以保存前向迭代器的状态,算法可以对序列多次扫描
3、算法replace
要求前向迭代器,forward_list
上的迭代器是前向迭代器双向迭代器
1、双向迭代器可以正向反向读写元素
2、除了支持所有前向迭代器的操作外,还支持前置和后置递减运算符--
3、算法reverse
要求双向迭代器,除了forward_list
外,其他标准库都提供符合双向迭代器要求的迭代器随机访问迭代器
介绍
1、随机访问迭代器提供在常量时间
O(1)
内访问任意元素的能力
2、此类迭代器除了支持双向迭代器的操作,还支持一些其他操作
3、算法sort
要求随机访问迭代器,array
、deque
、string
、vector
的迭代器都是随机访问迭代器,用于访问内置数组元素的指针也是随机访问迭代器额外支持的操作
1、用于比较两个迭代器相对位置的关系运算符
<
、<=
、>
、>=
2、迭代器和一个整数值的加减运算,计算结果是在序列中前进(或后退)给定整数个元素的位置
3、用于两个迭代器上的减法运算,得到两个迭代器的距离
4、下标运算符iter[n]
,与*(iter[n])
等价
算法参数规范
1、在任何其他算法分类之上,还有一组参数规范。理解参数规范可以更方便得知算法需要的参数及操作,大多算法都具有如下四种形式之一
2、alg
是算法的名字,beg
和end
表示算法所操作的输入范围。几乎所有算法都接受一个输入范围,是否有其他参数依赖于要执行的操作,dest
、beg2
、end2
都是可以顾名思义的迭代器参数
3、除了这些迭代器参数,一些算法还接受额外的、非迭代器的特定参数alg(beg, end, other_args); alg(beg, end, dest, other_args); alg(beg, end, beg2, other_args); alg(beg, end, beg2, end2, other_args);
算法命名规范
1、除了参数规范,算法还遵循一套命名和重载规范
2、一些算法使用重载形式传递一个谓词。接受谓词参数来代替<
或==
运算符的算法,以及不接受额外参数的算法,通常都是重载的函数,如unique
3、一些算法有_if
版本。接受一个元素值的算法通常有另一个不同名的版本(非重载版本),其接受一个谓词代替元素值,这些算法都有_if
后缀,如find
4、一些算法有区分拷贝和不拷贝的版本。默认情况下,重排元素的算法将重排后的元素写回给定输入序列中,这些算法还提供另一个版本将元素写到指定输出目的位置,其名字后附加_copy
后缀,如reverse
unique(beg, end); // 使用 == 运算符比较元素 unique(beg, end, comp); // 使用 comp 比较元素 find(beg, end, val); // 查找输入范围中 val 第一次出现的位置 find_if(beg, end, pred); // 查找第一个令 pred 为真的元素 reverse(beg, end); // 反转输入范围中元素的顺序 reverse_copy(beg, end, dest); // 将元素按逆序拷贝到 dest
特定容器算法
链表类型的成员算法
1、与其他容器不同,链表类型
list
和forward_list
定义了几个成员函数形式的算法,这些算法都返回void
,如下表
2、链表类型定义的其他算法的通用版本也可以用于链表,但代价太高。这些通用版本算法需要交换元素,而链表可以通过改变元素间的链接来快速交换元素。因此,链表版本的算法比通用版本的算法性能好得多,应当优先使用成员函数版本的算法list 和 forward_list 的成员算法 描述 lst.merge(lst2),lst.merge(lst2, comp) 将来自 lst2 的元素合并入 lst。lst 和 lst2 都必须是有序的,元素将从 lst2 中删除,合并后 lst2 变为空。第一个版本使用 <
运算符,第二个版本使用 comp 给定的比较操作lst.remove(val),lst.remove_if(pred) 调用 erase 删除与给定值相等或令一元谓词 pred 为真的每个元素 lst.reverse() 反转 lst 中元素的顺序 lst.sort(),lst.sort(comp) 使用 <
或 comp 给定的比较操作排序元素lst.unique(),lst.unique(pred) 调用 erase 删除同一个值的连续拷贝。第一个版本使用 ==
,第二个版本使用 pred 给定的二元谓词splice 成员
1、链表类型还定义了
splice
算法,其形式如lst.splice(args)
或flst.splice_after(args)
,参数描述如下表
2、此算法是链表数据结构特有的,因此不需要通用版本splice 的 args 参数 描述 (p, lst2) p 是一个指向 lst 中元素的迭代器,或一个指向 flst 首前位置的迭代器。函数将 lst2 的所有元素移动到 lst 中 p 之前的位置或是 flst 中 p 之后的位置,将元素从 lst2 中删除。lst2 不能是与 lst 或 flst 相同的链表 (p, lst2, p2) p2 是一个指向 lst2 中位置的有效迭代器。函数将 p2 指向的元素移动到 lst 中,或将 p2 之后的元素移动到 flst 中。lst2 可以是与 lst 或 flst 相同的链表 (p, lst2, b, e) b 和 e 表示 lst2 中的合法范围。函数将给定范围中的元素从 lst2 移动到 lst 或 flst。lst2 可以是与 lst 或 flst 相同的链表,但 p 不能指向给定范围中的元素 链表特有的操作会改变容器
1、多数链表特有的算法与其通用版本很相似,但不完全相同,二者至关重要的区别是链表版本会改变底层的容器
2、例如,通用版本的merge
将合并的序列写入到一个给定的目的迭代器,两个输入序列不变;而链表版本的merge
会销毁给定的链表,元素将从指定链表中删除,被合并到调用merge
的链表中
关联容器
章节概要:使用关联容器;
map
;set
;关联容器概述;通用操作;重复关联容器;关键字类型要求;关键字类型的比较函数;pair
类型;创建pair
的函数;关联容器操作;关联容器迭代器;迭代器解引用;关联容器和算法;添加元素;insert
操作;insert
返回值;删除元素;erase
操作;map
下标操作;下标操作的返回值;访问元素;在multimap
或multiset
中查找元素;示例:单词转换程序;无序容器;使用无序容器;管理桶;关键字类型要求
使用关联容器
关联容器类型
1、关联容器支持高效的关键字查找和访问。两个主要的关联容器是map和set
2、map中的元素是一些键值对,其中关键字起到索引作用,值则表示与索引相关联的数据。set每个元素只包含一个关键字,可以快速检查一个给定关键字是否在 set 中
3、类型map
和multimap
定义在头文件map
中,类型set
和multiset
定义在头文件set
中,相对应的无序容器定义在头文件unordered_map
和unordered_set
中关联容器类型 描述 map 关联数组,保存键值对 set 只保存关键字的容器 multimap 关键字可重复出现的 map multiset 关键字可重复出现的 set unordered_map 用哈希函数组织的无序 map unordered_set 用哈希函数组织的无序 set unordered_multimap 用哈希函数组织的无序 map,关键字可重复出现 unordered_multiset 用哈希函数组织的无序 set,关键字可重复出现 使用 map
1、
map
是键值对的集合。例如,可以将人名作为关键字,将电话号码作为值,我们称这样的数据结构为将名字映射到电话号码
2、map
常被称为关联数组,关联数组与普通数组类似,不同之处在于其下标不必是整数,我们通过关键字而不是位置来查找值
3、类似顺序容器,关联容器也是模板。因此定义一个map
,必须指定关键字和值的类型
4、从map
提取元素时,会得到一个pair
类型对象(一个模板类型,保存名为 first 和 second 的公有数据成员),map
所使用的pair
用first
保存关键字,second
保存值// 统计每个单词出现的次数 map<string, int> word_count; // 创建 string 到 int 的空 map string word; while(cin >> word) word_count[word]++; // 提取关键字 word 的计数器并累加 // 打印结果 for(const auto &w : word_count) cout << w.first << ": " << w.second << endl;
使用 set
1、
set
是关键字的集合。当只是想知道一个值是否存在时,set
是最有用的
2、set
也是一个模板,定义一个set
必须指定其元素类型
3、find
成员返回一个迭代器,如果给定关键字在set
中,则迭代器指向该关键字,否则指向尾后迭代器
4、对于上面使用map
的例子,我们可以额外使用set
实现忽略统计某些单词,如下// 统计每个单词出现的次数 map<string, int> word_count; // 创建 string 到 int 的空 map set<string> exclude = {"the", "but", "and", "or", "an", "a"}; string word; while(cin >> word) // 只统计不在 exclude 中的单词 if(exclude.find(word) == exclude.end()) word_count[word]++; // 提取关键字 word 的计数器并累加
关联容器概述
通用操作
1、关联容器支持所有的普通容器操作(见 9.2 节)
2、但不支持顺序容器的位置相关的操作,例如push_front
或push_back
,原因是关联容器中的元素是根据关键字存储的,这些操作对关联容器没有意义
3、此外,关联容器也不支持构造函数或插入操作这些接受一个元素值和一个数量值的操作重复关联容器
1、一个
map
或set
的关键字必须唯一,即对于一个给定关键字,只能有一个元素与之对应。而multimap
和multiset
没有此限制,即多个元素都可以具有相同的关键字
2、下例展示了具有唯一关键字的容器与允许重复关键字的容器的区别// 定义有 20 个元素的 vector,保存 0-9 每个整数的两个拷贝 vector<int> ivec; for(vector<int>::size_type i = 0; i != 10; i++) { ivec.push_back(i); ivec.push_back(i); // 重复保存一次 } // iset 包含来自 ivec 的不重复的元素,miset 包含所有 20 个元素 set<int> iset(ivec.begin(), ivec.end()); multiset<int> miset(ivec.begin(), ivec.end()); cout << ivec.size() << endl; // 打印出 20 cout << iset.size() << endl; // 打印出 10 cout << miset.size() << endl; // 打印出 20
关键字类型要求
有序容器的关键字类型
1、对于有序容器,关键字类型必须定义元素比较的方法。默认情况下,标准库使用关键字类型的
<
运算符来比较两个关键字
2、在集合类型(set
)中,元素类型就是关键字类型;在映射类型(map
)中,元素的第一部分的类型就是关键字类型
3、先前可以向一个算法提供我们自定义的比较操作,与之类似,也可以提供自定义的操作来代替关键字上的<
运算符
4、但所定义的操作必须在关键字类型上定义一个严格弱序(可以看做<=
),无论我们怎样定义比较函数,它必须具备下列性质
5、实际编程中,如果一个类型定义了行为正常的<
运算符,则它可以用作关键字类型比较函数所需的性质
1、两个关键字不能同时
<=
对方
2、如果k1<=
k2,且k2<=
k3,那么必须也有k1<=
k3
3、如果存在两个关键字,任何一个都不<=
对方,那么称这两个关键字等价(容器将它们视作相等)。如果k1 等价于 k2,且k2 等价于 k3,那么必须也有k1 等价于 k3关键字类型的比较函数
1、用来组织一个容器中元素操作的类型也是容器类型的一部分。为了指定使用自定义的操作,必须在定义关联容器类型时提供操作的类型,在尖括号中紧跟元素类型给出
2、如下例,我们定义了一个比较函数,我们希望创建一个multiset
使用该函数为元素排序。定义multiset
时必须提供两种类型:一个关键字类型和一个函数指针类型
3、bookstore(compareIsbn)
使用传入函数地址初始化对象,表示当我们向bookstore
添加元素时,通过调用compareIsbn
为这些元素排序// 比较函数 bool compareIsbn(Sales_data &lhs, Sales_data &rhs) { return lhs.isbn() < rhs.isbn(); } // bookstore 中的多条记录可以有相同的 ISBN // bookstore 中的元素以 ISBN 的顺序进行排列 // 尖括号中 Sales_data 为关键字类型,decltype(compareIsbn)* 为指向 compareIsbn() 的函数指针 multiset<Sales_data, decltype(compareIsbn)*> bookstore(compareIsbn);
pair 类型
pair
类型与操作1、在介绍关联容器操作之前,我们需要了解名为
pair
的标准库类型,它定义在头文件utility
中
2、一个pair
保存两个数据成员。类似容器,pair
是一个用来生成特定类型的模板。因此当创建一个pair
时,必须提供两个类型名,其两个数据成员将分别具有对应的类型,两个类型不要求一致:pair<string, vector<int>> line
3、pair
的默认构造函数对数据成员进行值初始化,我们也可以为每个成员提供初始化器:pair<string, string> author{"James", "Joyce"}
4、与其他标准库类型不同,pair
的数据成员是public
的,我们可以通过成员访问符直接访问
5、标准库定义了如下几个pair
操作pair 操作 描述 pair<T1,T2> p; p 是一个 pair,两个类型分别为 T1 和 T2 的成员都进行了值初始化 pair<T1,T2> p(v1,v2); p 是一个成员类型为 T1 和 T2 的 pair,first 和 second 成员分别用 v1 和 v2 进行初始化 pair<T1,T2> p = {v1,v2}; 等价于 p(v1,v2) make_pair(v1,v2) 返回一个用 v1 和 v2 初始化的 pair,pair 类型由 v1 和 v2 的类型推断 p.first 返回 p 的名为 first 的公有数据成员 p.second 返回 p 的名为 second 的公有数据成员 p1 < p2 关系运算符按字典序定义,利用元素的 < 运算符来实现 p1 == p2,p1 != p2 当 first 和 second 分别相等时,则 pair 相等 创建 pair 的函数
1、如果一个函数需要返回一个
pair
。在新标准下,我们可以对返回值进行列表初始化
2、在早期版本中,不允许使用花括号包裹的初始化器来返回pair
类型对象,必须显式构造返回值// 返回 pair 的函数 pair<string, int> process(vector<string> &v) { // 处理 v if(!v.empty()) return {v.back(), v.back().size()}; // 列表初始化 return make_pair(v.back(), v.back().size()); // 使用 make_pair return pair<string, int>(v.back(), v.back().size()); // 早期版本 else return pair<string, int>(); // 隐式构造返回值 }
关联容器操作
关联容器迭代器
额外的类型别名
类型别名 描述 key_type 此容器类型的关键字类型 mapped_type 每个关键字关联的类型,只适用于 map value_type 对于 map,为 pair<const key_type, mapped_type>,对于 set,与 key_type 相同 迭代器解引用
1、当解引用一个关联容器迭代器时,会得到一个类型为容器
value_type
的值的引用
2、如下例,对map
而言,value_type
是一个pair
类型,但要注意map
的value_type
中的key_type
是const
的(即表示索引名的first
不可改变)
3、set
的迭代器类型同时定义了iterator
和const_interator
,但两种类型都只允许只读访问(即索引名不可改变)
4、遍历关联容器的方法与先前遍历顺序容器的方法类似,使用begin
和end
成员完成// 获得指向 word_count 中一个元素的迭代器 map<string, int> word_count; auto map_it = word_count.begin(); // *map_it 是一个指向 pair<const string, int> 对象的引用 cout << map_it->first << ' ' << map_it->second; map_it->first = "new key"; // 错误:关键字是 const 的,不能改变索引名 map_it->second++; // 正确:可以通过迭代器改变元素的值
关联容器和算法
1、我们通常不对关联容器使用泛型算法,因为关键字是
const
意味着不能将关联容器传递给修改或重排容器元素的算法
2、虽然关联容器可用于只读元素的算法,但是很多这类算法都要搜索序列。由于关联容器的元素不能通过它们的关键字快速查找,因此使用搜索算法几乎总是个坏主意
3、关联容器定义了一个名为find
的成员函数,通过一个给定的关键字直接获取元素。泛型算法库中也有一个find
算法,但此算法会进行顺序搜索,因此使用关联容器定义的find
成员函数比调用泛型find
好得多
4、实际编程中,如果我们真要对一个关联容器使用算法,要么是将它当做一个源序列,要么是当做一个目的位置
添加元素
insert
操作1、关联容器的
insert
成员向容器中添加一个元素或一个元素范围
2、由于map
和set
包含不重复的关键字,因此插入已存在的元素对容器没有任何影响
3、对一个map
进行insert
操作时,必须记住元素类型是pair
,我们可以在insert
参数列表中创建一个pair
,此外还另有如下三种方法// 重复元素对容器没有影响 vector<int> ivec = {2,4,6,8,2,4,6,8}; // ivec 有 8 个元素 set<int> set2; // 空集合 set2.insert(ivec.cbegin(), ivec.cend()); // set2 有 4 个元素 set2.insert({1,3,5,7,1,3,5,7}); // set2 现在有 8 个元素 // 向 map 添加元素的 4 种方法 map<string, size_t> word_count; word_count.insert({word, 1}); word_count.insert(make_pair(word, 1)); word_count.insert(pair<string, size_t>(word, 1)); word_count.insert(map<string, size_t>::value_type(word, 1));
insert 操作 描述 c.insert(v),c.emplace(args) v 是 value_type 类型的对象,args 用来构造一个元素。对于 map 和 set,只有当元素的关键字不在 c 中时才插入(或构造)元素,函数返回一个 pair,包含一个迭代器,指向具有指定关键字的元素,以及一个指示插入是否成功的 bool 值。对于 multimap 和 multiset,总会插入(或构造)给定元素,并返回一个指向新元素的迭代器 c.insert(b,e),c.insert(il) b 和 e 是迭代器,表示一个 c::value_type 类型值的范围,il 是这种值的花括号列表。函数返回 void c.insert(p,v),c.insert(p,args) 类似 insert(v) 或 emplace(v),但将迭代器 p 作为一个指示,指出从哪里开始搜索新元素应该存储的位置。返回一个迭代器,指向具有给定关键字的元素 insert
返回值1、
insert
和emplace
的返回值依赖于容器类型和参数。对于不包含重复关键字的容器,它们返回一个pair
,其first
指向具有给定关键字的元素,second
为一个bool
值,如果关键字已在容器中则为false
2、作为一个例子,我们用insert
重写单词计数程序,如下例map<string, size_t> word_count; string word; while(cin >> word) { // 插入一个元素,关键字等于 word,值为 1 // ret 记录返回值 auto ret = word_count.insert({word, 1}); // 如果已经存在,则 insert 什么也没做,手动 +1 if(!ret.second) ret.first->second++; // 递增计数器,等价于 word_count[word]++ // ret.first 是指向具有该关键字的元素的迭代器 // ret.first->second 该元素的计数器部分(second) }
删除元素
erase
操作erase 操作 描述 c.erase(k) 从 c 中删除每个关键字为 k 的元素。返回一个 size_type 值,指出删除的元素的数量 c.erase(p) 从 c 中删除迭代器 p 指定的元素。p 必须指向 c 中一个真实元素,不能等于 c.end()。返回指向 p 之后元素的迭代器,若 p 指向 c 中的尾元素,则返回 c.end() c.erase(b,e) 删除迭代器对 b 和 e 所表示的范围中的元素。返回 e
map 下标操作
下标操作
1、
map
和unordered_map
提供了下标运算符和一个对应的at
函数
2、set
类型不支持下标,因为set
中没有与关键字相关联的值
3、我们不能对一个multimap
或一个unordered_multimap
进行下标操作,因为这些容器中可能有多个值与一个关键字相关联
4、下标和at
操作只适用于非const
的map
和unordered_map
下标操作 描述 c[k] 返回关键字为 k 的元素;如果 k 不在 c 中,添加一个关键字为 k 的元素,对其进行值初始化 c.at(k) 访问关键字为 k 的元素,带参数检查;若 k 不在 c 中,抛出一个 out_of_range 异常 下标操作的返回值
1、
map
的下标运算符与我们用过的其他下标运算符的一个不同之处是返回类型。通常,解引用一个迭代器返回的类型和下标运算符返回的类型是一样的,但对map
则不然
2、当对一个map
进行下标操作时,会获得一个mapped_type
对象;当解引用一个map
时,会得到一个value_type
对象
3、相同的是,map
的下标运算符返回一个左值,这意味着我们既可以读也可以写元素
4、如果关键字还未在map
中,下标运算符会添加一个新元素,这一特性允许我们编写出十分简洁的程序cout << word_count["Anna"]; ++word_count["Anna"]; cout << word_count["Anna"];
访问元素
访问操作
1、关联容器提供多种查找一个指定元素的方法,如下表。具体应该使用哪个操作依赖于我们要解决什么问题
2、对于不允许重复关键字的容器,使用find
和count
没什么区别;对于允许重复关键字的容器,count
额外还会统计有多少个元素有相同的关键字
3、lower_bound
和upper_bound
不适用于无序容器set<int> iset = {0,1,2,3,4,5,6,7,8,9}; iset.find(1); // 返回一个迭代器,指向 key == 1 的元素 iset.find(11); // 返回一个迭代器,其值等于 iset.end() iset.count(1); // 返回 1 iset.count(11); // 返回 0 // 使用 find 检查一个元素是否存在 if(word_count.find("foobar") == word_count.end())
访问操作 描述 c.find(k) 返回一个迭代器,指向第一个关键字为 k 的元素,若 k 不在容器中,则返回尾后迭代器 c.count(k) 返回关键字等于 k 的元素的数量 c.lower_bound(k) 返回一个迭代器,指向第一个关键字不小于 k 的元素(首个具有给定关键字的元素的位置) c.upper_bound(k) 返回一个迭代器,指向第一个关键字大于 k 的元素(最后一个匹配给定关键字的元素之后的位置) c.equal_range(k) 返回一个迭代器 pair,表示关键字等于 k 的元素的范围。若 k 不存在,pair 的两个成员均等于 c.end() 在
multimap
或multiset
中查找元素1、在一个不允许重复关键字的关联容器中查找一个元素是一件很简单的事情——元素要么在容器中,要么不在;但对于允许重复关键字的容器过程更为复杂:在容器中可能有很多元素具有给定的关键字
2、如果一个multimap
或multiset
中有多个元素具有给定关键字,则这些元素在容器中会相邻储存
3、例如,给定一个从作者到书籍的映射,我们可能想打印一个特定作者的所有著作,共有三种方法解决这个问题// 最直观的方法:使用 find 和 count string search_item("Alain de Botton"); // 要查找的作者 auto entries = authors.count(search_item); // 元素的数量 auto iter = authors.find(search_item); // 作者的第一本书 // 用一个循环来查找此作者的所有著作 while(entries) { cout << iter->second << endl; // 打印书籍名 ++iter; // 前进到下一本书 --entries; // 记录已经打印了多少本书 }
// 面向迭代器的解决方法:使用 lower_bound 和 upper_bound string search_item("Alain de Botton"); // 要查找的作者 // beg 和 end 表示对应此作者的元素的范围 for(auto beg = authors.lower_bound(search_item), end = authors.upper_bound(search_item); beg != end; beg++) cout << beg->second << endl; // 打印书籍名
// 最直接的方法:使用 euqal_range string search_item("Alain de Botton"); // 要查找的作者 // pos 保存迭代器时,表示与关键字匹配的元素范围,是一个 pair for(auto pos = authors.equal_range(search_item); pos.first != pos.second; pos.first++) cout << pos.first->second << endl; // 打印书籍名
示例:单词转换程序
需求描述:向程序输入一段文字,将如下单词转换成对应单词
where r u y dont u send me a pic k thk 18r
where are you why dont you send me a picture okay? thanks! later
单词转换程序
1、我们的程序将使用三个函数来实现这些功能
2、函数word_transform
管理整个过程,它接受两个ifstream
参数:第一个参数绑定到单词转换规则文件,第二个参数绑定到要转换的文本文件
3、函数buildMap
会读取转换规则文件,并创建一个map
,用于保存每个单词到其转换内容的映射
4、函数transform
接受一个string
,如果存在转换规则,返回转换后的内容void word_transform(ifstream &map_file, ifstream &input) { auto trans_map = buildMap(map_file); // 调用 buildMap,保存转换规则 string text; // 保存输入中的每一行 while(getline(input, text)) // 读取一行输入 { istringstream stream(text); // 字符输入流,用于读取每个单词 string word; bool firstword = true; // 控制是否打印空格 while(stream >> word) // 读取一个单词 { if(firstword) firstword = false; else cout << ' '; // 单词之间打印一个空格 cout << transform(word, trans_map); // 调用 transform,翻译并输出 } cout << endl; // 完成一行的转换,打印换行 } }
map<string, string> buildMap(ifstream &map_file) { map<string, string> trans_map; // 保存转换规则 string key; // 要转换的单词 string value; // 替换后的内容 // 读取第一个单词存入 key 中,行中剩余内容存入 value while(map_file >> key && getline(map_file, value)) if(value.size() > 1) // 检查是否有转换规则 trans_map[key] = value.substr(1); // 跳过前导空格 else throw runtime_error("no rule for" + key); return trans_map; }
const string &transform(const string &s, const map<string, string> &m) { // 实际的转换工作;此部分是程序的核心 auto map_it = m.find(s); // 如果单词在转换规则 map 中 if(map_it != m.cend()) return map_it->second; // 使用替换短语 else return s; // 否则返回原 string }
无序容器
介绍
1、新标准定义了4 个无序关联容器,这些容器不使用比较运算符来组织元素,而是使用一个哈希函数和关键字类型的
==
运算符
2、在关键字类型的元素没有明显的序关系的情况下,无序容器是非常有用的。在某些应用中,维护元素的序代价非常高时,无序容器也很有用
3、理论上哈希技术能获得更好的平均性能,但实际中还需要进行性能测试和调优。不过因此,使用无序容器通常更为简单,也有更好的性能使用无序容器
1、除了哈希管理操作之外,无序容器还提供了与有序容器相同的操作(如
find
、insert
等)
2、这意味着我们曾用于map
和set
的操作也能用于unordered_map
和unordered_set
。类似的,无序容器也有允许重复关键字的版本
3、因此,通常可以用一个无序容器替换对应的有序容器,反之亦然。但是由于元素未按顺序存储,其输出会与有序容器的版本不同管理桶
1、无序容器在存储上组织为一组桶,每个桶保存零个或多个元素。无序容器使用一个哈希函数将元素映射到桶,为了访问一个元素,容器首先计算元素的哈希值,它指出应该搜索哪个桶
2、容器将具有特定哈希值的所有元素保存在相同桶中。如果容器允许重复关键字,所有具有相同关键字的元素也都会保存在相同桶中。因此,无序容器的性能依赖于哈希函数的质量和桶的数量与大小
3、对于相同的参数,哈希函数必须总是产生相同结果。理想情况下,哈希函数还能将每个特定的值映射到唯一的桶,但将不同关键字的元素映射到相同的桶也是允许的。当一个桶保存多个元素时,需要顺序搜索这些元素来查找目标元素
4、无序容器提供了一组管理桶的函数,如下表。它们将允许我们查询容器状态及必要时强制容器进行重组无序容器管理操作 描述 c.bucket_count() 正在使用的桶的数量 c.max_bucket_count() 容器能容纳的最多的桶的数量 c.bucket_size(n) 第 n 个桶有多少个元素 c.bucket(k) 关键字为 k 的元素在哪个桶里 local_iterator 可以用来访问桶中元素的迭代器类型 const_local_iterator 桶迭代器的 const 版本 c.begin(n),c.end(n) 桶 n 的首元素迭代器和尾后迭代器 c.cbegin(n),c.cend(n) 与前两个函数类型,返回 const_local_iterator c.load_factor() 每个桶的平均元素数量,返回 float 值 c.max_load_factor() c 试图维护的平均桶大小,返回 float 值。c 会在需要时添加新的桶,以使得 load_factor <= max_load_factor c.rehash(n) 重组存储,使得 bucket_count >= n 且 bucket_count > size/max_load_factor c.reserve(n) 重组存储,使得 c 可以保存 n 个元素且不必 rehash 关键字类型要求
描述
1、默认情况下,无序容器使用关键字类型的
==
运算符来比较元素,他们还使用一个hash<key_type>
类型对象来生成每个元素的哈希值
2、标准库为内置类型(包括指针)提供了hash
模板,还为一些标准库类型(如string
、智能指针等)定义了hash
。因此,我们可以直接定义关键字类型是内置类型和标准库类型的无序容器
3、但是,我们不能直接定义关键字类型为自定义类类型的无序容器。与容器不同,不能直接使用哈希模板,而是必须提供我们自己的hash
模板版本,我们将在后续介绍如何做到这点定义重载函数
1、我们不使用默认的
hash
,而是用另一种办法,类似于为有序容器重载关键字类型的默认比较操作
2、为了能将Sales_data
用作关键字,我们需要提供函数来代替==
运算符和哈希值计算函数。我们从定义这些重载函数开始,如下例
3、我们的hasher
使用一个标准库hash
类型对象来计算ISBN
成员的哈希值,该hash
类型建立在string
类型之上。类似的,eq0p
通过比较ISBN
号来比较两个Sales_data
4、接着如下,我们使用这些函数来定义一个unordered_multiset
size_t hasher(const Sales_data &sd) { return hash<string>()(sd.isbn()); } bool eq0p(const Sales_data &lhs, const Sales_data &rhs) { return lhs.isbn() == rhs.isbn(); } using SD_multiset = unordered_multiset<Sales_data, decltype(hasher)*, decltype(eq0p)*>; // 参数是桶大小、哈希函数指针 和 相等性判断运算符指针 SD_multiset bookstore(42, hasher, eq0p);
动态内存
章节概要:动态内存与智能指针;
shared_ptr
类(智能指针);定义与使用智能指针;智能指针操作;make_shared
函数;shared_ptr
的拷贝与赋值;shared_ptr
的销毁与释放;设计使用动态生存期资源的类;直接管理内存(动态内存指针);new
动态分配和初始化对象;delete
释放内存;结合使用与异常;shared_ptr
和new
结合使用;智能指针和异常;自定义的释放;其他智能指针;unique_ptr
类;weak_ptr
类;设计核查指针类;动态数组;new
和动态数组;智能指针管理动态数组;allocator
类;使用标准库:文本查询程序
动态内存与智能指针
引入
1、我们的程序到目前为止只使用过静态内存或栈内存。静态内存用来保存局部
static
对象、类static
数据成员以及定义在任何函数外的变量;栈内存用来保存定义在函数内的非static
对象
2、分配在静态内存或栈内存中的对象由编译器自动创建和销毁。对于栈对象,仅在其定义的程序块运行时才存在;而static
对象在使用之前分配,程序结束时销毁
3、除了静态内存和栈内存,每个程序还拥有一个内存池,这部分内存被称作自由空间或堆。程序用堆来存储动态分配的对象(即程序运行时分配的对象)
4、动态对象的生存期由程序控制,也就是说,当动态对象不使用时,代码必须显式地销毁它们。虽然使用动态内存有时是必要的,但正确地管理动态内存是非常棘手的动态内存
1、动态内存的管理是通过一对运算符来完成的:
new
在动态内存中为对象分配空间并返回指向该对象的指针,我们可以选择对对象进行初始化;delete
接受一个动态对象指针,销毁该对象并释放关联的内存
2、动态内存的使用很容易出问题,因为确保在正确的时机释放内存是十分困难的。有时我们会忘记释放内存,就会产生内存泄漏;有时在尚有指针引用内存时释放,就会产生引用非法内存的指针智能指针
1、为了更安全便捷地使用动态内存,新的标准库提供了两种智能指针类型来管理动态对象,它们都定义在
memory
头文件中
2、智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象
3、新标准库提供的两种智能指针区别在于管理底层指针的方式:shared_ptr
允许多个指针指向同个对象,unique_ptr
则独占所指向的对象
4、标准库还定义了一个名为weak_ptr
的伴随类,它是一种弱引用,指向shared_ptr
所管理的对象
shared_ptr 类(智能指针)
定义与使用智能指针
1、类似于
vector
,智能指针也是模板。因此,当我们创建智能指针时,必须在尖括号内提供额外的信息,即提供指针指向的类型
2、默认初始化的智能指针中保存着一个空指针,我们将在后续介绍初始化智能指针的其他方法
3、智能指针使用方式与普通指针类似,解引用一个智能指针返回它指向的对象。如果在一个条件判断中使用智能指针,效果为检测它是否为空shared_ptr<string> p1; // shared_ptr,可以指向 string shared_ptr<list<int>> p2; // shared_ptr,可以指向 int 的 list if(p1 && p1 -> empty()) // 如果智能指针 p1 不为空,其指向的 string 对象也不为空 *p1 = "hi"; // 解引用 p1,将一个新值赋予 string 对象
智能指针操作
智能指针通用的操作 描述 shared_ptr<T> sp,unique_ptr<T> up 空智能指针,可以指向类型为 T 的对象 p 将 p 用作一个条件判断,若 p 指向一个对象(即 p 不为空),则为 true *p 解引用 p,得到它指向的对象 p->mem 等价于(*p).mem p.get() 返回 p 中保存的指针(内置指针类型)。要小心使用,若智能指针释放了其对象,返回的指针所指向的对象也就消失了 swap(p, q),p.swap(q) 交换 p 和 q 中的指针 shared_ptr 独有的操作 描述 make_shared<T>(args) 返回一个 shared_ptr,指向一个动态分配的类型为 T 的对象。使用 args 初始化此对象 shared_ptr<T> p(q) p 是 shared_ptr q 的拷贝;此操作会递增 q 中的引用计数器。q 中的指针必须能转换为 T* p = q p 和 q 都是 shared_ptr,所保存的指针必须能相互转换。此操作会递减 p 的引用计数,递增 q 的引用计数;若 p 的引用计数变为 0,则将其管理的原内存释放 p.use_count() 返回与 p 共享对象的智能指针的数量;可能很慢,主要用于调试 p.unique() 若 p.use_count() 为 1,返回 true,否则返回 false make_shared 函数
1、最安全的分配和使用动态内存的方法是调用
memory
头文件中定义的make_shared
函数:make_shared\<T>(args)
2、该函数在动态内存中分配一个对象并初始化,返回指向此对象的shared_ptr
3、当要用make_shared
时,必须指定要创建的对象的类型,定义方式与模板类似
4、类似于顺序容器的emplace
成员函数,make_shared
也可以用其参数来构造给定类型的对象,在其后面的小括号中给出参数调用该类的构造函数。如果不传递任何参数,则会进行值初始化
5、我们通常用auto
定义一个对象保存make_shared
的结果,这种定义方法比较简单// 指向一个值为 42 的 int 的 shared_ptr shared_ptr<int> p1 = make_shared<int>(42); // 指向一个值为 "9999999999" 的 string shared_ptr<string> p2 = make_shared<string>(10, '9'); // 指向一个值初始化的 int,即值为 0 shared_ptr<int> p3 = make_shared<int>(); // 使用 auto 定义对象更方便地保存结果 // p4 指向一个动态分配的空 vector<string> auto p4 = make_shared<vector<string>>();
shared_ptr 的拷贝与赋值
1、当进行拷贝或赋值操作时,每个
shared_ptr
都会记录有多少个其他shared_ptr
指向相同的对象
2、我们可以认为,每个shared_ptr
都有一个关联的计数器,通常称其为引用计数。无论何时我们拷贝一个shared_ptr
,计数器就会递增
3、例如,当用一个shared_ptr
初始化另一个shared_ptr
时,或将它作为参数传递给一个函数时,或作为函数返回值时,它所关联的计数器就会递增。反之,当我们给shared_ptr
赋予新值,或者某个shared_ptr
被销毁,计数器就会递减
4、一旦一个shared_ptr
的计数器变为 0,它就会自动释放自己所管理的对象auto p = make_shared<int>(42); // p 指向的对象只有 1 个引用者 auto q(p); // q 和 p 指向相同对象,此对象有 2 个引用者 auto r = make_shared<int>(30); // r 指向的对象只有 1 个引用者 r = q; // 给 r 赋值,令它指向另一个地址 // 此时,将递增 q 指向对象的引用计数,递减 r 原先指向对象的引用计数 // 而 r 原先指向的对象的引用计数变为 0,没有引用者,将自动释放内存
shared_ptr 的销毁与释放
析构函数与销毁对象
1、当指向一个对象的最后一个
shared_ptr
被销毁时,shared_ptr
会自动销毁此对象。它是通过另一个特殊的成员函数——析构函数完成销毁工作的
2、类似于构造函数,每个类都有一个析构函数。就像构造函数控制对象初始化一样,析构函数控制对象销毁时做的操作
3、析构函数一般用来释放对象所分配的资源。例如string
的构造函数会分配内存来保存字符,而string
的析构函数就负责释放这些内存
4、shared_ptr
的析构函数会递减它指向的对象的引用计数,当引用计数变为 0,析构函数就会销毁对象,并释放内存shared_ptr
能自动释放相关联的内存1、当动态内存不再使用时,
shared_ptr
类会自动释放动态对象,这一特性使得动态内存的使用变得十分容易,如下例
2、由于最后一个shared_ptr
销毁前内存都不会释放,所以保证shared_ptr
无用后销毁十分重要。如果忘记销毁无用的shared_ptr
,程序仍能正确执行,但会浪费内存
3、shared_ptr
无用后仍保留的一种可能情况是:将shared_ptr
存放在一个容器中,随后重排了容器,从而不再需要某些元素。这种情况下,应确保及时手动用erase
删除无用的shared_ptr
// 该函数返回一个 shared_ptr,指向一个 Foo 类型的动态分配对象,对象通过一个类型为 T 的参数 arg 初始化 shared_ptr<Foo> factory(T arg) { return make_shared<Foo>(arg); } void use_factory(T arg) { shared_ptr<Foo> p = factory(arg); // ...此处省略使用 p 的代码 } // p 离开了作用域,它指向的内存会被自动释放 shared_ptr<Foo> use_factory2(T arg) { shared_ptr<Foo> p = factory(arg); // ...此处省略使用 p 的代码 return p; // 返回 p 时,引用计数递增 } // p 离开了作用域,但引用递增不为 0,不会被销毁
设计使用动态生存期资源的类
使用动态内存的场景
1、程序不知道自己需要使用多少对象
2、程序不知道所需对象的准确类型
3、程序需要在多个对象间共享数据因共享数据而使用动态内存的情景
1、容器类是出于上述第一种原因而使用动态内存的典型例子;我们还将在 15 章见到出于第二种原因而使用动态内存的例子;而在此,我们将举出因第三种原因而使用动态内存的例子
2、到目前为止,我们使用过的类中,分配的资源具有与原对象一致的生存期。例如每个vector
都拥有自己的元素,当我们拷贝vector
时,原vector
和副本vector
中的元素是相互分离的。此外,一个vector
的元素只有当这个vector
还存在时才存在,当vector
被销毁,则对应元素也销毁
3、但某些类中,分配的资源具有与原对象相独立的生存期。例如,假设我们希望定义一个名为Blob
的类,保存一组元素。与容器不同,我们希望Blob
对象的不同拷贝之间共享相同的元素。即,当我们拷贝一个Blob
元素时,原Blob
和副本Blob
应该引用相同的底层元素
4、一般而言,如果两个对象共享底层的数据,当某个对象被销毁时,不能单方面销毁底层数据。此外,允许多个对象共享相同的状态是使用动态内存的一个很常见的原因Blob<string> b1; // 空 blob // 任意一个新作用域 { Blob<string> b2 = {"a", "an", "the"}; b1 = b2; // b1 和 b2 共享相同的元素 } // 此时 b2 由于离开作用域而被销毁,但其元素不能被销毁,因为 b1 仍指向这些元素
定义
StrBlob
类1、由于后续才会学习模板有关内容,因此现在我们先定义一个管理
string
的类,命名为StrBlob
,如下例
2、实现一个新的集合类型的最简单方法是使用某个标准库容器来管理元素。采用这种方法,我们可以借助标准库类型管理元素所使用的内存。本例中,我们将使用vector
保存元素。但是,我们不能在一个Blob
对象内直接保存vector
,因为一个对象的成员在对象销毁时也会被销毁。为了保证元素继续存在,我们将vector
保存在动态内存中
3、为了实现我们希望的数据共享,我们为每个StrBlob
设置了一个shared_ptr
来管理动态分配的vector
,使用动态指针能自动完成对vector
的管理与释放。此外,我们还需要为类定义一些操作和构造函数#include <initializer_list> #include <iostream> #include <memory> #include <string> #include <vector> using std::initializer_list; using std::make_shared; using std::shared_ptr; using std::string; using std::vector; class StrBlob { public: // 定义类型 using size_type = vector<string>::size_type; // 构造函数(使用初始化列表构造) StrBlob() : data(make_shared<vector<string>>()) { } StrBlob(initializer_list<string> il) : data(make_shared<vector<string>>(il)) { } // 成员函数,其中 data-> 实际上就是使用 vector 的成员函数 size_type size() const { return data->size(); } bool empty() const { return data->empty(); } // 添加或删除元素 void push_back(const string &t) { data->push_back(t); } void pop_back(); // 元素访问 string &front(); string &back(); private: // 动态指针操控的动态内存数据成员 shared_ptr<vector<string>> data; // 检查操作,如果 data[i] 不合法,抛出一个异常 void check(size_type i, const string &msg) const; };
元素访问成员函数
1、由于要使用
pop_back
、front
、back
操作访问vector
中的元素,且它们在访问元素前都必须检查元素是否存在,因此我们定义了一个名为check
的private
工具函数,用于检查给定索引是否在合法范围内。该函数还接受一个string
,会将此参数传递给异常处理程序,string
用于描述错误内容
2、pop_back
和元素访问成员函数首先调用check
,如果check
成功,再继续利用底层vector
的操作完成自己的工作void StrBlob::check(size_type i, const string &msg) const { if (i >= data->size()) throw std::out_of_range(msg); } string &StrBlob::front() { // 如果 vector 为空,check 会抛出一个异常 check(0, "front on empty StrBlob"); return data->front(); } string &StrBlob::back() { // 如果 vector 为空,check 会抛出一个异常 check(0, "back on empty StrBlob"); return data->back(); } void StrBlob::pop_back() { // 如果 vector 为空,check 会抛出一个异常 check(0, "pop_back on empty StrBlob"); data->pop_back(); }
StrBlob
的拷贝、赋值和销毁1、类似先前设计的
Sales_data
类,StrBlob
类也使用默认版本的拷贝、赋值和销毁成员函数来对此类对象进行这些操作
2、我们的StrBlob
只有一个数据成员,它是shared_ptr
类型。因此当我们对StrBlob
对象执行这些操作时,它的shared_ptr
成员就会被执行这些操作
3、如前所述,使用了智能指针,其会自动统计引用计数,且会在引用计数为 0时自动销毁
直接管理内存(动态内存指针)
new 动态分配和初始化对象
new
的使用1、C++定义了两个运算符来分配和释放动态内存:
new
分配内存,delete
释放内存。但相对于智能指针,这两个运算符非常容易出错;而且,自己直接管理内存的类和使用智能指针的类不同,它们不能依赖类对象进行拷贝、赋值和销毁操作的任何默认定义
2、在自由空间分配的内存是无名的,因此new
无法为其分配的对象命名,而是返回一个指向该对象的指针(动态内存指针/内置指针)。默认情况下,动态分配的对象是默认初始化的,此外我们也可以用其他一些通用的初始化方式
3、对于定义了自己的构造函数的类类型,要求值初始化是没有意义的,不管采用什么形式,对象都会通过默认构造函数来初始化;但对于内置类型,两种形式就差别较大了,值初始化的内置类型对象有着已定义良好的值,而默认初始化的对象的值是未定义的;类似的,对于类中依赖于编译器合成的默认构造函数的内置类型成员,如果它们未在类内初始化,那么它们的值也是未定义的
4、出于与变量初始化相同的原因,对动态分配的对象进行初始化通常总是个好主意int *pi = new int; // pi 指向一个动态分配的、未初始化的无名 int 对象 string *ps = new string; // 初始化为空 string int *pi2 = new int(1024); // pi2 指向的对象的值为 1024 string *ps2 = new string(10, '9'); // ps2 指向的对象的值为 "9999999999" vector<int> *pv2 = new vector<int>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; string *ps3 = new string; // 默认初始化为空的 string string *ps4 = new string(); // 值初始化为空的 string int *pi3 = new int; // 默认初始化,但 *pi3 的值未定义 int *pi4 = new int(); // 值初始化为 0,即 *pi4 的值为 0
有关
auto
和const
1、如果我们提供了一个小括号包括的初始化器,就可以使用
auto
从该初始化器来推断我们想要分配的对象的类型
2、由于编译器要用初始化器的类型来推断分配的类型,只有当括号中仅有单一初始化器时才可以使用auto
3、用new
分配const
对象是合法的。类似于其他任何const
对象,一个动态分配的const
对象必须进行初始化,且由于分配的对象是const
的,其返回的指针也是指向const
的指针
4、对于一个定义了默认构造函数的类类型,其const
动态对象可以隐式初始化,而其他类型的对象必须显式初始化// p 指向一个与 obj 类型相同的对象,该对象用 obj 进行值初始化 auto p1 = new auto(obj); // 错误:括号中只能有单个初始化器 auto p2 = new auto(a, b, c); // 分配并初始化一个 const int const int *pci = new const int(1024); // 分配并默认初始化一个 const 的空 string const string *pcs = new const string;
内存耗尽
1、虽然现代计算机通常都配备了大容量内存,但自由空间被耗尽的情况还是有可能发生。一旦一个程序用光了所有可用内存,
new
就会失败,并且默认情况下抛出类型为bad_alloc
的异常
2、我们可以改变使用new
的方式来阻止它抛出异常(如下),我们称这种形式的new
为定位 new,定位 new允许我们向new
传递额外的参数
3、在此例中,我们传递给它一个标准库定义的nothrow
对象,意图是告诉它不抛出异常。这种形式的new
如果不能分配内存,就会返回一个空指针
4、bad_alloc
和nothrow
都定义在头文件new
中int *p1 = new int; // 如果分配失败,抛出 std::bad_alloc 异常 int *p2 = new (nothrow) int; // 如果分配失败,不抛出异常,返回空指针
delete 释放内存
释放动态内存
1、为了防止内存耗尽,在动态内存使用完毕后,必须将其归还给系统。我们通过
delete
来将动态内存归还
2、delete
接受一个指针,指向我们想要释放的对象:delete p;
(p 必须指向动态分配对象或空指针)
3、与new
类似,delete
也执行两个动作:销毁对象和释放内存指针值与
delete
1、我们传递给
delete
的指针必须指向动态分配的内存或是空指针。释放一块并非new
分配的内存,或者将相同指针值释放多次,其行为未定义
2、如下例,对于delete i;
的请求,编译器会生成错误信息,因为编译器知道 i 不是一个指针。但对于pi1
和pi2
所产生的错误却更具有潜在危害:
3、通常情况下,编译器不能分辨一个指针指向的是静态分配还是动态分配的对象,类似的,它也不能分辨一个指针所指向的内存是否已经释放了。对于这些delete
,大多数编译器会编译通过,但它们本身是错误的
4、虽然一个const
对象的值不能被改变,但它本身可以被销毁// i 是整型,pi1 是整型静态指针,pi2 是空指针 int i, *pi1 = &i, *pi2 = nullptr; // pd 指向动态分配的 double 对象,pd2 与 pd 指向相同动态对象 double *pd = new double(33), *pd2 = pd; delete i; // 错误:i 不是一个指针 delete pi1; // 未定义:pi1 指向一个局部变量(不是动态对象) delete pi2; // 正确,可以释放空指针 delete pd; // 正确 delete pd2; // 未定义:pd2 指向的内存已经被释放了 const int *pci = new const int(1024); delete pci; // 正确,可以释放 const 动态对象
生存期直到被释放
1、如前所述,由
shared_ptr
管理的内存在最后一个shared_ptr
销毁时会自动释放,但对于动态内存指针来说就不是这样了,其在被显式释放之前始终存在
2、返回指向动态内存的指针的函数给其调用者增加了一个额外负担——调用者必须记得释放内存
3、如下例,我们重写之前已定义的factory
和use_factory
函数,必须做如下修改// factory 返回一个指针,指向一个动态分配的对象 Foo *factory(T arg) { // ...此处省略处理 arg 的代码 return new Foo(arg); // 调用者必须负责释放此内存 } void use_factory(T arg) { Foo *p = factory(arg); // 调用了 factory,就必须记得释放 p 的内存 // ... 此处省略使用 p 的代码 delete p; // 如果已经不再需要 p,必须释放掉 p 的内存 } // 如果忘记 delete 的话,即使离开了作用域,p 的内存仍然没有被释放
delete
后重置指针值1、当我们
delete
一个指针后,指针值就变得无效了。虽然指针已经无效,但在很多机器上指针仍然保存着动态内存的地址,这被称为空悬指针
2、未初始化指针的所有缺点空悬指针都有(例如由于数据被释放,地址指向的数据未定义,但指针仍可能被不知情地调用)。有一种办法可以避免空悬指针的问题:在指针即将离开作用域之前释放内存,这样在关联内存被释放后就没有机会继续使用指针了
3、如果我们需要保留指针,可以在delete
后将nullptr
赋予指针,这样就清楚地指出指针不指向任何对象
4、动态内存的一个基本问题是可能多个指针指向相同内存。在delete
后重置指针的方法只对被直接操作重置的指针有效,对于其他指向相同内存的指针无效。在实际系统中,查找指向相同内存的所有指针是异常困难的,因此查找所有指针再依次重置指针值也是很难实现的int *p(new int(42)); // p 指向动态内存 auto q = p; // q 和 p 指向相同内存 delete p; // p 和 q 都变得无效 p = nullptr; // 重置 p 指出其不再绑定到任何对象 // q 并没有被重置,q 仍然是一个空悬指针
结合使用与异常
shared_ptr 和 new 结合使用
有关结合使用
1、如前所述,如果我们不初始化一个智能指针,它就会被初始化为一个空指针,借此我们还可以用
new
返回的指针来初始化智能指针
2、接受指针参数的智能指针构造函数是explicit
的,因此我们不能将一个内置指针通过隐式转换变成智能指针,必须使用直接初始化来初始化智能指针
3、出于相同的原因,也不能在一个返回shared_ptr
的函数的返回语句中隐式转换普通指针,必须将shared_ptr
显式绑定到一个想要返回的指针上shared_ptr<double> p1; // p1 可以指向一个 double,目前指向空指针 shared_ptr<int> p2(new int(42)); // 正确:使用直接初始化,p2 指向一个值为 42 的 int shared_ptr<int> p3 = new int(1024); // 错误:必须使用直接初始化形式 shared_ptr<int> clone(int p) { return new int(p); // 错误:不能隐式转换为 shared_ptr<int> return shared_ptr<int>(new int(p)); // 正确:显式地用 int* 创建 shared_ptr<int> }
定义和改变 shared_ptr 的方法 描述 shared_ptr<T> p(q) p 管理内置指针 q 所指向的对象。q 必须要指向 new 分配的内存,且能够转换为 T* 类型 shared_ptr<T> p(u) p 从 unique_ptr u 那里接管了对象的所有权,将 u 置空 shared_ptr<T> p(q, d) p 接管了内置指针 q 所指向对象的所有权。q 必须能转换为 T* 类型,p 将使用可调用对象 d( lambda
表达式或对象)来代替 deleteshared_ptr<T> p(p2, d) 与 p(p2) 类似,p 是 p2 的拷贝,但 p 将用可调用对象 d 代替 delete p.reset(),p.reset(q),p.reset(q, d) 若 p 是唯一指向其对象的 shared_ptr,reset 会释放此对象。若传递了可选的参数内置指针 q,会令 p 指向 q,否则将 p 置空。若还传递了参数 d,将用可调用对象 d 代替 delete 不要混合使用普通指针和智能指针
1、
shared_ptr
可以协调对象的析构,但仅限于其自身的拷贝,这也是推荐使用make_shared
而不是new
的原因。这样,我们就能在分配对象同时将shared_ptr
与之绑定,避免无意中将同一块内存绑定到多个独立创建的shared_ptr
上
2、考虑下例的函数。虽然不能传递给process
内置指针,但可以传递一个临时的shared_ptr
,它是用一个内置指针显式构造的,但是这样做可能会导致错误。当调用结束,临时对象会被销毁,但原先的指针仍指向已释放的内存地址,成为空悬指针void process(shared_ptr<int> ptr) { // 使用 ptr } // ptr 离开作用域,被销毁 shared_ptr<int> p(make_shared<int>(42)); // 创建 p,此时引用计数为 1 process(p); // 拷贝 p 会递增它的引用计数,在 process 中引用计数为 2 int i = *p; // 正确:离开函数时引用计数为 1,没有被销毁 // 显式构造临时的 shared_ptr int *x(new int(1024)); // 危险:x 是一个普通指针,不是一个智能指针 process(x); // 错误:不能将 int* 转换成一个 shared_ptr<int> process(shared_ptr<int>(x)); // 合法,但内存会被释放 int j = *x; // 未定义:x 是一个空悬指针
不要用
get
初始化另一个智能指针或为其赋值1、智能指针类型定义了一个
get
函数(前表中提过),它返回一个内置指针,指向智能指针管理的对象
2、此函数是为了向不能使用智能指针的代码中传递一个内置指针的情况而设计的,使用get
返回的指针的代码不能delete
此指针
3、虽然编译器不会给出错误,但将另一个智能指针也绑定到get
返回的指针上是错误的shared_ptr<int> p(new int(42)); // 此时引用计数为 1 int *q = p.get(); // 正确:q 是内置指针,但使用 q 时要注意,不要让它被管理的指针被释放 // 新程序块 { shared_ptr<int>(q); // 未定义:两个独立的 shared_ptr 指向相同的内存 } // 程序块结束,q 被销毁,它指向的内存被释放 int foo = *p; // 未定义:p 指向的内存已经被释放了
其他
shared_ptr
操作1、
shared_ptr
还定义了其他一些操作(具体看前表),比如我们可以用reset
将一个新指针赋予一个shared_ptr
2、与赋值类型,reset
会更新引用计数,需要时会释放 p 指向的对象。因此,reset
常常和unique
一起使用,unique
检查智能指针的引用计数是否为 1,这样便可以控制多个shared_ptr
共享的对象
3、在改变底层对象之前,我们检查自己是否是当前对象仅有的用户(使用unique
);如果不是,在改变之前要制作一份新的拷贝以保证其他用户正常指向当前内存shared_ptr<int> p; p = new int(1024); // 错误:不能将一个指针赋予 shared_ptr p.reset(new int(1024)); // 正确:p 指向一个新对象 if(!p.unique()) p.reset(new int(*p)); // 我们不是唯一用户,分配新的拷贝(通过 new 拷贝 p 的值到新的地址) *p += newVal; // 现在我们知道自己是唯一用户,可以改变对象的值
智能指针和异常
发生异常时的内存释放
1、先前介绍过异常处理程序能在异常发生后令程序流程继续,我们注意到这种程序需要确保异常发生后资源能够被正确释放
2、一个简单的确保资源被释放的方式是使用智能指针,这样即使程序块过早结束,智能指针类也能确保内存不再需要时被释放。如下函数,无论函数正常处理退出,还是发生异常退出,局部对象都会被销毁
3、与之相对,发生异常时我们直接管理的内存是不会自动释放的。如果在new
和delete
之间发生异常,且异常未被捕获,则内存就永远不会被释放了。在函数f2
之外没有指针指向这块内存,因此就无法释放了void f1() { shared_ptr<int> sp(new int(42)); // 分配一个新对象 // 这段代码抛出异常,且在 f1 中未被捕获 } // 函数结束后,shared_ptr自动释放内存 void f2() { int *ip = new int(42); // 动态分配一个新对象 // 这段代码抛出异常,且在 f2 中未被捕获 delete ip; // 在退出之前释放内存 }
智能指针和哑类
1、包括所有标准库类在内的很多C++类都定义了析构函数,负责清理对象使用的资源,但并不是所有类都有这样的良好定义。特别是那些为C和C++两种语言设计的类,通常都要求用户显式地释放所使用的任何资源
2、那些分配了资源,却没有定义析构函数释放资源的类,可能遇到与使用动态内存相同的错误——忘记释放资源。类似的,如果资源分配和释放之间发生异常,也会发生资源泄露
3、与管理动态内存类似,我们可以使用类似的技术来管理这些类。例如下面一段使用网络库的代码,如果connection
有析构函数,就可以在f
结束时自动关闭链接,但它没有
4、这个问题与上一个程序中使用shared_ptr
避免内存泄漏等价,使用shared_ptr
来保证connection
被正确关闭,已被证明是一种有效的方法struct destination; // 表示正在连接什么 struct connection; // 使用连接所需的信息 connection connect(destination*); // 打开链接 void disconnect(connection); // 关闭给定链接 void f(destination &d /* 其他参数 */) { connection c = connect(&d); // 获得链接,但要使用后关闭 // ...使用连接 // 如果退出 f 前忘记调用 disconnect,就无法关闭 c 了 }
使用自定义的释放操作
1、默认情况下,
shared_ptr
假定它们指向的是动态内存,因此当其被销毁时默认对它管理的内存执行delete
操作
2、为了用shared_ptr
管理一个connection
,必须自定义一个函数来代替delete
。这个删除器函数必须能完成对shared_ptr
中保存的指针进行释放的操作
3、在本例中,我们的删除器必须接受单个类型为connection
的参数。改写f
函数,当创建一个shared_ptr
时,可以传递一个可选的指向删除器函数的参数void end_connection(connection *p) { disconnect(*p); // 调用上面写好的关闭函数 } void f(destination &d /* 其他参数 */) { connection c = connect(&d); shared_ptr<connection> p(&c, end_connection); // 传递第二个指向删除器函数的参数 // ...使用连接 // 当 f 退出时(即使因为异常),connection 会被正确关闭 }
其他智能指针
unique_ptr 类
unique_ptr
操作1、一个
unique_ptr
拥有它所指向的对象,与shared_ptr
不同,某个时刻只能有一个unique_ptr
指向一个给定对象。当unique_ptr
被销毁时,其指向的对象也被销毁。下表列出了unique_ptr
特有的操作(通用操作同前表)
2、与shared_ptr
不同,没有类似make_shared
的标准库函数,因此当我们定义unique_ptr
时,需要将其绑定到new
返回的指针上
3、初始化unique_ptr
必须采用直接初始化,此外其不支持普通拷贝或赋值。虽然我们不能拷贝或赋值unique_ptr
,但可以通过release
或reset
将指针所有权转移给另一个unique_ptr
。但注意如果我们不用另一个智能指针来保存release
返回的指针,程序就要负责释放资源unique_ptr 独有的操作 描述 unique_ptr<T> u1,unique_ptr<T, D> u2 空 unique_ptr,可以指向类型为 T 的对象。u1 会使用 delete 释放它的指针;u2 会使用类型为 D 的可调用对象释放指针 unique_ptr<T, D> u(d) 空 unique_ptr,指向类型为 T 的对象,用类型为 D 的对象 d 代替 delete u = nullptr 释放 u 指向的对象,将 u 置空 u.release() u 放弃对指针的控制权,返回指针,并将 u 置空 u.reset(),u.reset(q),u.reset(nullptr) 释放 u 指向的对象。如果提供了内置指针 q,则令 u 指向这个对象,否则将 u 置空 unique_ptr<double> p1; // 定义可以指向 double 的空 unique_ptr unique_ptr<int> p2(new int(42)); // p2 指向一个值为 42 的 int unique_ptr<string> p3(new string("hello world")); unique_ptr<string> p4(p3); // 错误:不支持拷贝 unique_ptr<string> p5; p5 = p3; // 错误:不支持赋值 // 将所有权从 ps1 转移给 ps2 unique_ptr<string> ps1(new string("hello")); unique_ptr<string> ps2(ps1.release()); // release 将 ps1 置空,并返回指针 // 将所有权从 ps3 转移给 ps2 unique_ptr<string> ps3(new string("world")); ps2.reset(ps3.release()); // reset 释放了 ps2 原来指向的内存 // 易错:不使用智能指针接收 release 返回的指针 ps2.release(); // 错误:ps2 不会释放内存,而且我们丢失了指针 auto ps = ps2.release(); // 正确:但也要记得 delete(ps)
局部变量和返回值可以拷贝
1、不能拷贝
unique_ptr
的规则有一个例外:我们可以拷贝或赋值一个将要被销毁的unique_ptr
2、最常见的例子是从函数返回一个unique_ptr
,此外还可以返回一个局部变量的拷贝
3、对于这两种情况,编译器知道要返回的对象即将被销毁,这种情况下,编译器会执行一种特殊的拷贝,后续介绍unique_ptr<int> clone(int p) { // 正确:从 int* 创建一个 unique_ptr<int>,并作为返回值拷贝 return unique_ptr<int>(new int(p)); // 正确:也可以将局部变量作为返回值拷贝 unique_ptr<int> ret(new int(p)); return ret; }
向
unique_ptr
传递删除器1、类似
shared_ptr
,unique_ptr
默认情况下用delete
释放它指向的对象。与shared_ptr
相同,我们可以重载一个unique_ptr
默认的删除器,但其管理删除器的方式与shared_ptr
不同,其原因后续介绍
2、重载一个unique_ptr
删除器会影响到其类型以及如何构造(或reset
)该类型对象。与重载关联容器的比较操作类似,必须在尖括号中提供删除器类型。在创建和reset
一个对象时,必须提供一个指定类型的可调用对象(删除器)
3、作为更具体的例子,我们将重写先前用shared_ptr
写的连接程序,用unique_ptr
代替shared_ptr
// p 指向一个类型为 objT 的对象,并使用一个类型为 delT 的对象释放 objT 对象 // 它会调用一个名为 fcn 的 delT 类型对象 unique_ptr<objT, delT> p(new objT, fcn); void f(destination &d /* 其他需要的参数 */) { connection c = connect(&d); // 打开链接 // 当 p 被销毁,链接将自动关闭 unique_ptr<connection, decltype(end_connection)*> p(&c, end_connection); // ...使用连接 // 当 f 退出时(即使由于异常),connection 会被正确关闭 }
weak_ptr 类
weak_ptr
操作1、
weak_ptr
是一种不控制所指向对象生存期的智能指针,他指向由shared_ptr
管理的对象。将一个weak_ptr
绑定到shared_ptr
不会增加其引用计数,当shared_ptr
被释放,即使还有weak_ptr
指向对象,对象也会被释放。因此weak_ptr
具有弱共享对象的特点
2、下表列出了weak_ptr
的操作。当我们创建一个weak_ptr
时,要用shared_ptr
初始化它
3、由于对象可能不存在,我们不能使用weak_ptr
直接访问对象,必须调用lock
检查指向的对象是否存在。如果存在,lock
返回一个指向共享对象的shared_ptr
。与其他任何shared_ptr
类似,只要此shared_ptr
存在,其指向的底层对象就一直存在weak_ptr 独有的操作 描述 weak_ptr<T> w 空 weak_ptr 可以指向类型为 T 的对象 weak_ptr<T> w(sp) 与 shared_ptr sp 指向相同对象的 weak_ptr。T 必须能转换为 sp 指向的类型 w = p p 可以是一个 shared_ptr 或一个 weak_ptr。赋值后 w 与 p 共享对象 w.reset() 将 w 置空 w.use_count() 与 w 共享对象的 shared_ptr 的数量 w.expired() 若 w.use_count() 为 0,返回 true,否则返回 false w.lock() 如果 expired 为 true,返回一个空 shared_ptr,否则返回一个指向 w 的对象的 shared_ptr auto p = make_shared<int>(42); weak_ptr<int> wp(p); // wp 弱共享 p,p 的引用计数未改变 if(shared_ptr<int> np = wp.lock()) // 如果 np 不为空则条件成立 { // 在 if 中,np 与 p 共享对象 // ...使用 np }
设计核查指针类
核查指针类
1、作为
weak_ptr
用途的一个展示,我们将为先前定义过的StrBlob
类定义一个伴随指针类。该类命名为StrBlobPtr
,保存一个weak_ptr
,指向StrBlob
的data
成员,且是初始化时提供给它的
2、通过使用weak_ptr
,不会影响一个给定的StrBlob
所指向的vector
的生存期,但可以阻止用户访问一个不再存在的vector
// 对于访问一个不存在元素的尝试,StrBlobPtr 抛出一个异常 class StrBlobPtr { public: // 构造函数 StrBlobPtr() : curr(0) { } StrBlobPtr(StrBlob &a, size_t sz = 0) : wptr(a.data), curr(sz) { } // 成员函数 string &deref() const; // 解引用 StrBlobPtr &incr(); // 前缀递增 private: // 若检查成功,check 返回一个指向 vector 的 shared_ptr shared_ptr<vector<string>> check(size_t i, const string &msg) const; // 保存一个 weak_ptr,意味着底层 vector 可能被销毁 weak_ptr<vector<string>> wptr; // 在数组中的当前位置 size_t curr; }; shared_ptr<vector<string>> StrBlobPtr::check(size_t i, const string &msg) const { auto ret = wptr.lock(); // 判断 vector 是否存在 // 抛出错误 if (!ret) throw std::runtime_error("unbound StrBlobPtr"); if (i >= ret->size()) throw std::out_of_range(msg); return ret; // 返回指向 vector 的 shared_ptr }
指针操作
1、我们将在 14 章学习如何定义自己的运算符。现在我们将定义名为
deref
和incr
的函数,分别用来解引用和递增StrBlobPtr
,且这两个函数都将调用check
函数
2、当然,为了访问StrBlob
的data
成员,我们的StrBlobStr
类必须声明为StrBlob
的友元。我们还要为StrBlob
定义begin
和end
操作,返回指向它自身的StrBlobPtr
string &StrBlobPtr::deref() const { auto p = check(curr, "dereference past end"); return (*p)[curr]; // (*p) 是对象所指向的 vector } // 前缀递增:返回递增后对象的引用 StrBlobPtr &StrBlobPtr::incr() { // 如果 curr 已经指向容器尾后位置,就不能递增它 check(curr, "increment past end of StrBlobPtr"); ++curr; // 推进当前位置 return *this; } class StrBlobPtr; class StrBlob { public: // 友元 friend class StrBlobPtr; // 返回指向首元素和尾后元素的 StrBlobPtr StrBlobPtr begin() { return StrBlobPtr(*this); } StrBlobPtr end() { auto ret = StrBlobPtr(*this, data->size()); return ret; } // ...相同部分省略 }
动态数组
动态数组
1、
new
和delete
运算符一次分配/释放一个对象,但有些应用需要一次为很多对象分配内存的功能(例如vector
和string
需要在连续内存中保存它们的元素)。因此,当容器需要重新分配内存时,必须一次性为很多元素分配内存
2、对此,C++语言和标准库提供了两种方式:C++语言定义了另一种new
表达式语法,可以分配并初始化一个对象数组;而标准库中提供了allocator
类,允许我们将分配和初始化分离,并且使用allocator
通常会提供更好的性能和更灵活的内存管理能力
3、事实上,大多数应用都没有直接访问动态数组的需求。当一个应用需要可变数量的对象时,我们先前在StrBlob
中使用标准库vector
的方法会更安全快速可靠。大多数应用都应当首先使用标准库容器而不是动态分配的数组new 和动态数组
用
new
分配动态数组1、为了让
new
分配一个对象数组,我们要在类型名后跟上一对方括号指明分配对象的数目。new
将返回指向第一个对象的指针。方括号中的大小必须是整型,但不必是常量
2、也可以用一个表示数组类型的类型别名来分配一个数组,这样new
表达式中就不必使用方括号了
3、分配一个数组会得到一个元素类型的指针:虽然我们称new T[]
分配的内存为动态数组,但它不是一个数组类型的对象,而是一个数组元素类型的指针
4、由于分配的内存不是数组类型,因此不能对动态数组调用begin
或end
(它们使用数组维度返回指针)。出于相同原因,也不能用范围for
来处理动态数组的元素int *pia = new int[get_size()]; // pia 指向一个 int,调用 get_size() 确定分配多少个 int using arrT = int[42]; // arrT 表示 42 个 int 的数组类型 int *p = new arrT; // 分配一个 42 个 int 的数组
初始化动态数组
1、默认情况下
new
分配的对象都是默认初始化的,也可以对数组元素进行值初始化,方法是在数组大小后跟一对空括号。在新标准中,我们还可以提供一个元素初始化器的花括号列表
2、如果初始化器数目小于元素数目,剩余元素将进行值初始化;如果大于元素数目,则new
失败,不会分配任何内存,抛出bad_array_new_length
异常
3、虽然我们用空括号对数组中元素进行值初始化,但不能在括号中给出初始化器,这意味着不能用auto
分配数组int *pia = new int[10]; // 10 个未初始化的 int int *pia2 = new int[10](); // 10 个值初始化为 0 的 int string *psa = new string[10]; // 10 个空 string string *psa2 = new string[10](); // 10 个空 string // 10 个 int 分别用列表中对应的初始化器初始化 int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9}; // 10 个 string,前 4 个用给定的初始化器初始化,剩余的进行值初始化 string *psa3 = new string[10]{"a", "an", "the", string(3, 'x')};
可以动态分配空数组
1、如下例,可以用任意表达式来确定要分配的对象的数目。但该例中,如果
get_size
返回 0,代码仍能正常运行。虽然我们不能创建大小为 0的静态数组,但当n = 0
时调用new[n]
是合法的
2、当我们用new
分配一个大小为 0 的数组时,new
返回一个合法的非空指针,此指针保证与new
返回的其他指针都不相同。对于零长度数组来说,此指针就像尾后指针一样
3、我们可以像使用尾后迭代器一样使用这个指针。可以用其进行比较操作(如样例中循环),也可以向其加/减 0,也可以从此指针减去自身得到 0。但此指针不能解引用——它不指向任何元素size_t n = get_size(); // get_size 返回需要的元素数目 int *p = new int[n]; // 分配数组保存元素 for(int *q = p; q != p + n; q++) { // ...处理数值 } char arr[0]; // 错误:不能定义长度为 0 的静态数组 char *cp = new char[0]; // 正确:但 cp 不能解引用
释放动态数组
1、为了释放动态数组,我们是用一种特殊形式的
delete
,其在指针前加上一个空方括号
2、这种delete
销毁参数指向的数组的元素,并释放内存。数组中的元素将按逆序销毁
3、当我们释放一个指向数组的指针时,空方括号是必须的:它指示编译器此指针指向一个对象数组的首元素。如果忽略了方括号,其行为未定义delete p; // p 必须指向一个动态分配的对象或为空 delete [] pa; // pa 必须指向一个动态分配的数组或为空
智能指针管理动态数组
unique_ptr
管理1、标准库提供了一个可以管理
new
动态数组的unique_ptr
版本。为了使用unique_ptr
管理动态数组,必须在对象类型后跟一对空方括号
2、类型说明符中的方括号<T []>
指出其指向一个T 类型数组而不是单个 T 类型
3、下表列出了指向动态数组的unique_ptr
的操作。与先前版本有些不同,我们不能使用.
和->
运算符,但我们可以使用下标运算符来访问数组元素unique_ptr<int []> up(new int[10]); // up 指向一个包含 10 个未初始化 int 的数组 up.release(); // 自动用 delete[] 销毁其指针 for(size_t i = 0; i != 10; i++) { up[i] = i; // 可以使用下标运算符为其赋值 }
指向数组的 unique_ptr 操作 描述 unique_ptr<T []> u u 可以指向一个动态分配的数组吗,数组元素类型为 T unique_ptr<T []> u(p) u 指向内置指针 p 所指向的动态分配的数组。p 必须能转换为类型 T* u[i] 返回 u 拥有的数组中位置 i 处的对象 除去 .
和->
外的unique_ptr
操作仍然支持 shared_ptr
管理1、与
unique_ptr
不同,shared_ptr
不直接支持管理动态数组,如果希望使用,必须提供自定义的删除器
2、如下例,我们传递给shared_ptr
一个lambda
作为删除器,它使用delete[]
释放数组。如果未提供删除器,这段代码是未定义的
3、有关delete[]
:默认情况下,shared_ptr
只将使用delete
释放对象,如果对动态数组使用delete
,其后果与直接释放动态数组指针时忘记[]
一样
4、shared_ptr
不直接支持动态数组的特性使得访问元素比较麻烦,其未定义下标运算符,且智能指针类不支持指针算术运算。因此,为了访问元素,必须用get
获取内置指针,再用它访问数组元素// 为了使用 shared_ptr,必须提供删除器 shared_ptr<int> sp(new int[10], [](int *p) {delete[] p;}); sp.reset(); // 使用提供的 lambda 释放数组,它使用 delete[] // 访问元素 for(size_t i = 0; i != 10; i++) { *(sp.get() + i) = i; // 使用 get 获取内置指针,再进行指针算术运算来选择元素,解引用后赋值 }
allocator 类
分配与构造组合的缺点
1、
new
有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起;类似的,delete
也将对象析构和内存释放组合在了一起
2、当分配一大块内存时,我们通常计划在这块内存上按需构造对象,这种情况下,我们希望内存分配和对象构造能够分离。意味着,我们可以提前分配大块内存,但只在真正需要时才执行创建对象
3、一般情况下,内存分配和对象构造组合可能会导致不必要的浪费(如下例)。我们可能创建了一些永远用不到的对象,而对于能用到的对象,也都被赋值了两次(初始化+赋值)。更重要的是,那些没有默认构造函数的类就不能动态分配数组了string *p = new string[n]; // 构造 n 个空 string string s; string *q = p; // q 指向第一个 string while(cin >> s && q != p + n) *q++ = s; // 赋予 *q 一个新值(可能读取数远小于分配数,造成浪费) size_t size = q - p; // 记住我们读取了多少个 string // ...使用数组 delete[] p; // 释放内存
allocator
操作1、标准库
allocator
定义在头文件memory
中,用于将内存分配和对象构造相分离
2、allocator
提供一种类型感知的内存分配方法,其分配的内存是原始的、未构造的,下表列出了该类的操作allocator 操作 描述 allocator<T> a 定义一个名为 a 的 allocator 对象,可以为类型为 T 的对象分配内存 a.allocate(n) 分配一段原始的、未构造的内存,保存 n 个类型为 T 的对象 a.deallocate(p, n) 释放从 T* 指针 p 中地址开始的内存,这块内存保存了 n 个类型为 T 的对象。p 必须是先前由 allocate 返回的指针,且 n 必须是 p 创建时所要求的大小。在调用 deallocate 之前,用户必须对每个在这块内存中创建的对象调用 destroy a.construct(p, args) p 必须是一个类型为 T* 的指针,指向一块原始内存。args 被传递给类型为 T 的构造函数,用来在 p 指向的内存中构造一个对象 a.destroy(p) p 为 T* 类型的指针,此算法对 p 指向的对象执行析构函数 分配未构造的内存
1、
allocator
是一个模板,要定义allocator
对象,必须指明其可以分配的对象类型。当它分配内存时,会根据给定对象类型确定恰当的内存大小和对其位置
2、创建allocator
对象后,要先使用allocate
分配一段原始内存,其分配的内存是未构造的,我们在这些内存中按需构造对象。新标准库中,construct
成员函数接受一个指针,用于在给定位置构造一个元素,还接受可选的额外参数,用于初始化构造的对象
3、为了使用allocate
返回的内存,必须先用construct
构造对象,使用未构造的内存的行为是未定义的。当用完对象后,必须对每个元素调用destroy
销毁,且只能对真正构造了的元素进行destroy
4、一旦元素被destroy
销毁后,就可以重新使用这部分内存来保存其他string
,也可以将其归还系统。释放内存通过调用deallocate
完成// 分配一段原始内存 allocator<string> alloc; // 可以分配 string 的 allocator 对象 auto p = alloc.allocate(n); // 分配 n 个未初始化的 string // 在未分配的内存中构造对象 auto q = p; // q 指向最后构造的元素之后的位置 alloc.construct(q++); // *q 为空字符串 alloc.construct(q++, 10, 'c'); // *q 为 cccccccccc alloc.construct(q++, "hi"); // *q 为 hi // 使用构造的对象 cout << *p << endl; // 正确:使用 string 的输出运算符 cout << *q << endl; // 灾难:当前 q 还未构造对象,其指向未构造的内存 // 销毁元素 while(q != p) alloc.destroy(--q); // 销毁元素,释放真正构造的 string // 归还内存给系统 alloc.deallocate(p, n); // 归还内存
拷贝和填充未初始化内存的算法
1、标准库还为
allocator
定义了两个伴随算法,可以在未初始化内存中创建对象,它们都定义在头文件memory
中。这些函数在给定目的位置创建元素,而不是由系统分配内存给它们。其返回值是目标范围的尾后迭代器
2、作为一个例子,假定有一个int的vector
,希望将其内容拷贝到动态内存中。我们将分配一块两倍于vector
空间的动态内存,将原vector
拷贝到前一半空间,再用一个给定值填充后一半空间allocator 算法 描述 uninitialized_copy(b, e, b2) 从迭代器 b 和 e 指出的输入范围中拷贝元素到迭代器 b2 指定的未构造的原始内存中。b2 指向的内存必须足够大,能容纳输入序列中元素的拷贝 uninitialized_copy_n(b, n, b2) 从迭代器 b 指向的元素开始,拷贝 n 个元素到 b2 开始的内存中 uninitialized_fill(b, e, t) 在迭代器 b 和 e 指定的原始内存范围中创建对象,对象的值均为 t 的拷贝 uninitialized_fill_n(b, n, t) 从迭代器 b 指向的内存地址开始创建 n 个对象。b 必须指向足够大的未构造的原始内存,能够容纳给定数量的对象 // 分配两倍于 vector 空间的动态内存 auto p = alloc.allocate(vi.size() * 2); // 通过拷贝 vi 中的元素构造从 p 开始的前一半元素 auto q = uninitialized_copy(vi.begin(), vi.end(), p); // 将剩余一半元素初始化为 42 uninitialized_fill_n(q, vi.size(), 42);
使用标准库:文本查询程序
描述
1、我们将实现一个简单的文本查询程序,作为标准库相关内容学习的总结
2、程序允许用户在一个给定文件中查询单词,查询结果是当前单词在文件中出现的次数,以及其所在行的列表。如果一个单词在一行中出现多次,此行只列出一次。行会按照升序输出
3、例如,我们可能读入一个文件,在其中寻找单词 element,该程序的前几行输出是这样的:element occurs 112 times (line 36) A set element contians only a key; (line 158) operator creates a new element (line 160) Regardless of whether the element (line 168) When we fetch an element from a map, we (line 214) If the element is not found, find returns ...
文本查询程序设计
从需求入手,我们的程序需要完成以下任务:
1、当程序需要读取输入文件时,它必须记住单词出现的每一行。因此程序需要逐行读取文件,并将每一行分解为独立的单词
2、当程序生成输出时:
—-它必须能提取每个单词所关联的行号
—-行号必须按升序出现且无重复
—-它必须能打印给定行号中的文本利用标准库,我们可以实现这些要求:
1、我们将使用一个
vector<string>
保存整个输入文件的一份拷贝。输入文件中的每行保存为vector
一个元素。当需要打印一行时,可以用行号作为下标来提取文本行
2、我们使用一个istringstream
来将每行分解为单词
3、我们使用一个set
保存每个单词在输入文本中出现的行号。这保证了每行只出现一次且行号升序排列
4、我们使用一个map
将单词与它出现的行号set
关联起来。这样就可以方便地提取任意单词的set
5、我们的解决方案还使用shared_ptr
,原因稍后解释数据结构
1、虽然我们可以用
vector
、set
和map
直接编写程序,但如果定义一个更抽象的解决方案,会更为有效
2、我们将从定义一个保存输入文件的类——TextQuery
开始,这会令文件查询更为容易。它包含一个vector
和一个map
,vector
保存输入文件的文本,map
关联每个单词和它出现的行号set
。这个类将有一个用来读取给定输入文件的构造函数和一个执行查询的操作
3、返回所有内容的最简单的方式是再定义另一个类——QueryResult
,保存查询结果。这个类会有一个print
函数,完成结果打印工作类之间共享数据
1、我们的
QueryResult
要表达查询的结果,其中包括行号的set
和行对应的文本,而这些数据都保存在TextQuery
类型对象中,因此我们必须确定如何访问它们
2、可能我们可以拷贝set
,但这样非常耗时,而且我们不希望拷贝vector
,这可能引起整个文件的拷贝。而通过返回指向TextQuery
对象内部的迭代器(或指针),虽然可以避免拷贝,但会开启新的陷阱:如果TextQuery
对象在对应的QueryResult
对象之前被销毁,QueryResult
将引用一个不存在的对象中的数据
3、因此,对于这两个类生存期应该同步,考虑到两个类概念上共享了数据,就可以使用shared_ptr
反映数据结构中的这种共享关系使用
TextQuery
类// 使用文本查询程序 void runQueries(ifstream &infile) { // infile 是一个 ifstream,指向我们要处理的文件 TextQuery tq(infile); // 保存文件并建立查询 map // 与用户交互:提示用户输入要查询的单词,完成查询并打印结果 while (true) { cout << "enter word to look for, or q to quit: "; string s; // 若遇到文件末尾或用户输入了 q,终止循环 if (!(cin >> s) || s == "q") break; // 指向查询并打印结果 print(cout, tq.query(s)) << endl; } }
程序详细的类定义
TextQuery
类定义1、我们先从
TextQuery
类定义开始。创建类对象时构造函数接受一个istream
,用来读取输入文件。该类还提供query
操作,接受一个string
,返回QueryResult
表示string
出现的那些行
2、设计类的数据成员时,要考虑QueryResult
共享数据的需求,其共享保存输入文件的vector
和保存单词关联行号的set
using line_no = vector<string>::size_type; // 行号 class QueryResult; // 为了定义 TextQuery::query() 的返回类型,这个定义是必须的 // TextQuery 类定义 class TextQuery { public: TextQuery(ifstream &); // 构造函数 QueryResult query(const string &) const; // 查询操作 private: shared_ptr<vector<string>> file; // 输入文件 map<string, shared_ptr<set<line_no>>> wm; // 每个单词到它所在行号的集合的映射 };
TextQuery
构造函数// TextQuery 类构造函数:读取输入文件并建立单词到行号的映射 TextQuery::TextQuery(ifstream &is) : file(new vector<string>) { string text; while (std::getline(is, text)) // 对文件中的每一行 { file->push_back(text); // 保存此行文本 int n = file->size() - 1; // 当前行号 istringstream line(text); // 用于将行文本分解为单词 string word; // 用于接收并处理行中每个单词 while (line >> word) // 对行中每个单词 { // 如果单词不在 wm 中,以之为下标在 wm 中添加一项 auto &lines = wm[word]; // lines 是一个 shared_ptr if (!lines) // 第一次遇到这个单词时,此指针为空 lines.reset(new set<line_no>); // 分配一个新的 set lines->insert(n); // 将此行号插入 set 中 } } }
QueryResult
类1、
QueryResult
类有三个数据成员:一个string
,保存查询单词;一个shared_ptr
,指向保存输入文件的vector
;一个shared_ptr
,指向保存单词出现行号的set
2、它唯一的一个成员函数是一个构造函数,其工作是将参数保存在对应的数据成员中// QueryResult 类 class QueryResult { public: // 打印函数的友元 friend ostream &print(ostream &os, const QueryResult &qr); // 构造函数 QueryResult(string s, shared_ptr<set<line_no>> p, shared_ptr<vector<string>> f) : sought(s), lines(p), file(f) { } private: string sought; // 查询单词 shared_ptr<set<line_no>> lines; // 出现的行号 shared_ptr<vector<string>> file; // 输入文件 };
TextQuery::query
函数1、
query
函数接受一个string
参数,即要查询的单词,query
用它在map
中定位对应行号的set
。如果找到了这个string
,该函数构造并返回QueryResult
2、唯一的问题是:如果给定string
未找到,应该返回什么?此时没有可返回的set
,为此我们定义了一个局部static
对象,它是一个指向空行号set
的shared_ptr
。当未找到给定单词时,返回此对象的一个拷贝// TextQuery::query() 函数 QueryResult TextQuery::query(const string &sought) const { // 如果找到 sought,我们将返回一个指向此 set 的指针 static shared_ptr<set<line_no>> nodata(new set<line_no>); // 使用 find 而不是下标运算符来查找单词,避免将单词添加到 wm 中 auto loc = wm.find(sought); if (loc == wm.end()) return QueryResult(sought, nodata, file); // 未找到 else return QueryResult(sought, loc->second, file); }
打印结果
// 打印结果:print() 函数 ostream &print(ostream &os, const QueryResult &qr) { // 如果找到了单词,打印出现次数和所有出现的位置 os << qr.sought << " occurs " << qr.lines->size() << " time" << (qr.lines->size() > 1 ? "s" : "") << endl; // 打印单词出现的每一行 for (auto num : *qr.lines) // 对 set 中每个单词 // 避免行号从 0 开始给用户带来困惑 os << "\t(line " << num + 1 << ") " << *(qr.file->begin() + num) << endl; return os; }
完整的类
#include <fstream> #include <iostream> #include <map> #include <memory> #include <set> #include <sstream> #include <string> #include <vector> using std::cin; using std::cout; using std::endl; using std::ifstream; using std::istringstream; using std::map; using std::ostream; using std::set; using std::shared_ptr; using std::string; using std::vector; using line_no = vector<string>::size_type; // 行号 class QueryResult; // 为了定义 TextQuery::query() 的返回类型,这个定义是必须的 // TextQuery 类定义 class TextQuery { public: TextQuery(ifstream &); // 构造函数 QueryResult query(const string &) const; // 查询操作 private: shared_ptr<vector<string>> file; // 输入文件 map<string, shared_ptr<set<line_no>>> wm; // 每个单词到它所在行号的集合的映射 }; // TextQuery 类构造函数:读取输入文件并建立单词到行号的映射 TextQuery::TextQuery(ifstream &is) : file(new vector<string>) { string text; while (std::getline(is, text)) // 对文件中的每一行 { file->push_back(text); // 保存此行文本 int n = file->size() - 1; // 当前行号 istringstream line(text); // 用于将行文本分解为单词 string word; // 用于接收并处理行中每个单词 while (line >> word) // 对行中每个单词 { // 如果单词不在 wm 中,以之为下标在 wm 中添加一项 auto &lines = wm[word]; // lines 是一个 shared_ptr if (!lines) // 第一次遇到这个单词时,此指针为空 lines.reset(new set<line_no>); // 分配一个新的 set lines->insert(n); // 将此行号插入 set 中 } } } // QueryResult 类 class QueryResult { public: // 打印函数的友元 friend ostream &print(ostream &os, const QueryResult &qr); // 构造函数 QueryResult(string s, shared_ptr<set<line_no>> p, shared_ptr<vector<string>> f) : sought(s), lines(p), file(f) { } private: string sought; // 查询单词 shared_ptr<set<line_no>> lines; // 出现的行号 shared_ptr<vector<string>> file; // 输入文件 }; // TextQuery::query() 函数 QueryResult TextQuery::query(const string &sought) const { // 如果找到 sought,我们将返回一个指向此 set 的指针 static shared_ptr<set<line_no>> nodata(new set<line_no>); // 使用 find 而不是下标运算符来查找单词,避免将单词添加到 wm 中 auto loc = wm.find(sought); if (loc == wm.end()) return QueryResult(sought, nodata, file); // 未找到 else return QueryResult(sought, loc->second, file); } // 打印结果:print() 函数 ostream &print(ostream &os, const QueryResult &qr) { // 如果找到了单词,打印出现次数和所有出现的位置 os << qr.sought << " occurs " << qr.lines->size() << " time" << (qr.lines->size() > 1 ? "s" : "") << endl; // 打印单词出现的每一行 for (auto num : *qr.lines) // 对 set 中每个单词 // 避免行号从 0 开始给用户带来困惑 os << "\t(line " << num + 1 << ") " << *(qr.file->begin() + num) << endl; return os; } // 使用文本查询程序 void runQueries(ifstream &infile) { // infile 是一个 ifstream,指向我们要处理的文件 TextQuery tq(infile); // 保存文件并建立查询 map // 与用户交互:提示用户输入要查询的单词,完成查询并打印结果 while (true) { cout << "enter word to look for, or q to quit: "; string s; // 若遇到文件末尾或用户输入了 q,终止循环 if (!(cin >> s) || s == "q") break; // 指向查询并打印结果 print(cout, tq.query(s)) << endl; } } int main() { string filename = "passage.txt"; ifstream infile(filename); runQueries(infile); return 0; }
拷贝控制
章节概要:拷贝、赋值与销毁;拷贝控制;拷贝构造函数;拷贝赋值运算符;重载赋值运算符;析构函数;三/五法则;阻止拷贝;定义删除的函数;拷贝控制和资源管理;行为像值的类;行为像指针的类;交换操作;类的
swap
;拷贝控制示例;动态内存管理类;对象移动;右值引用;std::move
;移动构造函数和移动赋值运算符;移动迭代器;右值引用和成员函数;引用限定符
拷贝、赋值与销毁
拷贝控制
1、当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值、销毁时要做什么
2、一个类通过定义五种特殊的成员函数来控制这些操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数
3、拷贝/移动构造函数定义当用同类型对象初始化本对象时的操作;拷贝/移动赋值运算符定义将一个对象赋予同类型另一个对象时的操作;析构函数定义当此类型对象销毁时的操作。我们称这些操作称为拷贝控制操作拷贝构造函数
定义拷贝构造函数
1、如果一个构造函数的第一个参数是自身类类型的引用,且额外参数都有默认值,则此构造函数是拷贝构造函数
2、虽然我们可以定义一个接受非const
引用的拷贝构造函数,但此参数几乎总是一个const
的引用
3、拷贝构造函数在几种情况下都会被隐式使用。因此,拷贝构造函数通常不应该是explicit
的class Foo { public: Foo(); // 默认构造函数 Foo(const Foo&); // 拷贝构造函数 };
合成拷贝构造函数
1、如果我们没有定义一个类的拷贝构造函数,编译器会为我们定义一个合成的拷贝构造函数
2、对于某些类来说,合成拷贝构造函数用于阻止拷贝该类对象;而一般情况下,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中依次将每个非static
成员拷贝到当前创建的对象中
3、每个成员的类类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;对于内置类型的成员,则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐个拷贝数组元素
4、作为例子,下面的代码等价于先前定义的Sales_data
的合成拷贝构造函数Sales_data::Sales_data(const Sales_data &orig) : bookNo(orig.bookNo), units_sold(orig.units_sold), revenue(orig.revenue) { }
拷贝初始化
1、现在,我们可以完全理解直接初始化和拷贝初始化之间的差异了,如下例
2、当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与提供参数最匹配的构造函数;而使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换
3、拷贝初始化在以下情况时会发生:使用=
定义变量;将对象作为实参传递给一个非引用类型的形参;从返回类型为非引用类型的函数返回对象;用花括号列表初始化数组元素或聚合类成员
4、此外,某些类类型会对它们分配的对象使用拷贝初始化:当我们初始化标准库容器,或调用其insert
或push
成员,容器会对其元素进行拷贝初始化。与之相对,用emplace
创建的元素都进行直接初始化string dots(10, '.') // 直接初始化 string s(dots); // 直接初始化 string s2 = dots; // 拷贝初始化 string null_book = "9-999-99999-9"; // 拷贝初始化 string nines = string(100, '9'); // 拷贝初始化
参数、返回值、引用与限制
1、在参数调用过程中,具有非引用类型的参数要进行拷贝初始化;类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用者的结果
2、拷贝构造函数被用来初始化非引用类类型参数,这也是拷贝构造函数的参数必须是引用的原因:为了调用拷贝构造函数,我们必须拷贝它的实参,而为了拷贝它的实参,就必须调用拷贝构造函数
3、因此,如果我们使用的初始化值要求通过一个explicit
的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了,如下vector<int> v1(10); // 正确:直接初始化 vector<int> v2 = 10; // 错误:(已知前提)接受大小参数的构造函数是 explicit 的 void f(vector<int>); // 函数 f 的参数进行拷贝初始化 f(vector<int>(10)); // 正确:从一个 int 直接构造一个临时 vector 作为参数 f(10); // 错误:不能用一个 explicit 的构造函数拷贝一个实参
拷贝赋值运算符
描述
1、与类控制其对象如何初始化一样,类也可以控制其对象如何赋值
2、与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个Sales_data trans, accum; trans = accum; // 使用 Sales_data 的拷贝赋值运算符
重载赋值运算符
1、介绍合成赋值运算符之前,我们需要了解一些有关重载运算符的知识,详细内容将在 14 章介绍
2、重载运算符本质上是函数,其名字由operator
后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=
的函数。类似于其他函数,运算符函数也有一个返回类型和一个参数列表
3、重载运算符的参数表示运算符的运算对象。某些运算符(包括赋值运算符)必须定义为成员函数。如果是一个运算符是一个成员函数,其左侧运算对象就会绑定到隐式的this
参数。对于一个二元运算符(例如赋值运算符),其右侧运算对象作为显式参数传递
4、为了与内置类型赋值保持一致,赋值运算符通常返回一个其左侧运算对象的引用;此外标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用class Foo { public: Foo& operator=(const Foo&); // 赋值运算符 };
合成拷贝赋值运算符
1、与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符
2、类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止类型对象赋值。如果并非出于此目的,它会将右侧运算对象的每个非static
成员赋予左侧运算对象的对应成员,这一工作通过拷贝赋值运算符来完成
3、对于数组类型的成员,逐个赋值数组元素,合成拷贝赋值运算符返回一个左侧运算对象的引用
4、作为例子,下面的代码等价于先前定义的Sales_data
的合成拷贝赋值运算符Sales_data& Sales_data::operator=(const Sales_data &rhs) { bookNo = rhs.bookNo; // 调用 string::operator= units_sold = rhs.units_sold; // 使用内置的 int 赋值 revenue = rhs.revenue; // 使用内置的 double 赋值 return *this; // 返回一个此对象的引用 }
析构函数
定义析构函数
1、析构函数执行与构造函数相反的操作,其释放对象使用的资源,并销毁对象的非
static
数据成员
2、析构函数是类的一个成员函数,名字由波浪号~
后接类名构成。它没有返回值,也不接受参数
3、由于析构函数不接收参数,因此它不能被重载。对一个给定类,只有唯一一个析构函数class Foo { public: ~Foo(); // 析构函数 };
析构函数的工作
1、如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分
2、在一个构造函数中,成员初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序初始化。而在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序逆序销毁
3、在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁。析构部分是隐式的,成员销毁时发生什么完全依赖于成员的类型:销毁类类型成员需要执行成员自己的析构函数;而内置类型没有析构函数,因此销毁内置类型成员什么也不需要做
4、隐式销毁内置指针不会delete
其指向的对象。而与普通指针不同,智能指针是类类型,所以有析构函数,因此其在析构阶段会被自动销毁什么时候会调用析构函数
1、无论何时一个对象被销毁,就会自动调用其析构函数
2、例如:变量离开作用域;对象被销毁时,其成员被销毁;容器被销毁时,其元素被销毁;对指向动态分配对象的指针应用delete
时;创建临时对象的完整表达式结束时
3、由于析构函数自动运行,我们的程序可以按需分配资源,通常无需担心何时释放这些资源
4、注意:当指向一个对象的引用或指针离开作用域时,析构函数不会执行合成析构函数
1、当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。在析构函数体(无论是否为空)执行完毕后,成员才会被自动销毁
2、要认识到析构函数体自身并不直接销毁成员,成员是在析构函数体之后的析构阶段中被销毁的,析构函数体是作为成员销毁步骤之外的另一部分而进行的
3、下面的代码等价于先前定义的Sales_data
的析构函数class Sales_data { public: // 成员会被自动销毁,除此之外不需要做其他事情 ~Sales_data() { } }
三/五法则
描述
1、如前所述,有三个基本操作可以控制类的拷贝:拷贝构造函数、拷贝赋值运算符、析构函数,称为三法则
2、新标准下,还可以定义两个操作:移动构造函数和一个移动赋值运算符,在三法则基础上加上这两个操作称为五法则
3、C++虽然不要求我们定义所有操作,但三/五法则的操作通常被看作整体。通常,只需要其中一个操作而不需要定义所有操作的情况是很少见的,其需要满足两项基本原则基本原则
1、需要析构函数的类也需要拷贝和赋值操作:通常,对析构函数的需求比对拷贝构造函数或赋值运算符的需求更为明显,如果一个类需要析构函数,几乎可以肯定它也需要拷贝构造函数和拷贝赋值运算符
2、需要拷贝操作的类也需要赋值操作,反之亦然:如果一个类需要拷贝构造函数,几乎可以肯定它也需要拷贝赋值运算符
阻止拷贝
引入
1、虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但对某些类来说,这些操作没有合理的意义
2、此情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream
阻止了拷贝,以避免多个对象写入或读取相同IO
缓冲
3、但是如何阻止拷贝?即使不定义拷贝控制成员,编译器也会为其生成合成版本定义删除的函数
1、新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝
2、删除的函数是这样一种函数:我们虽然声明了它,但不能以任何方式使用它。在函数的参数列表后加上=delete
来定义删除的函数
3、与=default
不同,=delete
必须出现在函数首次声明时,此外我们可以对任何函数指定=delete
struct NoCopy { NoCopy() = default; // 使用合成的默认构造函数 NoCopy(const NoCopy&) = delete; // 将拷贝构造函数定义为删除,阻止拷贝 NoCopy &operator=(const NoCopy&) = delete; // 将拷贝赋值运算符定义为删除,阻止赋值 ~NoCopy() = default; // 使用合成的析构函数 };
析构函数不能删除
1、注意:我们不能删除析构函数。如果析构函数被删除,就无法销毁此类对象了
2、对于一个删除了析构函数的类,编译器将不允许定义该类变量或创建该类临时对象,而且我们也不能定义该类变量或临时对象
3、虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但注意,不能释放这些对象struct NoDtor { NoDtor() = default; // 使用合成的默认构造函数 ~NoDtor() = delete; // 删除了析构函数,我们将不能销毁 NoDtor 对象 }; NoDtor nd; // 错误:NoDtor 的析构函数被删除,不能定义该类对象 NoDtor *p = new NoDtor(); // 正确:可以动态分配该类对象,但不能销毁 delete p; // 错误:NoDtor 的析构函数是删除的
private
拷贝控制1、在新标准发布前,类通过将其拷贝构造函数和拷贝赋值运算符声明为
private
来阻止拷贝(现在也可以这样做)
2、如下例:由于析构函数是public
的,用户可以定义该类型对象;由于拷贝构造函数和拷贝赋值运算符是private
的,用户代码不能拷贝该类型对象,但注意友元和成员函数仍可以拷贝对象class PrivateCopy { private: PrivateCopy(const PrivateCopy&); PrivateCopy &operator=(const PrivateCopy&); public: PrivateCopy() = default; ~PrivateCopy(); };
拷贝控制和资源管理
引入
1、通常,管理类外资源的类必须定义拷贝控制成员(三/五法则的成员)。为了定义这些成员,我们首先必须定义此类对象的拷贝语义:通过定义拷贝操作,使类的行为看起来像一个值或一个指针
2、类的行为像一个值:类有自己的状态,当拷贝像值的对象时,副本与原对象完全独立,改变副本不会对原对象有影响,反之亦然
3、类的行为像一个指针:类共享状态,当拷贝像指针的对象时,副本和原对象使用相同底层数据,改变副本也会改变原对象,反之亦然
4、为了说明这两种方式,我们将为HasPtr
类定义拷贝控制成员,该类有两个成员:一个int和一个string
指针。这些成员本身就是值,因此通常应该让它们的行为像值。而我们如何拷贝指针成员决定了像HasPtr
这样的类是具有类值行为还是类指针行为行为像值的类
定义行为像值的类
1、为了提供类值的行为,对于类管理的资源,每个对象都应该有一份自己的拷贝。如下例
HasPtr
,对于ps指向的string
,每个HasPtr
对象都要有自己的拷贝
2、为了实现类值类型,HasPtr
需要:定义拷贝构造函数,完成string
拷贝,而不是拷贝指针;定义析构函数来释放string
;定义拷贝赋值运算符来释放对象当前string
,并从右侧运算对象拷贝string
class HasPtr { public: // 默认构造函数 HasPtr(const string &s = string()) : ps(new string(s)), i(0) { } // 拷贝构造函数:对 ps 指向的 string,每个 HasPtr 对象都有自己的拷贝 HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i) { } // 拷贝赋值运算符(还未定义) HasPtr& operator=(const HasPtr &rhs); // 析构函数 ~HasPtr() { delete ps; // 记得释放内置的动态指针,其不会自动释放 } private: string *ps; int i; };
类值拷贝赋值运算符
1、赋值运算符通常组合了析构函数和构造函数的操作。其销毁左侧运算对象并从右侧运算对象拷贝数据,但要特别注意这些操作要按正确顺序执行,有关的大多错误编译器不会提示
2、如下例,定义了HasPtr
的赋值运算符。此外为了说明防范自赋值操作的重要性,思考下面列出的第二个反例HasPtr& HasPtr::operator=(const HasPtr &rhs) { auto newp = new string(*rhs.ps); // 拷贝底层 string delete ps; // 释放旧内存 ps = newp; // 从右侧运算对象拷贝数据到本对象 i = rhs.i; return *this; // 返回本对象 } // 反例,这样编写是错误的! HasPtr& HasPtr::operator=(const HasPtr &rhs) { delete ps; // 释放对象指向的 string // 注意:如果 rhs 和 *this 是同一个对象,我们将从已释放的内存中拷贝数据 ps = new string(*rhs.ps); i = rhs.i; return *this; }
行为像指针的类
定义行为像指针的类
1、对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,用来拷贝指针成员本身而不是它指向的
string
2、我们的类虽然仍然需要析构函数来释放构造函数分配的内存,但不能单方面释放,只有当最后一个指向string
的HasPtr
被销毁才能释放
3、令一个类展现类指针行为的最好办法是用shared_ptr
来管理资源。拷贝shared_ptr
便会拷贝指针,且shared_ptr
自带引用计数,能在恰当时间释放内存
4、但有时我们希望直接管理资源,这时使用引用计数就很有用了。我们将重新定义HasPtr
使其具有类指针行为,并不使用shared_ptr
引用计数的工作方式
1、除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用于记录有多少对象与创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态因此将计数器初始化为 1
2、拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,也包括计数器。其递增共享的计数器,指出给定对象的状态又被一个新用户所共享
3、析构函数将递减计数器,指出共享状态的用户少了一个。如果计数器变为 0,则析构函数就必须销毁状态
4、拷贝赋值运算符将递增右侧对象计数器,递减左侧对象计数器。如果左侧对象计数器为 0,则它没有共享用户,拷贝赋值运算符就必须销毁状态定义使用引用计数的类
1、通过使用引用计数,我们就可以编写类指针行为的
HasPtr
了
2、在此,我们添加了名为use
的数据成员,用于记录有多少对象共享相同string
class HasPtr { public: // 构造函数分配新的 string 和新的计数器,将计数器置为 1 HasPtr(const string &s = string()) : ps(new string(s)), i(0), use(new size_t(1)) { } // 拷贝构造函数拷贝所有三个数据成员,并递增计数器 HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; } HasPtr& operator=(const HasPtr &rhs); ~HasPtr(); private: string *ps; int i; size_t *use; };
类指针析构函数和拷贝赋值运算符
1、析构函数不能无条件地
delete
,因为可能还有其他对象指向这块内存。其应该先递减引用计数,然后判断如果计数器变为 0,再释放动态指针的内存
2、拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作。它递增右侧对象计数,递减左侧对象计数,必要时释放左侧对象内存HasPtr::~HasPtr() { if(--*use == 0) // 如果引用计数变为 0 { delete ps; // 释放 string 内存 delete use; // 释放计数器内存 } } HasPtr& HasPtr::operator=(const HasPtr &rhs) { ++*rhs.use; // 递增右侧对象计数 if(--*use == 0) // 递减本对象计数 { delete ps; // 释放 string 内存 delete use; // 释放计数器内存 } ps = rhs.ps; // 将数据从 rhs 拷贝到本对象 i = rhs.i; use = rhs.use; return *this; // 返回本对象 }
交换操作
交换操作
1、除了定义拷贝控制成员,管理资源的类通常还要定义一个
swap
函数。对于要与重排元素顺序的算法一起使用的类,定义swap
尤为重要,这类算法在交换两个元素时会调用swap
2、如果一个类定义了自己的swap
,那么算法将使用类自定义版本的swap
;否则,算法将使用标准库定义的swap
3、我们虽然不知道swap
是如何实现的,但理论上很容易理解:其进行一次拷贝和两次赋值。例如交换两个HasPtr
的代码可能如下
4、但理论上,这些内存分配都是不必要的,我们更希望swap
交换指针,而不是分配string
的副本。如下// 分配副本的方式 HasPtr temp = v1; // 创建 v1 的值的一个临时副本 v1 = v2; // 将 v2 的值赋给 v1 v2 = temp; // 将保存的 v1 的值赋予 v2 // 交换指针的方式 string *temp = v1.ps; // 为 v1.ps 中的指针创建一个副本 v1.ps = v2.ps; // 将 v2.ps 的指针赋给 v1.ps v2.ps = temp; // 将保存的 v1.ps 的指针赋予 v2.ps
类的 swap
1、可以在我们的类上定义一个自定义的
swap
来重载swap
的默认行为,其类型实现如下
2、我们首先将swap
定义为friend
,以便访问数据成员。由于swap
的存在就是为了优化代码,因此我们将其声明为inline
内联函数。swap
函数体对给定对象的每个数据成员调用了swap
3、swap
函数应调用对应类swap
,而不是std::swap
。具体原因在下一小节介绍class HasPtr { friend void swap(HasPtr &lhs, HasPtr &rhs); // ...省略成员定义 }; inline void swap(HasPtr &lhs, HasPtr &rhs) { using std::swap; swap(lhs.ps, rhs.ps); // 变换指针,而不是 string 数据 swap(lhs.i, rhs.i); // 变换 int 成员 }
应调用对应类 swap,而不是 std::swap
1、上例中,数据成员是内置类型的,而内置类型没有特定版本
swap
。因此,对swap
的调用会调用标准库std::swap
2、但如果一个类有特定的swap
,那么调用std::swap
就是错误的了。如果有一个名为Foo
类,他有一个类型为HasPtr
的成员 h,如果未定义Foo
的swap
,就会使用标准库版本swap
,但其会对HasPtr
管理的string
进行不必要的拷贝
3、我们可以为Foo
编写swap
,来避免这些拷贝。但如下第一段代码,此版本与简单使用默认版本swap
没有任何性能差异,问题在于我们显式调用了标准库swap
。我们不希望使用std::swap
,而是希望使用HasPtr
的swap
,因此正确的定义应如第二段代码
4、特别注意:每个swap
调用都应该是未加限定的,即每个调用都应该是swap
而不是显式的std::swap
。这种情况下,只要存在类型特定的swap
,其匹配程度会优先于std
版本(即使使用using
)。如果没有特定匹配,且作用域内有using std::swap;
,才会使用std
版本// 第一段(缺陷) void swap(Foo &lhs, Foo &rhs) { // 错误:该函数使用了 std::swap,而不是 HasPtr 版本的 swap std::swap(lhs.h, rhs.h); // ...交换类型 Foo 的其他成员 } // 第二段(正确) void swap(Foo &lhs, Foo &rhs) { using std::swap; // 正确:即使使用 using 声明,只要不显式指定使用 std::swap,仍优先使用 HasPtr 版本的 swap swap(lhs.h, rhs.h); // ...交换类型 Foo 的其他成员 }
赋值运算符中使用 swap
1、定义
swap
的类通常用swap
来定义赋值运算符。这些运算符使用了一种名为拷贝并交换的技术,将左侧对象与右侧对象的副本进行交换
2、这个技术的有趣之处是他自动处理了自赋值情况且天然就能保证异常安全。它在改变左侧对象之前拷贝右侧对象,保证了自赋值的正确;能保证异常安全的原因是代码唯一可能抛出异常的是拷贝构造函数中的new
,而它就算发生异常会也在改变左侧对象之前发生// 注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数将右侧对象的 string 拷贝到 rhs 中 HasPtr &HasPtr::operator=(HasPtr rhs) // rhs 是右侧对象的一个副本 { // 交换左侧对象和局部变量 rhs 的内容 swap(*this, rhs); // rhs 现在指向本对象曾经使用的内存 return *this; // rhs 被销毁,从而 delete 了 rhs 中的指针 }
拷贝控制示例
描述
1、虽然通常来说分配资源的类更需要拷贝控制,但资源管理不是一个类需要拷贝控制的唯一原因,一些类也需要拷贝控制来进行簿记工作和其他操作
2、作为类需要拷贝控制来进行簿记工作的例子,我们将定义Message
和Folder
类,分别表示电子邮件消息和消息目录。每个Message
的内容只有一个副本,以确保从任何Folder
查看此Message
都会看到修改后的内容。为了记录Message
位于哪些Folder
中,每个Message
都保存一个它所在Folder
的set
;同样,每个Folder
都保存一个它包含Message
的指针的set
3、篇幅原因,我们在此只设计Message
类,但我们假定Folder
类有addMsg
和remMsg
两个成员函数,分别用于在给定Folder
对象中添加或删除Message
Message 类的设计
1、
Message
将提供save
和remove
操作,向一个给定Folder
添加或删除Message
。创建Message
时只需指明消息内容,不会指出Folder
,而将Message
放到特定Folder
中必须调用save
2、当拷贝Message
时,副本和原对象是不同的Message
对象,但两个Message
出现在相同的Folder
中。因此,拷贝操作包括消息内容和Folder
指针set
的拷贝,而且我们必须在所有其所在的Folder
中添加指向新创建Message
的指针
3、销毁一个Message
,它将不复存在。因此,我们必须从所有其所在的Folder
中删除指向该Message
的指针
4、当将一个Message
赋予另一个Message
对象时,左侧对象内容会被右侧对象内容所替代。我们还必须更新Folder
的set
,从原来左侧对象的Folder
中将它删除,并将它添加到右侧对象的Folder
中定义 Message 类
class Message { public: friend class Folder; // 友元 Folder 类 friend void swap(Message &, Message &); // 友元 swap 函数 // 默认构造函数:数据成员 folders 被隐式初始化为空 set explicit Message(const string &str = "") : contents(str) { } // 拷贝控制成员:管理指向本 Message 的指针 Message(const Message &); // 拷贝构造函数 Message& operator=(const Message &); // 拷贝赋值运算符 ~Message(); // 析构函数 // 从给定 Folder 的 set 中增加/删除本 Message void save(Folder &); void remove(Folder &); private: string contents; // 实际消息文本 set<Folder*> folders; // 包含本 Message 的 Folder // 拷贝控制对象所使用的工具函数 void add_to_Folders(const Message &); // 将 Message 添加到指向参数的 Folder 中 void remove_from_Folders(); // 从 folders 中的每个 Folder 中移除本 Message };
save 和 remove 成员函数
// 将 Message 保存到 Folder void Message::save(Folder &f) { folders.insert(&f); // 将给定 Folder 添加到 folders 中 f.addMsg(this); // 将本 Message 添加到 f 的 set 中 } // 将 Message 从 Folder 移除 void Message::remove(Folder &f) { folders.erase(&f); // 将给定 Folder 从 folders 中移除 f.remMsg(this); // 将本 Message 从 f 的 set 中移除 }
拷贝构造函数
// 公共操作:将 Message 添加到指向参数的 Folder 中 void Message::add_to_Folders(const Message &m) { for (auto f : m.folders) // 对每个包含 m 的 Folder f->addMsg(this); // 向该 Folder 添加指向本 Message 的指针 } // 拷贝构造函数:拷贝 contents 和 folders 数据成员 Message::Message(const Message &m) : contents(m.contents), folders(m.folders) { add_to_Folders(m); // 将本 Message 添加到指向 m 的 Folder 中 }
析构函数
// 公共操作:从 folders 中的每个 Folder 中移除本 Message void Message::remove_from_Folders() { for (auto f : folders) // 对 folders 中每个指针 f->remMsg(this); // 从该 Folder 中移除本 Message } // 析构函数 Message::~Message() { remove_from_Folders(); }
拷贝复制运算符
// 拷贝赋值运算符 Message &Message::operator=(const Message &rhs) { // 通过先删除指针再插入对象,来处理自赋值情况 remove_from_Folders(); // 更新 folders,删除左侧对象所在的 Folder 的记录 contents = rhs.contents; // 从 rhs 拷贝消息内容 folders = rhs.folders; // 从 rhs 拷贝 folders add_to_Folders(rhs); // 将该 Message 添加到指向 rhs 的 Folder 中 return *this; }
swap 函数
1、标准库中定义了
string
和set
的swap
版本。因此如果我们的Message
自定义它的swap
,那么它将从中受益,避免对contents
和folders
进行不必要的拷贝
2、但同时,我们的自定义的swap
还要管理被交换的指针,在Message
被交换后,原本指向m1
的Folder
就要指向m2
,反之亦然。这需要我们在swap
中定义相关操作// swap 函数 void swap(Message &lhs, Message &rhs) { using std::swap; // 本例中严格来说并不需要,但这是一个好习惯 // 将每个 Message 的指针从它原来所在 Folder 中删除 for (auto f : lhs.folders) f->remMsg(&lhs); for (auto f : rhs.folders) f->remMsg(&rhs); // 交换 contents 和 folders swap(lhs.contents, rhs.contents); // 使用 string 类的 swap(string&, string&) swap(lhs.folders, rhs.folders); // 使用 set 类的 swap(set&, set&) // 将每个 Message 的指针添加到新的 Folder 中 for (auto f : lhs.folders) f->addMsg(&lhs); for (auto f : rhs.folders) f->addMsg(&rhs); }
动态内存管理类
描述、
1、某些类需要在运行时分配可变大小的内存。这种类通常可以使用标准库容器来保存它们的数据,例如之前定义的
StrBlob
使用vector
来管理其元素的底层内存
2、但是,这一策略并不适用于全部类,某些类需要自己分配内存,这些类通常必须自定义拷贝控制成员来管理分配的内存
3、我们将实现标准库vector
的简化版本,我们只实现其针对string
的版本,命名为StrVec
(只实现部分功能)StrVec 类的设计
1、回忆
std::vector
的行为:其将元素保存在连续内存中,预先分配足够内存来保存可能需要的更多元素。添加元素的成员函数将检查是否有空间容纳更多元素:如果有,将在下个可用位置构造一个对象;如果没有,则重新分配空间,将已有元素移入新获得的空间,释放旧内存,添加新元素
2、StrVec
类使用类似的策略,我们将使用一个allocator
来获得原始内存。由于allocator
分配的内存是未构造的,我们将在需要添加元素时用其construct
成员创建对象;类似的,我们将使用destroy
成员销毁元素
3、每个StrVec
有三个指针指向其元素所使用的内存:elements
指向已分配的内存的首元素;first_free
指向最后一个实际元素之后的位置(已分配内存的尾后,未分配内存的首);cap
指向分配的整个内存的尾后位置。除了这些指针,StrVec
还有一个类型为allocator<string>
的alloc
静态成员,用于分配StrVec
使用的内存
4、该类还有四个工具函数:alloc_n_copy
会分配内存,并拷贝给定范围的元素;free
会销毁构造的元素并释放内存;chk_n_alloc
保证StrVec
至少有容纳一个新元素的空间,如果不够则添加新元素,调用reallocate
分配内存;reallocate
的内存用完时为StrVec
分配新内存定义 StrVec 类
// 类 vector 的内存分配策略的简化实现 class StrVec { public: // allocator 成员进行默认初始化 StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) { } StrVec(const StrVec &); // 拷贝构造函数 StrVec &operator=(const StrVec &); // 拷贝赋值运算符 ~StrVec(); // 析构函数 void push_back(const string &); // 拷贝元素 size_t size() const { return first_free - elements; } size_t capacity() const { return cap - elements; } string *begin() const { return elements; } string *end() const { return first_free; } // ... private: static allocator<string> alloc; // 分配元素 // 被添加元素的函数所使用 void chk_n_alloc() { if (size() == capacity()) reallocate(); } // 工具函数,被拷贝构造函数、赋值运算符和析构函数所使用 pair<string *, string *> alloc_n_copy(const string *, const string *); void free(); // 销毁元素并释放内存 void reallocate(); // 获得更多内存并拷贝已有元素 string *elements; // 指向数组首元素的指针 string *first_free; // 指向数组第一个空闲元素的指针(未分配内存的首) string *cap; // 指向数组尾后位置的指针 };
使用 construct
1、函数
push_back
调用chk_n_alloc
确保有空间容纳新元素,如果需要,chk_n_alloc
会调用reallocate
。当chk_n_alloc
返回时,push_back
知道必定有空间容纳新元素,便可以要求allocator
类型成员来construct
新的尾元素
2、construct
第一个参数是一个指针,指向调用allocate
所分配的未构造内存空间;剩余参数用于确定用哪个构造函数来构造对象
3、值得注意的是,对construct
的调用也会递增first_free
,表示已经构造了新元素,其使用前置递增// push_back 函数 void StrVec::push_back(const string &s) { chk_n_alloc(); // 确保有空间分配元素 alloc.construct(first_free++, s); // 在 first_free 指向的元素中构造 s 的副本 }
alloc_n_copy 成员
1、我们在拷贝或赋值
StrVec
时,可能会调用alloc_n_copy
成员函数。类似vector
,我们的StrVec
也要有类值的行为。当我们拷贝或赋值StrVec
时必须分配独立的内存,并从原StrVec
中拷贝元素至新对象
2、alloc_n_copy
会分配足够内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。其返回一个指针的pair
,分别指向新空间的开始位置和尾后位置// alloc_n_copy 函数 pair<string *, string *> StrVec::alloc_n_copy(const string *b, const string *e) { // 分配空间保存给定范围中的元素 auto data = alloc.allocate(e - b); // 初始化并返回一个 pair,该 pair 由 data 和 uninitialized_copy 的返回值(都是指针)构成 return {data, uninitialized_copy(b, e, data)}; }
free 成员
1、
free
有两个责任:首先destroy
元素,然后释放StrVec
自己分配的内存空间
2、for
调用allocator
的destroy
成员(其会运行string
的析构函数),从构造的尾元素开始,到首元素为止,逆序销毁所有元素。一旦元素被销毁,我们就调用deallocate
来释放本StrVec
分配的内存// free 函数 void StrVec::free() { // 不能传递给 deallocate 空指针,如果 elements 为 0,函数什么也不做 if (elements) { // 逆序销毁旧元素 for (auto p = first_free; p != elements; /* 空 */) alloc.destroy(--p); alloc.deallocate(elements, cap - elements); } }
拷贝控制成员
1、实现了
alloc_n_copy
和free
成员后,想要实现拷贝控制成员就很简单了
2、拷贝构造函数调用alloc_n_copy
,将返回结果赋予数据成员:alloc_n_copy
的返回值是一个指针的pair
,其first
指向新构造空间的首元素,second
指向尾后位置
3、析构函数调用free
4、拷贝赋值运算符在释放已有内存前调用alloc_n_copy
,以正确处理自赋值问题// 拷贝构造函数 StrVec::StrVec(const StrVec &s) { // 调用 alloc_n_copy 分配空间 auto newdata = alloc_n_copy(s.begin(), s.end()); elements = newdata.first; first_free = cap = newdata.second; } // 析构函数 StrVec::~StrVec() { free(); } // 拷贝赋值运算符 StrVec &StrVec::operator=(const StrVec &rhs) { // 调用 alloc_n_copy 分配空间 auto data = alloc_n_copy(rhs.begin(), rhs.end()); free(); elements = data.first; first_free = cap = data.second; return *this; }
reallocate 成员
在重新分配内存过程中移动而不是拷贝元素
1、在编写用于重新分配内存的
reallocate
之前,我们先思考此函数应该做什么:为一个新的更大的string
分配内存;在内存空间前一部分构造对象,保存现有元素;销毁原内存元素,释放这块内存
2、我们可以看出,为一个StrVec
重新分配内存会引起从旧内存到新内存逐个拷贝string
。虽然我们不知道string
具体实现细节,但知道string
具有类值行为,因此拷贝string
就必须真的拷贝数据
3、但对于reallocate
要拷贝StrVec
的string
时,一旦将旧空间元素拷贝到新空间后,这些旧空间元素就不再需要了,而又被释放。因此拷贝这些string
的数据是多余的,如果我们能避免分配和释放string
的额外开销,StrVec
的性能会好得多移动构造函数和 std::move
1、通过使用新标准库引入的两种机制,我们就可以避免
string
拷贝的花销
2、首先,有一些标准类(包括string
),都定义了移动构造函数。虽然关于其工作的细节和具体实现都未公开,但我们知道移动构造函数用于将资源移动(而不是拷贝)到正在创建的对象,且移后源string
仍保持有效、可析构的状态
3、第二个机制是名为move
的标准库函数,它定义在utility
头文件中。目前关于move
我们需要了解两点:首先,当reallocate
在新内存构造string
时,必须调用move
来表示希望使用移动构造函数,否则将使用拷贝构造函数;其次,我们通常不为move
提供using
声明,原因后续解释,当我们使用move
时一般直接调用std::move
定义 reallocate 成员
// reallocate 成员 void StrVec::reallocate() { // 我们将分配当前两倍大小的内存 auto newcapacity = size() ? 2 * size() : 1; // 分配新内存 auto newdata = alloc.allocate(newcapacity); // 将数据从旧内存移动到新内存 auto dest = newdata; // 指向新数组中下一个空闲位置 auto elem = elements; // 指向旧数组中下一个元素 for (size_t i = 0; i != size(); ++i) // 使用 move 表明使用移动构造函数 alloc.construct(dest++, std::move(*elem++)); free(); // 释放旧内存 // 更新数据结构,执行新元素 elements = newdata; first_free = dest; cap = elements + newcapacity; }
对象移动
引入
1、新标准一个最主要的特性是可以移动(而非拷贝)对象。很多情况下都会发生对象拷贝,但某些情况下对象拷贝后就立刻被销毁,这时移动对象(相比拷贝对象)会大幅提升性能
2、使用移动而不是拷贝的另一个原因是:对于像IO
类和unique_ptr
类这样的类,都包含不能被共享的资源(指针或 IO 缓冲)。因此,这类对象不能拷贝但可以移动右值引用
右值引用
1、为了支持移动操作,新标准引入了一种新的引用类型——右值引用。所谓右值引用就是必须绑定到右值的引用,我们通过
&&
(而不是&
)来获得右值引用
2、右值引用有一个重要性质:只能绑定到一个将要销毁的对象。因此我们可以自由地将一个右值引用的资源移动到另一个对象
3、右值引用也只是某个对象的另一个名字。如我们所知,对于常规引用(为了区分,我们可称其为左值引用),我们不能将其绑定到要求转换的表达式、字面常量或返回右值的表达式;而右值引用与之相反,我们可以将它绑定到这类表达式上,但不能直接将它绑定到一个左值上
4、返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值表达式的例子,我们可以将左值引用绑定到这类表达式;返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值,我们可以将右值引用或const
的左值引用绑定到这类表达式
5、左值持久,右值短暂:左值有持久的状态,而右值要么是字面常量,要么是表达式求值过程中创建的临时变量。由于右值引用只能绑定到临时对象,我们得知所引用的对象将要被销毁且其没有其他用户,这意味着使用右值引用的代码可以自由地接管所引用对象的资源int i = 42; int &r = i; // 正确:r 引用 i int &&rr = i; // 错误:不能把一个右值引用绑定到左值 int &r2 = i * 42; // 错误:i * 42 是一个右值 const int &r3 = i * 42; // 正确:我们可以将一个 const 的引用绑定到右值上
变量是左值
1、变量可以看作只有一个运算对象而没有运算符的表达式,类似其他任何表达式,变量表达式也有左值/右值属性
2、变量表达式都是左值。带来的结果是,我们不能将一个右值引用绑定到右值引用类型变量上
3、在了解到右值表示临时对象这一结果后,变量是左值这一结论就并不令人惊讶了,毕竟变量是持久的,直到离开作用域才被销毁int &&rr1 = 42; // 正确:字面常量是右值 int &&rr2 = rr1; // 错误:表达式 rr1 是左值
std::move
1、虽然不能将右值引用直接绑定到左值,但可以显式地将左值转换为对应右值引用类型。我们还可以通过调用
move
标准库函数来获得绑定到左值上的右值引用。move
定义在头文件utility
中,其使用将在 16 章描述的机制来返回给定对象的右值引用
2、如下例,move
调用告诉编译器,我们有一个左值,但我们希望像右值一样处理它。调用move
就意味着承诺除了对rr1
赋值或销毁外,我们将不再使用它
3、在调用move
后,我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,或赋予它新值,但不能使用一个移后源对象的值
4、如前所述,与大多数标准库名字不同,我们通常不对move
使用using
声明,而直接调用std::move
int &&rr3 = std::move(rr1); // 正确:但只可以对 rr1 进行赋值或销毁,不能使用 rr1 的值
移动构造函数和移动赋值运算符
移动构造函数
1、类似
string
类或其他标准库类,如果我们自定义的类也同时支持移动和拷贝,那么也能从中受益。为此需要定义移动构造函数和移动赋值运算符,这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源
2、移动构造函数的第一个参数也是该类类型的引用,但它是一个右值引用,且函数的任何额外参数都必须有默认实参
3、除了资源移动,移动构造函数还必须确保移后源对象是销毁后无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所属权已经归属了新的对象
4、如下例,我们为StrVec
定义了移动构造函数。其中noexcept
用于通知标准库我们的构造函数不会抛出任何异常,具体将在后续介绍
5、移动构造函数不分配任何内存,它接管给定StrVec
的内存。接管内存后,它将给定对象中的指针置为nullptr
,这样就完成了从给定对象的移动操作,此对象将继续存在。最终,源对象会被销毁并运行其析构函数StrVec::StrVec(StrVec &&s) noexcept // 表示移动操作不会抛出任何异常 : elements(s.elements), first_free(s.first_free), cap(s.cap) // 初始化成员接管 s 中的资源 { // 令 s 进入“对其运行析构函数是安全的”的状态 s.elements = s.first_free = s.cap = nullptr; }
移动操作、标准库容器和异常
1、由于移动操作不分配任何资源,因此移动操作通常不会抛出任何异常,我们应当将此事通知标准库,否则标准库会认为类对象随时可能抛出异常,并为了处理这种可能额外做一些准备
2、一种通知标准库的方法是在构造函数中指明noexcept
。noexcept
是新标准引入的,我们将在 18 章讨论更多细节
3、目前重要的是,知道noexcept
是我们承诺函数不抛出异常的一种方法,且我们在一个函数的参数列表后指定noexcept
。对于构造函数,其出现在参数列表和初始化列表的冒号之间,且如果要分开声明和定义构造函数,必须在类的声明和定义中都指定noexcept
移动赋值运算符
1、移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果其不抛出任何异常,应把它标记为
noexcept
;类似拷贝赋值运算符,移动赋值运算符也必须正确处理自赋值
2、如下例,我们直接检查this
指针与rhs
的地址是否相同。如果相同,则左右两侧的对象指向相同的对象,不需要做任何事情;否则,我们释放左侧对象的内存,并接管右侧对象的内存,再将右侧对象的指针置空StrVec &StrVec::operator=(StrVec &&rhs) noexcept { // 直接检测自赋值 if(this != &rhs) { free(); // 释放已有元素 elements = rhs.elements; // 从 rhs 接管资源 first_free = rhs.first_free; cap = rhs.cap; // 将 rhs 置于可析构状态 rhs.elements = rhs.first_free = rhs.cap = nullptr; } return *this; }
移后源对象必须可析构
1、从一个对象移动数据并不会销毁对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入可析构状态。如上例中,我们将右侧对象的指针全部置空
2、除了将移后源对象置为析构安全的状态外,移动操作还必须保证对象仍然有效。一般来说,对象有效就是指可以安全地为其赋值或可以安全地使用而不依赖其当前值。另一方面,移动操作对移后源对象留下的值没有任何要求,因此程序不应该依赖于移后源对象的数据
3、例如,当我们从string
对象移动数据时,我们知道移后源对象仍然有效,因此我们可以执行empty
或size
这些操作。但是我们不知道会得到什么结果,我们可能期望一个移后源对象是空的,但这并没有保证。因此移后源对象虽然保持有效、可析构的状态,但用户不能对其值进行任何假设合成的移动操作
1、与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。但是合成移动操作的条件与前者大不相同
2、不同于拷贝操作,当我们不声明移动操作时,编译器不会为某些类合成移动操作,特别如果一个类定义了自己的拷贝操作,编译器更不会为其合成移动操作。因此,某些类就没有移动操作,而这些类会用对应的拷贝操作代替移动操作
3、只有当一个类没有定义任何自己的拷贝控制成员,且每个非static
数据成员都可以移动时,编译器才会合成移动构造函数或移动赋值运算符。编译器可以移动内置类型成员,如果一个成员是类类型且有对应的移动操作,那么也能移动这个类成员
4、移动操作永远不会隐式定义为删除的函数。但是如果我们显式要求编译器生成=default
的移动操作,且编译器不能移动所有成员,则会将其定义为删除的函数。定义一个移动操作的类也必须定义了拷贝操作,否则这些成员默认地被定义为删除// 编译器会为 X 和 hasX 合成移动操作 struct X { int i; // 内置类型可以移动 std::string s; // string 定义了自己的移动操作 }; struct hasX { X mem; // X 有合成的移动操作 }; X x1, x2 = std::move(x1); // 使用合成的移动构造函数 hasX hx1, hx2 = std::move(hx1); // 使用合成的移动构造函数
移动与拷贝操作的选择
1、移动右值,拷贝左值:如果一个类既有拷贝操作又有移动操作,编译器将使用普通的函数匹配规则来确定使用哪个函数。赋值操作的情况类似
2、没有移动操作,右值也被拷贝:没有移动操作时,编译器不会合成移动操作,函数匹配规则保证该类型对象会被拷贝,即使试图调用move
也是如此// 移动右值,拷贝左值 StrVec v1, v2; v1 = v2; // v2 是左值,所以匹配拷贝赋值操作 StrVec getVec(istream &); // 假定 getVec 返回一个右值 v2 = getVec(cin); // getVec(cin) 是一个右值,匹配移动赋值操作 // 右值也被拷贝 class Foo { public: Foo() = default; Foo(const Foo&); // 拷贝构造函数 // 其他成员定义,但 Foo 未定义移动构造函数 }; Foo x; Foo y(x); // 拷贝构造函数,x 是一个左值 Foo z(std::move(x)); // 拷贝构造函数,因为未定义移动构造函数
定义
Message
类的移动操作1、回想先前定义的
Message
和Folder
类就应该定义移动操作,这样Message
就可以使用string
和set
的移动操作来避免拷贝contents
和folders
成员的额外开销
2、但是,除了移动folder
成员,我们还必须更新每个指向原Message
的Folder
。我们必须删除旧Message
指针,并添加新Message
指针。由于两个移动操作都需要更新指针,我们先定义这一操作,如下
3、Message
的移动构造函数调用move
来移动contents
,并默认初始化自己的folders
成员;而移动赋值运算符直接检查自赋值情况// 从本 Message 移动 Folder 指针 void Message::move_Folders(Message *m) { folders = std::move(m->folders); // 使用 set 的移动赋值运算符 for (auto f : folders) // 对于每个 Folder { f->remMsg(m); // 从 Folder 中删除旧 Message f->addMsg(this); // 将本 Message 添加到 Folder 中 } m->folders.clear(); // 确保销毁 m 是无害的 } // 移动构造函数 Message::Message(Message &&m) : contents(std::move(m.contents)) { move_Folders(&m); // 移动 folders 并更新 Folder 指针 } // 移动赋值运算符 Message& Message::operator=(Message &&rhs) { if(this != &rhs) // 直接检查自赋值情况 { remove_from_Folders(); contents = std::move(rhs.contents); // 移动赋值运算符 move_Folders(&rhs); // 重置 Folders 指向本 Message } return *this; }
移动迭代器
1、先前
StrVec
的reallocate
成员使用了一个for
来调用construct
从旧内存将元素拷贝到新内存。作为一种替换方法,我们可以调用uninitialized_copy
来构造新分配的内存,且更为简单。但是,uninitialized_copy
对元素进行拷贝操作(而不是移动),标准库中并没有类似的函数将对象移动到未构造内存
2、新标准库定义了一种移动迭代器适配器,其通过改变给定迭代器的解引用运算符的行为来适配此迭代器,移动迭代器的解引用运算符生成一个右值引用
3、我们通过调用标准库make_move_iterator
函数将一个普通迭代器转换为一个移动迭代器。其接受一个迭代器参数,返回一个移动迭代器
4、原迭代器的所有操作在移动迭代器都能正常工作,因此我们可以将其传递给算法,比如uninitialized_copy
void StrVec::reallocate() { // 分配大小两倍于当前规模的内存空间 auto newcapacity = size() ? 2 * size() : 1; auto first = alloc.allocate(newcapacity); // 移动元素 auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first); free(); // 释放旧空间 elements = first; // 更新指针 first_free = last; cap = elements + newcapacity; }
右值引用和成员函数
移动版本的成员函数
1、除了构造函数和赋值运算符,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数需要遵循一种参数模式:一个版本接受一个指向
const
的左值引用,第二个版本接受一个指向非const
的右值引用
2、一般来说,我们不需要定义接受const X&&
或X&
参数的函数版本。当我们希望从实参窃取数据时,通常传递一个右值引用,因此实参不能是const
的;类似的,从一个对象拷贝数据不应该改变对象,因此不需要接受X&
的版本
3、如下例,我们为先前StrVec
的push_back
函数定义拷贝和移动版本。当我们调用push_back
时,实参类型决定新元素是拷贝还是移动到容器中// 简单示例 void push_back(const X&); // 拷贝:绑定到任意类型的 X void push_back(X&&); // 移动:只能绑定到类型 X 的可修改的右值 // StrVec 的 push_back class StrVec { public: void push_back(const string&); // 拷贝元素 void push_back(string&&); // 移动元素 // ...其他成员定义,如前 }; // 拷贝版本的 push_back void StrVec::push_back(const string &s) { chk_n_alloc(); // 确保有足够空间容纳元素 alloc.construct(first_free++, s); // 在 first_free 指向的元素中构造 s 的一个副本 } // 移动版本的 push_back void StrVec::push_back(string &&s) { chk_n_alloc(); // 确保有足够空间容纳元素 alloc.construct(first_free++, std::move(s)); } StrVec vec; // 空 vec string s = "some string or another"; vec.push_back(s); // 调用拷贝版本 vec.push_back("done"); // 调用移动版本
左/右值引用成员函数与函数重载
1、通常,我们在一个对象上调用成员函数,而不管该对象是左值还是右值,如下例。在旧标准中,我们没法阻止这种使用方式,为了维持向后兼容性,新标准库仍然允许向右值赋值。但是,我们可能希望在自定义的类中阻止这种用法,希望强制左侧运算对象(即
this
指向的对象)是左值
2、我们指出this
的左/右值属性的方式与定义const
成员函数类似,即在参数列表后放置一个引用限定符。引用限定符可以是&
或&&
,分别指出this
可以指向一个左值或一个右值
3、引用限定符只能用于非static
成员函数,且必须同时出现在函数声明和定义中。一个函数可以同时用const
和引用限定,但引用限定符必须跟在const
后面
4、就像一个成员函数可以根据是否const
来区分重载版本,引用限定符也可以区分重载版本。而且我们可以综合引用限定符和const
来区分一个成员函数的重载版本// 令人惊讶的调用方式 string s1 = "a value", s2 = "another"; auto n = (s1 + s2).find('a'); // 在一个右值 (s1 + s2),即两个字符串组合的结果上调用了 find 成员 s1 + s2 = "wow!"; // 对一个右值 (s1 + s2),即两个字符串组合的结果上进行了赋值 // 引用限定符 class Foo { public: Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值 Foo func() &&; // 只能操作不可修改的右值 Foo someMem() & const; // 错误:const 限定符必须在前 Foo anotherMem() const &; // 正确:const 限定符在前 };
重载运算与类型转换
章节概要:重载运算符;使用重载运算符;重载运算符的设计;输入输出运算符;算术和关系运算符;赋值运算符;下标运算符;递增递减运算符;成员访问运算符;
lambda
是函数对象;标准库函数对象;可调用对象与function
;标准库function
类型;重载、类型转换与运算符;类型转换运算符;避免有二义性的类型转换;函数匹配与重载运算符
重载运算符
使用重载运算符
重载运算符
1、重载运算符是具有特殊名字的函数:它们的名字由关键字
operator
后接要定义的运算符共同组成。重载运算符也包含返回类型、参数列表和函数体
2、重载运算符的参数数量与其作用的运算对象数量一样,一元运算符有一个参数,二元运算符有两个参数。且对于二元运算符,左侧对象传递给第一个参数,右侧对象传递给第二个参数。除了重载的函数调用运算符operator()
之外,其他重载运算符都不能含有默认参数。如果一个运算符函数是成员函数,那它的左侧对象绑定到隐式的this
指针上
3、对于一个运算符函数,它要么是类的成员,要么至少含有一个类类型参数,这意味着当运算符作用于内置类型运算对象时,我们无法改变其含义。我们只能重载运算符,不能发明运算符。对于一个重载运算符来说,其优先级和结合律与对应的内置运算符一致
4、对于+
、-
、*
、&
这四个既是一元也是二元的运算符来说,也可以被重载,其会从参数数量判断我们定义的是哪种运算符。我们不能重载::
、.*
、.
、?:
运算符,其余可被重载的运算符如下表可重载 可重载 可重载 可重载 可重载 可重载 + - * / % ^ & | ~ ! , = < > <= >= ++ – << >> == != && || += -= /= %= ^= &= |= *= <<= >>= [] () -> ->* new new[] delete delete[] 直接调用重载运算符函数
1、通常情况下,我们将运算符作用于类型正确的实参,用这种间接方式调用重载运算符函数
2、然而,我们也可以像调用普通函数那样直接调用运算符函数,先指定函数名,然后传入数量正确、类型适当的实参
3、我们也可以像调用其他成员函数一样显式调用成员运算符函数data1 + data2; // 普通的表达式 operator+(data1, data2); // 等价的函数调用 data1 += data2; // 基于调用的表达式 data1.operator+=(data2); // 对成员运算符函数的等价调用
某些运算符不应被重载
1、由于某些运算符指定了运算对象求值的顺序,又因为重载运算符本质上是函数调用,所以这些关于求值顺序的规则无法应用到重载运算符上。比如
&&
、||
、,
运算符
2、此外,&&
和||
的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值,因此不建议重载它们
3、还有一个原因使得我们一般不重载,
和&
运算符:C++语言已经定义了这两种运算符用于类类型对象的特殊含义,所以它们不应该被重载,否则它们的异常行为将导致类的用户无法适应
重载运算符的设计
使用与内置类型一致的含义
1、当你开始设计一个类时,首先应该考虑的是这个类将提供哪些操作,然后再思考应该把每个操作定义为普通函数还是重载运算符。如果某些操作在逻辑上与运算符相关,才适合定义成重载运算符
2、具体如下:如果类执行IO
操作,则定义移位运算符使其与内置类型IO
保持一致;如果某个操作检查相等性,则定义operator==
,并且通常它也应该有operator!=
;如果类包含一个内在的单序比较操作,则定义operator<
,并且通常它也应该有其他关系操作
3、除此之外,重载运算符的返回类型通常应该与内置版本返回类型兼容:逻辑运算符和关系运算符应返回bool
;算术运算符应返回类类型的值;赋值运算符和复合赋值运算符应该返回左侧对象引用选择作为成员或非成员
1、当我们定义重载运算符时,首先要决定将其声明为类的成员函数还是一个普通的非成员函数。某些情况下我们别无选择,因为有的运算符必须作为成员;另一些情况下,运算符作为普通函数比作为成员函数更好
2、这些准则有助于我们做出抉择:=
、[]
、()
、->
运算符必须是成员;复合赋值运算符与=
略有不同,其一般是成员(并非必须);改变对象状态的运算符或者与给定类型密切相关的运算符,例如++
、--
、*
运算符,通常是成员;具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系、位运算等,通常是普通非成员
3、程序员希望能在含有混合类型的表达式中使用对称性运算符。例如,我们能求一个int
和一个double
的和,因为它们中任意一个都可以是左侧对象或右侧对象,所以加法是对称的。如果我们想提供含有类对象的混合类型表达式,则运算符必须定义成非成员函数,原因如下
4、如果我们把运算符定义为成员函数,他的左侧对象必须是运算符所属类对象,如下例。现实中,因为string
将operator+
定义成普通的非成员函数,所以"hi" + s
才能等价于operator+("hi", s)
正常执行操作// 假设 operator+ 只是 string 类的成员 string s = "world"; string t = s + "!"; // 正确:左侧对象是 string,可以调用 operator+ 将一个 const char* 添加到 string 中 string u = "hi" + s; // 错误:左侧对象是 const char*,其本身作为内置类型根本没有成员函数,也无法调用到 string 重载的 operator+ 运算符
输入输出运算符
概述
1、如我们所知,
IO
库分别使用>>
和<<
来执行输入输出操作
2、对于这两个操作,IO
库定义了其读写内置类型的版本,而对于类则需要自定义新版本以支持IO
操作输出运算符 <<
1、通常情况下,输出运算符第一个形参是非常量
ostream
的引用(表示左侧对象);第二个形参是一个类类型常量引用(表示右侧对象);为了与内置类型一致,一般返回它的ostream
形参。如下例,是重载的Sales_data
的输出运算符
2、用于内置类型的输出运算符不太考虑格式化操作,尤其不会打印换行符,用户也希望重载的输出运算符也能如此行事。令输出运算符尽量减少格式化操作,可以使用户有权控制输出的细节
3、输入输出运算符必须是非成员函数,否则它们的左侧对象将是我们的类对象(成员重载运算符的左侧对象绑定到this
),即调用会变成data << cout
,违背平常的使用习惯// 必须定义为非成员函数,参数第一个形参是左侧对象,第二个形参是右侧对象 ostream &operator<<(ostream &os, const Sales_data &item) { os << item.isbn() << ' ' << item.units_sold << ' ' << item.revenue << ' ' << item.avg_price(); return os; }
输入运算符 >>
1、通常情况下,输入运算符第一个形参是将读取的流的引用;第二个形参是类类型非常量引用;通常返回给定流的引用。如下例,是重载的
Sales_data
的输入运算符
2、注意,输入运算符必须处理输入可能失败的情况,在执行输入运算符时可能出现以下错误:当流含有错误类型数据时,读取操作可能失败;当读取操作到达文件末尾或者遇到输入流其他错误时也会失败
3、在该程序中,我们没有逐个检查读取操作,而是等读取结束后赶在使用数据前直接一次性检查。当读取操作发生错误时,输入运算符应当负责从错误中恢复,如该例中将对象置为合法状态(尽管数据错误,但技术上正确),我们能略微保护使用者免受错误影响
4、但是,一些输入运算符需要做更多数据验证工作来标示错误,例如该例中发生错误丢弃数据时,应当设置流的条件状态来标示失败信息。通常情况下,输入运算符只设置failbit
表示出现错误,但此外还可以设置eofbit
表示文件耗尽,设置badbit
表示流被破坏。最好的方式是由IO
库自己标示这些错误istream &operator>>(istream &is, Sales_data &item) { double price; // 先把数据读入 price 再使用 is >> item.bookNo >> item.units_sold >> price; // 读入所有数据 if (is) // 检查输入是否成功 item.revenue = item.units_sold * price; else item = Sales_data(); // 输入失败:对象被赋予默认状态 return is; }
算术和关系运算符
概述
1、通常情况下,我们把算术运算符和关系运算符定义为非成员函数以允许左右侧对象进行转换
2、这些运算符一般不需要改变运算符对象的状态,所以形参都是常量引用算术运算符 +
1、算术运算符通常会计算两个运算对象并得到一个新值,这个值常常位于一个局部变量内,操作完成后返回该局部变量的副本作为结果
2、如果类定义使用算术运算符,则它一般也会定义对应的复合赋值运算符,此时使用复合赋值运算符来定义算术运算符是最有效的方法,如下Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs) { Sales_data sum = lhs; // 把 lhs 的数据成员拷贝给 sum sum += rhs; // 使用复合赋值运算符(前提是已定义) return sum; }
相等运算符 ==
1、通常情况下,类通过定义相等运算符来检验两个对象是否相等。也就是说,它们会比较对象的每一个数据成员,只有全部相等时才认为两个类相等
2、据此,我们的Sales_data
的相等运算符不但应该比较bookNo
,还应该比较具体销售数据,如下
3、这体现了相等运算符的设计准则:如果一个类含有判断两个对象是否相等的操作,一定要重载运算符而不是定义为普通函数,以方便用户使用;如果类定义了operator==
,则其应该能判断一组对象中是否含有重复数据;通常情况下,相等运算符应该具有传递性;如果类定义了operator==
,也应该定义operator!=
;相等和不相等中的其中一个应该把工作委托给另一个bool operator==(const Sales_data &lhs, const Sales_data &rhs) { return lhs.isbn() == rhs.isbn() && lhs.units_sold == rhs.units_sold && lhs.revenue == rhs.revenue; } bool operator!=(const Sales_data &lhs, const Sales_data &rhs) { return !(lhs == rhs); }
关系运算符 <
1、定义了相等运算符的类也常常(但不总是)包含关系运算符。特别是,因为关联容器和一些算法要用到
<
,因此定义operator<
会比较有用
2、通常情况下关系运算符应该:定义顺序关系,令其与关联容器中对关键字的要求一致,并且如果类同时含有==
,则定义一种关系令其与==
保持一致。特别是,如果两个对象是!=
的,那么一个对象应该<
另一个对象
3、尽管我们可能认为Sales_data
也应该支持关系运算符,但其并不需要,因为其不存在一种可靠的<
定义(按照什么排序,需要用到什么的排序),因此这个类不定义<
也许更好
4、如果存在唯一一种逻辑可靠的<
定义,则应该考虑为这个类定义<
;如果这个类同时还包含==
,则当且仅当<
的定义和==
产生一致结果时才定义<
赋值运算符
赋值运算符 =
1、之前在 13 章已经介绍过拷贝复制运算符和移动赋值运算符,它们可以把类对象赋值给该类另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧对象
2、例如,标准库vector
还允许接受花括号列表作为参数(如下),我们也可以把这个运算符加到我们自定义的StrVec
中,其返回左侧对象引用
3、通常情况下,我们更倾向于将赋值运算符定义在类的内部// vector 接受花括号列表作为参数 vector<string> v; v = {"a", "an", "the"}; // 在 StrVec 中定义 class StrVec { public: StrVec& operator=(initializer_list<string>); // ...其他成员不变 }; StrVec& StrVec::operator=(initializer_list<string> il) { // alloc_n_copy 分配内存空间并从给定范围拷贝元素 auto data = alloc_n_copy(il.begin(), il.end()); free(); // 销毁对象中的元素并释放内存空间 elements = data.first; // 更新数据成员使其指向新空间 first_free = cap = data.sencond; return *this; }
复合赋值运算符 +=
1、复合赋值运算符不非得是类成员,但我们也还是更倾向于将其定义在类内。其返回左侧对象引用
2、下面是Sales_data
中复合赋值运算符的定义// 作为成员的二元运算符,左侧对象绑定到隐式的 this // 假定两个对象表示的是同一本书 Sales_data& Sales_data::operator+=(const Sales_data &rhs) { units_sold += rhs.units_sold; revenue += rhs.revenue; return *this; }
下标运算符
下标运算符 []
1、下标运算符
operator[]
必须是成员函数。为了与原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样的好处是下标可以出现在赋值运算符任意一端
2、进一步,我们最好同时定义下标运算符的常量版本和非常量版本,以返回对应类型的数值。由于下标运算符返回元素的引用,因此当元素是常量,我们便不能为其赋值了。如下,我们定义StrVec
的下标运算符class StrVec { public: // 必须是成员函数,通常有 const 和非 const 版本 string& operator[](size_t n) { return elements[n]; } const string& operator[](size_t n) const { return elements[n]; } // ...其他成员 private: string *elements; // 指向数组首元素的指针 }; // 假设 svec 是一个 StrVec 对象 const StrVec cvec = svec; // 如果 svec 含有元素,对第一个元素运行 string 的 empty 函数 if(svec.size() && svec[0].empty()) { svec[0] = "zero"; // 正确:下标运算符返回 string 的引用 cvec[0] = "zip"; // 错误:对 cvec 取下标返回的是常量引用 }
递增递减运算符
前置递增递减运算符
1、为了说明递增递减运算符,我们直接在
StrBlobPtr
中定义它们,其应返回递增递/减后的对象引用,如下例
2、递增运算符中,我们把curr
的值传递给check
,如果其小于vector
的大小,则check
正常返回;否则说明已到达末尾,抛出异常
3、递减运算符先递减curr
,再调用check
。此时,如果递减前curr
(无符号数)已经是 0 了,则传给check
的值将是一个表示无效下标的非常大的正数,也一定超过vector
大小而抛出异常class StrBlobPtr { public: // 前置递增递减运算符 StrBlobPtr& operator++(); StrBlobPtr& operator--(); // ...其他成员 }; // 前置递增递减运算符 StrBlobPtr& StrBlobPtr::operator++() { // 如果 curr 已经指向尾后位置,则无法递增 check(curr, "increment past end of StrBlobPtr"); // 检查 curr ++curr; // 递增 curr return *this; } StrBlobPtr& StrBlobPtr::operator--() { // 如果 curr 是 0,则继续递减会产生一个无效下标 --curr; // 递减 curr check(curr, "decrement past begin of StrBlobPtr"); // 检查 curr return *this; }
区分前置和后置运算符
1、想要同时定义前置和后置运算符,必须首先解决区分问题,因为普通的重载形式无法区分这两种情况
2、为此,后置版本接受一个额外的(不被使用的)int
形参。当我们使用后置运算符时,编译器为这个形参提供一个值为 0 的实参
3、这个形参的唯一作用就是区分前置和后置版本,而不是真的要在实现后置版本时参与运算后置递增递减运算符
1、对于后置版本来说,在递增/递减之前需要先记录对象的状态,以供返回值使用
2、后置版本不需要直接检查范围,我们将调用前置版本来实现操作,在前置版本中才会检查范围和递增/递减
3、操作执行后,对象将被递增/递减,但我们将返回提前记录下的对象的状态,因此返回一个对应对象(而无须对象引用)class StrBlobPtr { public: // 后置递增递减运算符 StrBlobPtr operator++(int); // 后置运算符的 int 形参 StrBlobPtr operator--(int); // ...其他成员 }; // 后置递增递减运算符 StrBlobPtr StrBlobPtr::operator++(int) // 后置运算符的 int 形参 { // 此处无须检查有效性,调用前置递增运算时才需要 StrBlobPtr ret = *this; // 记录当前值 ++*this; // 调用前置运算符前移一个元素(需要保证前置运算符已定义) return ret; // 返回当前记录的值,且源对象的值已递增 } StrBlobPtr StrBlobPtr::operator--(int) { // 此处无须检查有效性,调用前置递减运算时才需要 StrBlobPtr ret = *this; // 记录当前值 --*this; // 调用前置运算符后移一个元素(需要保证前置运算符已定义) return ret; // 返回当前记录的值,且源对象的值已递减 }
成员访问运算符
成员访问运算符 *和->
1、在迭代器和智能指针中常常用到解引用运算符
*
和箭头运算符->
,我们以如下形式向StrBlobPtr
添加这两种运算符
2、->
必须是类的成员。*
通常也是类的成员,尽管并非必须如此,但几乎也总是这样
3、值得注意的是,这两个运算符定义成了const
成员,因为获取元素并不会改变对象状态;此外它们的返回值分别是非常量的对象引用和对象指针,因为一个StrBlobPtr
只能绑定到非常量StrBlobPtr
对象class StrBlobPtr { public: string& operator*() const { auto p = check(curr, "dereference past end"); return (*p)[curr]; // (*p) 是对象所指的 vector } string* operator->() const { // 将实际工作委托给解引用运算符 return & this->operator*(); } // ...其他成员 };
函数调用运算符
函数调用运算符
描述
1、如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更灵活
2、函数调用运算符必须是成员函数。如下例,我们设计absInt
类含有一个调用运算符,其返回参数的绝对值。使用时,令一个absInt
的对象absObj
作用于一个实参列表,这一过程看起来非常像函数调用的过程。即使absObj
只是一个对象,而非函数,但我们也能调用该对象
3、如果类定义了调用运算符,则该类对象称作函数对象,因为可以调用这些对象,所以说这些对象行为像函数一样struct absInt { // 函数调用运算符 operator() int operator()(int val) const { return val < 0 ? -val : val; } }; int i = -42; absInt absObj; // 含有函数调用运算符的对象 int ui = absObj(i); // 将 i 传递给 absObj.operator()
含有状态的函数对象类
1、和其他类一样,函数对象类除了
operator()
之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作
2、如下例,我们定义一个打印string
实参内容的类。该类的构造函数接受一个输出流引用和一个用于分隔的字符,且都设置了默认值,之后的函数调用运算符中使用数据成员来协助打印给定的string
3、函数对象常常作为泛型算法的实参,例如可以使用标准库for_each
算法和我们的PrintString
来打印容器内容:for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'))
。其中第三个实参是PrintString
的一个临时对象,用cerr
和'\n'
初始化该对象,我们将把容器vs
中的元素依次打印到cout
中并用换行符分隔class PrintString { public: // 默认构造函数 PrintString(ostream &o = cout, char c = ' ') : os(o), sep(c) { } // 函数调用运算符 void operator()(const string &s) const { os << s << sep; } private: ostream &os; // 用于写入的目的流 char sep; // 用于将不同输出隔开的字符 }; PrintString printer; // 使用默认值,打印到 cout printer(s); // 在 cout 中打印 s,后面跟一个空格 PrintString errors(cerr, '\n'); // 构造新的 PrintString 函数对象并初始化 errors(s); // 在 cerr 中打印 s,后面跟一个换行符
lambda 是函数对象
描述
1、上一小节,我们使用一个
PrintString
对象作为调用for_each
的实参,这个用法和我们先前编写的使用lambda
表达式的程序类似。实际上,当我们编写了一个lambda
后,编译器将该表达式翻译成一个未命名类的未命名对象
2、在lambda
表达式产生的类中含有一个重载的函数调用运算符。如下例,对于我们传递给stable_sort
作为最后一个实参的lambda
表达式来说,其行为类似于下面的类的一个未命名对象,我们也可以用这个对象替换实参中的lambda
// 根据单词长度对其排序,对于长度相同的单词按照字母表顺序排序 stable_sort(words.begin(), words.end(), [](const string &a, const string &b) { return s1.size() < s2.size(); }); // lambda 等同于下面的类的未命名对象 class ShorterString { public: bool operator()(const string &s1, const string &s2) const { return s1.size() < s2.size(); } }; // 可以用这个对象替换 lambda stable_sort(words.begin(), words.end(), ShorterString());
表示
lambda
及相应捕获行为的类1、当一个
lambda
通过引用捕获变量时,将由程序负责确保lambda
执行时引用的对象确实存在,因此编译器可以直接使用该引用而无须在lambda
产生的类中将其存储为数据成员。相反,通过值捕获变量时,变量被拷贝到lambda
中,则必须为每个值建立对应的数据成员,同时创建构造函数以初始化数据成员
2、如下例,有一个lambda
,作用是找到第一个长度不小于给定值的string
,其产生的类类似于下面的类。与上面的ShorterString
不同,该类含有数据成员和构造函数,合成的类不含有默认构造函数,因此要使用这个类必须提供一个实参// 获得第一个指向满足条件元素的迭代器,该元素满足 size() >= sz auto wc = find_if(words.begin(), words.end(), [sz](const string &a) { return a.size() >= sz; }); // 该 lambda 产生的类类似于下面的类 class SizeComp { public: // 构造函数,形参对应捕获的变量,没有默认值 SizeComp(size_t n) : sz(n) { } // 函数调用运算符 bool operator()(const string &s) const { return s.size() >= sz; } private: size_t sz; // 对应通过值捕获的变量 }; // 使用该类必须提供实参 auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
标准库函数对象
介绍
1、标准库定义了一组表示算术运算符、关系运算符、逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如
plus
类定义函数调用运算符用于对一些对象执行+
操作,modulus
类执行二元的%
操作,equal_to
类执行==
操作等
2、这些类都被定义成模板,我们可以为其指定具体应用类型,即调用运算符的形参类型,类似于使用vector
3、下标列出了这些标准库函数对象,它们定义在头文件functional
中算术运算 关系运算 逻辑运算 plus equal_to logical_and minus not_equal_to logical_or multiplies greater logical_not divides greater_equal modulus less negate less_equal plus<int> intAdd; // 可执行 int 加法的函数对象 negate<int> intNegate; // 可对 int 值取相反数的函数对象 // 使用 intAdd::operator(int, int) 求和 int sum = intAdd(10, 20); // sum = 30 sum = intNegate(intAdd(10, 20)); // sum = -30 // 使用 intNegate::operator(int) 生成 -10,再将 -10 作为 intAdd 的第二个参数 sum = intAdd(10, intNegate(10)); // sum = 0
在算法中使用标准库函数对象
1、表示运算符的函数对象类常用来替换算法中的默认运算符。如我们所知,默认情况下标准库排序算法使用
operator<
将序列升序排列。现在,如果要降序排列,我们可以传入一个greater
类型对象,该类将产生一个调用运算符并负责执行待排序类型的>
运算,如下
2、需要注意的是,标准库规定其函数对于指针同样适用。我们之前介绍过比较两个无关指针将产生未定义行为,然而我们可能会希望通过比较指针内存地址来排序指针的vector
。直接这么做将产生未定义行为,因此我们可以使用标准库函数对象来实现此目的,如下// 使用 greater 降序排列 sort(svec.begin(), svec.end(), greater<string>()); // 有关指针的排序 vector<string *> nameTable; // 指针的 vector // 错误:直接比较将产生未定义行为 sort(nameTable.begin(), nameTable.end(), [](string *a, string *b) { return a < b; }); // 正确:标准库规定指针的 less 是定义良好的 sort(nameTable.begin(), nameTable.end(), less<string *>());
可调用对象与 function
引入
1、C++中有几种可调用对象:函数、函数指针、
lambda
表达式、bind
对象、重载了函数调用运算符的类
2、和其他对象一样,可调用对象也有类型。例如,每个lambda
都有他自己唯一的类类型,函数及函数指针的类型则由其返回值类型和实参类型决定等等
3、然而,两个不同类型的可调用对象有可能共享同一种调用形式,调用形式指明了调用返回值类型以及实参类型。例如int(int, int)
是一个函数类型,接受两个int
返回一个int
不同类型可能具有相同调用形式
1、对于多个可调用对象共享同种调用形式的情况,我们会希望把它们看成具有相同的类型。如下,这些调用对象分别对其参数执行了不同的算术运算,尽管类型各不相同,但仍共享同一种调用形式
int(int, int)
2、我们可能希望使用这些可调用对象构建一个简单的桌面计算器。为此需要定义一个函数表,用于存储指向这些可调用对象的指针,当程序需要特定操作时,从该表中查找调用的函数。C++中,函数表很容易通过map
实现
3、对于本例,我们使用表示运算符符号的string
作为关键字,使用实现运算符的函数作为值,假定所有函数相互独立,且只处理关于int
的二元运算,则可以定义成下例中的形式。但是,我们不能将mod
或divice
存入binops
,因为它们都是类类型(lambda
也有自己的类类型),与binops
的值类型不匹配// 普通函数 int add(int i, int j) { return i + j; } // lambda,产生一个未命名的函数对象类 auto mod = [](int i, int j) { return i % j; }; // 函数对象类 struct divide { int operator()(int denominator, int divisor) { return denominator / divisor; } }; // 构建从运算符到函数指针的映射,函数接受两个 int,返回一个 int map<string, int(*)(int, int)> binops; // 正确:add 是一个指向正确类型的指针 binops.insert({"+", add}); // {"+", add} 是一个 pair // 错误:mod 不是一个函数指针 binops.insert({"%", mod});
标准库
function
类型1、我们可以使用
function
标准库类型来解决上述问题,它定义在头文件functional
中,function
是一个模板,下表列出了它的操作
2、如下例,我们声明了一个function
类型,它可以表示接受两个int
、返回一个int
的可调用对象,使用这个function
可以重新定义上一小节的map
,并将所有相同调用形式的可调用对象都添加到这个map
3、一如往常,当我们索引map
时将得到关联值的引用,如果我们索引binops
,将得到function
对象的引用。function
重载了调用运算符,其接受自己的实参然后将传递给存好的可调用对象function 操作 描述 function<T> f; f 是一个用来存储可调用对象的空 function,这些可调用对象的调用形式应该与 T 相同,即 T 应该为 retType(args) function<T> f(nullptr); 显式构造一个空 function function<T> f(obj); 在 f 中存储可调用对象 obj 的副本 f 将 f 作为条件:当 f 含有一个可调用对象时为真,否则为假 f(args) 调用 f 中的对象,参数是 args function<T>的成员类型 描述 result_type 该 function 类型的可调用对象的返回类型 argument_type 当 T 只有一个实参时定义的类型,如果 T 只有一个实参,arguement_type 便是该实参类型的同义词 first_argument_type,second_argument_type 当 T 只有两个实参时定义的类型,如果 T 有两个实参,则 first_argument_type 和 second_argument_type 分别代表两个实参的类型 // function 对象的使用 function<int(int, int)> f1 = add; // 函数指针 function<int(int, int)> f2 = divide(); // 函数对象类的对象 function<int(int, int)> f3 = [](int i, int j) { return i * j; }; // lambda cout << f1(4, 2) << endl; // 打印 6 cout << f2(4, 2) << endl; // 打印 2 cout << f3(4, 2) << endl; // 打印 8 // 重新定义上一小节的 map,值变成了 function 对象 map<string, function<int(int, int)>> binops = {{"+", add}, // 函数指针 {"-", minus<int>()}, // 标准库函数对象 {"*", [](int i, int j) { return i * j; }}, // 未命名的 lambda {"/", divide()}, // 用户定义的函数对象 {"%", mod}}; // 命名了的 lambda // 使用 binops binops["+"](10, 5); // 调用 add(10, 5) binops["-"](10, 5); // 使用 minus<int> 对象的调用运算符 binops["*"](10, 5); // 使用 lambda 函数对象 binops["/"](10, 5); // 使用 divide 对象的调用运算符 binops["%"](10, 5); // 使用 lambda 函数对象
重载函数的二义性问题
1、我们不能直接将重载函数名存入
function
对象中,原因如下例
2、解决这种二义性问题的一种途径是存储函数指针而非函数名,另一种方式是使用lambda
指定版本// 二义性问题 int add(int i, int j) // 普通函数 { return i + j; } Sales_data add(Sales_data&, Sales_data&); // 重载的另一个函数 map<string, function<int(int, int)>> binops; binops.insert( {"+", tp} ); // 错误:哪个 add // 方法 1:函数指针 int (*fp)(int, int) = add; // 指针所指的 add 是接受两个 int 的版本 binops.insert( {"+", fp} ); // 正确:指向一个正确的 add 版本 // 方法 2:使用 lambda 指定版本 binops.insert( {"+", [](int a, int b) { return add(a, b);} } );
重载、类型转换与运算符
类型转换运算符
描述
1、类型转换运算符是类的一种特殊成员函数,负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如:
operator type() const;
,其中type
表示某种类型
2、类型转换运算符可以面向任意类型(除了void
)进行定义,只要该类型能作为函数的返回类型。因此,不允许转换成数组或函数类型,但允许转换成指针或引用
3、类型转换运算符没有显式的返回类型和形参,而且必须定义成类的成员函数。因其不应改变转换对象的内容,所以一般被定义成const
成员定义含有类型转换运算符的类
1、我们首先定义一个简单的类,令其表示0 到 255之间的一个整数,如下。
SmallInt
类既定义了向类类型的转换(构造函数),也定义了类类型向其他类型的转换(类型转换运算符)
2、尽管编译器一次只能执行一个自定义类型转换,但是隐式的自定义类型转换可以置于一个内置类型转换之前或之后,并与其一起使用,如下例
3、由于类型转换运算符是隐式执行的,所以无法给这些函数传参,定义中也不能使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都应该返回一个对应类型值class SmallInt { public: // 构造函数:从 int 转换成类类型对象 SmallInt(int i = 0) : val(i) { if(i < 0 || i > 255) throw out_of_range("Bad SmallInt value"); } // 类型转换运算符:从类类型对象转换成 int operator int() const { return val; } private: size_t val; // size_t 类型对象 }; SmallInt si; si = 4; // 首先将 4 隐式转换成 SmallInt,然后调用 SmallInt::operator= si + 3; // 首先将 si 隐式转换成 int,然后执行整数加法 si = 3.14; // 内置类型转换先将 double 转换成 int,然后调用 SmallInt(int) 构造函数 si + 3.14; // SmallInt 的类型转换运算符先将 si 转换成 int,然后内置类型转换将 int 转换成 double
可能产生意外结果
1、在实践中,类很少提供类型转换运算符。大多情况下,如果类型发生自动转换,用户会感到比较意外,而不是受到了帮助。然而存在一种例外:对于类,定义向
bool
的类型转换仍然比较普遍
2、早期版本中,如果类想定义一个向bool
的类型转换,则常常遇到一个问题:bool
是一种算术类型,类类型转换成bool
后可以被用在任何需要算术类型的上下文中。这样的类型转换可能引发意想不到的结果,特别是当istream
含有向bool
的类型转换时,如下例
3、该例中,程序试图将<<
作用于输入流。因为istream
本身没有定义<<
,本身应该产生错误。但是,该代码能使用istream
的bool
类型转换运算符,将cin
转换成bool
,然后bool
又会接着被提升成int
,并被用作内置的左移运算符(位运算)的左侧运算对象,这一行为与我们的预期大相径庭int i = 42; cin << i; // 如果 istream 向 bool 的类型转换不是显式的,则该代码在编译器看来是合法的
显式的类型转换运算符
1、为了防止上面的异常情况发生,新标准引入了显式的类型转换运算符,如下例
2、和显式的构造函数一样,编译器(通常)也不会将显式的类型转换运算符用于隐式类型转换。当类型转换运算符是显式时,必须通过显式的强制类型转换才可以使用
3、但有一个例外:如果表达式被用作条件,则编译器会将显式类型转换自动应用于它class SmallInt { public: // 编译器不会自动执行这一类型转换 explicit operator int() const { return val; } // ...其他成员一致 }; SmallInt si = 3; // 正确:SmallInt 的构造函数不是显式的 si + 3; // 错误:此处需要隐式的类型转换,但类的运算符是显式的 static_cast<int>(si) + 3; // 正确:显式地请求类型转换
避免有二义性的类型转换
引入
1、如果类中包含一个或多个类型转换,就必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则,我们的代码很可能具有二义性。有两种情况可能产生多重转换路径
2、第一种情况是两个类提供相同类型转换。例如A 类定义了接受B 类对象的转换构造函数,同时B 类定义了转换目标是A 类类型的类型转换运算符时,我们就说它们提供了相同的类型转换
3、第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身就可以通过其他类型转换联系在一起。典型例子是算术运算符,对于某个给定类来说,最好只定义最多一个与算术类型有关的转换规则实参匹配和相同类型转换
1、在下面例子中,我们定义了两种将 B 转换成 A 的方法:一种使用B的类型转换运算符,一种使用A的以 B 为参数的构造函数
2、该例中对 f 的调用存在二义性。如果确实想执行该调用,就必须显式地调用指定函数了
3、值得注意的是,我们无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性// 最好不要在两个类之间构建相同的类型转换 struct B; struct A { A() = default; A(const B&); // 把 B 转换成 A 的构造函数 // ...其他数据成员 }; struct B { operator A() const; // 把 B 转换成 A 的类型转换运算符 // ... 其他数据成员 }; A f(const A&); B b; A a = f(b); // 二义性错误:含义是 f(B::operator A()) 还是 f(A::A(const B&)) A a1 = f(b.operator A()); // 正确:使用 B 的类型转换运算符 A a2 = f(A(b)); // 正确:使用 A 的构造函数
转换目标为内置类型的多重类型转换
1、如果类定义了一组类型转换,它们的转换源(转换目标)类型本身就可以通过其他类型转换联系在一起,则同样会产生二义性问题。最简单的也最常见的例子就是类中定义了多个参数都是算术类型的构造函数,或者多个转换目标都是算数类型的类型转换运算符
2、如下例,如对f2
的调用中,无论哪个类型转换都无法精确匹配long double
,然而两个类型转换都能使用,因此哪个都不比另一个更好,调用将产生二义性struct A { // 最好不要创建两个转换源都是算数类型的类型转换 A(int = 0); A(double); // 最好不要创建两个转换目标都是算数类型的类型转换 operator int() const; operator double() const; // ...其他成员 }; void f2(long double); A a; f2(a); // 二义性错误:含义是 f(A::operator int()) 还是 f(A::operator double()) long lg; A a2(lg); // 二义性错误:含义是 A::A(int) 还是 A::A(double)
重载函数与转换构造函数
1、当我们调用重载函数时,从多个类型转换中进行选择将变得更加复杂。如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好
2、举个例子,当几个重载函数的参数分属不同类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升,如下例。我们可以显式地构造正确的类型以消除二义性struct C { C(int); // ...其他成员 }; struct D { D(int); // ...其他成员 }; void manip(const C&); void manip(const D&); // 重载版本的 manip manip(10); // 二义性错误:含义是 manip(C(10)) 还是 manip(D(10)) manip(C(10)); // 正确:调用 manip(const C&)
函数匹配与重载运算符
1、重载运算符也是重载的函数,因此,通用的函数匹配规则通常适用于在表达式中使用内置运算符还是重载运算符。不过当运算符函数出现在表达式中时,候选函数集的规模比我们使用调用运算符调用函数时更大
2、如果a
是一种类类型,则表达式a sym b
(sym
代表一种运算符)的含义可能是:a.operatorsym(b);
或operatorsym(a, b);
,且后者可能是一个非成员函数。和普通函数不同,我们不能通过调用形式来区分调用的是成员函数还是非成员函数
3、当我们使用重载运算符作用于类类型对象时,候选函数中包含该运算符的普通非成员版本和内置版本。此外,如果左侧对象是类类型,则定义在该类中的重载运算符也包含在候选函数内
4、当我们调用一个命名的函数时,具有该名字的成员函数和非成员函数之间不会彼此重载。当我们通过类类型对象进行函数调用时,只考虑该类的成员函数;当我们在表达式中使用重载运算符时,无法判断正在使用的是哪个版本,因此二者都应该在考虑范围内
5、举个例子,我们为先前的SmallInt
定义一个加法运算符,如下。我们可以使用这个类将两个SmallInt
对象相加,但如果我们试图执行混合模式的算术运算,就会遇到二义性问题class SmallInt { public: // 重载运算符,定义在类外(定义已省略),返回 SmallInt friend SmallInt operator+(SmallInt&, SmallInt&); SmallInt(int = 0); // 转换源为 int 的类型转换 operator int() const // 转换目标为 int 的类型转换 { return val; } private: size_t val; }; SmallInt s1, s2; SmallInt s3 = s1 + s2; // 使用重载的 operator+ int i = s3 + 0; // 二义性错误:是把 0 转换成 SmallInt 然后使用 SmallInt 重载的加法,还是把 s3 转换成 int 然后使用内置加法
面向对象程序设计
章节概要:
OOP
概述;
如果较为紧急,建议添加微信或QQ,并注明来意
评论系统可能加载较慢,请耐心等待或刷新重试