《C++17 STL Cookbook》将结合C++代码实例和标准库(STL),教会你如何充分使用C++17。这里要说明的是,本书会尽可能的去使用STL,从而教会大家使用C++17。
C++是一门伟大且具有力量的语言。它使用简单的高级接口,将隐藏复杂的解决方式隐藏于背后,不过这样就意味着需要编写高效和低开销的底层代码实现。国际标准化组织(ISO)C++标准委员会致力于改进C++标准。C++11标准为C++带来了大量不错的特性,C++14和C++17也为C++加入了些新的特性。
目前为止,C++作为一门编程语言提供了语言相应的语言特性个标准库工具,用于处理复杂的标准数据结构和算法,包括:智能指针、Lambda表达式、常量表达式、便捷式可控线程的并发编程、正则表达式、随机数发生器、异常、可变参数模板(C++的部分模板类是图灵完备的!)、自定义文字、便捷式文件系统遍历等等。这些功能使它成为一种通用的语言,并在软件行业的所有领域,用于实现高质量和高性能的软件。
不过,很多编程者只将C++当做一门编程语言学习,而不太重视标准库(STL)的使用。不使用C++所带的标准库,将会让C++看起来就像是具有class的C语言,21世纪的现代化程序不应该写成这样。并且,这样的使用令人沮丧,就像是卸掉了它的一条手臂一样。
Bjarne Stroustrup(C++之父)在他的《C++程序设计语言》(C++11版本)中写到
请牢记,标准库和语言功能都是为了支撑以软件质量为目标的编程技术。他们应被组合起来发挥作用——如同建房子的砖块——而非个别地采用来相对孤立地去解决某个特定问题。
这段话能很明确的概括我写这本书的目的。本书的所有例子都与实际息息相关,处理这些问题时,只依赖与STL,不依赖其他的库。少了其他库的依赖,就能很容易的将程序运行起来,不必去为开发环境所困扰。 我希望你们受这些例子的启发,找到使用标准库的灵感,用伟大的编程语言作为解决更高级问题的基石。
本书中所有的例子都很简单,都可以很容易编译和运行,不过读者们还是需要注意一下自己所选择的操作系统和编译器。下面就让我们来看一下在编译和运行本书例程时,所要注意的一些内容。
本书的所有例子都在Linux和Mac OS进行开发和验证,我们使用GNU的C++编译器g++,和LLVM的C++编译器 clang++。
shell环境下可以使用如下的命令使用g++编译例程:
$ g++ -std c++lz —o recipe_app recipe_code.cpp
要使用clang++的话,命令行类似:
$ clang++ -std C++ Iz -o recipe_app recipe_code.cpp
上面两个例子都假设我们的C++例程写在 recipe_code.cpp
文件中。完成编译后,生成可执行二进制文件recipe_app
,然后使用如下命令执行它:
$ . /recipe_app
书中很多例子,都是通过标准输入读取整个文件的内容。遇到这样的例子时,我们使用标准UNIX管道和cat
命令直接将文件内容传输给我们的应用,命令如下所示:
$ cat file.txt | ./recipe_app
上面的方法适用于Linux和Mac OS系统。在微软Windows Shell中,需要使用如下的命令:
> recipe app.exe < file.txt
如果你不想在Shell命令行中运行,你可以在Microsoft Visual Studio IDE中运行,不过需要你修改一下配置, "Configuration properties > Debugging "
,并且添加"< file. txt"
,使用 Visual Studio加载应用就能直接运行程序了。(Visual Studio IDE的话选定对应的解决方案,右键后选择“属性”,在“调试”页面输入相应的命令行参数)
如果最近你阅读了本书中C++17的新特性,并使用前卫的编译器编译了这些代码,你可能会在编译阶段遇到一些问题。因为你使用到的一些C++17 STL新特性可能还没有在编译器中进行实现。
运行本书代码时,需要给<execution_policy>
和<filesystem>
头文件添加前缀experimental/
。其会将你将是用到的一些STL算法、数值等等包含入你的代码中,不过这也取决于编译器标准库的更新程度和稳定性。
这同样使用于命名空间的新特性。标准库中,实验部分的实现并不在std
命名空间中,而是在std::experimental
中。
如果你没有编写过C++程序的经验,那么请将本书放回书架。如果你只想学习有关语言基础的知识,那么本书不是你理想的选择。当你了解完语言基础后,本书会对你的语言技巧进行升级。
除此之外,如果你符合如下的描述的话,可以继续阅读本书:
以上这些描述,都是基于你使用C++的频度而论。本书储备了很多优秀的STL新特性,等待你去发现。
本书中你会发现几个经常出现的标题:
(译者:这些副标题只在本节翻译,正文中使用英文原文作为副标题)
下面简单介绍一些这几个副标题所涵盖的内容:
本节会说明我们的期望,以及如何在初期对环境或软件进行配置。
本节包含实现所需的步骤。
本节会对前一节所发生的事情,进行详细解释。
本节包含了一些式例相关的补充信息,以便读者对式例有更深入的了解。
为式例提供一些帮助链接,有助于了解C++的更多知识。
本书中,使用不同的文本样式区分不同种类的信息。下面的一些例子会解释这些风格的含义。
文本的代码,数据库表名,文件夹名,文件名,文件的扩展名,路径名,虚拟的URL,用户输入和推特引用,会展示成这种样式: "下一步需要修改build.properties
文件。"
代码块为这种样式:
my_wrapper<T1, T2, T3> make wrapper (Tl t 1, T 2 t2, T3 t3) { return t 1, t2, t3; }
新术语和关键字使用粗体。你在屏幕上看到的单词,例如菜单或对话框,会是这种样式: "完成后,点击执行。"
警告或重要说明会显示在一个方框中。
提示和技巧会用斜体样式
我们欢迎读者的反馈。这样我们就知道这本书哪里好,哪里不好。读者的反馈对于我们来说十分重要,并且能帮助我们确定读者关注的重点,从而让读者在阅读本书时的收获最大化。一般的反馈可以通过发送电子邮件到 feedback@packtpub.com
,并在主题中提到这本书的名字即可。如果您是某个方便的专家,并且对写作或写书感兴趣的话, 可以了解一下我们的作者指南www.packtpub.com/authors 。
现在您已经是本书的主人,我们会为您购买本书的行为,提供相应的支持服务。
可使用您在 http://www.packtpub.com 的账号下载本书式例代码。 如果您在别处购买了本书,可以通过访问 http://wmv.packtpub.com/support ,客服会将注册文件直接发送给您。
您可以按照以下步骤下载代码:
本书代码github的托管地址为 https://github.com/PacktPublishing/Cpp17-STL-Cookbook
其他书籍的代码包和视频目录在 https://github.com/PacktPublishing/ 下都能看到。
快去看一下吧!
尽管我们很认真的保证本书内容的正确性,但难免还是会出现错误。如果您在我们的书或代码中发现了疑似错误的地方,请反馈给我们,我们将感激不尽。如果这真是个错误,我们将在后续的版本中修复这个问题,以免误导更多的读者。如果您发现了任何错误,请访问 http://mvw.packtpub.com/submit-errata 选择本书,点击勘误提交的链接,然后详述你发现的问题。当您的勘误得到了验证,您的勘误将会记录在我们的勘误列表上。
想要了解之前的勘误列表, 可以在 https://mwv.packtpub.com/books/content/support 上面输入书籍的名字查找对应的勘误列表。想要看到的内容将会出现在勘误栏下。
互联网上存在着盗版问题。Packt非常重视我们的版权和许可证。如果您在网上发现我们的作品的非法副本,请提供地址或网站名称,以便我们进行维权。
请通过 copyright@packtpub.com
联系我们,麻烦在邮件内附上与涉嫌盗版的相关资料。感谢您帮助我们保护相关作品的只是产权。
如果您对本书有任何的问题,您可以通过向 questions@packtpub.com
发送邮件告诉我们,我们会尽可能的解答您所提出的问题。
第1章,C++17新特性。介绍那些对C++语言来说很重大的改变,以便后续的章节中将精力集中在STL上。
第2章,STL容器。STL容器在C++17标准中进行了升级,让我们见识一下STL容器的数据类型是多么的丰富。粗略的了解一下容器后,再仔细了解其添加的内容。
第3章,迭代器。迭代器是STL中很重要的概念,其将STL算法和容器数据类型二者紧密联系在一起。我们将用实际例子来了解如何使用迭代器,从而更好的了解迭代器的概念。
第4章,Lambda表达式。这是一种很有意思的编程模式,其为纯函数式编程的方式。C++11标准引入Lambda表达式,C++14和C++17标准为其添加了一些新特性。
第5章,STL基础算法。介绍了STL的标准算法的特点,简单易用、高效、鲁棒性好和高度通用。我们将学习如何使用它们,这样就可以集中精力在解决问题上,而不是浪费时间去重新发明轮子。
第6章,STL算法的高级使用方式。演示如何通过使用STL基本算法,以更简洁的方式编写更复杂的算法,而无需重复代码。本章中,充分利用STL解决更复杂问题的同时,将学习如何结合现有的算法,来创建真正符合需求的新算法。
第7章,字符串,流和正则表达。对STL中关于字符串、通用I/O流和正则表达式的类型进行详细概述。
第8章,工具类。了解STL如何生成随机数、测量时间、管理动态内存、优雅地提示错误等等。我们会来了解一下这些极为有用、高可移植性的工具类,并且会介绍C++17带来的全新STL工具。
第9章,并行和并发。多处理器领域编写代码时,并行和并发就变得很重要。C++11标准首先引入并行和并发的概念,随后C++17进行加强,这对于我们编写并发程序来说有很大的帮助。
第10章,文件系统。虽然之前的STL提供对单个文件读取和操作,但这还无法达到用户的需求。C++17添加了很多新的操作(独立于操作系统库)用于处理文件系统路径,以及对目录进行遍历。
C++11,C++14和C++17标准为C++添加了许多新特性。当前的C++已经和10年前的C++完全不同了。C++标准并不是用来规范语言的,其实为了让相应编译器理解相应的语义,也是为了更好的理解C++标准模板库(STL)。
这本书中的例子展示了如何充分的利用STL。不过,作为本书的第1章,我们还是需要了解一下那些比较重要的新语言特性。掌握了这些新的语言特性,有助于你编写可读性高、可维护性强和表达性清晰的代码。
我们将了解到如何单独访问组对、元组和结构化绑定的数据结构的成员,以及如何使用新的if
和switch
限制变量的作用范围。新的括号初始化语法于C++11的语法有歧义,虽然看上去是相同的,不过这个已经被新括号初始化规则所修复。模板类实例的类型现在可以从构造函数的参数中自动推断出来,如果对一个模板类进行不同类型的特化,将会产生完全不同的代码,不过现在用constexpr-if
就能很容易的表示。大多数情况下,使用折叠表达式处理模板函数的可变参数包,会变得更加容易。最后,在只有头文件的库中使用声明内联变量,来定义全局静态对象会变得更加舒服,这之前的标准中只能在函数中进行。
库的实现者可能比实现应用程序的开发者对本章的示例更感兴趣。虽然我们有足够的理由去了解这些特性,但为了理解本书的其余部分,无需立即理解本章的所有示例。
C++17配备了一种新的特性——结构化绑定,其可以结合语法糖来自动推到类型,并可以从组对、元组和结构体中提取单独的变量。其他编程语言中,这种特性也被成为解包。
使用结构化绑定是为了能够更加简单的,为绑定了多个变量的结构体进行赋值。我们先来看下在C++17标准之前是如何完成这个功能的。然后,我们将会看到一些使用C++17实现该功能的例子:
访问std::pair
中的一个元素:假设我们有一个数学函数divide_remainder
,需要输入一个除数和一个被除数作为参数,返回得到的分数的整数部分和余数。可以使用一个std::pair
来绑定这两个值:
std::pair<int, int> divide_remainder(int dividend, int divisor);
考虑使用如下的方式访问组对中的单个值:
const auto result (divide_remainder(16, 3)); std::cout << "16 / 3 is " << << result.first << " with a remainder of " << result.second << '\n';
与上面的代码段不同,我们现在可以将相应的值赋予对应的变量,这样写出来的代码可读性更高:
auto [fraction, remainder] = divide_remainder(16, 3); std::cout << "16 / 3 is " << fraction << " with a remainder of " << remainder << '\n';
std::tuple
进行结构化绑定:让我们使用下面的实例函数,获取股票的在线信息:std::tuple<std::string, std::chrono::system_clock::time_point, unsigned> stock_info(const std::string &name);
我们可以使用如下的方式获取这个例子的各个变量的值:
const auto [name, valid_time, price] = stock_info("INTC");
struct employee{ unsigned id; std::string name; std::string role; unsigned salary; };
现在我们来看下如何使用结构化绑定访问每一个成员。我们假设有一组employee
结构体的实例,存在于vector
中,下面使用循环将其内容进行打印:
int main(){ std::vector<employee> employees{ /* Initialized from somewhere */ }; for (const auto &[id, name, role, salary] : employees){ std::cout << "Name: " << name << "Role: " << role << "Salary: " << salary << '\n'; } }
结构化绑定以以下方式进行应用:
auto [var1, var2, ...] = <pair, tuple, struct, or array expression>;
var1, var2, ...
表示一个变量列表,其变量数量必须匹配表达式所对应的结构。<pair, tuple, struct, or array expression>
必须是下面的其中一种:
std::pair
实例。std::tuple
实例。auto
部分,也就是var
的类型,可以是auto
,const auto
,const auto&
和auto&&
。Note:
不仅为了性能,还必须确保在适当的时刻使用引用,尽量减少不必要的副本。
如果中括号中变量不够,那么编译器将会报错:
std::tuple<int, float, long> tup(1, 2.0, 3); auto [a, b] = tup; // Does not work
这个例子中想要将三个成员值,只赋予两个变量。编译器会立即发现这个错误,并且提示我们:
error: type 'std::tuple<int, float, long>' decomposes into 3 elements, but only 2 names were provided
auto [a, b] = tup;
STL中的基础数据结构都能通过结构结构化绑定直接进行访问,而无需修改任何东西。考虑下面这个例子,循环中打印std::map
中的元素:
std::map<std::string, size_t> animal_population { {"humans", 7000000000}, {"chickens", 17863376000}, {"camels", 24246291}, {"sheep", 1086881528}, /* ... */ }; for (const auto &[species, count] : animal_population) { std::cout << "There are " << count << " " << species << " on this planet.\n"; }
从std::map
容器中获取元素的方式比较特殊,我们会在每次迭代时获得一个std::pair<const key_type, value_type>
实例。另外每个实例都需要进行结构化绑定(key_type
绑定到species
字符串上,value_type
为一个size_t
格式的统计数字),从而达到访问每一个成员的目的。
在C++17之前,使用std::tie
可达到类似的效果:
int remainder; std::tie(std::ignore, remainder) = divide_remainder(16, 5); std::cout << "16 % 5 is " << remainder << '\n';
这个例子展示了如何将结果组对解压到两个变量中。std::tie
的能力远没有结构化绑定强,因为在进行赋值的时候,所有变量需要提前定义。另外,本例也展示了一种在std::tie
中有,而结构化绑定没有的功能:可以使用std::ignore
的值,作为虚拟变量。分数部分将会赋予到这个虚拟变量中,因为这里我们不需要用到分数值,所以使用虚拟变量忽略分数值。
Note:
使用结构化绑定时,就不能再使用std::tie创建虚拟变量了,所以我们不得不绑定所有值到命名过的变量上。对部分成员进行绑定的做法是高效的,因为编译器可以很容易的对未绑定的变量进行优化。
回到之前的例子,divide_remainder
函数也可以通过使用传入输出参数的方式进行实现:
bool divide_remainder(int dividend, int divisor, int &fraction, int &remainder);
调用该函数的方式如下所示:
int fraction, remainder; const bool success {divide_remainder(16, 3, fraction, remainder)}; if (success) { std::cout << "16 / 3 is " << fraction << " with a remainder of " << remainder << '\n'; }
很多人都很喜欢使用特别复杂的结构,比如组对、元组和结构体,他们认为这样避免了中间拷贝过程,所以代码会更快。对于现代编译器来说,这种想法不再是正确的了,这里编译器并没有刻意避免拷贝过程,而是优化了这个过程。(其实拷贝过程还是存在的)。
Note:
与C的语法特征不同,将复杂结构体作为返回值传回会耗费大量的时间,因为对象需要在返回函数中进行初始化,之后将这个对象拷贝到相应容器中返回给调用端。现代编译器支持**返回值优化**(RVO, return value optimization)技术,这项技术可以省略中间副本的拷贝。
将变量的生命周期尽可能的限制在指定区域内,是一种非常好的代码风格。有时我们需要在满足某些条件时获得某个值,然后对这个值进行操作。
为了让这个过程更简单,C++17中为if和switch配备了初始化区域。
这个案例中,我们使用初始化语句,来了解下其使用方式:
if
:假设我们要在一个字母表中查找一个字母,我们std::map
的成员find
完成这个操作:if (auto itr (character_map.find(c)); itr != character_map.end()) { // *itr is valid. Do something with it. } else { // itr is the end-iterator. Don't dereference. } // itr is not available here at all
switch
:这个例子看起来像是从玩家输入的字母决定某个游戏中的行为。通过使用switch
查找字母相对应的操作:switch (char c (getchar()); c) { case 'a': move_left(); break; case 's': move_back(); break; case 'w': move_fwd(); break; case 'd': move_right(); break; case 'q': quit_game(); break; case '0'...'9': select_tool('0' - c); break; default: std::cout << "invalid input: " << c << '\n'; }
带有初始化的if
和switch
相当于语法糖一样。
// if: before C++17 { auto var(init_value); if (condition){ // branch A. var is accessible } else { // branch B. var is accessible } // var is still accessible }
// if: since C++17 if (auto var (init_value); condition){ // branch A. var is accessible } else { // branch B. var is accessible } // var is not accessible any longer
// switch: before C++17 { auto var (init_value); switch (var) { case 1: ... case 2: ... ... } // var is still accessible }
// switch: since C++17 switch(auto var (init_value); var){ case 1: ... case 2: ... ... } // var is not accessible any longer
这些有用的特性保证了代码的简洁性。C++17之前只能使用外部括号将代码包围,就像上面的例子中展示的那样。减短变量的生命周期,能帮助我们保持代码的整洁性,并且更加易于重构。
另一个有趣的例子是临界区限定变量生命周期。
先来看个栗子:
if (std::lock_guard<std::mutex> lg {my_mutex}; some_condition) { // Do something }
首先,创建一个std::lock_guard
。这个类接收一个互斥量和作为其构造函数的参数。这个类在其构造函数中对互斥量上锁,之后当代码运行完这段区域后,其会在析构函数中对互斥量进行解锁。这种方式避免了忘记解锁互斥量而导致的错误。C++17之前,为了确定解锁的范围,需要一对额外的括号对。
另一个例子中对弱指针进行区域限制:
if (auto shared_pointer (weak_pointer.lock()); shared_pointer != nullptr) { // Yes, the shared object does still exist } else { // shared_pointer var is accessible, but a null pointer } // shared_pointer is not accessible any longer
这个例子中有一个临时的shared_pointer
变量,虽然if
条件块或外部括号会让其保持一个无用的状态,但是这个变量确实会“泄漏”到当前范围内。
当要使用传统API的输出参数时,if
初始化段就很有用:
if (DWORD exit_code; GetExitCodeProcess(process_handle, &exit_code)) { std::cout << "Exit code of process was: " << exit_code << '\n'; } // No useless exit_code variable outside the if-conditional
GetExitCodeProcess
函数是Windows操作系统的内核API函数。其通过返回码来判断给定的进程是否合法的处理完成。当离开条件域,变量就没用了,也就可以销毁这个变量了。
具有初始化段的if
代码块在很多情况下都特别有用,尤其是在使用传统API的输出参数进行初始化时。
Note:
使用带有初始化段的
if
和switch
能保证代码的紧凑性。这使您的代码紧凑,更易于阅读,在重构过程中,会更容易改动。
C++11引入了新的括号初始化语法{}
。其不仅允许集合式初始化,而且还是对常规构造函数的调用。遗憾的是,当与auto
类型变量结合时,这种方式就很容易出现错误。C++17将会增强这一系列初始化规则。本节中,我们将了解到如何使用C++17语法正确的初始化变量。
一步初始化所有变量。使用初始化语法时,注意两种不同的情况:
// Three identical ways to initialize an int: int x1 = 1; int x2{1}; int x3(1); std::vector<int> v1{1, 2, 3}; // Vector with three ints std::vector<int> v2 = {1, 2, 3}; // same here std::vector<int> v3(10, 20); // Vector with 10 ints, each have value 20
auto v {1}; // v is int auto w {1, 2}; // error: only single elements in direct // auto initialization allowed! (this is new) auto x = {1}; // x is std::initializer_list<int> auto y = {1, 2}; // y is std::initializer_list<int> auto z = {1, 2, 3.0}; // error: Cannot deduce element type
无auto
类型声明时,{}
的操作没什么可大惊小怪的。当在初始化STL容器时,例如std::vector
,std::list
等等,括号初始化就会去匹配std::initializer_list
(初始化列表)的构造函数,从而初始化容器。其构造函数会使用一种“贪婪”的方式,这种方式就意味着不可能匹配非聚合构造函数(与接受初始化列表的构造函数相比,非聚合构造函数是常用构造函数)。
std::vector
就提供了一个特定的非聚合构造函数,其会使用任意个相同的数值填充vector
容器:std::vector<int> v(N, value)
。当写成std::vector<int> v{N, value}
时,就选择使用initializer_list
的构造函数进行初始化,其会将vector
初始化成只有N和value两个元素的变量。这个“陷阱”大家应该都知道。
{}
与()
调用构造函数初始化的方式,不同点在于{}
没有类型的隐式转换,比如int x(1.2);
和int x = 1.2;
通过静默的对浮点值进行向下取整,然后将其转换为整型,从而将x的值初始化为1。相反的,int x{1.2};
将会遇到编译错误,初始化列表中的初始值,需要与变量声明的类型完全匹配。
Note:
哪种方式是最好的初始化方式,目前业界是有争议的。括号初始化的粉丝们提出,使用括号的方式非常直观,直接可以调用构造函数对变量进行初始化,并且代码行不会做多于的事情。另外,使用{}括号将会是匹配构造函数的唯一选择,这是因为使用()进行初始化时,会尝试匹配最符合条件的构造函数,并且还会对初始值进行类型转换,然后进行匹配(这就会有处理构造函数二义性的麻烦)。
C++17添加的条件也适用于auto(推断类型)——C++11引入,用于正确的推导匹配变量的类型。auto x{123};
中std::initializer_list<int>
中只有 一个元素,这并不是我们想要的结果。C++17将会生成一个对应的整型值。
经验法则:
auto var_name {one_element};
将会推导出var_name的类型——与one_element一样。auto var_name {element1, element2, ...};
是非法的,并且无法通过编译。auto var_name = {element1, element2, ...};
将会使用std::initializer_list<T>
进行初始化,列表中elementN变量的类型均为T。C++17加强了初始化列表的鲁棒性。
Note:
使用C++11/C++14模式的编译器解决这个问题时,有些编译器会将
auto x{123};
的类型推导成整型,而另外一些则会推导成std::initializer_list<int>
。所以,这里需要特别注意,编写这样的代码,可能会导致有关可移植性的问题!
C++中很多类都需要指定类型,其实这个类型可以从用户所调用的构造函数中推导出来。不过,在C++17之前,这是一个未标准化的特性。C++17能让编译器自动的从所调用的构造函数,推导出模板类型。
使用最简单的方法创建std::pair
和std::tuple
实例。其可以实现一步创建。
std::pair my_pair (123, "abc"); // std::pair<int, const char*> std::tuple my_tuple (123, 12.3, "abc"); // std::tuple<int, double, const char*>
让我们定义一个类,了解自动化的对模板类型进行推断的价值。
template <typename T1, typename T2, typename T3> class my_wrapper { T1 t1; T2 t2; T3 t3; public: explicit my_wrapper(T1 t1_, T2 t2_, T3 t3_) : t1{t1_}, t2{t2_}, t3{t3_} {} /* ... */ };
好!我们定义了一个模板类。C++17之前,我们为了创建该类的实例:
my_wrapper<int, double, const char *> wrapper {123, 1.23, "abc"};
我们省略模板特化的部分:
my_wrapper wrapper {123, 1.23, "abc"};
C++17之前,我们可能会通过以下的方式实现一个工厂函数:
my_wrapper<T1, T2, T3> make_wrapper(T1 t1, T2 t2, T3 t3) { return {t1, t2, t3}; }
使用工厂函数:
auto wrapper (make_wrapper(123, 1.23, "abc"));
Note:
STL中有很多工厂函数,比如
std::make_shared
、std::make_unique
、std::make_tuple
等等。C++17中,这些工厂函数就过时了。当然,考虑到兼容性,这些工厂函数在之后还会保留。
我们已经了解过隐式模板类型推导。但一些例子中,不能依赖类型推导。如下面的例子:
// example class template <typename T> struct sum{ T value; template <typename ... Ts> sum(Ts&& ... values) : value{(values + ...)} {} };
结构体中,sum
能接受任意数量的参数,并使用折叠表达式将它们添加到一起(本章稍后的一节中,我们将讨论折叠表达式,以便了解折叠表达式的更多细节)。加法操作后得到的结果保存在value
变量中。现在的问题是,T
的类型是什么?如果我们不显式的进行指定,那就需要通过传递给构造函数的变量类型进行推导。当我们提供了多个字符串实例,其类型为std::string
。当我们提供多个整型时,其类型就为int
。当我们提供多个整型、浮点和双浮点时,编译器会确定哪种类型适合所有的值,而不丢失信息。为了实现以上的推导,我们提供了指导性显式推导:
template <typename ... Ts> sum(Ts&& ... ts) -> sum<std::common_type_t<Ts...>>;
指导性推导会告诉编译器使用std::common_type_t
的特性,其能找到适合所有值的共同类型。来看下如何使用:
sum s {1u, 2.0, 3, 4.0f}; sum string_sum {std::string{"abc"}, "def"}; std::cout << s.value << '\n' << string_sum.value << '\n';
第1行中,我们创建了一个sum
对象,构造函数的参数类型为unsigned
, double
, int
和floa
t。std::common_type_t
将返回double
作为共同类型,所以我们获得的是一个sun<double>
实例。第2行中,我们创建了一个std::string
实例和一个C风格的字符串。在我们的指导下,编译器推导出这个实例的类型为sum<std::string>
。
当我们运行这段代码时,屏幕上会打印出10和abcdef。其中10为数值sum
的值,abcdef为字符串sum
的值。
模板化编程中,通常要以不同的方式做某些事情,比如特化模板类型。C++17带了constexpr-if
表达式,可以在很多情况下简化代码。
本节中,我们会实现一个很小的辅助模板类。它能处理不同模板类型的特化,因为它可以在完全不同的代码中,选取相应的片段,依据这些片段的类型对模板进行特化:
完成代码中的通用部分。在我们的例子中,它是一个简单的类,它的成员函数add
,支持对U
类型值与T
类型值的加法:
template <typename T> class addable { T val; public: addable(T v) : val{v} {} template <typename U> T add(U x) const { return val + x; } };
假设类型T
是std::vector<something>
,而类型U
是int
。这里就有问题了,为整个vector
添加整数是为了什么呢?其应该是对vector
中的每个元素加上一个整型数。实现这个功能就需要在循环中进行:
template <typename U> T add(U x) { auto copy (val); // Get a copy of the vector member for (auto &n : copy) { n += x; } return copy; }
下一步也是最后一步,将两种方式结合在一起。如果T
类型是一个vector
,其每个元素都是U
类型,择进行循环。如果不是,则进行普通的加法:
template <typename U> T add(U x) const{ if constexpr(std::is_same<T, std::vector<U>>::value){ auto copy(val); for (auto &n : copy){ n += x; } return copy; } else { return val + x; } }
现在就可以使用这个类了。让我们来看下其对不同类型处理的是多么完美,下面的例子中有int
,float
, std::vector<int>
和std::vector<string>
:
addable<int> {1}.add(2); // is 3 addable<float> {1.f}.add(2); // is 3.0 addable<std::string> {"aa"}.add("bb"); // is "aabb" std::vector<int> v{1, 2, 3}; addable<std::vector<int>> {v}.add(10); // is std::vector<int> {11, 12, 13} std::vector<std::string> sv{"a", "b", "c"}; addable<std::vector<std::string>> {sv}.add(std::string{"z"}); // is {"az", "bz", "cz"}
新特性constexpr-if
的工作机制与传统的if-else
类似。不同点就在于前者在编译时进行判断,后者在运行时进行判断。所以,使用constexpr-if
的代码在编译完成后,程序的这一部分其实就不会有分支存在。有种方式类似于constexpr-if
,那就是#if-#else
的预编译方式进行宏替换,不过这种方式在代码的构成方面不是那么优雅。组成constexpr-if
的所有分支结构都是优雅地,没有使用分支在语义上不要求合法。
为了区分是向vector
的每个元素加上x,还是普通加法,我们使用std::is_same
来进行判断。表达式std::is_same<A, B>::value
会返回一个布尔值,当A和B为同样类型时,返回true,反之返回false。我们的例子中就写为std::is_same<T, std::vector<U>>::value()
(is_same_v = is_same<T, U>::value;
),当返回为true时,且用户指定的T为std::vector<X>
,之后试图调用add,其参数类型U = X
。
当然,在一个constexpr-if-else
代码块中,可以有多个条件(注意:a和b也可以依赖于模板参数,并不需要其为编译时常量):
if constexpr(a){ // do something } else if constexpr(b){ // do something else } else { // do something completely different }
C++17中,很多元编程的情况更容易表达和阅读。
这里对比一下C++17之前的实现和添加constexpr-if
后的实现,从而体现出这个特性的加入会给C++带来多大的提升:
template <typename T> class addable{ T val; public: addable(T v):val{v}{} template <typename U> std::enable_if_t<!std::is_same<T, std::vector<U>>::value, T> add(U x) const { return val + x; } template <typename U> std::enable_if_t<!std::is_same<T, std::vector<U>>::value, std::vector<U>> add (U x) const{ auto copy(val); for (auto &n: copy){ n += x; } return copy; } };
在没有了constexpr-if
的帮助下,这个类看起特别复杂,不像我们所期望的那样。怎么使用这个类呢?
简单来看,这里重载实现了两个完全不同的add
函数。其返回值的类型声明,让这两个函数看起里很复杂;这里有一个简化的技巧——表达式,例如std::enable_if_t<condition, type>
,如果条件为真,那么就为type
类型,反之std::enable_if_t
表达式不会做任何事。这通常被认为是一个错误,不过我们能解释为什么什么都没做。
对于第二个add
函数,相同的判断条件,但是为反向。这样,在两个实现不能同时为真。
当编译器看到具有相同名称的不同模板函数并不得不选择其中一个时,一个重要的原则就起作用了:替换失败不是错误(SFINAE, Substitution Failure is not An Error)。这个例子中,就意味着如果函数的返回值来源一个错误的模板表示,无法推断得出,这时编译器不会将这种情况视为错误(和std::enable_if
中的条件为false时的状态一样)。这样编译器就会去找函数的另外的实现。
很麻烦是吧,C++17中实现起来就变得简单多了。
这种库在声明函数时,始终是内联的,C++17中允许声明内联变量。C++17之前只能使用其他变通的方法实现内联变量,新标准的支持让实现只有头文件的库更加的容易。
本节中,我们创建一个类,可以作为典型头文件库的成员。其目的就是给定一个静态成员,然后使用inline
关键字对其进行修饰,使得其实例在全局范围内都能访问到,在C++17之前这样做是不可能的。
process_monitor
类必须包含一个静态成员,并且能全局访问。当该单元被重复包含时,会产生符号重定义的问题。
// foo_lib.hpp class process_monitor { public: static const std::string standard_string{ "some static globally available string"}; }; process_monitor global_process_monitor;
多个.cpp
文件中包含这个头文件时,链接阶段会出错。为了修复这个问题,添加了inline
关键字:
// foo_lib.hpp class process_monitor { public: static const inline std::string standard_string{ "some static globally available string"}; }; inline process_monitor global_process_monitor;
瞧,就是这样!
C++程序通常都有多个C++源文件组成(其以.cpp
或.cc
结尾)。这些文件会单独编译成模块/二进制文件(通常以.o
结尾)。链接所有模块/二进制文件形成一个单独的可执行文件,或是动态库/静态库则是编译的最后一步。
当链接器发现一个特定的符号,被定义了多次时就会报错。举个栗子,现在我们有一个函数声明int foo();
,当我们在两个模块中定义了同一个函数,那么哪一个才是正确的呢?链接器自己不能做主。这样没错,但是这也可能不是开发者想看到的。
为了能提供全局可以使用的方法,通常会在头文件中定义函数,这可以让C++的所有模块都调用头文件中函数的实现(C++中,头文件中实现的函数,编译器会隐式的使用inline来进行修饰,从而避免符号重复定义的问题)。这样就可以将函数的定义单独的放入模块中。之后,就可以安全的将这些模块文件链接在一起了。这种方式也被称为定义与单一定义规则(ODR,One Definition Rule)。看了下图或许能更好的理解这个规则:
如果这是唯一的方法,就不需要只有头文件的库了。只有头文件的库非常方便,因为只需要使用#include
语句将对应的头文件包含入C++源文件/头文件中后,就可以使用这个库了。当提供普通库时,开发者需要编写相应的编译脚本,以便连接器将库模块链接在一起,形成对应的可执行文件。这种方式对于很小的库来说是不必要的。
对于这样例子,inline
关键字就能解决不同的模块中使用同一符号采用不同实现的方式。当连接器找到多个具有相同签名的符号时,这些函数定义使用inline
进行声明,链接器就会选择首先找到的那个实现,然后认为其他符号使用的是相同的定义。所有使用inline
定义的符号都是完全相同的,对于开发者来说这应该是常识。
我们的例子中,连接器将会在每个模块中找到process_monitor::standard_string
符号,因为这些模块包含了foo_lib.hpp
。如果没有了inline
关键字,连接器将不知道选择哪个实现,所以其会将编译过程中断并报错。同样的原理也适用于global_process_monitor
符号。
使用inline
声明所有符号之后,连接器只会接受其找到的第一个符号,而将后续该符号的不同实现丢弃。
C++17之前,解决的方法是通过额外的C++模块文件提供相应的符号,这将迫使我们的库用户强制在链接阶段包含该文件。
传统的inline
关键字还有另外一种功能。其会告诉编译器,可以通过实现直接放在调用它的地方来消除函数调用的过程。这样的话,代码中的函数调用会减少,这样我们会认为程序会运行的更快。如果函数非常短,那么生成的程序段也会很短(假设函数调用也需要若干个指令,保护现场等操作,其耗时会高于实际工作的代码)。当内联函数非常长,那么二进制文件的大小就会变得很大,有时并无法让代码运行的更快。因此,编译器会将inline
关键字作为一个提示,可能会对内联函数消除函数调用。当然,编译器也会将一些函数进行内联,尽管开发者没有使用inline
进行提示。
C++17之前的解决方法就是将对应函数声明为静态函数,这个函数会返回某个静态对象的引用:
class foo{ public: static std::string& standard_string(){ static std::string s{"some standard string"}; return s; } };
通过这种方式,将头文件包含在多个模块中是完全合法的,但仍然可以访问相同的实例。不过,对象并没有在程序开始时立即构造,而是在第一次调用这个获取函数时才进行构造。对于一些特定的情况来说,这也个问题。假设我们想要在程序开始时就构造静态和全局函数,从而完成一些比较重要的事情(就和我们的例程库一样),不过当程序运行后,在调用时去构造这些对象,就会带来比较大的性能开销。
另一个解决方法是将非模板类看做一个模板类,因此非模板类也适用于这项规则。
不过,以上的两种策略在C++17中不太适用了,C++17已经使用新的inline
完美解决。
自C++11起,加入了变长模板参数包,能让函数结构任意数量的参数。有时,这些参数都组合成一个表达式,从中得出函数结果。C++17中使用折叠表达式,可以让这项任务变得更加简单。
首先,实现一个函数,用于将所有参数进行累加:
声明该函数:
template <typename ... Ts> auto sum(Ts ... ts);
那么现在我们拥有一个参数包ts
,并且函数必须将参数包展开,然后使用表达式进行求和。如果我们对这些参数进行某个操作(比如:加法),那么为了将这个操作应用于该参数包,就需要使用括号将表达式包围:
template<typename ... Ts> auto sum(Ts ... ts){ return (ts + ...); }
现在我们可以调用这个函数:
int the_sum {sum(1, 2, 3, 4, 5)}; // value: 15
这个操作不仅对int
类型起作用,我们能对任何支持加号的类型使用这个函数,比如std::string
:
std::string a{"Hello "}; std::string b{"World"}; std::cout << sum(a, b) << '\n'; // output: Hello World
这里只是简单的对参数集进行简单的递归,然后应用二元操作符+
将每个参数加在一起。这称为折叠操作。C++17中添加了折叠表达式,其能用更少的代码量,达到相同的结果。
其中有种称为一元折叠的表达式。C++17中的折叠参数包支持如下二元操作符:+
-
*
/
%
^
&
|
=
<
>
<<
>>
+=
-=
*=
/=
%=
^=
&=
|=
<<=
>>=
==
!=
<=
>=
&&
||
,
.*
->*
。
这样的话,在我们的例子中表达式(ts+...)
和(...+ts)
等价。不过,对于某些其他的例子,这就所有不同了——当...
在操作符右侧时,称为有“右折叠”;当...
在操作符左侧时,称为”左折叠“。
我们sum例子中,一元左折叠的扩展表达式为1+(2+(3+(4+5)))
,一元右折叠的扩展表达式为(((1+2)+3)+4)+5
。根据操作符的使用,我们就能看出差别。当用来进行整数相加,那么就没有区别。
如果在调用sum函数的时候没有传入参数,那么可变参数包中就没有可以被折叠的参数。对于大多数操作来说,这将导致错误(对于一些例子来说,可能会是另外一种情况,我们后面就能看到)。这时我们就需要决定,这时一个错误,还是返回一个特定的值。如果是特定值,显而易见应该是0。
如何返回一个特定值:
template <typenme ... Ts> auto sume(Ts ... ts){ return (ts + ... + 0); }
sum()
会返回0,sum(1, 2, 3)
返回(1+(2+(3+0)))
。这样具有初始值的折叠表达式称为二元折叠。
当我们写成(ts + ... + 0)
或(0 + ... + ts)
时,不同的写法就会让二元折叠表达式处于不同的位置(二元右折叠或二元左折叠)。下图可能更有助于理解左右二元折叠:
为了应对无参数传入的情况,我们使用二元折叠表达式,这里标识元素这个概念很重要——本例中,将0加到其他数字上不会有任何改变,那么0就一个标识元素。因为有这个属性,对于加减操作来说,可以将0添加入任何一个折叠表达式,当参数包中没有任何参数时,我们将返回0。从数学的角度来看,这没问题。但从工程的角度,我们需要根据我们需求,定义什么是正确的。
同样的原理也适用于乘法。这里,标识元素为1:
template <typename ... Ts> auto product(Ts ... ts){ return (ts * ... * 1); }
product(2, 3)
的结果是6,product()
的结果是1。
逻辑操作符and(&&)
和or(||)
具有内置的标识元素。&&
操作符为true,||
操作符为false。
对于逗号表达式来说,其标识元素为void()
。
为了更好的理解这特性,让我们可以使用这个特性来实现的辅助函数。
匹配范围内的单个元素
如何告诉函数在一定范围内,我们提供的可变参数至少包含一个值:
template <typename R, typename ... Ts> auto matches(const R& range, Ts ... ts) { return (std::count(std::begin(range), std::end(range), ts) + ...); }
辅助函数中使用STL中的std::count
函数。这个函数需要三个参数:前两个参数定义了迭代器所要遍历的范围,第三个参数则用于与范围内的元素进行比较。std::count
函数会返回范围内与第三个参数相同元素的个数。
在我们的折叠表达式中,我们也会将开始和结束迭代器作为确定范围的参数传入std::count
函数。不过,对于第三个参数,我们将会每次从参数包中放入一个不同参数。最后,函数会将结果相加返回给调用者。
可以这样使用:
std::vector<int> v{1, 2, 3, 4, 5}; matches(v, 2, 5); // return 2 matches(v, 100, 200); // return 0 matches("abcdefg", 'x', 'y', 'z'); // return 0 matches("abcdefg", 'a', 'b', 'f'); // return 3
如我们所见,matches
辅助函数十分灵活——可以直接传入vector
或string
直接调用。其对于初始化列表也同样适用,也适用于std::list
,std::array
,std::set
等STL容器的实例。
检查集合中的多个插入操作是否成功
我们完成了一个辅助函数,用于将任意数量参数插入std::set
实例中,并且返回是否所有插入操作都成功完成:
template <typename T, typename ... Ts> bool insert_all(T &set, Ts ... ts) { return (set.insert(ts).second && ...); }
那么这个函数如何工作呢?std::set
的insert
成员函数声明如下:
std::pair<iterator, bool> insert(const value_type& value);
手册上所述,当我们使用insert
函数插入一个元素时,该函数会使用一个包含一个迭代器和一个布尔值的组对作为返回值。当该操作成功,那么迭代器指向的就是新元素在set
实例中的位置。否则,迭代器指向某个已经存在的元素,这个元素与插入项有冲突。
我们的辅助函数在完成插入后,会访问.second
区域,这里的布尔值反映了插入操作成功与否。如果所有插入操作都为true,那么都是成功的。折叠标识使用逻辑操作符&&
链接所有插入结果的状态,并且返回计算之后的结果。
可以这样使用它:
std::set<int> my_set{1, 2, 3}; insert_all(my_set, 4, 5, 6); // Returns true insert_all(my_set, 7, 8, 2); // Returns false, because the 2 collides
需要注意的是,当在插入3个元素时,第2个元素没有插入成功,那么&&
会根据短路特性,终止插入剩余元素:
std::set<int> my_set{1, 2, 3}; insert_all(my_set, 4, 2, 5); // Returns flase // set contains {1, 2, 3, 4} now, without the 5!
检查所有参数是否在范围内
当要检查多个变量是否在某个范围内时,可以多次使用查找单个变量是否在某个范围的方式。这里我们可以使用折叠表达式进行表示:
template <typename T, typename ... Ts> bool within(T min, T max, Ts ...ts) { return ((min <= ts && ts <= max) && ...); }
表达式(min <= ts && ts <= max)
将会告诉调用者参数包中的每一个元素是否在这个范围内。我们使用&&
操作符对每次的结果进行处理,从而返回最终的结果。
如何使用这个辅助函数:
within(10, 20, 1, 15, 30); // --> false within(10, 20, 11, 12, 13); // --> true within(5.0, 5.5, 5.1, 5.2, 5.3) // --> true
这个函数也是很灵活的,其只需要传入的参数类型可以进行比较,且支持<=
操作符即可。并且该规则对于std::string
都是适用的:
std::string aaa {"aaa"}; std::string bcd {"bcd"}; std::string def {"def"}; std::string zzz {"zzz"}; within(aaa, zzz, bcd, def); // --> true within(aaa, def, bcd, zzz); // --> false
将多个元素推入vector中
可以编写一个辅助函数,不会减少任何结果,又能同时处理同一类的多个操作。比如向std::vector
传入元素:
template <typename T, typename ... Ts> void insert_all(std::vector<T> &vec, Ts ... ts){ (vec.push_back(ts), ...); } int main(){ std::vector<int> v{1, 2, 3}; insert_all(v, 4, 5, 6); }
需要注意的是,使用了逗号操作符将参数包展开,然后推入vector中。该函数也不惧空参数包,因为逗号表达式具有隐式标识元素,void()
可以翻译为什么都没做。
C++标准库中有大量的标准容器。容器通常包含一组数据或对象的集合。容器的厉害之处在于几乎可以和任何类型的对象一起使用,所以我们只需要为程序选择合适的容器即可。STL带给我们栈、自动增长的vector、map等等。这样我们就可以集中精力于我们的应用,而不用重复制作轮子。了解所有容器,对于C++开发者来说至关重要。
STL容器的分类如下,会在各节中进行详细描述:
想要存储一组对象最简单的方式,就是将其一个接一个的存在一块比较大的内存当中。内存可以使用随机访问的方式进行,其时间复杂度为O(1)。
最简单的方式就是使用std::array
,其就是对C风格数组的一种包装。不过,std::array
要比C风格数组要先进的多,因为其没有运行时开销,而且进行元素添加时,也会十分舒适和安全。还有一点和C风格数组一样,一旦创建,其长度就是固定的,创建过后无法改变长度。
std::vector
和std::array
很类似,不过std::vector
的长度可变。其会使用堆上的内存来存储对象。当新元素添加到vector
中后,当前长度超过了原始的长度,那么std::vector
会自动新分配一段更大的内存,用来放置包括新插入元素的所有元素,并且释放之前所占用的内存。此外,当新元素需要插入到两个旧元素之间时,std::vector
会移动当前已有的元素。当要删除vector
中间的一个已存在元素,那么vector
类会自动地移动其他对象,将删除后的缝隙填补起来。
如果有大量元素在std::vector
的头部或尾部进行插入或删除,那么为了填补空隙和移动已有元素,将会耗费很多时间。如遇到这样的情况,建议你考虑使用std::deque
。对象集合会存储在多段固定长度的连续内存中,这些内存段是相互独立的。这就使得双向队列变得很简单,并且增长也很容易,因为不同的内存段相对独立,只需要将新分配的内存段加入就可以了,无需对其他已存在的内存段进行移动。减少的场景也是一样的。
std::list
是一个典型的双向链表。如果是单向列表,那就需要进行遍历,所以std::forward_list
的优势在维护的复杂性上,因为其指针方向只有一个方向。列表遍历的时间复杂度是线性的O(n)。其在特定位置上插入和删除元素的时间复杂度为O(1)。
当对象集具有可进行排序的自然属性时,可以使用小于的关系将这些元素进行排序,我们就可以使用搜索树来保存这个排序关系。从名字就可以看出,搜索树可以帮助我们很容易的通过一个关键字查找到对应元素,其搜索的时间复杂度为O(log(n))。
STL提供了不同种类的树,std::set
是其中最简单的一种,保存元素不重复,存储的元素是可排序的(用一种树的结构)。
std::map
使用的是另一种方式,将存储的数据使用组对进行存储。一个组对有一个key值和一个对应值构成。搜索树会对key值部分进行排序,使组对能作为std::map
的一种关联容器。std::map
的key值和std::set
的值一样,在整个树中只能存在一个。
std::multiset
和std::multimap
是被特化的,key对象可以是重复的。
讨论关联容器时,搜索树并不是唯一的方式。使用哈希表查找元素的时间复杂度只有O(1),不过这就会忽略其自然序,所以不能简单的使用排序的方式进行遍历。哈希表大小可由用户控制,并且可以单独选择哈希函数,这是一项很重要的特性,因为其性能与空间复杂度依赖于此。
std::unordered_set
和std::unordered_map
具有很多接口与std::set
和std::map
一样,它们之间几乎可以相互替换。
搜索树的实现中,容器都具有多个变种: std::unordered_multiset
和std::unordered_multimap
,这两种方法都取消了对象/键的唯一性,因此我们可以用相同的键存储多个元素。
数组、列表、树和哈希表并不是存储和访问数据的唯一方式,这里还有栈、队列等其他的方式也可以存储和访问数据。类似的,越复杂的结构可以使用越原始的方式实现,并且STL使用以下形式的容器适配器进行操作:std::stack
、std::queue
和std::priotity_queue
。
最牛X的是当我们需要这样的数据结构时,我们可以选择一种适配器。然后,当我们觉得到它们性能较差时,就可以改变一个模板参数,以便让适配器使用不同的容器实现。实践中,这也就意味着我们可以将std::stack
实例中的元素类型从std::vector
切换成std::deque
。
由于std::vector
能自动增长,并且使用方式简单,很受C++开发新手的喜爱。可以通过查阅手册,来了解这个容器该如何使用,比如删除元素。这样使用STL容器,只是了解容器的皮毛,容器应该帮助我们写出更简洁、维护性好和更快的代码。
本节的全部内容都是在一个vector
实例中删除元素。当vector
中部的一个元素消失了,那么位于消失元素右边的所有元素都要往左移(这种操作的时间复杂度为O(n)。新手们会用循环来做这件事,因为循环的确好用。不过,循环会降低代码的优化空间。最后,比起STL的方法,循环是既不快,也不美,
首先,我们使用整数来填充一个std::vector
实例,之后剔除一些特定元素。我们演示的从vector
实例中删除元素正确的方法。
包含文件是首要任务。
#include <iostream> #include <vector> #include <algorithm>
声明我们所要使用的命名空间。
using namespace std;
现在我们来创建一个vector
实例,并用整数填满它。
int main(){ vector<int> v{1, 2, 3, 2, 5, 2, 6, 2, 4, 8};
然后移除一些元素。需要我们移除哪些呢?2出现的太多次了,就选择2吧。让我们移除它们吧。
const auto new_end(remove(begin(v), end(v), 2));
已经完成了两步中的一步。vector
在删除这些元素之后,长度并没有发生变化。那么下一步就让这个vector
变得短一些。
v.erase(new_end, end(v));
我们在这里暂停一下,输出一下当前vector
实例中所包含的元素。
for(auto i : v){ cout << i << ", "; } cout << '\n';
现在,让我们来移除一组指定的数据。为了完成这项工作,我们先定义了一个谓词函数,其可接受一个数作为参数,当这个数是奇数时,返回true。
const auto odd([](int i){return i % 2 != 0;});
这里我们使用remove_if
函数,使用上面定义的谓词函数,来删除特定的元素。这里我们将上面删除元素的步骤合二为一。
v.erase(remove_if(begin(v), end(v), odd), end(v));
所有的奇数都被删除,不过vector
实例的容量依旧是10。最后一步中,我们将其容量修改为正确的大小。需要注意的是,这个操作会让vector
重新分配一段内存,以匹配相应元素长度,vector
中已存的元素会移动到新的内存块中。
v.shrink_to_fit();
打印一下现在vector
实例中的元素。
for (auto i : v) { cout << i << ", "; } cout << '\n'; }
编译完成后,运行程序,就可以了看到两次删除元素后vector
实例中所存在的元素。
$ ./main 1, 3, 5, 6, 4, 8, 6, 4, 8,
我们可以清楚的看到,要从一个vector
实例中移除一个元素,首先要进行删除,然后进行擦除,这样才算真正的移除。这会让人感到困惑,那就让我们近距离观察一下这些步骤是如何工作的。
从vector
中移除2的代码如下所示:
const auto new_end (remove(begin(v), end(v), 2)); v.erase(new_end, end(v));
std::begin
和std::end
函数都以一个vector
实例作为参数,并且返回其迭代器,迭代器分别指向第一个元素和最后一个元素,就如下图所示。
std::remove
在删除2的时候,会先将非2元素进行移动,然后修改end迭代器的指向。该算法将严格保留所有非2个值的顺序。
在2步中,2的值仍然存在,并且vector
应该变短。并且4和8在现有的vector
中重复了。这是怎么回事?
让我们再来看一下所有的元素,目前vector
的范围并不是原来那样了,其是从begin
迭代器,到new_end
迭代器。new_end
之后的值其实就不属于vector
实例了。我们会注意到,在这个范围内的数值,就是我们想要的正确结果,也就是所有的2都被移除了。
最后,也就是为什么要调用erase
函数:我们需要告诉vector
实例,new_end
到end
之间的元素我们不需要了。我们仅需要保留begin
到new_end
间的元素就好了。erase
函数会将end
指向new_end
。这里需要注意的是std::remove
会直接返回new_end
迭代器,所以我们可以直接使用它。
Note:
vector
在这里不仅仅移动了内部指针。如果vector
中元素比较复杂,那么在移除的时候,会使用其析构函数来销毁相应的对象。
最后,这个向量就如步骤3所示:的确变短了。那些旧的元素已经不在vector
的访问范围内了,不过其仍存储在内存中。
为了不让vector
浪费太多的内存,我们在最后调用了shrink_to_fit
。该函数会为元素分配足够的空间,将剩余的元素移到该空间内,并且删除之前那个比较大的内存空间。
在上面的第8步中,我们定义了一个谓词函数,并在std::remove_if
中使用了它。因为不论删除函数返回怎么样的迭代器,在对vector
实例使用擦除函数都是安全的。如果vector
中全是偶数,那么std::remove_if
不会做任何事情,并且返回end
迭代器。之后的调用就为v.erase(end, end);
,同样没有做任何事情。
std::remove
函数对其他容器同样有效。当使用std::array
时,其不支持erase
操作,因为其内存空间固定,无法进行自动化处理。因为std::remove
只是将要删除的元素移动到容器末尾,而不是将其真正删除,所以这个函数也可以用于不支持空间大小变化的数据类型。当然也有其他类似的方法,例如字符串中,可以用哨兵值\0
来覆盖原始的end
迭代所指向的值。
因为其他元素要填补删除元素所留下来的空隙,从而需要进行移动,所以从std::vector
中删除元素的时间复杂度为O(n)。
移动其他元素也与此类似,当很多很大或很复杂的元素需要移动,那么就会花费很长的时间。当无法保证顺序时,我们需要对其进行优化,这就是本节的内容。
我们继续使用一些数字来填充std::vector
实例,并且实现一个快速删除函数,以O(1)的时间复杂度删除vector中的元素。
首先,包含必要的头文件:
#include <iostream> #include <vector> #include <algorithm>
定义主函数,并定义一个vector
实例:
int main(){ std::vector<int> v{123, 456, 789, 100, 200};
下一步就要删除索引为2的元素(789)。我们所要用的来删除元素的函数在后面进行实现,我们先假设已经实现好了。执行完成后,来看下vector
中的内容。
quick_remove_at(v, 2); for (int i : v){ std::cout << i << ", "; } std::cout << '\n';
现在,我们将删除另外一个元素。我们想删除123,但是要假装不知道其索引。因此,我们要使用std::find
函数在vector的合法范围内查找这个值,并返回其位置信息。得到索引信息后,我们就可以用quick_remove_at
将对应元素删除了,这里所使用到的是一个重载版本,能接受迭代器作为输入参数。
quick_remove_at(v, std::find(std::begin(v), std::end(v), 123)); for (int i : v) { std::cout << i << ", "; } std::cout << '\n'; }
我们实现了两种quick_remove_at
函数。具体实现代码中,需要注意与main
函数的前后关系。两个函数都能接收一个vector
实例的引用,所以这里允许用户使用各种类型的变量作为元素。对于我们来说,其类型就是T
。第一个 quick_remove_at
函数用来接收索引值,是一个具体的数,所以其接口如下所示:
template <typename T> void quick_remove_at(std::vector<T> &v, std::size_t idx) {
现在来展示一下本节的重点——如何在不移动其他元素的情况下,快速删除某个元素?首先,将vector
中最后一个元素进行重写。第二,删除vector
中最后一个元素。就这两步。我们的代码会对输入进行检查。如果输入的索引值超出了范围,函数不会做任何事情。另外,该函数会在传入空vector
的时候崩溃。
if (idx < v.size()) { v[idx] = std::move(v.back()); v.pop_back(); } }
另一个quick_remove_at
实现也很类似。用std::vector<T>
的迭代器替换了具体的索引数值。因为泛型容器已经定义了这样的类型,所以获取它的类型并不复杂。
template <typename T> void quick_remove_at(std::vector<T> &v, typename std::vector<T>::iterator it) {
现在我们来访问这些迭代器所指向的值。和另一个函数一样,我们会将最后一个元素进行重写。因为这次处理的是迭代器,所以我们要对迭代器指向的位置进行检查。如果其指向了一个错误的位置,我们就会阻止其解引用。
if (it != std::end(v)) {
在该代码块中,我们会做和之前一样的事情——我们要覆盖最后一个位置上的值——然后将最后一个元素从vector
中剪掉。
*it = std::move(v.back()); v.pop_back(); } }
这就完事了。让我们来编译程序,并运行:
$ ./main 123, 456, 200, 100, 100, 456, 200,
quick_remove_at
函数移除元素非常快,而且不需要动其他元素。这个函数使用了更加具有创造性的做法:这是一种与实际元素交换的方式,然后将最后一个元素从vector
中删除。虽然,最后一个元素与选中的元素没有实际的关联,但是它在这个特别的位置上,而且删除最后一个元素的成本最低!vector
的长度在删除完成后,也就减少1,这就是这个函数所要做的。并且无需移动任何元素。看一下下面的图,可能有助于你理解这个函数的原理。
完成这两步的代码如下:
v.at(idx) = std::move(v.back()); v.pop_back();
迭代器版本实现几乎一模一样:
*it = std::move(v.back()); v.pop_back();
逻辑上,我们将选定元素与最后一个元素进行交换。不过,在代码中元素并没有进行交换,代码直接使用最后一个值覆盖了选定元素的值。为什么要这样?当我们交换元素时,就需要将选定的元素存储在一个临时变量中,并在最后将这个临时变量中的值放在vector
的最后。这个临时变量是多余的,而且要删除的值对于我们来说是没有意义的,所以这里选择了直接覆盖的方式,更加高效的实现了删除。
好了,交换是无意义的,覆盖是一种更好的方式。让我们来看下这个,当我们要获取vector
最后元素的迭代器时,只需要简单的执行*it = v.back();
就行了,对吧?完全正确,不过试想我们存储了一些非常长的字符串在vector
中,或存储了另一个vector
或map
——这种情况下,简单的赋值将对这些值进行拷贝,那么就会带来非常大的开销。这里使用std::move
可将这部分开销优化掉:比如字符串,指向堆内存上存储的一个大字符串。我们无需拷贝它。只需要移动这个字符串即可,就是将目标指针指向这块地址即可。移动源保持不变,不过出于无用的状态,这样做可以类似的让目标指针指向源指针所在的位置,然后将原始位置的元素删除,这样做即完成了元素移动,又免去了移动消耗。
std::vector
可能是STL容器中适用范围最广的,因为其存储数据的方式和数组一样,并且还有相对完善的配套设施。不过,非法访问一个vector
实例还是十分危险的。如果一个vector
实例具有100个元素,那当我们想要访问索引为123的元素时,程序就会崩溃掉。如果不崩溃,那么你就麻烦了,未定义的行为会导致一系列奇奇怪怪的错误,查都不好查。经验丰富的开发者会在访问前,对索引进行检查。这样的检查其实比较多余,因为很多人不知道std::vector
有内置的检查机制。
本节我们将使用两种不同的方式访问一个std::vector
实例,并且利用其特性编写更加安全的代码。
先包含相应的头文件,并且用1000个123填满一个vector实例:
#include <iostream> #include <vector> using namespace std; int main() { const size_t container_size{1000}; vector<int> v(container_size, 123);
我们通过[]
操作符访问范围之外的元素:
cout << "Out of range element value: " << v[container_size + 10] << '\n';
之后我们使用at
函数访问范围之外的元素:
cout << "Out of range element value: " << v.at(container_size + 10) << '\n'; }
让我们运行程序,看下会发生什么。下面的错误信息是由GCC给出。其他编译器也会通过不同方式给出类似的错误提示。第一种方式得到的结果比较奇怪。超出范围的访问方式并没有让程序崩溃,但是访问到了与123相差很大的数字。第二种方式中,我们看不到打印出来的结果,因为在打印之前程序已经崩溃了。当越界访问发生的时候,我们可以通过异常的方式更早的得知!
Out of range element value: -726629391 terminate called after throwing an instance of 'std::out_of_range' what(): array::at: __n (which is 1010) >= _Nm (which is 1000) Aborted (core dumped)
std::vector
提供了[]
操作符和at
函数,它们的作用几乎是一样的。at
函数会检查给定的索引值是否越界,如果越界则返回一个异常。这对于很多情景都十分适用,不过因为检查越界要花费一些时间,所以at
函数会让程序慢一些。
当需要非常快的索引成员时,并能保证索引不越界,我们会使用[]
快速访问vector
实例。很多情况下,at
函数在牺牲一点性能的基础上,有助于发现程序内在的bug。
Note:
默认使用
at
函数是一个好习惯。当代码的性能很差,但没有bug存在时,可以使用性能更高的操作符来替代at
函数。
当然,我们需要处理越界访问,避免整个程序崩溃。为了对越界访问进行处理,我们可以使用截获异常的方式。可以用try
代码块将调用at函数的部分包围,并且定义错误处理的catch
代码段。
try { std::cout << "Out of range element value: " << v.at(container_size + 10) << '\n'; } catch (const std::out_of_range &e) { std::cout << "Ooops, out of range access detected: " << e.what() << '\n'; }
Note:
顺带一提,
std::array
也提供了at
函数。
array
和vector
不会对他们所承载的对象进行排序。有时我们去需要排序,但这不代表着我们总是要去切换数据结构,需要排序能够自动完成。在我们的例子有如有一个std::vector
实例,将添加元素后的实例依旧保持排序,会是一项十分有用的功能。
本节中我们使用随机单词对std::vector
进行填充,然后对它进行排序。并在插入更多的单词的同时,保证vector
实例中单词的整体排序。
先包含必要的头文件。
#include <iostream> #include <vector> #include <string> #include <algorithm> #include <iterator> #include <cassert>
声明所要使用的命名空间。
using namespace std;
完成主函数,使用一些随机单词填充vector
实例。
int main() { vector<string> v {"some", "random", "words", "without", "order", "aaa", "yyy"};
对vector实例进行排序。我们使用一些断言语句和STL中自带的is_sorted
函数对是否排序进行检查。
assert(false == is_sorted(begin(v), end(v))); sort(begin(v), end(v)); assert(true == is_sorted(begin(v), end(v)));
这里我们使用insert_sorted
函数添加随机单词到已排序的vector
中,这个函数我们会在后面实现。这些新插入的单词应该在正确的位置上,并且vector
实例需要保持已排序的状态。
insert_sorted(v, "foobar"); insert_sorted(v, "zzz");
现在,我们来实现insert_sorted
函数。
void insert_sorted(vector<string> &v, const string &word) { const auto insert_pos (lower_bound(begin(v), end(v), word)); v.insert(insert_pos, word); }
回到主函数中,我们将vector
实例中的元素进行打印。
for (const auto &w : v) { cout << w << " "; } cout << '\n'; }
编译并运行后,我们得到如下已排序的输出。
aaa foobar order random some without words yyy zzz
程序整个过程都是围绕insert_sorted
展开,这也就是本节所要说明的:对于任意的新字符串,通过计算其所在位置,然后进行插入,从而保证vector
整体的排序性。不过,这里我们假设的情况是,在插入之前,vector
已经排序。否则,这种方法无法工作。
这里我们使用STL中的lower_bound
对新单词进行定位,其可接收三个参数。头两个参数是容器开始和结尾的迭代器。这确定了我们单词vector
的范围。第三个参数是一个单词,也就是要被插入的那个。函数将会找到大于或等于第三个参数的首个位置,然后返回指向这个位置的迭代器。
获取了正确的位置,那就使用vector
的成员函数insert
将对应的单词插入到正确的位置上。
insert_sorted
函数很通用。如果需要其适应不同类型的参数,这样改函数就能处理其他容器所承载的类型,甚至是容器的类似,比如std::set
、std::deque
、std::list
等等。(这里需要注意的是成员函数lower_bound
与 std::lower_bound
等价,不过成员函数的方式会更加高效,因为其只用于对应的数据集合)
template <typename C, typename T> void insert_sorted(C &v, const T &item) { const auto insert_pos (lower_bound(begin(v), end(v), item)); v.insert(insert_pos, item); }
当我们要将std::vector
类型转换为其他类型时,需要注意的是并不是所有容器都支持std::sort
。该函数所对应的算法需要容器为可随机访问容器,例如std::list
就无法进行排序。
我们需要用键值对填充一个map
实例时,会碰到两种不同的情况:
我通常会使用insert
或emplace
函数对map
插入新元素,如果插入不成功,那么就是第二种情况,就需要去修改现有的元素。insert
和emplace
都会创建一个新元素尝试插入到map
实例中,不过在第二种情况下,这个新生成的元素会被扔掉。两种情况下,我们都会多余调用一次构造函数。
C++17中,添加了try_emplace
函数,其只有在满足条件的情况下,才能插入新元素。让我们实现一个程序,建立一张表,列出各国亿万富翁的数量。我们例子中不会使用很大开销进行元素创建,不过我们的例子来源于生活,其能让你明白如何使用try_emplace
。
本节中,我们将实现一个应用,其能创建一张百万富翁的列表。这个列表中按国家区分,里面记录了各国富人的数量。
包含头文件和声明命名空间。
#include <iostream> #include <functional> #include <list> #include <map> using namespace std;
定义一个结构器,代表对应的富翁。
struct billionaire { string name; double dollars; string country; };
主函数中,我们定义了一个百万富翁的列表。世界上有很多百万富翁,所以我们创建一个有限列表来存储这些富翁的信息。这个列表是已排序的。2017年福布斯富豪名单,世界百万富翁排行榜可以在 https://www.forbes.com/billionaires/list 查到。
int main() { list<billionaire> billionaires { {"Bill Gates", 86.0, "USA"}, {"Warren Buffet", 75.6, "USA"}, {"Jeff Bezos", 72.8, "USA"}, {"Amancio Ortega", 71.3, "Spain"}, {"Mark Zuckerberg", 56.0, "USA"}, {"Carlos Slim", 54.5, "Mexico"}, // ... {"Bernard Arnault", 41.5, "France"}, // ... {"Liliane Bettencourt", 39.5, "France"}, // ... {"Wang Jianlin", 31.3, "China"}, {"Li Ka-shing", 31.2, "Hong Kong"} // ... };
现在让我们定义一个表。这个表由表示国家名的字符串和一个组对构成。组对中会具有上面列表的一个(const)副本。这也就是每个国家最富有的人。组对中另一个变量是一个计数器,其会统计某国的富豪人数。
map<string, pair<const billionaire, size_t>> m;
现在,让我们将列表中的数据尝试插入到组对中。每个组对中都包含了对应国家的百万富翁,并将计数器的值置成1。
for (const auto &b : billionaires) { auto [iterator, success] = m.try_emplace(b.country, b, 1);
如果这一步成功,那就不用再做其他事了。我们使用b和1创建的组对已经插入到表中。如果因为键已存在而插入失败,那么组对就不会构建。当我们百万富翁结构体非常大时,我们需要将运行时拷贝的时间节省下来。不过,在不成功的情况下,我们还是要对计数器进行增加1的操作。
if (!success) { iterator->second.second += 1; } }
现在,我们来打印一下每个国家百万富翁的数量,以及各个国家中最富有的人。
for (const auto & [key, value] : m) { const auto &[b, count] = value; cout << b.country << " : " << count << " billionaires. Richest is " << b.name << " with " << b.dollars << " B$\n"; } }
编译并运行程序,就会得到下面的输出(这里的输出是不完整的,因为列表比较长)。
$ ./efficient_insert_or_modify
China : 1 billionaires. Richest is Wang Jianlin with 31.3 B$
France : 2 billionaires. Richest is Bernard Arnault with 41.5 B$
Hong Kong : 1 billionaires. Richest is Li Ka-shing with 31.2 B$
Mexico : 1 billionaires. Richest is Carlos Slim with 54.5 B$
Spain : 1 billionaires. Richest is Amancio Ortega with 71.3 B$
USA : 4 billionaires. Richest is Bill Gates with 86 B$
本节围绕着std::map
中的try_emplace
函数展开,这个函数是C++17添加的。下面是其函数声明之一:
std::pair<iterator, bool> try_emplace(const key_type& k, Args&&... args);
其函数第一个参数k
是插入的键,args
表示这个键对应的值。如果我们成功的插入了元素,那么函数就会返回一个迭代器,其指向新节点在表中的位置,组对中布尔变量的值被置为true。当插入不成功,组对中的布尔变量值会置为false,并且迭代器指向与新元素冲突的位置。
这个特性在我们的例子中非常有用——可以完美处理第一次访问到,和之后访问到的情况。
Note:
std::map
中insert
和emplace
方法完全相同。try_emplace
与它们不同的地方在于,在遇到已经存在的键时,不会去构造组对。当相应对象的类型需要很大开销进行构造时,这对于程序性能是帮助的。
如果我们将表的类型从std::map
换成std::unordered_map
,程序照样能工作。这样的话,当不同类型的表具有较好的性能特性时,我们就可以快速的进行切换。例子中,唯一可观察到的区别是,亿万富翁表不再按字母顺序打印,因为哈希表和搜索树不同,其不会对对象进行排序。
std::map
中查找元素的时间复杂度为OParseError: KaTeX parse error: Can't use function '\(' in math mode at position 4: log\̲(̲n),与插入元素的时间复杂相同,因为要在对应位置上插入元素,那么就先要找到这个位置。通常,插入M个新元素的时间复杂度为OParseError: KaTeX parse error: Undefined control sequence: \* at position 2: M\̲*̲log\(n)。
为了让插入更加高效,std::map
插入函数接受一个迭代器参数hint
。自C++11起,该参数为指向将插入新元素到其前的位置的迭代器。如果这个迭代器给定正确,那么插入的时间复杂度就为O。
本节会是用传入迭代器的方式向std::map
实例中插入多个新元素,从而减少耗时:
包含必要的头文件。
#include <iostream> #include <map> #include <string>
创建一个map
实例,并填充一些内容。
int main() { std::map<std::string, size_t> m { {"b", 1}, {"c", 2}, {"d", 3} };
我们将插入多个元素,对于每次插入,我们都会传入一个hint迭代器。第一次插入我们不指定其开始位置,只将插入位置指向map
的end
迭代器之前。
auto insert_it (std::end(m));
我们将以字母表的反序进行元素的插入,然后使用hint迭代器,然后使用insert
函数的返回值重新初始化迭代器的值。下一个元素将在hint
迭代器前插入。
for (const auto &s : {"z", "y", "x", "w"}) { insert_it = m.insert(insert_it, {s, 1}); }
为了展示在什么情况下insert
函数不工作,我们将要插入最左侧位置的元素插入到最右侧。
m.insert(std::end(m), {"a", 1});
最后我们打印当前的map
。
for (const auto & [key, value] : m) { std::cout << "\"" << key << "\": " << value << ", "; } std::cout << '\n'; }
编译运行程序,错误的插入并没有对结果又什么影响,map
实例中对象的顺序仍然是对的。
"a": 1, "b": 1, "c": 2, "d": 3, "w": 1, "x": 1, "y": 1, "z": 1,
本例与常用的方式不同,多了一个迭代器。并且我们提到了这个迭代器的正确与否。
正确的迭代器将会指向一个已存在的元素,其值要比要插入元素的键大,所以新元素会插在这个迭代器之前。如果用户提供的迭代器位置有误,那么插入函数会退化成未优化的版本,其时间复杂度恢复OParseError: KaTeX parse error: Can't use function '\(' in math mode at position 4: log\̲(̲n)。
对于第一次插入,我们选择了map
实例的end
迭代器,因为没有其他更好的选择。在插入“z”之后,函数会返回相应的迭代器,这样我们就知道了要插入“y”的位置。“x”也同理,后面的元素依次类推。
Note:
在C++11之前,hint迭代器只是建议作为搜索开始位置的迭代器。
其中,比较有趣的事情是,在给定错误的迭代器,map
实例依旧能保持其排序。那么他是如何工作的呢?还有插入的时间复杂度为O意味着什么?
std::map
通常使用二叉搜索树实现。当在搜索树中插入一个新键时,该键要和其他键进行比较,从末端到顶端。如果键小于或大于其他节点的键,搜索树的左侧或右侧分支则会成为更深的节点。不过,搜索算法会阻止节点达到当前搜索树的底端。否则会打破搜索树的平衡,所以为了保证正确性,需要使用一个平衡算法用来管理节点。
当我们将元素插入到树中时,这些键值就会成为邻居。如果有hint
传入,那么很容易检查键是否正确。如果这种情况出现,则可以省去搜索的时间。而后,平衡算法会可能还要运行。虽然优化并不是总能成功,不过平均下来,性能上还是会有提升。可以使用多次插入的方式,来统计运行的耗时,这被称之为摊销复杂度。
如果插入的hint
是错的,那么插入函数会放弃使用hint
,转而使用搜索算法进行查找。虽然程序不会出什么问题,但这样做会让程序变慢。
在std::map
数据结构中,键-值通常都对应存在,而且键通常是唯一并排序过的,而且键值一旦设定那么就不允许用户再进行修改。为了阻止用户修改键,键的类型声明使用了const
。
这种限制是非常明智的,其可以保证用户很难在使用std::map
的时候出错。不过,如果我们真的需要修改map
的键值该怎么办呢?
C++17之前,因为对应的键已经存在,我们不得不将整个键-值对从树中移除,然后再插入。这种方法的确定很明显,其需要分配出一些不必要的内存,感觉上也会对性能有一定的影响。
从C++17起,我们无需重新分配内存,就可以删除和重新插入map键值对。下面的内容中将会展示如何操作。
我们使用std::map
类型一个实现应用,用于确定车手在虚拟比赛中的排位。当车手在比赛中完成超越,那么我们将使用C++17的新方法改变其键值。
包含必要的头文件和声明使用的命名空间。
#include <iostream> #include <map> using namespace std;
我们会在修改map的时候打印之前和之后结果,所以这里先实现了一个辅助函数。
template <typename M> void print(const M &m) { cout << "Race placement:\n"; for (const auto &[placement, driver] : m) { cout << placement << ": " << driver << '\n'; } }
主函数中,我们实例化并初始化一个map
,其键为整型,表示是当前的排位;值为字符型,表示驾驶员的姓名。我们在这里先打印一下这个map
,因为我们会在下一步对其进行修改。
int main() { map<int, string> race_placement { {1, "Mario"}, {2, "Luigi"}, {3, "Bowser"}, {4, "Peach"}, {5, "Yoshi"}, {6, "Koopa"}, {7, "Toad"}, {8, "Donkey Kong Jr."} }; print(race_placement);
让我来看下排位赛的某一圈的情况,Bowser因为赛车事故排在最后,Donkey Kong Jr. 从最后一名超到第三位。例子中首先要从map
中提取节点,因为这是唯一能修改键值的方法。extract
函数是C++17新加的特性。其可以从map
中删除元素,并没有内存重分配的副作用。看下这里是怎么用的吧。
{ auto a(race_placement.extract(3)); auto b(race_placement.extract(8));
现在我们要交换Bowser和Donkey Kong Jr.的键。键通常都是无法修改的,不过我们可以通过extract
方法来修改元素的键。
swap(a.key(), b.key());
std::map
的insert
函数在C++17中有一个新的重载版本,其接受已经提取出来的节点,就是为了在插入他们时,不会分配不必要的内存。
race_placement.insert(move(a)); race_placement.insert(move(b)); }
最后,我们打印一下目前的排位。
print(race_placement); }
编译并运行可以得到如下输出。我们可以看到初始的排位和最后的排位。
$ ./mapnode_key_modification Race placement: 1: Mario 2: Luigi 3: Bowser 4: Peach 5: Yoshi 6: Koopa 7: Toad 8: Donkey Kong Jr. Race placement: 1: Mario 2: Luigi 3: Donkey Kong Jr. 4: Peach 5: Yoshi 6: Koopa 7: Toad 8: Bowser
在C++17中,std::map
有一个新成员函数extract
。其有两种形式:
node_type extract(const_iterator position); node_type extract(const key_type& x)
在例子中,我们使用了第二个,能接受一个键值,然后找到这个键值,并提取对应的map
节点。第一个函数接受一个迭代器,提取的速度会更快,应为给定了迭代器就不需要在查找。
当使用第二种方式去提取一个不存在的节点时,会返回一个空node_type
实例。empty()
成员函数会返回一个布尔值,用来表明node_type
实例是否为空。以任何方式访问一个空的实例都会产生未定义行为。
提取节点之后,我们要使用key()
函数获取要修改的键,这个函数会返回一个非常量的键。
需要注意的是,要将节点重新插会到map
时,我们需要在insert
中移动他们。因为extract
可避免不必要的拷贝和内存分配。还有一点就是,移动一个node_type
时,其不会让容器的任何值发生移动。
使用extract
方法提取的map
节点实际上非常通用。我们可以从一个map
实例中提取出来节点,然后插入到另一个map
中,甚至可以插入到multimap
实例中。这种方式在unordered_map
和unordered_multimap
实例中也适用。同样在set/multiset
和unordered_set/unordered_multiset
也适用。
为了在不同map
或set
结构中移动元素,键、值和分配器的类型都必须相同。需要注意的是,不能将map
中的节点移动到unordered_map
中,或是将set
中的元素移动到unordered_set
中。
当我们使用std::unordered_map
代替std::map
时,对于键的选择要从另一个角度出发。std::map
要求键的类型可以排序。因此,元素可以进行排序。不过,当我们使用数学中的向量作为键呢?这样一来就没有判断哪个向量大于另一个向量,比如向量(0, 1)和(1, 0)无法相比较,因为它们指向的方向不同。在std::unordered_map
中这都不是问题,因为不需要对键的哈希值进行排序。对于我们来说只要为类型实现一个哈希函数和等同==
操作符的实现,等同操作符的是实现是为了判断两个对象是否完全相同。本节中,我们就来实验一下这个例子。
本节中,我们要定义一个简单的coord
数据结构,其没有默认哈希函数,所以我们必须要自行定义一个。然后我们会使用coord
对象来对应一些值。
包含使用std::unordered_map
所必须的头文件
#include <iostream> #include <unordered_map>
自定义数据结构,这是一个简单的数据结构,还不具备对应的哈希函数:
struct coord { int x; int y; };
实现哈希函数是为了能让类型作为键存在,这里先实现比较操作函数:
bool operator==(const coord &l, const coord &r) { return l.x == r.x && l.y == r.y; }
为了使用STL哈希的能力,我们打开了std命名空间,并且创建了一个特化的std::hash
模板。其使用using
将特化类型进行别名。
namespace std { template <> struct hash<coord> { using argument_type = coord; using result_type = size_t;
下面要重载该类型的括号表达式。我们只是为coord
结构体添加数字,这是一个不太理想的哈希方式,不过这里只是展示如何去实现这个函数。一个好的散列函数会尽可能的将值均匀的分布在整个取值范围内,以减少哈希碰撞。
result_type operator()(const argument_type &c) const { return static_cast<result_type>(c.x) + static_cast<result_type>(c.y); } }; }
我们现在可以创建一个新的std::unordered_map
实例,其能结构coord
结构体作为键,并且对应任意值。例子中对std::unordered_map
使用自定义的类型来说,已经很不错了。让我们基于哈希进行实例化,并填充自定义类型的map
表,并打印这个map
表:
int main() { std::unordered_map<coord, int> m { { {0, 0}, 1}, { {0, 1}, 2}, { {2, 1}, 3} }; for (const auto & [key, value] : m) { std::cout << "{(" << key.x << ", " << key.y << "): " << value << "} "; } std::cout << '\n'; }
编译运行这个例子,就能看到如下的打印信息:
$ ./custom_type_unordered_map
{(2, 1): 3} {(0, 1): 2} {(0, 0): 1}
通常实例化一个基于哈希的map表(比如: std::unordered_map
)时,我们会这样写:
std::unordered_map<key_type, value_type> my_unordered_map;
编译器为我们创建特化的std::unordered_map
时,这句话背后隐藏了大量的操作。所以,让我们来看一下其完整的模板类型声明:
template< class Key, class T, class Hash = std::hash<Key>, class KeyEqual = std::equal_to<Key>, class Allocator = std::allocator< std::pair<const Key, T> > > class unordered_map;
这里第一个和第二个模板类型,我么填写的是coord
和int
。另外的三个模板类型是选填的,其会使用已有的标准模板类。这里前两个参数需要我们给定对应的类型。
对于这个例子,class Hash
模板参数是最有趣的一个:当我们不显式定义任何东西时,其就指向std::hash<key_type>
。STL已经具有std::hash
的多种特化类型,比如std::hash<std::string>
、std::hash<int>
、std::hash<unique_ptr>
等等。这些类型中可以选择最优的一种类型类解决对应的问题。
不过,STL不知道如何计算我们自定义类型coord
的哈希值。所以我们要使用我们定义的类型对哈希模板进行特化。编译器会从std::hash
特化列表中,找到我们所实现的类型,也就是将自定义类型作为键的类型。
如果新特化一个std::hash<coord>
类型,并且将其命名成my_hash_type,我们可以使用下面的语句来实例化这个类型:
std::unordered_map<coord, value_type, my_hash_type> my_unordered_map;
这样命名就很直观,可读性好,而且编译器也能从哈希实现列表中找到与之对应的正确的类型。
std::set
是一个奇怪的容器:工作原理和std::map
很像,不过std::set
将键作为值,没有键值对。所以没做办法与其他类型的数据进行映射。表面上看,std::set
因为没有太多的例子,导致很多开发者几乎不知道有这样的容器。想要使用类似std::set
的功能时,只有自己去实现一遍。
本节展示如何使用std::set
收集很多不同的元素,过滤这些元素,最后只输出一个元素。
从标准输入流中读取单词,所有不重复的单词将放到一个std::set
实例中。之后,枚举出所有输入流中不重复的单词。
包含必要的头文件。
#include <iostream> #include <set> #include <string> #include <iterator>
为了分析我们的输入,会使用到std
命名空间:
using namespace std;
现在来实现主函数,先来实例化一个std::set
。
int main() { set<string> s;
下一件事情就是获取用户的输入。我们只从标准输入中读取,这样我们就要用到istream_iterator
。
istream_iterator<string> it {cin}; istream_iterator<string> end;
这样就得到了一对begin
和end
迭代器,可以用来表示用户的输入,我们可以使用std::inserter
来填满set
实例。
copy(it, end, inserter(s, s.end()));
这样就完成了填充。为了看到从标准输入获得的不重复单词,我们可以打印当前的set
实例。
for (const auto word : s) { cout << word << ", "; } cout << '\n'; }
最后,让我们编译并运行这个程序。从之前的输入中,重复的单词都会去除,获得不重复的单词,然后以字母序排序输出。
$ echo "a a a b c foo bar foobar foo bar bar" | ./program
a, b, bar, c, foo, foobar,
程序中有两个有趣的部分。第一个是使用了std::istream_iterator
来访问用户输入,另一个是将std::set
实例使用std::inserter
用包装后,在使用std::copy
填充。这看起来像是变魔术一样,只需要一行代码,我们就能完成使用输入流填充实例,去除重复的单词和以字母序进行排序。
std::istream_iterator
这个例子的有趣之处在于一次性可以处理流中大量相同类型的数据:我们对整个输入进行逐字的分析,并以std::string
实例的方式插入set
。
std::istream_iterator
只传入了一个模板参数。也就我们输入数据的类型。我们选择std::string
是因为我们假设是文本输入,不过这里也可以是float
型的输入。基本上任何类型都可以使用cin >> var;
完成。构造函数接受一个istream
实例。标准输入使用全局输入流std::cin
表示,例子中其为istream
的参数。
istream_iterator<string> it {cin};
输入流迭代器it
就已经实例化完毕了,其可以做两件事情:当对其解引用(*it
)时,会得到当前输入的符号。我们通过输入迭代器构造std::string
实例,每个字符串容器中都包含一个单词;当进行自加++it
时,其会跳转到下一个单词,然后再解引用访问下一个单词。
不过,每次自加后的解引用时都须谨慎。当标准输入为空,迭代器就不能再解引用。另外,我们需要终止使用解引用获取单词的循环。终止的条件就是通过和end
迭代器进行比较,知道何时迭代器无法解引用。如果it==end
成立,那么说明输入流已经读取完毕。
我们在创建it
的同时,也创建了一个std::istream_iterator
的end
迭代器。其目的是于it
进行比较,在每次迭代中作为中止条件。
当std::cin
结束时,it
迭代器将会与end
进行比较,并返回true。
std::inserter
调用std::copy
时,我们使用it
和end
作为输入迭代器。第三个参数必须是一个输出迭代器。因此,不能使用s.begin()
或s.end()
。一个空的set
中,这二者是一致的,所以不能对set
的迭代器进行解引用(无论是读取或赋值)。
这就使std::inserter
有了用武之地。其为一个函数,返回一个std::insert_iterator
,返回值的行为类似一个迭代器,不过会完成普通迭代器无法完成的事。当对其使用加法时,其不会做任何事。当我们对其解引用,并赋值给它时,它会连接相关容器,并且将赋值作为一个新元素插入容器中。
当通过std::inserter
实例化std::insert_iterator
时,我们需要两个参数:
auto insert_it = inserter(s, s.end());
其中s是我们的set
,s.end()
是指向新元素插入点的迭代器。对于一个空set
来说,从哪里开始和从哪里结束一样重要。当使用其他数据结构时,比如vector
和list
,第二个参数对于定义插入新项的位置来说至关重要。
将二者结合
最后,所有的工作都在std::copy
的调用中完成:
copy(input_iterator_begin, input_iterator_end, insert_iterator);
这个调用从std::cin
中获取输入迭代器,并将其推入std::set
中。之后,其会让迭代器自增,并且确定输入迭代器是否达到末尾。如果不是,那么可以继续从标准输入中获取单词。
重复的单词会自动去除。当set
已经拥有了一个单词,再重复将这个单词添加入set
时,不会产生任何效果。与std::multiset
的表现不同,std::multiset
会接受重复项。
std::stack
是一个适配类,其能让用户使用自己定义的类型作为栈中的元素。本节中,我们会使用std::stack
构造一个逆波兰(RPN,reverse polish notation)计算器,为了展示如何使用std::stack
。
RPN是一种记号法,可以用一种非常简单的解析方式来表达数学表达式。在RPN中,1+2
解析为1 2 +
。操作数优先,然后是操作符。另一个例子:(1+2)*3
表示为1 2 + 3 *
。这两个例子已经展示了RPN可以很容易的进行解析,并且不需要小括号来定义子表达式。
本节中,我们将从标准输入中读取一个RPN表达式,然后根据表达式解析出正确的计算顺序,并得到结果。最后,我们将输出得到的结果。
包含必要的头文件。
#include <iostream> #include <stack> #include <iterator> #include <map> #include <sstream> #include <cassert> #include <vector> #include <stdexcept> #include <cmath>
声明所使用的命名空间。
using namespace std;
然后,就来实现我们的RPN解析器。其能接受一对迭代器,两个迭代器分别指定了数学表达式的开始和结尾。
template <typename IT> double evaluate_rpn(IT it, IT end) {
在遍历输入时,需要记住所经过的所有操作数,直到我们看到一个操作符为止。这也就是使用栈的原因。所有数字将会被解析出来,然后以双精度浮点类型进行保存,所以保存到栈中的数据类型为double
。
stack<double> val_stack;
为了能更方便的访问栈中的元素,我们实现了一个辅助函数。其会修改栈中内容,弹出最顶端的元素,并返回这个元素。
auto pop_stack ([&](){ auto r (val_stack.top()); val_stack.pop(); return r; });
另一项准备工作,就是定义所支持的数学操作符。我们使用map
保存相关数学操作符的作用。每个操作符的实现我们使用Lambda函数实现。
map<string, double (*)(double, double)> ops { {"+", [](double a, double b) { return a + b; }}, {"-", [](double a, double b) { return a - b; }}, {"*", [](double a, double b) { return a * b; }}, {"/", [](double a, double b) { return a / b; }}, {"^", [](double a, double b) { return pow(a, b); }}, {"%", [](double a, double b) { return fmod(a, b); }}, };
现在就可以对输入进行遍历了。假设我们的输入是字符串,我们使用全新的std::stringstream
获取每个单词,这样就可以将操作数解析为数字了。
for (; it != end; ++it) { stringstream ss {*it};
我们获得的每个操作数,都要转换成double
类型。如果当前解析的字符是操作数,那么我们将转换类型后,推入栈中。
if (double val; ss >> val) { val_stack.push(val); }
如果不是操作数,那么就必定为一个操作符。我们支持的操作符都是二元的,所以当遇到操作符时,我们需要从栈中弹出两个操作数。
else { const auto r {pop_stack()}; const auto l {pop_stack()};
现在我们可以从解引用迭代器it
获取操作数。通过查询opsmap
表,我们可以获得参与Lambda计算的l和r值。
try { const auto & op (ops.at(*it)); const double result {op(l, r)}; val_stack.push(result); }
我们使用try
代码块将计算代码包围,因为我们的计算可能会出错。在调用map
的成员函数at
时,可能会抛出一个out_of_range
异常,由于用户具体会输入什么样的表达式,并不是我们能控制的。所以,我们将会重新抛出一个不同的异常,我们称之为invalid argument
异常,并且携带着程序未知的操作符。
catch (const out_of_range &) { throw invalid_argument(*it); }
这就是遍历循环的全部,我们会将栈中的操作数用完,然后得到对应的结果,并将结果保存在栈顶。所以我们要返回栈顶的元素。(我们对栈的大小进行断言,如果大小不是1,那么就有缺失的操作符)
} } return val_stack.top(); }
现在我们可以使用这个RPN解析器了。为了使用这个解析器,我们需要将标准输入包装成一个std::istream_iterator
迭代器对,并且传入RPN解析器函数。最后,我们将输出结果:
int main() { try { cout << evaluate_rpn(istream_iterator<string>{cin}, {}) << '\n'; }
这里我们再次使用了try
代码块,因为用户输入的表达式可能会存在错误,所以当解析器抛出异常时,需要在这里获取。我们需要获取对应的异常,并且打印出一条错误信息:
catch (const invalid_argument &e) { cout << "Invalid operator: " << e.what() << '\n'; } }
完成编译步骤后,我们就可以使用这个解析器了。输入3 1 2 + * 2 /
,其为(3*(1+2))/2
数学表达式的RPN表达式,然后我们获得相应的结果:
$ echo "3 1 2 + * 2 /" | ./rpn_calculator 4.5
整个例子通过解析我们的输入,持续向栈中压入操作数的方式完成相应的数学计算。本例中,我们会从栈中弹出最后两个操作数,然后使用操作符对这两个操作数进行计算,然后将其结果保存在栈中。为了理解本节中的所有代码,最重要的就是要理解,我们如何区分了输入中的操作数和操作符,如何管理我们的栈,以及如何选择正确的计算操作符。
栈管理
我们使用std::stack
中的成员函数push
将元素推入栈中:
val_stack.push(val);
出站元素的获取看起来有些复杂,因为我们使用了一个Lambda表达式完成这项操作,其能够引用val_stack
对象。这里我们为代码添加了一些注释,可能会更好理解一些:
auto pop_stack ([&](){ auto r (val_stack.top()); // 获取栈顶元素副本 val_stack.pop(); // 从栈中移除顶部元素 return r; // 返回顶部元素副本 });
这个Lambda表达式能够一键式获取栈顶元素,并且能删除顶部元素。在std::stack
的设计当中,无法使用一步完成这些操作。不过,定义一个Lambda函数也是十分快捷和简介,所以我们可以使用这种方式获取值:
double top_value {pop_stack()};
从输入中区别操作数和操作符
主循环中执行evaluate_rpn
时,我们会根据迭代器遍历标准输入,然后判断字符是一个操作数,还是一个操作符。如果字符可以被解析成double
变量,那这就是一个数,也就是操作数。我们需要考虑有些比较难以解析的数值(比如,+1和-1),这种数值可能会被解析成操作符(尤其是+1这种)。
用于区分操作数和操作符的代码如下所示:
stringstream ss {*it}; if (double val; ss >> val) { // It's a number! } else { // It's something else than a number - an operation! }
如果字符是一个数字,流操作符>>
会告诉我们。首先,我们将字符串包装成一个std::stringstream
。然后使用stringstream
对象的能力,将流中std::string
类型解析并转换成一个double
变量。解析失败时也能知道是为什么,因为只解析器需要解析数字出来;否则,需要解析的就不是一个数字。
选择和应用正确的数学操作符
判断完当前用户的输入是否为一个数后,我们先假设输入了一个操作符,比如+
或*
。然后,查询map
表ops,找到对应的操作,并返回相应的函数,其函数可以接受两个操作数,然后返回对应操作后的结果。
map
表本身的类型看起来会相对复杂:
map<string, double (*)(double, double)> ops { ... };
其将string
映射到double (*)(double, double)
。后者是什么意思呢?这个类型是一个函数指针的声明,说明这个函数接受两个double类型的变量作为输入,并且返回值也是double
类型。可以将(*)
部分理解成函数的名字,例如double sum(double, double
,这样就好理解多了吧。这里的重点在于我们的Lambda函数[](double, double) {return /* some double */ }
,其可转换为实际匹配指针声明的函数。这里Lambda不获取任何东西,所以可以转化为函数指针。
这样,我们就可以方便的在map
表中查询操作符是否支持:
const auto & op (ops.at(*it)); const double result {op(l, r)};
map
会为我们隐式的做另一件事:当我们执行ops.at("foo")
时,如果"foo"
是一个合法键(实际中我们不会用这个名字存任何操作),那么在这个例子中,map
表将会抛出一个异常,例子中可以捕获这个异常。当我们捕获这个异常时,我们会重新抛出一个不同的异常,为了描述我们遇到了什么样的错误。相较于out of range
,用户也能更好的了解invalid argument
异常的含义,因此我们在使用的时候,程序的map
表到底支持哪些操作,我们是不知道的。
evaluate_rpn
函数可以传入迭代器,感觉这样传递的方式要比传入标准输入更加容易理解。这让程序更容易测试,或适应来自于用户的不同类型的输入。
使用字符串流或字符串数组的迭代器作为输入,例如下面的代码,evaluate_rpn
不用做任何修改:
int main() { stringstream s {"3 2 1 + * 2 /"}; cout << evaluate_rpn(istream_iterator<string>{s}, {}) << '\n'; vector<string> v {"3", "2", "1", "+", "*", "2", "/"}; cout << evaluate_rpn(begin(v), end(v)) << '\n'; }
Note:
在有意义的地方使用迭代器,会使得代码可重复利用度高,模块化好。
std::map
在收集和统计数据方面非常有用,通过建立键值关系,将可修改的对象映射到对应键上,可以很容易的实现一个词频计数器。
本节中,我们将从标准输入中获取用户的输入,或是从记录一部小说的文本文件。我们会去标记输入单词,并统计一共有多少个单词。
包含必要的头文件。
#include <iostream> #include <map> #include <vector> #include <algorithm> #include <iomanip>
声明所使用的命名空间。
using namespace std;
我们将使用一个辅助函数,对输入中的符号进行处理。
string filter_punctuation(const string &s) { const char *forbidden {".,:; "}; const auto idx_start (s.find_first_not_of(forbidden)); const auto idx_end (s.find_last_not_of(forbidden)); return s.substr(idx_start, idx_end - idx_start + 1); }
现在,我们来实现真正要工作的部分。使用map
表对输入的每个单词进行统计。另外,使用一个变量来保存目前为止看到的最长单词的长度。程序的最后,我们将打印这个map
表。
int main() { map<string, size_t> words; int max_word_len {0};
将标准输入导入std::string
变量中,标准输入由空格隔开。通过如下方法获取输入单词。
string s; while (cin >> s) {
我们获得的单词可能包含标点符号,因为这些符号可能紧跟在单词后面。使用辅助函数将标点符号去除。
auto filtered (filter_punctuation(s));
如果当前处理的单词是目前处理最长的单词,我们会更新max_word_len
变量。
max_word_len = max<int>(max_word_len, filtered.length());
然后,我们将增加该词在words map
中的频率。如果是首次处理该单词,那么将会隐式创建一个键值对,然后插入map
,之后再进行自加操作。
++words[filtered]; }
当循环结束时,words map
会保存所有输入单词的频率。map
中单词作为键,并且键以字母序排列。我们想要以频率多少进行排序,词频最高的排第一位。为了达到这样的效果,首先实现一个vector
,将所有键值对放入这个vector
中。
vector<pair<string, size_t>> word_counts; word_counts.reserve(words.size()); move(begin(words), end(words), back_inserter(word_counts));
然后,vector
中将将具有words map
中的所有元素。然后,我们来进行排序,把词频最高的单词排在最开始,最低的放在最后。
sort(begin(word_counts), end(word_counts), [](const auto &a, const auto &b) { return a.second > b.second; });
现在所有元素如我们想要的顺序排列,之后将这些数据打印在用户的终端上。使用std::setw
流控制器,可以格式化输出相应的内容。
cout << "# " << setw(max_word_len) << "<WORD>" << " #<COUNT>\n"; for (const auto & [word, count] : word_counts) { cout << setw(max_word_len + 2) << word << " #" << count << '\n'; } }
编译后运行,我们就会得到一个词频表:
$ cat lorem_ipsum.txt | ./word_frequency_counter # <WORD> #<COUNT> et #574 dolor #302 sed #273 diam #273 sit #259 ipsum #259 ...
本节中,我们使用std::map
实例进行单词统计,然后将map
中的所有元素放入vector
中,然后进行排序,再打印输出。为什么要这么做?
先看一个例子。当我们要从a a b c b b b d c c
字符串中统计词频时,我们的map
内容如下:
a -> 2
b -> 4
c -> 3
d -> 1
不过,这是未排序的,这不是我们想要给用户展示的排序。我们的程序要首先输出b的频率,因为b的频率最高。然后是c,a,d。不幸的是,我们无法要求map
使用键所对应的值进行排序。
这就需要vector
帮忙了,将map
中的键值对放入vector
中。这个方法明确的将这些元素从map
中删除了。
vector<pair<string, size_t>> word_counts;
然后,我们使用std::move
函数将词-频对应关系填充整个vector
。这样的好处是让单词不会重复,不过这样会将元素从map
中完全删除。使用move
方法,减少了很多不必要的拷贝。
move(begin(words), end(words), back_inserter(word_counts));
Note
一些STL的实现使用短字符优化——当所要处理的字符串过长,这种方法将无需再在堆上分配内存,并且可以将字符串直接进行存储。在这个例子中,移动虽然不是最快的方式,但也不会慢多少。
接下来比较有趣的就是排序操作,其使用了一个Lambda表达式作为自定义比较谓词:
sort(begin(word_counts), end(word_counts), [](const auto &a, const auto &b) { return a.second > b.second; });
排序算法将会成对的处理元素,比较两个元素。通过提供的Lambda函数,sort
方法将不会再使用默认比较谓词,其会将a.second
和b.second
进行比较。这里的键值对中,第二个值为词频数,所以可以使用.second
得到对应词频数。通过这种方式,将移动所有高频率的词到vector
的开始,并且将低频率词放在末尾。
当有超级多的元素需要排序时,某些键值描述可能会出现多次,那么使用std::multimap
完成这项工作无疑是个不错的选择。
先找个应用场景:当使用德文写作时,使用很长的句子是没有问题的。不过,使用英文时,就不行了。我们将实现一个辅助工具来帮助德国作家们分析他们的英文作品,着重于所有句子的长度。为了帮助这些作家改善其写作的文本风格,工具会按句子的长度对每个句子进行分组。这样作家们就能挑出比较长的句子,然后截断这些句子。
本节中,我们将从标准输入中获取用户输入,用户会输入所有的句子,而非单词。然后,我们将这些句子和其长度收集在std::multimap
中。之后,我们将对所有句子的长度进行排序,打印给用户看。
包含必要的头文件。std::multimap
和std::map
在同一个头文件中声明。
#include <iostream> #include <iterator> #include <map> #include <algorithm>
声明所使用的命名空间。
using namespace std;
我们使用句号将输入字符串分成若干个句子,句子中的每个单词以空格隔开。句子中的一些对于句子长度无意义的符号,也会计算到长度中,所以,这里要使用辅助函数将这些符号过滤掉。
string filter_ws(const string &s) { const char *ws {" \r\n\t"}; const auto a (s.find_first_not_of(ws)); const auto b (s.find_last_not_of(ws)); if (a == string::npos) { return {}; } return s.substr(a, b); }
计算句子长度函数需要接收一个包含相应内容的字符串,并且返回一个std::multimap
实例,其映射了排序后的句子长度和相应的句子。
multimap<size_t, string> get_sentence_stats(const string &content) {
这里声明一个multimap
结构,以及一些迭代器。在计算长度的循环中,我们需要end
迭代器。然后,我们使用两个迭代器指向文本的开始和结尾。所有句子都在这个文本当中。
multimap<size_t, string> ret; const auto end_it (end(content)); auto it1 (begin(content)); auto it2 (find(it1, end_it, '.'));
it2
总是指向句号,而it1
指向句子的开头。只要it1
没有到达文本的末尾就好。第二个条件就是要检查it2
是否指向字符。如果不满足这些条件,那么就意味着这两个迭代器中没有任何字符了:
while (it1 != end_it && distance(it1, it2) > 0) {
我们使用两个迭代器间的字符创建一个字符串,并且过滤字符串中所有的空格,只是为了计算句子纯单词的长度。
string s {filter_ws({it1, it2})};
当句子中不包含任何字符,或只有空格时,我们就不统计这句。另外,我们要计算有多少单词在句子中。这很简单,每个单词间都有空格隔开,单词的数量很容易计算。然后,我们就将句子和其长度保存在multimap
中。
if (s.length() > 0) { const auto words (count(begin(s), end(s), ' ') + 1); ret.emplace(make_pair(words, move(s))); }
对于下一次循环迭代,我们将会让it1
指向it2
的后一个字符。然后将it2
指向下一个句号。
it1 = next(it2, 1); it2 = find(it1, end_it, '.'); }
循环结束后,multimap
包含所有句子以及他们的长度,这里我们将其返回。
return ret; }
现在,我们来写主函数。首先,我们让std::cin
不要跳过空格,因为我们需要句子中有空格。为了读取整个文件,我们使用std::cin
包装的输入流迭代器初始化一个std::string
实例。
int main() { cin.unsetf(ios::skipws); string content {istream_iterator<char>{cin}, {}};
只需要打印multimap
的内容,在循环中调用get_sentence_stats
,然后打印multimap
中的内容。
for (const auto & [word_count, sentence] : get_sentence_stats(content)) { cout << word_count << " words: " << sentence << ".\n"; } }
编译完成后,我们可以使用一个文本文件做例子。由于长句子的输出量很长,所以先把最短的句子打印出来,最后打印最长的句子。这样,我们就能首先看到最长的句子。
$ cat lorem_ipsum.txt | ./sentence_length
...
10 words: Nam quam nunc, blandit vel, luctus pulvinar,
hendrerit id, lorem.
10 words: Sed consequat, leo eget bibendum sodales,
augue velit cursus nunc,.
12 words: Cum sociis natoque penatibus et magnis dis
parturient montes, nascetur ridiculus mus.
17 words: Maecenas tempus, tellus eget condimentum rhoncus,
sem quam semper libero, sit amet adipiscing sem neque sed ipsum.
整个例子中,我们将一个很长的字符串,分割成多个短句,从而评估每个句子的长度,并且在multimap
中进行排序。因为std::multimap
很容易使用,所以变成较为复杂的部分就在于循环,也就是使用迭代器获取每句话的内容。
const auto end_it (end(content)); // (1) Beginning of string auto it1 (begin(content)); // (1) First '.' dot auto it2 (find(it1, end_it, '.')); while (it1 != end_it && std::distance(it1, it2) > 0) { string sentence {it1, it2}; // Do something with the sentence string... // One character past current '.' dot it1 = std::next(it2, 1); // Next dot, or end of string it2 = find(it1, end_it, '.'); }
将代码和下面的图结合起来可能会更好理解,这里使用具有三句话的字符串来举例。
it1
和it2
总是随着字符串向前移动。通过指向句子的开头和结尾的方式,确定一个句子中的内容。std::find
算法会帮助我们寻找下一个句号的位置。
std::find的描述:
从当前位置开始,返回首先找到的目标字符迭代器。如果没有找到,返回结束迭代器。
这样我们就获取了一个句子,然后通过构造对应字符串的方式,将句子的长度计算出来,并将长度和原始句子一起插入multimap
中。我们使用句子的长度作为元素的键,原句作为值存储在multimap
中。通常一个文本中,长度相同的句子有很多。这样使用std::map
就会比较麻烦。不过std::multimap
就没有重复键值的问题。这些键值也是排序好的,从而能得到用户们想要的输出。
将整个文件读入一个大字符串中后,遍历字符串时需要为每个句子创建副本。这是没有必要的,这里可以使用std::string_view
来完成这项工作,该类型我们将会放在后面来介绍。
另一种从两个句号中获取句子的方法就是使用std::regex_iterator
(正则表达式),我们将会在后面的章节中进行介绍。
std::priority_queue
是另一种适配容器(类似于std::stack
)。其实为另一种数据结构的包装器(默认的数据结构为std::vector
),并且提供类似队列的接口。同样也遵循队列的特性,先进先出。这与我们之前使用的std::stack
完全不同。
这里仅仅是对std::queue
的行为进行描述,本节将展示std::priority_queue
是如何工作的。这个适配器比较特殊,其不仅有FIFO的特性,还混合着优先级。这就意味着,FIFO的原则会在某些条件下被打破,根据优先级的顺序形成子FIFO队列。
本节中,我们将创建一个待办事项的结构。为了程序的简明性就不从用户输入解析输入了。这次专注于std::priority_queue
的使用。所以我们使用一些待办事项和优先级填充一个优先级序列,然后以FIFO的顺序读出这些元素(这些元素是通过优先级进行过分组)。
包含必要的头文件。std::priority_queue
在<queue>
中声明。
#include <iostream> #include <queue> #include <tuple> #include <string>
我们怎么将待办事项存在优先级队列中呢?我们不能添加项目时,附加优先级。优先级队列将使用自然序对待队列中的所有元素。现在我们实现一个自定义的结构体struct todo_item
,并赋予其优先级系数,和一个字符串描述待办事件,并且为了让该结构体具有可排序性,这里会实现比较操作符<
。另外,我们将会使用std::pair
,其能帮助我们聚合两个类型为一个类型,并且能完成自动比较。
int main() { using item_type = std::pair<int, std::string>;
那么现在我们有了一个新类型item_type
,其由一个优先级数字和一个描述字符串构成。所以,我们可以使用这种类型实例化一个优先级队列。
std::priority_queue<item_type> q;
我们现在来填充优先级队列。其目的就是为了提供一个非结构化列表,之后优先级队列将告诉我们以何种顺序做什么事。比如,你有漫画要看的同时,也有作业需要去做,那么你必须先去写作业。不过,std::priority_queue
没有构造函数,其支持初始化列表,通过列表我们能够填充优先级队列(使用vector
或list
都可以对优先级队列进行初始化)。所以我们这里定义了一个列表,用于下一步的初始化。
std::initializer_list<item_type> il { {1, "dishes"}, {0, "watch tv"}, {2, "do homework"}, {0, "read comics"}, };
现在我们可以很方便的遍历列表中的所有元素,然后通过push
成员函数将元素插入优先级列表中。
for (const auto &p : il) { q.push(p); }
这样所有的元素就都隐式的进行了排序,并且我们可以浏览列表中优先级最高的事件。
while(!q.empty()) { std::cout << q.top().first << ": " << q.top().second << '\n'; q.pop(); } std::cout << '\n'; }
编译运行程序。结果如我们所料,作业是最优先的,看电视和看漫画排在最后。
$ ./main 2: do homework 1: dishes 0: watch tv 0: read comics
std::priority_queue
使用起来很简单。我们只是用了其三个成员函数。
q.push(item)
将元素推入队列中。q.top()
返回队首元素的引用。q.pop()
移除队首元素。不过,如何做到排序的呢?我们将优先级数字和描述字符串放入一个std::pair
中,然后就自然得到排序后的结果。这里有一个std::pair<int, std::string>
的实例p
,我们可通过p.first
访问优先级整型数,使用p.second
访问字符串。我们在循环中就是这样打印所有待办事件的。
如何让优先级队列意识到{2, "do homework"}
要比{0, "watch tv"}
重要呢?
比较操作符<
在这里处理了不同的元素。我们假设现在有left < right
,两个变量的类型都是pair。
left.first != right.first
,然后返回left.first < right.first
。left.first == right.first
,然后返回left.second < right.second
。以这种方式就能满足我们的要求。最重要的就是pair
中第一个成员,然后是第二个成员。否则,std::priority_queue
将会字母序将元素进行排序,而非使用数字优先级的顺序(这样的话,看电视将会成为首先要做的事情,而完成作业则是最后一件事。对于懒人来说,无疑是个完美的顺序)。
迭代器是C++中非常重要的概念。STL旨在打造一组灵活和通用的工具集,迭代器是工具集中重要的一环。不过,有时候迭代器使用起来比较繁琐,所以很多编程人员还是喜欢用C的指针来完成相应的功能。一半的编程人员基本上会放弃使用STL中的迭代器。本章介绍了迭代器,并展示如何让它们很快的工作起来。快速地介绍是不能完全覆盖迭代器强大的功能,但是这种小例子能让你增加对迭代器的好感度。
大多数容器类(除了类似C风格的数组),可包含一系列的数据项。许多日常任务会处理超大的数据量,这里先不关心如何获得这些数据。不过,如果我们考虑数组和链表,并且想要计算这两种结构所有项的和,那么将如下使用两种不同的算法:
通过查询数组的大小,来进行加和计算:
int sum {0}; for (size_t i {0}; i < array_size; ++i) { sum += array[i]; }
使用迭代器进行循环,直到数组的末尾:
int sum {0}; while (list_node != nullptr) { sum += list_node->value; list_node = list_node->next; }
两种方法都能计算出所有项的加和,不过我们键入的代码,有多少用在实际加和任务中了呢?如果说要使用其他结构体来存储这些数据,例如std::map
,难道我们还要在重新实现一个函数?使用迭代器是最佳的选择。
使用迭代器的代码才更加的通用:
int sum {0}; for (int i : array_or_vector_or_map_or_list) { sum += i; }
这段代码很简洁,只是使用C++11添加的for循环范围特性就完成了整体的叠加。其就像是个语法糖,将其扩展后类似如下代码:
{ auto && __range = array_or_vector_or_map_or_list ; auto __begin = std::begin(__range); auto __end = std::end(__range); for ( ; __begin != __end; ++__begin) { int i = *__begin; sum += i; } }
这段代码对于使用迭代器的老手来说并没有什么,不过对于刚接触迭代器的新手来说就像是在变魔术。
假设我们的vector
内容如下所示:
std::begin(vector)
和vector.begin()
等价,并且返回vector
中指向第一个元素的迭代器(指向1)。std::end(vector)
与vector.end()
等价,并返回指向vector
末尾元素的迭代器(指向5的后方)。
每一次迭代,循环都会检查开始迭代器是否与末尾迭代器不同。如果是,那么可以对开始迭代器进行解引用,并获取其指向的值。然后,推动迭代器指向下一个元素,再与末尾迭代器进行比较,以此类推。这也能提升代码的可读性,这样的迭代器就类似于C风格的指针。实际上,C风格的指针也是一种迭代器。
C++中很多迭代器类型,都有各自的局限性。不用去死记这些限制,只要记住一种类型的能力是从更强大的类型继承过来的即可。当知道算法是使用何种迭代器实现时,编译器就可以以更好的方式优化这个算法。所以,开发者只要表达清楚自己想要实现的算法,那么编译器将选择优化后的实现来完成对应的任务。
让我们来看下这些迭代器吧(从左往右):
只能用来读取指向的值。当该迭代器自加时,之前指向的值就不可访问。也就是说,不能使用这个迭代器在一个范围内遍历多次。std::istream_iterator
就是这样的迭代器。
类似于输入迭代器,不过其可以在指示范围内迭代多次。std::forward_list
就是这样的迭代器。就像一个单向链表一样,只能向前遍历,不能向后遍历,但可以反复迭代。
从名字就能看出来,这个迭代器可以自增,也可以自减,迭代器可以向前或向后迭代。std::list
,std::set
和std::map
都支持双向迭代器。
与其他迭代器不同,随机访问迭代器一次可以跳转到任何容器中的元素上,而非之前的迭代器,一次只能移动一格。std::vector
和std::deque
的迭代器就是这种类型。
这种迭代器具有前述几种迭代器的所有特性,不过需要容器内容在内存上是连续的,类似一个数组或std::vector
。
该迭代器与其他迭代器不同。因为这是一个单纯用于写出的迭代器,其只能增加,并且将对应内容写入文件当中。如果要读取这个迭代中的数据,那么读取到的值就是未定义的。
如果一个迭代器既有输出迭代器的特性,又有其他迭代器的特性,那么这个迭代器就是可变迭代器。该迭代器可读可写。如果我们从一个非常量容器的实例中获取一个迭代器,那么这个迭代器通常都是可变迭代器。
我们已经认识了STL中提供的各种迭代器。我们只需实现一个迭代器,支持前缀加法++
,解引用*
和比较操作==
,这样我们就能使用C++11基于范围的for循环对该迭代器进行遍历。
为了更好的了解迭代器,本节中将展示如何实现一个迭代器。迭代该迭代器时,只输出一组数字。实现的迭代器并不支持任何容器,以及类似的结构。这些数字是在迭代过程中临时生成的。
本节中,我们将实现一个迭代器类,并且对该迭代器进行迭代:
包含必要的头文件。
#include <iostream>
迭代器结构命名为num_iterator
:
class num_iterator {
其数据类型只能是整型,仅用是用来计数的,构造函数会初始化它们。显式声明构造函数是一个好习惯,这就能避免隐式类型转换。需要注意的是,我们会使用position
值来初始化i
。这就让num_iterator
可以进行默认构造。虽然我们的整个例子中都没有使用默认构造函数,但默认构造函数对于STL的算法却是很重要的。
int i; public: explicit num_iterator(int position = 0) : i{position} {}
当对迭代器解引用时*it`,将得到一个整数:
int operator*() const { return i; }
前缀加法操作++it
:
num_iterator& operator++() { ++i; return *this; }
for
循环中需要迭代器之间进行比较。如果不相等,则继续迭代:
bool operator!=(const num_iterator &other) const { return i != other.i; } };
迭代器类就实现完成了。我们仍需要一个中间对象对应于for (int i : intermediate(a, b)) {...}
写法,其会从头到尾的遍历,其为一种从a到b遍历的预编程。我们称其为num_range
:
class num_range {
其包含两个整数成员,一个表示从开始,另一个表示结束。如果我们要从0到9遍历,那么a为0,b为10([0, 10)
):
int a; int b; public: num_range(int from, int to) : a{from}, b{to} {}
该类也只有两个成员函数需要实现:begin
和end
函数。两个函数都返回指向对应数字的指针:一个指向开始,一个指向末尾。
num_iterator begin() const { return num_iterator{a}; } num_iterator end() const { return num_iterator{b}; } };
所有类都已经完成,让我们来使用一下。让我们在主函数中写一个例子,遍历100到109间的数字,并打印这些数值:
int main() { for (int i : num_range{100, 110}) { std::cout << i << ", "; } std::cout << '\n'; }
编译运行后,得到如下输出:
100, 101, 102, 103, 104, 105, 106, 107, 108, 109,
考虑一下如下的代码段:
for (auto x : range) { code_block; }
这段代码将被编译器翻译为类似如下的代码:
{ auto __begin = std::begin(range); auto __end = std::end(range); for ( ; __begin != __end; ++__begin) { auto x = *__begin; code_block } }
这样看起来就直观许多,也能清楚的了解我们的迭代器需要实现如下操作:
也需要begin
和end
方法返回相应的迭代器,用来确定开始和结束的范围。
Note:
本书中,我们使用
std::begin(x)
替代x.begin()
。如果有begin
成员函数,那么std::begin(x)
会自动调用x.begin()
。当x
是一个数组,没有begin()
方法是,std::begin(x)
会找到其他方式来处理。同样的方式也适用于std::end(x)
。当用户自定义的类型不提供begin/end
成员函数时,std::begin/std::end
就无法工作了。
本例中的迭代器是一个前向迭代器。再来看一下使用num_range
的循环,从另一个角度看是非常的简单。
Note:
回头看下构造出迭代器的方法在
range
类中为const
。这里不需要关注编译器是否会因为修饰符const
而报错,因为迭代const
的对象是很常见的事。
上一节中,我们实现了自己的迭代器,不过为了融合STL提供的迭代器的优点,我们需要提供一些迭代器接口。后面我们会来学习如果实现这些接口,不过将我们自定义的迭代器与STL的标准迭代器放在一起时,有时会发现有编译不通过的问题。这是为什么呢?
STL算法尝试寻找更多有关于我们所使用迭代器的信息。不同迭代器的能力是不同的,不大可能用同样的算法实现不同的迭代器。例如,我们只是简单的从一个std::vector
将其中的数字拷贝到另一个时,我们的实现中可以直接调用memcpy
快速实现这个功能。如果容器是std::list
的话,memcpy
的方式就不好用了,只能一个个的单独拷贝。实现者将大量的自动优化思想注入STL算法实现当中。为了能更好的使用,我们也会为我们的迭代器装备这些思想。
本节中,我们将实现一个简单的计数迭代器(与STL算法一起使用),一开始这个实现是无法编译通过的。我们需要做一些兼容性操作,使得程序通过编译。
包含必要的头文件。
#include <iostream> #include <algorithm>
实现一个计数迭代器,作为基础版本。当我们使用其进行遍历时,我们只需要增加计数器即可。num_range
用来处理begin
和end
迭代器。
class num_iterator { int i; public: explicit num_iterator(int position = 0) : i{position} {} int operator*() const { return i; } num_iterator& operator++() { ++i; return *this; } bool operator!=(const num_iterator &other) const { return i != other.i; } bool operator==(const num_iterator &other) const { return !(*this != other); } }; class num_range { int a; int b; public: num_range(int from, int to) : a{from}, b{to} {} num_iterator begin() const { return num_iterator{a}; } num_iterator end() const { return num_iterator{b}; } };
声明所使用的命名空间。
using namespace std;
现在让我们来遍历100到109间的数字。这里需要注意的是,110这里是开区间,所以值无法遍历到110。
int main() { num_range r {100, 110};
现在,我们使用一个STL算法std::minmax_element
。这个算法会返回一个std::pair
,其具有两个迭代器:一个指向最小值的迭代器和一个指向最大值的迭代器。在这个范围中100和109即为这两个迭代器所指向的位置。
auto min_max(minmax_element(r.begin(), r.end())); cout << *min_max.first << " - " << *min_max.second << '\n'; }
我们在编译的时候遇倒如下的错误信息。这个错误与std::iterator_traits
有关。这个错误可能在使用其他编译器时,错误信息的格式不同,或者就没有错误。这个错误在clang 5.0.0 (trunk 299766)版本出现。
为了修正这个错误,我们需要激活迭代器的迭代功能。之后定义一个num_iterator
结构体,我们会对std::iterator_traits
进行特化。这个特化就是告诉STL我们的num_iterator
是一种前向迭代器,并且指向的对象是int
类型的值。
namespace std { template <> struct iterator_traits<num_iterator> { using iterator_category = std::forward_iterator_tag; using value_type = int; }; }
让我们再对程序进行编译,之前的错误应该不存在了。输出了范围内的最大值和最小值:
100 - 109
一些STL算法需要知道其处理容器的迭代器类型,有些还需要知道迭代器所指向的类型。这就是要有不同实现的原因。
不过,所有STL算法将会通过std::iterator_traits<my_iterator>
访问对应类型的迭代器(这里假设迭代器类型为my_iterator)。这个特性类需要包含五种不同类型的成员定义:
it1- it2
结果的类型pointer、reference和difference_type并没有在num_iterator中定义,因为其实际的内存值不重复(我们只是返回int值,不想数组一样是连续的)。因此num_iterator并不需要定义这些类型,因为算法是依赖于解引用后指定内存上的值。如果我们的迭代器定义了这些类型,就可能会出现问题。
C++17标准之前,C++都鼓励自定义迭代器继承于std::iterator<...>
,这样所有主流的类型都会自动定义。C++17中这条建议仍然能工作,但是不再推荐从std::iterator<...>
继承了。
大多数情况下,我们想要用数据来填充任何容器,不过数据源和容器却没有通用的接口。这种情况下,我们就需要人工的去编写算法,将相应的数据推入容器中。不过,这会分散我们解决问题的注意力。
不同数据结构间的数据传递现在可以只通过一行代码就完成,这要感谢STL中的迭代适配器。本节会展示如何使用迭代适配器。
本节中,我们使用一些迭代器包装器,展示如何使用包装器,并了解其如何在编程任务中给予我们帮助。
包含必要的头文件。
#include <iostream> #include <string> #include <iterator> #include <sstream> #include <deque>
声明使用的命名空间。
using namespace std;
开始使用std::istream_iterator
。这里我们特化为int
类型。这样,迭代器就能将标准输入解析成整数。例如,当我们遍历这个迭代器,其就和std::vector<int>
一样了。end
迭代器的类型没有变化,但不需要构造参数:
int main() { istream_iterator<int> it_cin {cin}; istream_iterator<int> end_cin;
接下来,我们实例化一个std::deque<int>
,并且将标准输入中的所有数字拷贝到队列中。队列本身不是一个迭代器,所以我们使用std::back_inserter
辅助函数将队列包装入std::back_insert_iterator
中。这样指定的迭代器就能执行v.pack_back(item)
,将标准输入中的每个元素放入容器中。这样就能让队列自动增长。
deque<int> v; copy(it_cin, end_cin, back_inserter(v));
接下来,我们使用std::istringstream
将元素拷贝到队列中部。先使用字符串,来定义一个字符流的实例:
istringstream sstr {"123 456 789"};
我们需要选择列表的插入点。这个点必须在中间,我们使用队列的起始指针,然后使用std::next
函数将其指向中间位置。函数第二个参数的意思就是让指针前进多少,这里选择v.size() / 2
步,也就是队列的正中间位置(这里我们将v.size()
强转为int
类型,因为std::next
第二个参数类型为difference_type
,是和第一个迭代器参数间的距离。因此,该类型是个有符号类型。根据编译选项,如果我们不进行显式强制转化,编译器可能会报出警告)。
auto deque_middle (next(begin(v), static_cast<int>(v.size()) / 2));
现在,我们可以从输入流中一步步的拷贝整数到队列当中。另外,流的end
包装迭代器为空的std::istream_iterator<int>
。这个队列已经被包装到一个插入包装器中,也就是成为std::insert_iterator
的一个实例,其指向队列中间位置的迭代器,我们用deque_middle
表示:
copy(istream_iterator<int>{sstr}, {}, inserter(v, deque_middle));
现在,让我们使用std::front_insert_iterator
插入一些元素到队列中部:
initializer_list<int> il2 {-1, -2, -3}; copy(begin(il2), end(il2), front_inserter(v));
最后一步将队列中的全部内容打印出来。std::ostream_iterator
作为输出迭代器,在我们的例子中其就是从std::cout
拷贝打印出的信息,并将各个元素使用逗号隔开:
copy(begin(v), end(v), ostream_iterator<int>{cout, ", "}); cout << '\n'; }
编译并运行,即有如下的输出。你能找到那些数字是由哪行的代码插入的吗?
$ echo "1 2 3 4 5" | ./main -3, -2, -1, 1, 2, 123, 456, 789, 3, 4, 5,
本节我们使用了很多不同类型的迭代适配器。他们有一点是共同的,会将一个对象包装成迭代器。
std::back_insert_iterator
back_insert_iterator
可以包装std::vector
、std::deque
、std::list
等容器。其会调用容器的push_back
方法在容器最后插入相应的元素。如果容器实例不够长,那么容器的容量会自动增长。
std::front_insert_iterator
front_insert_iterator
和back_insert_iterator
一样,不过front_insert_iterator
调用的是容器的push_front
函数,也就是在所有元素前插入元素。这里需要注意的是,当对类似于std::vector
的容器进行插入时,其已经存在的所有元素都要后移,从而空出位置来放插入元素,这会对性能造成一定程度的影响。
std::insert_iterator
这个适配器与其他插入适配器类似,不过能在容器的中间位置插入新元素。使用std::inserter
包装辅助函数需要两个参数。第一个参数是容器的实例,第二个参数是迭代器指向的位置,就是新元素插入的位置。
std::istream_iterator
istream_iterator
是另一种十分方便的适配器。其能对任何std::istream
使用(文件流或标准输入流),并且可以根据实例的具体特化类型,对流进行分析。本节中,我们使用了std::istram_iterator<int>(std::cin)
,其会将整数从标准输入中拉出来。
通常,对于流来说,其长度我们是不知道的。这就存在一个问题,也就是end
迭代器指向的位置在哪里?对于流迭代器来说,它就知道相应的end
迭代器的位置。这样就使得迭代器的比较更加高效,不需要通过遍历来完成。这样就是为什么end
流迭代器不需要传入任何参数的原因。
std::ostream_iterator
ostream_iterator
和istream_iterator
类似,不过是用来进行输出的流迭代器。与istream_iterator
不同在于,构造时需要传入两个参数,且第二个参数必须要是一个字符串,这个字符串将会在各个元素之后,推入输出流中。这样我们就能很容易的在元素中间插入逗号或者换行的符号,以便用户进行观察。
迭代器通常根据指向位置的移动,来遍历容器中的元素,但不需要迭代对应的数据类型。迭代器也会被用来实现算法,其可以通过++it
指向下一个元素,并且通过*it
解引用得到对应的值。
本节中,我们将用迭代器来实现斐波那契函数。斐波那契函数会有类似如下的迭代:F(n) = F(n - 1) + F(n - 2)
。数列的初始值F(0) = 0
和 F(1) = 1
。这样下列序列就可以进行计算:
我们要实现一个函数,可以输出斐波那契第n个数的值。通常我们都会使用函数迭代,或者是循环来实现这个函数。这样的话,我们只能一个个的将相应的值算出来,然后才能计算出下一个值。这里我们有两个选择——递归调用斐波那契函数计算整个数列,这样很浪费计算时间,或者将最后两个斐波那契数作为临时变量,并用它们来计算下一个数。第二种方法我们需要重新实现斐波那契算法循环。这样我们就可以将斐波那契数列计算的代码和我们实际的代码放在一起:
size_ta{0}; size_tb{1}; for(size_ti{0};i< N;++i){ constsize_told_b{b}; b+=a; a=old_b; // do something with b, which is the current fibonacci number }
使用迭代器实现斐波那契数列是一件很有意思的事情。如何将循环中的迭代,使用迭代器的前向自加操作来代替呢?其实很简单,让我们来看一下。
本节中,我们主要关注如何用一个迭代器实现生成斐波那契数列。
为了打印斐波那契数列在终端,我们需要包含标准输入输出流头文件。
#include <iostream>
我们调用斐波那契迭代器——fibit
。其会指向一个值i
,其保存的值为斐波那契数列对应的位置,a
和b
保存斐波那契数列中最后两个值。实例化迭代器时,需要将斐波那契迭代器初始化为F(0)
的值:
class fibit { size_t i {0}; size_t a {0}; size_t b {1};
下一步,定义标准构造函数和另一个构造函数用来初始化迭代器。
public: fibit() = default; explicit fibit(size_t i_) : i{i_} {}
当我们对迭代器解引用时,迭代器将返回对应位置的数值。
size_t operator*() const { return b; }
当移动迭代器++
时,其会移动到下一个斐波那契数上。这里的实现与基于循环的实现几乎是一样的。
fibit& operator++() { const size_t old_b {b}; b += a; a = old_b; ++i; return *this; }
当使用循环时,增加后的迭代器将会和end
迭代器进行比较,所以这里需要为迭代器实现不等于!=
操作符。我们只比较当且迭代器所对应的步数,这比循环1000000次再结束迭代器简单许多,这样我们就不需要计算太多的斐波那契数:
bool operator!=(const fibit &o) const { return i != o.i; } };
为了能让斐波那契迭代器适应for
循环的范围写法,我们需要实现一个范围类。我们称这个类为fib_range
,其构造函数只需要一个参数,这个参数能够告诉我们我们想要遍历的范围:
class fib_range { size_t end_n; public: fib_range(size_t end_n_) : end_n{end_n_} {}
begin
和end
函数将会返回对应位置上的迭代器,也就是F(0)
和F(end_n)
对应的迭代器。
fibit begin() const { return fibit{}; } fibit end() const { return fibit{end_n}; } };
好了,其他与迭代器相关的代码我们就不管了。因为我们辅助类就能很好的帮助我们将这些细节的东西隐藏掉!让我们打印10个斐波那契数字:
int main() { for (size_t i : fib_range(10)) { std::cout << i << ", "; } std::cout << '\n'; }
编译运行后,我们会在终端上看到如下的打印:
1, 1, 2, 3, 5, 8, 13, 21, 34, 55,
为了兼容STL中的迭代器,这里实现的迭代器必须支持std::iterator_traits
类。想要知道怎么做,要参考一下3.2节(让自己的迭代器与STL的迭代器兼容),其对如何兼容进行了明确地说明。
Note:
试着从迭代器的角度思考,这样的代码在很多情况下就显得十分优雅。不用担心性能,编译器会根据模板对迭代器相关的代码进行优化。
为了保证例子的简洁性,我们并没有对其做任何事情,不过要是作为斐波那契迭代器的发布库的话,其可用性还是比较差的——fibit
传入一个参数的构造函数,可以直接使用end
迭代器替换,因为fibit
并没有包含任何一个合法的斐波那契值,这里的库并不强制使用这种方式。
还有些方面需要进行修复:
将fibit(size_t i_)
声明为私有构造函数,并在fibit
类中将fib_range
类声明为一个友元类。这样用户就只能使用正确的方式进行迭代了。
可以使用迭代器哨兵,避免用户引用end
迭代器。可以参考一下3.6节(使用哨兵终止迭代)中内容,以获得更多信息。
有时我们需要反向迭代一个范围内的内容。基于范围的for循环中,STL迭代通常都使用前向累加的方式进行迭代,那么当需要反向时,就需要对其进行递减。当然,这里可以将迭代器进行包装,将调用累加操作改为递减的操作。听起来要写好多冗余的代码,来对反向迭代进行支持。
STL中提供了反向迭代适配器,其能帮助我们对迭代器进行包装。
本节中,我们将用另一种方式使用反向迭代器,只为了展示如何使用它们:
包含必要的头文件:
#include <iostream> #include <list> #include <iterator>
声明所使用的命名空间:
using namespace std;
为了有东西可以迭代,我们实例化一个整数列表:
int main() { list<int> l {1, 2, 3, 4, 5};
现在,让我们来反向打印这些数字。为了完成反向打印,我们调用std::list
的成员函数rbegin
和rend
获得反向迭代器,并且将数字推入输出流ostream_iterator
适配器中:
copy(l.rbegin(), l.rend(), ostream_iterator<int>{cout, ", "}); cout << '\n';
如果容器不提供rbegin
和rend
函数的话,就需要使用双向迭代器来帮忙了,这里可以使用工厂函数std::make_reverse_iterator
创建双向迭代器。其能接受普通迭代器,然后将其转换为反向迭代器:
copy(make_reverse_iterator(end(l)), make_reverse_iterator(begin(l)), ostream_iterator<int>{cout, ", "}); cout << '\n'; }
编译并运行该程序,就能得到如下的输出:
5, 4, 3, 2, 1, 5, 4, 3, 2, 1,
为了将一个普通迭代器转换为一个反向迭代器,容器至少要支持双向迭代。这就需要双向类别或更高级的迭代器才能满足条件。
反向迭代器是普通迭代器的一种,并且连接口和普通迭代器都一样,不过其累加操作会被当做递减操作来进行。
下面就来聊一下begin
和end
迭代器的位置。先来看一下图,迭代器区域里面是一串标准的数字序列。
如果序列是从1到5,begin
迭代器将指向元素1所在的位置,并且end
迭代器将指向元素5后面的位置。当定义了反向迭代器,rbegin
迭代器就指向了元素5,并且rend
迭代器指向元素1之前的位置。可以将书反过来看,可以发现这两个中方式是镜像的。
当我们想让我们自定义的容器类支持反向迭代,我们不用将所有细节一一实现;我们只需使用std::make_reverse_iterator
工厂函数,将普通的迭代器包装成反向迭代器即可,背后的操作STL会帮我们完成。
对于STL算法和基于范围的for循环来说,都会假设迭代的位置是提前知道的。在有些情况下,并不是这样,我们在迭代器到达末尾之前,我们是很难确定结束的位置在哪里。
这里使用C风格的字符串来举例,我们在编译时无法知道字符串的长度,只能在运行时使用某种方法进行判断。字符串遍历的代码如下所示:
for (const char *c_ponter = some_c_string; *c_pointer != '\0'; ++c_pointer) { const char c = *c_pointer; // do something with c }
对于基于范围的for循环来说,我们可以将这段字符串包装进一个std::string
实例中,std::string
提供begin()
和end()
函数:
for (char c : std::string(some_c_string)) { /* do something with c */ }
不过,std::string
在构造的时候也需要对整个字符串进行遍历。C++17中加入了std::string_view
,但在构造的时候也会对字符串进行一次遍历。对于比较短的字符串来说这是没有必要的,不过对于其他类型来说就很有必要。std::istream_iterator
可以用来从std::cin
捕获输入,当用户持续输入的时候,其end
迭代器并不能指向输入字符串真实的末尾。
C++17添加了一项新的特性,其不需要begin
迭代器和end
迭代器是同一类型的迭代器。本节我们看看,这种小修改的大用途。
本节,我们将在范围类中构造一个迭代器,其就不需要知道字符串的长度,也就不用提前找到字符串结束的位置。
包含必要的头文件。
#include <iostream>
迭代器哨兵是本节的核心内容。奇怪的是,它的定义完全是空的。
class cstring_iterator_sentinel {};
我们先来实现迭代器。其包含一个字符串指针,指针指向的容器就是我们要迭代的:
class cstring_iterator { const char *s {nullptr};
构造函数只是初始化内部字符串指针,对应的字符串是外部输入。显式声明构造函数是为了避免字符串隐式转换为字符串迭代器:
public: explicit cstring_iterator(const char *str) : s{str} {}
当对迭代器进行解引用,其就会返回对应位置上的字符:
char operator*() const { return *s; }
累加迭代器只增加迭代器指向字符串的位置:
cstring_iterator& operator++() { ++s; return *this; }
这一步是最有趣的。我们为了比较,实现了!=
操作符。不过,这次我们不会去实现迭代器的比较操作,这次迭代器要和哨兵进行比较。当我们比较两个迭代器时,在当他们指向的位置相同时,我们可以认为对应范围已经完成遍历。通过和空哨兵对象比较,当迭代器指向的字符为\0
字符时,我们可以认为到达了字符串的末尾。
bool operator!=(const cstring_iterator_sentinel) const { return s != nullptr && *s != '\0'; } };
为了使用基于范围的for
循环,我们需要一个范围类,用来指定begin
和end
迭代器:
class cstring_range { const char *s {nullptr};
实例化时用户只需要提供需要迭代的字符串:
public: cstring_range(const char *str) : s{str} {}
begin()
函数将返回一个cstring_iterator
迭代器,其指向了字符串的起始位置。end()
函数会返回一个哨兵类型。需要注意的是,如果不使用哨兵类型,这里将返回一个迭代器,这个迭代器要指向字符串的末尾,但是我们无法预知字符串的末尾在哪里。
cstring_iterator begin() const { return cstring_iterator{s}; } cstring_iterator_sentinel end() const { return {}; } };
类型定义完,我们就来使用它们。例子中字符串是用户输入,我们无法预知其长度。为了让使用者给我们一些输入,我们的例子会判断是否有输入参数。
int main(int argc, char *argv[]) { if (argc < 2) { std::cout << "Please provide one parameter.\n"; return 1; }
当程序运行起来时,我们就知道argv[1]
中包含的是使用者的字符串。
for (char c : cstring_range(argv[1])) { std::cout << c; } std::cout << '\n'; }
编译运行程序,就能得到如下的输出:
$ ./main "abcdef" abcdef
循环会将所有的字符打印出来。这是一个很小的例子,只是为了展示如何使用哨兵确定迭代的范围。当在无法获得end
迭代器的位置时,这是一种很有用的方法。当能够获得end
迭代器时,就不需要使用哨兵了。
迭代器很有用,能提供一般化的接口供用户使用。不过,迭代器经常被当做指针误用。当指针指向一个非法的内存位置时,不能进行解引用。这对迭代器也适用,不过有大量的条件来界定迭代器指向的位置是否合法。这些可以通过看一下STL文档就能了解到,但是还会写出很容易出现bug的代码。
最好的情况是,这些问题没有在客户的机器上出现,而是开发者测试这些程序时就能暴露出来。不过,通常即使是解引用了悬垂指针和错误的迭代器,代码也不会报错。这种情况是最糟的,因为这种未定义行为的代码,没法确定会发生什么。
幸运的是,有工具可以帮助我们。GUN STL有调试模式可选,GUN C++编译器和LLVM clang C++编译器都提供这样的库,其会为我们生成具有调试信息的二进制程序,可以让错误更容易暴露出来。这种库非常容易使用,并且特别有用,我们将在本节展示。Microsoft Visual C++标准库还提供了更多的检查项。
本节我们将使用迭代器故意访问一个非法位置:
包含头文件。
#include <iostream> #include <vector>
首先实例化一个整型类vector
,并且让指针指向值1。我们使用shrink_to_fit()
将vector
的容积设置为3,多分配的内存是不必要的,小一点的存储空间会让迭代速度更快:
int main() { std::vector<int> v {1, 2, 3}; v.shrink_to_fit(); const auto it (std::begin(v));
然后解引用迭代器,打印相应的内容:
std::cout << *it << '\n';
接下来,让我们向vector
中增加一个新数。这样vector
的长度就不够再放下另外一个数,这里vector
会自动增加其长度。通过分配一个新的更大的内存块来实现长度的增加,会将所有现存的项移到新的块,然后删除旧的内存块。
v.push_back(123);
现在,让我们再次通过迭代器从1开始打印vector
。这就坏了。为什么呢?因为在vector
自增的过程中,会分配新的内存,删除旧的内存,但是迭代器却不知道这个改变。这就意味着,迭代器将会指向旧地址,并且我们不知道这样做会怎样。
std::cout << *it << '\n'; // bad bad bad! }
编译变这个程序并运行,我们不会看到任何错误,不过迭代器解引用所打印出来的数字看上去像是随机数。看上去没有问题,反而最有问题。如果不指出来,可能没人会发现问题。
这时调试工具就派上了用场。GUN STL支持一种预处理宏_GLIBCXX_DEBUG
,其会激活STL中对健壮性检查的代码。这会让程序变慢,不过更容易找到Bug。我们可以通过-D_GLIBCXX_DEBUG
编译选项来启用这些代码,或者在代码的最开始加上这个宏。如你所见,其输出相关的错误信息,并关闭了应用的进程。Microsoft Visual C++ 编译器可以通过/D_ITERATOR_DEBUG_LEVEL=1
启用检查。
LLVM/clang实现的STL也有调试标识,其目的是为了调试STL代码,而非用户的代码。对于用户的代码的调试,我们会使用不同的选项来调试。向clang编译器传入-fsanitize=address -fsanitize=undefined
,可以看看会发生什么:
WOW!clang编译器对于运行错误的描述非常详细。由于信息非常的多,这里只截取其中一部分。当然,这个选项并不是clang独有的特性,对于GCC同样适用。
Note:
一些运行时的问题是因为一些库的丢失,编译器不会将libasan和libubsan( AddressSanitizer内存检测工具)自动添加到程序中,需要通过包管理器或类似的工具进行安装。
如我们之前所见,我们不需要通过修改任何代码,只需要通过为编译器添加一些编译器特性就能容易的找到代码中的Bug。
这些特性由调试器实现。一个调试器通常由一个编译器模块和一个运行时库组成。当调试器被激活时,编译器将会添加额外的信息到我们的代码中,然后形成二进制可执行文件。在运行时,调试器库由二进制文件自己去链接,例如:对应库实现会代替malloc
和free
函数,来分析程序到底想要多少内存。
调试器可以检测不同类型的Bug。这里只列举出一些常用的类型:
vector
类型的数据结构时,判别我们访问的位置是否在合法范围内。当然,我们还能检测到更多类型的Bug。
不过,激活所有的调试器不太可行,因为这样会导致程序运行的非常缓慢。不过,在单元测试和集成测试中,激活调试器是一个很好的方式。
对于不同类型的Bug,调试器的种类也是多种多样,并且还有很多调试器还在开发中。我们可以上网了解更多的信息,以便我们自己去调试程序。GCC和LLVM网站首页就列举了很多调试器,可以从在线文档中了解其调试能力:
使用调试器对程序进行整体测试是每个开发者都应该具有的意识。不过,在大多数公司中,开发者并没有这样的意识,即便是我们知道所有恶意软件和计算机病毒最重要的入口就是程序的Bug。
当时是一个开发新手时,看一下你的团队中是否有使用调试器的可能。如果没有,那你上班的第一天就有机会修复那些重大的Bug,并发现隐藏的Bug。
不同的编程语言引领了不同的编程方式。不同语言有各自的受众群体,因为表达方式的不同,所以对于优雅地定义也不同。
纯函数式编程算是编程风格中一种比较特别的方式。其与C和C++命令方式编程的风格大相径庭。虽然风格迥异,但是纯函数式编程却能在大多数情况下产生非常优雅地代码。
这里用向量点乘为例,使用函数式方法优雅地实现这个功能。给定两个向量,然后让对应位置上的两个数字相乘,然后将所有数字加在一起。也就是(a, b, c) * (d, e, f)
的结果为(a * e + b * e + c * f)
。我们在C和C++也能完成这样的操作。代码可能类似如下的方式:
std::vector<double> a {1.0, 2.0, 3.0}; std::vector<double> b {4.0, 5.0, 6.0}; double sum {0}; for (size_t i {0}; i < a.size(); ++i) { sum += a[i] * b[i]; } // sum = 32.0
如何使用其他语言让这段代码更优雅呢?
Haskell是一种纯函数式语言,其使用一行代码就能计算两个向量的点积:
Python虽然不是纯函数式编程语言,但是也会提供类似功能:
STL提供了相应的函数实现std::inner_product
,也能在一行之内完成向量点积。不过,其他语言中在没有相应的库对某种操作进行支持的情况下,也能做到在一行之内完成。
不需要对两种语言的语法进行详细了解的情况下,大家都应该能看的出,两个例子中最重要的就是zip函数。这个函数做了什么?假设我们有两个向量a和b,变换后将两个向量混合在一起。例如:[a1, a2, a3]
和[b1, b2, b3]
,使用zip函数处理的结果为[(a1, b1), (a2, b2), (a3, b3)]
。让我们仔细观察这个例子,就是将两个向量连接在了一起。
现在,关联的数字可以直接进行加法,然后累加在一起。在Haskell和Python的例子中我们看到,这个过程不需要任何循环或索引变量。[译者注:Python中是有循环的……]
这里没法让C++代码如同Haskell或Python那样优雅和通用,不过本节的内容就是为了实现一个类似的迭代器——zip迭代器——然后使用这个迭代器。向量点积有特定的库支持,至于是哪些库,以及这些库如何使用,并不在本书的描述范围内。不过,本节的内容将尝试展示一种基于迭代器的方式,来帮助你使用通用的模块另外完成编程。
本节中,我们会实现一个类似Haskell和Python中的zip函数。为了不对迭代器的机制产生影响,vector
中的变量这里写死为double
:
包含头文件
#include <iostream> #include <vector> #include <numeric>
定义zip_iterator
类。同时也要实现一个范围类zip_iterator
,这样我们在每次迭代时就能获得两个值。这也意味着我们同时遍历两个迭代器:
class zip_iterator {
zip迭代器的容器中需要保存两个迭代器:
using it_type = std::vector<double>::iterator; it_type it1; it_type it2;
构造函数会将传入的两个容器的迭代器进行保存,以便进行迭代:
public: zip_iterator(it_type iterator1, it_type iterator2) : it1{iterator1}, it2{iterator2} {}
增加zip迭代器就意味着增加两个成员迭代器:
zip_iterator& operator++() { ++it1; ++it2; return *this; }
如果zip中的两个迭代器来自不同的容器,那么他们一定不相等。通常,这里会用逻辑或(||)替换逻辑与(&&),但是这里我们需要考虑两个容器长度不一样的情况。这样的话,我们需要在比较的时候同时匹配两个容器。这样,我们就能遍历完其中一个容器时,及时停下循环:
bool operator!=(const zip_iterator& o) const { return it1 != o.it1 && it2 != o.it2; }
逻辑等操作符可以使用逻辑不等的操作符的实现,是需要将结果取反即可:
bool operator==(const zip_iterator& o) const { return !operator!=(o); }
解引用操作符用来访问两个迭代器指向的值:
std::pair<double, double> operator*() const { return {*it1, *it2}; } };
迭代器算是实现完了。我们需要让迭代器兼容STL算法,所以我们对标准模板进行了特化。这里讲迭代器定义为一个前向迭代器,并且解引用后返回的是一对double
值。虽然,本节我们没有使用difference_type
,但是对于不同编译器实现的STL可能就需要这个类型:
namespace std { template <> struct iterator_traits<zip_iterator> { using iterator_category = std::forward_iterator_tag; using value_type = std::pair<double, double>; using difference_type = long int; }; }
现在来定义范围类,其begin
和end
函数返回zip
迭代器:
class zipper { using vec_type = std::vector<double>; vec_type &vec1; vec_type &vec2;
这里需要从zip迭代器中解引用两个容器中的值:
public: zipper(vec_type &va, vec_type &vb) : vec1{va}, vec2{vb} {}
begin
和end
函数将返回指向两容器开始的位置和结束位置的迭代器对:
zip_iterator begin() const { return {std::begin(vec1), std::begin(vec2)}; } zip_iterator end() const { return {std::end(vec1), std::end(vec2)}; } };
如Haskell和Python的例子一样,我们定义了两个double
为内置类型的vector
。这里我们也声明了所使用的命名空间。
int main() { using namespace std; vector<double> a {1.0, 2.0, 3.0}; vector<double> b {4.0, 5.0, 6.0};
可以直接使用两个vector
对zipper
类进行构造:
zipper zipped {a, b};
我们将使用std::accumulate
将所有值累加在一起。这里我们不能直接对std::pair<double, double>
实例的结果进行累加,因为这里没有定义sum
变量。因此,我们需要定义一个辅助Lambda函数来对这个组对进行操作,将两个数相乘,然后进行累加。Lambda函数指针可以作为std::accumulate
的一个参数传入:
const auto add_product ([](double sum, const auto &p) { return sum + p.first * p.second; });
现在,让我们来调用std::accumulate
将所有点积的值累加起来:
const auto dot_product (accumulate( begin(zipped), end(zipped), 0.0, add_product));
最后,让我们来打印结果:
cout << dot_product << '\n'; }
编译运行后,得到正确的结果:
32
OK,这里使用了语法糖来完成了大量的工作,不过这和Haskell的例子也相差很远,还不够优雅。我们的设计中有个很大的缺陷,那就是只能处理double
类型的数据。通过模板代码和特化类,zipper
类会变得更通用。这样,我们就能将list
和vector
或deque
和map
这样不相关的容器合并起来。
为了让设计的类更加通用,其中设计的过程是不容忽视的。幸运的是,这样的库已经存在。Boost作为STL库的先锋,已经支持了zip_iterator
。这个迭代器非常简单、通用。
顺便提一下,如果你想看到了使用C++实现的更优雅的点积,并且不关心zip
迭代器相关的内容,那么你可以了解一下std::valarray
。例子如下,自己看下:
#include <iostream> #include <valarray> int main() { std::valarray<double> a {1.0, 2.0, 3.0}; std::valarray<double> b {4.0, 5.0, 6.0}; std::cout << (a * b).sum() << '\n'; }
范围库
这是C++中非常有趣的一个库,其支持zipper
和所有迭代适配器、滤波器等等。其受到Boost范围库的启发,并且某段时间内里,很有可能进入C++17标准。不幸的是,我们只能在下个标准中期待这个特性的加入。这种性能可以带来更多的便利,能让我们想表达的东西通过C++快速实现,并可以通过将通用和简单的模块进行组合,来表现比较复杂的表达式。
在文档中对其描述中,有个非常简单的例子:
计算从1到10数值的平方:
const int sum = accumulate(view::ints(1) | view::transform([](int i){return i*i;}) | view::take(10), 0);
从数值vector
中过滤出非偶数数字,并且将剩下的数字转换成字符串:
std::vector<int> v {1,2,3,4,5,6,7,8,9,10}; auto rng = v | view::remove_if([](int i){return i % 2 == 1;}) | view::transform([](int i){return std::to_string(i);}); // rng == {"2"s,"4"s,"6"s,"8"s,"10"s};
如果你等不及想要了解这些有趣的特性,可以看一下范围类的文档,https://ericniebler.github.io/range-v3 。
Lambda表达式是C++11添加的非常重要的一个特性。C++14和C++17对Lambda进行补充,使得Lambda表达式如虎添翼。那就先了解一下,什么是Lambda表达式呢?
Lambda表达式或者Lambda函数为闭包结构。闭包是描述未命名对象的通用术语,也可以称为匿名函数。为了在C++中加入这个特性,就需要相应对象实现()
括号操作符。C++11之前想要实现类似具有Lambda的对象,代码如下所示:
#include <iostream> #include <string> int main() { struct name_greeter { std::string name; void operator()() { std::cout << "Hello, " << name << '\n'; } }; name_greeter greet_john_doe {"John Doe"}; greet_john_doe(); }
构造name_greeter
对象需要传入一个字符串。这里需要注意的是,这个结构类型,Lambda可以使用一个没有名字的实例来表示。对于闭包结构来说,我们称之为捕获一个字符串。其就像我们在构造这个例子中的实例时传入的字符串一样,不过Lambda不需要参数,就能完成打印Hello, John Doe
。
C++11之后,使用闭包的方式来实现会更加简单:
#include <iostream> int main() { auto greet_john_doe ([] { std::cout << "Hello, John Doe\n"; }); greet_john_doe(); }
这样就行了!不再需要name_greeter
结构体,直接使用Lambda表达式替代。这看起来像魔术一样,本章的第一节中会对细节进行详细的描述。
Lambda表达式对于完成通用和简介类代码是非常有帮助的。其能对通用的数据结构进行处理,这样就不惧用户指定的特殊类型。闭包结构也会被用来将运行在线程上的数据进行打包。C++11标准推出后,越来越多的库支持了Lambda表达式,因为这对于C++来说已经是很自然的事情了。另一种使用方式是用于元编程,因为Lambda在编译时是可以进行预估的。不过,我们不会往元编程的方向去讲述,元编程的内容可能会撑爆这本书。
本章我们着重于函数式编程,对于那些对函数式编程不了解的开发者或初学者来说,这看起来非常的神奇。如果你在代码中看到Lambda表达式横飞,请先别沮丧。在这个函数式编程越来越流行的年代,需要拓展对于现代C++的了解。如果你看到的代码有点复杂,建议你多花点时间去分析它们。当你驯服了Lambda表达式,你就能驾驭它驰骋疆场,不再会为之困惑。
我们可以使用Lambda表达式来包装代码,为了在之后对其进行调用。我们可以像调用函数那样,给Lambda表达式传入不同的参数,从而得到不同的结果,这样我们就不需要在类中实现这个函数了。
C++11标准正式将Lambda语法加入C++,之后的C++14和C++17标准中对Lambda语法进行了升级。本节我们将看到如何使用Lambda表达式,以及其给我们带来的改变。
现在我们就来使用Lambda表达式完成一个程序,在实践中体验Lambda表达式:
Lambda表达式不需要任何库,不过我们需要将一些字符串打印在屏幕上,所以需要包含必要的的头文件:
#include <iostream> #include <string>
这次我们所有内容都会在主函数中完成。我们定义了两个没有参数的函数对象,并且返回整型常量1和2。需要注意的是,返回部分在大括号对{}
中,就像普通的函数那样,而小括号()
表示没有参数传入,当然也可以像普通函数那样定义函数签名,对于第二个Lambda表达式没有添加小括号对。不过两个表达式都有中括号对[]
:
int main() { auto just_one ( [](){ return 1; } ); auto just_two ( [] { return 2; } );
那么现在我们就来调用这两个函数,就像调用普通函数那样:
std::cout << just_one() << ", " << just_two() << '\n';
现在,来定义另一个函数对象,其名为plus,因为它要将两个参数进行加和:
auto plus ( [](auto l, auto r) { return l + r; } );
这个函数对象也不难用。使用auto
类型定义两个参数,只要是作为参数的实参类型支持加法操作,那么就没有任何问题:
std::cout << plus(1, 2) << '\n'; std::cout << plus(std::string{"a"}, "b") << '\n';
当然,我们可以不使用变量的方式对Lambda表达式进行保存。我们只需要在使用到的地方对其进行定义即可:
std::cout << [](auto l, auto r){ return l + r; }(1, 2) << '\n';
接下来,我们定义一个闭包,包里面装着一个计数器。当我们调用这个计数器时,其值就会自加,并且会将自加后的值返回。为了对计数变量进行初始化,我们(在中括号对中)对count
进行了赋值。为了能让函数对获取的值进行修改,我们使用mutable
关键字对函数进行修饰,否则在编译时会出问题:
auto counter ( [count = 0] () mutable { return ++count; } );
现在让我们调用函数对象5次,并且打印其返回值,观察每次调用后计数器增加后的值:
for (size_t i {0}; i < 5; ++i) { std::cout << counter() << ", "; } std::cout << '\n';
我们也可以通过捕获已经存在的变量的引用,在闭包中进行修改。这样的话,捕获到的值会自加,并且在闭包外部也能访问到这个变量。为了完成这个任务,我们在中括号对中写入&a
,&
符号就意味着捕获的是对应变量的引用,而非副本:
int a {0}; auto incrementer ( [&a] { ++a; } );
如果这样能行,那我们就可以多次的调用这个函数对象,并且直接在外部对a变量的值进行观察:
incrementer(); incrementer(); incrementer(); std::cout << "Value of 'a' after 3 incrementer() calls: " << a << '\n';
最后一个例子是一个多方位展示,这个例子中一个函数对象可以接受参数,并且将其传入另一个函数对象中进行保存。在这个plus_ten
函数对象中,我们会调用plus
函数对象:
auto plus_ten ( [=] (int x) { return plus(10, x); } ); std::cout << plus_ten(5) << '\n'; }
编译并运行代码,我们将看到如下的内容打印在屏幕上。我们也可以自己计算一下,看看打印的结果是否正确:
1, 2 3 ab 3 1, 2, 3, 4, 5, Value of a after 3 incrementer() calls: 3 15
上面的例子并不复杂——添加了数字,并对调用进行计数,并打印计数的结果。甚至用一个函数对象来连接字符串,并用这个函数对象对对应字符串进行计数。不过,这些实现对于对Lambda表达式不太了解的人来说,看着就很困惑了。
所以,先让我们了解一下Lambda表达式的特点:
[capture list] (parameters) mutable (optional) constexpr (optional) exception attr (optional) -> return type (optional) { body }
Lambda表达式的最短方式可以写为[]{}
。其没有参数,没有捕获任何东西,并且也不做实质性的执行。
那么其余的部分是什么意思呢?
捕获列表 capture list
指定我们需要捕获什么。其由很多种方式,我们展示两种比较“懒惰”的方式:
[=] () {...}
时,会捕获到外部所有变量的副本。[&] () {...}
时,会捕获到外部所有变量的引用。当然,也可以在捕获列表中单独的去写需要捕获的变量。比如[a, &b] () {...}
,就是捕获a
的副本和b
的引用,这样捕获列表就不会去捕获那些不需要捕获的变量。
本节中,我们定义了一个Lambda表达式:[count=0] () {...}
,这样我们就不会捕获外部的任何变量。我们定义了一个新的count
变量,其类型通过初始化的值的类型进行推断,由于初始化为0,所以其类型为int
。
所以,可以通过捕获列表捕获变量的副本和/或引用:
[a, &b] () {...}
:捕获a
的副本和b
的引用。[&, a] () {...}
:除了捕获a
为副本外,其余捕获的变量皆为引用。[=, &b, i{22}, this] () {...}
:捕获b
的引用,this
的副本,并将新变量i
初始化成22,并且其余捕获的变量都为其副本。Note:
当你需要捕获一个对象的成员变量时,不能直接去捕获成员变量。需要先去捕获对象的
this
指针或引用。
mutable (optional)
当函数对象需要去修改通过副本传入的变量时,表达式必须用mutable
修饰。这就相当于对捕获的对象使用非常量函数。
constexpr (optional)
如果我们显式的将Lambda表达式修饰为constexpr
,编译器将不会通过编译,因为其不满足constexpr
函数的标准。constexpr
函数有很多条件,编译器会在编译时对Lambda表达式进行评估,看其在编译时是否为一个常量参数,这样就会让程序的二进制文件体积减少很多。
当我们不显式的将Lambda表达式声明为constexpr
时,编译器就会自己进行判断,如果满足条件那么会将Lambda表达式隐式的声明为constexpr
。当我们需要一个Lambda表达式为constexpr
时,我们最好显式的对Lambda的表达式进行声明,当编译不通过时,编译器会告诉我们哪里做错了。
exception attr (optional)
这里指定在运行错误时,是否抛出异常。
return type (optional)
当想完全控制返回类型时,我们不会让编译器来做类型推导。我们可以写成这样[] () -> Foo {}
,这样就告诉编译器,这个Lambda表达式总是返回Foo
类型的结果。
我们现在想编写一些观察函数,用来观察一些变量的变化,当相应变量的数值发生改变时会进行提示,比如气压仪或是股票软件这类的东西。当有些值发生变化时,对应的观察对象就会被调用,之后以对应的方式进行反应。
为了实现这个观察器,我们存储了一些相关的函数对象在一个vector
中,这些函数都接受以int
变量作为参数,这个参数就是观察到的值。 我们不清楚这些函数对于传入值会做什么特殊的处理,不过我们也没有必要知道。
那么vector
中的函数对象类型是什么呢?std::vector<void (*)(int)>
,只要函数声明成void f(int)
就符合这个这个函数指针类型的定义。这对于Lambda表达式同样有效,不过Lambda表达就是不能捕获任何值了——[](int x) {...}
。对于捕获列表来说,Lambda表达式确实和普通的函数指针不同,因为其就不是一个函数指针,是一个函数对象,也就是将很多数据耦合到一个函数当中!想想在C++11时代之前,C++中没有Lambda表达式,类和结构体通常会将数据和函数耦合在一起,并且当你修改一个类中的数据成员时,你得到的是一个完全不同类型的数据。
这样vector
中就无法将使用同样类型名字的不同类别的对象存储在一起。不能捕获已存在的变量,这个限制对于用户来说非常的不友好,也限制了代码的使用范围。用户该如何保存不同类型的函数对象呢?对接口进行约束,采用特定的传参方式传入已经观察到的值?
本节中,我们将展示使用std::function
来解决这个问题,其将扮演一个“Lambda表达式多态包装器”的角色,捕获列表是不是空的都没有关系。
本节我们将创建很多Lambda表达式,其捕获类型是完全不同的,但是其函数签名的类型是相同的。然后,使用std::function
将这些函数对象存入一个vector
:
包含必要的头文件:
#include <iostream> #include <deque> #include <list> #include <vector> #include <functional>
我们先实现一个简单的函数,其返回值是一个Lambda表达式。其需要传入一个容器,并且返回一个函数对象,这个函数对象会以引用的方式捕获容器。且函数对象本身接受传入一个整型参数。当向函数对象传入一个整型时,表达式将会把传入的整型,添加到捕获的容器尾部:
template <typename C> static auto consumer (C &container) return [&] (auto value) { container.push_back(value); }; }
另一个辅助函数将会打印传入的容器中所有的内容:
template <typename C> static void print (const C &c) { for (auto i : c) { std::cout << i << ", "; } std::cout << '\n'; }
主函数中,我们先实例化一个deque
和一个list
,还有一个vector
,这些容器存放的元素都是int
类型。
int main() { std::deque<int> d; std::list<int> l; std::vector<int> v;
现在使用consumer
函数对象与刚刚实例化的容器进行配合:将在vector
中存储生成自定义的函数对象。然后,用一个vector
存放着三个函数对象。每个函数对象都会捕获对应的容器对象。这些容器对象都是不同的类型,不过都是函数对象。所以,vector
中的实例类型为std::function<void(int)>
。所有函数对象都将隐式转换成一个std::function
对象,这样就可以存储在vector
中了。
const std::vector<std::function<void(int)>> consumers {consumer(d), consumer(l), consumer(v)};
现在我们将10个整型值传入自定义函数对象:
for (size_t i {0}; i < 10; ++i) { for (auto &&consume : consumers) { consume(i); } }
三个容器都包含了同样的10个整数。让我们来打印它们:
print(d); print(l); print(v); }
编译运行程序,就会看到如下输出,和我们的期望是一样的。
$ ./std_function 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
本节中比较复杂的地方就是这一行:
const std::vector<std::function<void(int)>> consumers {consumer(d), consumer(l), consumer(v)};
d,l和v对象都包装进一个consumer(...)
调用中。这个调用会返回多个函数对象,这样每个函数对象都能捕获这三个容器实例。虽然函数对象只能接受int
型变量为参数,但是其捕获到的是完全不同的类型。这就将不同类型的A、B和C变量存入到一个vector
中一样。
为了这个功能,需要找到一个共同的类型,也就是能保存不同类型的函数对象,这个类型就是std::function
。一个std::function<void(int)>
对象可以存储我们的函数对象或传统函数,其接受只有一个整型参数和返回为空的函数类型。这里使用了多态性,为函数类型进行解耦。思考如下的写法:
std::function<void(int)> f ( [&vector](int x) { vector.push_back(x); });
这里有个函数对象,将Lambda表达式包装入std::function
对象当中,当我们调用f(123)
时,会产生一个虚函数调用,其会重定向到对象内部的实际执行函数。
当存储函数对象时,std::function
就显得非常智能。当我们使用Lambda表达式捕获越来越多的变量时,std::function
实例的体积也会越来越大。如果对象体积特别特别巨大,那么其将会在堆上分配出对应内存空间来存放这个函数对象。这些对于我们代码的功能性并没有什么影响,这里需要让你了解一下是因为这样的存储方式会对性能有一定的影响。
Note:
很多初学者都认为或希望
std::function<...>
的实际表达类型是一个Lambda表达式。不过这是错误的理解!因为有多态库的帮助,其才能将Lambda表达式进行包装,从而抹去类型的差异。
其实很多函数没有必要完全自定义的去实现。让我们先来看一个使用Haskell实现的在文本中查找单一单词的例子。第一行定义了一个unique_words
函数,在第二行中传入一个字符串:
Wow,就是这么简单!这里不对Haskell的语法做过多的解释,让我们来看一下代码。其定义了一个unique_words
的函数,该函数对其传入的参数进行了一系列的处理。首先,使用map toLower
将所有字符都小写化。然后,将句子用逗号进行分割,比如"foo bar baz"
就会已变成["foo", "bar","baz"]
。接下来,将单词列表进行排序。这样,["a", "b", "a"]
就会变为["a", "a", "b"]
。现在,使用group
函数,其会将相同的词组放到一个列表中,也就是["a", "a", "b"]
成为[ ["a", "a"], ["b"] ]
。现在就差不多快完事了,接下来就让我们数一下列表中一共有多少个组,这个工作由length
函数完成。
多么完美的编程方式呀!我们可以从右往左看,来了解这段代码是如何工作的。这里我就不需要关心每个细节是如何进行实现(除非其性能很差,或者有Bug)。
我们不是来赞美Haskell的,而是来提升我们自己C++技能的,这样的方式在C++中同样奏效。本节的例子会展示如何使用Lambda表达式来模仿并置函数。
本节中定义了一些函数对象,并将它们串联起来,也就是将一个函数的输出作为另一个函数的输入,以此类推。为了很好的展示这个例子,我们编写了一些串联辅助函数:
包含必要的头文件
#include <iostream> #include <functional>
然后,我们实现一个辅助函数concat
,其可以去任意多的参数。这些参数都是函数,比如f,g和h。并且一个函数的结果是另一个函数的输入,可以写成f(g(h(...)))
:
template <typename T, typename ...Ts> auto concat(T t, Ts ...ts) {
现在,代码就会变有些复杂了。当用户提供函数f,g和h时,我们现将其转换为f( concat(g,h))
,然后再是f(g(concat(h)))
,类似这样进行递归,直到得到f(g(h(...)))
为止。用户提供的这些函数都可以由Lambda表达式进行捕获,并且Lambda表达式将在之后获得相应的参数p,然后前向执行这些函数f(g(h(p)))
。这个Lambda表达式就是我们要返回的。if constexpr
结构会检查在递归步骤中,当前函数是否串联了多个函数:
if constexpr (sizeof...(ts) > 0) { return [=](auto ...parameters) { return t(concat(ts...)(parameters...)); }; }
当我们到达递归的末尾,编译器会选择if constexpr
的另一分支。这个例子中,我们只是返回函数t
,因为其传入的只有参数了:
else { return t; } }
现在,让我们使用刚创建的函数连接器对函数进行串联。我们先在主函数的起始位置定义两个简单的函数对象:
int main() { auto twice ([] (int i) { return i * 2; }); auto thrice ([] (int i) { return i * 3; });
现在,来串联他们。这里我们将两个乘法器函数和一个STL函数std::plus<int>
放在一起,STL的这个函数可以接受两个参数,并返回其加和。这样我们就得到了函数twice(thrice(plus( a, b )))
:
auto combined ( concat(twice, thrice, std::plus<int>{}) );
我们来应用一下。combined
函数现在看起来和一般函数一样,并且编译器会将这些函数连接在一起,且不产生任何不必要的开销:
std::cout << combined(2, 3) << '\n'; }
编译运行这个例子就会得到如下的结果,和我们的期望一致,因为2 * 3 * (2 + 3)
为30:
$ ./concatenation 30
concat
函数是本节的重点。其函数体看起来非常的复杂,因为其要对另一个Lambda表达式传过来ts
参数包进行解析,concat
会递归多次调用自己,每次调用参数都会减少:
template <typename T, typename ...Ts> auto concat(T t, Ts ...ts) { if constexpr (sizeof...(ts) > 0) { return [=](auto ...parameters) { return t(concat(ts...)(parameters...)); }; } else { return [=](auto ...parameters) { return t(parameters...); }; } }
让我们写一个简单点的版本,这次串联了三个函数:
template <typename F, typename G, typename H> auto concat(F f, G g, H h) { return [=](auto ... params) { return f( g( h( params... ) ) ); }; }
这个例子看起来应该很简单了吧。返回的Lambda表达式可以对f,g和h函数进行捕获。这个Lambda表达式可以接受任意多的参数传入,然后在调用f,g和h函数。我们先定义auto combined (concat(f, g, h))
,并在之后传入两个参数,例如combined(2, 3)
,这里的2和3就为concat
函数的参数包。
看起来很复杂,但concat
却很通用,有别与f(g(h( params... )))
式的串联。我们完成的是f(concat(g, h))(params...)
的串联,f(g(concat(h)))(params...)
为其下一次递归调用的结果,最终会的结果为f(g(h( params...)))
。
当使用通用代码过滤数据时,我们通常会定义一些谓词,这些谓词就是告诉计算机,哪些数据是我们想要样,哪些数据时我们不想要的。通常谓词都是组合起来使用。
例如,当我们在过滤字符串时,我们需要实现一个谓词,当其发现输入的字符串以foo
开头就返回true,其他情况都返回false。另一个谓词,当其发现输入的字符串以“bar”结尾时,返回true,否则返回false。
我们也不总是自己去定义谓词,有时候可以复用已经存在的谓词,并将它们结合起来使用。比如,如果我们既想要检查输入字符串的开头是否是foo
,又想检查结尾是否为“bar”时,就可以将之前提到的两个谓词组合起来使用。本节我们使用Lambda表达式,用一种更加舒服的方式来完成这件事。
我们将来实现一个非常简单的字符串过滤谓词,并且将其和辅助函数结合让其变得更加通用。
包含必要的头文件
#include <iostream> #include <functional> #include <string> #include <iterator> #include <algorithm>
这里实现两个简单的谓词函数,后面会用到它们。第一个谓词会告诉我们字符串的首字母是否是a
,第二个谓词则会告诉我们字符串的结尾字母是否为b
:
static bool begins_with_a (const std::string &s) { return s.find("a") == 0; } static bool ends_with_b (const std::string &s) { return s.rfind("b") == s.length() - 1; }
现在,让我们来实现辅助函数,我们称其为combine
。其需要一个二元函数作为其第一个参数,可以是逻辑'与'或逻辑'或'操作。之后的两个参数为需要结合在一起的谓词函数:
template <typename A, typename B, typename F> auto combine(F binary_func, A a, B b) {
之后,我们会返回一个Lambda表达式,这个表达式可以获取到两个合并后的谓词。这个表达式需要一个参数,这个参数会传入两个谓词中,然后表达式将返回这个两个谓词结合后的结果:
return [=](auto param) { return binary_func(a(param), b(param)); }; }
在实现主函数之前,先声明所使用命名空间:
using namespace std;
现在,让将两个谓词函数合并在一起,形成另一个全新的谓词函数,其会告诉我们输入的字符串是否以'a'开头,并且以'b'结尾,比如"ab"或"axxxb"就会返回true。二元函数我们选择std::logical_and
。这是个模板类,需要进行实例化,所以这里我们使用大括号对创建其实例。需要注意的是,因为该类的默认类型为void,所以这里我们并没有提供模板参数。特化类的参数类型,都由编译器推导得到:
int main() { auto a_xxx_b (combine( logical_and<>{}, begins_with_a, ends_with_b));
我们现在可以对标准输入进行遍历,然后打印出满足全新谓词的词组:
copy_if(istream_iterator<string>{cin}, {}, ostream_iterator<string>{cout, ", "}, a_xxx_b); cout << '\n'; }
编译边运行程序,就会得到如下输出。我们输入了四个单词,但是只有两个满足我们的谓词条件:
$ echo "ac cb ab axxxb" | ./combine ab, axxxb,
STL已经提供了一些非常有用的函数对象,例如std::logical_and
,std::logical_or
等等。所以我们没有必要所有东西都自己去实现。可以去看一下C++的参考手册,了解一下都有哪些函数对象已经实现:
当我们有很多工作要做时,可能就会导致很多代码的重复。使用Lambda表达式就很容易的避免重复代码,并且Lambda表达式将帮助你将这些重复的任务包装起来。
本节,我们将使用Lambda表达式接受一组参数,然后分发给相应的任务函数。这种方式并不需要添加额外的数据结构,所以编译器很容易的将这些函数打包成一个二进制文件(并且没有额外的开销)。
我们将要完成两个Lambda表达式辅助器,一个能接受一组参数,并调用多个函数对象;另一个使用一个函数调用,引发后续多个函数调用。我们的例子中,我们将使用不同的打印函数打印一些信息出来。
包含打印头文件。
#include <iostream>
首先,让我们实现multicall
函数,这个函数是本章的重点。这个函数可以接受任意数量的参数,并且返回一个Lambda表达式,这个Lambda表达式只接受一个参数。表达式可以通过这个参数调用所有已提供的函数。这样,我们可以定义auto call_all (multicall(f, g, h))
函数对象,然后调用call_all(123)
,从而达到同时调用f(123); g(123); h(123);
的效果。这个函数看起来比较复杂,是因为我们需要一个语法技巧来展开参数包functions,并在std::initializer_list
实例中包含一系列可调用的函数对象。
template <typename ... Ts> static auto multicall (Ts ...functions) { return [=](auto x) { (void)std::initializer_list<int>{ ((void)functions(x), 0)... }; }; }
下一个辅助器能接受一个函数f和一个参数包xs
。这里要表示的就是参数包中的每个参数都会传入f中运行。这种方式类似于for_each(f, 1, 2, 3)
调用,从而会产生一系列调用——f(1); f(2); f(3);
。本质上来说,这个函数使用同样的技巧来为函数展开参数包xs
:
template <typename F, typename ... Ts> static auto for_each (F f, Ts ...xs) { (void)std::initializer_list<int>{ ((void)f(xs), 0)... }; }
brace_print
函数能接受两个字符,并返回一个新的函数对象,这个函数对象可以接受一个参数x
。其将会打印这个参数,当然会让之前的两个字符将这个参数包围:
static auto brace_print (char a, char b) { return [=] (auto x) { std::cout << a << x << b << ", "; }; }
现在,我们终于可以在main函数中使用这些定义好的东西了。首先,我们定义函数f,g和h。其使用括号打印函数将其参数进行包围。nl
函数只打印换行符。
int main() { auto f (brace_print('(', ')')); auto g (brace_print('[', ']')); auto h (brace_print('{', '}')); auto nl ([](auto) { std::cout << '\n'; });
让我们将所有函数和multicall
辅助器放在一起:
auto call_fgh (multicall(f, g, h, nl));
这里我们提供一组数字,之后这些数字就会被相应的括号包围,然后打印出来。这样,我们现在调用一次,就等于以前调用五次主函数中定义的函数。
for_each(call_fgh, 1, 2, 3, 4, 5); }
编译运行,我们应该能得到期望的结果:
$ ./multicaller (1), [1], {1}, (2), [2], {2}, (3), [3], {3}, (4), [4], {4}, (5), [5], {5},
我们刚刚实现的辅助函数还是挺复杂的。我们使用了std::initializer_list
来帮助我们展开参数包。为什么这里不用特殊的数据结构呢?再来看一下for_each
的实现:
auto for_each ([](auto f, auto ...xs) { (void)std::initializer_list<int>{ ((void)f(xs), 0)... }; });
这段代码的核心在于f(xs)
表达式。xs
是一个参数包,我们需要将其进行解包,才能获取出独立的参数,以便调用函数f。不幸的是,我们知道这里不能简单的使用...
标记,写成f(xs)...
。
所以,我能做的只能是构造出一个std::initializer_list
列表,其具有一个可变的构造函数。表达式可以直接通过return std::initializer_list<int>{f(xs)...};
方式构建,不过其也有缺点。在让我们看一下for_each
的实现,看起来要比之前简单许多:
auto for_each ([](auto f, auto ...xs) { return std::initializer_list<int>{f(xs)...}; });
这看起来非常简单易懂,但是我们要了解其缺点所在:
要想for_each
修复上面所有的问题,会让其变的更加复杂。例子中做到了一下几点:
(void)std::initializer_list<int>{...}
转换为void
类型。f(xs)...
包装进(f(xs),0)...
表达式中。这会让程序将返回值完全抛弃,不过0将会放置在初始化列表中。f(xs)
在(f(xs), 0)...
表达式中,将会再次转换成void
,所以这里就和没有返回值一样。这些不幸的事导致例程如此复杂丑陋,不过其能为所有可变的函数对象工作,并且不管这些函数对象是否返回值,或返回什么样的值。
这种技术可以很好控制函数调用的顺序,严格保证多个函数/函数对象以某种顺序进行调用。
Note:
不推荐使用C风格的类型转换,因为C++有自己的转换操作。我们可以使用
reinterpret_cast<void>(expression)
代替例程中的代码行,不过这样会降低代码的可读性,会给后面的阅读者带来一些困扰。
大多数用过std::copy_if
和std::transform
的开发者可能曾经疑惑过,为什么标准库里面没有std::transform_if
。std::copy_if
会将源范围内符合谓词判断的元素挑出来,不符合条件的元素忽略。而std::transform
会无条件的将源范围内所有元素进行变换,然后放到目标范围内。这里的变换谓词是由用户提供的一个函数,这个函数不会太复杂,比如乘以多个数或将元素完全变换成另一种类型。
这两个函数很早就存在了,不过到现在还是没有std::transform_if
函数。本节就来实现这个函数。看起来实现这个函数并不难,可以通过谓词将对应的元素选择出来,然后将这些挑选出来的元素进行变换。不过,我们会利用这个机会更加深入的了解Lambda表达式。
我们将来实现我们的transform_if
函数,其将会和std::accumulate
一起工作。
包含必要的头文件。
#include <iostream> #include <iterator> #include <numeric>
首先,我们来实现一个map
函数。其能接受一个转换函数作为参数,然后返回一个函数对象,这个函数对象将会和std::accumulate
一起工作。
template <typename T> auto map(T fn) {
当传入一个递减函数时,我们会返回一个函数对象,当这个函数对象调用递减函数时,其会返回另一个函数对象,这个函数对象可以接受一个累加器和一个输入参数。递减函数会在累加器中进行调用,并且fn
将会对输入变量进行变换。如果这里看起来比较复杂的话,我们将在后面进行详细的解析:
return [=] (auto reduce_fn) { return [=] (auto accum, auto input) { return reduce_fn(accum, fn(input)); }; }; }
现在,让我们来实现一个filter
函数。其和map
的工作原理一样,不过其不会对输入进行修改(map
中会对输入进行变换)。另外,我们接受一个谓词函数,并且在不接受谓词函数的情况下,跳过输入变量,而非减少输入变量:
template <typename T> auto filter(T predicate) {
两个Lambda表达式与map
函数具有相同的函数签名。其不同点在于input
参数是否进行过操作。谓词函数用来区分我们是否对输入调用reduce_fn
函数,或者直接调用累加器而不进行任何修改:
return [=] (auto reduce_fn) { return [=] (auto accum, auto input) { if (predicate(input)) { return reduce_fn(accum, input); } else { return accum; } }; }; }
现在让我们使用这些辅助函数。我们实例化迭代器,我们会从标准输入中获取整数值:
int main() { std::istream_iterator<int> it {std::cin}; std::istream_iterator<int> end_it;
然后,我们会调用谓词函数even
,当传入一个偶数时,这个函数会返回true。变换函数twice
会对输入整数做乘2处理:
auto even ([](int i) { return i % 2 == 0; }); auto twice ([](int i) { return i * 2; });
std::accumulate
函数会将所对应范围内的数值进行累加。累加默认就是通过+
操作符将范围内的值进行相加。我们想要提供自己的累加函数,也就是我们不想只对值进行累加。我们会将迭代器it
进行解引用,获得其对应的值,之后对再对其进行处理:
auto copy_and_advance ([](auto it, auto input) { *it = input; return ++it; });
我们现在将之前零零散散的实现拼组在一起。我们对标准输入进行迭代,通过输出迭代器ostream_iterator
将对应的值输出在终端上。 copy_and_advance
函数对象将会接收用户输入的整型值,之后使用输出迭代器进行输出。将值赋值给输出迭代器,将会使打印变得高效。不过,我们只会将偶数挑出来,然后对其进行乘法操作。为了达到这个目的,我们将copy_and_advance
函数包装入even
过滤器中,再包装入twice
引射器中:
std::accumulate(it, end_it, std::ostream_iterator<int>{std::cout, ", "}, filter(even)( map(twice)( copy_and_advance ) )); std::cout << '\n'; }
编译并运行程序,我们将得到如下的输出。奇数都被抛弃了,只有偶数做了乘2运算:
$ echo "1 2 3 4 5 6" | ./transform_if 4, 8, 12,
本节看起来还是很复杂的,因为我们使用了很多嵌套Lambda表达式。为了跟清晰的了解它们是如何工作的,我们先了解一下std::accumulate
的内部工作原理。下面的实现类似一个标准函数的实现:
template <typename T, typename F> T accumulate(InputIterator first, InputIterator last, T init, F f) { for (; first != last; ++first) { init = f(init, *first); } return init; }
函数参数f在这起到主要作用,所有值都会累加到用户提供的init
变量上。通常情况下,迭代器范围将会传入一组数字,类似0, 1, 2, 3, 4
,并且init
的值为0。函数f
只是一个二元函数,其会计算两个数的加和。
例子中循环将会将所有值累加到init
上,也就类似于init += (((0 + 1) + 2) + 3) + 4
。这样看起来std::accumulate
就是一个通用的折叠函数。折叠范围意味着,将二值操作应用于累加器变量和迭代范围内的每一个值(累加完一个数,再累加下一个数)。这个函数很通用,可以用它做很多事情,就比如实现std::transform_if
函数!f
函数也会递减函数中进行调用。
transform_if
的一种很直接的实现,类似如下代码:
template <typename InputIterator, typename OutputIterator, typename P, typename Transform> OutputIterator transform_if(InputIterator first, InputIterator last,OutputIterator out,P predicate, Transform trans) { for (; first != last; ++first) { if (predicate(*first)) { *out = trans(*first); ++out; } } return out; }
这个实现看起来和std::accumulate
的实现很类似,这里的out
参数可以看作为init
变量,并且使用函数f
替换if
。
我们确实做到了。我们构建了if
代码块,并且将二元函数对象作为一个参数提供给了std::accumulate
:
auto copy_and_advance ([](auto it, auto input) { *it = input; return ++it; });
std::accumulate
会将init
值作为二元函数it
的参数传入,第二个参数则是当前迭代器所指向的数据。我们提供了一个输出迭代器作为init
参数。这样std::accumulate
就不会做累加,而是将其迭代的内容转发到另一个范围内。这就意味着,我们只需要重新实现std::copy
就可以了。
通过copy_and_advance
函数对象,使用我们提供的谓词,将过滤后的结果传入另一个使用谓词的函数对象:
template <typename T> auto filter(T predicate) { return [=] (auto reduce_fn) { return [=] (auto accum, auto input) { if (predicate(input)) { return reduce_fn(accum, input); } else { return accum; } }; }; }
构建过程看上去没那么简单,不过先来看一下if
代码块。当predicate
函数返回true时,其将返回reduce_fn
函数处理后的结果,也就是accum
变量。这个实现省略了使用过滤器的操作。if
代码块位于Lambda表达式的内部,其具有和copy_and_advance
一样的函数签名,这使它成为一个合适的替代品。
现在我们就要进行过滤,但不进行变换。这个操作有map
辅助函数完成:
template <typename T> auto map(T fn) { return [=] (auto reduce_fn) { return [=] (auto accum, auto input) { return reduce_fn(accum, fn(input)); }; }; }
这段代码看起来就简单多了。其内部有一个还有一个Lambda表达式,该表达式的函数签名与copy_and_advance
,所以可以替代copy_and_advance
。这个实现仅转发输入变量,不过会通过二元函数对fn
的调用,对参数进行量化。
之后,当我们使用这些辅助函数时,我们可以写成如下的表达式:
filter(even)( map(twice)( copy_and_advance ) )
filter(even)
将会捕获even
谓词,并且返回给我们一个函数,其为一个包装了另一个二元函数的二元函数,被包装的那个二元函数则是进行过滤的函数。map(twice)
函数做了相同的事情,twice
变换函数,将copy_and_advance
包装入另一个二元函数中,那另一个二元函数则是对参数进行变换的函数。
虽然没有任何的优化,但我们的代码还是非常的复杂。为了让函数之间能一起工作,我们对函数进行了多层嵌套。不过,这对于编译器来说不是一件很难的事情,并且能对所有代码进行优化。程序最后的结果要比实现transform_if
简单很多。这里我们没有多花一分钱,就获得了非常好的函数模组。这里我们就像堆乐高积木一样,可将even
谓词和twice
转换函数相结合在一起。
Lambda表达式结合参数包一起使用,可以用来解决比较复杂的问题。本节中,我们将实现一个函数对象,其能接受任意多的输入参数,然后生成相应的笛卡尔乘积。
笛卡尔乘积是一个数学运算。其可以表示为A x B
,其意思为使用集合A和集合B来结算笛卡尔乘积。结果为另一个单独的集合,其包含集合A和集合B一一对应的组对。这个运算的意义在于,将两个集合中的元素进行匹配。下图就描述了这种运算操作:
图中,A = (x, y, z)
,B = (1, 2, 3)
,所产生的笛卡尔乘积为(x, 1)
, (x, 2)
,(x, 3)
,(y, 1)
,(y, 2)
等等。如果A和B为同一个集合,比如说是(1, 2)
,那么其笛卡尔乘积为(1, 1)
, (1, 2)
,(2, 1)
, 和(2, 2)
。有时候,这样的操作却十分冗余,比如集合(1, 1)
,或是刚才例子中的(1, 2)
和(2, 1)
。笛卡尔乘积可以通过一个简单的条件,对结果进行过滤。
我们实现了一个函数对形象,其能接受一个函数f
,以及一组参数。该函数对象将会通过输出参数集合创建笛卡尔乘积,将冗余的部分进行过滤,并对每个乘积调用函数f
。
包含打印输出的头文件。
#include <iostream>
然后,我们定义一个简单的辅助函数,用来对组对中的值进行打印:
static void print(int x, int y) { std::cout << "(" << x << ", " << y << ")\n"; } int main() {
复杂的地方到了。我们先实现了一个辅助函数cartesian
,我们将在下一步实现这个函数。这个函数能接受一个参数f
,在我们使用过程中,这个f
函数就是print
函数。另一些参数是x
和参数包rest
。其包含了计算笛卡尔乘积的元素。在f(x, rest)
表达式中:当x=1
和rest=2, 3, 4
,为了得到结果,我们需要调用三次:f(1, 2); f(1, 3); f(1, 4);
。(x < rest)
的条件,会删除冗余的组对。我们来看下代码:
constexpr auto call_cart ( [=](auto f, auto x, auto ...rest) constexpr { (void)std::initializer_list<int>{ (((x < rest) ? (void)f(x, rest) : (void)0) ,0)... }; });
cartesian
函数在本节中,算是最复杂的部分了。其能接受一个参数包xs
,并返回一个其捕获的函数对象。返回的函数对象能接受一个函数对象f
。参数包,比如xs = 1, 2, 3
,其内部Lambda表达式将会生成如下调用:call_cart(f, 1, 1, 2, 3); call_cart(f, 2, 1, 2, 3); call_cart(f, 3, 1, 2, 3);。通过对这些函数的调用,我们能得到我们想要的所有笛卡尔乘积。我们使用...
对xs
参数包扩展了两次,第一次看起来有些奇怪。调用call_cart
时,我们第一次对xs
进行了扩展。第二次扩展将会使得call_cart
调用多次,并且每次的第二个参数都会不同。
constexpr auto cartesian ([=](auto ...xs) constexpr { return [=] (auto f) constexpr { (void)std::initializer_list<int>{ ((void)call_cart(f, xs, xs...), 0)... }; }; });
那么,现在让我们使用数字集1, 2, 3
来生成笛卡尔乘积,并对组对进行打印。过滤了冗余的组对,所剩的结果应该为 (1, 2)
, (2, 3)
, 和 (1, 3)
。我们对很多的结果进行了过滤,并且不考虑结果中组对中的数字顺序。这也就是说,我们不需要(1, 1)
,并且认为(1, 2)
和(2, 1)
为同一个组对。首先,我们让cartesian
函数产生一个函数对象,其会包含所有可能的组对,并且能够接受我们的打印函数。然后,我们将所产生的组对,使用打印函数进行打印输出。我们将print_cart
变量声明为constexpr
,这样我们就能在编译时获得所有的乘积结果:
constexpr auto print_cart (cartesian(1, 2, 3)); print_cart(print); }
编译并运行程序,我们就会得到如下的输出。通过call_cart
中的x < rest
判断条件,我们可以将一些冗余组对结果进行删除:
$ ./cartesian_product (1, 2) (1, 3) (2, 3)
另一个看起来比较复杂的地方就是Lambda表达式了。但当我们充分的了解后,我们就不会再对Lambda表达式有任何的困惑了!
那么,让我们来仔细的了解一下吧。我们将所发生的事情,画了一张图来说明:
这里有3步:
1, 2, 3
作为新集合中的三个元素,其报了三个新的集合。第一个则是集合中的每一个单独向,而第二部分则是整个集合本身。好了,回到我们例子:
constexpr auto cartesian ([=](auto ...xs) constexpr { return [=](auto f) constexpr { (void)std::initializer_list<int>{ ((void)call_cart(f, xs, xs...), 0)... }; }; });
内部表达式call_cart(xs, xs...)
将会对集合1, 2, 3
分别进行表示,比如:1, [1, 2, 3]
。整个表达式((void)call_cart(f, xs, xs...), 0)...
其将...
放在外部,其会将集合进行拆解,我们将会得到2,[1, 2, 3]
和3, [1, 2, 3]
。
call_cart
完成了第2和第3步:
auto call_cart ([](auto f, auto x, auto ...rest) constexpr { (void)std::initializer_list<int>{ (((x < rest) ? (void)f(x, rest) : (void)0) ,0)... }; });
参数x
始终包含从这个集合中挑出的但选值,并且rest
包含了整个集合。让我么先忽略x < rest
这个条件。这里,f(x, rest)
表达式与...
参数包展开所得到的调用f(1, 1)
,f(1, 2)
等等,其就会生成将被打印的组对。这就是第2步完成的事。
第3步中,就是用x < rest
条件来过滤冗余的组对了。
我们先给所有Lambda表达式和持有变量声明成constexpr
。通过这样做,我们可以在运行时对代码进行评估,这样编译出的二进制文件将会包含所有组对,而无需在运行时对其进行计算。需要注意的是,这里需要传入常量函数的参数为已知量,这样才能在运行时让编译器知道,并对函数进行执行。
STL不仅包含数据结构,还有很多算法。数据结构可以帮助存放特定情况下需要保存的数据,而算法则会将数据结构中存储的数据进行变换。
让我们来看一个标准的例子,例如对vector
实例中的数据进行累加。这个可以简单的通过循环迭代vector
中的元素,将所有值累加在一个对应的值上:
vector<int> v {100, 400, 200 /*, ... */ }; int sum {0}; for (int i : v) { sum += i; } cout << sum << '\n';
不过,作为一个标准的例子,当然可以使用STL的算法来完成:
cout << accumulate(begin(v), end(v), 0) << '\n';
例子中循环变量也不是很长,不过其可读性比accumulate
差很多。一个10行的循环代码看起来的确很尴尬,那么本章我们就看来了解一下标准算法(accumulate
, copy
, move
, transform
和shuffle
等等)的工作机制。
其思想就是提供丰富的算法供开发者使用,避免耗费大量的时间在重复制造轮子上面。另一方面就是,即便开发者会自己去实现相应STL中的算法,也要进行大量的测试来确保自己实现的算法是否正确,STL提供的算法都是经过了严格的测试。所以没有必要做重复的工作,这样也能节省代码审阅者的时间,否则他们还要确定算法实现中是否有Bug。
另一个重点是STL算法非常的高效。很多STL算法提供了多种特化实现,这样足以应对依赖迭代器类型的使用方式。例如,将vector
中的所有元素都填充0时,就可以使用std::fill
。因为vector
使用的是一段连续的内存,对于这种使用连续内存存放数据的结构都可以使用std::fill
进行填充,这个函数类似于C中的memset
函数。当开发者将容器类型从vector
改为list
,STL算法就不能再使用memset
了,并且需要逐个迭代list的元素,并将元素赋0。开发者不能为了使用memset
将数据类型写死为vector
或array
,因为实际项目中,还是有很多数据结构存储的地址并不是连续的。大多数情况下,想要自己去将代码实现的更聪明是没有太多意义的,因为STL的实现者已经考虑到了这种情况,并且STL还是免费使用的,为什么不用呢?
让我们总结一下前面提到的几点。使用STL算法的好处:
很多算法都是对迭代器进行操作,第3章已经解释了迭代器的工作原理。本章专注于如何使用STL算法解决各种问题,了解这些STL应该如何使用。要展示所有STL算法的使用方式不是本书所要做的事情,这个事情C++手册已经完成了,你可以在网上进行查询,或者花钱购买电子/纸质发布版本。
作为一个STL“忍者”需要将C++手册放在手边……嗯,至少放在浏览器的书签中吧。当我们在完成一个任务的过程中,每个开发者都可以回看一下任务本身,在完成自己的任务时,确定这个STL算法是否适合于你的问题。
在线版本的C++手册:http://cppreference.com
其也提供离线下载功能。
Note:
在面试过程中,对于STL算法的熟悉程度也是判断一个开发者对C++的熟悉程度的标准之一。
大多数STL数据结构都支持迭代器。这就意味着大多数数据结构能够通过成员函数begin()
和end()
成员函数得到相应的迭代器,并能对数据进行迭代。迭代的过程看起来是相同的,无论是什么样的数据结构都是一样的。
我们可以对vector
,list
,deque
,map
等等数据结构进行迭代。我们甚至可以使用迭代器作为文件/标准输入输出的出入口。此外,如之前章节介绍,我们能将迭代器接口放入算法中。这样的话,我们可以使用迭代器访问任何元素,并且可以将迭代器作为STL算法的参数传入,对特定范围内的数据进行处理。
std::copy
算法可以很好的展示迭代器是如何将不同的数据结构进行抽象,而后将一个容器的数据拷贝到另一个容器。类似这样的算法就与数据结构的类型完全没有关系了。为了证明这点,我们会把玩一下std::copy
。
本节中,我们将对不同的变量使用std::copy
。
首先,包含必要的头文件,并声明所用到的命名空间。
#include <iostream> #include <vector> #include <map> #include <string> #include <tuple> #include <iterator> #include <algorithm> using namespace std;
我们将使用整型和字符串值进行组对。为了能很好的将其进行打印,我们将会重载<<
流操作:
namespace std { ostream& operator<<(ostream &os, const pair<int, string> &p) { return os << "(" << p.first << ", " << p.second << ")"; } }
主函数中,我们将使用整型-字符串对填充一个vector
。并且我们声明一个map
变量,其用来关联整型值和字符串值:
int main() { vector<pair<int, string>> v { {1, "one"}, {2, "two"}, {3, "three"}, {4, "four"}, {5, "five"}}; map<int, string> m;
现在将vector
中的前几个整型字符串对使用std::copy_n
拷贝到map
中。因为vector
和map
是两种完全不同的结构体,我们需要对vector
中的数据进行变换,这里就要使用到insert_iterator
适配器。std::inserter
函数为我们提供了一个适配器。在算法中使用类似std::copy_n
的算法时,需要与插入迭代器相结合,这是一种更加通用拷贝/插入元素的方式(从一种数据结构到另一种数据结构),但这种方式不是最快的。使用指定数据结构的成员函数插入元素无疑是更加高效的方式:
copy_n(begin(v), 3, inserter(m, begin(m)));
让我们打印一下map
中的内容。纵观本书,我们会经常使用std::copy
函数来打印容器的内容。std::ostream_iterator
在这里很有用,因为其可以将用户的标准输出作为另一个容器,而后将要输出的内容拷贝过去:
auto shell_it (ostream_iterator<pair<int, string>>{cout, ", "}); copy(begin(m), end(m), shell_it); cout << '\n';
对map
进行清理,然后进行下一步的实验。这次,我们会将vector
的元素移动到map
中,并且是所有元素:
m.clear(); move(begin(v), end(v), inserter(m, begin(m)));
我们将再次打印map
中的内容。此外,std::move
是一种改变数据源的算法,这次我们也会打印vector
。这样,我们就会看到算法时如何对数据源进行的移动:
copy(begin(m), end(m), shell_it); cout << '\n'; copy(begin(v), end(v), shell_it); cout << '\n'; }
编译运行这个程序,看看会发生什么。第一二行非常简单,其反应的就是copy_n
和move
算法执行过后的结果。第三行比较有趣,因为移动算法将其源搬移到map
中,所以这时的vector
是空的。在重新分配空间前,我们通常不应该访问成为移动源的项。但是为了这个实验,我们忽略它:
$ ./copying_items (1, one), (2, two), (3, three), (1, one), (2, two), (3, three), (4, four), (5, five), (1, ), (2, ), (3, ), (4, ), (5, ),
std::copy
是STL中最简单的算法之一,其实现也非常短。我们可以看一下等价实现:
template <typename InputIterator, typename OutputIterator> OutputIterator copy(InputIterator it, InputIterator end_it, OutputIterator out_it) { for (; it != end_it; ++it, ++out_it) { *out_it = *it; } return out_it; }
这段代码很朴素,使用for
循环将一个容器中的元素一个个的拷贝到另一个容器中。此时,有人就可能会发问:"使用for
循环的实现非常简单,并且还不用返回值。为什么要在标准库实现这样的算法?",这是个不错的问题。
std::copy
并非能让代码大幅度减少的一个实现,很多其他的算法实现其实非常复杂。这种实现其实在代码层面并不明显,但STL算法更多的在于做了很多底层优化,编译器会选择最优的方式执行算法,这些底层的东西目前还不需要去了解。
STL算法也让能避免让开发者在代码的可读性和优化性上做权衡。
Note:
如果类型只有一个或多个(使用
class
或struct
包装)的矢量类型或是类,那么其拷贝赋值通常是轻量的,所以可以使用memcopy
或memmove
进行赋值操作,而不要使用自定义的赋值操作符进行操作。
这里,我们也使用了std::move
。其和std::copy
一样优秀,不过std::move(*it)
会将循环中的源迭代器,从局部值(左值)转换为引用值(右值)。这个函数就会告诉编译器,直接进行移动赋值操作来代替拷贝赋值操作。对于大多数复杂的对象,这会让程序的性能更好,但会破坏原始对象。
排序是一项很常见的任务,并且可以通过各种各样的方式进行。每个计算机科学专业的学生,都学过很多排序算法(包括这些算法的性能和稳定性)。
因为这是个已解决的问题,所以开发者没必要浪费时间再次来解决排序问题,除非是出于学习的目的。
本节中,我们将展示如何使用std::sort
和std::partial_sort
。
首先,包含必要的头文件和声明所使用的命名空间。
#include <iostream> #include <algorithm> #include <vector> #include <iterator> #include <random> using namespace std;
我们将打印整数在vector
出现的次数,为了缩短任务代码的长度,我们在这里写一个辅助函数:
static void print(const vector<int> &v) { copy(begin(v), end(v), ostream_iterator<int>{cout, ", "}); cout << '\n'; }
我们开始实例化一个vector
:
int main() { vector<int> v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
因为我们将使用不同的排序函数将vector
多次打乱,所以我们需要一个随机数生成器:
random_device rd; mt19937 g {rd()};
std::is_sorted
函数会告诉我们,容器内部的值是否已经经过排序。所以这行将打印到屏幕上:
cout << is_sorted(begin(v), end(v)) << '\n';
std::shuffle
将打乱vector
中的内容,之后我们会再次对vector
进行排序。前两个参数是容器的首尾迭代器,第三个参数是一个随机数生成器:
shuffle(begin(v), end(v), g);
现在is_sorted
函数将返回false,所以0将打印在屏幕上,vector
的元素总量和具体数值都没有变,不过顺序发生了变化。我们会将函数的返回值再次打印在屏幕上:
cout << is_sorted(begin(v), end(v)) << '\n'; print(v);
现在,在通过std::sort
对vector
进行排序。然后打印是否排序的结果:
sort(begin(v), end(v)); cout << is_sorted(begin(v), end(v)) << '\n'; print(v);
另一个比较有趣的函数是std::partition
。有时候,并不需要对列表完全进行排序,只需要比它前面的某些值小就可以。所以,让使用partition
将数值小于5的元素排到前面,并打印它们:
shuffle(begin(v), end(v), g); partition(begin(v), end(v), [] (int i) { return i < 5; }); print(v);
下一个与排序相关的函数是std::partial_sort
。我们可以使用这个函数对容器的内容进行排序,不过只是在某种程度上的排序。其会将vector
中最小的N个数,放在容器的前半部分。其余的留在vector
的后半部分,不进行排序:
shuffle(begin(v), end(v), g); auto middle (next(begin(v), int(v.size()) / 2)); partial_sort(begin(v), middle, end(v)); print(v);
当我们要对没做比较操作符的结构体进行比较,该怎么办呢?让我们来定义一个结构体,然后用这个结构体来实例化一个vector
:
struct mystruct { int a; int b; }; vector<mystruct> mv { {5, 100}, {1, 50}, {-123, 1000}, {3, 70}, {-10, 20} };
std::sort
函数可以将比较函数作为第三个参数进行传入。让我们来使用它,并且传递一个比较函数。为了展示其实如何工作的,我们会对其第二个成员b进行比较。这样,我们将按mystruct::b
的顺序进行排序,而非mystruct::a
的顺序:
sort(begin(mv), end(mv), [] (const mystruct &lhs, const mystruct &rhs) { return lhs.b < rhs.b; });
最后一步则是打印已经排序的vector
:
for (const auto &[a, b] : mv) { cout << "{" << a << ", " << b << "} "; } cout << '\n'; }
编译运行程序。第一个1是由std::is_sorted
所返回的。之后将vector
进行打乱后,is_sorted
就返回0。第三行是打乱后的vector
。下一个1是使用sort之后进行打印的。然后,vector
会被再次打乱,并且使用std::partition
对部分元素进行排序。我们可以看到所有比5小的元素都在左边,比5大的都在右边。我们暂且将现在的顺序认为是乱序。倒数第二行展示了std::partial_sort
的结果。前半部分的内容进行了严格的排序,而后半部分则没有。最后一样,我们将打印mystruct
实例的结果。其结果是严格根据第二个成员变量的值进行排序的:
$ ./sorting_containers 1 0 7, 1, 4, 6, 8, 9, 5, 2, 3, 10, 1 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 4, 3, 5, 7, 8, 10, 9, 6, 1, 2, 3, 4, 5, 9, 8, 10, 7, 6, {-10, 20} {1, 50} {3, 70} {5, 100} {-123, 1000}
这里我们使用了很多与排序算法相关的函数:
算法函数 | 作用 |
---|---|
std::sort | 接受一定范围的元素,并对元素进行排序。 |
std::is_sorted | 接受一定范围的元素,并判断该范围的元素是否经过排序。 |
std::shuffle | 类似于反排序函数;其接受一定范围的元素,并打乱这些元素。 |
std::partial_sort | 接受一定范围的元素和另一个迭代器,前两个参数决定排序的范围,后两个参数决定不排序的范围。 |
std::partition | 能够接受谓词函数。所有元素都会在谓词函数返回true时,被移动到范围的前端。剩下的将放在范围的后方。 |
对于没有实现比较操作符的对象来说,想要排序就需要提供一个自定义的比较函数。其签名为bool function_name(const T &lhs, const T &rhs)
,并且在执行过程中无副作用。
当然排序还有其他类似std::stable_sort
的函数,其能保证排序后元素的原始顺序,std::stable_partition
也有类似的功能。
Note:
std::sort
对于排序有不同的实现。根据所提供的迭代器参数,其实现分为选择排序、插入排序、合并排序,对于元素数量较少的容器可以完全进行优化。在使用者的角度,我们通常都不需要了解这些。
复制、转换和过滤是对一段数据常做的操作。本节,我们将重点放在过滤元素上。
将过滤出的元素从数据结构中移除,或是简单的移除其中一个,但对于不同数据结构来说,操作上就完全不一样了。在链表中(比如std::list
),只要将对应节点的指针进行变动就好。不过,对于连续存储的结构体来说(比如std::vector
,std::array
,还有部分std::deque
),删除相应的元素时,将会有其他元素来替代删除元素的位置。当一个元素槽空出来后,那么后面所有的元素都要进行移动,来将这个空槽填满。这个听起来都很麻烦,不过本节中我们只是想要从字符串中移除空格,这个功能没有太多的工作量。
当我们定义了一个结构体时,我们是不会考虑如何将其元素进行删除的。当需要做这件事的时候,我们才会注意到。STL中的std::remove
和std::remove_if
函数可以给我们提供帮助。
我们将通过不同的方式将vector
中的元素进行删除:
包含必要的头文件,并声明所使用的命名空间。
#include <iostream> #include <vector> #include <algorithm> #include <iterator> using namespace std;
一个简单的打印辅助函数,用来打印vector
中的内容:
void print(const vector<int> &v) { copy(begin(v), end(v), ostream_iterator<int>{cout, ", "}); cout << '\n'; }
我们将使用简单的整数对vector
进行实例化。然后,对vector
进行打印,这样就能和后面的结果进行对比:
int main() { vector<int> v {1, 2, 3, 4, 5, 6}; print(v);
现在,我们移除vector
中所有的2。std::remove
将2值移动到其他位置,这样这个值相当于消失了。因为vector
长度在移除元素后变短了,std::remove
将会返回一个迭代器,这个迭代器指向新的末尾处。旧的end
迭代器所指向的地方,实际上就没有什么意义了,所以我们可以告诉vector
将这个位置进行擦除。我们使用两行代码来完成这个任务:
{ const auto new_end (remove(begin(v), end(v), 2)); v.erase(new_end, end(v)); } print(v);
现在,我们来移除奇数。为了完成移除,我们需要实现一个谓词函数,这个函数用来告诉程序哪些值是奇数,然后结合std::remove_if
来使用。
{ auto odd_number ([](int i) { return i % 2 != 0; }); const auto new_end ( remove_if(begin(v), end(v), odd_number)); v.erase(new_end, end(v)); } print(v);
下一个尝试的算法是std::replace
。我们使用这个函数将所有4替换成123。与std::replace
函数对应,std::replace_if
也存在于STL中,同样可以接受谓词函数:
replace(begin(v), end(v), 4, 123); print(v);
让我们重新初始化vector
,并为接下来的实验创建两个空的vector
:
v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; vector<int> v2; vector<int> v3;
然后,我们实现两个判读奇偶数的谓词函数:
auto odd_number ([](int i) { return i % 2 != 0; }); auto even_number ([](int i) { return i % 2 == 0; });
接下来的两行做的事情完全相同。其将偶数拷贝到v2和v3中。第一行使用std::remove_copy_if
函数,当相应数值不满足谓词条件时,函数会从源容器中拷贝到另一个容器中。第二行std::copy_if
则是拷贝满足谓词条件的元素。
remove_copy_if(begin(v), end(v), back_inserter(v2), odd_number); copy_if(begin(v), end(v), back_inserter(v3), even_number);
然后,打印这两个vector
,其内容应该是完全相同的。
print(v2); print(v3); }
编译运行程序。第一行输出的是vector
初始化的值。第二行是移除2之后的内容。接下来一行是移除所有奇数后的结果。第4行是将4替换为123的结果。最后两行则是v2和v3中的内容:
$ ./removing_items_from_containers 1, 2, 3, 4, 5, 6, 1, 3, 4, 5, 6, 4, 6, 123, 6, 2, 4, 6, 8, 10, 2, 4, 6, 8, 10,
这里我们使用了很多与排序算法相关的函数:
算法函数 | 作用 |
---|---|
std::remove | 接受一个容器范围和一个具体的值作为参数,并且移除对应的值。返回一个新的end迭代器,用于修改容器的范围。 |
std::replace | 接受一个容器范围和两个值作为参数,将使用第二个数值替换所有与第一个数值相同的值。 |
std::remove_copy | 接受一个容器范围,一个输出迭代器和一个值作为参数。并且将所有不满足条件的元素拷贝到输出迭代器的容器中。 |
std::replace_copy | 与std::replace 功能类似,但与std::remove_copy 更类似些。源容器的范围并没有变化。 |
std::copy_if | 与std::copy 功能相同,可以多接受一个谓词函数作为是否进行拷贝的依据。 |
Note:
表中没有if的算法函数,都有一个*_if版本存在,其能接受谓词函数,通过谓词函数判断的结果来进行相应的操作。
如果说std::copy
是STL中最简单的算法,那么std::transform
就是第二简单的算法。和copy
类似,其可将容器某一范围的元素放置到其他容器中,在这个过程中允许进行一些变换(变换函数会对输入值进行一定操作,然后再赋给目标容器)。此外,两个具有不同元素类型的容间也可以使用这个函数。这个函数超级简单,并且非常有用,这个函数会让标准组件具有更好的可移植性。
本节,我们将使用std::transform
在拷贝的同时,修改vector
中的元素:
包含必要的头文件,并且声明所使用的命名空间:
#include <iostream> #include <vector> #include <string> #include <sstream> #include <algorithm> #include <iterator> using namespace std;
vector
由简单的整数组成:
int main() { vector<int> v {1, 2, 3, 4, 5};
为了打印元素,会将所有元拷贝素到ostream_iterator
适配器中。transform
函数可以接受一个函数对象,其能在拷贝过程中对每个元素进行操作。这个例子中,我们将计算每个元素的平方值,所以代码将打印出平方数。因为直接进行了打印,所以平方数并没有进行保存:
transform(begin(v), end(v), ostream_iterator<int>{cout, ", "}, [] (int i) { return i * i; }); cout << '\n';
再来做另一个变换。例如,对于数字3来说,显示成3^2 = 9
显然有更好的可读性。下面的辅助函数int_to_string
函数对象就会使用std::stringstream
对象进行打印操作:
auto int_to_string ([](int i) { stringstream ss; ss << i << "^2 = " << i * i; return ss.str(); });
这样就可以将整型值放入字符串中。可以说我么将这个证书映射到字符串中。使用transform
函数,使我们可以拷贝所有数值到一个字符串vector
中:
vector<string> vs; transform(begin(v), end(v), back_inserter(vs), int_to_string);
在打印完成后,我们的例子就结束了:
copy(begin(vs), end(vs), ostream_iterator<string>{cout, "\n"}); }
编译并运行程序:
$ ./transforming_items_in_containers 1, 4, 9, 16, 25, 1^2 = 1 2^2 = 4 3^2 = 9 4^2 = 16 5^2 = 25
std::transform
函数工作原理和std::copy
差不多,不过在拷贝的过程中会对源容器中的元素进行变换,这个变换函数由用户提供。
通常,需要确定某种元素在某个容器范围内是否存在。如果存在,我们会对这个值进行修改,或者访问与其相关的值。
查找元素的目的是不同的。当想要让在一段已排序的元素中进行查找,可以使用二分查找法,这种方法要比线性查找快的多。如果没有排序,那么就只能进行线性遍历来查找对应的值。
传统的STL查找算法我们都可以使用,所以了解一下这些算法。本节将会使用两个不同的算法,线性查找算法std::find
,二分查找算法std::equal_range
。
本节,我们将对一个比较小的数据集进行线性和二分查找:
包含必要的头文件,以及声明所使用的命名空间。
#include <iostream> #include <vector> #include <list> #include <algorithm> #include <string> using namespace std;
数据集会包含city
结构体,只是存储的城市的名字和人口数量:
struct city { string name; unsigned population; };
搜索算法需要将元素与目标对象进行对比,所以我们需要重载city
结构体的==
操作符:
bool operator==(const city &a, const city &b) { return a.name == b.name && a.population == b.population; }
我们也需要将city
实例进行打印,所以我们对其输出操作符<<
也进行了重载:
ostream& operator<<(ostream &os, const city &city) { return os << "{" << city.name << ", " << city.population << "}"; }
查找函数通常会返回迭代器。当函数找到相应的元素时,会返回指向其的迭代器,否则就会返回容器的end
迭代器。第二种情况下,我们就不能对该迭代器进行访问。因为要打印输出结果,所以需要实现一个函数,这个函数会返回另一个函数对象,并会将数据结构的end
迭代器进行包装。当要对结果进行打印时,会与容器的end
迭代器相比较,如果不是end
,那么打印出查找到的值;如果是end
,则仅打印<end>
:
template <typename C> static auto opt_print (const C &container) { return [end_it (end(container))] (const auto &item) { if (item != end_it) { cout << *item << '\n'; } else { cout << "<end>\n"; } }; }
我们使用德国的一些城市对vector
进行实例化:
int main() { const vector<city> c { {"Aachen", 246000}, {"Berlin", 3502000}, {"Braunschweig", 251000}, {"Cologne", 1060000} }