《C++Primer 第五版》——第十章 泛型算法
标准库容器定义的操作集合很小,标准库并未给每个容器添加大量功能,而是提供一组算法,这些算法大部分独立于特定的容器。这意味着这些算法是 通用的( generic ,或称泛型的) :它们可用于不同类型的容器和不同类型的元素。
标准库并未给每个容器都定义对应的成员函数来实现大多数特定操作,例如查找特定元素、替换或删除一个特定值、重排元素顺序等。而是定义了一组 泛型算法( generic algorithm ) :
- 称它们为“算法”,是因为它们实现了一些经典算法的公共接口,比如排序和搜索;
- 称它们是“泛型的”,是因为它们可以用于不同类型的元素和多种容器类型(不仅包括标准库容器类型,如
vector
,还包括内置的数组类型),以及后面将看到的,还能用于其他类型的序列。
10.1 概述
大多数标准库算法都定义在头文件 algorithm
中。标准库还在头文件 numeric
中定义了一组数值泛型算法。
一般情况下,这些算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围来进行操作。通常情况下,算法遍历范围,对其中每个元素进行一些处理。
例如,假定我们有一个 vector<int>
,希望知道这个容器中是否包含一个特定值。回答这个问题最方便的方法是调用标准库算法 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;
在这里,程序在输出语句中执行这个检测,并在其中使用了条件运算符来报告搜索是否成功。
关于标准库的泛型算法函数 find
:
由于 find
操作的是迭代器,因此可以用同样的 find
函数在任何容器中查找值。 例如,可以用 find
在一个list<string>
中查找一个给定值:
string val = "a value"; // 要查找的值
// 此调用在 list 中查找 string 元素
auto resutl = find(lst.cbegin(), lst.cend(), val);
类似的,由于指针就像内置数组上的迭代器一样,所以 可以用 find
在内置的数组类型中查找值 :
int ia[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int val = 3;
int* result = find(begin(ia), end(ia), val);
此例中使用了标准库的函数 begin
和 end
(参考3.5.3)来获得指向 ia 中首元素和尾元素之后位置的指针,并传递给 find
。
还可以在序列的子范围中查找,只需将指向子范围首元素和尾元素之后位置的指针传递给 find
。例如,下面的语句在 ia[1] 、 ia[2] 和 ia[3] 中查找给定元素:
// 在从 ia[1] 开始,直至(但不包含) ia[4] 的范围内查找元素
auto result = find(ia + 1, ia + 4, val);
函数 find 的算法如何工作
为了弄清这些算法如何用于不同类型的容器,下面会更深入地观察一下 find
函数。 find
的工作是在一个未排序的元素序列中查找一个特定元素。概念上, find
应执行如下步骤:
- 访问序列中的首元素。
- 比较此元素与要查找的值。
- 如果此元素与要查找的值匹配,
find
返回标识此元素的值。 - 否则,
find
前进到下一个元素,重复执行步骤2和3。 - 如果到达序列尾,
find
应停止。 - 如果
find
到达序列末尾,它应该返回一个指出元素未找到的值。此值和步骤3返回的值必须具有相容的类型。
这些步骤都不依赖于容器所保存的元素类型,只与迭代器有关。因此,只要有一个迭代器可用来访问元素, find
就完全不依赖于容器类型(甚至无须理会保存元素的是不是容器)。
迭代器令算法不依赖于容器,但算法依赖于元素类型的操作
在上述 find
函数流程中,除了第2步外(第2步是元素类型的比较操作),其他步骤都可以用迭代器操作来实现:
虽然迭代器的使用令算法不依赖于容器类型,但大多数算法都使用了一个(或多个)元素类型上的操作。
例如,在步骤2中, find
用元素类型的 ==
运算符完成每个元素与给定值的比较。而其他算法可能要求元素类型支持 <
运算符。不过,在后面将会看到,大多数算法提供了一种方法,允许程序员使用自定义的操作来代替默认的运算符。
关键概念:泛型算法永远不会执行容器的操作
10.2 初识泛型算法
附录A按操作方式列出了所有算法。
除了少数例外,标准库算法都对一个范围内的元素进行操作。此元素范围一般被称为“输入范围”。接受输入范围的算法总是使用前两个参数来表示此范围,两个参数分别是指向要处理的第一个元素和尾元素之后位置的迭代器。
虽然大多数算法遍历输入范围的方式相似,但它们使用范围中元素的方式不同。理解算法的最基本的方法就是了解它们是否读取元素、改变元素或是重排元素顺序。
10.2.1 只读算法
一些算法只会读取其输入范围内的元素,而从不改变元素,这些算法被称为只读算法。 find
就是这样一种算法,之前使用的 count
函数也是如此。
另一个 只读算法是 accumulate
,它定义在头文件 numeric 中 。 accumulate
函数接受三个参数,前两个指出了需要求和的元素的范围,第三个参数是和的初值。假定 vec 是一个整数序列,则:
// 对 vec 中的元素求和,和的初值是0。
int sum = accumulate(vec.cbegin(), vec.cend(), 0);
这条语句将 sum 设置为 vec 中元素的和,和的初值被设置为0
Note: accumulate
的第三个参数的类型决定了函数中使用哪个加法运算符以及返 回值的类型。
算法和元素类型
accumulate
将第三个参数作为求和起点,这蕴含着一个编程假定:将元素类型加到和的类型上的操作必须是可行的。即,序列中元素的类型必须与第三个参数匹配,或者能够转换为第三个参数的类型。在上例中, vec 中的元素可以是 int
,或者是 double
, long long
或任何其他可以与 int
相加的类型。
下面是另一个例子,由于 string
定义了 +
运算符,所以可以通过调用 accumulate
函数来将 vector
中所有 string
元素连接起来:
string sum = accumulate(v.cbegin(), v.cend(), string(""));
此调用将v中每个元素连接到一个 string
上,该 string
初始时为空串。注意,这里通过第三个参数显式地创建了一个 string
。将空串当做一个字符串字面值传递给第三个参数是不可以的,会导致一个编译错误。
string sum = accumulate(v.cbegin(), v.cend(), ""); // 错误: const char* 类型没有定义 + 运算符
原因在于,如果传递了一个字符串字面值,用于保存和的对象的类型将是 const char*
。如前所述,此类型决定了使用哪个 +
运算符。由于 const char*
并没有定义 +
运算符,此调用将产生编译错误。
Note:
操作两个序列的算法
关于标准库的泛型算法函数 equal
:
equal(rosterl.cbegin(), rosterl.cend(), roster2.cbegin()); // roster2中的元素数目应该至少与rosterl一样多
由于 equal
利用迭代器完成操作,因此程序员可以通过调用 equal
来比较两个不同类型的容器中的元素。而且,元素类型也不必一样,只要能用 ==
来比较两个元素类型即可。 例如,在此例中,roster1可以是 vector<string>
,而roster2是 list<const char*>
。
但是, equal
基于一个非常重要的假设:它假定第二个序列至少与第一个序列一样长。 此算法要处理第一个序列中的每个元素,它假定每个元素在第二个序列中都有一个与之对应的元素。
Note:
10.2.2 写容器元素的算法
一些标准库算法将新值赋予序列中的元素。当程序员使用这类算法时,必须注意确保序列的原大小不小于自己要求算法写入的元素数目。 还是和之前说的一样,算法不会执行容器操作,因此它们自身不可能改变容器的大小。
一些算法会自己向输入范围写入元素。这些算法本质上并不危险,因为它们最多写入与给定序列一样多的元素。
例如,算法 fill
接受一对迭代器表示一个范围,还接受一个值作为第三个参数。 fill
将给定的这个值赋予输入序列中的每个元素。
fill(vec.begin(), vec.end(), 0); // 范围内元素都置为0
fill(vec.begin(), vec.begin() + vec.size()/2, 10); // 将容器的一个子序列设置为10
由于 fill
向给定输入序列中写入数据,因此,只要传递了一个有效的输入序列,写入操作就是安全的。
关键概念:迭代器参数
算法不检查写操作,这是程序员的责任
一些标准库算法接受一个迭代器来指出一个单独的目的位置。这些算法将新值赋予一个序列中的元素,该序列从目的位置迭代器指向的元素开始。 例如,函数 fill_n
接受一个单迭代器、一个计数值和一个值。它将给定值赋予迭代器指向的元素开始的指定个元素。程序员可以用 fill_n
将一个新值赋予 vector
中的元素:
vector<int> vec; // 空 vector
// 使用vec,赋予它不同值
fill_n(vec.begin(), vec.size(), 0) ; // 将所有元素重置为 0
标准库函数 fill_n
假定写入指定个元素是安全的。即,如下形式的调用
fill_n(dest, n, val);// 此处 fill_n 假定dest指向一个元素,而从dest开始的序列至少包含n个元素
一个初学者非常容易犯的错误是在一个空容器上调用写元素的标准库算法,比如 fill_n
:
vector<int> vec; // 空向量
fill_n(vec.begin(), 10, 0); // 灾难:修改vec中的10个(不存在)元素
这个调用是一场灾难。在这里指定了要写入10个元素,但vec中并没有元素,它是空的。 并且这条语句的结果是未定义的。
Note:
插入迭代器 back_inserter
一种保证标准库算法有足够元素空间来容纳输出数据的方法是使用 插入迭代器(insertiterator) 。插入迭代器是一种向容器中添加元素的迭代器。通常情况,当程序员通过一个迭代器向容器元素赋值时,值被赋予迭代器指向的元素。而当通过一个插入迭代器赋值时,一个与赋值运算符右侧值相等的元素被添加到容器中。
后面将在10.4.1节中详细介绍插入迭代器的内容。为了展示如何用插入迭代器给容器插入元素,现在将使用 back_inserter
函数,它是定义在头文件 iterator 中的一个函数。
back_inserter
接受一个指向容器的引用,返回一个与该容器绑定的插入迭代器。当程序员通过此插入迭代器赋值时,赋值运算符会调用 push_back
将一个具有给定值的元素添加到容器中 :
vector<int> vec;// 空向量
auto it = back_inserter(vec); // 通过它赋值会将元素添加到vec中
*it = 42; // vec中现在有一个元素,值为42
程序员常常使用 back_inserter
来创建一个迭代器,作为标准库算法的目的位置来使用。例如:
vector<int> vec; // 空向量
// 正确: back_inserter 返回一个插入迭代器,可用来向vec添加元素
fill_n(back_inserter(vec), 10, 0); // 添加10个元素到vec
在每步迭代中,向给定序列的一个元素赋值。 由于传递的参数是 back_inserter
返回的插入迭代器,因此每次赋值都会在vec上调用其成员函数 push_back
。 最终,这条 fill_n
调用语句向vec的末尾添加了10个元素,每个元素的值都是0。
拷贝算法
拷贝 copy
算法是另一个向目的位置迭代器指向的输出序列中的元素写入数据的算法。 所以跟前面提到的一样, 传递给 copy
的目的序列至少要包含与输入序列一样多的元素 ,这一点很重要。此算法接受三个迭代器,前两个表示一个输入范围,第三个表示目的序列的起始位置。此算法将输入范围中的元素拷贝到目的序列中。
程序员可以用函数 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
此例中定义了一个名为a2的数组,并使用 sizeof
函数确保数组a2与数组a1包含同样多的元素。接下来调用 copy
完成从a1到a2的拷贝。在调用 copy
后,两个数组中的元素具有相同的值。
copy
返回的是其目的位置迭代器(递增后)的值。 对于上例,即ret恰好指向拷贝到a2的尾元素之后的位置。
多个算法都提供所谓的“拷贝”(_copy)版本。拷贝版本的算法计算新元素的值,但不会将它们放置在输入序列的末尾,而是创建一个新序列保存这些结果。
10.2.3 重排容器元素的算法
某些标准库算法会重排容器中元素的顺序 ,一个明显的例子是 sort
函数。调用 sort
会重排输 入序列中的元素,使之有序,它是利用元素类型的 <
运算符来实现排序的。
例如,假定程序员想分析一系列儿童故事中所用的词汇。假定已有一个 vector
,保存了多个故事的文本。而程序员希望化简这个 vector
,使得每个单词只出现一次,而不管单词在任意给定文档中到底出现了多少次。
为了便于说明问题,下面将使用下面简单的故事作为输入:
消除重复单词
为了消除重复单词,首先将 vector
排序,使得重复的单词都相邻出现。一旦 vector
排序完毕,就可以使用另一个称为 unique
的标准库算法 来重排 vector
,使得不重复的元素出现在 vector
的开始部分。由于算法不能执行容器的操作,所以使用 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());
}
使用unique
words排序完毕后,程序员希望将每个单词都只保存一次。 unique
算法重排输入序列,将相邻的重复项“消除”(并不是真正的删除,标准库算法不直接删除容器元素),并返回一个指向不重复值范围末尾的迭代器。 调用 unique
后, vector
将变为:
words的大小并未改变,它仍有10个元素。但这些元素的顺序被改变了——相邻的重复元素被“删除”了。删除打引号是因为 unique
并不真的删除任何元素,它只是将后面的不重复值前移来覆盖相邻的重复元素,使得不重复元素出现在序列开始部分。 unique
返回的迭代器指向最后一个不重复元素之后的位置。此位置之后的元素仍然存在,但程序员不知道它们的值是什么。
Note:
使用容器操作删除元素
为了真正地删除无用元素,必须使用容器操作。 在上面的例子中删除从end_unique开始直至words末尾的范围内的所有元素。 这个调用之后,words包含来自输入的8个不重复的单词。
值得注意的是,即使words中没有重复单词,这样调用 erase
也是安全的——因为在此情况下, unique
会返回words.end(),所以传递给 erase
的两个参数具有相同的值:words.end()。迭代器相等意味着传递给 erase
的元素范围为空。删除一个空范围没有什么不良后果,因此程序即使在输入中无重复元素的情况下也是正确的。
10.3 定制操作 lambda
很多标准库算法都会比较输入序列中的元素。默认情况下,这类算法使用元素类型定义的 <
或 ==
运算符完成比较。标准库还为这些算法定义了额外的版本,允许程序员提供自己定义的操作来代替默认运算符。
例如, sort
算法默认使用元素类型的 <
运算符。但可能程序员希望的排序顺序与 <
所定义的顺序不同,或是程序员的序列可能保存的是未定义 <
运算符的元素类型。在这两种情况下,都需要重载 sort
的默认行为。
10.3.1 向算法传递函数
作为一个例子,假定希望在调用elimDups(参见10.2.3)后打印 vector
的内容。此外还假定希望单词按其长度排序,大小相同的再按字典序排列。为了按长度重排 vector
,将使用 sort
的第二个版本,此版本是重载过的,它接受第三个参数,此参数是一个 谓词(predicate) 。
谓词
谓词是一个返回bool值的函数或者仿函数,其返回结果是一个能用作条件的值。标准库算法所使用的谓词分为两类:
- 一元谓词 (unary predicate,意味着它们只接受单一参数)
- 二元谓词 (binary predicate,意味着它们有两个参数)
接受谓词参数的标准库算法对输入序列中的元素调用谓词。因此,元素类型必须能转换为谓词的参数类型。
接受一个二元谓词参数的 sort
版本用这个谓词代替 <
来比较元素,而这个谓词必须满足将在11.2.2节中介绍的条件。当前只需知道,此操作必须在输入序列中所有可能的元素值上定义一个一致的序。在6.2.2节中定义的isShorter就是一个满足这些要求的函数,因此可以将isShorter传递给 sort
。这样做会将元素按大小重新排序:
// 比较函数,用来按长度排序单词
bool isShorter(const string &s1, const string &s2){
return s1.size() < s2.size();
}
// 按长度由短至长排序words
sort(words.begin() ,words.end(), isShorter);
如果words包含的数据与10.2.3节中一样,调用 sort
会将words重排,使得所有长度为3的单词排在长度为4的单词之前,然后是长度为5的单词,依此类推。
排序算法
在将words按长度大小重排的同时,还希望具有相同长度的元素按字典序排列。为了保持相同长度的单词按字典序排列,可以使用 标准库算法 stable_sort
,这种稳定排序算法将范围内的元素排序,同时不改变相等元素(“相等”是指按照 sort
算法默认或赋予谓词后的比较方式所判断的相等)的相对顺序(相对顺序是按照 sort
默认的排序后的顺序) 。
通常情况下,程序员不关心有序序列中相等元素的相对顺序,它们毕竟是相等的。但是, 在本例中定义的“相等”关系表示“具有相同长度” 。而具有相同长度的元素,如果看其内容,其实还是各不相同的。通过调用 stable_sort
,可以保持等长元素间的字典序:
elimDups(words); // 将words按字典序重排,并消除重复单词
// 按长度重新排序,长度相同的单词维持字典序
stable_sort(words.begin(), words.end() ,isShorter);
for(const auto &s : words) // 无须拷贝字符串
cout << s << "" ; // 打印每个元素,以空格分隔
cout << endl;
假定在此调用前words是按字典序排列的,则调用之后,words会按元素大小排序,而长度相同的单词会保持字典序。如果对原来的 vector
内容运行这段代码,输出为:
fox red the over slow jumps quick turtle
10.3.2 lambda 表达式
根据标准库算法接受一元谓词还是二元谓词,程序员传递给算法的谓词必须严格接受一个或两个参数。 但是,有时程序员希望进行的操作需要更多参数,这就超出了算法对谓词的限制。为了解决此问题,需要使用另外一些语言特性。
一个相关的例子是,现在将修改上一节的程序,求大于等于一个给定长度的单词有多少。同时还会修改输出,使程序只打印大于等于给定长度的单词。现在将此函数命名为biggies,其框架如下所示:
void biggies(vector<string> &words, vector<string>::size_type sz)
{
elimDups(words); // 将 words 按字典序排序,删除重复单词
// 按长度排序,长度相同的单词维持字典序
stable_sort(words.begin(), words.end(), isShorter);
// 获取一个迭代器,它指向第一个满足 size() is >= sz 的元素
// 计算满足 size >= sz 的元素数目
// 打印长度不小于给定值的单词,每个单词后面接一个空格
}
现在新问题是在 vector
中寻找第一个大于等于给定长度的元素。一旦找到了这个元素,根据其位置,就可以计算出有多少元素的长度大于等于给定值。
可以使用 标准库算法 find_if
来查找第一个具有特定大小的元素 。它类似 find
, find_if
算法接受一对迭代器,表示一个范围。但与 find
不同的是, find_if
的第三个参数是一个谓词。 find_if
算法对输入序列中的每个元素调用给定的这个谓词。它返回第一个使谓词返回非0值的元素,如果不存在这样的元素,则返回尾迭代器。
编写一个函数,令其接受一个 string
和一个长度,并返回一个 bool
值表示该 string
的长度是否大于给定长度,是一件很容易的事情。但是, find_if
接受一元谓词——程序员传递给 find_if
的任何函数都必须严格只接受一个参数,以便能用来自输入序列的一个元素调用它。没有任何办法能传递给它第二个参数来表示长度。为了解决想传递更多参数给接收一元或二元谓词的标准库函数,需要使用另外一种语言特性。
介绍 lambda
程序员可以向一个算法传递任何类别的 可调用对象(callable object) 。对于一个对象或一个表达式,如果可以对其使用调用运算符 ()
,则称它为可调用的。即,如果e是一个可调用的表达式,则我们可以编写代码 e(args) ,其中args是一个逗号分隔的一个或多个参数的列表。
有四种可调用对象,分别是
- 函数
- 函数指针。
- 重载了函数调用运算符的类(第14章介绍)
- lambda 表达式(lambda expression)
一个 lambda 表达式表示一个可调用的代码单元。可以将其理解为一个未命名的内联函数。 与任何函数类似,一个 lambda 表达式具有一个返回类型、一个参数列表和一个函数体。但与其他函数不同, lambda 表达式可能定义在函数内部。 一个 lambda 表达式具有如下形式
[capture list] (parameter list) -> return type { function body }
其中,
但是,与普通函数不同, lambda 表达式必须使用尾置返回(参见6.3.3节)来指定返回类型。
尾置返回类型( trailing return type) 。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用。尾置返回类型跟在形参列表后面并以一个 ->
符号开头。为了表示函数真正的返回类型跟在形参列表之后,在本应该出现返回类型的地方放置一个 auto
关键字:
//func接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i) -> int (*)[10];
对于 lambda 表达式,程序员可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体:
auto f = []{ return 42;}; // 将f定义为一个 lambda 表达式 []{ return 42;}
此例中定义了一个可调用对象f,它不接受任何参数,返回42。
lambda 表达式的调用方式与普通函数的调用方式相同,都是使用调用运算符:
cout << f() << endl; // 打印42
注意:
- 在 lambda 表达式中忽略括号和参数列表等价于 指定一个空参数列表。在此例中,当调用f时,参数列表是空的。
- 如果忽略返回类型, lambda 根据函数体中的返回语句推断出返回类型。此时如果函数体只有一条
return
语句,不包含其它语句,则从返回的表达式的类型推断出返回类型。否则,返回类型为void
。
Note:
向 lambda 传递参数
与一个普通函数调用类似,调用一个 lambda 时给定的实参被用来初始化 lambda 的形参。 通常,实参和形参的类型必须匹配。但与普通函数不同, lambda 不能有默认参数。因此,一个 lambda 调用的实参数目永远与形参数目相等。一旦形参初始化完毕,就可以执行函数体了。
作为一个带参数的 lambda 的例子,我们可以编写一个与 isshorter 函数完成相同功能的 lambda :
[](const string &a, const string &b)
{return a.size() < b.size();}
空捕获列表表明此 lambda 不使用它所在函数中的任何局部变量。 lambda 的参数与isShorter的参数类似,也是 const string & 。 lambda 的函数体也与isShorter类似,比较其两个参数的 size() ,并根据两者的相对大小返回一个布尔值。
使用此 lambda 表达式来调用 stable_sort
的示例:
//按长度排序,长度相同的单词维持字典序
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b){return a.size() < b.size();});
当 stable_sort
需要比较两个元素时,它就会调用给定的这个 lambda 表达式。
使用捕获列表
现在已经准备好解决原来的问题了——编写一个可以传递给 find_if
的可调用表达式。在这里希望这个表达式能将输入序列中每个 string
的长度与前面例子的biggies函数中的sz参数的值进行比较。
虽然一个 lambda 表达式可以出现在一个函数中,并使用其局部变量,但它只能使用那些明确指明的变量。 一个 lambda 通过将局部变量包含在其捕获列表中来指出将会使用这些变量。 捕获列表 指引 lambda 在其内部包含访问局部变量所需的信息。
在下面例子中, lambda 表达式会捕获sz,并只有单一的 string
参数。其函数体会将 string
的大小与捕获的sz的值进行比较:
[sz](const string &a)
{return a.size() >= sz;};
lambda 表达式以一对 []
开始,程序员可以在其中提供一个以逗号分隔的名字列表,而这些名字都是它所在函数中定义的。
由于此 lambda 捕获sz,因此 lambda 的函数体可以使用sz。 lambda 不捕获words,因此不能访问此变量。 如果使用其他函数的局部变量,并给 lambda 提供一个空捕获列表,则代码会编译错误 :
// 错误:sz未捕获
[](const string &a)
{return a.size() >= sz;};
Note:
调用 find_if
使用此 lambda 表达式,我们就可以查找第一个长度大于等于sz(前面的例子)的元素:
// 在之前的函数biggies中继续添加下面代码
// 获取一个迭代器,指向第一个满足 size() >= sz 的元素
auto wc = find_if(words.begin(), words.end(),
[sz](const string &a) {return a.size() >= sz;});
这里对 find_if
的调用返回一个迭代器,指向第一个长度不小于给定参数sz的元素。如果这样的元素不存在,则返回 words.end() 的一个拷贝。
我们可以使用 find_if
返回的迭代器来计算从它开始到words的末尾一共有多少个元素:
// 在之前的函数biggies中继续添加下面代码
// 计算满足size >= sz的元素的数目
auto count = words.end() - wc;
cout << count << " " << make_plural(count, "word", "s")
<< " of length " << sz << " or longer" << endl;
这里的输出语句调用make_ plural(参见6.3.2)来输出"word"或"words",具体输出哪个取决于大小是否等于1。
for_each 算法
问题的最后一部分是打印words中长度大于等于sz的元素。为了达到这一目的, 程序员可以使用 标准库算法 for_each
。此算法接受一个可调用对象,并对输入序列中每个元素调用此对象 :
// 打印长度大于等于给定值的单词,每个单词后面接一个空格
for_each( wc, words.end(), [](const string &s) {cout << s << "";} );
cout << endl;
在上面例子中,此 lambda 表达式中的捕获列表为空,但其函数体中还是使用了两个名字: s和 cout
,前者是它自己的参数。
捕获列表为空,是因为我们只对 lambda 所在函数中定义的非 static
变量使用捕获列表。一个 lambda 可以直接使用定义在当前函数之外的名字。在本例中, cout
不是定义在biggies中的局部非 static
变量,而是定义在头文件 iostream 中。因此,只要在biggies出现的作用域中包含了头文件 iostream , lambda 表达式就可以直接使用 cout
。
Note:
完整例子—— biggies 函数
//求大于等于一个给定长度的单词有多少。我们还会修改输出,使程序只打印大于等于给定长度的单词
void biggies(vector<string> &words, vector<string>::size_type sz)
{
elimDups(words); // put words in alphabetical order and remove duplicates
// sort words by size, but maintain alphabetical order for words of the same size
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b) { return a.size() < b.size();});
// get an iterator to the first element whose size() is >= sz
auto wc = find_if(words.begin(), words.end(),
[sz](const string &a){ return a.size() >= sz; });
// compute the number of elements with size >= sz
auto count = words.end() - wc;
cout << count << " " << make_plural(count, "word", "s")
<< " of length " << sz << " or longer" << endl;
// print words of the given size or longer, each one followed by a space
for_each(wc, words.end(),
[](const string &s){cout << s << " ";});
cout << endl;
}
10.3.3 lambda 捕获和返回
当定义一个 lambda 表达式时,编译器生成一个与它对应的新的(未命名的) class 类型。 后面将在14.8.1节中介绍这种类是如何生成的。
目前,可以这样理解,当向一个函数传递一个 lambda 时,同时定义了一个新类型和该类型的一个对象:传递的参数就是此编译器生成的类类型的未命名对象。类似的,当使用 auto
关键字定义一个用 lambda 初始化的变量时,定义了一个编译器从 lambda 生成的类类型的对象。
默认情况下 ,从 lambda 生成的类类型都包含一些数据成员,它们对应这个 lambda 在捕获列表中所捕获的变量。类似任何普通类的数据成员, lambda 的数据成员也在 lambda 对应类类型的对象创建时被初始化。
值捕获
类似参数传递, lambda 捕获列表中变量的捕获方式也可以是值或引用。后面表格列出了几种不同的构造捕获列表的方式。与传值参数类似,采用值捕获的前提是变量可以拷贝。与传值参数不同,被捕获的变量的值是在 lambda 创建时就拷贝,而不是调用 lambda 表达式时拷贝:
void fcn1(){
size_t v1 = 42; // 局部变量
// 将v1拷贝到名为f的可调用对象
auto f = [v1] {return v1;}; // 此时拷贝v1的值
v1 = 0 ;
auto j = f(); // j为42;f保存了我们创建它时v1的拷贝
}
由于被捕获变量的值是在 lambda 创建时拷贝,因此随后对其修改不会影响到 lambda 内对应的值。
引用捕获
我们定义lambda时可以采用引用方式捕获变量。例如:
void fcn2()
{
size_t v1 = 42; // 局部变量
// 对象f2包含v1的引用
auto f2 = [&v1] { return v1; };
v1 = 0;
auto j = f2(); // j为0;f2保存v1的引用,而非拷贝
}
v1之前的 &
指出v1应该以引用方式捕获。 一个以引用方式捕获的变量与其他任何类型的引用的行为类似。当程序员在 lambda 函数体内使用此变量时,实际上使用的是引用所绑定的对象。 在本例中,当 lambda 返回v1时,它返回的是v1指向的对象的值。
引用捕获与返回引用有着相同的问题和限制。如果程序员采用引用方式捕获一个变量,就必须确保被引用的对象在 lambda 执行的时候是存在的。 因为 lambda 捕获的都是局部变量,这些变量在所属函数结束后就不复存在了。如果 lambda 可能在函数结束后执行,它捕获的引用所指向的局部变量已经消失。
引用捕获有时是必要的。例如,我们可能希望biggies函数接受一个 ostream
对象的引用,用来输出数据,并接受一个字符作为分隔符:
void biggies1(std::vector<std::string>&words,
std::vector<std::string>::size_type sz, std::ostream &os = std::cout, char c = '\n')
{
// 和之前一样的重排words的代码
// 打印count的语句改为打印到os
std::for_each(words.begin(),words.end(),
[&os, c](const std::string &s){os << s << c;}); // os和c都是显式捕获,但一个是引用捕获一个是值捕获
}
因为不能拷贝 ostream
对象(参见8.1.1),因此捕获os的唯一方法就是捕获其引用(或指向os的指针)。
当程序员向一个函数传递一个 lambda 表达式时,就像本例中调用标准库算法 for_each
那样, lambda 会立即执行。在此情况下,以引用方式捕获os没有问题,因为当 for_each
执行时,其捕获的变量是存在的。
程序员也可以从一个函数返回 lambda 表达式的对象。函数可以直接返回一个可调用对象,或者返回一个类对象,该类含有可调用对象的数据成员。 如果函数返回一个 lambda ,则与函数不能返回一个局部变量的引用类似,此 lambda 也不能包含引用捕获。
建议:尽量保持 lambda 的变量捕获简单化
隐式捕获
程序员除了显式列出希望使用的来自所在函数的变量之外,还可以让编译器根据 lambda 体中的代码来推断我们要使用哪些变量(隐式捕获)——为了指示编译器推断捕获列表,应在捕获列表中写一个 &
或 =
。 &
告诉编译器采用捕获引用方式, =
则表示采用值捕获方式。 例如,我们可以重写传递给 find_if
的 lambda :
// sz 为隐式捕获的值捕获方式
wc = find_if(words.begin(), words.end(),
[=](const string &s){ return s.size() >= sz;});
如果程序员希望对一部分变量采用值捕获,对其他变量采用引用捕获,可以混合使用隐式捕获和显式捕获:
void biggies(vector<string> &words, vector<string>::size_type sz, ostream &os = cout, char c = ' ')
{
// 其它代码如前面的biggies一样
// c为显式的值捕获,其他的所有变量(os)为隐式的引用捕获
for_each(words.begin(), words.end(),
[&, c](const string &s) { os << s << c; });
// os为显式的引用捕获,其他变量(c)为隐式的值捕获,等价于上一行
for_each(words.begin(), words.end(),
[=, &os](const string &s) { os << s << c; });
}
当程序员混合使用隐式捕获和显式捕获时,捕获列表中的第一个元素必须是一个 &
或 =
符号。此符号指定了默认捕获方式是引用捕获或值捕获。当混合使用隐式捕获和显式捕获时,显式捕获的变量必须使用与隐式捕获不同的方式。即,如果隐式捕获是引用方式(使用了 &
),则显式捕获命名变量必须采用值方式,因此不能在其名字前使用 &
。类似的,如果隐式捕获采用的是值方式(使用了 =
),则显式捕获命名变量必须采用引用方式,即,在名字前使用 &
。
lambda 的捕获列表 | |
---|---|
[] | 空捕获列表。代表该 lambda 表达式不能使用所在函数中的变量。一个 lambda 只有捕获变量后才能使用它们 |
[names] | names 是一个逗号分隔的名字列表,这些名字都是 lambda 所在函数的局部变量。默认情况下采用值捕获方式,捕获列表中的变量都被拷贝。某一个名字前如果使用了 & ,则对于该变量采用引用捕获方式。名字前只能添加 & ,要么什么都不添加表示值捕获方式。 |
[&] | 隐式捕获列表,采用引用捕获方式。 lambda 的函数体中所使用的,来自所在函数的所有实体,都将采用引用方式使用。 |
[=] | 隐式捕获列表,采用值捕获方式。 lambda 的函数体将拷贝所使用的,来自所在函数的所有实体的值 |
[&, identifier_list] | identifier_list 是一个逗号分隔的列表,包含0个或多个来自所在函数的变量,它们都是显式捕获,这些变量采用值捕获方式,而任何隐式捕获的变量都采用引用方式捕获。 identifier_list 可以包括 this ,中的名字前面不能使用 & |
[=, identifier_list] | identifier_list 中的所有变量都采用引用方式捕获。而任何隐式捕获的变量都采用值方式捕获。 identifier_list 中的名字不能包括 this (C++20前),且这些名字之前必须添加 & 当默认捕获符是 = 时, identifier_list 中的名字必须以 & 开始,或者为 *this (C++17 起) 或 this (C++20 起)。 |
可变 lambda
默认情况下,对于一个值被拷贝的变量, lambda 不会改变其值。如果程序员希望能改变一个值捕获的变量的值,就必须在参数列表和函数体中间添加 关键字 mutable
。因此,可变( mutable ) lambda 能指定一个空参数列表,及省略形参列表的内容,但不能省略 ()
:
void fcn3()
{
size_t v1 = 42; // 局部变量
// f可以改变其捕获的变量的值
auto f = [v1] () mutable { return ++v1; };
v1 = 0;
auto j = f(); // j的值是43
}
一个引用捕获的变量是否可以修改,依赖于此引用捕获的是一个 const
类型还是一个非 const
类型:
void fcn4()
{
size_t v1 = 42; // 局部变量
// v1是一个对非const变量的引用
// 我们可以通过在 lambda 中对v1的引用来改变它的值
auto f2 = [&v1] { return ++v1; };
v1 = 0;
auto j = f2(); // j是1
}
指定 lambda 返回类型
默认情况下,如果一个 lambda 函数体包含除 return
语句之外的任何语句,则编译器假定此 lambda 返回 void
。与其他返回 void
的函数类似,被推断返回 void
的 lambda 不能返回值。
下面给出了一个简单的例子,我们可以使用标准库 transform
算法和一个 lambda 来将一个序列中的每个负数替换为其绝对值:
transform (vi.begin(), vi.end(), vi.begin(),
[](int i){ return i < 0 ? -i : i; });
标准库算法函数 transform
接受三个迭代器和一个可调用对象。前两个迭代器表示输入序列,第三个迭代器表示目的位置。标准库算法 transform
对输入序列中每个元素调用可调用对象,并将结果写到目的位置。 如本例所示,目的位置迭代器与表示输入序列开始位置的迭代器可以是相同的。当它们相同时, transform
将输入序列中每个元素替换为可调用对象操作该元素得到的结果。
在本例中,我们传递给 transform
算法一个 lambda ,它返回其参数的绝对值。 lambda 函数体是单一的 return
语句,返回一个条件表达式的结果。我们无须指定返回类型,因为可以根据条件运算符的类型推断出来。但是,如果我们将程序改写为看起来是等价的 if
语句,就会产生编译错误 (C++11的标准是这样,但是 gcc 和大部分编译器都优化了,允许 lambda 的函数体中存在多条返回语句,只要它们返回的表达式类型相同,且在 C++14 就允许下面这种写法) :
//错误:不能推断lambda的返回类型
transform (vi.begin(), vi.end(), vi.begin(),
[](int i){ if (i< 0) return -i; else return i; });
编译器推断这个版本的 lambda 返回类型为 void
,但它实际上返回了一个 int
值。这就产生了冲突。
当程序员需要为一个 lambda 定义返回类型时,必须使用尾置返回类型(参见6.3.3):
transform (vi.begin(), vi.end(), vi.begin(),
[](int i) -> int { if (i < 0) return -i; else return i; });
在此例中,传递给 transform
的第四个参数是一个 lambda ,它的捕获列表是空的,接受单一 int
参数,返回一个 int
值。它的函数体是一个返回其参数的绝对值的 if
语句。
省略了返回值类型的 lambda 表达式,但是该 lambda 表达式的返回类型可以按照下列规则推演出来:
10.3.4 参数绑定
建议: 对于那种只在一两个地方使用的简单操作, lambda 表达式是最有用的。如果程序员需要在很多地方使用相同的操作,通常应该定义一个函数,而不是多次编写相同的 lambda 表达式。类似的,如果一个操作需要很多语句才能完成,通常使用函数更好。
如果 lambda 的捕获列表为空,通常可以用函数来代替它。如前面章节所示,既可以用一个 lambda ,也可以用函数isShorter来实现将 vector
中的单词按长度排序。类似的,对于打印 vector
内容的 lambda 编写一个函数来替换它也是很容易的事情,这个函数只需接受一个 string
并在标准输出上打印它即可。
但是,对于捕获局部变量的 lambda ,用函数来替换它就不是那么容易了。例如,我们用在 find_if
调用中的 lambda 比较一个 string
和一个给定大小。我们可以很容易地编写一个完成同样工作的函数:
bool check_size(const string &s, string::size_type sz){
return s.size() >= sz;
}
但是,我们不能用这个函数作为 find_if
的一个参数。如前文所示, find_if
接受一个一元谓词,因此传递给 find_if
的可调用对象必须只接受单一参数。biggies传递给 find_if
的 lambda 使用捕获列表来保存sz。为了用check_size来代替此 lambda ,必须解决如何向sz形参传递一个参数的问题。
标准库 bind 函数
我们可以解决向check_size传递一个长度参数的问题,方法是使用一个新的名为 bind
的标准库函数,它定义在头文件 functional中。 可以将 bind
函数看作一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表。
绑定check_size的sz参数
作为一个简单的例子,我们将使用 bind
生成一个调用check_size的对象,如下所示,它用一个定值作为其大小参数来调用check_size:
// check6是一个可调用对象,接受一个 string 类型的参数
// 并用此 string 和6来调用check_size
auto check6 = bind(check_size, _1, 6);
此 bind
调用只有一个占位符,表示check6只接受单一参数。占位符出现在 arg_list 的第一个位置,表示 check6 的此参数对应check_size的第一个参数。此参数是一个 const string &
。因此,调用check6必须传递给它一个 string
类型的参数,而check6会将此参数传递给check_size。
string s = "hello" ;
bool b1 = check6(s); // check6(s)会调用check_size(s,6)
使用 bind
,我们可以将原来基于 lambda 的 find_if
调用,替换为如下使用check_size的版本::
// 原
auto wc = find_if(words.begin(), words.end(), [sz](const string &a));
// 现
auto wc = find_if(words.begin(), words.end(), bind(check_size, _1, sz));
此 bind
调用生成一个可调用对象,将check_size的第二个参数绑定到sz的值。当 find_if
对words中的 string
调用这个对象时,这些对象会调用check_size,将给定的 string
和sz传递给它。因此, find_if
可以有效地对输入序列中每个 string
调用check_size,实现 string
的大小与sz的比较。
使用 placeholders 命名空间中的所有名字
标准库函数 bind
的占位符 _n
都定义在一个名为 placeholders
的命名空间中,而这个命名空间本身定义在 std
命名空间中。 为了使用这些名字,两个命名空间都要写上。
与我们的其他例子类似,对 bind
的调用代码假定之前已经恰当地使用了 using
声明。例如, _1 对应的 using
声明为:
using std::placeholders::_1;
此声明说明我们要使用的名字 _1 定义在命名空间 placeholders
中,而此命名空间又定义在命名空间 std
中。
对于要使用的每个占位符名字,程序员都必须提供一个单独的 using
声明。 编写这样的声明很烦人,也很容易出错。可以使用另外一种不同形式的 using
语句(详细内容将在18.2.2节中介绍),而不是分别声明每个占位符,如下所示:
bind 的参数
如前文所述,程序员可以用 bind
修正参数的值。除此之外,还可以用 bind
绑定给定可调用对象中的参数或重新安排其顺序。
例如,假定f是一个可调用对象,它有5个参数,则下面对 bind
的调用:
// g是一个有两个参数的可调用对象
auto g = bind(f, a, b, _2, c, _1);
这条语句生成一个新的可调用对象g,它接受两个参数,分别用占位符_2和_1表示。这个新的可调用对象将它自己的参数作为f的第三个和第五个参数传递给f。f的第一个、第二个和第四个参数分别被绑定到给定的值a、b和c 上。
传递给g的参数按位置绑定到占位符。即,第一个参数绑定到_1,第二个参数绑定到_2。因此,当我们调用g时,其第一个参数将被传递给f作为最后一个参数,第二个参数将被传递给f作为第三个参数。实际上,这个 bind
调用会将 g(_1, _2)
映射为 f(a, b, _2, c, _1)
。即,对g的调用会调用f,用g的参数代替占位符,再加上绑定的参数a、b和c。例如,调用g(X, Y)会调用 f(a, b, Y, c, X)
。
用 bind 重排参数顺序
下面是用 bind
重排参数顺序的一个具体例子,我们可以用 bind
颠倒isShoter的含义:
//按单词长度由短至长排序
sort(words.begin(), words.end(), isShorter);
//按单词长度由长至短排序
sort(words.begin(), words.end(), bind(isShorter, _2, _1));
在第一个调用中,当标准库算法 sort
需要比较两个元素A和B时,它会调用isShorter (A, B)。在第二个对 sort
的调用中,传递给isShorter的参数被交换过来了。因此,当 sort
比较两个元素时,就好像调用isShorter(B, A)一样。
绑定引用参数
默认情况下, bind
的那些不是占位符的参数都是被拷贝后,再传递到 bind
返回的可调用对象中。 但是,与 lambda 类似,有时对有些绑定的参数程序员可能希望以引用方式传递,或是要绑定参数的类型无法拷贝。
如果程序员希望传递给 bind
一个对象而又不拷贝它,就必须使用标准库 ref
函数:
向后兼容:参数绑定
10.4 再探迭代器
除了为每个容器定义的迭代器之外,标准库在头文件 iterator 中还定义了额外几种迭代器。这些迭代器包括以下几种。
- 插入迭代器(insert iterator):这些迭代器被绑定到一个容器上,可用来向容器插入元素。
- 流迭代器(stream iterator):这些迭代器被绑定到输入或输出流上,可用来遍历所关联的IO流。
- 反向迭代器(reverse iterator):这些迭代器向后而不是向前移动。除了 forward_list 之外的标准库容器都有反向迭代器。
- 移动迭代器(move iterator):这些专用的迭代器不是拷贝其中的元素,而是移动它们。后面将在13.6.2节介绍移动迭代器。
10.4.1 插入迭代器
插入器是一种迭代器适配器,它接受一个容器,并生成一个迭代器,这个迭代器能实现向给定容器添加元素。 当程序员通过一个插入迭代器进行赋值时,该迭代器调用对应的容器操作来向给定容器的指定位置插入一个元素。
下表列出了这种迭代器支持的操作。
插入迭代器的操作 | |
---|---|
it = t | 在 it 指定的当前位置插入值 t 。假定 c 是 it 绑定的容器,依赖于插入迭代器的不同种类,此赋值会分别调用 c.push_back(t) 、 c.push_front(t) 或 c.insert (t,p) ,其中 p 为传递给 inserter 的迭代器位置 |
*it 和 ++it 和 it++ | 这些操作虽然存在,但不会对 it 做任何事情,每个操作都返回 it |
插入器有三种类型,差异在于元素插入的位置:
Note:
理解插入器的工作过程是很重要的:
当调用 inserter(c, iter)
时,会返回一个迭代器,接下来对这个迭代器进行赋值时,会将元素插入到 iter 原来所指向的元素之前的位置。 即,如果 it 是由 inserter
生成的迭代器,则下面这样的赋值语句
*it = val;
其效果与下面代码一样
it = c.insert(it, val) ; // it指向新加入的元素
++it; // 递增it使它指向原来的元素
front_inserter
生成的迭代器的行为与 inserter
生成的迭代器完全不一样。当程序员使用 front_inserter
时,元素总是插入到容器第一个元素之前。即使程序员传递给 inserter
的位置原来指向第一个元素,只要在此元素之前插入一个新元素,此元素就不再是容器的首元素了:
list<int> lst = { 1, 2, 3, 4};
list<int> lst2,lst3; //空list
//拷贝完成之后,lst2包含4 3 2 1
copy(lst.cbegin(), lst.cend(), front_inserter(lst2));
//拷贝完成之后,lst3包含1 2 3 4
copy(lst.cbegin(), lst.cend(), inserter(lst3, lst3.begin()));
当调用 front_inserter(c)
时,我们得到一个插入迭代器,接下来会调用 push_front
。当每个新元素被插入到容器c中时,它变为c的新的首元素。 因此, front_inserter
生成的迭代器会将插入的元素序列的顺序颠倒过来,而 inserter
和 back_inserter
则不会。
10.4.2 iostream 迭代器(即,流迭代器 stream iterator)
虽然 iostream
类型不是容器,但标准库定义了可以用于这些IO类型对象的迭代器。
istream_iterator
读取输入流,ostream_iterator
向一个输出流写数据。
这些迭代器将它们对应的流当作一个特定类型的元素序列来处理。流迭代器也是一种迭代器适配器,不过和之前讲的迭代器适配器有所差别,它的操作对象不再是某个容器,而是流对象。通过使用流迭代器,程序员可以用泛型算法从流对象读取数据以及向其写入数据。
istream_iterator 操作
istream_iterator 操作 | |
---|---|
istream_iterator< T > in (is); | in 从输入流 is 读取类型为 T 的值 |
istream_iterator< T > end; | 读取类型为 T 的值的 istream_iterator 迭代器,表示尾后位置 |
inl == in2 | in1 和 in2 必须读取相同类型。如果它们都是尾后迭代器,或绑定到相同的输入,则两者相等 |
inl != in2 | |
*in | 返回从流中读取的值 |
in->mem | 与 (*in).mem 的含义相同 |
++in, in++ | 使用元素类型所定义的 >> 运算符从输入流中读取下一个值。与以往一样,前置版本返回一个指向递增后迭代器的引用,后置版本返回旧值 |
当创建一个流迭代器时,必须指定迭代器将要读写的对象类型。 一个 istream_iterator
使用 >>
来读取流。因此, istream_iterator
要读取的类型必须定义了输入运算符。 当创建一个 istream_iterator
时,程序员可以将它绑定到一个流。 当然,程序员还可以默认初始化迭代器,这样就创建了一个可以 当作尾后值使用的迭代器。
istream_iterator<int> int_it(cin); // 从cin读取int
istream_iterator<int> int_eof; // 尾后迭代器
ifstream in("afile");
istream_iterator<string> str_it(in);// 从"afile"读取字符串
下面是一个用 istream_iterator
从标准输入读取数据,存入一个 vector
的例子:
可以将上面程序重写为如下形式,这体现了 istream_iterator
更有用的地方:
使用算法操作流迭代器
由于算法使用迭代器操作来处理数据,而流迭代器又至少支持某些迭代器操作,因此程序员至少可以用某些标准库算法来操作流迭代器。稍后会看到如何分辨哪些算法可以用于流迭代器。下面是一个例子,我们可以用一对 istream_iterator
来调用标准库算法 accumulate
:
istream_iterator<int> in(cin), eof;
cout << accumulate(in, eof, 0) << endl;
此调用会计算出从标准输入读取的值的和。如果输入为:
23 109 45 89 6 34 12 90 34 23 56 23 8 89 23
则输出为664。
istream_iterator 允许使用懒惰求值
注意:
ostream_iterator 操作
程序员可以对任何支持输出运算符( <<
运算符)的类型定义 ostream_iterator
。
- 当创建一个
ostream_iterator
时,可以提供(可选的)第二参数,它是一个字符串,在输出每个元素后都会打印此字符串——此字符串必须是一个C风格字符串 (即,一个字符串字面常量或者一个指向以空字符结尾的字符数组的指针)。 - 必须将
ostream_iterator
绑定到一个指定的流,不允许空的或表示尾后位置的ostream_iterator
。
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
来输出值的序列:
ostream_iterator<int> out_iter(cout, " ");
for(auto e : vec)
*out_iter++ = e; // 赋值语句实际上最后将元素写到cout
cout << endl;
此程序将vec中的每个元素写到 cout
,每个元素后加一个空格。每次向out_iter赋值时,写操作就会被提交。
值得注意的是,当向 ostream_iterator
对象out_iter赋值时,可以忽略解引用和递增运算。 即,循环可以重写成下面的样子:
for(auto e : vec)
out_iter = e; // 赋值语句将元素写到cout
cout << endl;
注意:
可以通过调用copy来打印vec中的元素,这比编写循环更为简单:
copy(vec.begin(), vec.end(), out_iter);
cout << endl;
使用流迭代器处理类类型
可以为任何定义了输入运算符(>>
)的类型创建 istream_iterator
对象。类似的,只要类型支持输出运算符(<<
),就可以为其定义 ostream_iterator
。
由于Sales_item既有输入运算符也有输出运算符,因此可以使用IO迭代器重写1.6节中的书店程序:
istream_iterator<Sales_item> item_iter(cin), eof;
ostream_iterator<Sales_item> out_iter(cout, "\n");//写入cout,每个结果后面都跟一个换行符
// 将第一笔交易记录存在sum中,并读取下一条记录
// 此处,我们对item_iter执行后置递增操作,对结果进行解引用操作。
// 这个表达式读取下一条交易记录,并用之前保存在item_iter中的值来初始化sum。
Sales_item sum = *item_iter++;
while (item_iter != eof) { // while循环会反复执行,直至在cin上遇到文件尾为止
// 如果当前交易记录(存在item_iter 中)有着相同的ISBN号
if (item_iter->isbn() == sum.isbn())
sum += *item_iter++;// 将其加到sum上并读取下一条记录
else {
out_iter = sum; // 输出sum当前值
sum = *item_iter++; // 读取下一条记录
}
// sum = *item_iter++;重复代码,可优化
}
out_iter = sum; // 记得打印最后一组记录的和
此程序使用item_iter从 cin
读取Sales_item交易记录,并将和写入 cout
,每个结果后面都跟一个换行符。定义了自己的迭代器后,我们就可以用item_iter读取第一条交易记录,并用它的值来初始化sum:
// 将第一条交易记录保存在sum中,并读取下一条记录
Sales_item sum = *item_iter++;
此处,我们对item_iter执行后置递增操作,再对结果进行解引用操作。这个表达式读取下一条交易记录,并用之前保存在item_iter中的值来初始化sum。
while 循环会反复执行,直至在 cin
上遇到文件尾为止。在 while 循环体中,我们检查sum与刚刚读入的记录是否对应同一本书。如果两者的 ISBN 不同,我们将sum赋予out_iter,这将会打印sum的当前值,并接着打印一个换行符。在打印了前一本书的交易金额之和后,我们将最近读入的交易记录的副本赋予sum,并递增迭代器,这将读取下一条交易记录。循环会这样持续下去,直至遇到错误或文件尾。在退出之前,记住要打印输入中最后一本书的交易金额之和。
10.4.3 反向迭代器
反向迭代器(std::reverse_iterator
) 就是在容器中从尾元素向首元素反向移动的迭代器适配器 。对于反向迭代器,递增(以及递减)操作的含义会颠倒过来。递增一个反向迭代器(++it
)会移动到前一个元素;递减一个迭代器(--it
)会移动到下一个元素。
除了 forward_list
之外,其他容器都支持反向迭代器。 程序员可以通过调用成员函数 rbegin
、 rend
、 crbegin
和 crend
来获得反向迭代器。这些成员函数返回指向容器尾元素和首元素之前一个位置的迭代器。与普通迭代器一样, 反向迭代器也有 const 和非 const 版本。
假定有一个 vector
的容器vec,下图显示了它的4种迭代器:
下面的循环是一个使用反向迭代器的例子,它按逆序打印vec种的元素:
vector<int> vec = { 0,1,2,3,4,5,6,7,8,9 };
for (auto r_iter = vec.crbegin(); // r_iter绑定到尾元素
r_iter != vec.crend(); // crend 指向首元素之前的位置
++r_iter) // 实际是递减,移动到前一个元素
cout << *r_iter << endl; // 打印 9 8 7 ... 0
虽然颠倒递增和递减运算符的含义可能令人混淆,但这样做是我们可以用算法透明地向前或向后处理容器。例如,可以通过向 sort
传递一对反向迭代器来将 vector
整理为递减序:
sort(vec.begin(),vec.end()); // 按“正常序”排序vec
// 按逆序排序:将最小元素放在vec的末尾
sort(vec.rbegin(),vec.rend());
反向迭代器需要递减运算符
程序员 只能从既支持 ++
也支持 --
的迭代器来定义反向迭代器 。毕竟反向迭代器的目的是在序列中反向移动。
- 除了
forward_list
之外,标准容器上的其他迭代器都既支持递增运算又支持递减运算。 - 但是,流迭代器不支持递减
--
运算,因为不可能在一个流中反向移动。
因此不可能从一个流迭代器或一个 forward_list
之上创建反向迭代器。
反向迭代器和其它迭代器之间的关系
假定有一个名为line的 string
,保存着一个逗号分隔的单词列表,我们希望打印line中的第一个单词。使用标准库算法 find
可以很容易地完成这一任务:
//在一个逗号分隔的列表中查找一个元素
auto comma = find(line.cbegin(), line.cend(), ',');
cout << string(line.cbegin(), comma) << endl;
如果line中有逗号,那么comma将指向这个逗号;否则,它将等于 line.cend() 。当打印从 line.cbegin() 到 comma 之间的内容时,将打印到逗号为止的序列,或者打印整个 string
(如果其中不含逗号的话)。
由于我们将成员函数 crbegin
和 crend
返回的迭代器传递给 find
, find
将从line的最后一个字符开始向前搜索。当 find
完成后,如果line中有逗号,则rcomma指向最后一个逗号——即,它指向反向搜索中找到的第一个逗号。如果line中没有逗号,则rcomma指向 line.crend()
但我们试图打印通过找到的单词时,看起来下面的代码是显然的方法:
// 错误:将逆序输出单词的字符
cout << string(line.crbegin(), rcomma) << endl;
但它会生成错误的输出结果。例如,如果我们的输入是
FIRST,MIDOLE,LAST
则这条语句会打印 TSAL 。
下图说明了问题所在:
我们使用的是反向迭代器,会反向出来 string
。因此,上述输出语句从 crbegin
返回的迭代器指向的元素开始反向打印line中内容。而我们希望按正常顺序打印从rcomma开始到line末尾间的字符。但是,我们不能直接使用rcomma。因为它是一个反向迭代器,意味着它会反向朝着 string
的开始位置移动。需要做的是将rcomma转换回一个普通迭代器以在line中正向移动。通过调用 reverse_iterator
(逆序遍历的迭代器适配器,即反向迭代器) 的 base
成员函数来返回其对应的底层迭代器(也就是反向迭代器实现的基础——普通迭代器):
//正确:得到一个正向迭代器,从逗号开始读取字符直到line末尾
cout << string(rcomma.base(), line.cend()) << endl;
给定和之前一样的输入,这条语句会打印预期的输出 LAST 。
上图的对象显示了普通迭代器与反向迭代器之间的关系。例如, rcomma 和 rcomma.base() 指向了不同的元素, line.crbegin() 和 line.cend() 也是如此。这些不同保证了元素范围无论是正向处理还是反向处理都是相同的。
从技术上讲,普通迭代器与反向迭代器的关系反映了左闭合区间 (参见9.2.1)的特征。关键点在于 [line.crbegin(), rcomma) 和 [rcomma.base(), line.cend()) 指向line中相同的元素范围。为了实现这一点,rcomma 和 rcomma.base() 必须生成相邻位置而不是相同位置, crbegin() 和 cend() 也是如此。
Note:
10.5 泛型算法结构
任何标准库算法函数的最基本的特性是它要求其迭代器提供哪些操作。 某些标准库算法,如 find,只要求通过迭代器访问元素、递增迭代器以及比较两个迭代器是否相等这些能力。其他一些标准库算法,如 sort
,还要求读、写和随机访问元素的能力。算法所要求的迭代器操作可以分为5个迭代器类别(iterator category)(C++17后为6个类别)。
如下表所示,每个标准库算法都会对它的每个迭代器参数指明须提供哪类迭代器。
迭代器类别(C++17前) | |
---|---|
输入迭代器 | 只读,不写;单遍扫描,只能递增 |
输出迭代器 | 只写,不读;单遍扫描,只能递增 |
前向迭代器 | 可读写;多遍扫描,只能递增 |
双向迭代器 | 可读写;多遍扫描,既能递增,又能递减 |
随机访问迭代器 | 可读写,多遍扫描,支持全部迭代器运算可读写,多遍扫描,支持全部迭代器运算 |
C++标准中的划分:
第二种标准库算法分类的方式 是按照是否读、写或是重排序列中的元素来分类。附录A按这种分类方法列出了所有算法。
标准库算法还共享一组参数传递规范和一组命名规范 ,将在后面介绍这些内容。
10.5.1 5类迭代器
类似容器,迭代器也定义了一组公共操作。一些操作所有迭代器都支持,另外一些只有特定类别的迭代器才支持。例如, ostream_iterator
只支持递增、解引用和赋值。而标准库容器 vector
、 string
和 deque
的迭代器除了这些操作外,还支持递减、关系和算术运算。
迭代器是按它们所提供的操作来分类的,而这种分类形成了一种层次。除了输出迭代器之外,一个高层类别的迭代器支持低层类别迭代器的所有操作。
C++标准指明了泛型和数值算法的每个迭代器参数的最小类别。 例如, find
算法在一个序列上进行一遍扫描,对元素进行只读操作,因此至少需要输入迭代器。 replace
函数需要一对迭代器,至少是前向迭代器。类似的, replace_copy
的前两个迭代器参数也要求至少是前向迭代器。其第三个迭代器表示目的位置,必须至少是输出迭代器。其他的例子类似。对每个迭代器参数来说,其能力必须与规定的最小类别至少相当。向算法传递一个能力更差的迭代器会产生错误。
WARNING:
迭代器类别
输入迭代器(input iterator) :可以读取序列中的元素。一个输入迭代器必须支持
输入迭代器只用于顺序访问。对于一个输入迭代器it, *it++
保证是有效的,但递增它可能导致所有其他指向流的迭代器失效。其结果就是,不能保证输入迭代器的状态可以保存下来并用来访问元素。因此,输入迭代器只能用于单遍扫描算法。标准库算法 find
和 accumulate
要求输入迭代器;而istream_iterator是一种输入迭代器。
输出迭代器(output iterator) :可以看作输入迭代器功能上的补集——只写而不读元素。输出迭代器必须支持
我们只能向一个输出迭代器赋值一次。类似输入迭代器,输出迭代器只能用于单遍扫描算法。用作目的位置的迭代器通常都是输出迭代器。例如, copy
函数的第三个参数就是输出迭代器。 ostream_iterator
类型也是输出迭代器。
前向迭代器(forward iterator) :可以读写元素。这类迭代器只能在序列中沿一个方向移动。前向迭代器支持所有输入和输出迭代器的操作,而且可以多次读写同一个元素。因此,我们可以保存前向迭代器的状态,使用前向迭代器的算法可以对序列进行多遍扫描。算法 replace
要求前向迭代器,而 forward_list
的迭代器是前向迭代器。
双向迭代器(bidirectional iterator) :可以正向/反向读写序列中的元素。除了支持所有前向迭代器的操作之外,双向迭代器还支持前置和后置递减运算符(–)。算法 reverse
要求双向迭代器,除了 forward_list
之外,其他标准库都提供符合双向迭代器要求的迭代器。
随机访问迭代器(random-access iterator) :提供在常量时间内访问序列中任意元素的能力。此类迭代器支持双向迭代器的所有功能,此外还支持如下操作:
算法 sort
要求随机访问迭代器。 array
、 deque
、 string
和 vector
的迭代器都是随机访问迭代器,用于访问内置数组元素的指针也是。
标准库顺序容器的迭代器类别:
10.5.2 标准库算法形参模式
在任何其他算法分类之上,还有一组参数规范。理解这些参数规范对学习新算法很有帮助——通过理解参数的含义,你可以将注意力集中在算法所做的操作上。大多数标准库算法具有如下4种形式之一:
alg(beg, end, other args);
alg(beg, end, dest, other args); // dest
alg(beg, end, beg2, other args);
alg(beg, end, beg2, end2, other args);
其中 alg 是算法的名字,beg 和 end 表示算法所操作的输入范围。几乎所有标准库算法都接受一个输入范围,是否有其他参数依赖于要执行的操作。 上面列出了常见的一种—— dest 、 beg2 和 end2 ,它们都是迭代器参数。如果用到了这些迭代器参数,它们分别承担指定目的位置和第二个范围的角色。除了这些迭代器参数,一些算法还接受额外的、非迭代器的特定参数。
接受单个目标迭代器的算法
dest 参数是一个表示算法可以写入的目的位置的迭代器。标准库算法假定(assume):按其需要写入数据,不管写入多少个元素都是安全的。
WARNING:
特殊的是:
接受第二个输入序列的算法
接受单独的 beg2 或是接受 beg2 和 end2 的标准库算法用这些迭代器表示第二个输入范围。 这些算法通常使用第二个范围中的元素与第一个输入范围结合来进行一些运算。
如果一个算法接受 beg2 和 end2 ,则这两个迭代器表示第二个范围。这类算法接受两个完整指定的左闭合区间范围:[beg, end)
表示的范围和 [beg2, end2)
表示的第二个范围。
只接受单独的 beg2 (不接受 end2 )的算法将 beg2 作为第二个输入范围中的首元素。此范围的结束位置未指定,这些算法假定从 beg2 开始的范围与 beg 和 end 所表示的第一个范围至少一样大。
WARNING:
10.5.3 标准库算法命名规范
除了参数规范,标准库算法还遵循一套命名和重载规范。这些规范处理诸如:如何提供一个操作代替默认的 <
或 ==
运算符以及算法是将输出数据写入输入序列还是一个分离的目的位置等问题。
一些标准库算法使用重载形式传递一个谓词
接受谓词参数来代替 <
或 ==
运算符的算法,以及那些不接受额外参数的算法,通常都是重载的函数。函数的一个版本用元素类型的运算符来比较元素;另一个版本接受一个额外谓词参数,来代替 <
或 ==
:
unique(beg, end); // 使用 == 运算符比较元素
unique(beg, end, comp); // 使用给定谓词 comp 比较元素,若不是等价关系则函数行为未定义
标准库算法 unique
的两个调用都重新整理给定序列,并将连续的重复元素删除。
- 第一个调用使用元素类型的
==
运算符来检查重复元素; - 第二个则调用 comp (comp指代任何一个谓词)来确定两个元素是否相等。
由于两个版本的函数在参数个数上不相等,因此编译器具体应该调用哪个版本不会产生歧义(参见6.4)。
_if 版本的标准库算法
接受一个元素值的算法通常有另一个不同名的(不是重载的)版本,该版本接受一个谓词代替元素值。一般情况,如果算法本身和另一个接受谓词参数的版本都接受相同数目的参数,则接受谓词参数的算法会有附加的 _if
后缀:
find(beg, end, val) ; // 查找输入范围中val第一次出现的位置 val 是一个值
find_if(beg, end, pred) ; // 查找第一个令pred为真的元素 pred 是一个谓词
这两个算法都在输入范围中查找特定元素第一次出现的位置。算法 find
查找一个指定值;算法 find_if
查找使得 pred 返回非零值的元素。
这两个算法提供了命名上差异的版本,而非重载版本,因为两个版本的算法都接受相同数目的参数。因此可能产生重载歧义,虽然很罕见,但为了避免任何可能的歧义,标准库选择提供不同名字的版本而不是重载。
区分拷贝元素的版本和不拷贝的版本
默认情况下 ,重排元素的算法将重排后的元素写回给定的输入序列中。这些算法还提供另一个版本,将元素写到一个指定的输出目的位置。如下面例子一样,将值写到额外目的空间的算法的名字都附加了一个 _copy
后缀 :
reverse(beg, end); //反转输入范围中元素的顺序
reverse_copy(beg, end, dest);//将元素按逆序拷贝到dest
一些标准库算法同时提供 _copy
和 _if
版本。这些版本都接受一个目的位置迭代器和一个谓词:
//从v1中删除奇数元素
remove_if(v1.begin(), v1.end(), [](int i) { return i % 2; });
//将偶数元素从v1拷贝到v2; v1不变
remove_copy_if(v1.begin(), v1.end(), back_inserter(v2) , [](int i){ return i % 2; });
两个算法都调用了 lambda 表达式来确定元素是否为奇数。
- 在第一个调用中,
remove_if
从输入序列中将奇数元素删除。 - 在第二个调用中,
remove_copy_if
将偶数元素从输入范围拷贝到v2中。
10.6 特定容器算法
与其他容器不同,链表类型 list
和 forward_list
定义了几个成员函数形式的算法。特别是,它们定义了独有的 sort 、 merge 、 remove 、 reverse 和 unique 。通用版本标准库算法的 sort
要求随机访问迭代器,因此不能用于 list
和 forward_list
,因为这两个类型分别提供双向迭代器和前向迭代器。
链表类型定义的其他算法的通用版本可以用于链表,但代价太高。这些算法需要交换输入序列中的元素。 一个链表可以通过改变元素间的链接而不是真的交换它们的值来快速“交换”元素 。因此,这些链表版本的算法的性能比对应的通用版本好得多。
Best Practices:对于 list
和 forward_list
,应该优先使用成员函数版本的算法而不是通用算法。
list 和 forward_list 成员函数版本的算法 | |
---|---|
下面操作都返回 void | |
lst.merge(lst2) | 将来自 lst2 的元素合并入 lst 。 lst 和 lst2 都必须是有序的。 |
lst.merge(lst2, comp) | 元素将从 lst2 中删除。在合并之后, lst2 变为空。第一个版本使用 < 运算符;第二个版本使用给定的比较操作谓词 |
lst.remove(val) | 通过调用成员函数 erase ,删除掉与给定值相等(==)或 令一元谓词pred返回值为真 的每个元素 |
lst.remove_if(pred) | |
lst.reverse() | 反转 lst 中元素的顺序 |
lst.sort() | 使用 < 或 给定比较操作谓词 排序元素 |
lst.sort(comp) | |
lst.unique() | 调用成员函数 erase 删除同一个值的连续拷贝。第一个版本使用 == ;第二个版本使用给定的二元谓词 |
lst.unique(pred) |
splice成员
链表类型还定义了 splice
算法,它是一个成员函数,此算法是链表数据结构所特有的,因此不需要通用版本。
list
和 forward_list
的 splice
成员函数的参数:
lst.splice(args) 或 flst.splice_after(args)
list 和 forward_list 成员函数版本的算法 | |
---|---|
lst.splice(args) 或 flst.splice_after(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 不能指向给定范围中元素 |
链表特有的操作会改变容器
多数链表特有的算法都与其通用版本很相似,但不完全相同。链表特有版本与通用版本间的一个至关重要的区别是——链表版本会改变底层的容器。 例如,
remove
的链表版本会删除指定的元素。unique
的链表版本会删除第二个和后继的重复元素。
类似的,链表特有的成员函数版本算法 merge
和 splice
会销毁其参数。例如,
- 通用版本的
merge
将合并的序列写到一个给定的目的迭代器;两个输入序列是不变的。 - 而链表版本的
merge
成员函数会销毁给定的链表——元素从参数指定的链表中删除,被合并到调用merge
的链表对象中。在merge
调用后之后,来自两个链表中的元素仍然存在,但它们都已在同一个链表中。