C---示例学习指南-全-

C++ 示例学习指南(全)

原文:Learn C++ by Example

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

C++是一种不断改进的语言,几乎被用于计算领域的每个角落,从嵌入式系统、操作系统、浏览器、游戏和交易系统到你们可能用来阅读这本书的电子书阅读器。每三年就会推出一个新的 C++标准,编译器供应商也迅速采用最新的功能。我已经在看似不同的游戏和金融行业中专业编写 C++超过 20 年。我主要担心我的代码性能,这导致我创建了 Compiler Explorer,而不是关注语言中的每一个小变化。

同时,在我心中,我担心我可能遗漏了某些东西。当我听说 Fran 正在写这本书时,我很兴奋有机会赶上我长期以来一直忽视的语言的新部分。

我第一次在 C++ on Sea 会议上见到 Fran。她负责主持闪电演讲——每位演讲者有 5 分钟的时间进行演讲,一个接一个地快速进行。作为演讲的主持人,Fran 必须在一个人上台而前一个人离开时进行过渡,在这个过程中,她邀请观众参与各种猜测游戏,比如高或低牌游戏或砸谜题游戏,让我们猜测各种与 C++关键字混合的会议演讲者名字。我们当时并不知道她正在尝试一些她在本书中用作例子的游戏!

在这本书中,Fran 涵盖了包括智能指针、范围、可选类型、变体类型、改进的字符串格式化、constexpr、概念和协程在内的许多最新的 C++特性。如果你对其中任何一个不熟悉,那么你将有一个美好的体验。正如我提到的,我并不总是跟上最新的特性,阅读这本书是一种很有趣的获取它们的方式。而且我终于学会了聚合初始化器和初始化列表之间的区别!

语言的持续进化意味着今天的 C++不再是你们可能从 20 世纪 90 年代和 21 世纪初期记忆中的 bug-prone tangle of memory leaks。不幸的是,在线资源缓慢跟进,并且经常展示旧的、已弃用的做事方式。本书澄清了许多误解,并将为你指明正确的道路。

例子既有趣又好玩,但展示了真实世界的非平凡代码。每个部分都展示了开发过程,随着 Fran 逐一介绍新概念,代码也在迭代。我鼓励你在阅读每个部分时参与进来,编译并运行代码。至少对我来说,通过实践学习比仅仅阅读更有效,通过修改代码,你可以更好地了解修改的容易程度。

在这个过程中,有许多关于我们在编写代码时必须做出的权衡的通用发展建议。有一些测试探索了边缘情况及其处理方法,甚至有如何编写如果使用不当则无法编译的代码的例子,以及有用的错误信息。

每个部分都有相关的链接到在线资源,如博客文章、参考网站和在线工具(不仅仅是编译器探索器),这些资源可以帮助您更深入地理解问题。这些链接完美地补充了书籍,不会分散示例的流程,但如果你愿意,你可以进一步探索。

通过实例学习 C++是一种有趣且实用的方式,可以学习 C++的最新特性。如果您像我一样,一直在担心自己错过了什么,或者如果您在一段时间后重返 C++,那么让弗兰带您踏上现代 C++可能性的旅程,并学习如何用代码走出困境吧!

—马特·戈德博尔特

前言

我第一次接触 C++是在 20 世纪 90 年代,当时被要求编写一个 C++解析器来模拟在 PC 上为嵌入式设备编写的代码。当时我只知道 C 语言,所以这对我来说是一次火与血的洗礼。C++主要是 C 语言加上类,就像许多早期的 C++版本一样。随着时间的推移,我学到了更多,并爱上了这门语言。作为 ACCU(accu.org/)的长期成员,我自愿担任其《Overload》杂志的编辑,这意味着我必须每隔一个月写一篇社论,并鼓励人们投稿以及收集审稿团队的反馈。《Overload》杂志的文章涵盖了从新手到资深专业人士的各种内容,深入探讨了 C++,以及更广泛的编程主题,因此作为编辑,我需要努力跟上所有这些内容。这是一个挑战,我还有很多东西要学。

我曾用 C++进行个人项目,你可以在 YouTube 上找到我许多演讲。我也在专业上使用 C++,主要是在投资银行和其他金融机构。我还了解其他语言,经常在编写 C++库的量化团队和使用它们的前端团队之间充当中间人。我确实理解火箭科学家在编码中使用的许多底层数学。说实话,我只和两位火箭科学家一起工作过,但你可以用 C++做聪明的事情。重要的是理解你在做什么,以及如何测试你的代码。

C++是一门不断发展的语言,所以我永远无法跟上所有的变化。然而,意识到自己不知道的东西意味着我可以挑选特定的部分来练习。在这本书中,我分享了各种小型项目,旨在帮助您学习各种新的 C++特性。多年来,我遇到了许多曾经了解 C++但后来转向使用其他语言一段时间的人,当他们考虑再次学习 C++时,他们被新特性和方法的数量所压倒。花时间学习某样东西,然后发现很难重新熟悉它,这是令人沮丧的。我想鼓励任何处于这种情况的人专注于关键元素,以快速恢复。我希望这本书能满足这一需求。

这本书并没有涵盖从 C++11 以来所有变化的内容。在我撰写这本书的时候,C++23 已经最终确定,所以我包括了当时写作时的一些最新特性。C++ 将会继续变化,但拥有几个可以玩耍的小项目意味着你可以随着语言的不断发展而练习使用它们。例如,这本书使用了各种容器,从 std::vectorstd::unordered_map,等等。容器一直是 C++ 的基本组成部分,但最近的变化使得它们更容易使用。这本书使用了各种新特性,而没有试图成为整个语言的参考书。关于本书的部分提供了更多详细信息。

致谢

撰写这本书既有趣又具有挑战性。我在尝试解释 C++ 的各个方面时学到了很多。我经常向他人寻求帮助或想法,同时努力找到简单但正确描述语言的方法。感谢所有与我争论以确保我正确的人。

我想感谢 Matt Godbolt 为我撰写前言。我很高兴你享受阅读这本书。

在 Manning,我想感谢我的开发编辑 Doug Rudder 和我的 技术编辑 Tim van Deurzen 在撰写本书时的反馈、帮助和鼓励。此外,感谢帮助制作本书的全体工作人员。

我还想感谢所有花时间给我反馈的人,特别是 Howard Hinnant、Andreas Fertig、Nina Ranns、Silas Brown 和 Seb Rose,他们都从繁忙的日程中抽出时间对各个章节进行详细评论,指出我不清楚或不正确的地方。我也感谢 ACCU 和那些在一般电子邮件组中解释我写作时发现的有趣边缘情况的人。任何剩余的错误都是我自己的。

我感谢所有参与 C++ 的人,包括 Matt Godbolt 为他的编译器探索器,Andreas Fertig 为 C++ Insights,以及所有投入时间和金钱开发新标准或参与各种讨论小组的人。

最后,感谢所有审稿人:Amit Lamba、Arun Saha、Aryan Maurya、Balbir Singh、Clifford Thurber、David Racey、Frédéric Flayol、Jean-François Morin、Johannes Lochmann、John Donoghue、Jonathan R. Choate、Jonathan Reeves、Joseph Perenia、Juan José Durillo Barrionuevo、Keith Kim、Kent Spillner、Matteo Battista、Mattia Antonino Di Gangi、Maurizio Tomasi、Michael Kolesidis、Mitchell Fox、Partha Pratim Mukherjee、Patrick Regan、Raushan Jha、Rich Yonts、Samson Hailu、Satej Kumar Sahu、Srikar Vedantam、Sriram Macharla、Timothy Moore、Vimal Upadhyay 和 William Walsh。你们的建议帮助使这本书变得更好。

关于本书

在过去十年左右的时间里,C++发生了很大的变化。一些以前对语言很熟悉的人可能会因为要学习的新事物太多而感到沮丧。这不必那么困难。现在跟上进度将使跟踪 C++的持续变化和演变变得更容易。这本书专注于使用 C++各个部分的小型项目,而不是整个语言的阐述。你将在旅途中尝试一些想法,并学习语言特性,而不是通过一行示例逐个部分地深入语言语法和标准库。第一章是介绍,从第二章到最后一章,你将创建小型项目和游戏来帮助你学习。你甚至可能会觉得很有趣!

谁应该阅读这本书

如果你以前使用过 C++但未能跟上最近的变化,这本书适合你。如果你曾经是专家,但现在你的知识变得模糊,并且你想重新提高水平,这本书将帮助你。如果你以前从未是专家,但以前使用过一些 C++并想学习更多,特别是新的方法和功能,这本书也将非常有价值。

这本书是如何组织的:路线图

这本书有九个章节。第一章提供介绍,其余章节专注于编码的谜题或游戏。在某些情况下,我们首先制作一个简化版本,然后再改进游戏。在所有情况下,我们都专注于 C++的一个或多个主要特性,并在旅途中学习各种其他想法和方法:

  • 第一章提供了关于 C++的背景信息,展示了它的相关性和实用性,并介绍了一些最近的变化。

  • 第二章使用std::vector创建帕斯卡三角形。它还涵盖了移动语义、使用std::format、范围和 lambda 表达式。

  • 第三章使用随机数来制作一个猜数字游戏。它还介绍了std::optionalstd::function和用户输入的处理。

  • 第四章使用std::chrono中的时间点和持续时间来编写倒计时。我们还遇到了用户字面量,并学习了std::ratio

  • 第五章涵盖了编写类来构建一副牌并玩高低牌游戏。它还涵盖了作用域枚举、std::array、三向比较运算符和std::variant

  • 第六章再次使用类来让一些“blob”从纸袋中跑出来,这次修订了继承并详细介绍了现在 C++中可用的新特殊成员函数。此外,它还涵盖了零规则、类型特性和智能指针。

  • 第七章使用std::mapstd::multimap构建一个答案砸游戏。这些容器并不新鲜,但我们看到了如何使用结构化绑定与std::pairstd::tuple一起使用,使我们能够整洁地查询映射。此外,我们还从文件中读取数据。

  • 第八章使用了较新的std::unordered_map并描述了std::hash,以构建一个读心机,或者至少是一个根据先前结果猜测您将选择正面还是反面的程序。它还展示了如何将读心机转换为协程。

  • 第九章通过详细介绍参数包和std::visit来结束全书,展示了如何制作老丨虎丨机游戏。本章鼓励您使用各种算法、std::format和 lambda 进行更多练习。

首先,阅读第一章,然后准备好您选择的编译器。您可以按任何顺序阅读章节;然而,它们在某种程度上相互关联,尽管每个章节都创建了一个自包含的项目。当某个功能再次使用时,首次提及会有标记,这样您就可以在需要时跳回。按顺序阅读章节可能更容易,因为您会逐渐将新的方法添加到您的技能库中。无论您如何决定阅读这本书,都请停下来尝试一些代码。然后玩您创建的游戏,或者与项目互动。保持头脑清醒,提问,实验,最重要的是,享受乐趣!

关于代码

这本书包含了许多源代码示例,既有编号列表,也有与普通文本混排的。在两种情况下,源代码都使用固定宽度字体格式化,以将其与普通文本区分开来。有时,代码也会加粗,以突出显示章节中从先前步骤中更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书中的可用页面空间。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中移除。代码注释伴随许多列表,突出显示重要概念。

本书的所有九章都包含代码。代码都在书中,但可以从github.com/doctorlove/BootstrapCpp.git克隆。第一章是一个简短的main函数,用于讨论 C++的现代方法,而有趣的游戏从第二章开始。您需要一个编译器,isocpp.org/get-started提供了几个优秀的免费编译器的链接。一些功能,如std::format,并非所有编译器都支持,但本书会指出这一点,源代码中的注释也会显示替代方案。

您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为livebook.manning.com/book/learn-c-plus-plus-by-example。本书中示例的完整代码可以从 Manning 网站下载,网址为www.manning.com/books/learn-c-plus-plus-by-example

liveBook 讨论论坛

购买《通过示例学习 C++》包括对 Manning 的在线阅读平台 liveBook 的免费访问。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定的章节或段落附加评论。为自己做笔记、提出和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/learn-c-plus-plus-by-example/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 的论坛和行为的规则。

Manning 对我们读者的承诺是提供一个场所,在这里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣偏离!只要这本书还在印刷中,论坛和先前讨论的存档将可通过出版商的网站访问。

其他资源

每一章都提到了进一步的资源,这些资源被汇总在本书的附录中,因此您可以轻松回顾,而无需做笔记。

关于作者

Frances Buontempo 拥有多年的 C++经验。她曾在伦敦的几家公司的程序员工作,主要专注于金融。她喜欢测试和删除代码,并努力持续学习。她在 YouTube 上发表了关于 C++及其他内容的演讲。她是 ACCU 的《Overload》杂志的编辑,并乐意考虑来自想要分享从这本书中学到的东西的读者的文章。

关于封面插图

《通过示例学习 C++》封面上的图像是“莱姆诺斯岛女人”,或“莱姆诺斯岛的女性”,取自雅克·格拉塞·德·圣索沃尔的收藏,1788 年出版。每一幅插图都是手工精心绘制和着色的。

在那些日子里,仅凭人们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富多样的地域文化的书封面,庆祝计算机行业的创新精神和主动性,这些文化通过像这样的一些收藏品中的图片被重新带回生活。

1 再次欢迎,C++

本章涵盖

  • 为什么 C++ 仍然相关

  • C++ 何时有用

  • 在阅读本书之前你需要知道什么

  • 这本书将如何帮助你启动 C++ 知识

  • 你将在本书中学到什么

C++ 是一种古老但不断发展的语言。在编程中,你可以用它来做几乎所有的事情,你会在许多地方找到它的应用。实际上,C++ 的发明者 Bjarne Stroustrup 将其描述为“一切的无形基础”。有时,C++ 可能深深地存在于另一种语言的库中,因为它可以用于性能关键路径。此外,它可以在小型嵌入式系统中运行,或者为视频游戏供电。甚至你的浏览器可能也在使用这种语言。C++ 几乎无处不在。

这种语言是编译的,针对特定的架构,如 PC、大型机、嵌入式设备、定制硬件,或任何你能想到的东西。如果你需要你的代码在不同的机器上运行,你需要重新编译它。这既有优点也有缺点。不同的配置给你带来更多的维护工作,但针对特定架构的编译可以使你深入到底层,从而获得速度优势。无论你针对哪个平台,你都需要一个编译器。你还需要一个编辑器或集成开发环境(IDE)来编写 C++ 代码。

C++ 源于 C,它具有类似的优点但是一种更低级的语言。如果你认识到 ++ 是增量运算符,你就会意识到语言的名称本身暗示它是 C 的继承者。你可以通过编写高级代码来避免 C++ 中指针和内存分配的深度,你同样可以在 C++ 代码中下降到 C 或甚至汇编语言。尽管 C++ 从未打算接管世界或甚至取代 C,但它确实提供了许多新的编程方法。例如,你可以在编译时做许多令人惊讶的事情,使用类型安全特性而不是 C 中常用的预处理器宏。

这种语言支撑着各种技术,包括其他语言的编译器或解释器,甚至 C++ 编译器本身。你可以为其他语言开发库,编写游戏,定价金融工具,以及做更多的事情。如果你曾在提示符中输入过 make,你很可能在不自知的情况下使用了 C++。如果你以数字形式阅读这本书,C++ 可能正在为你的浏览器或电子阅读器供电,或者它可能被用来编写你机器的设备驱动程序。

这本书将为你打下 C++语言和库特性的坚实基础。每一章都通过一个小型、自包含的项目进行讲解,专注于一个领域。除了每一章的主要特性外,还会涵盖语言的其他部分。例如,如果你填充了一个容器,比如向量或数组,你可能还希望有一种方式来显示和操作其内容。因此,下一章将专注于向量,同时也介绍了范围和 lambda 表达式,以及使用format来显示输出。通过逐渐积累你的知识库,你将获得信心,这将使你能够重新发现 C++的乐趣。本书将重点关注几个基本部分,展示语言现在比过去更容易的各种方式。你将获得坚实的基础,准备好使用和学习更多的 C++。

1.1 为什么 C++很重要?

C++是由一个委员会设计的。有些语言是由公司或个人引入和开发的。C++不是。最初由 Bjarne Stroustrup 发明,国际标准化组织(ISO)的工作组 21(WG21)现在负责其新版本。你可以在isocpp.org/std找到更多详细信息。自 2011 年以来,每三年就会有一个新的标准被批准,每次都会添加新特性,有时还会简化做事的方式。这意味着有很多东西要学习。一些文档和描述听起来就像是用法律术语写的,可能会让人感到压倒。本书将使用一些精确的定义来帮助你理解这些解释。委员会成员提出建议,撰写论文来解释他们的想法,并展示如何实现新特性或小的改进,这导致了影响其他编程语言的创新。例如,如果没有 C++引入模板,Java 和 C#可能就不会有泛型。思想是双向流动的。C++也吸收了其他语言的想法,包括函数式编程习惯,如 lambda 表达式。

这些最近的标准为 C++注入了新的活力,引起了极大的兴奋。那些多年来一直在使用 C++的公司可能之前依赖于内部库来支持现在已成为核心语言特性的功能。升级到较新标准可能是一项艰巨的工作,但这意味着更多的人将能够在不花费时间熟悉特定实现的情况下参与代码库的开发。除了企业技术栈的变化之外,现在还有几个专注于 C++的会议,以及播客和博客,因为新一代的人开始参与其中。C++以其非常核心而闻名,极客们争论着困难的问题,对新手(以及彼此)都很刻薄。这部分是不公平的,但 IncludeCpp 小组(www.includecpp.org/)试图包容和欢迎。他们有一个 Discord 群组,并在 C++会议上设有摊位,所以如果你一个人去,可以直接去找他们打招呼。最近的变化使得 C++的几个部分更容易解释和使用,但同时也引入了更多的边缘情况和复杂性。这本书将倾向于坚持那些使你的生活更轻松的常用特性,但了解一些新特性是有价值的,尽管这些特性支持度不高。

如果你是在 C++11 之前了解 C++的,你可能会被这些变化吓到。实际上,如果你花时间追赶并眨眼,你仍然错过了很多东西。不必担心。尽管 C++可能类似于骑自行车(如果你摔倒了会受伤),但 C++不必痛苦。这本书将阻止你掉进兔子洞。你可以享受乐趣,并学习许多方法和范式,从面向对象编程到函数式方法。C++的基础知识也会使其他语言和方法更容易理解。此外,C++如此普遍,它永远不会消失,所以了解一点是有用的。你永远不可能知道所有内容。甚至 Bjarne 本人据称也说过,他在 C++知识方面的评分是七分。所以不必担心。要成为一名优秀的程序员,你不必知道语言的每一个细节。在了解足够的基础上学习更多是很重要的。如果你现在就建立你的理解,你也会发现跟上进度更容易。

C++ 随着时间的推移而发展壮大。最初,C++ 是在 C 的基础上增加了类,引入了关键字 new(以及 deleteclass)以及构造函数和析构函数的概念。这些函数在对象创建时以及对象超出作用域或被删除时自动运行。与垃圾回收语言(如 C# 和 Java)不同,你控制着对象的整个生命周期。垃圾回收语言的拥护者有时会贬低 C++,声称它很容易导致内存泄漏。现在,你不需要使用 newdelete,C++ 有智能指针来帮助进行内存管理。该语言随着时间的推移而发展,增加了各种新特性。尽管它从开始到现在已经有所增长,但它仍然相对紧凑。就像所有其他语言一样,它取决于你如何使用它。你可以在任何语言中编写糟糕的代码。你同样也可以在任何语言中编写优美的代码,但你需要学会如何做。通过在阅读这本书的过程中尝试代码,你将最终得到一些可以玩耍的小程序。它们将涵盖语言的各个方面,为你打下坚实的基础。你将看到 C++ 可以有多么出色。

尽管有许多 C++ 的竞争对手,但 C++ 仍然具有持久力。它始终位居 TIOBE 指数(https://www.tiobe.com/tiobe-index/#2022)的榜首,并在 2022 年排名前三。你可以使用 C,但你将看到星星(指针用 * 字符表示)。如果你想使用比数组更复杂的数据结构,你必须自己实现。你可以使用高性能 Fortran 进行极快的计算。英国气象办公室使用 Fortran 进行他们的天气预报,因为他们需要在非常短的时间内处理大量数据。Fortran 还在许多学术机构中存在,所以如果你是学者或学生,你可能见过或使用过它。然而,它是一个相对较小的领域。你在更广阔的世界中更有可能遇到一些 C++ 代码。

已经发明了各种新的语言,旨在处理 C++ 的缺陷或烦恼。D 语言由于类似 C 的语法和高层结构,感觉与 C++ 类似,它是为了处理创作者不喜欢的 C++ 方面而发明的。同时,C++ 仍在不断发展,但它始终旨在保持向后兼容性,因此受到历史决策的限制。新语言没有遗留问题,因此有更多的自由。Go、Objective C、Swift、Rust 以及最近出现的 Carbon 在某些领域也挑战了 C++。这很好,学习几种语言并思考什么可能使程序员的职业生涯更轻松是件好事。很多时候,最新 C++ 标准中引入的新想法都是基于其他语言的洞察。随着新语言的引入,C++ 仍然非常流行,并且经常接受它们提出的任何挑战。C++ 在不久的将来不会消失。如果你愿意,你也可以参与其中,提交错误报告或建议。委员会由志愿者组成,他们努力改进该语言。ISOCpp 提供了如何参与的详细信息 (isocpp.org/std/meetings-and-participation)。

如果你学习了 C++,你将为其他语言打下坚实的基础。与其他语言的相似性可以帮助你快速掌握如何使用它们。你将熟悉一些数据结构和算法,以及从函数式编程到面向对象代码的各种范式。即使你最终没有加入标准委员会或发明自己的编程语言,你也将处于继续终身学习之旅并理解底层发生的事情的有利位置。

1.2 你应该在什么时候使用 C++?

你可以用 C++ 做任何事情,但有些用例比其他用例更有意义。为了原型设计一些机器学习或运行统计计算,可能最快的方法是从 Python 开始,并使用现有的库。当然,这些库可能包含一些 C++ 代码。如果你足够自信,可以查看库的源代码来找出为什么出现错误,你将比其他程序员有先发优势。如果有人需要一个具有前端(无论是网站还是具有图形用户界面 (GUI) 的本地程序),你可以用 C++ 构建整个系统,但可能更容易将其拆分。与 C# 等语言不同,C++ 核心语言不支持 GUI,因此前端将需要一个外部库,例如跨平台的 C++ 库 Qt (www.qt.io/)。你也可以用完全不同的东西编写前端,并将 C++ 代码作为服务或库调用。所以,考虑到你可能从另一种语言开始尝试一个想法,或在另一个工具链中构建应用程序的一部分,你什么时候应该使用 C++?

如果你想要一款第一人称射击风格的电子游戏,你可以尝试用 JavaScript 编写,但使用编译到硬件的语言更合理。解释型语言会比编译型语言慢。因此,C++经常被用来编写游戏引擎、渲染图形、处理物理、检测碰撞,并为机器人提供声音和人工智能。脚本语言可能会调用这个引擎,但引擎的强大和速度通常来自 C++,从高端显卡或其他昂贵的游戏设备中榨取每一寸性能。这也使 C++适合高性能计算(HPC)、金融应用、嵌入式设备和机器人技术。

因为 C++让你接近底层硬件,你可以破坏东西。如果你不小心,可能会 brick 一个嵌入式设备,使机器无法使用。如果你编写一个在笔记本电脑或计算机上运行的程序,你不太可能做到这一点。它可能会崩溃,自豪地宣布在退出时出现段错误或类似错误。没有操作系统的嵌入式设备是不同的。如果它只运行一个程序而没有操作系统,并且出了问题,可能会发生糟糕的事情。这也是可以接受的。Bjarne Stroustrup 曾经说,“如果你从未失败,那么你努力的程度还不够”(www.stroustrup.com/quotes.html)。尽管这种语言允许你使用原始指针,并可能超出内存边界或调用未定义的行为,但本书将引导你远离危险。只需记住,人们常说,权力越大,责任越大。有了足够坚实的基础,你可以负责任地编程,学到很多东西,并享受乐趣。

尽管 C++原生不支持几件事情,例如单元测试、GUI 编码,甚至网络(几乎进入 C++23,可能在未来标准中实现),但你可以使用合适的第三方库来做这些事情。核心 C++语言提供的是一个大而周到的标准库。如果你使用 C,并需要一个正态分布的随机数,你需要翻阅一本数学书或阅读 Donald Knuth 关于这个问题的看法。如果你需要一个查找表,你可以使用 C++的标准 map。在 C 中,你必须自己编写。事实上,你从 C++中得到堆栈、队列、堆,以及几乎所有你能想到的数据结构,还有大量的算法。这意味着学习 C++为理解其他语言提供了坚实的基础。

如果你需要快速进行大量数值计算,C++ 是一个很好的选择。现代语言版本也支持各种随机数分布,正如你将在本书中看到的那样,这使得设置各种复杂的模拟相对容易。即使不使用语言中最新和最优秀的部分,你也能用 C++ 构建一些严肃的应用程序。例如,英国帝国理工学院附属的 MRC 中心全球传染病分析中心开源了他们的 COVID-19 模拟模型 (github.com/mrc-ide/covid-sim)。这些模型在英国疫情期间被用于决定公共政策。C++ 承担了繁重的工作,并提供了一些用 R 编写的脚本以显示结果。

C++ 通常被描述为一种多范式语言。它支持面向对象编程,但你也允许编写自由函数。如果你想写低级过程式代码,你也可以使用泛型(即模板)和函数式编程风格。你甚至可以进行模板元编程(TMP),让编译器为你进行计算。这其实是一个意外发现,由 Erwin Unruh 在 1994 年的一次 C++ 委员会会议上提出。他展示了一个无法编译但会打印出编译器错误信息中的素数的程序。玩转 TMP 可以很有趣,探索并推向极致,但简单的案例可以提供更快的运行时间,以及类型安全的、编译器评估的常量。如果你学会了如何使用一些 C++,你将为许多其他语言打下稳定的基石,并了解各种不同的编程范式。

1.3 为什么阅读这本书?

随着语言的发展,人们为每个新的标准和更通用的风格指南编写书籍。如果你不了解新特性,风格指南将毫无意义,而新特性建立在之前的变化之上,所以全部细节可能会让人感到压倒性。面对不断变化的目标,你从哪里开始?从你所在的地方开始。你需要一种方法来自我启动学习。这本书将通过一些小型项目带你了解一些核心变化,这样你就有东西可以实验。通过使用一些新特性,你将更好地识别现代 C++ 代码正在做什么,并知道在哪里关注进一步的变更和发展。

与其阅读可能错过的所有更改列表,ISOCpp 网站有一个常见问题解答部分 (isocpp.org/wiki/faq),它概述了一些最近的更改和宏观问题。该网站由标准 C++ 基金会运营,这是一个非营利组织,其目的是支持 C++ 软件开发社区并推广对现代标准 C++ 的理解和应用。该网站甚至有一个针对有其他语言背景且想学习 C++ 的人的板块。它没有针对“如果你之前已经知道 C++ 有一段时间了,如何学习 C++”的板块。这本书填补了这一空白。你不需要一个包含多年来引入的所有功能的冗长列表。你需要的是足够的,以恢复你的信心。

你可以关注网上众多优秀的资源,以保持对语言中已经发生和正在发生的变化的了解。ISOCpp 将帮助你做到这一点。然而,你需要停下来尝试一些事情来学习。花时间进行实验将会有回报,这本书将引导你通过一些有用的实验。以小块的形式尝试功能将帮助你使思想和概念具体化。你将不时看到替代方法。通过看到将项目放入 vector 的两种方式,你将学习一个新特性(emplace 方法)并回忆起一个旧特性(push_back)。这将帮助你阅读他人的代码,不会被不熟悉的方法所困惑。你将学习如何考虑替代方案,意识到来自不同地方的建议,有时这些建议是相互冲突的。这本书将采取实用主义的方法,同时鼓励你考虑替代方案。

1.4 这本书是如何教授 C++ 的?

本书涵盖了从 C++11 开始引入的 C++ 特性的子集。在撰写本书时,C++23 正处于特性冻结阶段,使其准备好推出新的标准。每一章都专注于一个主要特性,尽管它也介绍了并使用了其他现代特性和惯用法。有些人以前对 C++ 很熟悉,但被需要学习的新事物数量所吓倒,而初学者往往很快就会感到害怕。这不必如此困难。现在跟上进度将使跟踪 C++ 继续变化和发展的过程变得更加容易。如果你已经有一段时间没有使用 C++,并且看到其他书籍列出了所有新的特性和惯用法,但你不知道从哪里开始或如何使用它们,这本书将帮助你专注于一些重要的部分,使你能够在其他地方深入探讨复杂边缘情况和详细解释。

这本书专注于使用 C++ 的各个部分进行独立项目。你将在旅途中尝试一些想法,并学习语言特性,而不是通过一行行的示例来逐个分析语言的语法和标准库。如果你已经生疏了,这本书将给你一个练习和重新发现使用 C++ 欢乐的机会。正如你可能意识到的,编写一个完整的程序比玩弄一两行代码能提供更多的实践机会。因此,这本书将帮助你自学。

1.4.1 这本书面向的对象

这本书的目标读者是那些曾经使用过一些,甚至很多,这种语言但失去了对最近变化跟踪的人。如果你认识这种语法并想尝试学习更多,你将从这本书中获得一些收获。如果你知道 int x = 5; int &y = x; 的作用,之前使用过 std::vector<int>,并认识 std::cout << x,你将能够理解。如果你之前见过 int x{1};,你已经走了一半的路。如果没有,不要慌张。花括号是初始化几乎所有内容的新方法,你很快就会习惯的。如果你以前知道所有复杂的边缘情况,并能引用之前标准的章节和段落,这本书将帮助你专注于一些新特性,让你重新回到驾驶座。一旦你读完这本书,你将知道如何获取最新的编译器以及如何关注即将到来的变化,你将能够阅读和编写现代 C++。现在让我们看看一些代码,以了解一些编写语言的新方法。

1.4.2 再次,C++!

通常,人们会从“Hello, World!”程序开始学习一门语言,所以我们就这样做。以下代码会在屏幕上打印一条问候信息。

列表 1.1 Hello, World

#include <iostream>                    ❶

auto main() -> int {                   ❷
    std::cout << "Hello, world!\n";    ❸
}

❶ 包含一个头文件

❷ 后置返回类型

❸ 运算符 :: 和 <<

如果你将这个程序保存为名为 hello_world.cpp 的文件,你可以编译它。例如,使用 GNU 编译器集合(gcc;见 gcc.gnu.org/),使用支持 C++11 的 g++:

g++ hello_world.cpp -o ./hello.out

本书假设你认识 include 语句、作用域解析 operator:: 和流插入 operator <<。代码在 main 函数中向标准(std)cout 插入问候语,这是可执行代码的常规入口点。然而,你可能不熟悉函数名称末尾的 尾随返回类型 ->,以及行首的关键字 auto。你可以在这里写 int main(),就像你以前一直做的那样,但自从 C++11 引入这个特性后,许多人开始为了保持一致性而到处使用它。当你想要推断函数返回的类型时,这很有用。我们的 hello 程序不需要尾随返回。此外,main 函数是特殊的,因为它默认返回 0,所以即使它返回 int 类型,也不需要返回语句。没有尾随返回类型,一些模板函数可能很难指定。让我们考虑一个使用模板函数的例子。

你可以使用 + 运算符轻松地添加数字。例如,auto x = 1 + 1.23.。我们的朋友 auto 又出现了。我们试图将一个整数(1)和一个双精度浮点数(1.23)相加,所以结果是双精度浮点数,这是由于 整数 提升。如果你想有一个通用加法函数,你可以尝试为每一对可能的参数编写重载,或者更合理地,编写一个模板函数。甚至更好,你可以使用已经为你编写好的一个。functional 头文件包含 plus 的定义。事实上,这个头文件包含两个定义,其中一个将两个相同类型的参数相加,我们通过说 std::plus<int> 来添加两个整数。自 C++14 以来,引入了一个可以推断模板参数类型的版本。使用 std::plus<> 选择新的特化,它会为我们计算出类型。如果你尝试第一个版本,1.23 会被转换为 int,所以你会得到 1 + 1,这可能会让一些编译器发出警告,而第二个版本将 int 1 和 double 1.23 相加得到 2.23。试试看!

列表 1.2 添加两个数字

#include <iostream>
#include <functional>

auto main() -> int {
    std::cout << std::plus<int>{}(1, 1.23) << '\n';     ❶
    std::cout << std::plus<>{}(1, 1.23) << '\n';        ❷
}

❶ 强制两个整数的和,因此返回 2

❷ 确定不同的类型

你习惯于函数以返回类型开头,然后是名称和参数,例如 int main()。返回类型是首先给出的。为了指定返回类型,plus 需要表达两个函数参数的加法操作。这使用参数名称来做要容易得多,但那些名称对于通常的返回类型是不可见的。尾随返回类型使得使用参数名称来指定返回类型成为可能。你需要在开头说 auto,并在尾随 -> 后面指出返回的类型。

让我们看看 plus<> 特化版本的 operator() 的简化版本。记住,我们想要声明一个函数,它接受两个参数并返回它们的和。我们将使用一个模板,包含两个类型名,允许相加两种不同的类型。加法本身是简单的部分,只需简单地使用 + 操作符。声明从 auto 开始,以类型结束。

列表 1.3 添加两种不同类型的功能

template<typename T, typename U>
auto simple_plus(T lhs, U rhs) -> decltype(lhs + rhs)
{
    return lhs + rhs;
}

操作符函数是一个模板,使用两种不同的类型 TU 分别表示二元运算的左侧 (lhs) 和右侧 (rhs)。返回类型使用 decltype 指示符和表达式 lhs + rhs 声明。如果你眯起眼睛看,你会发现这与我们之前看到的 main 函数的语法相似。将它们并排放置,看看:

auto main() -> int
auto simple_plus(T& lhs, U& rhs ) -> decltype(lhs + rhs)

你可以看到在两种情况下都跟随 auto、函数名和参数,然后是箭头和尾随返回类型。当我们添加 1 和 1.23 时,参数类型被推断为整数和双精度浮点数。尾随返回类型使用表达式 (1 + 1.23) 来获取双精度浮点数的返回类型。

如果你已经认识这些新特性,那太好了。还有很多新事物要学习。如果你以前从未见过这些,请专注于这里的主要观点,这是你在尝试“Hello, World!”时看到的:尾随返回类型。你已经学到了一些东西。

1.4.3 从阅读本书中你将学到什么

你将学习如何使用语言的一些新元素,从范围到随机数,并在旅途中学习几种其他更简单的方法来做事情。这本书从向量开始,并在此基础上构建。向量是一个很好的复习和学习新特性的方法,包括范围、视图、函数式对象和 lambda 表达式。一旦你熟悉了使用范围和算法填充、显示、查询和操作向量,你就可以准备使用标准库的其他部分,包括时间(chrono)、随机数,最后是协程。

C++11 中引入的基于范围的 for 循环使语言变得更简单。你可以使用它们遍历容器,而无需首先深入迭代器。随着时间的推移,完整的范围也已成为标准,提供了便利,避免了直接使用迭代器,同时提供了更统一的查找和额外的安全性。以前,可以将一个容器的开始传递给算法,并将另一个容器的结束传递给算法,而不会意识到这个问题,直到运行时发生可怕的事情。范围避免了这个问题。你将熟悉使用范围来查看和复制容器的内容。

通过使用构造函数和操作符的 default 关键字,你会发现为什么在类中不需要那么多样板代码。你将学习如何使用新的随机数分布。如果你习惯于调用 C 的 rand 函数,新的方法一开始可能看起来很复杂,但它很强大,并且当正确使用时,可以帮助你避免人们常犯的错误,例如在模拟掷骰子或洗牌时。

通过在每个章节中使用自包含的项目,你将有机会使用各种新功能和旧功能。你将达到理解新功能的地步,知道何时以及如何以惯用的方式使用它们。有时关于做事的最佳方式有不同的观点。你早些时候看到了尾随返回类型:auto main() -> int。有些人非常喜欢它并在任何地方使用它,但有些人则不喜欢。语言的演变使我们超越了争论括号放置(如果你不喜欢我的方法,请提前原谅)并给了我们更多可以争论的东西。这本书将提供替代方案,在讨论此类问题时坚定地站在中间,这样你就可以专注于尝试编写一些代码并尝试用新的方式表达自己。

1.5 一些专业技巧

在学习过程中可能会感到迷茫或不知所措,尤其是当你试图处理一个大主题时。如果你记住以下几点,你将能够找到自己的路。

首先,许多新特性都是 语法糖,其次,许多代码元素使用标点符号,这很难查找。如果你想知道之前给出的主函数中 -> 符号的作用,你会从哪里开始?一个非常有用的工具是 Andreas Fertig 的 C++ Insights (cppinsights.io/) 网站。C++ Insights 将代码转换为显示一些较新 C++ 功能背后的细节。它基于 Clang (clang.llvm.org/) 和 Andreas 对 C++ 的理解 (cppinsights.io/about.html)。如果你输入我们在列表 1.2 中查看的 plus 代码,C++ Insights 将为你转换代码。

列表 1.4 C++ Insights 输出

#include <iostream>
#include <functional>

int main()                     ❶
{
  std::operator<<
    (
        std::cout.operator<<
        (
        std::plus<int>{{}}.operator()(1, static_cast<const int>(1.23))
        ),
        '\n'
    );                         ❷
  std::operator<<
    (
        std::cout.operator<<
        (
        std::plus<void>{}.operator()(1, 1.23)
        ),
        '\n'
    );                         ❸
  return 0;                    ❹
}

❶ 追加返回值已被重写。

❷ 明确写出 << 和 () 是操作符,并将 1.23 转换为 int 类型。

❸ 明确写出 << 和 () 是操作符。

❹ 我们没有明确返回 0,但它对我们来说发生了。

直接尝试一下 (cppinsights.io/s/508b2063)。洞察力可能会显示很多细节,生成的代码基于 Clang,所以它可能不总是适用于其他编译器,但列表 1.4 显示了转换后的尾随返回符号 ->,以及使用的 std::plus<int>std::plus<void> 结构。如果你不能理解你遇到的一个函数,尝试使用 C++ Insights 获取线索。

需要记住的是,并非所有编译器都支持所有新特性,因此你可能需要多个编译器。至少,你可能需要在 Visual Studio 中使用/std:c++latest选项或在 g++中使用--std=c++20。如果你不想设置另一个工具,你总是可以通过 Matt Godbolt 的编译器探索器(godbolt.org/)在线尝试各种编译器中的 C++代码。它支持大量的不同编译器,让你可以看到每个编译器的行为。这本书会尽量遵循常见的部分,但如果你想要探索更多,这是一个很好的资源,与 C++ Insights 一起使用。每个资源都有一个链接到另一个资源,所以为什么不两者都使用呢?在花费时间设置工具链之前,CppReference 有一个列表,列出了每个新特性的编译器支持(en.cppreference.com/w/cpp/compiler_support),以帮助你决定你需要哪个版本。这是检查函数签名或简单地找到你需要包含以使用某个特性的标准头文件的一个很好的资源。

最后,如果你遇到了困难,不要慌张。编译器可能会因为你在某个模板代码中忘记分号而给出几个错误。不过,较新的编译器可能会指出实际的问题,而不是给出需要翻阅的页面错误。大多数现代编译器确实会尝试提供一些帮助,所以如果你之前遇到过困难并放弃了,现在可能会更容易一些。然而,你有时还是会遇到难以理解的错误。如果你无法解决它们,可以寻求帮助,或者尝试从第一个错误开始。如果这也不行,尝试从最后一个错误开始,或者至少找到一个指向你的代码的错误,而不是库代码。如果这也不行,可以全部注释掉,然后慢慢将你的代码添加回去。或者,更好的是,考虑使用版本控制并回滚到之前工作过的版本。这本书不会带你详细了解如何设置一个合理的开发环境,但会指出一些有用的工具和需要考虑的事项。

摘要

  • C++无处不在,几乎可以用于任何事情。

  • C++正在不断发展,每三年就会有一个新的标准,由 ISO 的 WG21 决定。

  • C++是一种多范式语言。

  • 你需要一个支持你选择平台的编译器。

  • 其他类似的语言也都有,但 C++能为你打下各种技术和习惯用法坚实的基础。

  • 目前没有任何单个编译器支持该语言最新版本的每个特性,但你可以使用 Godbolt 和 C++ Insights 来尝试短小的代码片段,以检查它们是否可以编译。

  • 编写整个程序是学习的好方法,你将在本书的其余部分中这样做。

2 容器、迭代器和范围

本章涵盖

  • 填充和使用容器,重点关注数字向量

  • 基于范围的 for 循环和 auto

  • 使用标准算法的容器

  • 使用 format 显示输出

  • 范围、视图和 lambda 表达式

容器和算法长期以来一直是 C++ 的基本组成部分。容器包括序列(例如,vector),关联容器(例如,map),以及自 C++ 11 以来,无序关联容器(例如,unordered_map)。容器管理其元素的存储。数据结构和算法的分离提供了极大的灵活性,允许一个算法应用于各种容器。将范围添加到核心语言提供了简化访问和操作容器的方法。为了探索这些特性,在本章中,我们将构建帕斯卡三角形,它通过从前一行相邻数字相加得到,第一行从单个 1 开始。这些条目可以用来计算事件组合的数量等等。我们将使用向量来存储值,从第一行开始,以练习使用向量和将内容输出到屏幕。然后我们将生成并显示更多行,学习如何以不同的方式使用向量。最后,我们将讨论三角形的一些属性。这将帮助我们思考稍后对代码进行测试。

如果您想跟随操作,则需要编译器和编辑器或 IDE。免费资源的列表可在 isocpp.org/get-started 找到。我使用 Vim 与 GNU 编译器集合(GCC)在 Windows 子系统(WSL)中的组合,以及 Visual Studio 2022 社区版,在 C++ 命令行属性中使用 /std:c++latest

2.1 创建和显示向量

首先,我们将创建一个包含单个数字的向量并显示它。这将构成三角形的第一行。向量是最常用的容器,因此从它们开始是方便的。然后我们可以练习将不同的元素放入向量中,包括其他向量,并使用算法处理它们。在编码过程中,我们还将使用几个其他 C++ 功能。

将代码放在 main 入口点函数之外是一个好主意,这样您可以轻松添加测试或构建库以简化代码的重用。话虽如此,我们将把所有内容放在一个名为 main.cpp 的文件中,以保持简单,并创建一个从 main 中调用的函数。我们首先创建一个包含一个数字的向量,并显示其内容如下。

列表 2.1 填充和显示容器

#include <iostream>                 ❶
#include <vector>                   ❶

void generate_triangle()
{
    std::vector<int> data{ 1 };     ❷
    for (auto number : data)        ❸
    {
        std::cout << number << ' ';
    }
    std::cout << '\n';
}

int main()
{
    generate_triangle();
}

❶ 包含输出和向量本身的头文件

❷ 定义一个向量,初始化为单个数字 1

❸ 使用基于范围的 for 循环遍历向量

如果您正在参与,请编译并运行您的代码。对于 GCC 工具,编译方式如下:

g++ -Wall --std=c++2a -o main.out main.cpp

我们在这里不需要说明使用的是哪个std,只要编译器支持至少 C++11 标准,并且我们正在检查任何警告,使用all到警告标志-W-o标志指定了输出文件名,一旦构建了单个main.cpp文件,我们可以通过输入./main.out来运行它。如果你使用的是 IDE,找到你的构建按钮,然后找到运行按钮。你应该在屏幕上看到一个数字。

代码包含一些新的 C++特性。在顶部,我们包含了两个头文件:iostream用于输入和输出流以及vector。这应该是熟悉的。然后有一个函数用于生成和显示三角形的第一行,使用vector进行存储。vector被初始化为单个数字 1:

std::vector<int> data{ 1 };

注意我们使用的是花括号,称为统一初始化语法。如果我们说

std::vector<int> data( 1 );

相反,我们得到一个只有一个值的向量,这个值是 0。向量有各种构造函数。第二个版本使用(1)将数字 1 视为元素的数量。第一个版本,使用花括号{1},是使用初始化列表。列表可以包含多个项目,向量是用初始化列表的内容创建的。尝试使用初始化列表{1, 2.3}将生成编译器错误。这需要一个narrowing conversion,因为2.3是一个双精度浮点数,而我们想要一个int类型的vector。我们甚至可以使用{}来初始化一个单个数字:int x{ 42 }。由于花括号初始化可以在许多地方使用,因此它被称为统一初始化。ISOCpp 建议优先使用花括号初始化(mng.bz/n1m5),因为它避免了缩窄并允许一致性。初始化是一个很大的主题,可能会变得复杂。例如,Nicolai Josuttis 谈到了“C++初始化的噩梦”(www.youtube.com/watch?v=7DTlWPgX6zs)。这里要注意的重要一点是我们可以使用初始化列表来构造一个向量。

有了我们的数据容器,我们可以在标准输出流(cout)上显示其内容。我们使用基于范围的for循环来遍历容器,使用插入操作符<<将容器中的元素流式传输出来。通常,基于范围的for循环有for、括号和冒号,就像我们在列表 2.1 中看到的那样:

for (auto number : data) 

这是一个比传统的for循环更简洁的语法。在冒号的左侧有一个类型和一个变量名。我们可以偷懒,让编译器通过使用auto来自动确定类型。在冒号的右侧有一个容器、数组或类似的东西。我们可以将基于范围的for循环视为一种语法糖,使我们的生活更轻松。我们不需要明确说明停止条件或如何遍历项目。基于范围的for循环为我们做了这些。

如果我们在 C++ Insights(cppinsights.io/)中尝试这段代码,我们会看到循环被转换为一个传统的 C 风格for循环,包含三个部分:一个开始,一个停止条件,和一个增量。每个容器都有一个开始和一个结束,基于范围的for循环使用这些来遍历元素。C++ Insights 显示了所有细节,但给出的代码与

for (auto position = data.begin(); position!=data.end(); ++position)

使用 C 风格的for循环时,我们使用一个指向向量的迭代器位置,当我们想要打印值时,需要使用operator*来解引用:

std::cout << *position << ' ';

基于范围的for循环更容易使用,这意味着我们可以以更高的层次进行编码,而不必考虑迭代器。

让我们更详细地看看auto。这个关键字告诉编译器推断类型。如果你在使用 IDE,将鼠标悬停在auto这个词上可能会告诉你推断出的类型。Visual Studio 说它使用std::vector<int>>::iterator::value_类型,即int。在我们的情况下,用int代替auto几乎没有明显的区别,但几乎总是使用auto(AAA)有一些优点。这个短语是由 Herb Sutter 在他的 Guru of the Week 博客(mng.bz/vPpp)上提出的。在更复杂的情况下,auto将节省很多打字,同时倾向于保持代码类型安全。如果我们将容器的类型更改为使用double,我们就不需要更改循环,因此当使用auto时,代码更不容易出错。使用auto还可以捕捉到容易忽略的细微差别。如果我们使数据成为常量

const std::vector<int> data{ 1 };

循环变量的类型会自动更改为const_iterator,所以我们不需要记住在那里进行更改。实际上,我们甚至可以使用auto来声明我们的容器:

auto data = std::vector<int>{ 1 };

因为data是一个包含int的初始化列表构造的int向量,所以它被推断为vector<int>

更重要的是,auto可以帮助我们避免隐式转换,包括缩窄转换,并强制我们初始化变量。我们可以说auto variable = init,或者如果我们想要一个特定的类型,我们可以说auto variable = type{init}。在两种情况下,我们都被迫明确地说明如何初始化变量。我们不能说auto variable;,因为这将导致编译器错误。如果我们尝试像auto x = int{1.5}这样的操作,我们也会得到编译器错误,因为我们正在尝试使用缩窄转换。如果我们说int x = 1.5代替,我们可能会得到一个警告,但有些人会忽略警告。这不是一个好主意,但这种情况确实会发生。使用auto可以阻止潜在的错误。

回到我们的向量。我们可以对创建向量的方式进行一个小改动。我们告诉编译器在向量中放置一个整数,所以它当然可以推断出向量中元素的类型。是的,它现在可以了。自从 C++17 以来,我们可以说std:: vector data{ 1 }。注意我们没有指定模板类型。相反,我们依赖于类模板参数推导(CTAD)。如果我们决定在几乎每个地方都使用auto,我们甚至可以将我们的声明改为auto data = std::vector{ 1 }。现在,如果我们想要一个空向量,类型无法推断,因为auto data = std:: vector{}没有一种方法可以推断出元素类型,所以它无法编译。CTAD 是另一个新特性,它可以节省我们一些打字。

现在,我们可以显示帕斯卡三角形的第一个行。这看起来可能是一个小步骤,但我们已经看到了一些 C++特性,并且可以在此基础上构建。接下来,我们将向三角形添加更多行,并在过程中学习更多 C++。

2.2 创建和显示帕斯卡三角形

我们现在有了第一行,并将用它来制作接下来的几行,展示我们得到的结果。我们将使用 C++20 的 range 库来打印结果。Ranges 是 C++20 中引入的几个较大特性之一,它超越了更短的语法。一旦我们有了几行,我们就会考虑一些帕斯卡三角形的性质,这将帮助我们测试我们的代码并练习使用我们的向量。让我们先回顾一下如何构建帕斯卡三角形。

2.2.1 帕斯卡三角形的提醒

帕斯卡三角形包含几个有用的数字序列。一个常见的用途是找出事件组合的方式。如果你掷一次硬币,你可以得到正面或反面。如果你掷两次,你可以得到两次正面,两次反面,或者一次正面一次反面,有两种方式:先正面后反面或先反面后正面。对于三次掷币,你可能得到全部正面,两次正面,一次正面,或者一个也没有,但对于给定数量的正面,有多少种组合呢?帕斯卡三角形会告诉我们。

三角形从定义上开始于一个数字 1。如果我们正在寻找掷硬币时可能组合的数量,那么对于零次掷币有一个结果。每一行随后从定义上开始和结束于 1。这对应于事件的组合。对于一次掷币,我们可以以一种方式得到一个单一的正面,或者以一种方式得到一个单一的反面。因此,第二行是两个 1。对于第三行,同样,我们开始和结束于 1,因为我们可以以一种方式得到全部正面,或者以一种方式得到全部反面。下一个数字是前一行中两个数字的和,按照图 2.1 所示排列行。

CH02_F01_Buontempo

图 2.1 该图显示了帕斯卡三角形的几行。

要生成第四行,我们从 1 开始,然后从上一行中求和前两个数字,得到 1 + 2 = 3;接下来,我们求和第二个和第三个数字,得到 2 + 1 = 3。我们已经用完了上一行,所以最后我们加一个 1。对于三次抛硬币,第四行告诉我们可以有多少种组合:1, 3, 3, 1。换句话说,有一种方法可以得到全部正面;三种方法可以得到两个正面(HHT, HTH, THH);三种方法可以得到一个正面(HTT, THT, TTH);最后,有一种方法可以得到没有正面(TTT)。

我们继续下一行,从 1 开始,在上一行中添加相邻的数字对,并以最后的 1 结束。我们可以在纸上无限期地这样做,但代码是另一回事。整数有一个最大值,这将在不同的机器和编译器之间有所不同。如果我们包含数值头文件,我们可以通过调用std::numeric_limits<int>::max()来找出平台给出的值。我得到 2,147,483,647,这是 2 的 31 次方减 1,即 2³¹ - 1。这足够存放几行。

2.2.2 编码帕斯卡三角形

有几种等效的方法可以生成三角形;然而,让我们根据我们刚刚看到的定义来编写代码。我们看到了如何使用上一行的数字构建一个新行,所以让我们构建一个函数,它接受最后一行并返回下一行。在上一个部分中,我们直接将数据发送到屏幕上,但这使得代码难以测试,所以返回数据并编写一个单独的显示函数是有意义的。毕竟,单一职责函数是合理的。我们为第一行创建了一个整数vector,所以我们将继续使用vector<int>为每一行。下一个列表显示了我们的函数,在下一行的开始和结束处添加一个 1,并在中间进行一些求和。

列表 2.2 使用上一行构建帕斯卡三角形的下一行

std::vector<int> get_next_row(const std::vector<int> & last_row)
{
    std::vector next_row{ 1 };                                      ❶
    if (last_row.empty())
    {
        return next_row;
    }
    for (size_t idx = 0; idx+1 < last_row.size(); ++idx)
    {
        next_row.emplace_back(last_row[idx] + last_row[idx + 1]);   ❷
    }
    next_row.emplace_back(1);
    return next_row;
}

❶ 使用 CTAD 推导出我们的模板包含整数

❷ 存储上一行中两个数字的和

我们使用花括号初始化第一行,因为有一个特定的值。现在我们想要计算值并将它们添加到一个vector中。有多种方法可以做到这一点。要将值添加到向量的末尾,我们可以使用push_backemplace_back。要在其他地方添加项目,我们可以使用insertemplaceemplace版本发送数据以在原地创建项目,而push_backinsert则接受一个完全形成的项目,并将它们复制到向量中,如图 2.2 所示。

CH02_F02_Buontempo

图 2.2 emplace(左侧)接受参数以在原地构建一个项目,而push_back则接受一个已完全构建的项目并将其复制到向量中。

对于我们的整数,这两种方法都达到相同的效果。有时,emplace版本会更快,因为它直接在向量中构造元素;然而,有时push_back可能更安全。emplace版本会为我们找到一个构造函数,这可能与我们自己选择的并不一样。贾森·特纳在 C++ Weekly 上讨论了其优缺点(www.youtube.com/watch?v=jKS9dSHkAZY)。总之,我们可能会看到两者都被使用。

我们现在可以计算每一行的值,但我们需要将它们存储在某个地方。向量是一个明智的选择,它给我们一个std::vector<std::vector<int>>。这听起来有点复杂,编译器可以为我们解决这个问题。这意味着当我们编写一个创建三角形的函数时,我们可以使用auto作为返回类型,以避免完全写出std::vector<std::vector<int>>。对于更复杂的函数,我们可能需要帮助编译器确定返回的类型,但在这个例子中,编译器可以应对。我们想要多少行?如果我们接受所需的数字作为参数,我们可以推迟这个决定。我们只需要调用我们的get_next_row函数来填充我们返回的向量,从空的数据行开始。

列表 2.3 生成帕斯卡三角形的几行

auto generate_triangle(int rows)             ❶
{
    std::vector<int> data;
    std::vector<std::vector<int>> triangle;
    for (int row = 0; row < rows; ++row)
    {
        data = get_next_row(data);           ❷
        triangle.push_back(data);            ❸
    }
    return triangle;
}

❶ 推导返回类型的自动简写

❷ 从前一行生成下一行

❸ 将其添加到三角形中

我们可以在这里停止,显示我们的三角形,并进入新的一章。然而,这种方法并不特别高效。我们可以做得更好。

2.2.3 移动语义和完美转发

向量有许多不同的构造函数。我们在列表 2.1 中创建三角形的第一行时使用了接受初始化列表的版本:

std::vector<int> data{ 1 };

为了生成三角形,我们默认构造每个数据行作为

std::vector<int> data;

并在函数调用后分配它:

data = get_next_row(data);

然后我们将数据推送到三角形的末尾:

triangle.push_back(data);    

这并不像它本可以做到的那样高效。我们创建了行数据,并将其复制推回到向量中。如果我们进行一些小的更改,实际上是通过使用不同的构造函数,我们可以避免复制。让我们看看如何使用所谓的完美转发来实现这一点。

我们之前看到vector支持push_backemplace_back。前者接受一个完整的项,这是我们在这里所拥有的,而后者在原地构造一个对象。push_back有两种版本。第一个通过引用接受一个项:

void push_back( const T& value );

这个版本将由我们的代码调用。它接受我们的data并在三角形的末尾创建一个副本。如果我们使用push_back的第二个重载,我们可以避免这个复制。该签名使用&&来表示rvalue reference

void push_back( T&& value ); 

什么是右值引用?任何表达式都有一个 值类别,例如右值或左值。还有其他类别,但在这里我们不会详细介绍它们。相反,我们将专注于避免复制。如果你想要深入了解,CppReference 提供了完整细节(mng.bz/468R)。

C 使用了左值和右值的概念。如果我们说

int x = 42;

变量 x 位于表达式的左侧,因此被称为左值(lvalue),而 42 位于右侧,被称为右值(rvalue)。左值有一个名称,而右值没有。当我们调用 get_next_row 时,我们得到一个右值。这是一个之前复制到左值 data 中的临时未命名向量。这是浪费的。与其保留数据的副本,我们可以使用 back 方法来获取三角形的最后一行。因此,我们需要用第一行初始化三角形,以便在后面有一个元素。现在我们可以像下面列表中所示的那样编写我们的函数。

列表 2.4 移动临时对象

auto generate_triangle(int rows)
{
    std::vector<std::vector<int>> triangle{ {1} };    ❶
    for (int row = 1; row < rows; ++row)              ❷
    {
        triangle.push_back(get_next_row(triangle.back()));
    }
    return triangle;
}

❶ 添加第一行,以便我们可以调用 back

❷ 从 1 开始,因为我们已经有一行

我们不再有数据的副本。push_back( const T& value ) 版本使用值的副本初始化新元素,但接受右值引用的版本 push_back( T&& value ) 可以为我们将临时对象移动到三角形中,避免复制。向量有各种构造函数,包括一个接受右值引用的构造函数,称为 移动构造函数。它的签名有我们之前看到的 &&

vector( vector&& other );

接受 T && 值的 push_back 方法可以通过调用 std::move 来利用这个构造函数,这被称为 移动语义push_back && 重载可以,并且通常可以,实现为

void push_back( T&& value ) {
    emplace_back(std::move(value));
}

push_back 方法内部,右值有一个名称(value),因此它变成了左值。通过调用 std::move(value),值被转换回右值,以便选择右值构造函数。实际上,C++ 的 move 操作并没有真正移动任何东西。它将一个值转换为右值。这允许调用接受右值的重载,这被称为 完美转发。一旦调用 move 并将右值传递给函数,值就处于有效但未指定的状态。由于它已经被移动,对我们来说就不再有用。如果没有移动,其他向量将作为左值传递,并且会调用复制构造函数。这涉及到不必要的复制,因此它将值转发得不够完美。

移动语义和完美转发是很大的主题,我们只是触及了表面。托马斯·贝克尔(Thomas Becker)在 2013 年写了一篇优秀的博客文章,详细介绍了这些细节(mng.bz/QRE6)。一个右值引用 && 可能是一个左值

或者一个右值。如果它有一个名称,它是一个左值,但调用 std::move 会将其转换为右值,从而允许完美转发。实际上,我们可以直接使用右值或临时对象调用 emplace_back

triangle.emplace_back(get_next_row(triangle.back()));

移动构造函数是如何避免复制的?向量连续存储项目,因此我们可以使用迭代器以及索引来访问元素。我们不需要在编译时知道元素的数量,因为向量可以动态调整大小。当向量空间不足时,它会分配更多的内存。我们可以将其视为一个指向某些项目的容器,如图 2.3 所示。

CH02_F03_Buontempo

图 2.3 一个向量指向其元素。

向量不仅仅是一个指向其元素的指针,关注这一点将揭示移动构造函数如何避免复制。复制构造函数或赋值操作需要复制每个元素,因此我们将有一个原始的向量,比如四个元素,如图 2.3 所示,以及一个相同的副本,也指向四个元素。移动构造函数可以通过指向 rvalue 的项而不是分配副本来有效地从 rvalue 中窃取元素,如图 2.4 所示。

CH02_F04_Buontempo

图 2.4 移动构造的向量可以窃取 rvalue 的元素。

此后没有任何其他东西可以尝试使用无名的临时元素,所以这是可以的。此外,实际上没有任何东西移动过,而是移动构造函数接管了临时数据的所有权,并且不需要复制任何元素。

我们已经看到了两种生成向量的方法。第二种方法更高效,因为它不会进行不必要的复制。我们现在需要一种方法来显示我们的三角形。

2.2.4 使用范围来显示向量

之前,我们直接将包含单个元素的向量发送到屏幕上,但如果我们写得更通用一些,我们可以将其发送到文件或任何其他流。我们通过为我们的三角形重载operator <<来实现这一点。我们有一行,它是一个vector,包含一个整数的vector。我们不需要在for循环内部再写一个for循环来写出每个元素,我们可以使用ranges库将元素复制到提供的流中。如果你的编译器还不支持ranges::copy,你可以使用std::copy代替。我们可以使用输出流迭代器(std::ostream_iterator)来复制并指示我们希望在数字之间留有空格;否则,它们将不可读。包含<algorithm>以使用std::copy和包含<iterator>头文件以使用std::ostream_iterator。然后按照以下列表添加一个新函数。

列表 2.5 将内容发送到流

#include <algorithm>                                                ❶
#include <iterator>
template<typename T>
std::ostream& operator << (std::ostream & s,                        ❷
    const std::vector<std::vector<T>>& triangle)
{
    for (const auto& row : triangle)                                ❸
    {
        std::ranges::copy(row, std::ostream_iterator<T>(s, " "));   ❹
        s << '\n';
    }
    return s;
}

❶ 包含算法以使用 ranges::copy

❷ 允许选择流

❸ 使用常量引用以避免复制

❹ 将行发送到输出流

注意我们现在通过在 for 循环中使用 const auto& row 来对三角形的每一行使用常量引用。这应该是熟悉的。如果我们使用 auto row: v,我们将把整行内容复制到 data 中。引用避免了复制,而 const 表示我们无法更改内容。C++ 核心指南(mng.bz/Xqn9)鼓励我们不要在基于范围的 for 循环中创建循环变量的昂贵副本,正如在表达式和语句(ES)部分所指出的,“ES.71:当有选择时,优先使用范围-for 语句而不是 for 语句。” 这些指南由 Bjarne Stroustrup 和 Herb Sutter 以及许多其他贡献者编纂,包含大量合理的建议。您将在本书中不时看到更多这样的建议。

for 循环为我们提供了每一行的引用。我们使用范围算法将这个引用发送到流中。与我们在列表 2.1 中使用的基于范围的 for 循环一样,范围的复制会确定从我们的数据向量开始和结束的位置。在概念上,任何通过提供 start 迭代器和 end 标志提供迭代的对象都是范围。较老算法使用相同类型的 beginend。标志是一个最近添加的通用概念,它泛化了 end 迭代器的想法,类似于使用空字符来指示 char 数组的结束。我们可以编写自己的标志来在遇到负数时停止或任何其他自定义逻辑。然而,我们的向量有一个 beginend,这是我们在这里需要的所有内容。我们可以使用同一头文件中的非范围复制算法,但我们需要自己指定 beginend

std::copy(data.begin(), data.end(), std::ostream_iterator<T>(s, " "));

两种版本的 copy 都可以,但范围版本稍微简洁一些。这是标准算法的许多范围版本之一。范围提供了比简洁语法更多的功能。我们还可以对范围进行视图操作,允许在不复制数据的情况下进行组合和过滤。视图按需评估;换句话说,它们支持惰性求值。在本章的后面部分,我们将使用更多范围。

现在我们可以调用我们的代码来生成三角形并查看我们得到的结果。如果我们请求大量的行数,它将无法适应屏幕,并且我们可能会溢出 int,所以让我们尝试 16。

列表 2.6 生成和显示三角形的主体代码

int main()
{
    auto triangle = generate_triangle(16);
    std::cout << triangle;
}

<< 运算符找到我们的新函数并生成一个左对齐的三角形,如图 2.5 所示。

CH02_F05_Buontempo

图 2.5 帕斯卡三角形的最初几行

警告 为常见类型(如 vector<vector<int>>)定义 operator << 通常是一个坏主意,因为如果两个不同的库或组件尝试做同样的事情,大型系统最终可能会发生冲突。对于你自己的类来说是可以的。编写一个命名函数更好。我们很快就会这么做。

如果我们尝试生成许多行,比如说 36 行,最后几行将不会适应屏幕,我们将开始看到整数溢出并变成负数。从左侧开始打印每一行是足够的简单,但它给出了非传统的输出。如果我们居中对齐输出,我们可以做得更好。这也给了我们学习新format库的机会。

2.2.5 使用格式显示输出

当我们看到如何生成三角形时,图 2.1 显示了居中对齐的行,这是显示三角形的传统方式。坚持使用 16 行可以显示最多四位的数字,因此如果我们将每个数字居中对齐在六个空间内,并在每行的开头添加足够的空间,我们就会得到我们想要的结果。我们可以通过包含format头文件来使用std::format工具来完成这项工作。format最初是 Victor Zverovich 的开源fmt库(fmt.dev/latest/index.html)。一些编译器目前还没有完全支持format,因此您可能需要使用这个库。安装库有多种方法,但最简单的是从主页下载并解压下载的文件。在随后的代码中,不要包含标准的format头文件,而是使用fmt/core.h;只使用头文件是最简单的:

#define FMT_HEADER_ONLY
#include <fmt/core.h>

在代码中,您还需要使用fmt::format而不是std::format,并且您需要通过使用-I开关告诉编译器额外的include路径:

-I/[path_to_unzipped_fmt_download]/include 

在撰写本文时,开源库包含的功能比当前标准 C++支持的功能更多,但在这里我们将坚持使用常见支持的功能。

提示:如果您的编译器目前不支持format,您可以使用开源的fmt库(fmt.dev/latest/index.html)。或者,fmt库包含一个指向 Godbolt(godbolt.org/z/Eq5763)的链接,其中包含fmt库,您可以在编译器探索器中尝试代码。

format库类似于 C 的printf函数,但通常更快、更简单、更安全。语法在字符串内部使用花括号作为占位符。占位符可以是空的,也可以是一个格式说明符(例如d表示十进制),或者给出一个从值中获取的参数的索引。如果我们不通过索引指定在哪里使用哪个值,它们将按顺序放置。格式说明符与 Python 的非常相似。如果我们请求一个使用d格式说明符的数字,但传递了一个字符串

auto does_not_compile = std::format("I am not a number {:d}", "ten");

如果我们使用错误的格式,将会得到编译器错误,这使得formatprintf更安全使用。对于数字,我们可能希望显示正负号,因此可以通过使用{:+d}在冒号后表示。如果我们不指定,默认情况下负数将显示负号,而正数则不显示符号。冒号之后,我们可以说我们想要十进制(d)、二进制(b)等等。

回顾图 2.5,最后一行的最大数字是 6435。因为我们的数字因此不会超过四位数长,所以我们可以将每个元素放在一个六位的块中,每边至少留一个空格。居中对齐的指定符是 ^,左对齐是 <,右对齐是 >,所以我们使用以下方式格式化元素

std::format("{: ⁶}", element);

注意冒号后面的占位符 {}。我们不使用索引,所以在冒号前面不要放任何内容。然后我们有 " ",这意味着用空格填充到长度为 6,并居中对齐值。实际上,我们可以在占位符内部添加更多的花括号来改变长度,将 6 传递到占位符内部,如下所示:

std::format("{: ^{}}", element, 6);

这给我们一个 嵌套替换字段。这样,我们可以计算每个数字需要多少空间。我们在这里不会做这件事,但花时间实验 format

我们还需要在每行的开头留空格以获得对称的三角形。如果我们计算出最后一行的长度,我们可以将其长度减半以确定放置第一行 1 的位置。让我们先思考几行。我们注意到最后一行的最大数字是 6435,它有四个数字。如果我们每边都加一个空格,我们需要为每个数字一个六位的块。第二行将需要两个六位块,共十二个字符。为了将我们的第一个数字放在中间,我们需要在开头留三个空格,使第一个块位于下一行的两个数字上。因为我们告诉 format 居中对齐值,第一个值将位于该块中间。图 2.6 使用 1234 来表示任何四位数,展示了这一点。

CH02_F06_Buontempo

图 2.6 如果我们在第一行的开头添加三个空格(如虚线所示),我们可以使三角形更加对称。

在行向量上调用 back().size() 告诉我们最终行将使用多少个六位块。为了将第一行放在中间,我们需要为每行添加三个空格;因此,我们最初需要 back().size() 的三倍填充。对于每一行,我们也在每一步中将填充减少三个,以形成三角形形状。

将我们的格式和空格计算结合起来,我们可以编写以下函数来显示我们的三角形。

列表 2.7 居中对齐输出

void show_vectors(std::ostream& s,
    const std::vector<std::vector<int>>& v)
{
    size_t final_row_size = v.back().size();
    std::string spaces(final_row_size * 3, ' ');     ❶
    for (const auto& row : v)
    {
        s << spaces;
        if (spaces.size() > 3)
            spaces.resize(spaces.size()-3);          ❷
        for (const auto& data : row)
        {
            s << std::format("{: ^{}}", data, 6);    ❸
        }
        s << '\n';
    }
}

❶ 每行三个空格

❷ 每行减少三个空格

❸ 在六个数字的块中居中对齐每个数字

我们可以在 main 函数中调用我们的新函数而不是之前的操作符。

列表 2.8 生成和显示三角形的 main 函数

int main()
{
    auto triangle = generate_triangle(16);
    show_vectors(std::cout, triangle);      ❶
}

❶ 将列表 2.6 中的操作符 << 替换为 show_vectors

这生成并显示我们的居中对齐三角形,如图 2.7 所示。输出看起来大致正确,但我们需要考虑如何测试我们的结果。我们将在这个过程中学习更多 C++。

CH02_F07_Buontempo

图 2.7 一个居中对齐的三角形

2.3 三角形的性质

我们已经看到了三角形的一些模式。我们知道每一行都以 1 开始和结束,所以我们可以先添加一个检查这个属性的检查。然后,我们将考虑每行期望的元素数量以及元素的总和。最后,我们将看到在数字太大而无法放入整数之前,我们可以安全生成多少行。我们将把这些属性构建成一系列测试。

不幸的是,C++ 并没有自带测试框架。我们不会花时间设置和学习这样的框架,而是将使用在 cassert 头文件中定义的 assert 函数。开头的字母 c 告诉我们我们正在从 C 标准库中引入代码。assert 是一个宏,因此预处理器会逐字复制其内容。如果断言中的表达式为假,它将终止我们的程序。某些设置只在使用 NDEBUG 宏定义的情况下在调试构建中使用 assert。如果没有它,断言会做些事情,但如果 NDEBUG 被定义,它们就什么都不做。检查你的设置。最简单的方法是检查 assert(0) 是否会终止程序。

列表 2.9 从失败的测试开始

#include <cassert>
#include <vector>
void check_properties(const std::vector<std::vector<int>> & triangle)
{
    assert(0);              ❶
}

int main()
{
    check_properties({});   ❷
}

❶ 在第 5 行强制断言失败

❷ 调用带有空向量的函数

在 Ubuntu 的 WSL 上使用 g++,我们看到消息 Aborted 以及行号、函数名和消息

test.out: main_assert.cpp:5: void check_properties(const std::vector<std::vector<int> >&): Assertion `0' failed.
Aborted

如果我们从 Visual Studio 运行它,我们会得到一个对话框,其中包含包括断言失败的行号在内的详细信息。从失败的测试开始是开始测试代码的好方法。至少,它证明了如果断言失败,我们会得到一些反馈。这意味着我们已经准备好测试我们的三角形生成。

注意:使用 assert 和从 main 中检查属性是一种实用的开始测试的方法;然而,花时间学习一个合适的单元测试框架是值得的。有几个 C++ 测试框架可用,包括 Catch2、Google Test 和 Boost。

现在我们有一个可以添加属性的函数。移除 assert(0),我们就可以添加属性来检查我们的三角形中是否有正确的数字了。

2.3.1 检查每行的第一个和最后一个元素

我们知道每行的第一个和最后一个数字必须是 1,所以我们将首先测试这一点。我们需要向我们的 properties 函数添加两个断言来测试我们的预期,如下所示。

列表 2.10 确保第一个和最后一个元素是 1

#include <cassert>                                   ❶
void check_properties(
    const std::vector<std::vector<int>>& triangle    ❷
)
{
    for (const auto & row : triangle)                ❸
    {
        assert(row.front() == 1);                    ❹
        assert(row.back() == 1);                     ❺
    }
}

❶ 包含 assert 宏

❷ 一个接受三角形作为常量引用的新函数

❸ 使用基于范围的 for 循环来检查每一行

❹ 检查第一个元素是否为 1

❺ 检查最后一个元素是否为 1

我们可以在生成三角形后从 main 中调用这个函数。我们的单个测试是成功的,所以我们准备添加更多。警告:因为失败的 assert 会调用 abort,如果有一件事失败,我们将不会检查其他属性。你可以通过堆叠失败消息并断言错误消息为空来避免这种情况。尝试一下,或者更好的是,尝试在一个合适的框架中编写测试。

2.3.2 检查每行的元素数量

帕斯卡三角形的其他属性。第 n 行有 n 个数字。为什么?我们知道第一行是一个单独的 1。第二行是两个 1。第三行以 1 开始,然后从上一行累加 1 来得到数字 2,然后行尾再有一个 1,这样我们就有了三个数字。第四行有四个数字,这个模式继续。如果你还不确定,请回顾图 2.5 中的三角形。如果我们跟踪行号,我们可以添加另一个 assert 来检查这个属性。

列表 2.11 确保每行具有预期的元素数量

size_t row_number = 1;                    ❶
for (const auto & row : triangle)
{
    assert(row.front() == 1);
    assert(row.back() == 1);
    assert(row.size() == row_number++);   ❷
}

❶ 使用变量跟踪行号

❷ 检查每行是否具有预期的尺寸

我们现在应该检查内容。如果我们检查每个条目,我们需要找到另一种方法来生成行中的每个数字;否则,我们将重复我们试图测试的代码。这个陷阱很容易陷入,而尝试从属性的角度思考可以帮助我们避免这样的问题。

2.3.3 检查行中元素的总和

每行数字的总和也遵循一个模式。表 2.1 展示了这些是 2 的幂,从 0 开始。记住,任何数的 0 次幂都是 1。这给我们提供了另一个要检查的属性。

表 2.1 三角形每行数字之和是 2 的幂。

行号 2 的幂
1 1 0
1+1 2 1
1+2+1 4 2
1+3+3+1 8 3
1+4+6+4+1 16 4

我们需要找到每行数字的总和来检查这个属性。而不是编写一个 for 循环,我们可以使用 标准模板库 (STL)。Herb Sutter 和 Andrei Alexandrescu 在他们的书 C++ 编程标准:101 条规则、指南和最佳实践(Addison-Wesley Professional,2004 年)中建议优先使用算法调用而不是手写循环。STL 还包含许多用于泛型容器的算法,包括位于 numeric 头文件中的 accumulate 方法,这正是我们所需要的。我们之前提到,一些算法支持范围,但一些,包括 accumulate,则不支持。因此,我们需要显式地找到 beginend

accumulate函数有两个版本。它们都接受某个范围或容器的第一个和最后一个迭代器,以及一个初始值。第一个版本将operator+应用于每个元素和当前的累积值,从提供的初始值开始。如果我们使用初始值 0,我们将获得所有元素的总和,这正是我们所需要的。第二个版本允许我们提供自己的二元运算符。这可以是任何接受两个参数的函数。第一个参数从给定的初始值开始,因此它必须是相同类型,或者初始值必须可以转换为该参数的类型。第二个参数接受迭代器的值;因此,它也需要是合适的类型。CppReference (mng.bz/yZGp)提供了完整的详细信息,包括签名。对于我们将使用的第一个版本,我们有

template< class InputIt, class T >
T accumulate( InputIt first, InputIt last, T init);

注意初始值init的类型为 T。返回值也是。如果我们使用int,即使是双精度容器的返回值也将是int。我们的容器有ints,所以我们没问题,但如果我们使用双精度,我们需要使用0.0accumulate函数非常灵活。第二个版本接受一个二元运算符:

template< class InputIt, class T, class BinaryOperation >
T accumulate( InputIt first, InputIt last, T init, BinaryOperation op );

我们可以使用operator*来找到所有数字的乘积,前提是我们从初始值 1 开始。更通用的第二种形式有时被称为左折叠。如果你想复习算法,查看algorithmnumeric头文件是一个好的起点。

现在我们可以将检查行总和的检查包含到我们的属性测试函数中。从预期的总起始值 1 开始,每次翻倍,我们可以检查行的总和是否是我们预期的 2 的幂。将预期的总数添加到我们的属性函数中,并使用accumulate函数以及numeric头文件,我们得到以下新的检查。

列表 2.12 确保每一行都有预期的元素总和

int expected_total = 1;                          ❶
for (const auto & row : triangle)
{
    assert(std::accumulate(row.begin(),
                       row.end(),
                       0) == expected_total);    ❷
    expected_total *= 2;                         ❸
}

❶ 我们预期的总起始值为 1。

❷ 检查总数

❸ 随着每次迭代,预期的总数翻倍。

如果我们运行我们的代码,所有的断言都通过了。这些属性并不能证明我们是正确的,但它们确实给了我们对生成代码的一些信心。现在我们将看看三角形的一个最终属性,然后以另一个模式结束,只是为了好玩。同样,我们将在路上练习更多的 C++。

2.3.4 我们可以正确生成多少行?

由于每个数字都是两个前一个数字的和,并且我们开始时使用的是正数,所以我们永远不会得到负数。对于初学者和数学家来说,添加正数应该总是得到正数。然而,数字在计算机上有时会做一些令人惊讶的,有时令人烦恼的事情,比如溢出。我们将先设置一个测试,然后看看我们是否能破坏它。如果我们继续添加ints,我们最终会超出可能的最大大小。标准告诉我们这是未定义的行为:

如果在表达式的评估过程中,结果在数学上未定义或不在其类型的可表示值范围内,则行为是未定义的。(eel.is/c++draft/expr

我们可以通过一些数学计算来找出我们选择的数值类型能够容纳的最大行数。然而,如果我们观察当我们尝试不断添加行时会发生什么,我们可以在路上学习更多关于 C++的知识。尽管我们依赖于未定义的行为,但在 Visual Studio 中,整数会环绕,这样我们就可以找到我们可以安全生成的最大行数。

有多种方法可以检查值不是负数。我们可以检查每个数字是否为正,或者尝试找到任何负数。我们可以将检查写入for循环中,但我们将遵循在可能的情况下使用算法的建议。实际上,我们将尝试几种方法,以获得更多关于算法的实践,并且我们将更多地了解范围。

algorithm头文件提供了几个非修改序列操作。其中许多用于查找或搜索元素。我们可以使用all_of来检查所有元素都是正数。我们也可以使用none_ofany_of,它们做我们可能期望的事情。所有三个都接受一个一元谓词,这是一个接受一个值并返回bool的函数。值来自容器或范围。

我们想要检查所有的数字都大于零。这比说它们都不是负数更积极。我们可以编写一个函数,但我们也可以使用匿名函数,也就是所谓的lambda。其语法看起来非常像正常函数,但它没有名字,并且在开头有一个由方括号[]表示的捕获列表。这允许我们通过引用或作为副本捕获局部变量。我们会说[&]来通过引用捕获 lambda 表达式体中使用的任何内容,而[=]来捕获通过值使用的任何内容。我们也可以通过指定特定的变量来说明[=, &x],这样x就被捕获为引用,而其他任何内容都是通过值捕获。我们也可以显式地将y命名为通过值捕获,在这种情况下,我们不需要等号:[y, &x]。在我们的情况下,我们不需要捕获任何内容。我们只需要检查是否有任何整数大于或等于零:

[](int x) { return x >= 0; }

命名函数看起来会是这样:

bool non_negative(int x){ return x >= 0; }

每个的语法都很相似,都有一个参数列表和花括号中的主体。命名函数必须指定一个返回类型。lambda 可以使用尾随返回类型,这在第一章中我们已经看到,但如果未提供,则返回类型会被推导。lambda 表达式构建闭包,这是一个从函数式编程中借用的术语。我们将在下一章中更详细地探讨这一点。

我们像这样在std::all_of中使用我们的 lambda:

std::all_of(row.begin(), row.end(), [](int x) { return x >= 0; })

使用命名函数绝对没问题,但对于小型函数,如果所有内容都在一个地方,可能会更容易看到正在发生的事情。现在,我们明确声明了 int 作为参数类型。我们知道我们的 vector 包含整数。然而,我们之前被告知几乎总是使用 auto,我们也可以在这里这样做:

std::all_of(row.begin(), row.end(), [](auto x) { return x >= 0; })

如果我们要更改向量中包含的类型,我们也不需要更改此代码。实际上,我们还可以使用一个范围来检查所有行,如下所示:

assert(std::ranges::all_of(row, [](auto x) { return x >= 0; }));

我们已经多次使用基于范围的 for 循环,并在列表 2.5 中使用 ranges::copy 将一行发送到屏幕。我们知道一些标准算法,如 all_of,有一个 ranges 等价物,尽管并非所有算法都有等价物。当它们存在时,它们可以节省我们输入 beginend。范围提供了更多。容器和算法是 STL 的一部分。这两个抽象很有用,但依赖于迭代器。编写自己的可能会很麻烦,如果你想要组合算法,你需要跟踪每次调用后的结束位置。臭名昭著的,remove_if 算法不会移除任何东西。相反,它将你不想移除的元素推送到集合的开始处,并返回一个指向第一个不需要的元素的迭代器,如果你想在没有这些元素的情况下做进一步的操作,你可以使用这个迭代器代替 end。以下代码显示了如果我们忘记跟踪新的结束点会发生什么。

列表 2.13 使用 remove_if

auto v = std::vector{ 0, 1, 2, 3, 4, 5 };
auto new_end = std::remove_if(v.begin(), v.end(),
    [](int i) { return i < 3; });                  ❶
std::cout << '\n';
for (int n : v) {                                  ❷
    std::cout << n << ' ';
}
for (auto it = v.begin(); it != new_end; ++it) {   ❸
    std::cout << *it << ' ';                       ❹
}

❶ 移除小于 3 的元素

❷ 显示整个容器

❸ 使用新的结束点

❹ 使用 * 解引用迭代器以获取每个元素

第一个循环在 Visual Studio 中打印出 3, 4, 5, 3, 4, 5,因为小于 3 的元素已经被移除。其他编译器可能会给出不同的结果。然而,我们现在有三个元素超过了新的结束点。第二个循环按要求显示了 3, 4, 5。如果你需要多次过滤和转换,事情会很快变得失控。

范围避免了这个问题。它们允许我们获取容器的只读 视图 并在视图中过滤或转换元素,而无需跟踪迭代器。我们可以使用 std::view 访问 ranges 视图。这是一个方便的缩写,表示 std::ranges::views,它在 ranges 头文件中定义。如果我们想跳过小于 3 的初始元素,我们可以使用 drop_while,这在各种其他编程语言中可能很熟悉:

for (int n : std::views::drop_while(v, [](int i) { return i < 3; })) {
    std::cout << n << ' ';
}

如果您的编译器还不支持范围,请在编译器探索器(godbolt.org/z/YrnsTGbfx)上尝试。我们还可以使用管道字符'|'drop_while应用于我们的容器。管道字符是一个运算符,允许我们将多个算法链接在一起,这既方便又强大。如果我们想组合几个视图,第一种方法最终会在括号内嵌套多个调用,而使用管道运算符分隔步骤会使代码更容易阅读。你可能熟悉 Unix 中用于将一个命令的输出发送到另一个命令的管道字符。我们只想为这个例子设置一个过滤器。我们可以使用管道运算符重写将向量发送到drop_while函数的版本,如下所示:

for (int n : v | std::views::drop_while([](int i) { return i < 3; })) {
    std::cout << n << ' ';
}

如果我们运行它,我们会看到3, 4, 5,而无需集中精力记住哪个迭代器指向哪里。

我们可以使用视图确保我们的三角形行中没有负数。而不是使用drop_while跳过初始元素,我们想要过滤掉任何负数,所以我们使用filter函数。

列表 2.14 通过使用视图确保没有负数

auto negative = [](int x) { return x < 0; };           ❶
auto negatives = row | std::views::filter(negative);   ❷
assert(negatives.empty());                             ❸

❶ 一个谓词,用于确定一个数字是否为负

❷ 过滤行以获取负数

❸ 检查负数是否为空

drop_while示例一样,我们有以下形式

v | function(lambda) 

这使我们能够看到我们的容器视图。

我们可以将此检查添加到我们的负数测试中。如果我们坚持生成 16 行,一切都会正常。然而,如果我们尝试 35 行,断言就会失败。当我们学习如何生成三角形的行时,我们注意到我们最终会耗尽数字。我们使用std::numeric_limits<int>::max()找到了可能的最大条目,这可能是 2,147,483,647,具体取决于您的编译器。第 34 行的最大值是 1,166,803,110。然后我们在下一行得到双倍的数量,因为我们添加了相邻的值,这将给出 2,333,606,220。这个数字超出了int的范围,并且按照标准,其行为是未定义的,正如我们所看到的。在某些系统上,这个值会回绕到最小值-2147483648,然后再次计数。这就是为什么我们的测试失败的原因。无符号整数会给我们更多的空间:它会在 4,294,967,295 之后再次回绕,但回绕到 0。这将使错误更难被发现。

核心指南告诉我们,我们不应该通过使用unsigned来尝试避免负值(mng.bz/M9VQ)。例如,我们可以将一个负值赋给一个unsigned,比如unsigned int u1 = -2。令人烦恼的是,这会编译并给我们一个很大的正数。对于有符号整数,我们可以检查该值是否不是负数。对于无符号整数,我们不能再进行检查了。我们知道我们可以安全生成多少行。让我们测试三角形的最后一个属性。

2.3.5 检查每一行是否对称

每一行都是对称的。第一个和最后一个数字都是 1,这是对称的,我们已经检查了这一点。我们可以进一步检查所有条目是否对称。这就像检查一个单词是否是回文一样,意味着它读起来前后一样。CppReference 将检查回文作为 ranges 的equal方法的示例(mng.bz/amej)。我们可以重新利用这个来检查我们的向量。我们需要确保行的前半部分与后半部分反转后匹配。Ranges 提供了一个容器的视图。视图有一个take方法,它遍历我们请求的元素数量。我们需要前半部分,即v.size()/2。我们使用ranges::equal方法将这个与反转的后半部分进行比较。

列表 2.15 检查对称性

bool is_palindrome(const std::vector<int>& v)
{
    auto forward = v | std::views::take(v.size() / 2);     ❶
    auto backward = v | std::views::reverse                ❷
                      | std::views::take(v.size() / 2);    ❸
    return std::ranges::equal(forward, backward);          ❹
}

❶ 前半部分的正向视图

❷ 反转视图

❸ 使用后半部分,通过|连接

❹ 检查这些是否相等

注意,我们已经使用管道操作符将视图连接在一起,不需要关注哪些迭代器在哪里需要。我们可以使用回文函数添加一个最终的断言到我们的测试中:

assert(is_palindrome(row));

现在我们有一套有用的测试,并使用了ranges库中的一系列方法。三角形中还有许多其他模式,但由于本章即将结束,我们只会再查看一个模式来总结我们所学的内容。

2.3.6 在一行中突出显示奇数

如果我们在三角形中突出显示奇数,我们会看到另一种模式。回顾我们的代码,在列表 2.7 中显示三角形,我们可以在打印之前使用ranges库中的另一个工具来转换每一行。每个奇数都是两个的倍数加一,因此我们可以通过检查x % 2来找到奇数。我们将用星号显示它们以查看模式。否则,我们显示一个空格。我们将使用视图的转换方法来对每一行应用操作:

auto odds = row |
    std::views::transform([](int x) { return x % 2 ? '*' : ' '; });

我们可以使用我们的转换代码来给出类似于列表 2.7 的内容,其中我们显示了三角形中的实际值。图 2.8 展示了产生的模式。

CH02_F08_Buontempo

图 2.8 通过打印奇数个*和偶数个空白空间得到的 Sierpinski 三角形的近似

列表 2.16 显示奇数用星号表示

void show_view(std::ostream& s,
    const std::vector<std::vector<int>>& v)
{
    std::string spaces(v.back().size(), ' ');
    for (const auto& row : v)
    {
        s << spaces;
        if (spaces.size())
            spaces.resize(spaces.size() - 1);
        auto odds = row | std::views::transform([](int x)
                            { return x % 2 ? '*' : ' '; });
        for (const auto& data : odds)
        {
            s << data << ' ';
        }
        s << '\n';
    }
}

我们预期会有对称性。重复的三角形可能是一个令人惊喜的发现。这近似于 Sierpinski 三角形,它是一个递归地分成更小三角形的三角形形状。如果我们画一个等边三角形,将角折叠在一起,并在折叠处画线,我们就会得到图 2.6 中间的空白三角形,以及顶部的三角形,左下角的三角形和右下角的三角形。然后我们可以对角落上的三个三角形做同样的处理。这个三角形是分形的,因为当你放大时它会重复。理论上我们可以永远地分割三角形,展示这种分形特性。我们也可以尝试使用偶数或者不同的模数,我们会看到其他模式。

在本章中,我们学到了很多关于如何使用向量的知识。我们没有涵盖所有内容,但我们已经做了足够的练习,以识别各种 C++特性和测试我们的代码。

摘要

  • 容器是 STL 的一部分,编译器有时可以为我们推导表达式的类型。

  • 当我们想直接提供值时,可以使用初始化列表{value1, value2, ...}来初始化对象。

  • 当我们想在容器中直接创建对象时,可以使用emplace_backemplace,或者当我们已经有一个对象时,可以使用push_backinsert

  • 基于范围的for循环是遍历容器的一种常见方式,避免了迭代器或索引的使用。

  • 几乎总是使用auto,包括在容器中使用时依赖类模板参数推导。

  • std::move将值转换为右值,允许完美转发。

  • 一些标准算法的版本接受beginend,而std::ranges命名空间中的其他版本现在支持范围。

  • 可以使用管道操作符将视图和过滤器链式连接。

  • Lambda 是未命名的函数,可以捕获变量并形成闭包。

  • 使用format对齐文本或设置数字的宽度或精度,以提高速度和类型安全。

3 输入字符串和数字

本章涵盖

  • 输入数字和字符串

  • 当我们可能没有值时使用optional

  • 处理随机数

  • 进一步练习使用 lambda 和std::function

在本章中,我们将编写一个猜数字游戏来练习使用字符串和数字进行输入。我们需要生成一个随机数字来猜测,接受玩家的输入,并报告玩家的猜测是否正确。我们将确保猜测实际上是一个数字,因此我们将学习如何处理字符串和数字。如果猜测错误,我们将给出提示,从“太大”或“太小”开始,然后添加更多提示,例如有多少位是正确的。对随机数的简要介绍将为后续章节奠定基础,并且我们将在过程中学习更多 C++特性。

3.1 猜测一个预定的数字

我们将从猜测一个固定数字开始。猜测一个永远不会改变的数字并不是一个很有趣的游戏,但这意味着我们可以专注于处理用户输入。如果我们把预定的数字放入一个函数中,我们以后可以改变它。

列表 3.1 一个要猜测的数字

unsigned some_const_number()
{
    return 42;
}

随意选择另一个数字。我们不需要一个完整的函数来做这件事,但这可能比发送硬编码或魔法数字的猜测游戏代码更清晰。我们稍后会用随机数替换它。现在,我们只需要做一些用户输入,看看它是否匹配。

3.1.1 以困难的方式接受用户输入

在上一章中,我们使用了流插入operator <<将值发送到屏幕上。iostream头文件还通过流提取operator >>提供输入。我们可以使用此运算符将输入发送到变量中,如下所示:

unsigned number;
std::cin >> number;

它为所有标准 C++类型定义,就像operator<<一样。我们正在尝试将任何键入的内容流式传输到unsigned,因为我们的猜测数字是unsigned。如果用户输入数字后按 Enter 键,变量可能包含一个数字。在上一章中,我们看到了我们可以将一个负数赋给一个unsigned。对于一个有符号数,最高位表示数字的符号,而一个无符号数使用这个位作为值的一部分,所以我们可以说unsigned int number = -2,它将编译,但数字在 Visual Studio 2022 中将具有很大的正值,4294967294。此外,输入可能不是一个数字,甚至可能太大而无法适合我们选择的数值类型。这表明直接将流式传输到unsigned是一个坏主意,但我们可以通过一些额外的工作来说服它相对良好地表现。我们将在下一节尝试替代方法。

让我们看看如果我们坚持直接将输入到 unsigned 中,我们能走多远。操作符会跳过任何初始空白,然后消费字符直到按下 Enter,如图 3.1 所示。如果只有初始空白和一些数字,一切正常。空白被忽略,数字被转换成一个存储在 unsigned 变量中的值。然而,如果输入不适合 unsigned,会发生两件事:输入流处于错误状态,并且它有未使用的字符需要清除。

CH03_F01_Buontempo

图 3.1 将流输入到 unsigned 中会跳过初始空白,接受尽可能多的数字,然后忽略其他任何内容,留下流中的未使用字符。

一旦遇到不合适的字符,就会设置一个标志,我们可以通过调用 std::cin .fail() 直接检查这个标志。我们也可以通过检查 (std::cin >> number) 是否为真来使用操作符的显式转换为 bool。流的转换是通过一个 explicit operator bool 进行的,这意味着它可以被显式地转换为 bool。CppReference (mng.bz/W164) 将这种检查描述为惯用法。操作符被标记为 explicit,这意味着我们需要在一个期望 bool 的上下文中,例如 ifwhile,这意味着我们不会意外地将流转换为 bool。如果发生错误,我们需要清除失败标志并使用 ignore 函数清除坏字符。该函数接受两个参数:要提取的字符数和一个停止的定界字符,因此我们希望提取尽可能多的字符,并在换行符 '\n' 处停止。然后我们可以循环直到用户输入合理的内容。将这一切组合起来并包括 limitsiostream 头文件,我们得到以下内容。

列表 3.2 从标准输入读取数字

unsigned input()
{
    unsigned number;
    while (!(std::cin >> number))                                  ❶
    {
        std::cin.clear();                                          ❷
        std::cin.ignore(
            std::numeric_limits<std::streamsize>::max(), '\n');    ❸
        std::cout << "Please enter a number.\n>";
    }
    return number;                                                 ❹
}

❶ 流入字符并检查是否没有失败

❷ 清除失败标志

❸ 清除无效输入

❹ 如果我们退出循环,则返回一个数字

带着从列表 3.1 中预定的初始数字,我们可以使用列表 3.2 中的输入函数创建一个猜谜游戏。

列表 3.3 尝试编写一个数字猜谜游戏

void guess_number(unsigned number)
{
    std::cout << "Guess the number.\n>";
    unsigned guess = input();
    while (guess != number)                 ❶
    {
        std::cout << guess << " is wrong. Try again\n>";
        guess = input();
    }
    std::cout << "Well done.\n";            ❷
}
int main() 
{
    guess_ number(some_const_number());     ❸
}

❶ 当猜测错误时循环

❷ 只有在猜测正确时才退出循环

❸ 使用预定的数字调用我们的猜测函数

我们可以玩游戏,但也可以进行一些改进。我们确保输入了一个数字。如果我们尝试一些乱码,我们会一次又一次地被告知,直到我们输入一个数字,如图 3.2 所示。

CH03_F02_Buontempo

图 3.2 如果我们不输入数字,我们会陷入循环。

现在尝试一个负数,例如 -1。图 3.3 显示了发生了什么。

CH03_F03_Buontempo

图 3.3 猜测一个负数并不像预期的那样工作。

我们知道为什么会发生这种情况;当我们将-1赋值给一个unsigned时,它会回绕。我们可以通过将类型更改为int来修复这个问题。如图 3.4 所示,如果我们尝试一些其他非负数,并以我们不太随机的数字42结束,我们就赢了。

CH03_F04_Buontempo

图 3.4 假设我们避免不良输入,我们可以玩一个相当可预测的游戏。

我们有一个类似数字猜谜游戏的样子,但最好给用户一个表明他们放弃的方式。通过改变输入函数,我们可以使数字输入成为可选的,这样用户就可以更容易地停止游戏。

3.1.2 接受可选的数字输入

cin中的c代表字符。我们不是直接将字符流输入到数值类型中,而是将字符流输入到一个字符串中。如果我们包含string头文件,我们可以这样接受输入:

std::string in;
std::cin >> in;

string将包含用户输入,但cin会在空白处停止。如果我们输入“Hello, World!”,字符串将只包含“Hello,”剩下的输入将留给我们,以便流到另一个string或忽略。我们可以这样获取整行:

std::string in;
std::getline(std::cin, in);

这将收集每一行中的每个字符,包括空白,直到行尾,将行尾之前的字符留给我们放入std::string in中。然后我们可以选择如何处理整行。

因为我们想将输入与一个数字进行比较,我们需要做些事情来转换输入。如果我们编写一个适当的函数,称为read_number,它接受一个流,我们处理包含sstream头文件后从getline得到的字符串:

std::istringstream in_stream(in);
auto number = read_number(in_stream);

我们如何实现这个read_number函数?有各种方法尝试从字符串或流中解析整数。与IOStreams一起工作可能会很快变得非常复杂。Angelika Langer 和 Klaus Kreft 写了一本名为《标准 C++ IOStreams 和 Locales:高级程序员指南和参考》(Addison-Wesley Professional;2000)的书,对这一主题进行了深入探讨。这是一本很大的书,反映了这一主题的复杂性。为了使事情简单,我们将在这里使用std::optional,这将使我们的生活更容易。

optional类型是在 C++17 中引入的,它位于optional头文件中。它有时被描述为一种词汇类型,与std::anystd::variant一起。它们是模板,所以需要传入一个类型作为参数。在图 3.3 中看到响应之后,我们知道我们应该使用整数而不是无符号整数,因此我们将使用有符号整数作为模板类型:

std::optional<int> value;

这没有值。我们可以通过显式检查has_value()成员函数或使用explicit运算符bool来查看optional是否有值;换句话说,在ifwhile表达式或类似表达式中使用optional。这与之前使用的流具有类似的语义。值得注意的是 C++语言和库中的模式。它们可以通过向我们展示合理的途径来告知我们的代码。没有值可能是合法的,但我们可以用整数初始化值

std::optional<int> value = 101;

或者更改值:

value = -2;

这允许optional可能包含一个值。一些函数式编程语言有maybe类型的概念。如果我们使用可选类型,我们不需要为表示变量未设置而保留值。operator bool将返回true如果值已设置。如果我们想使用这个值,我们调用value函数:

int actual_value = value.value();

如果optional不包含值,我们将得到一个异常。如果它包含值,我们将得到一个数字。

我们现在可以编写一个函数来从流中读取数字。我们可以在函数外部使用getline来形成一个流,读取整个输入行,或者在read_number函数中整理非数字输入。如果我们这样做,我们就不需要在调用函数时记住做这件事。我们新的函数看起来像这样。

列表 3.4 获取可选输入

std::optional<int> read_number(std::istream& in)
{
    int result{};                                                   ❶
    if (in >> result) {                                             ❷
        return result;                                              ❸
    }
    in.clear();                                                     ❹
    in.ignore(std::numeric_limits<std::streamsize>::max(), '\n');   ❹
    return {};                                                      ❺
}  

❶ 将 int 初始化为零。

❷ 尝试读取一个数字

❸ 返回一个 int(作为可选)

❹ 整理

❺ 否则返回一个空的可选

注意我们在倒数第二行返回了一个空的可选。如果我们返回result,我们返回的是一个int,因此optional将有一个值,这样就违背了使用optional来表示用户想要停止猜测的目的。

通过将流发送到读取函数而不是将其固定到标准输入,我们给自己提供了选择。例如,我们可以在函数外部使用std::stringstream in_stream(in)来获取整个输入行,并将其发送进来。这意味着我们仍然知道用户输入了什么。我们决定如果流不包含数字就清除它,所以如果直接发送cin,我们就失去了输入。这对我们的游戏来说已经足够好了,但我们可以看到我们在这里有选择。

如果用户输入一个数字,我们的新函数将返回一个带有值的optional;否则,返回一个空的optional。我们可以在while循环中检查空的可选:

while (guess = read_number(std::cin))    

这样我们就可以在玩家没有输入数字时跳出循环并停止请求猜测。注意,一些编译器在我们将赋值的结果用作条件时可能会发出警告,尤其是在使用带有警告标志 -Wparentheses 的 clang 或 GCC 时。使用第二组括号表示我们确实打算检查所赋的值,从而停止警告:

while ((guess = read_number(std::cin)))    

如果玩家放弃,我们甚至可以说出这个数字。将这些放在一起,我们就有了一个稍微更好的游戏代码。

列表 3.5 允许放弃

void guess_number_or_give_up(int number)
{
    std::cout << "Guess the number.\n>";
    std::optional<int> guess;
    while (guess = read_number(std::cin))                ❶
    {
        if (guess.value() == number)
        {
            std::cout << "Well done.";
            return;                                      ❷
        }
        std::cout << guess.value() << " is wrong. Try again\n>";
    }
    std::cout << "The number was " << number << "\n";    ❸
}

int main()
{
    guess_number_or_give_up(some_const_number());
}

❶ 如果输入不是数字,则退出循环

❷ 如果猜测正确,则停止

❸ 告诉玩家数字

如果我们现在玩游戏,我们可以通过输入“放弃”或任何非数字输入来放弃(图 3.5)。

CH03_F05_Buontempo

图 3.5 玩家现在可以选择放弃并找出数字。

我们的游戏可以工作,但如果在玩家猜错时给出提示会更好。一旦我们有了这个,我们就可以准备深入研究使用随机数了。

3.1.3 使用 std::function 和 lambda 进行验证和反馈

如果猜测错误,它要么太大要么太小。我们可以在原地检查这一点,但使用验证函数给我们更多的灵活性。虽然我们在这里只会报告数字太大或太小,但当我们创建质数猜测游戏时,我们将在最后一节添加各种其他反馈。我们再次使用 lambda,并看看如何将其发送到我们的猜测游戏。

我们想改变我们的函数签名,使其看起来像这样:

void guess_number_or_give_up(int number, *lambda message*)

然而,没有 lambda 关键字。每个 lambda 都有一个独特的类型,因此我们需要另一种方式来表达我们可以调用某些内容,比如一个函数或 lambda,这被称为可调用,作为我们的第二个参数。我们可以使用模板:

template<typename T>
void guess_number_or_give_up(int number, T message)

然而,这并没有表达出消息是可调用的。我们可以使用概念来约束模板类型,提供一种替代方法,我们将在下一章中探讨。现在,我们将使用std::function。这将帮助我们更好地理解 lambda。

std::function是一个模板,提供对 lambda、命名函数或任何可调用对象的通用包装器。我们需要在模板中指定返回和参数类型。对于我们的游戏,我们有一个数字和一个猜测,它们是消息函数的输入,我们想要返回一个要显示的消息,它可以是string。对于命名函数,签名看起来像这样:

std::string message(int, int);

返回类型首先,然后是函数名和参数(在我们的例子中是两个int)。要创建std::function,我们需要包含functional头文件并声明一个具有相同签名的函数包装器:

std::function<std::string(int, int)> callable;

模板参数std::string(int, int)看起来像命名函数,但没有名称。我们像调用任何函数一样调用callable

auto message = callable(1, 2); 

因为我们没有指定callable应该做什么,所以它是一个函数,因此会抛出异常。这反映了optional的行为。我们可以用 lambda 初始化callable

std::function<std::string(int, int)> callable = [](int number, int guess) { 
    return std::format("Your guess was too {}\n",
        (guess < number ? "small" : "big")); 
};

函数不再为空,我们可以安全地调用它。注意我们再次使用了 std::format。第 2.2.5 节提供了关于如果您的编译器还不支持 std::format,如何使用 fmt 库的说明。别忘了您需要将 std::format 改为 fmt::format,并包含 fmt/core.h 头文件而不是标准的 format 头文件。现在我们可以为我们的游戏添加一个额外的参数,以便在玩家的猜测错误时提供线索。

列表 3.6 如果猜测错误则提供线索

void guess_number_with_clues(unsigned number, 
        std::function<std::string(int, int)> message)
{
    std::cout << "Guess the number.\n>";
    std::optional<int> guess;
    while (guess = read_number(std::cin))
    {
        if (guess.value() == number)
        {
             std::cout << "Well done.";
             return;
        }
        std::cout << message(number, guess.value());       ❶
        std::cout << '>';                                  ❷
    }
    std::cout << std::format("The number was {}\n", number);
}

❶ 如果猜测错误则显示消息

❷ 在消息后添加提示

我们还需要更改我们的 main 函数,通过一个 lambda 函数提供消息。我们可以直接发送它,或者使用 auto 在单独的一行上声明 lambda。

列表 3.7 改进的数字猜测游戏

int main()
{
    auto make_message = [](int number, int guess) { 
        return std::format("Your guess was too {}\n",
            (guess < number ? "small" : "big")); 
    };
    guess_number_with_clues(some_const_number(), make_message);
}

为什么我们声明 messageauto 而不是指定 std::function<std:: string(int, int)>?尽管这减少了输入量,但这里也有一个重要的观点需要注意。lambda 或 闭包 的类型是我们无法命名的,但 auto 会为我们推断出确切的类型。两个具有相同参数和相同返回类型的 lambda 实际上具有不同的类型。然而,两个 lambda 都可以赋值给同一个 std::function。这对我们的目的很有用,但也有缺点。lambda 可以内联,避免函数调用的开销。如果我们把 lambda 复制到 std::function 中,它就不再可以内联,所以调用它可能会更慢。将我们的 lambda 复制到 std::function 也可能涉及动态内存分配。Scott Meyers 在他的书 Effective Modern C++(O’Reilly Media,2014)中的“项目 5:优先使用 auto 而不是显式类型声明”中提供了全部细节,我们已经知道我们几乎总是应该使用 auto。如果我们将 lambda 声明为 auto,我们可以避免开销,尽管它将在方法调用中复制到 std::function。实际上,我们可以将列表 3.6 中的函数签名更改为使用 auto

void guess_number_with_clues(unsigned number, auto message);

我们现在几乎总是使用 auto 的另一个原因。虽然我们已经失去了消息生成器是一个可调用函数的想法,但一旦我们对概念有所了解,我们就可以解决这个问题。对于急于求成的人来说,我们可以包含概念头文件,并说

void guess_number_with_clues(unsigned number,
    std::invocable<int, int> auto message)

以获得如果传递无法用两个整数调用的东西时的有用编译器错误。我们将在下一章中看到更多概念。对于有耐心的人,有一个提议要引入 std::function_ref 作为 std::function 的替代方案,以克服性能问题 (mng.bz/wjgg)。C++ 正在继续发展,使我们的生活变得更简单。然而,我们如何制作消息,现在当我们尝试猜测数字时(图 3.6)我们会得到线索。

CH03_F06_Buontempo

图 3.6 游戏现在提供线索并允许玩家放弃。

现在我们有一个功能正常、但有些无聊的猜数字游戏。我们可以通过选择一个随机数来猜测来改进它。

3.2 猜测一个随机数

C++11 引入了一个随机数库。与 C 的 rand 函数相比,使用它需要更多一些努力,但它提供了许多不同的方式来生成具有各种有用特性的随机数。本节将展示如何从众多分布中获取一个随机数。我们需要选择一个种子,选择一个引擎,并决定使用哪种分布。我们将在第六章中更详细地研究分布。本节为这些内容奠定了基础。

3.2.1 设置随机数生成器

对于我们的猜数字游戏,我们想要一个随机整数。从区间中选取随机数会很方便,并且任何数字都应该有相同的可能性,因此我们将使用 均匀 整数分布,称为 uniform_int_distribution。这种分布适合模拟掷骰子,每次掷骰子需要一个介于 1 和 6 之间的数字,且不偏向任何结果。它在需要等可能整数的任何情况下都很有用,例如在我们的游戏中选择一个数字让我们猜测。

每个分布都是一个模板,用于生成特定类型的数字。uniform_int_distribution 限制为整数类型。还有一个类似的 uniform_real_distribution 用于浮点数或双精度浮点数。我们将使用整数并请求介于 1100(包括)之间的数字:

std::uniform_int_distribution<int> dist(1, 100);

C 的 rand 函数不支持区间,而我们通常希望使用随机数。例如,掷骰子需要一个介于 1 和 6 之间的数字,或者从一副牌中抽牌需要一个介于 1 和 52 之间的数字。C++在这里帮了我们,允许我们明确指定。

为了提供数字,分布需要一个引擎或生成器。引擎提供随机数。是的,为了生成随机数,分布需要提供随机数。分布使用概率函数来确保数字是均匀的或遵循请求的任何分布。对于范围内的均匀数,分布会将引擎提供的数字压缩或转换到请求的区间。如果我们使用 C 的 rand,我们就必须自己将数字压缩到区间。

我们不能从函数中生成真正的随机数,因为每次调用返回不同值的函数通常会被视为一个错误。那么随机数引擎是如何工作的呢?我们可以通过编写一个以种子开始并执行一些算术以生成新数字的函数来生成 伪随机数,同时记住这个新数字以便下一次调用。最终,如果数字与原始种子匹配,数字将开始重复。许多伪随机数生成器使用多项式函数结合一些模运算。我们自己可以编写一个生成器。

列表 3.8 一个糟糕的随机数生成器

int random_number(int seed = 0)
{
    static int x = 0;   ❶
    if (seed)
        x = seed;

    x = ++x % 2;        ❷
    return x;
}

❶ 静态存储,用于保存下一次调用的数字

❷ 从最后一个值生成新值

这是一个糟糕的随机数生成器,因为它只会返回 01,这些值交替出现。我们可能会得到 0, 1, 0, 1, ...1, 0, 1, 0, ... 取决于种子。由于它每两个数字重复一次,它有一个 周期 为两个。幸运的是,C++ 提供了几个做得更好的引擎,包括简短命名的 mt19937 引擎。mt 代表 Mersenne Twister。这些生成器在它们的模数部分使用 Mersenne 基数,这些基数是 2 的幂次方减一,并且它们的计算步骤比我们的增量 ++x 要好得多。这个引擎提供了一个 2¹⁹⁹³⁷ - 1 的周期。我们也可以使用 std::default_random_engine,这可能是 mt19937 引擎。

有多种方式来设置随机数引擎的种子。如果我们坚持使用特定的数字,那么每次运行都会得到相同的随机数序列。通过提供相同的种子来重新生成伪随机数序列的能力对于模拟和测试很有用,因为每次运行的结果都是相同的。我们可以使用当前时间来在每次运行中获得不同的数字,但我们还没有学习 C++ 中的时间。我们将在下一章中学习。random 头文件提供了一个 random_device,它本身就是一个随机数生成器,产生 非确定性 随机数。CppReference 指出,它每次被调用时可能会生成相同的数字序列(mng.bz/84RZ)。一些较旧的实现总是返回 0,所以如果你多次调用它,检查你是否得到了不同的数字是值得的。随机设备可能会使用你的硬盘状态或类似的物理组件来生成数字。CppReference 也警告我们,尽管它生成随机数,但它被设计为生成种子,因为重复调用它可能会反复生成相同的数字。

在包含 random 头文件后,我们使用随机设备来为我们的随机数生成器设置种子:

std::random_device rd;
std::mt19937 engine(rd());

这为我们提供了所需的引擎或生成器,以便使用分布。

3.2.2 使用随机数生成器

拥有种子和引擎后,我们现在可以从分布中抽取一个数字。我们通过调用分布的 operator() 来完成此操作。

列表 3.9 生成单个随机数

int some_random_number()
{
    std::random_device rd;                              ❶
    std::mt19937 engine(rd());                          ❷
    std::uniform_int_distribution<int> dist(1, 100);    ❸
    return dist(engine);                                ❹
}

❶ 获取随机数的设备

❷ 使用设备种子引擎

❸ 选择数字的分布

❹ 我们实际的随机数

生成一个数字需要相当多的代码,但在我们能够请求一个随机数之前,我们需要一个种子、一个引擎和一个分布。在这里我们不能用更少的代码来解决问题。在未来的章节中我们也会使用随机数,所以我们将得到更多的实践机会。现在,如果我们想要几个随机数,我们可以在构造函数中创建一个类,并设置种子和分布,每次我们需要一个新的数字时,从成员函数中调用dist(engine)。我们将在第五章创建一个类,这里只需要一个数字,所以这个函数符合我们的需求。

注意到 C++给了我们比 C 函数更多的控制。我们可以将引擎切换为另一个重复频率较低的引擎,尽管mt19937在这里是合适的,因为我们只需要一个数字。我们还指定了随机数应该来自的范围。前三行是设置,我们只需要做一次。如果我们想要另一个随机数,我们再次调用dist(engine)而不需要设置。如果我们多次调用这个函数并记录结果,我们会看到 0 到 100 之间的数字以大约相等或均匀的比例生成。

我们现在可以通过在main函数中调用新函数而不是some_const_number来使我们的游戏稍微更具挑战性,同时保持其他一切不变。

列表 3.10 一个随机数猜测游戏

int main()
{
    auto message = [](int number, int guess) {
        return std::format("Your guess was too {}\n",
            (guess < number ? "small" : "big")); 
    };
    guess_number_with_clues(some_random_number(), message);    ❶
}

❶ 可能这次不是 42

我们可以更改消息以提供不同的线索(例如,数字是奇数还是偶数,或者我们可以跟踪猜测次数,以提醒用户他们是否已经尝试了一个数字)。我们在这里不会这样做,但我们可以看到传递消息如何使代码相对灵活。我们将要做的是生成一个素数来猜测。因此,我们将学习如何生成具有所需属性的随机数,在这种情况下,是一个素数,并且如果数字错误,我们将提供线索。

3.3 猜测一个素数

为了更多地练习随机数,我们将生成一个素数来猜测。如果玩家猜错了,我们将说明哪些数字是正确的。这将给我们更多的机会练习我们的消息 lambda。

3.3.1 检查数字是否为素数

我们需要调整生成数字的函数,以便猜测我们是否想要一个素数。我们不能再像在 3.9 列表中那样立即返回dist(engine),而是首先检查这个数字是否是素数。如果是,我们就返回它;否则,我们尝试另一个随机数,直到我们得到一个合适的数字。我们如何检查一个数字是否是素数?

素数有两个因子。只有一个因子,所以我们可以特别处理这种情况并返回 false。2 是 1×2(或 2×1),所以它恰好有两个因子。这是第一个素数。3 是下一个素数,所以我们可以立即为这两个数字返回 true。之后的任何 2 或 3 的倍数都不是素数。例如,6 可以被 2 和 3 整除,也可以被 1 和 6 整除。因此,我们可以使用operator%来检查这些。

数字 4 在检查 2 的倍数时被捕获。因此,我们只需要检查这个数是否是 5 以上的任何数的倍数,因为我们已经涵盖了 2、3 和 4。我们可以跟踪我们找到的素数,而不仅仅是考虑 2 或 3 的倍数,并构建所谓的埃拉托斯特尼筛法。这将更有效率,但这意味着我们需要跟踪素数。我们可以检查到我们的数字的平方根来节省一点时间。检查超过这个范围是没有意义的。例如,数字 35 是 5 乘以 7。从 5 开始检查,我们立即找到一个因子,所以我们可以说 35 不是素数。我们在 35 的平方根之前就找到了这个因子,略小于 6。找到第一个因子后,我们不需要检查 7,因为我们已经找到了 5 并返回了。如果一个因子大于平方根,那么总会有一个小于平方根的因子,我们首先会找到它。我们将对因子的检查组合到一个函数中,如下所示。

列表 3.11 检查一个数是否为素数的函数

bool is_prime(int n)
{
    if (n == 2 || n == 3)                      ❶
        return true;

    if (n <= 1 || n % 2 == 0 || n % 3 == 0)    ❷
        return false;

    for (int i = 5; i * i <= n; ++i)           ❸
    {
        if (n % i == 0)
            return false;                      ❹
    }

    return true;                               ❺
}

❶ 2 和 3 是素数。

❷ 1 和任何 2 或 3 的倍数都不是素数。

❸ 检查 5 及以上是否是因子

❹ 我们找到了一个因子,所以这个数不是素数。

❺ 如果我们到达这里,我们有一个素数。

我们可以对函数进行其他优化以使其更快,但对于我们的游戏来说这已经足够快了。我们有一种方法来检查一个数是否为素数,但在使用它之前,我们将为这个函数添加一些测试。

3.3.2 使用static_assert检查属性

我们将添加一个函数来测试我们的is_prime函数是否工作。我们可以为测试硬编码一些数字。这意味着我们不是使用任何运行时输入,因此我们可以在编译时运行我们的检查。我们通过在函数签名开头添加关键字constexpr(常量表达式)来表示这一点:

constexpr bool is_prime(int n)

说到一个函数或变量是constexpr,意味着它可以在编译时进行评估,从理论上讲。但这可能并不总是如此。一个constexpr变量是const的,这意味着我们无法改变它的值。对于一个constexpr函数,其参数也必须是常量表达式。例如,如果它们直到运行时才被设置,比如通过用户输入,那么评估就不能在编译时发生。因此,constexpr表示一个值,或返回值,在可能的情况下既是常量又在编译时计算。因此,使用constexpr可以让我们在编译时评估变量或函数。让我们看看如何做。

我们仍然可以在运行时调用我们的函数,但现在我们也可以在编译时检查代码。与我们在上一章中使用 C 的assert函数不同,我们可以在测试函数中使用static_assert

void check_properties()
{
    static_assert(is_prime(2)); 
}

static_assert也可以用在其他地方,例如在命名空间中(见mng.bz/E97o),但为我们的测试创建一个函数可以使它们更容易找到。static_assert需要一个常量表达式,例如我们的constexpr函数,如果表达式为假,则生成编译器错误。我们可以在main函数的开始处添加对check_properties函数的调用,并且我们的单个断言在编译时通过,运行时不需要做任何事情。如果我们用非素数,如 4,而不是 2,我们会得到一个编译错误:

main.cpp(108,24): error C2607: static assertion failed

早期发现和捕获错误始终是一件好事。此外,编译时的评估可以加快运行时。static_assertconstexpr都是在 C++11 中引入的。后者随着时间的推移变得更加灵活,允许局部变量和循环。在那之前,我们需要使用递归。C++20 随后引入了constevalconstinit指定符。consteval应用于函数,以确保它们在编译时被评估,而constexpr可能或可能不在编译时评估。constinit应用于变量,确保在编译时初始化。consteval函数也被称为即时函数,如果它不能在编译时评估,我们会得到一个编译错误。

我们还可以看到声明为constexpr的变量:

constexpr int x = 41 + 1;
constexpr bool x_prime = is_prime(42);

这使得变量在编译时既是常量也是计算的,所以我们不能改变它们。如果我们说x = 43来尝试这样做,结果会得到一个编译错误。编译时评估是一个强大的工具。现在的重要点是constexpr函数可以在编译时或运行时运行。

现在我们知道了如何测试一个数是否为素数,我们可以使用这个检查来生成一个素数来猜测我们的游戏。

3.3.3 生成随机素数

我们在列表 3.9 中看到了如何生成随机数。我们使用random_device来初始化一个引擎和一个分布来从范围内选择一个随机数。在 1 到 100 之间,素数并不多,所以我们将范围增加到 99,999,给我们更多的可能素数,最多有五位数。我们不需要返回生成的第一个数,我们需要检查它是否满足我们的要求。我们使用我们的is_prime函数,并在一个空的while循环中不断尝试,直到我们得到一个合适的数。让我们使用{}来初始化一切,以提醒自己关于统一初始化。

列表 3.12 生成素数

int some_prime_number()
{
    std::random_device rd;
    std::mt19937 engine{ rd() };
    std::uniform_int_distribution<int> dist{1, 99999};    ❶
    int n{};                                              ❷
    while (!is_prime(n))                                  ❸
    {
        n = dist(engine);                                 ❸
    }
    return n;
}

❶ 使用更大的区间

❷ 使用{}默认初始化 n。

❸ 继续直到我们得到一个素数

过滤掉不符合标准的随机数被称为拒绝抽样。这是一种生成满足特定属性的随机数的方法。许多分布提供了用于模拟和游戏的随机数,但当一个分布难以用数学公式表示时,拒绝抽样效果很好。

我们现在可以修改我们的猜测游戏,使用随机生成的素数,并适当地调整列表 3.10 中的猜测游戏调用:

guess_number_with_clues(some_prime_number(), message);

这很好,但我们可以生成更好的提示。我们可以通过一点思考来报告是否有任何数字是正确的。只有 10 个数字,所以我们可以用不同的数字做两次猜测。如果一个新的提示告诉我们哪些数字在数字中,我们就知道要使用哪些数字。我们可能会把它们放在错误的位置,而且可能会有重复,但应该更容易猜出数字。

3.3.4 决定哪些数字是正确的

我们将使用字符^来表示位置错误的数字,*表示位置正确的数字,点表示不存在的数字。如果数字是 12347,而我们猜测的是 23471,我们猜对了所有数字,但它们的位置是错误的。我们会通过显示"^^^^^"来表示这一点。如果数字是 78737,而我们猜测的是 87739,我们会显示"^^**"。在猜测下方显示这将给出

87739
^^**.

第二个 7 和第三个 3 在正确的位置,所以它们得到一个*。开头的 7 和 8 位置错误,所以每个都得到一个^。最后一个数字,9,是错误的,所以它得到一个点。

要创建提示,我们需要一个函数,该函数接受数字和猜测值,并返回一个字符串。如果我们将数字转换为字符串,我们可以逐个检查数字。有各种方法可以做到这一点,我们将使用format。我们想要添加前导零,所以数字和猜测值都是五位数长。我们在上一章中使用了格式说明符"{: ⁶}"来用空格填充数字,确保它有六个字符长。^表示居中对齐。这次,我们想要右对齐,所以使用>,,我们想要 0 而不是空格,给出"{:0>5}"。如果我们设置一个由五个点组成的字符串,std::string matches(5, '.'),并在正确的数字位置放置星号,我们就走了一半的路。

列表 3.13 函数开始指示哪些数字是正确的

std::string check_which_digits_correct(int number, int guess)
{
    auto ns = std::format("{:0>5}", (number));            ❶
    auto gs = std::format("{:0>5}", (guess));             ❶
    std::string matches(5, '.');                          ❷
    for (size_t i = 0, stop = gs.length(); i < stop; ++i)
    {
        char guess_char = gs[i];
        if (i < ns.length() && guess_char == ns[i])
        {
            matches[i] = '*';                             ❸
        }
    }
    return matches;
}

❶ 将数字转换为字符串

❷ 以五个点开始

❸ 用星号表示正确的数字

现在我们需要找出是否有任何数字位置错误。如果数字是 78737,而我们猜测的是 87739,我们有两个 7。一个是正确的,所以它得到了一个*,另一个是错误的。如果我们把数字中间的 7 改为*,我们不会在检查位置错误的数字时使用它。我们可以在第一个循环中做到这一点;然后我们通过第二个循环找到位置错误的数字,用^来表示这一点。一旦我们计算出一个数字是位置错误的,我们将它也改为^,这样我们就不报告只有一个数字在数字中时有两个位置错误的数字。例如,如果数字是 12347,猜测是 11779,两个 7 都是错误的,但我们要表示我们有一个位置错误的 7,而不是两个:

11779
*.^..

如果两个 7 都得到^,表示它们位置错误,这表明数字中包含两个 7。我们的反馈清楚地表明数字中只有一个 7。

std::string有一个find方法,如果没有找到匹配的位置,则返回npos。一些编译器现在也支持contains函数,它更简洁,但我们需要找到数字的位置以避免再次使用它,所以我们需要使用findfind函数接受要查找的字符和一个起始位置,并返回一个索引。因为我们想从开始搜索,所以我们需要使用起始位置0。如果我们得到npos,这意味着字符不在那里。我们可以使用具有初始化器的if语句在一个if语句中完成这个操作:

if (size_t idx = ns.find(guess_char, 0); idx != std::string::npos)

这是在 C++17 中引入的。它看起来像是一个普通的if语句,但有一个初始化语句,后面跟着一个分号,然后是一个条件:if (init; condition)。如果没有这个,我们就必须找到索引,然后检查单独语句中的值。两种方式都行,但具有初始化器的if语句可以使代码更紧凑,尤其是通过保持变量的作用域更小,因为变量仅在if块内部有效。将查找放置错误的数字的检查添加到前面的列表中,我们得到以下内容。

列表 3.14 显示放置错误的数字

std::string check_which_digits_correct(int number, int guess)
{
    auto ns = std::format("{:0>5}", (number));
    auto gs = std::format("{:0>5}", (guess));
    std::string matches(5, '.');
    for (size_t i = 0, stop = gs.length(); i < stop; ++i)
    {
        char guess_char = gs[i];
        if (i < ns.length() && guess_char == ns[i])
        {
            matches[i] = '*';
            ns[i] = '*';                                     ❶
        }
    }
    for (size_t i = 0, stop = gs.length(); i < stop; ++i)    ❷
    {
        char guess_char = gs[i];
        if (i < ns.length() && matches[i] != '*')
        {
            if (size_t idx = ns.find(guess_char, 0);
                idx != std::string::npos)                    ❸
            {
                matches[i] = '^';
                ns[idx] = '^';                               ❹
            }                                                ❺
        }
    }
    return matches;
} 

❶ 不要重复计算这个数字。

❷ 现在检查不匹配的猜测

❸ 查找猜测字符

❹ 也不要重复使用这个数字。

idx现在已经超出作用域。

我们可以也应该为属性函数添加测试。例如,在包含cassert头文件后,我们可以添加一个检查:

assert(check_which_digits_correct(12347, 23471) == "^^^^^");

本书提供的代码在属性函数中包含几个测试,涵盖了重复和缺失的数字,这里为了简洁省略。

我们现在可以使用我们的函数在猜测游戏中创建线索,并从main中调用属性测试。当我们进行这个更改时,我们将返回格式化为五位数的数字。这样,较短的数字会有前导零,所以^看起来像是在指向任何放置错误的数字。例如,如果数字是 17231,而我们猜测 1723,我们会看到

01723
.^^^^

这不是必需的,但它会提醒玩家他们可以使用零。以下列表显示了我们将事物组合在一起时的结果。

列表 3.15 一个更好的数字猜测游戏

void guess_number_with_clues(int number, auto message)
{
    std::cout << "Guess the number.\n";
    std::optional<int> guess;
    while (guess = read_number(std::cin))
    {
        if (guess.value() == number)
        {
            std::cout << "Well done.";
            return;
        }
        std::cout << guess.value() << " is wrong. Try again\n";
        std::cout << message(number, guess.value());          ❶
    }
    std::cout <<
        std::format("The number was {:0>5}\n", (number));     ❷
}

int main()
{
    check_properties();                                       ❸
    auto message = [](int number, int guess) {
        return std::format("{}\n",
            check_which_digits_correct(number, guess));
    };                                                        ❹
    guess_number_with_clues(some_prime_number(), message);    ❺
}

❶ 显示线索

❷ 以五位数字显示正确的数字

❸ 调用测试

❹ 显示哪些数字是正确的信息

❺ 玩游戏

如果我们玩游戏,我们可以从两个具有不同数字的质数开始,以缩小可能的数字范围。12347 和 56809 涵盖了所有数字,所以它们是好的起始猜测(图 3.7)。

CH03_F07_Buontempo

图 3.7 从两个具有不同数字的质数开始,以缩小可能的数字范围。

3.3.5 使用 std::function 提供不同的线索

现在,90113,如图 3.7 所示,不是一个质数。我们可以轻松地将这个检查添加到我们的信息中。

列表 3.16 一条较长的信息

auto get_message = [](int number, int guess) {
    return std::format("{}\n{}\n",
        is_prime(guess) ? "Prime" : "Not prime",      ❶
        check_which_digits_correct(number, guess));   ❷
};

guess_number_with_clues(some_prime_number(), get_message);

❶ 猜测的数是质数吗?

❷ 哪些数字是正确的?

我们可以进一步扩展,但是将许多单独的检查添加到单个 lambda 中是一个坏主意。当我们需要一个小函数时,lambda 是好的,但我们不应该让它们变得难以控制。我们需要不同的方法。由于数字不会超过五位数,我们可以添加一个长度检查。因此,我们正在尝试检查三件事,并在每种情况下返回一条消息。我们可以使用两个单独的 lambda 来检查长度和数字是否为质数,一个用于猜测并返回一个字符串。

列表 3.17 检查长度以及数字是否为质数

auto check_prime = [](int guess) {
    return std::string((is_prime(guess)) ? "" : "Not prime\n");
};

auto check_length = [](int guess) {
    return std::string((guess < 100000) ? "" : "Too long\n");
};

列表 3.14 提供了有关哪些数字是正确的线索,但它需要数字以及猜测。如果我们捕获要猜测的数字,我们可以使用 lambda 的闭包特性来创建一个接受单个整数的匿名函数。我们在 2.3 节首次遇到 lambda 时看到了 [=][&] 用于按值和按引用捕获。我们可以说 [number] 来表示按值捕获变量 number,因为我们捕获特定变量时没有使用 = 符号。我们可以使用 [&number] 来表示按引用捕获 number。无论如何,我们都有 封装 了我们的函数,接受两个数字,包括要猜测的数字来创建一个新的函数。

列表 3.18 捕获数字

int number = some_prime_number();
auto check_digits = number {     ❶
    return std::format("{}\n", 
        check_which_digits_correct(number, guess));
};

❶ 通过复制捕获数字

现在我们有三个 lambda,它们接受一个整数并返回一个字符串。将它们放入容器中,如 vector,将很好,这样游戏可以遍历线索,并可能添加更多检查。但是,vector 将包含什么类型呢?我们知道每个 lambda 都有不同的类型,但如果我们包含 functionalvector 头文件,我们可以将它们强制转换为 std::function 并放入容器中。猜谜游戏可以检查线索并只显示第一个。如果我们首先检查数字是否为质数,我们可以强制猜测是质数,并在另一次猜测之前避免提供更多线索。因此,我们需要对我们的猜测函数进行轻微的修改,以调用消息。

列表 3.19 使用所有线索

void guess_number_with_more_clues(int number, auto messages)
{
    std::cout << "Guess the number.\n>";
    std::optional<int> guess;
    while (guess = read_number(std::cin))
    {
        if (guess.value() == number)
        {
            std::cout << "Well done.";
            return;
        }
        std::cout << std::format("{:0>5} is wrong. Try again\n",
                                 guess.value());
        for (auto message : messages)            ❶
        {
            auto clue = message(guess.value());
            if (clue.length())                   ❷
            {                                    ❷
                std::cout << clue;               ❷
                break;                           ❷
            }
        }
    }
    std::cout << std::format("The number was {:0>5}\n", (number));
}

❶ 获取消息

❷ 仅显示第一个线索

现在我们可以调用我们的游戏,在 main 函数中调用我们的测试代码之后。

列表 3.20 整合所有内容

int main()
{
    check_properties();
    auto check_prime = [](int guess) {
        return std::string((is_prime(guess)) ? "" : "Not prime\n");
    };

    auto check_length = [](int guess) {
        return std::string((guess < 100000) ? "" : "Too long\n");
    };

    const int number = some_prime_number();
    auto check_digits = number {
        return std::format("{}\n",
             check_which_digits_correct(number, guess));    
    };
    std::vector<
        std::function<std::string(int)>
    > messages                                         ❶
    {
        check_length,
        check_prime,
        check_digits 
    };
    guess_number_with_more_clues(number, messages);    ❷
}

❶ 对齐检查和线索

❷ 玩游戏

有一些质数开始玩会更容易。尝试 12347 和 56809,因为它们使用了所有数字。我们可以自由地忽略任何线索,所以我们可以尝试找出我们首先需要哪些五个数字。

将 lambda 强制转换为 std::function 并非理想选择,正如我们在 3.1.3 节中看到的,因为它不能再内联。当我们学习到最后一章的模板参数包时,我们将看到另一种方法。现在,我们已经了解了输入和输出,以及字符串、整数和向量。我们还可以生成随机数。我们将学习如何处理时间,并继续构建我们的 C++ 知识。

摘要

  • 字符输入来自 std::cin,可以被流式传输到特定类型,但我们需要检查错误并清理未使用的输入。

  • 使用 std::getline 来获取整行文本,包括空白字符。

  • std::optional 可以用于可能未设置的值。

  • std::cinstd::optional 都有一个 explicit operator bool,这使得我们能够轻松地检查错误或缺失的值。

  • 在语言和库中寻找常见模式,以指导自己的代码。

  • C++中的随机数需要同时有一个引擎和一个分布。

  • 可以用 std::random_device 来初始化随机数生成器。

  • 接受采样是一种快速选择满足特定属性随机数的方法,如果不可用合适的分布。

  • 一些表达式可以在编译时计算,因此用 constexpr 标记它们是个好主意。

  • 使用 static_assert 在编译时检查表达式。

  • 可以将 lambda 存储在 std::function 中,但这可能会使代码更大、更慢。

4 时间点、持续时间和文字

本章涵盖

  • 使用std::chrono时间点和持续时间

  • 使用比率

  • 使用文字后缀

  • 使用重载的operator/创建日期

  • 时间点和持续时间的输入和输出

  • 使用不同的时区

在本章中,我们将编写一个简短的程序来创建一个事件倒计时。为此,我们将使用chrono头文件中的时间点和持续时间。这个特性是在 C++11 中引入的,尽管本质保持不变,但随着时间的推移,已经添加了几个有用的功能。霍华德·欣南特是这个特性的主要作者和设计师。在 2019 年的 Meeting C++演讲中,他提供了很多关于其设计的背景信息(www.youtube.com/watch?v=adSAN282YIw)。随着我们使用chrono,我们将学习到许多适用于许多其他情况的重要惯用和途径。

在第一部分,我们将构建一个简单的倒计时,然后更深入地探讨我们所使用的类型。我们将发现如何使用比率模板来理解持续时间。然后我们将学习如何读取日期,以便我们可以为任何事件倒计时并打印出各种单位的倒计时。我们将了解用于指定天数、月份等的文字后缀及其用途。我们还将遇到需求的概念,并触及相关概念。在涵盖了这些新的 C++特性之后,我们将使用时区时间完成一个倒计时。

4.1 一年最后一天还有多久?

我们将首先找出距离特定年份结束还有多久,以获得基本的倒计时。我们只需要少量代码,所以本章的项目在代码行数上很小。然而,随着我们编写代码,我们将扩展我们的知识。

要找出日期距离某个事件有多远,例如除夕夜,我们需要知道当前时间。chrono头文件为我们提供了这样做的方法,提供了日期和时间:

std::chrono::time_point now = std::chrono::system_clock::now();

其中隐藏着几个细节,我们将在本章中进一步展开。time_point是一个类模板,使用时钟和持续时间。我们有选择时钟的选项,每个选项都能为我们计算出时间和日期。我们将在下一节中查看持续时间细节,但就高层次而言,它指定了时间单位,例如秒或天。我们已应用类模板参数推导(CTAD)来避免指定这些模板参数,因此我们需要至少使用 C++17。如果没有它,我们就需要写出完整的类型,std::chrono::time_point<std::chrono::system_clock>,或者直接使用auto

什么是时钟?我们正在使用的system_clock是基于操作系统的时钟。现在,管理员可以更改系统的时钟,如果系统时间被更改,调用now可能会看起来像是回到了过去。这不是问题,但这是值得知道的。每个时钟都有一个名为is_steady的成员变量,它告诉我们这种情况是否可能发生。我们可以使用steady_clock代替,尽管它更适合计时间隔。还有其他时钟,例如,high_resolution_clock提供了最细粒度的滴答声。警告:尽管名字如此,这个时钟可能是一个system_clocksteady_clock,而不是具有超级小滴答大小的时钟。还有一个file_clock用于文件的戳记。不同的文件系统支持不同的分辨率,因此这提供了一种一致的方式来访问此类信息,无论文件系统使用的分辨率如何。在本章中,我们将坚持使用system_clock。它提供了一个基于协调世界时(UTC)的全局实时墙钟,并且很容易映射到 C 的time_t,允许我们在需要时与 C 库交互。

拥有另一个时间点,我们可以找到它们之间的差异,从而得到一个时间间隔或持续时间。例如,如果我们创建一个在年底最后一天的时间点,我们可以找到距离年底最后一天还有多长时间。除夕夜总是在 12 月 31 日,因此我们可以使用 C++20 的std::chrono::year_month_day指定一个特定的年份、月份和日期。

列表 4.1 创建特定日期

auto new_years_eve = std::chrono::year_month_day(
    std::chrono::year(2022),
    std::chrono::month(12),
    std::chrono::day(31)
);

我们很快就会看到如何从time_point now获取年份,这样我们就可以编写一个更通用和有用的倒计时。首先,我们将找到列表 4.1 中的固定日期和当前time_point之间的差异。在我们找到两个日期之间的差异之前,请注意year_month_day使用的是整值习语。整值习语起源于 Ward Cunningham 的 CHECKS 模式语言(c2.com/ppr/checks.html),其中提到整值来表示有意义的数量,并由 Martin Fowler 的量模式(martinfowler.com/eaaDev/Quantity.html)进一步探索,该模式用数量和单位来表示有尺寸的值。我们不是为每个参数使用整数并试图记住构造函数参数的顺序,尽管名字中有一个很大的提示,我们必须显式地将std::chrono::year传递给年份参数等等。整值习语创建轻量级类型以确保参数正确传递。如果我们尝试传递一个需要日期的月份,将会产生编译器错误,这可以早期和精确地定位问题。

要比较 new_years_eve 与当前日期时间,我们需要将日期转换为另一个 time_point。我们只有没有时间的日期,因此我们指定天数作为转换的 time_point 的持续时间:

auto event = std::chrono::time_point<std::chrono::system_clock, 
                          std::chrono::days>(new_years_eve);

我们可以使用 chrono 中的两个类型别名之一来使我们的 event 定义更简洁。首先,每当我们需要基于系统时钟的时间点时,我们可以使用 sys_time 并指定持续时间。因此,我们可以说

auto event = std::chrono::sys_time<std::chrono::days>(new_years_eve);

第二,如果我们需要具体的日期,我们可以使用 sys_days 作为缩写:

auto event = std::chrono::sys_days(new_years_eve);

无论哪种方式,我们现在都有两个 time_point,因此我们可以从中减去以找到差异,并使用 chronooperator<< 将值流出,该操作符是在 C++20 中引入的。

列表 4.2 两个时间点之间的持续时间

#include <chrono>
#include <iostream>

void duration_to_end_of_year()
{
    std::chrono::time_point now = std::chrono::system_clock::now();
    constexpr auto year = 2022;                           ❶
    auto new_years_eve = std::chrono::year_month_day(
        std::chrono::year(year),
        std::chrono::month(12),
        std::chrono::day(31)
    );
    auto event = std::chrono::sys_days(new_years_eve);    ❷
    std::chrono::duration dur = event - now;              ❸
    std::cout << dur << " until event\n";                 ❹
}

int main()
{
    duration_to_end_of_year();
}

❶ 现在硬编码一个年份

❷ 转换为时间点

❸ 查找差异

❹ 查找持续时间的 operator<<

如果你有一个不支持持续时间 operator<< 的旧编译器,你可以在函数的最后一行使用 count 方法将值发送到 cout

std::cout << dur.count() << " until event\n";

或者,你可以在一个合理的位置克隆 Howard Hinnant 的日期库 (github.com/HowardHinnant/date):

git clone https://github.com/HowardHinnant/date.git

从库中包含 "date/date.h" 并添加

using date::operator<<;

当你需要使用流插入操作符时。当你构建代码时,别忘了使用 -I 开关指向 date/include 目录。

实际输出将取决于我们何时运行列表 4.2,但我们会得到一个数字和一些单位。使用 Visual Studio 2022 给出

69579189669221[1/10000000]s until event 

数字表示秒的分数,输出中用 [1/10000000]s 表示。使用 Compiler Explorer 和 GCC 12.2 (godbolt.org/z/8Gj345e3d) 或 Clang 15.0 (godbolt.org/z/9zGvqfhPs),我们得到

-1508892372000803ns until event

自己决定粒度并使用实际的年份,而不是硬编码 2022 年,会更好。我们将在下一节更深入地探讨持续时间以实现这一点。

让我们花点时间来提醒自己我们已经使用了什么。如果我们尝试在第一章中提到的 C++ Insights 上运行代码 (cppinsights.io/s/7a85b40e),我们可以看到完整的类型展开。你的编译器可能使用稍微不同的类型和值,但洞察力给出了代码中发生的事情的大致想法。对于这两行

auto event = std::chrono::sys_days(new_years_eve);
std::chrono::duration dur = event - now;

在列表 4.2 的末尾是以下内容。

列表 4.3 C++ Insights 显示完整类型

std::chrono::time_point<std::chrono::system_clock,                    ❶
    std::chrono::duration<long, std::ratio<86400, 1> > >              ❷
        event = std::chrono::time_point<std::chrono::system_clock,
            std::chrono::duration<long, std::ratio<86400, 1> > >
                (new_years_eve.operator time_point());                ❸
std::chrono::duration<long, std::ratio<1, 1000000000> > dur =         ❹
        std::chrono::operator-(event, now);                           ❺

❶ 使用系统时钟的时间点

❷ 606024 秒的持续时间

❸ 转换为 sys_days 即天的时间点

❹ Insight 使用长秒和纳秒。

❺ 对时间点进行操作符重载

从 C++ Insights 的输出中我们可以看到,持续时间正在使用比率,因此我们需要从比率开始;然后我们可以更详细地展开持续时间。

4.2 详细理解持续时间

当我们从两个时间点中减去时,我们得到了一个时间间隔或duration,并显示了其值。duration计算一个单位中的,无论是整数还是浮点数。因此,duration被定义为模板,它接受两个类型,每个部分一个:

template<class Rep,class Period = std::ratio<1> > class duration;

表示,Rep,将是一个数值类型,例如integerfloatPeriod是一个ratio,告诉我们如何将滴转换为秒。花点时间详细了解ratio类型是值得的;然后我们将更好地准备处理各种持续时间。

4.2.1 比率

一分钟有 60 秒。当我们需要分钟时,我们可以将秒数除以 60,但代码中充斥着魔法数字会带来麻烦。如果我们决定想要小时而不是分钟,我们可能找不到它们被使用的所有地方。我们可以编写一个实用函数来完成转换,或者我们可以依赖更通用的东西。60:1 的比率将非常有用。幸运的是,C++在ratio头文件中正好提供了我们所需的功能。ratio可以用来表示任何有理数,因此它需要两个数字:分子和分母。C++使用这两个数字定义了一个template

template<std::intmax_t Num, std::intmax_t Denom = 1> class ratio;

intmax_t是最大的有符号整数类型,它在不同的实现中可能有所不同。使用template允许在编译时进行比率的算术运算。注意,分子和分母都是非类型模板参数;在这种情况下,是数字而不是类型。我们可以使用std::ratio<3, 6>创建一个 3:6 的比率。如果我们查看分子和分母

std::cout << std::ratio<3, 6>::num << '/' << std::ratio<3, 6>::den << '\n';

我们会发现比率已经被简化为最简形式,1/2。事实上,辅助方法ratio_equal告诉我们两个比率是等价的:

bool same = std::ratio_equal<std::ratio<3, 6>, std::ratio<1, 2>>::value;

ratio头文件还提供了算术函数,例如ratio_add,允许我们使用分数等在编译时进行算术运算,例如

using fract = std::ratio_add< std::ratio<3, 6>, std::ratio<1, 2>>;
std::cout << fract::num << '/' << fract::den << '\n';

这给出了 1/1。

本节开头我们看到的默认持续时间使用 1:1 的比率,Period = std::ratio<1>,相当于每滴 1 秒。chrono头文件提供了各种周期,从纳秒到年,每个周期都基于ratio头文件中的定义。纳秒是 1/1,000,000,000 秒。计算这样一个数字中有多少个零是容易出错的。幸运的是,ratio头文件为我们定义了std::nano

std::ratio<1, 1000000000>

我们可以使用这个而不是创建我们自己的常量。ratio头文件还定义了毫、千以及其他国际单位制(SI)比率。

在 4.3 列表中,我们看到了 C++ Insights 使用long表示持续时间和std::ratio<1, 1000000000>的周期。对于system_clock使用的表示和周期可能因编译器而异,但这并不重要。无论使用什么,我们都可以要求在时间点之间或任何其他持续时间中提供秒数。现在我们可以更改我们的倒计时,以提供我们选择的任何单位。

4.2.2 持续时间

我们的倒计时是以秒为单位的分数,但我们可能希望以天或分钟为单位报告。那么我们如何转换持续时间?要获取分钟的持续时间,我们使用 std::chrono::minutes,它使用 60:1 的比例。有各种周期可供选择。小时使用 3,600:1,毫秒使用 1:1,000,微秒使用 1:1,000,000。C++20 还引入了天、周、月和年。天和周相对简单,但一个月或一年有多少天?这取决于。C++20 使用 365.2425 天作为一年,30.436875 天作为一个月,正好是 1/12 年。公历模型太阳系大约,而 chrono 模型公历精确。我们甚至可以编写自己的日历,这些日历可以与 chrono 交互操作。霍华德·欣南特在他的 GitHub 页面上提供了示例,包括儒略历和伊斯兰历(mng.bz/A89Q)。

我们可以隐式或显式地在持续时间之间切换。从粗粒度到细粒度的时间间隔赋值不会四舍五入,所以隐式转换是有效的:

std::chrono::milliseconds ms = std::chrono::hours(2);

回到更细粒度的毫秒可能涉及四舍五入,因此我们需要使用命名转换:

auto two_hours_from_ms = duration_cast<hours>(ms);

这个例子将返回原始的 2 小时。在大多数情况下,从毫秒到小时可能会丢失一些精度。两小时是 7,200,000 毫秒。如果我们只有 7,199,999 毫秒,我们就会低于两小时一个毫秒,所以我们会得到 1 小时而不是 2。同样,23 小时几乎是一个整天,但将其转换为天会四舍五入到零,所以如果我们来回转换,我们会得到 0 小时。

让我们试试这个。我们将添加一个 using 指令,这样我们就不需要完全限定 std::chrono 中的类型和函数了。然而,不要无意识地这样做,并且永远不要在头文件中这样做。ISOCpp 的核心指南 SF.7 告诉我们不要在头文件的全局作用域中写入 using namespace(见 mng.bz/xjR6)。这样做可能会将两个名称引入作用域,并导致歧义。在我们的情况下,我们有一个小的函数,所以我们不会引入命名冲突。

那么 23 小时是多少天?我们需要一个 duration_cast 来找出。

列表 4.4 使用 duration 进行更粗粒度的表示

void durations()
{
    using namespace std::chrono;
    auto nearly_a_day = hours{23};                      ❶
    days a_day = duration_cast<days>(nearly_a_day);     ❷
    hours round_trip = a_day;                           ❸
    std::cout << nearly_a_day << " cast to " << a_day 
        << " and cast back to " << round_trip << '\n';
}

❶ 几乎是一个整天

❷ 转换为天

❸ 回退 0 小时

main 中调用 durations 来查看

23h cast to 0d and cast back 0h

operator<< 报告 0d,表示 0 天,也就是 0h,或 0 小时。别忘了你可以使用持续时间的 count 方法,或者如果你需要,可以使用 using date::operator<<。由于我们不会丢失精度,所以可以直接将天数赋值给小时。所以给定一个整天 a_day{1},我们可以赋值

hours n_hours = a_day;

并检查它们是否相同:

assert(a_day == n_hours); 

我们可以使用显式的duration_cast而不是直接赋值,但将duration_cast的使用仅限于那些会丢失精度的转换是一种很好的方法。这使得如果我们怀疑丢失精度的转换是错误的来源,我们很容易在我们的代码中找到这样的丢失精度转换。当我们可能丢失精度时需要使用duration_cast是好事,因为转换使潜在的丢失变得明确,如图 4.1 所示。

CH04_F01_Buontempo

图 4.1 将 23 小时转换为天数会丢失精度,因此需要使用duration_cast

我们甚至可以编写自己的duration;例如,一个世纪。我们需要提供一个周期类型。一个世纪比一年多 100 倍秒,所以我们需要 1:100 的比例,或者std::hecto。然后我们可以使用ratio头文件中的ratio_multiply来获取所需的类型。multiply函数为我们计算适当的分子和分母,因此我们可以使用类型别名,即using关键字来定义世纪:

using centuries = std::chrono::duration<long long, 
     std::ratio_multiply<std::chrono::years::period, std::hecto>>;

using语句类似于typedef的泛化,我们将在第八章中看到更多细节。我们可以像使用任何chrono持续时间一样使用我们的世纪持续时间;例如,将世纪转换为秒、小时或天数。秒和小时可以不进行转换直接转换,但要得到天数,我们需要使用duration_cast。这可能会令人惊讶,因为一世纪是 100 年,而一年有 365 或 366 个完整的日。然而,C++将一年定义为 365.2425 天,所以一世纪是 36524.25 天,这包含了一部分天数。因此,我们需要显式地使用duration_cast

列表 4.5 定义一个持续时间

void defining_a_duration()
{
    using namespace std::chrono;
    using centuries = duration<long long, 
        std::ratio_multiply<years::period, std::hecto>>;        ❶
    centuries two_hundred_years = centuries(2);                 ❶
    seconds sec = two_hundred_years;                            ❷
    hours hrs = two_hundred_years;                              ❷
    days day_count = duration_cast<days>(two_hundred_years);    ❸
    std::cout << "Two centuries is approximately " << day_count << '\n'; 
}

❶ 定义和使用一个持续时间

❷ 转换为秒或小时

❸ 持续数日

如果我们运行这段代码,我们会看到

Two centuries is approximately 73048d

预定义的便利持续时间足以用于倒计时到某个事件,但这个库的精心设计给了我们很大的灵活性。实际上,这个库中还有更多功能可以使我们的生活更轻松。我们不需要完全拼写std::chrono::month(12),因为持续时间和其他类型支持字面后缀。让我们更详细地看看字面后缀。

4.2.3 字面后缀和运算符/使代码更易读

我们注意到尝试读取包含许多零的数字,如 1000000000,可能会出错,但添加一个数字分隔符,如 1,000,000,000,有助于。C++现在支持数字分隔符,但逗号是一个运算符,所以我们使用单引号:

int readable_nano = 1'000'000'000;

这是对语言的一个小但有用的补充。在列表 4.4 中,我们使用了

auto nearly_a_day = hours{23};

这非常易于阅读,但chrono也支持字面后缀。通过添加h表示小时,我们也可以这样写

auto nearly_a_day = 23h;

小时字面后缀'h'非常直观。两种方法都行。这是怎么工作的?看似神奇的'h'是使用了chrono中的operator""h。这个运算符接受一个数字并返回小时类型的更强类型,实现方式如下:

hours operator""h(long long _Val) {
    return hours(_Val);
}

当遇到 23h 时,这个函数会被调用,给出我们想要的小时数。我们需要使用一个合适的命名空间才能使这个操作生效。我们在这里有选择。我们可以使用以下命名空间之一

  • std::literals,

  • std::chrono_literals,

  • std::literals::chrono_literals,

或者,更简单地说,使用 std::chrono,它通过指令使 chrono_literals 可见:

using namespace std::literals::chrono_literals 

operator""h 是一个 用户定义的文法 的例子,当我们使用 'h' 作为数字后缀时,它提供了转换为小时的转换。其他文法也受到支持,包括带有 'min' 的分钟和带有 's' 的秒。这些是在 C++11 中引入的,而 C++14 添加了毫秒、微秒和纳秒。这给了我们两种定义持续时间的方法,如表 4.1 所示。

表 4.1 定义特定持续时间的两种方法

持续时间 文法示例
hours{12} 12h
minutes{34} 34min
seconds{1} 1s
millisecond{1} 1ms
microsecond{1} 1us
nanosecond{1} 1ns

没有文法可以帮助构建天数、月数或年数。然而,日历中有天、月或年的文法。注意,所有预定义的 chrono::duration 类型都是复数形式,而日历类型是单数形式。它们的行为不同。我们可以添加月份,但不能添加一月和十二月。要指定月份,我们可以完整地拼写月份的名称;例如,使用命名常量 December。从数值类型进行转换意味着争论是否从 0 开始。实际上,C++20 使用 1 作为一月的值,但如果我们完整地键入 January,我们就不需要记住从哪里开始。日、月和年是 日历指定符,它们也可以以两种方式定义,如表 4.2 所示。

表 4.2 定义特定日、月或年的两种方法

公历 文法示例
year{2023} 2023y
month{1} January
day{23} 23d

用户定义的文法扩展了将 1u 写作无符号或 1.0f 写作浮点数的概念。C++ 标准库提供了时间文法,我们刚刚看到了。我们还有 'i' 用于复数;例如,2 + 3i,或者 's' 用于 std::string。是的,还有一个 operator""s,它在 string_literals 命名空间中,但它接受一个 const char*,而 chrono 的秒文法接受一个数值类型,所以它们是不含糊的。字符串文法很有用。如果我们用 "Hello" 初始化一个变量,我们正在使用一个 char 数组。如果我们使用 "Hello"s 代替,我们就有了一个直接的 std::string。我们也可以通过提供适当的操作符来定义自己的文法;然而,我们必须以下划线开始我们的后缀,以避免与标准文法操作符可能发生的冲突。

在本章的开头,在列表 4.1 中,我们创建了一个日期,明确地指出了年、月和日,而没有使用这些文法:

year_month_day(year(2022), month(12), day(31));

我们可以将它重写为

year_month_day{2022y, December, 31d};

事实上,我们还有一个更进一步的选项。chrono 库为了使代码可读,使用了一个技巧,那就是重载了 operator / 来创建年、月和日。CppReference (mng.bz/rjRj) 列出了大约 40 种重载方式来创建各种不同的日期。我们想要一个完整的年、月和日,因此我们可以用英文拼写月份,使用 'y' 后缀来指定年份,并用 '/' 来分隔日期。例如:

auto new_years_eve = 2022y / December / 31;

31^(st) 在指定了年和月之后不需要 'd' 后缀,因为它必须是那一天。year_month_day 可以用无数种方式构建,但以下三种顺序适用于完整的日期:

  • 年/月/日

  • 月/日/年

  • 日/月/年

我们将很快使用它来找出距离当前年结束还有多少天。在我们这样做之前,我们将回顾一下章节开头使用的 time_point。我们知道 time_point 是由 clockduration 定义的。我们看到了几种不同的时钟,现在我们也知道了 duration 的工作方式。尽管我们已经有足够的知识来完成我们的小型倒计时项目,但 chrono 的文档使用了 C++ 在许多地方都会出现的特性。特别是,提到了需求,并且使用了看似无害的短语 as if。它们是什么意思?

4.2.4 需求和概念

我们从使用

std::chrono::time_point now = std::chrono::system_clock::now();

time_point 是一个包含两种类型的类模板,一个是 clock,另一个是 duration

template<
    class Clock,
    class Duration = typename Clock::duration
> class time_point;

当我们找到当前时间时,我们使用了 system_clock,而 duration 默认为该时钟的 duration

CppReference (en.cppreference.com/w/cpp/chrono/time_point) 表示 time_point仿佛 存储了一个 duration 类型的值,表示从 Clock 的纪元开始的时间间隔。我们还没有见过 纪元 这个词。如果你之前使用过 C 的 time_t,你将熟悉从给定时刻(或纪元)开始计数滴答的想法,比如 1970 年 1 月的开始。其他系统从不同的时刻开始。例如,Windows 上的 Excel 使用 1900 年 1 月的开始(mng.bz/ddl1)。更重要的是,注意 仿佛 这个短语,它在 C++ 中经常出现。仿佛 规则 允许编译器在某些情况下重新排序指令或完全删除它们,只要程序的观察行为不会改变。对于一个时钟,实际的实现可以存储任何它喜欢的东西,只要它表现得仿佛存储了一个持续时间。编译器也可以在其他情况下重新排序或删除指令。如果一个程序读取了一个未初始化的变量,编译器也可以做任何事情,因为这通常是未定义的行为,通常简称为 UB。Ovle Maudel 之前写了一篇名为“Demons may fly out of your nose”的短文(mng.bz/BAjl),引用了“nasal demons”(来自 Usenet 群组 comp.std.c)这个短语,用来表示“C 编译器在遇到未定义构造时的意外行为。”没有人报告过因为未定义行为而鼻子里的鬼飞出来,但奇怪的事情确实发生了。有时仿佛规则意味着编译器可以优化我们的代码,这是好事,而有时它意味着我们有未定义的行为,这是坏事。在两种情况下,注意文档中的“仿佛”。

time_point 也使用了一个 Clock 类,根据 CppReference 的说明,这必须满足 Clock要求。现在,确保我们使用的时钟满足这些要求的责任就落在了我们身上;否则,我们可能会遇到鼻涕鬼。如果使用一个“不太像时钟”的东西,一些操作可能仍然可以工作,因此这个要求将在 C++23 中被取消(mng.bz/lVBR)。

词语 要求 也经常出现,并构成了 概念 语言特性的一个部分。我们在第二章中看到了容器,如 vector 和算法之间的分离。这种分离是通过模板实现的。算法是通用的,因此可以用于不同类型,在一系列元素上操作。模板允许一种形式的 鸭子类型,这是一个经常应用于动态语言的短语,但在我们使用 C++ 中的编译时模板时也同样适用。Stack Overflow (mng.bz/D95g) 提供了一个精彩的例子。

template <typename T> void f(T x) { x.Quack(); } 

来说明“如果它看起来像鸭子,叫声像鸭子,那么它就是鸭子”的短语,因此得名鸭子类型。如果我们尝试传递一个没有 Quack 函数的对象,我们会得到编译器错误,这可能是有用的,也可能不是。如果我们有方法来指定对象需要 Quack 函数,类似于

template <typename T> 
T must have a Quack function
void f(T x) { x.Quack(); } 

如果使用的对象没有 Quack 方法,编译器可以立即停止并通知我们。

使用比 f 更清晰的函数名,如果我们有

template<typename T> 
void might_be_a_duck(T x) { x.Quack(); }

并这样调用它

might_be_a_duck(42); 

我们将得到错误。根据 Visual Studio 2022,错误信息是 "left of '.Quack' must have class/struct/union"。在这种情况下,找到问题并不那么困难,但使用 requires 子句指定的要求会使问题更清晰。为了指定 Quack 函数必须存在,我们可以在函数签名中写一个 概念,为我们的 要求 命名并将其添加到函数签名中。

列表 4.6 编写和使用概念

template<typename T>
concept Quacks = requires(T t)    ❶
{
    t.Quack();                    ❷
};

template<typename T>
requires Quacks<T>                ❸
void must_be_a_duck(T x)
{
    x.Quack();
}

❶ 命名我们的要求

❷ 指定我们所需的内容

❸ 声明 T 必须实现 Quack

概念命名了我们想要的想法“T 必须有一个 Quack 函数”。当我们使用它时,我们不需要明确

template<typename T> requires Quacks<t> 

完整地。如果我们使用 auto,我们可以更简洁地表达:

void also_must_be_a_duck(Quacks auto x)
{
    x.Quack();
}

在任何情况下,must_be_a_duck(42)also_must_be_a_duck(42) 仍然会导致错误,但这次,Visual Studio 2022 会说

no matching overloaded function found, could be 'void must_be_a_duck(T)',  
the associated constraints are not satisfied

消息会更加有用。让我们回到时间和倒计时。

C++20 在 concepts 头文件中引入了几个命名要求。为了满足时钟要求,必须定义以下四种类型

  • rep

  • period

  • duration

  • time_point

时钟还必须支持 is_steadynow()chrono 提供了一个名为 is_clock 的类型特性,用于检查是否满足要求。特性描述了类型的属性,我们将在第六章中重新讨论它们。is_clock 特性有一个名为 value 的布尔成员,它报告类型是否满足要求。如果我们将其应用于一个 int

std::chrono::is_clock<int>::value

value 是错误的,因为 int 不是一个时钟。尽管 time_point 本身并不强制要求,但使用 time_points 的其他函数可能会强制要求。当使用这些要求时,编译器可以通过类似 'Clock type required' 这样的消息来指出错误类型的使用位置。因此,时钟要求可以提供更清晰的编译器错误信息。

更普遍地,要求和概念有助于在模板代码无法编译时提供更好的诊断。在上一章中,我们在列表 3.15 中使用了 auto 来传递消息提供者:

void guess_number_with_clues(int number, auto message)

我们从 std::function 开始,但需要更通用的东西。函数尝试调用或 调用 message 参数。如果我们传递了一个不可调用的东西,当使用消息时,我们会得到错误,错误发生在我们传递不适当的东西的地方。例如,调用

guess_number_with_clues(number, "Help!");

main函数内部会报错。Visual Studio 2022 会说,“term does not evaluate to a function taking two arguments”;这里的术语是“Help!”我们可以从concept头文件中添加invocable到函数签名中,表示消息应该可以用两个int调用:

void guess_number_with_clues(int number,
         std::invocable<int, int> auto message) 

通过这个添加,编译器可以更精确地定位问题。Visual Studio 2022 会说,“messageguess_number_with_cluesthe associated constraints are not satisfied”。它不仅告诉我们一个术语是错误的,而且还指出了哪个参数是错误的以及原因。

我们在这里只是触及了表面。注意要求,尝试一些其他的概念,并尝试编写自己的代码。现在我们更好地理解了时钟和持续时间,我们将通过报告各种单位的时间来改进到年底的最后一天的倒计时。

4.2.5 到年底的最后一天还有多少天?

在 4.2 列表中,我们找到了当前时间并使用year_month_day,包括年份在内的每个值都进行了硬编码,来计算

std::chrono::duration dur = event - now;

我们打印出了这个值,但得到了一个以秒为单位的巨大数字。

现在我们可以将这个时间转换为天数,并且可以使用当前年份而不是硬编码 2022 年。从system_clocknow方法开始的当前time_point给我们一个日期和时间。我们不能直接将其分配给year_month_day,因为这会丢失时间部分。我们可以通过先对now进行下取整来显式截断时间部分;然后我们可以创建另一个year_month_day对象并找出当前年份。将这些组合起来,我们可以找出一年中最后一天还有多少天。

列表 4.7 查找到年底的最后一天还有多少天

void countdown()
{
    using namespace std::chrono;
    time_point now = system_clock::now();

    const auto ymd = year_month_day{
        floor<days>(now)                              ❶
    };

    auto this_year = ymd.year();                      ❷
    auto new_years_eve = this_year / December / 31;   ❷

    auto event = sys_days(new_years_eve);
    duration dur = event - now;
    std::cout << duration_cast<days>(dur)             ❸
               << " until event \n ";
}

int main()
{
    countdown();
}

❶ 将now下取整到天数

❷ 使用当前年份

❸ 转换为天数

运行这段代码会告诉我们离年底的最后一天还有多少天:

343d until event

调用流插入operator<<会给数字添加一个'd'后缀。正如我们之前在 4.2 列表之后所提到的,一些较旧的编译器不支持operator <<,所以我们可能需要使用date库或者调用count并自己拼写单位:

std::cout << std::chrono::duration_cast<std::chrono::days>(dur).count()
          << " days\n";

我们有了到年底的最后一天的倒计时,但还有更多东西要学习。现在我们可以使用chrono中的last运算符来编写不同的倒计时,以找到一个月中的最后一天星期五。也许你那时会收到工资,所以找出离发工资还有多少天可能很有用。

4.2.6 使用last来查找到发工资还有多少天

12 月总是有 31 天,但我们可以使用last来代替:

auto new_years_eve = 2023y / std::chrono::December / std::chrono::last;

如果我们想找到 2 月的最后一天,这可能是 28 号或 29 号。我们可以尝试自己解决这个问题,但chrono会为我们完成这项工作。last是在 C++20 中引入的。它是一个非常简单的struct实例,称为标签类型 (mng.bz/NVax):

struct last_spec
{
    explicit last_spec() = default;
};
inline constexpr last_spec last{};

标签类型用于帮助选择函数的重载。运算符斜杠—operator/—有多个重载,包括几个接受last_spec的。例如:

constexpr year_month_day_last operator/( const year_month& ym, last_spec);

每个operator/接受两个参数。我们有一个年份、月份和last,所以

this_year / std::chrono::December / std::chrono::last;

使用运算符两次:

(this_year / std::chrono::December) / std::chrono::last;

首先,我们将年份和月份组合起来得到year_month,然后这个值与last结构一起用于创建year_month_day_last。我们可以再次使用 C++ Insights 来提示当我们使用

auto new_years_eve = 2023y / std::chrono::December / std::chrono::last;

生成的洞察是

std::chrono::year_month_day_last new_years_eve =
 operator/
    (operator/(std::operator""y(2023ULL), std::chrono::December),
    std::chrono::last_spec(std::chrono::last));

(见cppinsights.io/s/84b34f6d。)两个operator/调用是明显的,并且它们给我们一个year_month_day_last类型。C++ Insights 有一个链接到编译器探索器,它将显示更多(godbolt.org/z/qroM6xoT1)。在图 4.2 中,我们可以看到日期的值尚未计算。

CH04_F02_Buontempo

图 4.2 GCC 12.2 在编译器探索器上的输出

我们可以在左侧看到指令,在右侧看到操作数。实际的指令在不同方言之间有所不同。在图 4.2 中,mov(移动)指令在寄存器和内存之间移动数据,所以mov rbp, rsprbp中推入的数据移动到rspeax是另一个寄存器,用于返回值。push将操作数推入堆栈。pop弹出它们。指令ret从函数返回。Jason Turner 的 C++ Weekly 第 34 集介绍了阅读汇编语言,如果您想了解更多细节(见www.youtube.com/watch?v=my39Gpt6bvY)。您不需要能够阅读汇编代码就能看出我们有一个 2023 和一个 12,但没有 31。当创建year_month_day_last时,31 这个值是不需要的。除非我们试图找出这一天或将其流出,否则我们并不关心。chrono库做出了巨大的努力,以尽可能高效。

在简要查看内部细节以获取更多关于如何创建日期的详细信息后,我们现在将使用last进行更多练习。last struct将告诉我们二月的最后一天,正如我们之前所提到的:

auto end_of_feb = 2023y / std::chrono::February / std::chrono::last;

28^(th)或 29^(th)除非使用,否则不会计算。我们也可以以其他方式使用last。Chrono 还提供了一个weekday_last,它可以与weekday_indexed一起使用。我们可以直接使用这些,或者使用operator[] (mng.bz/E96D)来找到一年的第一个星期一或一个月的最后一天星期五。要找到最后一天星期五,或者确实任何一个月的特定一天,我们可以说

auto last_friday_in_year = this_year / December / Friday[last];

如果我们将其流出,我们得到

2023/Dec/Fri[last]

再次,last用于选择合适的重载,并不进行计算。我们仍然需要说出月份,因为last适用于天数或星期。我们也可以说Friday[1]来找到第一个星期五。weekday_indexed接受一个在range[1, 5]中的值,表示某个月份的第一、第二、第三、第四或第五个工作日,因此它是基于 1 而不是 0。

让我们再写一个倒计时。假设你每月的最后一天周五领取工资。距离发工资还有多少天?我们已经有了所有需要的部分。有了当前时间和本月的最后一个周五,我们可以像之前一样使用 sys_days 来创建一个日期,然后找到持续时间。

列表 4.8 发工资前的天数

void pay_day()
{
    using namespace std::chrono;

    time_point now = system_clock::now();
    const auto ymd = year_month_day{
        floor<days>(now)
    };                                                         ❶

    auto pay_day = ymd.year() / ymd.month() / Friday[last];    ❷
    auto event = sys_days(pay_day);
    duration dur = event - now;                                ❸
    std::cout << duration_cast<days>(dur)                      ❸
        << " until pay_day \n";
}

int main()
{
    pay_day();
}

❶ 当前年份、月份、日期

❷ 当前月份的最后一个周五

❸ 减去以找到发工资的天数。

写作时还有五天,可能还有几个小时,但我们使用 duration_cast 进行了向下取整。我们有两个倒计时,已经覆盖了很多内容。尽管如此,我们还没有编写任何测试。让我们停下来思考如何使用时间和日期来测试代码。

4.2.7 编写可测试的代码

我们可以在这里停止,因为我们已经得到了我们想要制作的倒计时以及更多。然而,这段代码直接将输出写入屏幕,这使得测试变得困难。它还直接使用当前日期和时间,这在测试中经常引起问题。我们可以做得更好。一旦我们改进了代码,我们将在循环中调用它,以观察时间倒计时到年底。

如果我们返回持续时间,调用代码可以使用该值做任何它想做的事情,这使得测试代码更容易。如果我们还传入 now 的值,我们可以改变测试的时间。在函数内部调用 now 是众所周知地难以测试的。在极端情况下,我见过有人写了一个他们声称需要 24 小时才能运行的测试,因为他们想在金融计算中检查一天内结果之间的差异。我建议传入所需的时间,而不是调用 now 并等待一天。你可能也有类似的故事。

我们的可测试倒计时将返回持续时间。此外,如果我们将函数标记为 constexpr,我们可以在某些测试中使用静态断言。这次我们用 last 代替 31 来练习。除了发送当前日期和时间之外,代码与列表 4.7 中的代码类似,但更加灵活。

列表 4.9 一个可测试的倒计时

constexpr                                                     ❶
std::chrono::system_clock::duration countdown(std::chrono::system_clock::time_point start)   ❷
{
    using namespace std::chrono;

    auto days_only = floor<days>(start);

    const auto ymd = year_month_day{days_only};

    auto this_year = ymd.year();
    auto new_years_eve = this_year / December / last;

    auto event = sys_days(new_years_eve);
    return event - start;                                     ❸
}

int main()
{
    std::cout << countdown(std::chrono::system_clock::now())
              << " until event \n";
}

❶ 在编译时可能

❷ 传入一个时间点

❸ 返回一个持续时间

我们现在可以更容易地测试我们的函数,甚至可以使用 static_assert 来引发编译时错误,就像我们之前做的那样。

列表 4.10 检查倒计时函数

void check_properties()
{
    using namespace std::chrono;
    constexpr auto new_years_eve = 2022y / December / last;          ❶
    constexpr auto one_day_away = sys_days{ new_years_eve } - 24h;   ❶
    constexpr auto result = countdown(one_day_away);                 ❶
    static_assert(duration_cast< days>(result) == days{ 1 });        ❷
}
int main()
{
    check_properties();
}

❶ 使用 constexpr 进行编译时表达式

❷ 使用 static_assert 在编译时进行测试

我们已经涵盖了几个核心的 C++ 概念。如果我们在一个循环中调用我们的倒计时并显示秒而不是天数,我们可以坐下来观看时间的流逝。如果我们包含 thread 头文件,我们可以在每次调用之间 sleep 一段时间,使用 chrono 文字面量来指定多长时间(例如,5000ms)。这相当不错,不是吗?试试看!

列表 4.11 在循环中调用倒计时

#include <thread>
int main()
{
    using namespace std::chrono;                        ❶
    for (int i = 0; i < 5; ++i)
    {
        std::this_thread::sleep_for(5000ms);            ❶
        auto dur = countdown(system_clock::now());
        std::cout << duration_cast<seconds>(dur) <<     ❷
          " until event\n";
    }
}

❶ 使用 std::chrono 中的 ms

❷ 显示多少秒

如果我们运行这个程序,我们会看到距离年底的秒数逐渐减少:

4343635s until event
4343630s until event
4343625s until event
4343620s until event
4343615s until event

我们最初使用固定的年份硬编码了新年除夕,然后学习了如何将其泛化到当前年份。我们还看到了如何找到到一个月最后一个星期五的时间。尽管如此,我们还没有读取日期。

4.3 输入、输出和格式化

如果我们输入一个事件日期,我们可以使倒计时更加通用。我们如何从流中读取日期?

4.3.1 解析日期

我们可以使用来自 chronoparse 方法来读取日期。这在 Visual Studio 2022 中受支持,但最新的 Clang 和 GCC 不支持此方法,因此您需要使用 4.1 节末提到的日期库。再次提醒,包含库中的 "date/date.h" 并将接下来的 std::chrono::parse 改为 date::parse。别忘了使用 -I 开关指向您克隆的 date/include 目录。

我们可以选择所需的格式;例如,使用 %Y-%m-%d 格式来表示用连字符分隔的四位年份、月份和日期:

std::chrono::year_month_day date;
std::cin >> std::chrono::parse("%Y-%m-%d", date);

如果输入的格式与预期格式不匹配,则流处于错误状态,我们可以检查这一点。

我们也可以使用 from_stream 方法,该方法接受流作为参数,如下所示:

std::chrono::from_stream(std::cin, "%Y-%m-%d", date);

parsefrom_stream 函数有几个重载,用于处理时间,包括 sys_time 以及年、月、日等。本质上,每个 parse 重载都映射到相应的 from_stream,因此您可以使用适合您的方法。

我们可以添加一个使用 parse 方法的函数,允许用户输入事件日期并报告还有多长时间发生。输入可能无效,因此我们需要一种处理这种情况的方法。在列表 3.4 中,我们编写了一个名为 read_number 的函数,它接受一个 std::istream 并返回一个 std::optional<int> 来处理无效输入。我们可以在这里使用类似的模式,如果出现问题,清除无效输入。当我们在脑海中保留字面量时,我们将使用 operator""s 来使格式成为一个 std::string。我们并不 需要 做这件事,因为 "%Y-%m-%d" 格式指定符就可以工作,但了解如何直接创建字符串是有价值的。此操作符位于 string 头文件中的 std::string_literals 命名空间中,因此我们需要包含此头文件。我们还需要包含 optional 头文件,以便我们可以编写以下函数来读取日期。

列表 4.12 读取日期

#include <optional>
#include <string>
std::optional<std::chrono::year_month_day> read_date(std::istream& in)
{
    using namespace std::string_literals;              ❶
    auto format_str = "%Y-%m-%d"s;                     ❶
    std::chrono::year_month_day date;
    if (in >> std::chrono::parse(format_str, date))    ❷
    {
        return date;                                   ❸
    }
    in.clear();                                        ❹
    std::cout << "Invalid format. Expected " << 
            format_str  << '\n';
    return {};                                         ❺
}

❶ 使用 ""s 创建字符串

❷ 输入是否有效?

❸ 返回有效日期

❹ 清除无效输入

❺ 返回无值的可选类型

在列表 4.9 中,我们在 countdown 函数中使用了硬编码的事件日期,因此我们需要一个新函数来接受用户提供的日期。如果您正在使用 date 库而不是 chrono,则在下一个列表中将 using 命名空间切换为

using namespace date;

而不是。将选定的日期作为第二个参数传递。

列表 4.13 到任何事件的倒计时

constexpr std::chrono::system_clock::duration 
countdown_to(std::chrono::system_clock::time_point now,
    std::chrono::year_month_day date)
{
    using namespace std::chrono;
    auto event = sys_days(date);
    return event - now;
}

通过读取日期,我们可以创建一个通用的倒计时。我们应该考虑我们想要如何显示输出,因为这给我们另一个使用 format 的机会。

4.3.2 格式化时间点和持续时间

在我们读取了所选事件日期后,我们可以从main中调用列表 4.13 中的倒计时。如果我们把输入读入一个字符串,我们不需要清理任何无效字符,因为它们已经被读入字符串中。因为我们返回的是一个可选值,我们在调用我们的倒计时之前检查这个值是否正确。如果我们想要以天为单位输出,我们需要将持续时间转换为天数。

列表 4.14 一个通用倒计时

int main()
{
    using namespace std::chrono;
    std::cout << "Enter a date\n>";
    std::string str;
    std::cin >> str;                                                       ❶
    std::istringstream in(str);
    std::optional<std::chrono::year_month_day> event_date = read_date(in);
    if (event_date)                                                        ❷
    {
        auto dur = countdown_to(system_clock::now(),event_date.value());
        std::cout << duration_cast<days>(dur) <<                           ❸
            " until " <<  event_date.value() << "\n";   
    }
}

❶ 将所有输入读入一个字符串

❷ 检查我们是否得到了一个有效的日期

❸ 转换为天数

当然,我们也可以使用任何其他时间段。此外,我们可以使用std::format而不是durationtime_point。这让我们可以选择如何报告持续时间,以及如何显示日期。如果我们想要以秒为单位报告持续时间,我们使用:%S,而对于四位数的年份,然后是月份,然后是日期,我们可以使用:%Y-%m-%d或快捷方式:%F

std::cout << std::format("{:%S} until {:%F}\n", dur, date);

可用于持续时间和时间点的格式字符串有很多(见mng.bz/84gW))。 如果找不到所需的内容,可以退回到最初使用的持续时间转换,或者从时间点中提取所需的日期部分。chrono库功能强大且灵活,通常有多种方法可以实现所需的功能。

我们有一个倒计时;实际上,我们有几个倒计时。现在,报告事件还有多少秒是非常好的;然而,如果在这之间发生夏令时,我们的输出将是不正确的。系统时钟使用协调世界时(UTC),因此我们需要使用时区来考虑本地时间。

4.4 时区

2022 年 3 月 27 日凌晨 2 点开始实行英国夏令时(BST)。如果我们调用我们的通用countdown_to方法来找出从 27 日凌晨 3 点到第二天之间有多少小时

auto got = countdown_to(sys_days{ 2022y / March / 27 } + 3h,
                                    { 2022y / March / 28 });
auto got_hours_bst = duration_cast<hours>(got);

我们得到 21 小时,比一个完整的 24 小时少 3 小时。表面上看起来这是可以的;然而,我们的倒计时正在使用 UTC 的当前时间。在 BST(英国夏令时)中,这将是在凌晨 4 点,因此只剩下 20 小时。C++20 引入了时区,但它们并不被广泛支持。Visual Studio 2022 和 GCC 13.2.0 支持它们,但在撰写本文时,Clang 还没有支持。如果您之前克隆了日期库,您需要使用库中的tz.cpp文件来使用时区。到目前为止,我们只使用了头文件中的功能,但时区也需要这个源文件。Rainer Grimm 的网站上有编译和使用库的说明(mng.bz/9Qe0),同样,Howard Hinnant 的 GitHub 页面也有(mng.bz/K9XE)。您还需要使用date命名空间而不是chrono

我们可以使用时区将系统时间转换为zoned_time,通过调用get_local_time来实现。我们可以通过名称选择时区,并将其与一个时间点配对来创建一个时区时间:

zoned_time("Europe/London", when).get_local_time();

这些名称来自互联网名称与数字地址分配机构(IANA)的时间区域(tz)数据库(www.iana.org/time-zones)。如果位置不存在,我们将得到一个异常。或者,我们可以使用 current_zone() 来获取当前时区的本地时间。如果我们坚持使用一个接受系统时间和事件日期的函数,并返回一个类似于列表 4.13 中的持续时间的函数,我们需要将事件转换为 zoned_time 并在 sys_time 中找到差异。使用 local_time 的算术忽略了时间变化。例如,如果我们每天在当地时间上午 9 点有一个会议,那么在 local_time 中添加一天将给出第二天上午 9 点的当地时间,即使存在中间的 UTC 偏移变化。我们想知道时间的物理差异,所以使用 sys_time。返回类型具有 eventnow 之间差异的精度,这是 system_clock::duration

列表 4.15 本地时间倒计时

std::chrono::system_clock::duration
countdown_in_local_time(std::chrono::system_clock::time_point now,
    std::chrono::year_month_day date) 
{
    using namespace std::chrono;
    auto sys_event = zoned_time(current_zone(),
                      local_days{ date }).get_sys_time();    ❶
    return sys_event - now;                                  ❷
}

❶ 将本地时间事件转换为 sys_time

❷ 物理时间的差异

这个倒计时考虑了夏令时。

我们只是刚刚触及了 chrono 的表面。如果需要了解如何完成这里未涵盖的内容,霍华德·欣南特(Howard Hinnant)已经编写了一个示例列表(mng.bz/0lnW)。

我们练习了读取 input 和使用 format 进行输出。我们还使用了字面量后缀。我们还没有做过的事情是编写我们自己的类,所以我们将在下一章中这样做,创建一副扑克牌来制作另一个游戏。

概述

  • 有各种时钟,每个都支持一个 now 方法,该方法返回一个 time_point

  • 系统时钟是不稳定的,所以如果系统时间发生变化,它可能会倒退。

  • 使用 year_month_day 来访问 yearmonthdate 字段,并使用 sys_days 将其转换为 time_point

  • 持续时间由一个数值类型和一个 ratio 定义,告诉我们它们在哪个单位中。std::ratio<1> 表示秒,而 std::ratio<60> 表示分钟。

  • 如果转换不会丢失精度,持续时间可以隐式转换;否则,我们必须使用 duration_cast

  • 我们可以定义我们自己的持续时间。

  • chrono 提供了字面量后缀,例如 operator""s 用于秒。

  • 我们可以使用 operator/ 通过字面量创建 year_month_day 来形成日期,例如 2022y / December / last

  • 时间点由一个时钟和一个持续时间组成。

  • 可以使用需求来为模板提供帮助,以便在模板代码无法编译时提供更清晰的诊断信息。

  • 概念是一组命名的需求。

  • 我们可以使用 operator<< 将日期和持续时间写入流。持续时间附加其单位的字面量;例如,'d' 表示天数。

  • 使用 parsefrom_stream 读取日期或时间。

  • format 库还支持 time_pointduration

  • 可以使用 current_zone() 或一个命名时区将系统时间转换为本地时区,并考虑夏令时。

5 创建和使用对象和数组

本章涵盖

  • 编写类或结构

  • 作用域枚举

  • 当我们知道需要多少元素时,使用array而不是vector

  • 编写比较运算符

  • 默认函数

  • 使用std::variant

在本章中,我们将创建一副牌并编写一个高低牌游戏,用于猜测牌堆中下一张牌是更高还是更低。我们将创建一个代表牌的类,并将一副牌存储在一个array中。我们需要考虑如何为我们的牌定义比较运算符,以及如何编写构造函数和其他成员函数。我们还需要使用随机洗牌。然后我们将扩展游戏以包括小丑牌,并学习如何使用std::variant。到本章结束时,我们将有一个可工作的牌游戏,并准备好使用类做更多的事情。

5.1 创建一副扑克牌

我们将首先定义一个卡片类。我们可以使用关键字classstruct来声明一个卡片。如果我们使用struct,则默认所有内容都是公开的,这是一个简单的起点。一张卡片有一个花色和一个数值。有四种花色,每种花色有 13 个可能的数值:1,或王牌;2 到 10;以及三种宫廷牌。我们还需要显示和比较卡片,以及创建一整副牌的方法。我们将从卡片本身开始。

到目前为止,我们所有的代码都放在了main.cpp文件中。对于本章,我们将创建一个头文件,命名为playing_cards.h,并将其包含在main.cpp中。随着我们添加函数,大多数函数都将放入一个playing_cards.cpp源文件中。让我们花点时间提醒一下使用源文件和头文件的基本知识。当我们使用头文件时,我们总是需要一个包含保护。这阻止了头文件在同一个源文件中被包含多次,这可能导致问题,包括违反单一定义规则。如果没有保护,包含相同的头文件两次,这很容易间接发生,如果头文件包含另一个头文件,意味着枚举、结构体等将被定义两次,这是不允许的。这不是什么新鲜事。C++Reference 提供了更多关于这个主题的详细信息(mng.bz/z0Rg)。有些人仍然使用宏来包含或作为头文件保护,选择一个独特的名称。

列表 5.1 宏样式的包含保护

#ifndef PLAYING_CARDS_HEADER 
#define PLAYING_CARDS_HEADER 
...
#endif

然而,pragma指令once现在得到了广泛的支持。如果这个指令在您的编译器上不起作用,使用宏版本是可以的。让我们为我们的牌创建一个命名空间,将结构和函数保持在namespace作用域内。

列表 5.2 playing_cards头文件

#pragma once       ❶

namespace cards    ❷
{
}

❶ 包含保护

❷ 为后续声明命名空间

最后,我们在main.cpp中包含这个头文件,使用引号""而不是尖括号<>,这表示它是我们的而不是库头文件。搜索包含的头文件的具体位置是实现定义的,但引号版本会在尖括号版本初始搜索失败时搜索。人们通常使用尖括号来表示标准库头文件,使用引号来表示自己的头文件。我们的主函数目前还没有做什么,但现在我们有地方放置我们的代码了。

列表 5.3 包含头文件

#include "playing_cards.h"     ❶

int main()
{
}

❶ 包含我们的头文件

我们现在可以为我们的游戏创建一些牌了。

5.1.1 使用范围枚举定义牌类型

我们知道我们需要为每一张牌指定一个花色和一个数值。最初我们可以使用整数来表示数值,尽管我们也可以用整数来表示花色,但使用enum会更清晰。C++11 引入了范围枚举,它们看起来与旧的未范围enum非常相似,但在enum关键字和名称之间有一个class,或者等价地,struct。在playing_cards.h文件中添加一个enum,每个花色对应一个枚举值,在命名空间内。

列表 5.4 范围enum用于花色

#pragma once            ❶

namespace cards         ❷
{
    enum class Suit {   ❸
        Hearts,
        Diamonds,
        Clubs,
        Spades
    };
}

❶ 包含保护

❷ 命名空间

❸ 注意到单词class

单词class的添加带来了很大的不同。没有它,我们有一个旧式的enum,可以无限制地使用Hearts或其他任何值,或者枚举值。这意味着我们可能会错误地比较来自不同枚举的值。如果使用两个不同的枚举来指示函数是否成功,它们可能会都使用OK表示成功,使用许多值之一表示失败。然后,检查结果是否为OK时,可能会混淆这两个不同的枚举。要使用我们的花色,我们需要说Suit::Hearts,这样就可以避免潜在的意外比较。

范围枚举的值到整型的隐式转换是不存在的,这在旧的枚举中是可能的。如果我们想将值用作数字,我们需要显式使用类型转换。范围枚举更安全。

我们在头文件中的命名空间内开始使用一个struct来保存初始牌类型的数值和花色。

列表 5.5 牌结构

struct Card
{
    int value;
    Suit suit;
};

然后,在main中,我们可以创建一个带有数值和花色的牌,前提是我们包含我们的头文件并使用cards命名空间。我们将使用聚合初始化,它看起来非常像使用初始化列表的统一初始化。我们在第二章中使用它来制作帕斯卡三角形的第一行:std::vector<int> data{1}。聚合初始化是不同的。初始化列表是相同类型的值的列表,但我们的聚合初始化使用不同类型的列表。我们的Card结构体有一个int数据成员,后面跟着一个Suit,所以我们按照这个顺序提供这些值来实例化一个Card

列表 5.6 使用Card结构体

#include "playing_cards.h"        ❶
using namespace cards;            ❷

int main()
{
    Card card{2, Suit::Clubs};    ❸
}

❶ 包含我们的头文件

❷ 使用命名空间

❸ 创建一个带有数值和花色的卡片

我们可以只指定值,Card card{2},而花色将默认初始化为第一个 enum 值。然而,我们不能说 Card card{Suit::Clubs}。我们可以在末尾省略初始化器,但不能在开头省略。

如果我们没有使用作用域 enum 来表示花色,我们将使用两个 int 来制作卡片,并且必须记住哪个是哪个。使用 card{2, Suit::Clubs} 比使用 card{2, 3} 更清晰且更不容易出错。然而,目前,我们可以使用 0 或 14 作为卡片的数值。我们在上一章学习整值习语时了解了 year_month_day。现在我们可以通过为卡片的数值创建一个类型,并确保只使用 1 到 13 的数值来应用同样的想法。除了验证使用的值外,我们还将看到如何使用该类型来轻松显示卡片。

5.1.2 使用数值的强类型定义卡片类型

面值需要接受一个 int 并将其存储起来,提供获取函数,以便代码在需要时可以使用该值。在上一章中,我们考虑了整值习语来创建轻量级类型,以确保参数被正确传递。如果我们创建一个具有 explicit 构造函数的 FaceValue 类,我们无法在需要面值的地方传递一个 int。例如,如果我们有一个函数,其签名是

void do_something_with_face_value(const cards::FaceValue & value);

我们不能使用 int 来调用它。相反,我们需要创建一个面值:

do_something_with_face_value(cards::FaceValue{ 5 });

由于构造函数是显式的,int 不能隐式转换为我们的新类型。

如果我们使用的值无效,我们将抛出一个异常。来自 stdexcept 头文件的 std::invalid_argument 异常是有意义的。

列表 5.7 一个数值类型

#include <stdexcept>
namespace cards
{    
    class FaceValue
    {
    public:
        explicit FaceValue(int value) : value_(value)    ❶
        {
            if (value_ < 1 || value_ > 13)
            {
                throw std::invalid_argument(
                         "Face value invalid"
                      );                                 ❷
            }
        }
        int value() const
        {
            return value_;
        }
    private:
        int value_;
    };
    ...
}

❶ 显式构造函数

❷ 验证数值

我们可以将 Card 定义中的 int value 类型更改为 FaceValue value。要创建类似于列表 5.6 中的卡片,我们必须显式地创建一个 FaceValue,然后创建一个 Card card{ FaceValue(2), Suit::Clubs},而不是能够说 Card card{2, Suit::Clubs}。在构建卡片时,我们需要付出一点更多的努力,但如果我们正确构建,我们将得到一个花色和一个有效的卡片数值。在我们开始使用 FaceValue 之前,我们应该稍微更多地考虑我们如何制作卡片。事情仍然可能会出错。让我们回顾我们的卡片类型,确保我们只制作有用的扑克牌。

5.1.3 构造函数和默认值

在我们使用 FaceValue 之前,考虑一下列表 5.5 中定义的 Card 类型。我们的结构有两个成员,一个 int 值和一个 Suit。我们可以创建一个没有值或花色的卡片:

Card dangerous_card;

然而,这两个成员字段将不会被初始化。如果我们尝试读取这些字段,我们将遇到未定义的行为。在 Visual Studio 2022 的调试构建中,我偶然得到 -858993460 的值和 -858993460 的花色。在发布构建中,我可能会得到不同的垃圾值。编译器可以随意处理此类代码,因此你可能会用另一个编译器得到不同的行为。如果我们使用花括号初始化

Card less_dangerous_card{};

成员将被默认初始化。我们之前已经见过花括号或统一初始化,记住初始化变量是一个好习惯。我们可以尝试非常小心地不使用未初始化的值,但更安全的方法是确保我们首先不能创建危险的玩牌。我们可以采用各种方法来避免未初始化的成员变量。

最简单的方法是使用默认值来初始化值和花色。自 C++11 以来,我们可以使用 默认成员初始化器,直接为任何我们想要初始化的成员提供默认值。整数默认初始化为 0,枚举初始化为第一个值。

列表 5.8 一个卡片结构

struct Card
{
    int value{};    ❶
    Suit suit{};    ❶
};

❶ 使用默认值初始化成员

我们之前危险卡片现在有了值,我们可以安全地读取,得到 0 的红桃:一个非常不可能的玩牌,但没有未定义的行为。如果我们现在使用 FaceValue,我们无法创建一个值为 0 的卡片,因此我们需要选择一个可接受的价值,比如说,1。

列表 5.9 一个卡片结构

struct Card
{
    FaceValue value{1};    ❶
    Suit suit{};
};

❶ 使用有效默认值初始化 FaceValue

我们可以使用这个定义来为我们的游戏,但让我们先考虑一种替代方法,因为我们仍然有可能遇到问题。struct 的成员默认是公有的,这意味着我们可以直接使用它们。因此,我们可以轻松地更改它们的值,这可能不是一个好主意。我们可以将它们标记为私有,或者使用 class 而不是 struct,因为 class 的成员默认是私有的。在任何情况下,我们都需要一种设置值的方法;否则,每张卡片都将具有相同的值。我们可以添加一个公共构造函数,接受一个值和一个花色,并将它们存储起来。如果我们需要从 classstruct 外部获取这些值,我们还需要添加获取器。这些应该被标记为 const,因为它们不会更改 Card 成员值。这允许它们被一个卡片变量调用,无论它是否是 const。我们可以更改原始结构的名称,或者将其删除并在命名空间中的头文件中创建一个新的、改进的类型。

列表 5.10 一个卡片类

class Card
{
public:
    Card(FaceValue value, Suit suit):            ❶
        value_(value),                           ❷
        suit_(suit)                              ❷
    {
    }
    FaceValue value() const { return value_; }   ❸
    Suit suit() const { return suit_; }          ❸
private:
    FaceValue value_;                            ❹
    Suit suit_;                                  ❹
};

❶ 带有值和花色的构造函数

❷ 存储值和花色

❸ 标记为 const 的获取器

❹ 私有成员

我们不能再使用默认构造函数创建一个卡片。既然我们已经编写了自己的带参数的构造函数,我们就不会再自动生成默认构造函数。之前创建的危险卡片现在是不可能的。尝试

Card impossible_card;

将无法编译。如果你之前使用过 C++,这也应该很熟悉。

当我们使用 std::array 构建一副牌时,我们需要默认构造牌。C++11 引入了一种创建 默认 默认构造函数的方法。如果我们添加

Card() = default;

到列表 5.10 中的类,我们的 impossible_card 就变成了可能的。即使我们写了另一个构造函数,编译器也会定义一个默认构造函数。我们仍然应该像之前那样添加默认成员初始化器,以便默认构造函数可以初始化这些值。

列表 5.11 一个可默认构造的牌

class Card
{
public:
    Card() = default;                  ❶
    Card(FaceValue value, Suit suit):
        value_(value),
        suit_(suit)
    {
    }
    FaceValue value() const { return value_; }
    Suit suit() const { return suit_; }
private:
    FaceValue value_{1};               ❷
    Suit suit_{};                      ❷
};

❶ 默认构造函数

❷ 成员初始化器

我们也可以使用 = delete 标记一个构造函数为已删除。这将阻止生成该构造函数。我们可以为任何特殊成员函数这样做,例如 copymove 构造函数、赋值运算符或析构函数。在 C++11 之前,我们经常将想要隐藏的函数设置为私有,以避免它们被使用。能够说一个函数是已删除的更简单,并且使我们的意图更明确。我们将在下一章中更详细地研究特殊成员函数。现在,我们有一个健壮的牌类型。我们需要一种显示牌的方法;然后我们可以继续创建一副牌并编写我们的游戏。

5.1.4 显示扑克牌

为了显示一张牌,我们想要能够写出

std::cout << card << '\n';

因此,我们需要为我们的 Card 类型提供一个流插入运算符。我们在列表 2.5 中编写了一个流插入运算符。我们需要一个重载,它接受 std::ostream 的引用作为第一个参数,以及一个常量引用 Card 作为第二个参数:

std::ostream& operator<<(std::ostream & os, const Card & card);

我们返回流的一个引用,以便可以链式调用:

std::cout << card << ', ' another_card << '\n';

std::ostream 位于 iostream 头文件中,因此我们包含该头文件,并将我们的操作符声明添加到头文件中的命名空间。

列表 5.12 声明牌的 operator<<

#pragma once
#include <iostream>                                                   ❶

namespace cards
{
    ...
    std::ostream& operator<<(std::ostream & os, const Card & card);   ❷
}

❶ 包含我们使用的头文件

❷ 声明我们的函数

我们有两个数据成员需要输出。FaceValue 成员有一个名为 value 的获取器,我们可以用它来输出底层的 int。一张牌的值将显示为一个数字,即使它是 A 牌或宫廷牌。我们稍后会改进这一点。花色是一个范围 enum,我们目前也可以将其作为 int 输出。默认情况下,范围枚举使用 int 作为枚举值,因此我们可以使用 static_cast 将花色转换为 int 并输出。我们的头文件承诺在命名空间中定义一个函数,所以我们定义该函数在源文件 playing_cards.cpp 中的 namespace cards 内。

列表 5.13 定义牌的 operator <<

#include "playing_cards.h"                                          ❶

namespace cards                                                     ❷
{
    std::ostream& operator<<(std::ostream& os, const Card& card)    ❸
    {
        os << card.value().value()                                  ❹
           << " of " << static_cast<int>(card.suit());              ❺
        return os;
    }
} 

❶ 包含我们的头文件

❷ 在命名空间内添加代码

❸ 定义函数

❹ 获取 FaceValue 的值

❺ 将枚举转换为 int

如果你从提示符构建,你需要在你构建命令中指定两个 cpp 文件:

clang++ --std=c++20 main.cpp playing_cards.cpp -o ./main.out -Wall

带着使用 Card card{FaceValue(2), Suit::Clubs} 构造的 Card,我们现在可以写出

std::cout << card << '\n';

并得到 2 of 2。梅花是 enum 的第三个元素,所以使用基于 0 的索引确实给我们 2 代表梅花,但看到 2 of 梅花 会更美观。

我们可以更新卡片的流操作符,但可能存在我们只想显示面值或花色的情况。我们可以为每个写一个流操作符,或者我们可以写一个 to_string 方法。C++11 为数值类型添加了 to_string 方法。这些函数位于 string 头文件中。

我们可以编写自己的 to_string 重载,一个用于 Suit,一个用于 FaceValueSuit 的声明接受一个 Suit 并返回一个 string

std::string to_string(Suit suit);

与其他声明一样,它属于头文件。我们还包含 string 头文件在我们的头文件中,因为我们正在使用 std::string。关于声明的部分就到这里。我们如何定义函数呢?在上一章中,我们提到我们可以使用 std::literals 中的 operator ""s 来创建一个 std::string"Hearts"s 创建了一个 std::string,而 "Hearts" 是一个字符数组。这不是什么大问题,但我们是返回一个字符串,所以让我们创建一个字符串。对于我们的 to_string 函数,最简单的方法是使用 switch 语句,将枚举符和花色配对。我们添加一个默认值来抑制关于没有返回语句的代码路径的潜在警告。

列表 5.14 将枚举值转换为字符串

std::string to_string(const Suit & suit)
{
    using namespace std::literals;   ❶
    switch (suit)
    {
    case Suit::Hearts:
        return "Hearts"s;            ❷
    case Suit::Diamonds:
        return "Diamonds"s;          ❷
    case Suit::Clubs:
        return "Clubs"s;             ❷
    case Suit::Spades:
        return "Spades"s;            ❷
    default:
        return "?"s;
    }
   }

❶ 用于操作符 ""s

❷ 直接创建 std::strings

我们可以在末尾抛出一个异常而不是返回一个问号。有选择,但这个简单的方法已经足够好。

注意 Java 和 C# 枚举支持一个 ToString 方法,但 C++ 不支持。如果 C++ 有反射,我们可以将枚举值转换为字符串。然而,C++ 还不支持反射,但有一个技术规范(简称 TS;见 www.iso.org/deliverables-all.html)用于编译时或静态反射 (mng.bz/G9n8)。潜在的 C++ 功能有时有示例实现,一些编译器也提供实验性头文件,例如 <experimental/reflect>(见 mng.bz/YRMQ)。有多个反射建议 (mng.bz/OPjO),所以时间会告诉我们 C++ 最终会采取哪种方法。

现在当我们显示我们创建的卡片时,我们可以得到 2 of Clubs。然而,由于目前的状态,宫廷卡片和 A 牌将显示为数字。因为我们创建了一个 FaceValue 类型,我们可以写另一个 to_string 重载,为宫廷卡片和 A 牌提供特殊案例。任何其他值将使用 std::to_string 方法为 int。像往常一样,我们在头文件中声明函数,并在玩牌源文件中的命名空间内定义它。

列表 5.15 将卡片值转换为字符串

std::string to_string(const FaceValue & value)
{
    using namespace std::literals;               ❶
    switch (value.value())
    {
    case 1:
        return "Ace"s;                           ❷
    case 11:
        return "Jack"s;                          ❷
    case 12:
        return "Queen"s;                         ❷
    case 13:
        return "King"s;                          ❷
    default:
        return std::to_string(value.value());    ❸
    }
}

❶ 用于操作符 ""s

❷ 直接创建 std::strings

❸ 2 到 9 作为字符串

我们现在可以更新我们的流插入操作符以使用我们的重载 to_string 函数

列表 5.16 显示 A 牌、杰克、王后、国王或数字

std::ostream& operator<<(std::ostream& os, const Card& card)
{
    os << to_string(card.value())               ❶
        << " of " << to_string(card.suit());    ❶
    return os;
}

❶ 使用我们的新函数

如果我们流出一个特殊值卡片

std::cout << Card{ FaceValue(1), Suit::Hearts } << '\n';

我们看到“红桃 A”。我们可以制作单独的牌,所以现在我们需要制作一副牌。

5.1.5 使用数组创建一副牌

我们之前使用vector时想要一个元素集合。当元素数量未知但我们需要 52 张牌来组成一副完整的牌时,vector非常棒。C++11 引入了数组类型(en.cppreference.com/w/cpp/container/array),用于固定大小的数组。它位于array头文件中,并使用类型和大小定义:

template<class T, std::size_t N> struct array;

vector取了元素类型T,但array也需要一个编译时的大小N。向量可以动态调整大小,但数组的大小在编译时固定为所选大小。数组在维护方面有非常小的开销,并且可以放在栈上而不是堆上。这如图 5.1 所示。

CH05_F01_Buontempo

图 5.1 向量有更多的开销,将元素放置在堆上,并且可以动态改变大小,而数组有较小的开销和固定的大小。

因此,我们可以声明一副牌

std::array<Card, 52> deck;

我们可以使用 C 风格的数组Card deck[52],但std::array使我们更安全,因为我们总是知道数组的大小。在这两种情况下,我们都会得到 52 个默认构造的牌。使用vector,我们会push_backemplace任何我们需要的新的牌,并且vector会增长。我们可以使用聚合初始化来初始化一些或所有牌。因此

std::array<Card, 52> deck{Card{FaceValue(2), Suit::Hearts}};

在开始处放置一张红桃 2,并使用默认构造函数为剩余的 51 张牌。我们可以像在vector或 C 风格数组中一样访问特定元素,使用operator[],所以deck[0]是第一张牌。如果我们需要将我们的array传递给需要一个指向数组类型的指针的函数(例如,在 C 库函数中),我们可以调用data成员函数来获取对底层数据的指针。

让我们编写一个函数来创建一副牌。我们需要包含array头文件,在我们的头文件中声明函数,然后在源文件中定义它。对于四种花色中的每一种,我们需要 13 个值。不幸的是,我们无法简单地遍历Suit枚举。即使在这种情况下,值也不一定是连续的。因此,在一般情况下,使用operator++可能会使用一个无效的枚举值。我们可以做的是将这些值放入一个initializer_list中。我们在第二章讨论统一初始化语法时使用了花括号初始化。通过创建一个花色的初始化列表

{Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit::Spades}

我们有一个类似数组的对象可以在循环中使用。我们需要遍历每个花色的 13 个面值。从array的开始位置使用迭代器,我们可以使用*card设置其内容,并在每次循环中通过使用++card移动到下一张牌。

列表 5.17 构建一副牌

std::array<Card, 52> create_deck()
{
    std::array<Card, 52> deck;
    auto card = deck.begin();                                         ❶
    for (auto suit : 
        {Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit::Spades})    ❷
    {    
        for (int value = 1; value <= 13; value++)                     ❸
        {
            *card = Card{ FaceValue(value), suit };                   ❹
            ++card;                                                   ❺
        }
    }
    return deck;
}

❶ 从第一张牌开始的迭代器

❷ 花色的初始化列表

❸ 循环值

❹ 设置牌的值

❺ 移动到下一张牌

我们可以使用到目前为止所拥有的内容来制作一副牌,但我们注意到在第二章中鼓励避免使用原始循环并优先考虑算法。我们可以重构列表 5.17 中的函数,使用算法来创建一副牌。在本章中我们没有看到任何测试,但 GitHub 代码中包含一个check_properties函数,类似于我们在前几章中编写的测试函数。在重构代码之前,我们应该考虑测试什么。对于面值为 0 的牌,我们会得到一个异常吗?我们真的有 52 张不同的牌吗?

5.1.6 使用 generate 填充数组

algorithm头文件中包含一个名为generate的方法,该方法将函数对象生成的连续值分配给范围[first, last)。C++20 引入了新的版本,包括适用于范围的重载,因此我们可以直接使用std::array<Card, 52> deck,而无需自己找到beginend。我们可以使用 lambda 作为函数对象来生成值:

std::ranges::generate(deck, []() { return Card{value, suit}; });

我们希望循环遍历 1 到 13 的值,每个花色中有一个相同的值。我们注意到枚举没有operator++操作符,因为这可能会使用一个无效的枚举值;因此,我们在列表 5.17 中使用初始化列表来遍历每个枚举器。让我们考虑一个替代方案,并了解一些关于作用域枚举的更多信息。在我们的情况下,枚举值是连续的,实际上,当我们到达最后一副牌时,我们可以从开始处重新开始,这样我们就可以使用 104 张牌的数组来得到两副牌,如果我们想的话。我们可以使用static_cast将枚举值转换为int,因为我们注意到作用域枚举有一个底层类型,默认情况下将是int。我们这样声明我们的enum

enum class Suit

在列表 5.4 中。我们也可以指定一个类型;例如:

enum class Suit: short

如果我们不需要整数,这可能会节省一点空间,如果我们有非常少的值,我们甚至可以使用char。或者,如果我们需要一个非常长的枚举值列表,我们可以使用long long。而不是将类型强制转换为int,在一般情况下,我们可以使用underlying_type来决定转换为什么类型。然后我们可以选择下一个花色,并在达到末尾时回到起点。

列表 5.18 递增我们的枚举

Suit& operator++(Suit & suit)
{
    using IntType = typename std::underlying_type<Suit>::type;       ❶
    if (suit == Suit::Spades)
        suit = Suit::Hearts;                                         ❷
    else
        suit = static_cast<Suit>(static_cast<IntType>(suit) + 1);    ❸
    return suit;
}

❶ 底层枚举类型

❷ 返回第一副牌

❸ 使用强制转换进行递增

这段代码依赖于连续的枚举值,改变枚举值的顺序会破坏代码。然而,了解作用域枚举的underlying_type是值得注意的。

与我们所有牌的代码一样,我们将函数放在扑克牌源文件中,并在头文件中声明它。我们现在可以生成我们 array 所需的值。无论我们使用 generate 的范围版本还是 begin/end 版本,我们都需要包含 algorithm 头文件。我们从一张牌的值为一开始,为每张生成的牌递增。如果值大于 13,我们将回到一,并递增花色。所有这些都在 lambda 中发生,所以我们通过引用捕获值和花色,使用 [&value, &suit]generate 函数对牌堆中的每个项目调用 lambda 一次,将生成的牌分配给每个元素。

列表 5.19 生成牌组

#include <algorithm>
std::array<Card, 52> create_deck()
{
    std::array<Card, 52> deck;
    int value = 1;                                     ❶
    Suit suit = Suit::Hearts;                          ❶
    std::ranges::generate(deck, [&value, &suit]() {    ❷
        if (value > 13)
        {
           value = 1;                                  ❸
            ++suit;                                    ❸
        }
        return Card{FaceValue(value++), suit};         ❹
    });
    return deck;
}

❶ 以红桃 A 开始

❷ 通过引用捕获

❸ 重置值并递增花色

❹ Lambda 返回一个 Card 并递增值

我们有一副完整的扑克牌,所以我们几乎准备好构建我们的游戏。首先,我们需要能够比较两张牌,以决定一张牌是否比另一张牌高或低。

5.1.7 比较运算符和默认值

对于一个类型,有六种可能的比较:

  • 等于 (==)

  • 不等 (!= )

  • 小于 (<)

  • 大于 (>)

  • 小于或等于 (<=)

  • 大于或等于 (>=)

C++ 已经允许我们长时间编写自己的比较运算符。例如,我们可以在类定义中内联实现一个小于运算符。

列表 5.20 Card 的小于运算符

bool operator<(const Card& other) const
{
    return value < other.value.value() && suit < other.suit;
}

然后,我们可以比较两张牌:

Card{FaceValue(2), Hearts} < Card{FaceValue(3), Hearts}

是否将花色包含在比较中可能是一个讨论点,因为一些纸牌游戏将一个花色视为比另一个花色更有价值。更重要的是,我们预计大于或等于 (operator >=) 将返回相反的结果。然而

Card{FaceValue(2), Hearts} >= Card{FaceValue(3), Hearts}

无法编译。如果我们编写一个小于运算符,其他比较就不会为我们生成。我们可以自己编写所有比较运算符,但这既繁琐又容易出错。C++20 引入了 operator<=>,有时称为 spaceship operator,因为它看起来有点像一艘宇宙飞船,这使得我们的工作更简单。这个宇宙飞船运算符给出三种可能值之一,因此也被称为 三路比较运算符

  • x <=> y < 0 如果 x 小于 y

  • x <=> y > 0 如果 x 大于 y

  • x <=> y == 0 如果 x 等于 y

返回类型是一个顺序类别类型。详细内容涉及较多,但对于整型,例如int或我们的Suit枚举,我们会得到一个std::strong_ordering返回值,它在compare头文件中定义(见mng.bz/p1D0)。我们可以使用关键字auto,而不是查找我们需要使用哪个特定的返回类型。这个结果可以自动转换成六个双向比较运算符之一。现在,我们既可以自己实现飞船运算符,也可以用关键字default来标记它。如果我们这样做,编译器会为我们生成所有的比较。默认比较运算符将使用类中按顺序定义的字段,因此值和花色都会被比较。因此,字段需要是可比较的,所以我们的FaceValue也需要一个飞船运算符。默认版本将能够比较两个FaceValue的值,使用的是value_成员,这正是我们所需要的。

我们首先需要添加compare头文件,它会计算出返回类型并为我们合成比较运算符。然后我们在FaceValueCard的定义中添加一行,最后我们就有了所需的内容。

列表 5.21 默认的三向比较运算符

#include <compare>

namespace cards
{
    ...
    class FaceValue
    {
    public:
        ...
        auto operator<=>(const FaceValue&) const = default;   ❶
    private:
        int value_;                                           ❷
    };

    class Card
    {
    public:
        ....
        auto operator<=>(const Card&) = default;              ❸
    private:
        FaceValue value_{1};                                  ❹
        Suit suit_{};                                         ❹
    };
};

❶ 生成默认比较

❷ 用于比较的值

❸ 生成默认比较

❹ 用于比较的值和花色

为我们的两种类型添加六个比较运算符几乎不费吹灰之力。因为我们把 1 用作 A,这个默认运算符意味着 A 是最小的牌。我们也可以自己编写比较,或者使用 2 到 14 的值,使 14 为 A,因此是价值最高的牌。你可以自由地这样做以进行额外的练习。有了牌堆和比较牌的方法,我们现在可以创建一个高或低牌局游戏。

5.2 高或低牌局游戏

当我们创建我们的牌堆时,牌是按顺序排列的,因此我们可以计算出下一张是什么。随机化顺序会使游戏更有趣,因此我们需要一种洗牌的方法。

5.2.1 洗牌

我们之前已经使用过随机数;然而,我们现在需要一个随机洗牌,而不是随机数的序列。algorithm 头文件中有我们需要的函数。如果我们查看 CppReference (mng.bz/eEjZ),我们会看到 random_shuffleshuffle 方法。每个 random_shuffle 版本都已弃用或删除。一个版本使用了 C 的 rand 函数,这可能在某个时候被弃用。我们已经看到 C++ 随机数生成器有多好。使用 rand 可能依赖于全局状态,这会导致多线程代码出现问题。一些简单的 random_shuffle 实现也使用了 rand() % i 来为索引 i 交换元素。每次我们使用随机数的模运算时,我们都有偏斜分布的风险。Stephan Lavavej 在 2013 年发表了一个名为“rand() Considered Harmful”的演讲(见 mng.bz/g7Dn),解释了为什么我们应该避免与 % 一起使用 rand。如果我们想模拟掷骰子,使用 rand() % 6 不会给我们一个均匀分布,因为 MAX_INT 不是六的倍数。因此,较小的骰子点数将稍微更有可能。试试看。

避免弃用的洗牌方法,我们只剩下 std::shuffle。这需要一个要洗的元素和一个随机数生成器。我们可以将 beginend 传递给 std::shuffle,或者直接在我们的牌组上使用范围变体,std::ranges::shuffle。我们将使用 random_device 来初始化一个 mt19937 生成器,就像我们之前做的那样。我们需要包含 algorithmrandom 头文件,分别用于 shuffle 和随机生成器。我们需要通过引用传递牌组,这样我们就可以改变它。

列表 5.22 洗牌卡片

#include <algorithm>
#include <random>
void shuffle_deck(std::array<Card, 52> & deck)    ❶
{
    std::random_device rd;
    std::mt19937 gen{ rd() };                     ❷
    std::ranges::shuffle(deck, gen);              ❸
}

❶ 通过引用传递牌组

❷ 初始化随机数生成器

❸ 洗牌

对于需要多次洗牌的纸牌游戏,有一个具有洗牌方法、在构造函数中设置生成器的类是有意义的。尽管如此,列表 5.22 中的简单方法对于我们的高低牌游戏来说是足够的。我们现在有了一种洗牌的方法,所以我们可以构建我们的游戏。

5.2.2 构建游戏

我们将展示牌组中的第一张牌,并询问玩家下一张牌将是更高还是更低,我们将继续进行,直到牌用完或玩家猜错。我们可以使用单个字符,'h' 表示更高或 'l' 表示更低,这样玩家就不需要输入太多:

char c;
std::cin >> c;

我们比较当前牌和下一张牌,依赖于列表 5.21 中给出的默认的三向比较自动生成的 operator<operator> 来判断猜测是否正确。

列表 5.23 检查猜测是否正确

bool is_guess_correct(char guess, const Card & current, const Card & next)
{
    return (guess == 'h' && next > current) 
            || (guess == 'l' && next < current);
}

游戏从牌组中的第一张牌开始。我们可以用各种方式找到数组中的第一张牌,但跟踪正确猜测的数量并在游戏结束时报告这个数字可能是个好主意。我们可以使用这个计数来索引数组,就像使用 C 风格数组一样,索引将告诉我们我们已通过牌组的程度。我们将遍历牌组中的所有牌,但如果猜错则停止。将这些组合在一起就得到了我们的高低牌游戏函数。

列表 5.24 高低牌游戏

void higher_lower()
{
    auto deck = create_deck();
    shuffle_deck(deck);

    size_t index = 0;
    while (index + 1 < deck.size())                                   ❶
    {
        std::cout << deck[index]                                      ❷
            << ": Next card higher (h) or lower (l)?\n>";
        char c;
        std::cin >> c;                                                ❸
        bool ok = guess_correct(c, deck[index], deck[index + 1]);     ❹
        if (!ok)
        {
            std::cout << "Next card was " << deck[index + 1] << '\n';
            break;                                                    ❺
        }
        ++index;
    }
    std::cout << "You got " << index << " correct\n";                 ❻
}

❶ 在剩余的 51 张牌周围循环

❷ 显示当前牌

❸ 高或低

❹ 检查猜测

❺ 如果猜错则退出循环

❻ 显示正确数量

再次,我们将在扑克牌源文件中定义它,并在我们的头文件中声明它。然后我们从main函数中调用它。

列表 5.25 我们的游戏

#include "playing_cards.h"
int main()
{
    cards::higher_lower();
}

不要忘记,A 牌是最低的牌面,花色也有顺序。正确地拿到一手牌是困难的。一场典型的游戏可能如下进行:

9 of Spades: Next card higher (h) or lower (l)?
>l
4 of Hearts: Next card higher (h) or lower (l)?
>h
Next card was Ace of Hearts
You got 1 correct

我们有一个工作的牌局。我们创建了一个简单的结构并在数组中使用它。我们让 C++为我们做大部分工作,生成我们需要的比较来确定一张牌是高还是低。我们可以在这里停止,但一些牌局也使用JokerJoker没有花色或牌面值,所以我们如何将Joker添加到我们的牌组中?

5.2.3 使用std::variant支持卡片或Joker

定义一个Joker的最简单方法是一个空的struct

列表 5.26 一个Joker

struct Joker
{
};

那就是我们所需要的。

我们知道如何制作一副 52 张的扑克牌:

std::array<Card, 52> cards = create_deck();

我们如何添加两个Joker?我们不能将Joker添加到这张牌组中,因为它们是不同类型。我们可以创建一个公共基类型并使用指针来实现动态多态,但这似乎过于复杂。一个更简单的方法是使用一个包含两种类型之一(卡片或Joker)的数组。C++17 中引入的std::variant使得这成为可能。它位于variant头文件中,其行为类似于union,但更安全。C 的union类型有一系列可能的成员。

列表 5.27 一个联合体

union CardOrJoker
{ 
    Card card;
    Joker joker;
};

联合体足够大,可以容纳使用的最大类型。要从这个联合体中访问一个Card,你使用card成员,对于Joker,使用joker成员,但你需要跟踪正在使用哪种类型。相比之下,variant知道它当前持有哪种类型,因此variant通常被描述为一种类型安全的联合体

我们通过声明它可以持有的类型来声明一个variant

std::variant<Card, Joker>

variant是一个定义为可变参数模板的类模板。我们将在最后一章更详细地探讨这些内容,但就现在而言,请注意定义中的三个点:

template <class... Types>
class variant;

这些点被称为参数包,允许我们使用零个或多个模板参数。这使我们能够定义一个包含所需两种类型的variant。我们在第三章使用了std::optional来处理输入,它只需要一种类型。声明一个未赋值的optional

std::optional<Card> card; 

没有值。如果我们在这个布尔上下文中使用这张牌,它将评估为 false,所以我们可以使一个 optional 工作,但代码可能难以理解。我们需要记住 if(!card) 意味着我们有一个 Jokers。那么我们如何使用 variant 呢?

一个 variant 被初始化为第一个可选类型,前提是这个类型可以被默认构造。如果它不能,我们会得到一个编译错误。我们的两种类型都可以被默认构造,所以这里不会发生这种情况。所以使用

std::variant<Card, Joker> card;

默认构造了一个 Card,因为那是第一个类型。我们也可以创建一个 Joker

std::variant<Card, Joker> joker{ Joker{} };

实际上,有各种创建 variant 的方法。我们可以避免使用临时 Joker{} 来构造 variant,使用 std::in_place_index 函数。对于一个 Joker,我们想要索引 1,并且没有为 joker 的构造函数提供任何参数,所以我们将使用 std::in_place_index 并设置值为 1:

std::variant<Card, Joker> joker2(std::in_place_index<1>);

对于 Card,我们使用零索引并将值和花色传递给 Card 构造函数:

std::variant<Card, Joker> two_of_clubs(std::in_place_index<0>,
                                       FaceValue(2), Suit::Clubs); 

更多细节请见 mng.bz/amzY

我们可以通过检查 variant 的类型来确定我们是否有 Jokers:

bool is_joker = std::holds_alternative<Joker>(two_of_clubs);

有各种方法可以检索值。例如,我们可以使用带索引的 get

Card from_variant = std::get<0>(two_of_clubs);

如果我们尝试获取一个 Joker

Joker from_variant = std::get<1>(two_of_clubs);

会抛出 std::bad_variant_access。或者,我们可以使用 get_if 来避免异常。而不是索引,我们可以使用类型,std::get<Card>(two_of_clubs),这样可以节省记住类型顺序的麻烦。CppReference 提供了所有细节(en.cppreference.com/w/cpp/utility/variant),但我们现在已经足够了解如何制作带有 Jokers 的牌组。

我们已经使用了 optional 并遇到了 variant。还有一种类型,称为 std::any,它位于 any 头文件中。这三种类型都是在 C++17 中引入的,为类似问题提供了一些不同的替代方案。正如其名所示,我们可以几乎用 any 做任何事情,特别是任何可复制的类型。any 变量可以根据需要转换为其他类型:

std::any some_card = Joker();
some_card = Card{ 2, Suit::Club };

我们需要使用 any_cast 方法来获取值。如果我们有一个 Card 而不是 Joker,调用

std::any_cast<Joker>(some_card);

将抛出 std::bad_any_cast

因此,我们可以使用 any;然而,使用 variant 更清晰,因为我们要么有一个 Card,要么有一个 Joker。我们甚至可以使用 optional,用一个没有值的变量来表示 Joker,但使用 variant 的意图更清晰。

5.2.4 使用扩展牌组构建游戏

让我们制作一个扩展牌组。首先,我们需要向牌组中添加 Jokers。我们可以用很多种方法来做这件事。我们遇到了 array 并注意到我们可以使用聚合初始化来初始化一些或所有元素。因此,我们可以这样制作前两个元素为 Joker

std::array<std::variant<Card, Joker>, 54> deck{ Joker{} , Joker{} };

我们也可以制作像之前一样的 52 张普通牌:

std::array<Card, 52> cards = create_deck(); 

如果我们复制这 52 张牌,我们将得到一张带有两张小丑牌的牌组。我们在第二章中已经使用了copycopy有几个变体,它们都位于algorithm头文件中。在第二章中,我们遇到了ranges::copy版本。牌组开始处有两张小丑牌,因此我们想要复制两张小丑牌之后的牌。因此,我们需要从begin + 2 开始复制,如图 5.2 所示。

CH05_F02_Buontempo

图 5.2 在我们的数组开始处有两张小丑牌,我们将牌复制到begin + 2

在代码中,我们编写

std::ranges::copy(cards, deck.begin() + 2);

我们可以使用std::copy代替,使用beginend成员函数:

std::copy(cards.begin(), cards.end(),deck.begin() + 2);

我们甚至可以使用beginend自由函数:

std::copy(std::begin(cards), std::end(cards), std::begin(new_deck)+2);

一些东西,比如 C 风格的数组,可以迭代但没有beginend方法,在这种情况下可以使用这些自由函数。如果我们使用自由函数而成员函数可用,它们会为我们调用成员函数,所以在这种情况下对我们没有影响。

我们需要在我们的头文件中包含variant头并声明该函数。使用ranges版本,我们可以在玩牌源文件中创建一个扩展牌组。

列表 5.28 创建扩展牌组

std::array<std::variant<Card, Joker>, 54> create_extended_deck()
{
    std::array<std::variant<Card, Joker>, 54> deck{Joker{}, Joker{}};    ❶
    std::array<Card, 52> cards = create_deck();
    std::ranges::copy(cards, deck.begin() + 2);                          ❷
    return deck;
}

❶ 从两张小丑牌开始

❷ 在两张小丑牌之后复制一张普通牌

我们需要洗扩展牌组的牌。我们的原始函数适用于 52 张牌的数组。我们现在有一个包含Joker或牌的变体数组的数组,因此我们可以在我们的头文件中声明一个重载函数:

void shuffle_deck(std::array<std::variant<Card, Joker>, 54>& deck);

然后,我们可以定义新的函数。

列表 5.29 洗扩展牌组

void shuffle_deck(std::array<std::variant<Card, Joker>, 54>& deck)
{
    std::random_device rd;
    std::mt19937 gen{ rd() };
    std::ranges::shuffle(deck, gen);
}

与列表 5.22 中的上一个版本相比,这个洗牌的唯一区别是牌组的类型。我们可以编写一个函数模板来避免重复。试试看!

为了使我们的更高或更低牌游戏与扩展牌组一起工作,我们需要进行两个补充。首先,我们需要决定是否包含Joker的猜测是正确的。如果我们说如果任一牌是小丑牌,猜测就是正确的,那么玩家实际上得到了一个免费回合。我们将使用std::holds_alternative<Joker>函数来查看我们是否有小丑牌,并在那种情况下返回true。否则,我们有两张非小丑牌,因此我们可以调用我们的原始函数,使用std::get<Card>从变体中获取牌。

列表 5.30 检查扩展牌组的猜测是否正确

bool is_guess_correct(char c,
    const std::variant<Card, Joker>& current,
    const std::variant<Card, Joker>& next)
{
    if (std::holds_alternative<Joker>(current) ||
        std::holds_alternative<Joker>(next))
        return true;                                       ❶
    Card current_card = std::get<Card>(current);           ❷
    Card next_card = std::get<Card>(next);                 ❷
    return is_guess_correct(c, current_card, next_card);   ❸
}

❶ 如果任一牌是小丑牌则返回 true

❷ 从变体中获取牌

❸ 否则调用原始函数

我们可能需要显示小丑牌,因此我们需要为我们的变体重载流插入运算符。同样,我们使用holds_alternative来查看我们是否有小丑牌,在这种情况下,我们将"JOKER"发送到流;否则,我们调用我们的原始函数。

列表 5.31 流式输出牌和小丑牌

std::ostream& operator<<(std::ostream& os, const std::variant<Card, Joker>& card)
{
    if (std::holds_alternative<Joker>(card))    ❶
        os << "JOKER";
    else
        os << std::get<Card>(card);             ❷
    return os;
}

❶ 一张小丑牌

❷ 流式传输牌

现在我们可以使用扩展牌组编写一个新的游戏。代码与列表 5.24 中的原始游戏相同,只是扩展牌组的创建不同。

列表 5.32 带有鬼牌的高低牌游戏

void higher_lower_with_jokers()
{
    auto deck = create_extended_deck();    ❶
    shuffle_deck(deck);

    size_t index = 0;
    while (index + 1 < deck.size())
    {
        std::cout << deck[index]
            << ": Next card higher (h) or lower (l)?\n>";
        char c;
        std::cin >> c;
        bool ok = is_guess_correct(c, deck[index], deck[index + 1]);
        if (!ok)
        {
           std::cout << "Next card was " << deck[index + 1] << '\n';
           break;
        }
        ++index;
    }
    std::cout << "You got " << index << " correct\n";
}

❶ 创建一个带有鬼牌的牌组

我们相对不太可能得到鬼牌,但它可能发生。一个典型的游戏可能看起来像这样:

8 of Hearts: Next card higher (h) or lower (l)?
>l
3 of Hearts: Next card higher (h) or lower (l)?
>h
5 of Hearts: Next card higher (h) or lower (l)?
>h
5 of Diamonds: Next card higher (h) or lower (l)?
>h
Next card was Ace of Clubs
You got 3 correct

我们已经构建了自己的类型等等。然而,我们还没有尝试面向对象编程。在下一章中,我们将编写另一个类并提供虚函数来学习更多关于类的内容。

摘要

  • 头文件需要一个包含保护,并且 pragma 指令 once 现在得到了广泛的支持。

  • 优先使用作用域枚举而不是 C 风格枚举。

  • 某些函数可以被标记为默认或删除。

  • string 头文件为数值提供了 to_string 方法。

  • 当大小在编译时已知时,使用 std::array 作为容器。

  • 三元比较运算符(operator <=>)在 C++20 中被引入,并可以标记为 default,为我们生成比较。

  • 使用 std::shuffle 来洗牌一个集合,传递一个适当种子的随机数生成器。

  • 如果一个对象是有限数量的不相关类型之一,请使用 std::variant

  • 如果需要任何可能的可复制构造类型,请使用 std::any

  • 许多容器都有 beginend 成员函数,但它们也可以作为自由函数提供,以供更广泛的使用。

6 智能指针和多态

本章涵盖

  • 使用继承实现动态多态

  • 特殊成员函数

  • 类型特性

  • 使用智能指针

  • 随机数分布

在本章中,我们将再次使用类,但这次使用继承。我们将创建各种"Blob"类。我们的粘液块将能够向前和向后移动。如果我们把粘液块排成一行在虚拟纸袋的底部,我们可以让它们开始赛跑,看看哪个粘液块首先逃离纸袋。除了在类上进行练习之外,我们还可以声称并进一步证明我们可以通过编码从纸袋中逃脱,这是所有程序员都应该追求的技能。

我们将从简单的类层次结构开始,创建一个一次迈一步的粘液块。我们将考虑在继承时需要哪些特殊成员函数,并使用类型特性来查询各种成员函数。我们将再次使用随机数,使用各种分布来决定粘液块迈多大的一步。这种随机性将使比赛更加刺激。通过将粘液块存储在智能指针中,我们可以在vector中保持各种类型的粘液块。它们的行为将根据粘液块类型而变化,给我们提供动态多态性。然后它们可以赛跑,我们可以坐下来观看,并为我们通过编码从纸袋中逃脱而自我祝贺。

6.1 类层次结构

我们将使用星号*来表示粘液块,并留下星号轨迹以显示路径。我们可以用|字符表示袋子的侧面,用-字符表示底部。所有粘液块都将从袋子的底部开始,然后一次移动一步或更多。我们可能会看到类似这样的情况:

  *        
| *     * |
| *   * * |
| * * * * |
-----------

为了将纸袋中的粘液块赛跑,我们需要定义一个Blob类型。我们可以给每个粘液块一个xy坐标,但我们将不改变x,因此只需要跟踪y。如果我们将粘液块存储在vector中,我们可以使用向量的索引来指示x坐标。对于我们的第一次赛跑,我们将有一种类型的粘液块,它总是以相同的量向前移动。稍后,我们可以添加第二种类型的Blob,它将采取随机的一步以增加一些变化。

6.1.1 抽象基类

我们将为我们的 blob 创建一个基类,稍后创建派生类。我们知道每个 blob 都需要移动一步,所以我们需要一个 step 函数。我们还想知道总共移动了多少步,这样我们就可以显示正确的星号数量。step 函数会改变实例,增加总步数,但 total_steps 函数不会改变实例,所以后者可以被标记为 const。因此,我们需要两个成员函数,但保持它们为抽象的,通过在声明后使用 = 0 来表示。派生类可以实现它们自己的这些函数版本,从而给我们提供所需的多态性。这两个抽象函数都需要标记为 virtual。虚方法通过一个称为 v-table 的虚函数指针表来实现。当我们通过指针或引用调用虚方法时,v-table 被用来查找要调用的重写的虚函数。因此,虚方法允许我们创建具有不同 step 函数的不同派生类。抽象基类 (ABC) 可以存在于一个名为 Race.h 的头文件中,位于一个名为 Race 的命名空间内。这个类不需要太多的代码。

列表 6.1 基类的一个初步尝试

namespace Race
{
    class Blob
    {
    public:
        virtual void step() = 0;               ❶
        virtual int total_steps() const = 0;   ❷
    };
}

❶ 执行一步的抽象函数

❷ 返回总步数的抽象函数

我们不能创建这个 Blob 的实例,因为它有抽象函数。如果我们编写一些派生类,实现这两个抽象函数,我们可以创建各种 blob 并进行比赛。然而,我们没有为我们的基类声明析构函数。实际上,我们还没有添加任何特殊成员函数:

  • 默认构造函数X()

  • 拷贝构造函数X(const X&)

  • 拷贝赋值运算符operator = (const X&)

  • 移动构造函数X(X&&)

  • 移动赋值运算符operator = (X&&)

  • 析构函数~X()

这意味着所有六个函数都被默认定义,因为我们没有定义任何一个。不写代码并因此接受六个默认值通常被称为 零规则。这在许多情况下都是完美的。核心指南甚至告诉我们,如果可能的话,避免定义默认值 (mng.bz/M9Km);毕竟,代码越少通常意味着错误越少。尽管如此,有时我们确实需要编写一些代码。如果我们正在管理内存或资源句柄,我们需要确保发生正确的事情;否则,我们可能会得到内存泄漏。在我们的类中,我们并没有管理内存或句柄,但我们需要 多态性,因此,我们确实有一个问题。考虑当我们有一个派生类时会发生什么。

我们的 Blob 包含所有六个特殊成员函数的默认实现,包括析构函数。任何派生类都会自动调用基类的析构函数。这始终是 C++ 的情况。我们需要派生类,填充两个抽象函数的实现,这样我们就可以进行 blob 竞赛。如果我们使用各种 blob 类型的指针或引用,我们可以调用虚拟方法,并且派生类的重写将被使用,允许不同的 step 实现。我们可以定义一个 派生 类并将派生实例赋值给 类的指针:

Blob * blob = new DerivedBlob();

我们可以称之为

blob->step();

所采取的步骤将取决于 blob 的类型,因为 step 函数是 虚拟 的。然而,当我们完成时,如果我们调用

delete blob;

只有 Blob 的析构函数被调用,而不是 DerivedBlob 的。这是在找麻烦。如果我们没有虚拟析构函数就进行多态删除,会有未定义的行为。我们需要每个析构函数都被调用。如果我们创建了一个 DerivedBlob 指针而不是

DerivedBlob * blob = new DerivedBlob();

这两个析构函数都会被调用。这次,blobDerivedBlob,所以调用的是 DerivedBlob 的析构函数,然后是基类的析构函数。默认的析构函数不是虚拟的,所以它不在虚函数表中,如图 6.1 所示。

CH06_F01_Buontempo

图 6.1 虚函数在虚函数表中,但不是析构函数:Base 指针调用 Base 析构函数,而 Derived 指针调用 Derived 析构函数,但 Base 指向 Derived 类的指针做了错误的事情。

我们希望能够使用指向基类 Blob 的指针,这样我们就可以有各种派生类来制造一个有趣的竞争。实际上,我们不会使用原始指针;我们将找出如何变得更聪明。无论如何,我们需要一个小改动来解决问题。如果我们把析构函数标记为 virtual,它就会进入虚函数表,并且对于 Base 指针会调用正确的析构函数。当我们进行这个改动时,我们可以将拷贝构造函数和赋值操作标记为已删除,因为我们不需要复制 blob,我们还将添加一个默认构造函数,因为基类需要一个合适的构造函数。

列表 6.2 一个更好的基类

#pragma once

namespace Race
{
    class Blob
    {
    public:
        virtual ~Blob() = default;                ❶
        Blob() = default;                         ❷
        Blob(Blob const&) = delete;               ❸
        Blob& operator=(Blob const&) = delete;    ❸

        virtual void step() = 0;
        virtual int total_steps() const = 0;
    };
}

❶ 虚拟默认析构函数

❷ 默认构造函数

❸ 删除不必要的拷贝

我们现在可以安全地创建一个从 Blob 派生的具体类。

6.1.2 一个具体类

让我们创建一种新的 Blob 类型,每次移动时都会前进两步。这种新类型可以从 Blob 公开派生,因此基类的公共方法仍然是公共的,受保护的成员仍然是受保护的,但基类中的任何私有成员对派生类都是不可访问的。为了提醒如何使用公共、受保护和私有访问修饰符,请参阅 CppReference (en.cppreference.com/w/cpp/language/access)。我们有三个公共方法,所以这些仍然是公共的,我们没有其他需要考虑的。

我们需要实现抽象函数来创建一个具体类。由于这两个函数都只需要一行代码,我们可以在类中内定义这些函数。对于更大的函数,我们会使用单独的源文件并将定义放在那里。

要实现函数,我们通过编写一个与确切签名完全相同的函数来重写它们。基类的成员函数必须是虚拟的;否则,我们是在隐藏而不是重写一个函数。这在 C++中始终如此。如果你忘记了细节,请参阅斯科特·梅耶斯(Scott Meyers)的书籍《Effective C++》(Addison-Wesley Professional,2005;第 3 版)中的项目 33:避免在 Scott Meyers 的《Effective C++》中隐藏继承的名称。C++11 引入了override指定符,我们可以将其添加到成员函数声明的末尾,以明确表示我们正在重写一个函数。这意味着编译器可以告诉我们是否未能正确编写签名。例如,很容易忘记单词const并最终得到两个不同的函数。如果我们不希望任何进一步的派生类重写虚拟方法,我们还可以使用关键字final

steptotal_steps函数都使用到目前为止的步数。前者将添加到总步数,后者将报告总步数。我们的新类型 blob 每次需要走两步,因此它需要将总步数增加两步。我们可以在一个名为yint中记住到目前为止所走的步数,表示 blob 的y坐标。其他任何东西都不应该使用这个变量,所以我们将其设置为私有。我们的步进类放在命名空间内的Race头文件中,并内联实现两个虚拟函数。

列表 6.3 一个走固定大小步长的 blob

class StepperBlob : public Blob 
{ 
    int y = 0;                         ❶
public: 
    void step() override               ❷
    { 
        y += 2; 
    }
    int total_steps() const override   ❸
    {
        return y;
    } 
}; 

❶ 用于跟踪步骤的私有整型变量

❷ 向前两步

❸ 到目前为止的总步数

现在我们可以创建一个步进 blob:

Race::StepperBlob blob;

实际上,我们可以添加一个类似于我们之前所做过的check_properties函数,并使用asserts来检查我们的代码做了什么。如果我们将其放在main.cpp中,我们可以检查一步是否使我们向前移动两步。

列表 6.4 检查步数是否使 blob 向前移动

#include <cassert>
#include "Race.h"

void check_properties()
{
    Race::StepperBlob blob;
    blob.step();
    assert(blob.total_steps() == 2);
}

int main()
{
    check_properties();
}

现在我们可以构建一场赛跑。我们只有一个具体的 blob 类型,因此我们事先知道不会有赢家或输家,但这给我们提供了一个简单的热身赛跑。我们需要决定如何表示 blob 以及如何绘制袋子。

6.1.3 为赛跑做准备

我们可以在一个虚拟的纸袋中放几个StepperBlobs并让它们在屏幕上行走。我们将在介绍新类型的 blob 时使用抽象基类。对于本节,我们将专注于在赛跑中表示StepperBlobs。如果我们使用合适的图形库,我们可以构建一个壮观的显示;然而,我们还有更多关于 C++要学习。如果你想尝试,SFML(Simple and Fast Multimedia Library;见www.sfml-dev.org/index.php)相对容易上手。在这里,我们将坚持使用控制台。

首先,我们需要一个纸袋。我们决定使用|'-'来表示边缘,并用*的轨迹来表示块。

  *        
| *     * |
| *   * * |
| * * * * |
-----------

我们将使袋子高度为三行,并使用每行的字符串来构建显示。每行以一个'|'和一个空格开始,如果我们位于袋子之上,则为两个空格,然后是每个块两个空格或" *",最后是一个'|'。最后一行由表示袋子底部的'-'字符组成。

让我们编写一个绘图函数,接受StepperBlobsvector,这样我们就可以绘制块和纸袋。我们可以在一个向量中放置四个这样的块

std::vector<Race::StepperBlob> blobs(4); 

并将此传递给函数,使我们能够改变块的数量。如果我们想的话,可以使用std::array,但std::vector意味着我们可以在运行时改变数量。当我们遍历每一行时,我们可以将当前的y坐标与每个块的总步数进行比较。如果y位置高于一个块,我们显示两个空格;否则,我们使用一个空格然后是一个*。我们使steps函数为常量,因为它不会改变块,所以我们可以将vector的常量引用传递给绘图函数。我们将新函数放在Race.cpp文件中,并记得在头文件中添加声明。我们还需要iostreamstring头文件。

列表 6.5 绘制每个块当前的位置

#include <iostream>
#include <string>

#include "Race.h"

void Race::draw_blobs(const
    std::vector<Race::StepperBlob> & blobs)
{
    const int bag_height = 3;
    const int race_height = 8;
    for (int y = race_height; y >= 0; --y)
    {
        std::string output =
            y >= bag_height ? "  " : "| ";          ❶
        for (const auto& blob : blobs)
        {
            if (blob.total_steps() >= y)
            {
                output += "* ";                     ❷
            }
            else
            {
                output += "  ";                     ❸
            }
        }        
        output += y >= bag_height ? ' ' : '|';      ❹
        std::cout << output << '\n';
    }
    const int edges = 3;
    std::cout <<
        std::string(blobs.size() * 2 + edges, '-')
        << '\n';                                    ❺
}

❶ 袋子的左侧

❷ 带有轨迹的块

❸ 这里没有块

❹ 袋子的右侧

❺ 袋子的底部

我们想让块移动,所以我们需要另一个函数,move_blobs。因为块在移动时改变状态,所以我们通过非常量引用传递我们的向量。同样,我们在这里将使用StepperBlob并构建到下一节中使用不同类型的Blob。我们需要将签名添加到头文件中,并包含vector头文件。

列表 6.6 头文件中的声明

#include <vector>

namespace Race
{
...                                                           ❶

    void move_blobs(std::vector<Race::StepperBlob>& blobs);   ❷
    void draw_blobs(
        const std::vector<Race::StepperBlob>& blobs
    );                                                        ❷
}

❶ 块和步进器与之前相同

❷ 函数签名

然后,我们在Race.cpp文件中定义该函数。我们可以使用基于范围的for循环让每个块迈一步。

列表 6.7 移动所有块

void Race::move_blobs(
    std::vector<Race::StepperBlob> & blobs
)                               ❶
{
    for (auto& blob : blobs)    ❷
    {
        blob.step();            ❸
    }
}

❶ 通过引用传递

❷ 对每个块的引用

❸ 使块移动

如果我们在循环中调用这些函数,我们将有一个比赛。我们可以在更新之间清除屏幕并稍微暂停一下。我们之前在列表 4.11 的倒计时中使用了线程的sleep_for,所以暂停是直接的。如果我们包含thread头文件,我们可以使用chrono文字(如1000ms)来暂停:

using namespace std::chrono;
std::this_thread::sleep_for(1000ms);

现在,在 C++中没有一种平台无关的方法来清除屏幕。有时 C++用于没有屏幕的嵌入式设备,这很有意义。然而,使用特定的控制字符通常在 Linux、Windows 或 macOS 上有效:

std::cout << "\x1B[2J\x1B[H";

\x1B引入了一个控制字符,[2J清除屏幕,[H将光标移动到左上角。如果它在您的设置上不起作用,只需打印一个换行符'\n',您将得到屏幕上显示的每一帧。同样,我们添加了签名

void race(std::vector<Race::StepperBlob>& blobs);

将实现放入源文件。比赛会调用我们的 draw_blobsmove_blobs 函数一段时间,并在每次调用之间暂停和清屏。

列表 6.8 一个有点可预测的比赛

#include <thread>
... 
void Race::race(std::vector<Race::StepperBlob>& blobs)
{
    using namespace std::chrono;
    const int max = 3;
    std::cout << "\x1B[2J\x1B[H";              ❶
    for (int i = 0; i < max; ++i)
    {
        draw_blobs(blobs);
        move_blobs(blobs);
        std::this_thread::sleep_for(1000ms);   ❷
        std::cout << "\x1B[2J\x1B[H";          ❶
    }
    draw_blobs(blobs);
}

❶ 如果需要,清除屏幕或更改到 '\n'

❷ 暂停

我们需要从 main 函数中调用这个操作,并使用 Blob 的 vector。别忘了在你的构建中包含 Race.cpp

列表 6.9 一个热身比赛

#include "Race.h"
int main()
{
    check_properties();
    std::vector<Race::StepperBlob> blobs(4);
    Race::race(blobs);
}

当我们运行这个程序时,Blob 会同步移动,因此它们会一起逃逸。

|


               &#124;         &#124;
               &#124;         &#124;
               &#124; * * * * &#124;
               -----------

|


               &#124; * * * * &#124;
               &#124; * * * * &#124;
               &#124; * * * * &#124;
               -----------

|


                 * * * *
                 * * * *
               &#124; * * * * &#124;
               &#124; * * * * &#124;
               &#124; * * * * &#124;
               -----------

|


                 * * * *
                 * * * *
                 * * * *
                 * * * *
               &#124; * * * * &#124;
               &#124; * * * * &#124;
               &#124; * * * * &#124;
               -----------

|

这不是一个真正的比赛,而是一个简单的演示,说明如何用代码从纸袋中走出来。我们几乎准备好制作不同类型的 Blob。在我们这样做之前,让我们再思考一下类可能拥有的六个特殊成员函数。暂停以检查哪些函数存在和不存在将有助于在我们的脑海中巩固这六个特殊函数。

6.1.4 使用类型特性检查特殊成员函数

在 C++11 之前,我们有“三法则”:为一个类定义析构函数、拷贝构造函数或拷贝赋值运算符几乎肯定需要定义所有三个。如果这些函数中的任何一个需要执行特殊操作,比如克隆资源,其他函数也需要执行适当的操作。自 C++11 以来,我们必须考虑移动构造和移动赋值,这导致了所谓的“五法则”。实际上,我们注意到一个类可以有六个可能的特殊成员函数。如果我们没有定义任何一个,编译器将生成所有六个操作。我们在基类中添加了一个虚析构函数,以允许多态使用。那么,我们是否知道其他哪些函数仍然为我们隐式定义了?我们是否关心?也许吧。如果一个类型不支持移动操作,可能会错过优化机会。记住,我们在第二章中考虑了 vector 的两个 push_back 版本:

constexpr void push_back( const T& value );
constexpr void push_back( T&& value ); 

第一个版本在向量的末尾复制值,而第二个版本通过移动值避免了复制。如果类型不支持移动,将使用第一个版本。

我们的 Blob 类是抽象的,所以我们不能创建一个实例并尝试复制或移动它。我们如何测试这样的类?至少,我们需要基类是虚拟析构的并且不能被构造。如果我们使用 类型特性,我们可以尝试找出 Blob 有哪些函数。它们位于 C++11 中引入的 type_traits 头文件中。特性通过模板结构体提供各种操作的可发现性,这些结构体接受一个类型并填充一个名为 value 的布尔成员,告诉我们操作或特性是否受支持(见 http://mng.bz/yZDE)。我们可以查询

std::is_constructible<Blob>::value

并发现 Blob 是不可构造的。我们不必明确写出 value,我们可以使用 _v 代替:

std::is_constructible_v<Blob>

在 C++17 中引入了各种以 _v 结尾的辅助模板,这些模板与 value 成员相等。在任一情况下,我们将我们关心的类型提供给模板,并接收一个布尔值。令人放心的是,Blob 是不可构造的。

事实上,is_constructible 特性可以检查构建对象的各种方式。CppReference(见 mng.bz/XqyM)向我们展示了声明

template<class T, class... Args >
struct is_constructible;

在我们使用 std::variant 的上一章中,我们遇到了三个点或 参数包。我们可以尝试其他类型,看看我们能否从它们中创建一个 Blob。例如,我们可以通过检查是否无法使用 int 构造 Blob 来验证这一点。

std::is_constructible_v<Blob, int>

是错误的。我们还可以使用 is_default_constructible_v 来专门检查默认构造函数。

类型特性不仅涵盖构造。我们在第四章中简要介绍了概念,并考虑使用来自 concepts 头文件的 invocable 来确保模板参数是可调用的或可调用的。type_traits 头文件有一个 is_invocable 特性,用于确定概念是否适用于类型。我们还可以检查其他各种特性。请查看 type_traits 头文件。特性在编译时对类型进行操作,因此它们是 元编程 库的一部分,包括我们在第四章中遇到的 ratio 头文件,以及我们尚未查看的整数序列。

让我们看看我们能否检查基类具有哪些六个成员函数,并确保析构函数也是虚拟的。特性是 constexpr,因此我们可以在我们为列表 6.4 制作的 check_properties 函数中使用 static_assert

列表 6.10 检查特殊成员函数的类型特性

#include <type_traits>
void check_properties()
{
    ...                                               ❶
    static_assert(
        !std::is_default_constructible_v<Race::Blob>
    );                                                ❷
    static_assert(
         std::is_destructible_v<Race::Blob>
    );                                                ❷
    static_assert(
        !std::is_copy_constructible_v<Race::Blob>
    );                                                ❷
    static_assert(
        !std::is_copy_assignable_v<Race::Blob>
    );                                                ❷
    static_assert(
        !std::is_move_constructible_v<Race::Blob>
    );                                                ❷
    static_assert(
        !std::is_move_assignable_v<Race::Blob>
    );                                                ❷
    static_assert(
         std::has_virtual_destructor_v<Race::Blob>
    );                                                ❸
}

❶ 之前的检查

❷ 检查六个成员函数

❸ 检查析构函数是虚拟的

一些值是真实的,而另一些是错误的。我们根本无法通过复制或移动来构建一个 blob,因为 Blob 是一个具有纯虚成员函数的抽象类,但我们确实有一个虚析构函数。然而,移动构造和移动赋值检查可能具有误导性。is_move_constructible 特性告诉我们一个类型是否可以从右值引用中构造。我们的 blob 不可构造,因此根本不能进行移动构造。移动构造特性 不是检查移动构造函数的存在;而是检查类型是否可以从相同类型的右值中构造。is_move_assignable 告诉我们一个类型是否可以从右值中赋值,这可能意味着一个接受 const 指向左值的函数,因为 const 指向左值引用可以绑定到右值。特性 不是检查移动赋值运算符的存在。我们是否有移动特殊函数?

事实上,添加我们自己的析构函数阻止了隐式移动(见mng.bz/QR6e)。类型特性告诉我们是否可以从临时对象或右值赋值,但我们很可能会得到副本而不是移动。因为我们禁用了副本,所以我们不能移动或复制一个 blob。未能提供移动构造函数和移动赋值运算符不是错误,但正如我们在考虑vectorpush_back的两个版本时所指出的,我们错过了一个优化机会。在《有效现代 C++》的第 17 项(O'Reilly Media, Incorporated, 2014)中,Scott Meyers 指出,移动是请求。如果一个类型不是移动启用,任何“移动”实际上都是复制。此外,他告诉我们C++不会为具有用户声明的析构函数的类生成移动操作。最简单的解决方案是使用=default声明移动特殊函数。当我们这样做时,copy函数将被禁用!我们删除了它们,因为我们不需要它们。如果我们认为需要,我们可以声明它们。这通常被称为5 条规则。要么坚持零规则,接受默认值,要么根据您的需求声明或删除所有五个特殊成员以及构造函数。

现在,如果我们删除一个特殊的成员函数,移动赋值和所有其他特殊成员函数都将隐式删除。Peter Sommerlad 建议如果定义了析构函数,则删除移动赋值。他称这种模式为DesDeMovA: 析构 => 删除 移动 赋值(概述见mng.bz/46Eg)。禁用多态类的副本可能是合理的。我们将在本章末尾与其他设计考虑因素一起探讨这一点。请记住,添加析构函数或其他特殊成员函数可能会禁用其他函数,因此如果您需要,可能需要显式添加一个函数。可以使用type_traits来检查您有哪些函数。

这是一个很大的主题。Howard Hinnant 在“你一直想知道的关于移动语义的一切”中讨论了这一点,如果您想了解更多(见www.youtube.com/watch?v=vLinb2fgkHk)。他还说过,“我不遵循‘5 条规则’。毕竟,有 6 个特殊成员。”(见howardhinnant.github.io/classdecl.html。)这篇文章提供了一个整洁的表格,显示了当我们定义其他函数之一时,哪些特殊成员是默认的或被阻止的。他建议在您的类中明确指出您想要和不需要的内容。

我们从 6.2 列表中的Blob类在我们的比赛中工作得很好。我们从一个Blob派生了一个具体的类。我们甚至进行了一次热身赛。让我们再创建一种类型的 blob,这样我们就可以进行一场真正的比赛。

6.2 在向量中使用派生类进行编写和运用

让我们再做一个步进器,每次都采取不同的步数,使用随机数。StepperBlob总是采取两个步骤。创建一个新的Blob类型,平均每步两个步骤,但可能更多或更少,似乎是一个合适的对手。任何一种都有可能获胜。我们可以使用均匀分布来生成一个从04的整数步数,使用

std::uniform_int_distribution distribution{0, 4};

我们需要一个带种子的引擎,我们之前已经见过。我们可以使用random_device作为种子

std::random_device rd;
std::default_random_engine engine{ rd() };

并将引擎传递给分布以获取一个数字:

int step = distribution(engine);

没有种子,引擎将使用默认值,并在每次运行代码时给出相同的序列。default_random_engine通常是 Mersenne Twister,即mt19937。这个类有两个构造函数,我们可以使用它们来给引擎设置种子。我们已经使用了接受单个数字的版本。第二个版本接受一个种子序列,seed_seq

std::random_device rd;
std::seed_seq seeder{rd()};
std::default_random_engine engine{ seeder };

在理论上,seed_seq可以在每次运行程序时提供更多样化的结果,并且它确实允许我们在序列中提供几个种子。如果种子本身也是随机的,我们就能得到更多的多样性。这两种方法在只需要几十、几百或几千个随机结果时都适用,而对于我们编写的简单游戏,使用单个数字的最简单方法也足够好。如果我们需要数百万或数十亿种不同的数字序列,我们就需要更多思考。有一个提议要扩展 C++11 的随机数生成器(见wg21.link/P1932),它提供了关于当前引擎限制的更多详细信息。C++11 的随机库对于我们在本书中制作的游戏来说完全足够。只有在你想运行巨大的蒙特卡洛模拟或需要加密随机数时,才会出现问题。

6.2.1 一个随机移动的 blob

我们可以构建一个新的类,包含一个uniform_int_distribution和一个生成器,给我们一个新的 blob 类型。我们将在step函数中使用这些,将获得的随机数加到当前的步骤中:

y += distribution(generator);

如果我们想在 C++中使用每个分布,我们最终会编写 20 个不同的类。这感觉有些重复。

分布没有共同的基类,但它们都有一个接受生成器的operator(),它们使用它来创建符合分布的下一个随机数。因此,我们可以编写一个模板类,它接受生成器和分布作为类型。我们需要一个生成器和分布成员,其类型由模板指定。我们将它们都传递给构造函数,这意味着我们可以传递任何东西,包括一些用于测试的模拟生成器,只要它们有step函数所需的操作符。

新的类 RandomBlob 需要像 StepperBlob 一样公开继承自 Blobtotal_steps 函数仍然返回总步数 ystep 函数使用生成器和分布来获取一个随机步骤。一些分布返回的是双精度浮点数或浮点数而不是整数,因此我们可以使用 static_cast 来获取整数。

列表 6.11 一个通用随机 blob

template <typename T, typename U>                          ❶
class RandomBlob : public Blob
{
    int y = 0;
    T generator;                                           ❶
    U distribution;                                        ❶
public:
    RandomBlob(T gen, U dis)                               ❶
        : generator(gen), distribution(dis)
    {
    }
    void step() override
    {
        y += static_cast<int>(distribution(generator));    ❷
    }
    int total_steps() const override
    {
        return y;
    }
};

❶ 生成器和分布类型

❷ 添加一个随机步骤

我们使用引擎和分布来创建一个 RandomBlob。我们可以使用一个在零到四个步骤之间的均匀分布:

    std::random_device rd;
    Race::RandomBlob rnd_blob{ 
        std::default_random_engine{ rd() },
        std::uniform_int_distribution{ 0, 4 }
    };

我们可以更改分布参数以获得行为不同的 blob。我们可以使用一个均值为 2.0 和标准差为 1.0 的正态分布,这表明数字偏离平均值的可能性。更大的值意味着更极端的值更有可能:

    Race::RandomBlob another_rnd_blob{
        std::default_random_engine{ rd() },
        std::normal_distribution{2.0, 1.0}
    };

参数 2.01.0 对于这个分布来说意义大不相同。它们不再告诉我们最小值和最大值。每个分布都有针对底层分布函数的特定参数。

C++11 的 random 库支持各种分布(见en.cppreference.com/w/cpp/header/random):

  • 均匀整数和实数—适合在范围内以相等可能性选择一个数字。

  • 伯努利和相关二项分布—用于模拟成功或失败的数量。

  • 泊松分布—模拟在一段时间内事件可能发生的次数;例如,在接下来的几分钟内可能会有多少公交车到达。

  • 与正态分布相关的分布—产生实数(而不是整数)值。这一组有六个分布:正态分布、对数正态分布、卡方分布、柯西分布、费舍尔分布和 t 分布。这些可以用于大量不同的模型,但正态分布通常用于人们的身高和其他大多数值趋向于接近平均值或平均的情况,但极端值是可能的。

  • 抽样分布—类似于均匀分布,但通过提供权重,可以使特定的值或值范围更有可能。

在每种情况下,分布使用一个 概率函数 将引擎提供的数字平滑化,以给出分布所需的各种属性。对于均匀分布,每个数字必须具有相等的可能性。对于正态分布,接近平均值的值比极端值更有可能。许多有趣的数学原理隐藏在这背后,但生成概率函数的图表可以让我们了解可能有多少步骤。CppReference 提供了每个分布使用的函数。比较我们考虑的 uniform_int_distributionnormal_distribution,我们得到如图 6.2 所示的图表。

CH06_F02_Buontempo

图 6.2 [0, 4] 上的随机均匀步骤和均值为 2.0、标准差为 1.0 的正态分布步骤

平均而言,我们期望从这两个分布中任选其一有两次步骤。正态分布可能需要更多步骤,甚至可能倒退,而均匀分布的步骤永远不会是负数。我们可以使用我们想要的任何其他分布。我们甚至可以使用一个假的来测试。我们使用的模板头

template <typename T, typename U>

没有任何约束,所以TU可以是任何东西。我们在第四章中学习了要求和概念,注意到我们可以使用概念约束参数,例如invocable

void guess_number_with_clues(int number,
         std::invocable<int, int> auto message) 

确保只有接受两个整数作为消息参数的函数被传递到我们的数字猜谜游戏中。我们的RandomBlob需要一个接受无参数的invocable生成器,写作std::invocable<>。命名要求位于concepts头文件中,因此如果我们包含该头文件,我们可以将typename关键字替换为约束

    template <std::invocable<> T, typename U>
    class RandomBlob : public Blob

要更具体,如果使用不合适的数据类型,我们可以得到更好的错误信息。实际上,CppReference 为随机数提供了命名要求(见en.cppreference.com/w/cpp/named_req),包括RandomNumberEngineRandomNumberDistribution。这些命名要求可以让我们更加精确;然而,只有列出的要求中的一些在 C++20 概念库中得到了形式化。将生成器约束为invocable对于我们的简单游戏来说已经足够好了。

我们还可以做的一件重要的事情是为我们的代码添加测试,使用 lambda 表达式作为生成器和引擎,几乎不需要任何努力。返回0而不是随机数通常有助于找到错误,因为用零进行的数学运算通常很容易在脑海中完成。我们可以创建一个总是返回0的“生成器”,并在“分布”lambda 中调用它,以传递那个0

列表 6.12 使用随机生成器和分布进行测试

void check_properties()
{
    Race::RandomBlob random_blob(
        []() { return 0; },                   ❶
        [](auto gen) { return gen(); });      ❷
    random_blob.step();                       ❸
    assert(random_blob.total_steps() == 0);   ❹
}

❶ 每次都生成 0

❷ 传递 0

❸ 取一个随机步骤

❹ 总步骤数为 0

我们现在可以制作各种不同的随机 blob。预热赛使用了一个StepperBlobsvector。我们可以制作一个RandomBlobsvector,但如何让步进器和随机 blob 竞争呢?它们是不同的类型,但它们确实共享一个公共基类型。我们可以将原始指针放入一个vector中:

std::vector<Blob*>  

我们的方法支持我们需要的多态性,前提是我们使用指向Blob的指针或引用。确保引用在我们还需要它们的时候不会超出作用域是一项艰巨的工作,因此指针更好。然而,当我们完成时或发生异常时,我们必须手动删除 blob 指针。C++11 引入了智能指针来解决这些挑战,这使得我们的生活更加容易。

6.2.2 智能指针

我们编写的析构函数不做任何事情。它们不需要这样做。然而,C++在允许我们在构造函数中进行设置并在析构函数中进行清理方面表现出色。这被称为资源获取即初始化(RAII)。STL 经常使用 RAII。例如,我们知道vector在堆上为我们创建对象。当vector超出作用域时,分配的对象会被删除。我们不需要记住清理,因为vector是一个 RAII 类。如果我们将原始指针放入vector中,指针将被清理,而不是它们所指向的内容。

智能指针是另一种RAII类,用于管理原始指针。我们可以将智能指针放入vector中。当向量超出作用域时,每个包含元素的析构函数都会被调用。反过来,智能指针的析构函数会清理原始指针。智能指针有多种类型,但所有类型都允许我们编写如下所示代码。

列表 6.13 在vector中使用智能指针

void very_smart()
{
    std::vector<SmartPointer<Blob>> blobs;
    blobs.emplace_back(make_smart_pointer<StepperBlob>());   ❶
}                                                            ❷

❶ 将一些数据块放入向量中

❷ 范围结束,因此向量调用智能指针的析构函数

memory头文件列出了四个智能指针:

  • unique_ptr

  • shared_ptr

  • weak_ptr

  • auto_ptr

最后一个智能指针具有不寻常的复制语义(参见en.cppreference.com/w/cpp/memory/auto_ptr)。复制会窃取指针,这意味着这些智能指针不能用于向量或其他容器。如果向量进行大小调整,元素需要被复制或移动,因此具有不寻常复制语义的东西会引起问题。auto_ptr在 C++11 中被弃用,并在 C++17 中被移除。剩下的三种类型更容易使用。

unique_ptr管理一个原始指针。unique_ptr拥有底层的指针,因此复制是被禁用的,但移动是允许的。毕竟,如果某物是唯一的,你不应该能够复制它。相比之下,shared_ptr也管理一个原始指针,但多个shared_ptr可以拥有同一个对象。当所有拥有底层对象的shared_ptr超出作用域或重置为指向其他对象时,拥有的对象将被销毁,其内存将被释放。因此,共享指针有一个共享计数,存储在所谓的控制块中。我们可以指定当唯一或共享指针超出作用域时会发生什么,但它们默认都会调用delete。此信息也存储在控制块中。现在,共享意味着你可能会遇到循环依赖和潜在的资源泄露,因此还有一个weak_ptrweak_ptr就像一个shared_ptr,但更像是一个被动的观察者,监控控制块。被观察的shared_ptr可以删除底层资源,因此weak_ptr需要在尝试使用底层指针之前检查底层资源是否已被删除。有关更多详细信息,请参阅www.modernescpp.com/index.php/std-weak-ptr。这些三种智能指针之间的关系如图 6.3 所示。

CH06_F03_Buontempo

图 6.3 一个唯一指针拥有一个指针,共享指针共享一个指针和一个引用计数,而弱指针监控共享指针的控制块。

这三种类型的指针涵盖了各种使用场景;然而,对于我们的需求来说,unique_ptr就足够了。不需要共享或观察底层的原始指针。此外,Herb Sutter 建议默认使用unique_ptr(参见mng.bz/n1D8)。shared_ptr更复杂,因为它需要控制块,这使得它更重。从unique_ptr切换到shared_ptr很简单,所以从unique_ptr开始是有意义的。

因此,我们需要创建一个std::unique_ptr<Blob>vector。然后我们可以用从基类派生的任何类填充vector并使它们竞争。我们可以通过几种方式创建一个unique_ptr,要么是显式使用new

std::unique_ptr<Blob> blob(new StepperBlob);

或者使用make_unique方法:

std::unique_ptr<Blob> blob = std::make_unique<StepperBlob>();

后者方法更好。首先,Herb Sutter 的文章告诉我们避免使用new。使用new意味着我们可能需要直接处理原始指针,这需要很多小心。当我们把new StepperBlob传递给智能指针时,内存被分配,构造函数被调用。如果构造函数抛出异常,unique_ptr本身就不会被构造,因此它的析构函数不会被调用,StepperBlob内存就会泄漏。如果分配和构造发生在make_unique调用内部,所有的事情都会为我们处理。出于类似的原因,有一个make_shared函数。

6.2.3 比赛!

我们现在能够创建一个包含各种类型blob的向量并将它们设置为比赛。我们需要一个新的比赛函数,它接受一个std::vector<std::unique_ptr<Blob>>,还需要新的move_blobsdraw_blobs函数,它们也接受一个智能指针的vector。声明在头文件中,在命名空间内,我们需要包含memory头文件以使用unique_ptr

列表 6.14 向头文件添加重载方法

#include <memory>

namespace Race
{
...                                                         ❶
    void race(std::vector<std::unique_ptr<Blob>>& blob);    ❷
    void move_blobs(
        std::vector<std::unique_ptr<Blob>>& blobs           ❷
    );    
    void draw_blobs(
        const std::vector<std::unique_ptr<Blob>>& blobs     ❷
    );    
}

❶ 一切照旧

❷ 多态blob的重载

我们现在需要定义函数。比赛函数与列表 6.8 相同,除了blobs参数的类型。在列表 6.8 中,我们有一个StepperBlobsvector。现在我们可以在vector中有各种blob

列表 6.15 一个不太可预测的比赛

void Race::race(std::vector<std::unique_ptr<Blob>>& blobs)
{
    using namespace std::chrono;
    const int max = 3;
    std::cout << "\x1B[2J\x1B[H";
    for (int i = 0; i < max; ++i)
    {
        draw_blobs(blobs);
        move_blobs(blobs);
        std::this_thread::sleep_for(1000ms);
        std::cout << "\x1B[2J\x1B[H";
    }
    draw_blobs(blobs);
}

我们还需要实现移动和绘制函数。当我们移动列表 6.7 中的blob时,我们调用

blob.step();

对于每个blob。现在blobblob的唯一指针,所以我们需要使用operator->来在底层指针上调用成员函数。除此之外,函数非常相似。

列表 6.16 移动所有blob

void Race::move_blobs(std::vector<std::unique_ptr<Race::Blob>>& blobs)
{
    for (auto& blob : blobs)
    {
        blob->step();    ❶
    }
}

❶ -> 调用一个底层指针的方法

最后,我们需要实现重载的draw_blobs方法。同样,这与列表 6.5 中的先前绘制方法非常相似,但我们使用operator->来找到blob的当前步数。

列表 6.17 绘制每个blob的当前位置

void Race::draw_blobs(const std::vector<std::unique_ptr<Race::Blob>>& blobs)
{
    const int bag_height = 3;
    for (int y = 8; y >= 0; --y)
    {
        std::string output = y > 2 ? "  " : "| ";
        for (const auto& blob : blobs)
        {
            if (blob->total_steps() >= y)    ❶
                output += "* ";
            else
                output += "  ";
        }
        output += y >= bag_height ? ' ' : '|';
        std::cout << output << '\n';
    }
    std::cout << std::string(blobs.size() * 2 + 3, '-') << '\n';
}

❶ -> 调用一个底层指针的方法

有了新的方法,我们终于可以进行一场真正的比赛了。我们需要一些blob,一些赛步器与一些随机的blob比赛会很好。由于赛步器每次移动两步,使用介于04之间的整数均匀分布可以给出平均两步的随机blob。我们意识到这给了两种类型公平的机会。我们可以在一个循环中创建一个指向StepperBlobunique_ptr和一个指向RandomBlobunique_ptr,所以运行一半请求的数量,每次添加两个Blob。每个RandomBlob需要一个引擎和一个分布。引擎需要播种,所以我们通常使用random_device。最后,我们将分布范围设置为04,使用uniform_int_distribution{0, 4}

列表 6.18 创建用于比赛的blob

std::vector<std::unique_ptr<Race::Blob>>
create_blobs(int number)                                        ❶
{
    using namespace Race;
    std::vector<std::unique_ptr<Blob>> blobs;
    std::random_device rd;
    for (int i = 0; i < number/2; ++i)                          ❷
    {
        blobs.emplace_back(std::make_unique<StepperBlob>());    ❸
        blobs.emplace_back(
            std::make_unique<
                RandomBlob<std::default_random_engine,
                std::uniform_int_distribution<int>>
                >                                               ❹
            (
                std::default_random_engine{ rd() },             ❹
                std::uniform_int_distribution{ 0, 4 }           ❹
            )
        );
    }
    return blobs;
}

❶ 选择每种blob类型有多少个

❷ 循环到一半的数量,每次添加两个blob

Stepper是默认构造的。

RandomBlob需要一个引擎和一个分布。

我们现在可以在 main 中创建我们的 blob 并让它们赛跑。让我们尝试每种类型的四个 blob,总共八个来赛跑。

列表 6.19 正确的赛跑

int main()
{
    auto blobs = create_blobs(8);
    Race::race(blobs);
}

随机的 blob 可能会赢,但步进器可以打败它们。一开始,blob 在袋子的底部,准备出发。


|                 |
|                 |
| * * * * * * * * |
-------------------

它们移动,一些随机的 blob 可能会领先。

                *
        *   *   *
| * * * * * * * * |
| * * * * * * * * |
| * * * * * * * * |
-------------------

在下一次移动时,步进器可能会追上一点。

    *       *
  * * * * * * * *
  * * * * * * * *
| * * * * * * * * |
| * * * * * * * * |
| * * * * * * * * |
-------------------

最后,一些随机的 blob 赢了,但有一个在这个运行中被留在了后面。

    *           *
    *       *   *
  * * *   * * * *
  * * * * * * * *
  * * * * * * * *
  * * * * * * * *
| * * * * * * * * |
| * * * * * * * * |
| * * * * * * * * |
-------------------

第二次运行可能会得到不同的结果。我们已经通过赛跑的方式从纸袋中出来了。别忘了,我们也考虑了其他分布。尝试使用正态分布,看看是否有任何向后的情况。

6.2.4 一些设计考虑

我们在基类中禁用了移动和复制。如果我们留下了复制,我们可以创建一个新的派生类型:

class DerivedStepper : public StepperBlob { };

虽然派生类型没有做什么,但也许我们需要它为虚拟成员函数做其他事情。没有什么阻止我们使用 StepperBlob 编写一个函数:

void bogus(StepperBlob blob)
{
// ... 
}

在这个函数内部,我们有一个 StepperBlob,即使我们用派生类型调用它:

bogus(DerivedStepper());

bogus 函数内部,blob 是一个 StepperBlob,因为我们通过值传递了它,并且它被复制了。这被称为 切片。我们将派生类切片到了 StepperBlob 类,错误的虚函数被调用了。核心指南 (mng.bz/orDj) 建议使用 =delete 来删除复制和移动操作以避免切片 (mng.bz/6nr5)。DesDeMovA 方法也会有相同的效果。如果所有这些操作都去掉了,我们就可以添加一个克隆方法,如果我们需要启用复制的话。或者,只需在基类 Blob 中将复制标记为已删除,那么使用派生类调用 bogus 函数将不再编译。幸运的是,我们在基类中已经这样做了。我们甚至可以使用我们之前遇到的 final 关键字来阻止派生类的编写。我们可以将函数标记为 overridefinal,但我们也可以将整个类标记为 final

class StepperBlob final : public Blob

一个 final 类不能用作基类,所以派生类本身无法编译。

面向对象编程(OOP)可能会变得非常复杂。当我们展示赛跑时,我们真正需要的是每个 blob 的总步数。使用 std::vector <int> 来收集步数是可行的。我们可以为每个 blob 累积总步数,而不需要任何面向对象编程。我们一开始就可以这样做,但那样就会错过学习很多 C++ 的机会。实际上,记得我们在列表 6.12 中测试 RandomStepper 的时候吗?我们使用了 0

Race::RandomBlob random_blob(
    []() { return 0; },
    [](auto gen) { return gen(); });    

StepperBlob 需要 2 步,所以我们可以使用返回 2 而不是 0 的假生成器

Race::RandomBlob random_blob(
    []() { return 2; },
    [](auto gen) { return gen(); });    

通过 2 步来创建一个 blob。再次强调,我们不需要面向对象编程(OOP)。模板为我们提供了编译时或 静态多态性。OOP 有其位置,但 C++ 允许我们在各种范式下工作。

我们现在已经多次使用了vector,并在编译时知道需要多少项时,将其视为array的替代品。我们还没有构建需要查找表的任何东西。在下一章中,我们将使用一些关联容器来制作字典。

摘要

  • 总是在base类中声明一个虚析构函数,并用=0标记纯虚函数。

  • 零法则意味着如果我们没有声明任何一个特殊成员函数,将提供六个特殊成员函数。如果只声明了构造函数,则提供剩余的五个特殊成员函数。

  • 在重写的方法中添加关键字override以确保你有正确的签名,并使用关键字final来阻止进一步的覆盖。

  • 使用type_traits来查找类型的特性;例如,std::is_constructible_v

  • 添加析构函数会阻止编译器隐式提供移动特殊成员函数。

  • 五法则意味着任何需要移动语义的类都需要声明所有五个特殊成员函数,如果存在用户声明的析构函数、拷贝构造函数或拷贝赋值运算符,可能还需要构造函数。

  • 使用std::seed_seq可以生成更多种类的随机数,但仅使用std::random_device对于简单的游戏通常就足够了。

  • 相比于原始指针,使用智能指针,并默认首选更简单的unique_ptr

  • 使用智能指针的operator->来调用引用对象的成员函数。

7 关联容器和文件

本章涵盖

  • 填充和使用关联容器

  • 对和元组

  • 从文件中读取

  • 随机样本

我们已经多次使用过向量,但还没有使用过关联容器。关联容器持有键值对,为我们提供了一个查找表或字典。在这一章中,我们将使用字典来创建一个答案破坏游戏。我们将提供两个提示,每个都是一个单词的定义。第一个单词的结尾将与下一个单词的开始重叠,给出答案。例如,一个vector是一个“支持动态调整大小的顺序容器”,一个torch可以定义为“用手携带的点燃的棍子”,所以将单词 vector 和 torch 结合起来给出答案vectorch

我们将首先在map头中定义的std::map中存储一个字典,这个std::map在 C++11 之前就存在了,然后考虑其他类型的关联容器。我们将在下一章中使用更新的std::unordered_map,因此在这一章中使用std::map将是一个有用的复习,我们还将了解std::pair和更通用的std::tuple。我们将从硬编码的字典开始,之后使用随机样本从文件中读取数据,以在玩游戏时增加多样性。

7.1 硬编码答案破坏

我们将首先通过硬编码单词和定义来开始。我们可以直接将这些内容放入一个字典或map中。一个map允许我们根据keys存储values。如果键已存在,我们可以替换现有条目,但不能有两个具有相同键的条目。现在,语言字典可以为同一个单词提供多个定义,因此当我们使用合适的字典时,我们需要一个允许每个键有多个值的数结构。我们将从每个单词一个定义开始,但map头也提供了一个multimap,它支持多个条目,因此我们可以在以后有多个定义。让我们从使用每个键一个值的传统std::map开始。

7.1.1 创建和使用 std::map

与所有容器一样,map是一个类模板,因此我们需要声明键和值的类型。两者都将使用字符串,因此我们需要一个stringstringmap

std::map<std::string, std::string> dictionary;

我们可以使用operator[]来查询和插入键值对。要添加或覆盖条目,我们可以说

dictionary["assume"] = "take for granted, take to be the case";

然后,我们在dictionary中有一个条目,键为"assume",值为"take for granted, take to be the case"。我们可以使用相同的操作符来查找字符串;例如:

std::string new_value = dictionary["fictional"];  

当我们这样做时,new_value是一个默认的字符串,因为"fictional"不在字典中。在调用operator[]之后,新的键"fictional"和默认的字符串值最终出现在字典中,这可能不是我们的意图。让我们创建一个map并证明这一点。

我们将创建一个 string 键到 string 值的 map,并将内容流出到 std::cout,因此我们需要包含 mapstringiostream 头文件. 当我们流出一个 vector 时,我们使用了类似于以下范围的 for 循环

for(auto item: my_vector)

我们可以用同样的方式处理 map,并且我们将使用 const 引用来避免复制。每个 map 元素由两个捆绑在一起作为 std::pair 的字符串组成,它有 firstsecond 方法来访问每个元素。我们将在稍后更详细地探讨这个问题。现在,我们可以尝试查询一个不存在的元素并看看会发生什么。

列表 7.1 创建和显示 map

#include <iostream>
#include <map>
#include <string>

void warm_up()
{
    std::map<std::string, std::string> dictionary;                     ❶
    dictionary["assume"] = "take for granted, take to be the case";    ❷
    std::string new_value = dictionary["fictional"];                   ❸
    for (const auto & item : dictionary)                               ❹
    {
        std::cout << item.first << " : " << item.second << '\n';       ❺
    }
}

int main()
{
    warm_up();
}

❶ 声明字典

❷ 添加项目

❸ 查询不存在的项目

❹ 使用 const auto & 来避免复制

❺ 显示对

当我们运行这段代码时,我们可以看到对 "fictional" 的查询向字典中添加了一个空字符串:

assume : take for granted, take to be the case
fictional :

这种行为不符合直觉,可能会引起问题。当我们将容器作为参数传递给函数时,我们故意使用了 const 引用。我们想要引用,所以我们不复制整个容器,但通常我们只想查询而不是更改元素,因此将参数标记为 const。如果我们尝试用 map 做同样的事情

void unexpected(const std::map<std::string, std::string> & lookup)
{
     auto value = lookup["cheese"];
}

我们得到一个编译错误,告诉我们没有接受 const mapoperator[]。相反,我们可以调用 at 方法,它是一个 const 成员函数:

auto value = lookup.at("cheese");

如果键不存在,将抛出 std::out_of_range 异常。使用这种替代方法允许我们通过 const 引用传递 map,这将很有用。

operator[] 也会替换现有条目,因为 map 对每个键只有一个值。如果我们说

dictionary["fictional"] = "made up";

那个虚构条目现在具有值 "made up"。如果我们使用 insert 方法,我们可以避免覆盖现有条目。insert 有多种重载(见 mng.bz/5oEq),但最简单的版本返回两个东西,一个迭代器和 bool,也捆绑为 std::pair。当我们尝试插入一个新项目时,我们可以传递一个键和值的初始化列表:

auto result = dictionary.insert({ "insert", "place inside" });

result 的第二个项目是真实的,因为新条目已被添加。第一个项目包含指向新添加项目的迭代器。如果我们尝试覆盖现有项目

auto next_result = dictionary.insert({ "fictional", "not factual" });

next_result 的第二个项目是假的,第一个项目包含一个指向现有项目的迭代器,这允许我们看到现有值。

我们现在可以创建一个字典,这是一个有用的起点。在我们构建答案砸游戏之前,让我们暂停一下,更详细地考虑一下 std::pair,它现在已经出现好几次了。

7.1.2 对,元组,和结构化绑定

std::pair 存在于 utility 头文件中,它是由一个类模板定义的,基于两种类型:

template<typename T1, typename T2> struct pair;

我们已经使用了 firstsecond 成员变量。utility 头文件还提供了一个名为 make_pair 的辅助函数,它为我们创建所需的 pair 并推断类型。如果我们说

auto two_words = std::make_pair("Hello,", "world!");

我们得到一对 const char *s。我们可以使用字符串字面量 operator ""s 来获取一对 std::strings:

using namespace std::string_literals;
auto two_words = std::make_pair("Hello,"s, "world!"s);

而不是使用 auto,我们可以直接说 std::pair 并使用初始化列表:

std::pair two_words{"Hello,"s, "world!"s};

我们不需要明确指定这对元素的类型,因为 CTAD 会为每个元素推导出 std::string。类型不需要相同,所以如果我们想的话,我们可以有一个包含两种不同类型的对。例如

std::pair two_numbers{1, 1.23};

std::pair 持有一个 int 和一个 double

现在,一个对(pair)包含两个元素,但 C++11 引入了一种泛化,称为 tuple,它可以包含任意数量的项。没有人同意如何发音 tuple,所以你可以选择“two-pel”、“tupp-ell”或“chewple”。tuple 位于 tuple 头文件中,并使用我们之前遇到的参数包(三个点)定义:

template<typename... Types> class tuple;

有一个 make_tuple 函数可以创建元组,所以我们可以创建一个包含三个字符串的元组,如下所示:

auto three_words = std::make_tuple("Hello "s, "again, "s, "World!"s); 

然而,我们也可以这样表达

std::tuple three_words = {"Hello "s, "again, "s, "World!"s};

并且 CTAD 会启动,推导出我们有一个包含三个 std::strings 的元组。与 std::pair 一样,元组中可以包含各种类型,所以

std::tuple three_numbers{ 1, 1.23, 4.5f };

是一个 std::tuple,包含 intdoublefloat

现在,std::pairfirstsecond 成员来访问任意元素,但 std::tuple 可能不包含两个元素。我们在第五章中使用 variant 时有一个牌或小丑,并使用 std::get 来访问元素。tuple 头文件有一个 std::get 的重载,我们可以用它来检索 tuple 元素,指定我们想要的项的索引。例如,调用

auto first = std::get<0>(three_words);

将返回第一个字符串。

在 7.1 列表中,当我们显示字典时使用了 std::pair,隐藏在 auto 后面

for (const auto & item : dictionary) 

item 实际上是一对字符串,所以我们需要调用 firstsecond 来显示字典条目。我们可以做得更整洁。C++17 引入了 结构化绑定,允许我们将名称绑定到对、元组和更多(见 mng.bz/g7De)上。如果我们想将三个数字

std::tuple three_numbers{ 1, 1.23, 4.5f };

解包到三个变量中,我们可以自己获取每个元素:

int x = std::get<0>(three_numbers);
double y = std::get<1>(three_numbers);
float z = std::get<2>(three_numbers);

结构化绑定允许我们在一行中获取所有三个项目:

auto [x, y, z] = three_numbers;

我们必须使用 auto,然后是我们在 [] 中想要的变量名。实际上,结构化绑定是手动解包的语法糖,但它会复制一个隐藏的元组或对。使用 C++ Insights(见 cppinsights.io/s/0579bdbb)查看我们的三个数字,我们看到一个 tuple 的副本,有一个虚构的名字 __three_numbers6 和三个命名变量,分别指向三个元素:

std::tuple<int, double, float> three_numbers = 
                 std::tuple<int, double, float>{1, 1.23, 4.5F};
std::tuple<int, double, float> __three_numbers6 =
                 std::tuple<int, double, float>(three_numbers);
int && x =
    std::get<0UL>(static_cast<std::tuple<int, double, float> &&>(__three_numbers6));
double && y =
    std::get<1UL>(static_cast<std::tuple<int, double, float> &&>(__three_numbers6));
float && z =
    std::get<2UL>(static_cast<std::tuple<int, double, float> &&>(__three_numbers6));

我们在第二章中已经遇到过右值引用 &&。如果我们使用引用而不是复制,就可以避免复制:

auto &[x, y, z] = three_numbers;

隐藏的 __three_numbers6 就是一个引用,因为它遵循 引用折叠 规则,所以它愉快地绑定到引用上。

我们可以绑定到数组,甚至结构的非静态成员。例如,给定

struct DataObject { int x{ 0 }; double y{ 1.23 }; };

我们可以写成

DataObject data {};
auto [x, y] = data;

在每种情况下,我们使用auto并将其绑定到现有对象。本书的技术编辑 Tim van Deurzen 在 2019 年 Meeting C++上就结构化绑定进行了精彩的闪电演讲,如果您想了解更多信息(见www.youtube.com/watch?v=YC_TMAbHyQU)。

我们在考虑如何在列表 7.1 中显示dictionary时使用了std::pair。我们现在可以将dictionary的键值对绑定到两个名称,编写

for (const auto & [key, value] : dictionary)

因此,我们可以直接使用keyvalue,而无需在键值对上调用firstsecond

列表 7.2 使用结构绑定访问map

#include <iostream>
#include <map>
#include <string>

void structure_bindings()
{
    std::map<std::string, std::string> dictionary;
    dictionary["assume"] = "presume, take for granted";
    std::string new_word = dictionary["fictional"];
    for (const auto & [key, value] : dictionary)       ❶
    {
        std::cout << key << " : " << value << '\n';    ❷
    }
}

❶ 将结构绑定到键和值

❷ 显示键和值

类似地,如果我们使用insert,我们也可以使用结构绑定来保存结果:

auto [it, result] = dictionary.insert({ "insert", "place inside" })

然后,我们可以直接使用迭代器it和布尔值result,而无需使用first来获取迭代器,使用second来获取result

pairtuple可以在各种情况下使用,包括从函数中返回多个值,正如我们在考虑mapinsert函数时所见。结构化绑定还允许我们在使用返回值时编写更清晰的代码。掌握了mappair的基础知识后,我们现在可以制作一个简单的答案砸游戏。

7.1.3 一个简单的答案砸游戏

我们将创建两个字典来玩答案砸游戏。一个将包含 C++关键字和它们的定义,这样我们就可以在玩游戏时稍作复习。第二个将包含英文单词及其定义。我们可以使用operator[]来创建关键字字典。

列表 7.3 使用operator[]填充map

std::map<std::string, std::string> keywords;                             ❶
keywords["char"] = "type for character representation which can be"
    " most efficiently processed on the target system";                  ❷
keywords["class"] = "user defined type with private members by default";
keywords["struct"] = "user defined type with public members by default";
keywords["vector"] = "sequential container supporting dynamic resizing";
keywords["template"] = "family of classes or functions parameterized"
                       " by one or more parameters";

❶ 构建字典

❷ 填充字典

要使用operator[],我们需要一个可变的而不是constmap,但一旦我们设置了字典,我们就不需要更改它们。现在,我们注意到我们可以在insert之前传递键和值的初始化列表:

dictionary.insert({ "insert", "place inside" });

同样,我们可以使用一对初始化列表,或者甚至是一个包含两个字符串的初始化列表的初始化列表来构建我们的字典。

列表 7.4 使用初始化列表填充map

const std::map<std::string, std::string> dictionary{           ❶
    {"assume", "take for granted, take to be the case"},       ❷
    {"harsh", "coarse, large-grained or rough to the touch"},
    {"table", "piece of furniture"},
    {"tease", "mock, make fun of"},
    {"torch", "lit stick carried in one's hand"},
};

❶ 构建字典

❷ 用初始化列表的键值对填充它

第二种方法意味着我们可以将字典标记为const,以确保我们不会意外地更改其内容。

我们可以遍历关键字,通过const引用使用结构绑定来访问键和值,以避免复制字符串:

for (const auto & [word, definition] : keywords)

对于每个关键词,我们需要从字典中找到一个与之重叠的单词,这样我们才能将关键词和字典单词结合起来。给定单词 "char",我们可以在字典中查找以 "char" 开头的条目,但整个单词可能会被完全吞没而不是重叠。这没问题,但避免这种情况可能会更有趣。相反,我们可以尝试查找以 "har" 开头的条目。因此,我们需要从第二个字符(或索引 1)开始的子字符串,或者说是单词的词干或起始部分来查找:

size_t offset = 1;
auto stem = word.substr(offset);

然后,我们可以遍历字典,寻找以该词干开头的单词。我们需要检查键的子字符串是否从索引 0 开始,长度为 stem.size(),与词干 "har" 相等,因此我们会在我们的字典中找到 "harsh"。在最坏的情况下,这意味着我们可能需要逐个检查每个键,并且可能找不到一个单词。我们很快就会看到在 map 中查找键的更有效的方法。

如果没有匹配到词干 "har",我们可以再次尝试,从下一个字母开始,所以我们使用 "ar"。碰巧有一个 "har" 的匹配项,所以我们找到了一个合适的单词,不需要进一步检查。有些单词,如 "struct",需要更多的搜索。我们去掉初始的 's' 并搜索词干 "truct"。然而,字典中没有以 "truct" 开头的条目,所以我们可以尝试 "ruct" 并继续尝试,直到我们尝试匹配单个字母 "t"。我们需要至少一个重叠字母来将两个单词结合起来。有些单词可能根本找不到匹配项,因此我们可以用一个空字符串来表示这一点。我们也可以返回一个 optional 或甚至一个带有布尔值的 tuple 来指示我们找不到合适的单词,但空字符串也行。尝试这些不同的方法以获得额外的练习。

将搜索编写为单独的函数意味着我们可以对其进行测试。我们可以将函数放入一个新的源文件中,称为 Smash.cpp,并使用命名空间以及头文件 Smash.h 来声明所需的函数。搜索函数接受我们想要匹配的单词和要搜索的字典。如果我们找到一个单词,我们需要返回字典中的一个键,如果没有找到,则返回空字符串。如果我们还返回使用的偏移量,调用代码可以在不重新发现重叠位置的情况下将两个单词结合起来。正如我们所见,从函数中返回两个值的一个简单方法是通过 std::pair,所以我们在这里可以这样做,将代码放在 Smash.cpp 中,并在相应的头文件中声明函数。

列表 7.5 查找重叠单词

#include <map>
#include <string>
#include <utility>

#include "Smash.h"

std::pair<std::string, int> find_overlapping_word(std::string word,
    const std::map<std::string, std::string>& dictionary)
{
    size_t offset = 1;                                 ❶
    while (offset < word.size())
    {
        auto stem = word.substr(offset);
        for (const auto & [k, v] : dictionary)         ❷
        {                                              ❷
           auto key_stem = k.substr(0, stem.size());   ❷
           if (key_stem == stem)                       ❸
           {                                           ❸
              return { k, offset };                    ❸
          }
        }
        ++offset;                                      ❹
    }                                                  ❹
    return { "", -1 };                                 ❹
}

❶ 从单词的第二个字母开始

❷ 考虑每个键的起始部分

❸ 找到匹配项

❹ 未找到匹配项

尽管我们可能正在检查所有键,但重叠函数对于 answer smash 的第一次尝试已经足够好了。我们需要一个函数,它接受两个字典,一个是关键字字典,另一个是更通用的单词字典,两者都包含用作提示的定义。对于每个关键字,我们将尝试找到一个重叠的单词,如果我们找到一个单词,我们将显示两个定义作为提示。如果没有找到,我们将返回一个空字符串和偏移量 -1,因此我们将继续到下一个关键字。如果我们找到一个合适的单词,正确的答案就是关键字的起始部分与第二个单词的连接。我们可以使用 operator+ 来创建答案。

word.substr(0, offset) + second_word

现在,子字符串创建了一个临时字符串,然后连接操作又创建了一个新的字符串,因此这种方法效率不高。我们不需要复制子字符串。C++17 在 string_view 头文件中引入了 string_view,它提供的是字符串的视图而不是副本。std::string_view 给我们提供了对现有字符串的只读访问,这意味着视图有效时,现有字符串需要保持作用域。我们可以获取第一个单词的前一部分的视图,避免复制,并使用我们在第二章中看到的 std::format 来制作答案。因此,我们可以说

std::string answer = std::format("{}{}", 
    std::string_view(word).substr(0, offset), second_word);

避免临时复制子字符串。有关更多详细信息,请参阅mng.bz/amzj。使用string_view通常更高效,但由于它是对另一个对象的视图,我们需要注意在原始字符串超出作用域后不要使用该视图。为了简单起见,我们将在本例中坚持使用operator+。了解我们正在创建额外的副本并且不需要它是很有用的。

我们可以使用 std::getline 来读取猜测。玩家可以简单地按 Enter 键放弃。我们可以将响应与答案进行比较,以确定猜测是否正确,再次将代码放在 Smash.cpp 中,并在 Smash.h 中声明该函数。

列表 7.6 一个简单的 answer smash 游戏

#include <iostream>
#include <map>
#include <string>

#include "Smash.h"

void simple_answer_smash(
    const std::map<std::string, std::string> &keywords,
    const std::map<std::string, std::string> &dictionary)
{
    for (const auto & [word, definition] : keywords)               ❶
    {
        auto [second_word, offset] = find_overlapping_word(word,
                                         dictionary);              ❷
        if (offset == -1)                                          ❸
        {                                                          ❸
            std::cout << "Not match for " << word << '\n';         ❸
            continue;                                              ❸
        }
        std::string second_definition =
                dictionary.at(second_word);                        ❹
        std::cout << definition << "\nAND\n" 
                  << second_definition << '\n';                    ❺

        std::string answer =
                word.substr(0, offset) + second_word;              ❻
        std::string response;                                      ❼
        std::getline(std::cin, response);                          ❼
        if (response == answer)                                    ❽
        {
            std::cout << "CORRECT!!!!!!!!!\n";
        }
        else
        {
            std::cout << answer << '\n';
        }
        std::cout << word << ' ' << second_word << "\n\n\n";
    }
}

❶ 对于每个关键字

❷ 查找重叠

❸ 检查是否有合适的单词

❹ 使用 at 而不是 operator[] 对 const map 进行操作

❺ 显示两个定义

❻ 将两个单词合并在一起

❼ 获取响应

❽ 检查猜测是否正确

我们可以从 main 中调用它并玩游戏。

列表 7.7 玩第一个版本的 answer smash

#include "Smash.h"                                     ❶
int main()
{
    const std::map<std::string, std::string> keywords{
        {"char", "type for character representation which can be most"
                    "efficiently processed on the target system"},
        {"class", "user defined type with private members by default"},
        {"struct", "user defined type with public members by default"},
        {"vector", "sequential container supporting dynamic resizing"},
        {"template", "used for generic code"},
    };                                                 ❷
    const std::map<std::string, std::string> dictionary{
        {"assume", "take for granted, take to be the case"},
        {"harsh", "coarse, large-grained or rough to the touch"},
        {"table", "piece of furniture"},
        {"tease", "mock, make fun of"},
        {"torch", "lit stick carried in one's hand"},
    };                                                 ❸
    simple_answer_smash(keywords, dictionary);         ❹
}

❶ 包含声明 simple_answer_smash 的头文件

❷ 设置关键字

❸ 设置字典

❹ 玩游戏

我们考虑了第一个关键字 "char" 和字典单词 "harsh"。对于这个组合,我们看到提示

type for character representation which can be most efficiently processed on the target system
AND
coarse, large-grained or rough to the touch

我们可以尝试猜测或直接按 Enter 键查看答案:

charsh
char harsh

我们有一个简单的游戏。如果我们更深入地研究map和其他关联容器,我们将看到如何使重叠搜索更高效,并学习更多 C++。

7.2 关联容器

我们使用std::map构建了一个硬编码的游戏。如果我们了解该结构内部的工作原理,我们将能够进行一些轻微的性能改进。有了这些,我们需要一个相关的数据结构,即std::multimap,以便存储合适的语言字典,这使我们能够为每个键存储多个值。毕竟,单词有时有多个定义,所以当我们在本章的最后部分使用合适的字典时,我们可能需要为单个键存储多个值。

7.2.1 更详细地了解map类型

我们知道vectorarray都连续存储它们的元素,我们可以动态调整vector的大小,但不能调整array的大小。如果我们在一个vector中搜索一个项目,我们可能需要遍历所有元素才能找到我们需要的,可能到达末尾也找不到项目。如果我们有一个vector中有n个元素,我们可能需要检查所有n个元素,这被描述为O(n),或线性复杂度。我们已经看到我们可以动态地向map中添加对,但我们还没有考虑元素是如何存储的,所以我们不知道搜索是如何工作的。

map被设计成我们可以更快地搜索项目。而不是将元素存储在一起,map将它们存储在一个二叉树中。二叉树有节点,存储元素和指向其他子节点的指针,就像树中的分支一样,并且任何节点最多有两个分支;因此得名二叉。节点是有序的,这给了我们一个二叉搜索树,较小的元素位于左侧,较大的元素位于右侧。对于map,我们的元素是一个键和一个值,键用于决定一个项目是向左还是向右移动。

如果我们在map中放入{1:a}{3:c}{5:e},我们开始时有一个单个节点{1:a},然后添加{3:c}。由于键3大于1,新元素{3:c}将向右移动,如图 7.1 所示。

CH07_F01_Buontempo

图 7.1 有两个节点的Map:顶部的第一个节点{1:a}和右侧的下一个较大的节点{3:c}

当我们添加最后一个元素{5:e}时,会发生两件事。首先,新节点比{3:c}大,所以它将位于下方和右侧,但在这里添加一个子节点意味着树变得不平衡。实际上,我们有一个由{1:a}{3:c}{5:e}组成的链,而不是一个平衡的树,因为我们有很多右分支而没有左分支。将{3:c}提升为顶级节点可以平衡树,使较小的元素位于左侧,较大的元素位于右侧,如图 7.2 所示。

CH07_F02_Buontempo

图 7.2 在平衡的二叉搜索树中的三个元素:具有较小键的节点位于左侧,具有较大键的节点位于右侧。

在将元素放入map之后,我们现在可以搜索它。如果我们想知道{2:b}是否在map中,我们从顶层节点{3:c}开始,因为键是2,小于3,所以我们向下移动到左节点{1:a}。这并不等于2,而且它是一个叶节点终止节点,所以我们的搜索完成了。我们只考虑了树的一半。因为树是一个平衡的二叉树,所以我们将搜索左分支或右分支,因此我们具有对数复杂度,O(log(n))。实际上,搜索、删除和插入操作都具有对数复杂度。如果我们加倍元素的数量,我们在搜索时只需要额外一组比较。对于向量,搜索是O(n)。如果我们加倍元素的数量,我们在搜索时可能加倍比较的数量。我们可能会幸运地找到元素在开始处,但在最坏的情况下,我们必须检查所有项目。图 7.3 显示了常数大-O,O(n),和对数大-O,O(log(n))的最坏情况。

CH07_F03_Buontempo

图 7.3 常数时间复杂度:O(n)随着元素数量的增加增长得比对数复杂度O(log(n))快得多。

重新平衡保持了搜索的高效性。C++ maps 通常实现为红黑树。颜色是每个节点上的额外信息,用于插入或删除时使用。为了将搜索保持在对数复杂度O(log(n)),树需要保持平衡。如果一个分支的节点比另一个分支多得多,那么通过最大侧搜索会花费更长的时间。关于树数据结构和算法的经典资源是 Donald Knuth 的《计算机程序设计艺术》第三卷(Addison-Wesley Professional,1998)。

如果我们查看 CppReference(见en.cppreference.com/w/cpp/container/map),我们被告知 map 是一个排序的关联容器。C++11 引入了无序容器,我们将在下一章中探讨。我们必须指定我们 map 的键和值类型,但map还接受一个比较类型,用于在树中放置节点。比较默认为std::less<Key>。对于std::string,我们得到默认的std::less<std::string>,这等同于std::stringoperator<。我们可以指定其他比较方式。例如,我们可能希望首先将所有字符串转换为小写。对于用户定义的类型,我们可能需要编写比较运算符或定义飞船运算符以在map中使用我们的类型。如果我们有一个用户定义的类型,即使是一个简单的结构体,例如

struct Stuff { int x; };

如果我们尝试将Stuff用作 map 中的键,我们会得到编译错误:

std::map<Stuff, int> lookup;
lookup[Stuff{ 1 }] = 1;

我们需要做的只是将飞船运算符添加到结构体

friend auto operator <=> (const Stuff &, const Stuff&)  = default;

然后,我们可以将其用于查找。

C++标准通常告诉我们容器上操作的计算复杂度,这有助于我们在编码时做出明智的选择。大 O 或复杂度是最坏情况。例如,一个描述为O(n)的搜索可能只需查看一个元素,如果第一个检查的元素就是匹配项。在最坏的情况下,所有元素都会被比较。复杂度是指导可能发生多少操作的指南,而不是效率的保证。我们可能仍然需要基准测试我们的代码以查看其速度,并且性能分析器可以帮助我们找到瓶颈。

现在,当我们构建列表 7.6 中的简单答案粉碎器时,我们手动检查了键,因此我们可能将我们的词干词与所有n个键进行了比较,这给我们带来了O(n)。在没有性能分析的情况下,我们可以通过使用std::map提供的其他功能来改进这一点。

7.2.2 使用上下界来更高效地查找键

std::map有一个lower_boundupper_bound函数,这些函数帮助我们更有效地查询映射。这两个函数都找到元素将被插入的位置。lower_bound找到第一个大于或等于查询元素的元素,而upper_bound找到具有更大值的元素的位置。Nicolai Josuttis 的书籍《C++标准库,第二版》(Addison-Wesley Professional,2012 年),是一本关于进一步详细信息的优秀参考书。std::setstd::multiset也支持这些函数。我们还没有使用这些容器。集合允许我们保持一组唯一的值,就像映射一样,但只有键,而多重集合允许我们有重复的键。

此外,还有免费函数std::lower_boundstd::upper_bound,也可以用于其他容器,前提是元素按operator<排序。因此,我们可以将这些函数用于已排序的vector

const std::vector<int> data{ 1, 2, 4, 5, 6, 7 };
auto lower = std::lower_bound(data.begin(), data.end(), 3);
auto upper = std::upper_bound(data.begin(), data.end(), 3);

这可能比通过迭代元素尝试找到3要快。上下界都指向第三个元素4,如图 7.4 所示。

CH07_F04_Buontempo

图 7.4 已排序向量中3的上下界,lbub

当上下界匹配时,表示该项不存在。如果我们插入3并再次运行查询,lower_bound将返回指向3的迭代器,而upper_bound仍然返回指向值4的迭代器。因为位置不匹配,我们找到了值3。下界大于或等于元素,而上界总是大于元素,所以匹配的界限意味着它们都是更大的,而不同的界限意味着下界位于第一个这样的元素。

我们还可以在equal_range的一次调用中找到上下界。这个函数返回一个迭代器对,因此我们可以再次使用结构化绑定来获取上下界:

auto [lb, ub] = std::equal_range(data.begin(), data.end(), 3);

std::map 以及提到的其他容器具有相同行为的成员函数。我们有时会发现容器为了性能原因,对通用函数有专门的版本。

我们可以使用 lower_boundupper_bound 成员函数重写我们的查找重叠函数,从而避免可能检查所有键。之前,在第 7.5 节的列表中,我们遍历了所有键,如果找到与词干匹配的键,就退出循环。现在,我们可以使用 equal_range 来找到词干的上下界,因为这个函数将 lower_boundupper_bound 的结果打包成一个 std::pair。下界如果词干不存在,则找到插入点,所以我们可能位于字典的末尾或一个不匹配的词。在将词干与下界键的第一部分比较之前,我们需要检查下界不是在字典的末尾。

lb->first.substr(0, stem.size()) 

来发现我们是否找到了合适的单词。将这些组合起来,我们得到以下函数。

列表 7.8 更高效地查找重叠单词

std::pair<std::string, int> find_overlapping_word(std::string word,
    const std::map<std::string, std::string>& dictionary)
{
    size_t offset = 1;
    while (offset < word.size())
    {
        auto stem = word.substr(offset);
        auto [lb, ub] = dictionary.equal_range(stem);        ❶
        if (lb != dictionary.end() &&
                stem == lb->first.substr(0, stem.size()))    ❷
        {
            return {lb->first, offset};                      ❸
        }
        ++offset;
    }
    return {"", -1};
}

❶ 不再需要循环

❷ 我们是否找到了合适重叠?

❸ 返回单词和偏移量

我们可以在我们的游戏中使用这个函数来代替我们在列表 7.5 中编写的原始版本。

当我们调用 find_overlapping_word 时,我们只会找到第一个匹配的单词,我们可以改进这一点。可能存在多个重叠的单词,而且一个合适的字典可能对一个单词有多个条目。当我们有多个合适的单词时,我们可以随机选择,这将为我们的游戏增加一些多样性。我们还可以使用 std::multimap 来支持每个键的多个条目。当我们思考关联容器时,让我们了解多映射,然后我们将准备好使用合适的字典制作我们游戏的新版本。

7.2.3 多映射

std::multimap 也位于 map 头文件中,并使用键和值;例如:

std::multimap<std::string, std::string> dictionary;

multimap 支持相同键的多个值,并且像 std::map 一样行为,但每个键都有一个值的 vector

要插入项,我们可以使用 insert

dictionary.insert({ key, value });

或者 emplace

dictionary.emplace(key, value);

正如我们在第二章中看到的 vectorinsert 需要一个元素,因此对于 multimap 版本,我们会使用 std::pair,而 emplace 则从提供的参数构建元素。键值对仍然生活在树中的节点中,但我们可以有多个来搜索,如图 7.5 所示。

CH07_F05_Buontempo

图 7.5 给定键的多个值的 multimap

要检索值,我们需要处理每个键可能有多个值的情况。此外,std::multimap没有operator[]at函数,因此我们需要做其他事情。幸运的是,使用lower_boundupper_boundequal_range可以给我们所需的结果,允许我们找到与给定键对应的所有值。这些函数返回迭代器,如果存在与键对应的值,我们可以使用所有这些值。

让我们考虑以下示例。使用命名空间字面量,我们可以创建一个与图 7.5 匹配的multimap

std::multimap<int, std::string> mm{
    {1,"a"s}, {1,"a"s}, {3,"c"s}, {3,"g"s}, {5,"e"s}, {5,"h"s},
};

如果我们搜索mm.equal_range(2),我们将得到一个指向元素3:c的迭代器,对于下界和上界都是如此。这意味着具有键 2 的元素将被插入那里。如果我们搜索mm.equal_range(3)而不是mm.equal_range(2),下界是3:c,是第一个不小于键3的元素,上界是5:e,是第一个大于键3的元素。然后我们有一对迭代器,可以用来遍历所有键为3的元素。

我们需要找到一个以词根开头的单词,这样我们才能找到下界

auto stem = word.substr(offset);
auto lb = dictionary.lower_bound(stem);

当字典是一个multimap时。我们需要的是任何在词根之后的单词。如果我们复制词根

auto beyond_stem = stem;

我们可以在'z'之后添加一个字符来超越单词可能的词根

beyond_stem += ('z' + 1);

并使用它来找到上界:

auto ub = dictionary.upper_bound(beyond_stem);

如果有匹配项,我们将得到一个单词范围的起始和结束。我们将使用multimap构建一个更好的游戏,从这个范围内随机选择一个合适的单词。

7.3 基于文件的答案砸游戏

我们使用硬编码的关键词和一个小型词典制作了一个简单的答案砸游戏。通过从文件中加载数据,我们可以制作一个更有趣的游戏。本书提供的代码中,该章节文件夹中有两个csv文件。一个包含 C++关键词的选择,基于 CppReference 的定义(见en.cppreference.com/w/cpp/keyword),另一个包含基于 Wordnetcode 子集的各种英文单词(见mng.bz/M9KQ)。

7.3.1 从文件中加载数据

我们还没有使用文件,但我们已经使用了流,例如std::coutstd::cin,C++将文件视为流。文件位于fstream头文件中。我们可以使用文件名

std::ifstream infile{ filename };

并在布尔上下文中使用流来查看它是否打开:

if (infile)
// all good

当变量超出作用域时,文件会自动关闭,因此文件流使用资源获取即初始化(RAII),这是我们上一章遇到的。

文件可以是文本或二进制格式,因此我们可以在构造函数中指定模式(见mng.bz/yZDp)。我们的字典是文本格式,所以默认的文本模式对我们来说就足够了。如果我们想写入文件,我们使用输出文件流ofstream。输出文件流也可以是文本或二进制格式,但我们可能还想截断现有文件或追加到文件末尾。我们可以使用输入输出流的位或OR (|)操作符来指定打开输出和追加,即输入输出流(ios)的打开模式outapp

std::ofstream f1("test.txt", std::ios::out | std::ios::app); 

等等(见mng.bz/Xqy9)。要从文件中读取,我们可以使用operator>>std::getline,我们在第三章中使用std::cin时就是这样做的。对于输出流,我们会使用operator<<

我们文件中的单词以混合大小写存储,但我们不希望Intint被视为不同的单词。因此,我们应该将键转换为小写,以便可以直接与转换为小写的输入进行比较。我们需要自己编写一些代码,因此,在 CppReference 的指导下,我们可以将一个string转换为小写,使每个字符都变为小写。我们可以使用algorithm头文件中的transform函数和cctype头文件中的 C 的tolower函数(见shortener.manning.com/QR66)。tolower函数作用于int,而不是char,因此我们必须小心。我们需要将每个字符视为一个unsigned char,因为如果参数的值既不是文件结束(EOF)也不是可表示为无符号char的值,std::tolower的行为是未定义的。因此,我们在转换中使用了接受unsigned char的 lambda 表达式。

列表 7.9 将字符串转换为小写

#include <algorithm>
#include <cctype>
std::string str_tolower(std::string s) {
    std::transform(s.begin(), s.end(), s.begin(),
      [](unsigned char c) { return std::tolower(c); }
    );
    return s;
}

我们现在可以编写一个函数来从文件中加载字典。每一行将有一个单词、一个逗号和一个定义:

struct,user defined type with public members by default

我们可以逐行遍历文件,并尝试找到第一个逗号:

size_t position = line.find(',');

如果positionstd::string::npos,则表示无效行,我们可以记录并忽略它。否则,我们可以将行分割成键和值。键是逗号位置之前的子字符串

std::string key{ line.substr(0, position) };

定义是逗号位置之后的子字符串,直到行尾:

std::string value{ line.substr(position + 1) };

如果我们使用std::string作为文件名,我们可以编写一个返回multimap的函数,以便在我们的改进游戏中使用。multimap允许每个单词有多个定义。

列表 7.10 将文件加载到multimap

#include <fstream>
#include <iostream>
#include <map>
#include <string>

std::multimap<std::string, std::string>
    load_dictionary(const std::string& filename)
{
    std::multimap<std::string, std::string> dictionary;
    std::ifstream infile{ filename };                           ❶
    if (infile)                                                 ❶
    {
        std::string line;
        while (std::getline(infile, line))                      ❷
        {
           size_t position = line.find(',');                    ❸
           if (position != std::string::npos)
           {
                std::string key{ line.substr(0, position) };    ❹
                key = str_tolower(key);                         ❹
                std::string value{ line.substr(position + 1) };
                dictionary.emplace(key, value);                 ❺
           }
           else
           {
               std::cout << "***Invalid line\n" << line 
                         << "\nin " << filename << "***\n\n";
           }
        }
    }
    else
    {
        std::cout << "Failed to open " << filename << '\n';
    }
    return dictionary;
}

❶ 创建并打开一个文件用于读取

❷ 读取每一行

❸ 在逗号处分割

❹ 将键转换为小写

❺ 将键值对添加到 multimap

我们可以使用这个函数来加载关键词和字典。在某些操作系统中,文件路径上有反斜杠,所以像 "c:\" 这样的路径可能会在代码中引起问题,因为反斜杠也用于转义特殊字符。我们可以使用 C++11 中引入的原始字符串,用 R() 包围字符串来表示。如果我们把文件保存在工作目录中,我们不需要使用原始字符串,但这又是一个值得注意的新特性:

const auto dictionary = load_dictionary(R"(dictionary.csv)");
const auto keywords = load_dictionary(R"(keywords.csv)");

原始字符串还有更多内容,但在这里我们将坚持使用 string 文件名。我们需要记住,代码需要在包含文件的目录中运行;否则,代码将找不到输入文件。

原始字符串和文件系统类型

我们可以在原始字符串中使用各种括号 '(' 和 ')' 之外的起始和结束字符(见 mng.bz/46ER)。我们甚至可以使用 C++17 中引入的文件系统路径(见 en.cppreference.com/w/cpp/filesystem/path)来表示文件路径。

7.3.2 使用 std::sample 随机选择一个单词

我们可以随机选择一些关键词来玩游戏,而不是使用所有关键词。我们还可以从字典中选择几个重叠的单词。C++17 引入了一个 sample 函数,它允许我们从范围中选择一些项目而不进行替换。每个项目被选中的概率是相等的。std::sample 函数位于 algorithm 头文件中。它需要一个第一个和最后一个迭代器,一个输出迭代器来写入样本,选择多少个样本,以及一个随机数生成器。因此,我们可以包含 random 头文件来创建一个生成器

std::mt19937 gen{ std::random_device{}() };

并找到与单词词干匹配的条目。下限与词干匹配:

auto stem = word.substr(offset);
auto lb = dictionary.lower_bound(stem);

下限可能与我们要的词干匹配,也可能不匹配。对于上限,我们想要一个比词干更大的值:

auto beyond_stem = stem;
beyond_stem += ('z' + 1);
auto ub = dictionary.upper_bound(beyond_stem);

超出词干可以确保我们找到任何以几个字母匹配的单词。如果我们正在寻找 "pet",我们希望包括 "petal" 以及任何以 "pet" 开头的其他单词。

如果下限和上限 lbub 相等,我们无法找到一个合适的单词;否则,我们可以从这个范围内采样一个项目到一个 vector 中:

std::vector<std::pair<std::string, std::string>> dest;
std::sample(lb, ub, std::back_inserter(dest), 1, gen);

回到第二章,我们发现帕斯卡三角告诉我们抛硬币或在这种情况下从字典中选择条目可以有多少种组合。选择单个项目并不困难,但选择多个就更加复杂了,因此 C++ 为我们做了这项艰苦的工作。C++20 引入了 sample 算法的范围版本,我们可以用它来选择一些关键词。如果我们使用列表 7.10 加载关键词,我们可以使用 sample 选择 5 个:

std::vector<std::pair<std::string, std::string>> first_words;
std::ranges::sample(keywords, std::back_inserter(first_words), 5, gen);

现在我们已经拥有了创建基于文件中单词和定义的答案砸游戏所需的所有部分。

7.3.3 答案砸

首先,我们需要一个函数来在multimap中选择一个重叠的单词。因为我们可能得到多个匹配的单词或者一个具有两个不同定义的匹配单词,我们将使用我们刚刚遇到的随机sample函数来选择一个。如果我们创建一个函数模板,我们可以传入一个 sample 函数,这使得测试更容易。我们可以使用 lambda 来执行随机抽样或者总是选择第一个或最后一个项目等等,用于测试。使用模板意味着我们应该将函数放在头文件中,所以我们使用我们的Smash.h头文件。

在列表 7.8 中,我们找到了一个重叠的单词并报告了重叠。我们可以返回定义以节省额外的查找,因此我们可以使用tuple来返回单词、定义和偏移量,使用

std::tuple<std::string, std::string, int>

我们需要几个头文件:mapstringtuplevector。然后我们可以编写我们的函数。

列表 7.11 从multimap中选择单词

template <typename T>
std::tuple<std::string, std::string, int>
    select_overlapping_word_from_dictionary(std::string word,
        const std::multimap<std::string, std::string>& dictionary,
        T select_function)
{
    size_t offset = 1;
    while (offset < word.size())
    {
        auto stem = word.substr(offset);
        auto lb = dictionary.lower_bound(stem);                      ❶
        auto beyond_stem = stem;                                     ❶
        beyond_stem += ('z' + 1);                                    ❶
        auto ub = dictionary.upper_bound(beyond_stem);               ❶
        if (lb != dictionary.end() &&                                ❷
            lb != ub)                                                ❷
        {
            std::vector<std::pair<std::string, std::string>> dest;   ❸
            select_function(lb, ub, std::back_inserter(dest));       ❸
            auto found = dest[0].first;                              ❸
            auto definition = dest[0].second;                        ❸
            return { found, definition, offset };    
        }
        ++offset;                                                    ❹
    }
    return {"", "",  - 1};                                           ❺
}

❶ 找到合适的单词

❷ 检查是否找到了合适的单词

❸ 选择字典条目

❹ 没有找到单词,所以再试一次

❺ 没有找到

我们可以测试这个函数。在列表 6.12 中,我们使用了一个假的生成器和分布,但在这里我们只使用一个 lambda 来选择一个项目。我们可以总是选择第一个或最后一个项目进行测试。第一个项目是下界

auto select_first = [](auto lb, auto ub, auto dest) {
    *dest = *lb;
};

并且最后一个项目是上界之前的一个:

auto select_last = [](auto lb, auto ub, auto dest) {
     *dest = *(--ub);
};

我们可以在check_properties函数中使用assert函数再次测试我们的select_overlapping_word_from_dictionary函数。

列表 7.12 测试属性

#include <cassert>
void check_properties()
{
    auto select_first = [](auto lb, auto ub, auto dest) {
        *dest = *lb;
    };
    auto [no_word, no_definition, no_offset] =
        select_overlapping_word_from_dictionary(
            "class", {}, select_first
        );                      ❶
    assert(no_word == "");      ❷
    assert(no_offset == -1);    ❷
}

❶ 使用空 multimap 和 lambda

❷ 未找到合适的单词

最后,我们需要一个新的答案 smash 函数,它接受两个 multimap。这与我们在列表 7.6 中构建的带有 map 的硬编码版本非常相似,但现在它使用 lambda 从字典中采样一个项目:

std::mt19937 gen{ std::random_device{}() };
auto select_one = &gen {
    std::sample(lb, ub, dest, 1, gen);
};

抽取了五个关键词,并找到了字典中的重叠项,给出一个包含worddefinitionoffsettuple以保存额外的查找线索。

列表 7.13 更好的答案 smash 游戏

#include <algorithm>
#include <random>
void answer_smash(
    const std::multimap<std::string, std::string>& keywords,
    const std::multimap<std::string, std::string>& dictionary)
{
    std::mt19937 gen{ std::random_device{}() };                 ❶
    auto select_one = &gen {     ❶
        std::sample(lb, ub, dest, 1, gen);                      ❶
    };                                                          ❶
    const int count = 5;                                        ❷
    std::vector<                                                ❷
        std::pair<std::string, std::string>                     ❷
    > first_words;                                              ❷
    std::ranges::sample(                                        ❷
        keywords,
        std::back_inserter(first_words),
        count,
        gen
    );    
    for (const auto& [word, definition] : first_words)
    {
        auto [second_word, second_definition, offset] =         ❸
            select_overlapping_word_from_dictionary(word,
                                             dictionary,
                                             select_one);
        if (second_word == "")
        {
            continue;                                           ❹
        }
        std::cout << definition << "\nAND\n" <<
           second_definition << '\n';                           ❺
        std::string answer = word.substr(0, offset)

            + second_word;                                      ❻
        std::string response;                                   ❻
        std::getline(std::cin, response);                       ❻
        if (str_tolower(response) == answer)                    ❻
        {
            std::cout << "CORRECT!!!!!!!!!\n";
        }
        else
        {
            std::cout << answer << '\n';
        }
        std::cout << word << ' ' << second_word << "\n\n\n";
    }
}

❶ 将 std::sample 包装在 lambda 中

❷ 抽样五个关键词

❸ 寻找合适的单词

❹ 没有找到,所以再试一次

❺ 显示线索

❻ 检查小写响应

我们可以从main中调用游戏并看看我们做得怎么样。

列表 7.14 合适的答案 smash 游戏

#include "Smash.h"
int main()
{
    using namespace smashing;
    const auto dictionary = load_dictionary(R"(dictionary.csv)");
    const auto keywords = load_dictionary(R"(keywords.csv)");
    answer_smash(keywords, dictionary);
}

不要忘记在构建中包含Smash.cpp文件,并且代码需要从包含字典和关键词文件的目录中运行,或者更改代码中的路径。

在玩游戏时,你应该得到各种线索。有些相当令人愉悦。例如,线索

is a prvalue expression whose value is the address of the implicit object parameter
AND
discipline that interprets past events

"this""history"合并为"thistory"

我们已经构建了我们最初设定的答案 smash 游戏,并在使用std::mapstd::multimap的过程中进行了修订。我们注意到 C++引入了无序映射,所以我们将在下一章更详细地探讨这些内容。

摘要

  • 关联容器是标准模板库(STL)的一部分。

  • std::pair可以持有任何类型的两个值,我们使用firstsecond来访问这些值。

  • std::tuplestd::pair 的一般化,我们使用 std::get 来访问值。

  • 我们可以使用结构化绑定将对、元组等直接绑定到变量中。

  • std::mapoperator[] 可以用来查询和插入元素,所以如果你想避免意外添加元素,请使用 at 函数来查询。

  • std::string_view 可以用来避免字符串的复制,但必须注意其生命周期。

  • std::map 的搜索、删除和插入操作具有对数复杂度。

  • std::map 的键必须支持 std::less 操作符,因此我们可能需要向用户定义的类型添加关系运算符,以便将其用作字典键。

  • std::mapstd::multimapstd::set 是有序关联容器,通常实现为红黑树。

  • std::multimap 支持非唯一键。

  • 为了提高效率,请使用有序关联容器的下界和上界成员函数。

  • 文件是流,因此它们支持 operator<<operator>> 操作符。我们还可以使用 std::getline 从输入文件流中读取整行。

  • std::sample 函数可以从一个范围中选择 k 个不重复的样本。

8 无序映射和协程

本章涵盖

  • 无序映射

  • 散列

  • 协程

在本章中,我们将制作一个匹配便士游戏。这个游戏有两个玩家:我们和计算机。我们每人有一枚硬币,选择正面或反面。如果计算机匹配我们的选择,我们就会输。如果计算机的选择不同,我们就赢。我们可以使用随机分布来为计算机的猜测,因此我们不需要为第一场比赛编写很多代码。

一旦我们使初始的匹配便士游戏运行起来,我们将通过构建一个读心机来查看计算机是否能够预测我们的猜测。说实话,计算机实际上并不能真正地读取我们的思想。克劳德·E·香农在 1953 年写了一篇名为“读心机(?)”的短文(见mng.bz/vPDp)。标题中的问号是故意的。这个游戏已被用于博弈论中的思想实验和心理学研究。读心者需要跟踪之前发生的事情,因此我们将使用std::unordered_map来跟踪状态。在第七章中,我们使用了std::map。在这一章中,我们将使用std::unordered_map进行进一步练习。正如我们在第七章中提到的,std::map需要为它的键定义一个operator<std::unordered_map需要一个hash和一个等价运算符,因此我们还将了解std::hash。计算机将使用状态来预测我们的下一个选择。完成之后,我们将代码包裹在一个协程中以进行额外练习。

8.1 随机生成的匹配便士游戏

要开始,我们将使计算机随机生成一个01,代表正面或反面,使用std::uniform_int_distribution。我们还需要用户输入。在第三章中,我们读取数字进行猜数字游戏,因此我们需要与列表 3.4 中的函数类似的代码。该函数试图从一个流中提取一个数字,并返回一个std::optional。在这种情况下,我们只想接受01。任何其他输入都意味着玩家已经放弃。如果我们得到整个输入作为一个字符串,我们可以将输入与"0""1"进行比较,并返回适当的optional<int>。任何不是01的输入返回一个空的optional,表示玩家想要停止。

列表 8.1 读取optional 01

#include <iostream>
#include <optional>
#include <string>
std::optional<int> read_number(std::istream& in)
{
    std::string line;
    std::getline(in, line);
    if (line == "0") {
        return { 0 };        ❶
    }
    else if (line == "1") {
        return { 1 };        ❷
    }
    return {};               ❸
}

❶ 0

❷ 1

❸ 空的optional表示停止

要构建我们的便士游戏,我们需要计算机随机选择01,因此我们需要一个生成器和分布:

std::mt19937 gen{ std::random_device{}() };
std::uniform_int_distribution dist(0, 1); 

要获取计算机的选择,我们调用dist(gen)。我们比较玩家和计算机的回合来决定谁赢了。如果我们跟踪玩家赢的次数和回合数,我们就可以在游戏停止后报告一些统计数据。将这些内容整合在一起,我们就得到了一个便士游戏。

列表 8.2 便士游戏

#include <random>
void pennies_game()
{
    int player_wins = 0;                                            ❶
    int turns = 0;                                                  ❶
    std::mt19937 gen{ std::random_device{}() };                     ❷
    std::uniform_int_distribution dist(0, 1);                       ❷

    std::cout << "Select 0 or 1 at random and press enter.\n";      ❷
    std::cout << "If the computer predicts your guess it wins.\n";  ❷
    while (true)                                                    ❷
    {                                                               ❷
        const int prediction = dist(gen);                           ❷

        auto input = read_number(std::cin);                         ❸
        if (!input)                                                 ❹
        {                                                           ❹
            break;                                                  ❹
        }
        const int player_choice = input.value();

        ++turns;                                                    ❺
        std::cout << "You pressed " << player_choice                ❺
                  << ", I guessed " << prediction << '\n';          ❺

        if (player_choice != prediction)                            ❺
        {                                                           ❺
            ++player_wins;                                          ❺
        }
    }
    std::cout << "you win " << player_wins << '\n'
        << "I win " << turns - player_wins << '\n';
}

❶ 跟踪统计数据

❷ 计算机回合

❸ 玩家回合

❹ 如果没有选择01则停止

❺ 更新统计数据

我们需要从main函数中调用pennies_game函数,然后我们可以玩游戏。计算机平均可能会赢一半的时间。就目前而言,这个游戏并不那么有趣。如果两个对手都是人类,他们将通过不可预测的行为来试图智胜对方。如果计算机追踪我们的选择,我们面临更大的挑战。让我们通过允许计算机思考来扩展游戏,或者至少基于之前的移动来做出预测。我们能否设法表现得随机并打败计算机?

8.2 使用 unordered_map 进行匹配硬币

香农追踪一个人与他机器对战时的状态。他不是追踪计算机和玩家的确切选择,而是追踪赢或输是否导致改变,以及这种改变是否导致随后的赢或输。例如,这个人可能会输,选择相同,然后再次输。这给出了八种可能的状态,如表 8.1 所示。

表 8.1 硬币游戏的八种可能状态

最后一次结果 选择 最后一次结果 结果

对于每种状态,香农追踪玩家所做的最后两个选择,注意他们是否改变了他们的回合或坚持了相同的选择。如果两个选择匹配,它们形成预测。如果不匹配,占卜者做出随机选择。我们可以从游戏开始时追踪每一个选择,但使用最后两个选择效果很好。让我们思考一下追踪每个状态的选择会发生什么。我们将构建一对针对状态的选择,并使用这些来做出预测,如果它们匹配的话。

假设我们总是选择正面,所以我们从不改变主意。香农的策略能否找出我们在做什么?随着时间的推移,无论我们赢还是输,桌子中间的选择总是“同”,所以只有四行被填充。因为我们总是玩正面,所以最后两个选择最终总是“同”,导致结果列如表 8.2 所示。

表 8.2 如果我们总是选择正面时的状态和对应结果

状态 预测基础
最后一次结果 选择

任何后续的回合都必须对应于四个被填充的行之一,因为选择永远不会改变。机器将找到两个匹配的“同”结果并预测玩家将选择相同,因此它似乎已经读懂了我们的心思。如果我们每次都改变选择,状态表的其余四行最终将被填充成一对“变”,机器再次正确预测。

填充状态表确实需要一些时间。最初,八个状态中没有任何条目,所以计算机随机选择。将这个与玩家的选择进行比较,我们可以知道结果是一个胜利还是一个失败。我们记住这个结果,因为它给出了对应于第一列值的第一个状态的部分。对于第二次出牌,我们仍然没有针对八个状态的条目来用于预测,所以计算机再次随机选择,玩家进行一轮。我们记住了倒数第二个结果,现在知道玩家是否改变了主意,然后赢了或输了。这一轮的额外信息对应于状态表中的最后两列:

{penultimate outcome, choice, last outcome}.

现在我们已经有一个完整的当前状态,我们准备在下一轮添加相应的第一个选择。同样,计算机随机出牌,但现在我们知道玩家是否坚持了同样的选择或改变了它。我们将这个记录为对前一个状态的第一种结果,然后更新为下一次准备的状态。理论上,状态可能和之前一样,所以在下一轮,我们可能有一对完整的条目在一行中;否则,我们可能在另一行中开始一对的选择。随着时间的推移,我们将开始填写选择对,这意味着计算机可能对状态有匹配的结果,并能够进行预测。读心者检查状态表中是否有与当前状态匹配的匹配对。如果有,预测就是该对中的值;否则,就随机选择。玩家也做出他们的选择,赢或输。然后可以更新状态,并将最新的选择存储在相应的值中。

我们注意到,始终切换或始终选择相同的结果会被机器检测到。使用不那么明显的策略,跟踪最后两个移动的八个状态太多,难以记住,所以很难弄清楚机器在做什么。要战胜读心者,最好的办法是跟踪状态,这样我们就能知道它会预测什么,然后做相反的事情。读心者并不是在读取玩家的思想,但跟踪它的行为很难,所以它可能会给人一种读心或任性的印象。像许多机器智能的表象一样,真正发生的是模式匹配或某种类型的统计分析。

而不是使用最后两个状态,我们可以保留每一个选择,并使用多数、移动平均或其他统计方法来进行预测。香农使用一对来保持他构建的电路既小又简单但有效。使用最后两个选择进行预测出奇地有效,所以让我们坚持香农的原始想法。

我们可以将八个状态存储在一个关联容器中,使用 std::tuple 作为三部分键,使用 std::pair 作为两个结果。元组需要一个赢或输,一个选择相同或改变,以及另一个赢或输。类枚举是表示这些的好方法。我们在第五章制作扑克牌游戏套装时遇到了范围枚举。枚举通常比魔法数字更清晰,因为我们可以使用一个名称来表示值,并且类枚举是强类型的,因此它不能错误地隐式转换为整数。选择和结果最初是未知的,因此我们可以使用 ShrugUnset 来表示这些值。我们只需要在 enum 后面添加关键字 class 来创建范围枚举。

列表 8.3 三种可能的选择和结果

enum class Choice
{
    Same,
    Change,
    Shrug,
};
enum class Outcome
{
    Lose,
    Win,
    Unset,
};

我们状态的关键将是一个包含 OutcomeChoice 和另一个 Outcome 的元组,表示表 8.1 中的某一行,而值将是一个 Choices 对,因此我们需要包含 utilitytuple 头文件。我们可以为键和值使用 typedef 来节省每次使用时输入 std::tuple<Outcome, Choice, Outcome>std::pair<Choice, Choice> 的麻烦。我们可以做得比 typedef 更好。C++11 引入了 别名声明,允许我们使用 using 来为现有类型引入别名。我们在第 4.2.2 节中看到了这一点,当时我们定义了世纪并说 using centuries。我们可以写

using state_t = std::tuple<Outcome, Choice, Outcome>;
using last_choices_t = std::pair<Choice, Choice>;

别名声明可以用于模板族,因此它比 typedef 更通用,但如果我们指定所有模板参数,它们是等价的。我们将在下一章进一步练习使用声明。现在,请记住优先使用 using 而不是 typedef

我们已经有了状态的关键和值类型,但需要一个容器。我们在上一章学习了 std::map 并可以再次在这里使用它。然而,C++11 引入了 无序 容器,我们也可以用于查找表,所以让我们来看看这些容器是如何工作的。

8.2.1 无序容器和 std::hash

std::mapstd::multimap 以及 std::setstd::multiset 是使用 std::less 作为排序默认比较的 有序 关联容器。正如我们在上一章所学,元素被排列在一个平衡的二叉树中,因此搜索是 O(log(n))。无序容器使用一个称为哈希表的不同数据结构,它将元素存储在槽或桶中。让我们花点时间来了解一下哈希表。

哈希表使用 hash 函数来计算元素的索引,指示它属于哪个桶。索引允许我们直接跳转到元素所属的桶,而无需在树的一部分中遍历,因此搜索哈希表可能比搜索 std::map 或其他基于树的结构的搜索更快。

现在,两个不同的元素可能具有相同的 hash 值,这被称为 碰撞,因此我们可能在特定的桶中有多个元素。搜索时需要检查桶中的每个元素以找到特定的元素,这会稍微减慢速度。对于一个好的哈希函数,我们不会得到很多冲突,通常可以直接访问只有一个元素的桶,但有时我们可能需要检查桶中的几个元素。在最坏的情况下,我们可能所有的元素都在一个桶中,因此我们的复杂度将是 O(n)。然而,对于一个不错的 hash 函数,我们期望每个桶中只有一个项目,因此搜索的平均复杂度是 O(1)。在正式术语中,我们说大-O 或复杂度是 摊销常数时间。有时,标准会告诉我们一个操作的最好情况复杂度,但有时它会告诉我们平均或摊销时间。

让我们通过将单个字符键映射到整数值来可视化哈希表。如果我们使用键的小写版本的 ASCII 值作为 hash,则相同字母的大小写版本将结束在同一个桶中。如果我们添加两个元素,键为 'c''d',则没有冲突,所以我们最多只有一个元素。

在一个桶中存储元素。然而,如果我们随后添加一个键为 'D' 的元素,就会发生冲突,因为键为 'd''D' 的元素会存储在同一个桶中,如图 8.1 所示。

CH08_F01_Buontempo

图 8.1 两个哈希表,一个没有冲突,另一个有冲突,意味着一个桶包含多个元素

要搜索键为 'd' 的元素,我们需要检查第二个表中的两个元素。现在,碰撞并不是灾难。我们仍然可以找到元素,但使用更好的 hash 函数可以获得更好的性能。

C++11 的 unordered 容器是使用 std::hash 的哈希表,定义在 functional 头文件中,作为 hash 函数。C++ 为各种类型提供了 std::hash 的特化,包括数值类型,以及 std::string 等(见 en.cppreference.com/w/cpp/utility/hash)。如果我们想在无序容器中放置没有 hash 的类型,我们需要提供一个。该类型还必须在 hash 冲突的情况下支持相等性比较。

让我们使用 unordered_map 头文件中的 std::unordered_map 作为我们的状态表。与 std::map 一样,它需要一个 keyvalue 类型,但还需要一个 HashKeyEqual 类型。这些默认为 std::hashstd::equal_to,类似于

template<class Key, class Value, 
    class Hash = std::hash<Key>,
    class KeyEqual = std::equal_to<Key>
> class unordered_map;

我们的关键字是一个 std::tuple,它支持 std::equal_to。这是在 C++14 中引入的,默认为调用给定类型的 operator== 的函数对象。比较元组是即插即用的。给定两个元组

std::tuple t1 = {Outcome::Lose, Choice::Shrug, Outcome::Lose};
std::tuple t2 = {Outcome::Lose, Choice::Shrug, Outcome::Lose};

我们可以检查相等性:

bool match = t1 == t2;

这相当于

bool match = std::equal_to{}.operator(t1, t2);

首先,我们使用{}创建一个std::equal_to实例,然后std::equal_to的调用操作符默认调用operator==。因此,unordered_map类模板中的默认KeyEqual适用于我们的键。然而,std::tuple没有哈希实现,因此我们需要自己编写。我们可以特殊化struct

template<class Key>
struct hash;

对于我们的元组。本身,这个struct并没有做什么。然而,functional头文件中有几个特殊化提供了operator() const,它接受一个Key并返回一个size_t。许多操作符被标记为noexcept,因为它们不会抛出异常。我们将为state_t实现一个特殊化。CppReference 告诉我们,我们可以将自定义的std::hash特殊化注入到标准命名空间中(见en.cppreference.com/w/cpp/utility/hash)。我们通常在我们的命名空间中添加代码,而不是在namespace std中,以避免与标准代码冲突。为特定类型定义std::hash是一个例外。这意味着unordered_map将找到我们键的std::hash特殊化。

要特殊化一个模板,我们声明我们要特殊化的类型。hash只接受一个类型,template<class Key>,所以我们只有一个类型要特殊化。我们从模板头部删除class Key,留下template<>,并在尖括号中指定类型,这给我们

template<>
struct std::hash<state_t>

我们的特殊化需要一个接受键并返回size_t的操作符。它需要是const的,并且我们可以将其标记为noexcept

std::size_t operator()(state_t const& k) const noexcept

我们的元组有三个枚举类型,标准库为枚举类型提供了std::hash的特殊化。我们可以编写一个hash函数,结合各个元素的哈希值,为std::hash<state_t>提供特殊化。如果能找到一个方法来组合哈希值,以避免冲突会很好。对于

{Outcome::Lose, Choice::Shrug, Outcome::Win}

的哈希值之和会映射到与

{Outcome::Win, Choice::Shrug, Outcome::Lose}

相同的哈希值,从而造成冲突。如果我们能使用operator<<对每个元素的哈希值进行位移,就能做得更好。我们已经多次使用过流插入operator<<。内置的算术operator<<适用于数字而不是流,它将位向左移动(见mng.bz/n1D5)。将二进制数1, 1 << 1位移一次得到二进制数10,因为1向左移动了。如果我们再位移一次,2 << 1,我们得到二进制数100。通过不位移第一个元素,将第二个元素位移一位,将最后一个元素位移两位,然后对这三个位移后的哈希值求和,我们恰好避免了键的冲突。我们的方法在一般情况下并不好。我们尝试组合的元素越多,冲突的可能性就越大,位移越向左,最终得到零的可能性就越高。然而,对于我们的少量Outcomes 和Choices,这种方法确实有效。

我们需要包含 functional 头文件以使用 std::hash。我们元组的特化工作如下。

列表 8.4 为我们的状态元组特化 std::hash

#include <functional>
template<>                                                            ❶
struct std::hash<state_t>                                             ❶
{
    std::size_t operator()(state_t const& state) const noexcept       ❷
    {
        std::size_t h1 = std::hash<Outcome>{}(std::get<0>(state));    ❸
        std::size_t h2 = std::hash<Choice>{}(std::get<1>(state));     ❸
        std::size_t h3 = std::hash<Outcome>{}(std::get<2>(state));    ❸
        return h1 + (h2 << 1) + (h3 << 2);                            ❹
    }
}; 

❶ 为 state_t 特化 std::hash

❷ 实现 operator()

❸ 获取每个元素的哈希

❹ 移位和求和

这将适用于我们的特定用例。WG21 讨论了哈希组合函数(见 mng.bz/orDZ)并说实现一个好的 hash 函数并不简单。如果我们需要一个更通用的方法来组合字段以生成合适的哈希,Boost 库有一个 hash_combine 方法(见 mng.bz/6nre)。Boost 是一个历史悠久、免费且经过同行评审的 C++ 库。许多新的 C++ 功能最初都是在 Boost 中出现的,包括智能指针和 optionalany 以及 variant 类型。该库仍然包含许多 C++ 尚未支持但可能有一天会被采用的功能。它很大,但如果您以前从未见过它,那么值得一试。

拥有一个 hash 函数后,我们就可以在 unordered_map 中保存心灵感应机器的状态了。在包含 unordered_map 头文件后,我们可以编写一个返回初始状态的函数。表 8.1 中的八个键在 state_t 元组中表示。元组元素表示损失或胜利,然后是玩家选择“相同”或“交换”,结果是胜利或失败。相应的值是一个存储玩家在状态发生的前两次选择如何选择的对:

const auto unset = std::pair<Choice, Choice>{Choice::Shrug,Choice::Shrug};

我们可以使用初始化列表来初始化 std::unordered_map,就像我们在上一章中为 std::map 所做的那样。

列表 8.5 初始状态表

#include <unordered_map>
std::unordered_map<state_t, last_choices_t> initial_state()
{
    const auto unset = std::pair<Choice, Choice>{Choice::Shrug,
                                                 Choice::Shrug };
    return {
        { {Outcome::Lose, Choice::Same,   Outcome::Lose}, unset },
        { {Outcome::Lose, Choice::Same,   Outcome::Win},  unset },
        { {Outcome::Lose, Choice::Change, Outcome::Lose}, unset },
        { {Outcome::Lose, Choice::Change, Outcome::Win},  unset },
        { {Outcome::Win,  Choice::Same,   Outcome::Lose}, unset },
        { {Outcome::Win,  Choice::Same,   Outcome::Win},  unset },
        { {Outcome::Win,  Choice::Change, Outcome::Lose}, unset },
        { {Outcome::Win,  Choice::Change, Outcome::Win},  unset },
    };
}

我们试图确保不会出现 hash 冲突。对于我们的八个状态,冲突不会明显减慢游戏速度,但我们可以检查每个桶中最多只有一个元素。std::unordered_map 提供了一个 bucket_count,它告诉我们总共有多少个桶,以及一个 bucket_size 函数,它告诉我们特定桶中有多少个项。我们可以使用 assert 来编写一个 check_properties 函数,以验证我们没有任何冲突。

列表 8.6 检查是否存在 hash 冲突

#include <cassert>
void check_properties()
{
    std::unordered_map<
        state_t,
        last_choices_t
    > states = initial_state();

    for (size_t bucket = 0;
            bucket < states.bucket_count();
            bucket++)
    {
        assert(states.bucket_size(bucket) <= 1);    ❶
    }
}

❶ 每个桶最多一个项

测试通过了,但如果我们添加更多状态,我们手工制作的 hash 函数可能会出现问题。编写 hash 函数可能很困难。

现在,我们可以开始根据玩家的选择进行预测。将状态与心灵感应游戏分开意味着我们可以更容易地测试我们的代码。

8.2.2 使用 unordered_map 进行预测

心灵感应者要么根据状态表预测玩家的选择,要么随机选择。我们将保持状态表在一个类中,提供 getter 函数和 update 函数,以便在每个回合后使用。我们可以使用一个由列表 8.5 中的 initial_state 函数初始化的私有状态表。

列表 8.7 用于跟踪游戏状态的类

class State
{
    std::unordered_map<state_t,last_choices_t> state_lookup
                                             = initial_state();   ❶

public:
    last_choices_t choices(const state_t& key) const;             ❷
    void update(const state_t& key,
                const Choice& turn_changed);                      ❸
};

❶ 私有状态

❷ 为给定状态获取选择

❸ 在回合中更新值

我们有八个有效状态,但在我们有一个有效的 state_t 来查找之前需要一些预热。例如,我们将从没有回合开始,所以状态

{Outcome::Unset, Choice::Shrug, Outcome::Unset}

该状态不在表 8.1 中,所以我们将使 choices 函数在这种情况下返回一对 Shrugs。我们尝试在查找中找到一个键。如果找不到元素,find 方法返回 unordered_mapend,因此我们有一个无效状态。如果找到了,我们返回相应的值。

列表 8.8 查找选择或返回两个 Shrugs

last_choices_t choices(const state_t& key) const
{
    if (auto it = state_lookup.find(key);
            it!=state_lookup.end())                  ❶
    {                                                ❶
        return it->second;                           ❶
    }
    else
    {
        return { Choice::Shrug, Choice::Shrug };     ❷
    }
}

❶ 尝试找到键

❷ 在预热阶段,所以 Shrug

要更新状态,我们还需要注意初始的 state_t 不在我们的状态表中。再次尝试找到键:

if (auto it = state_lookup.find(key); it != state_lookup.end())

如果我们有一个有效状态,我们可以从迭代器中获取前两个选择:

const auto [prev2, prev1] = it->second;

我们可以更新键为新的一对:

last_choices_t value{ prev1, turn_changed };
it->second = value;

实际上,更新状态忽略了前几个回合中的无效状态,并且只更新有效状态。

列表 8.9 更新有效键的选择

void update(const state_t& key, const Choice& turn_changed)
{
    if (auto it = state_lookup.find(key);
            it != state_lookup.end())                  ❶
    {
        const auto [prev2, prev1] = it->second;        ❷
        last_choices_t value{ prev1, turn_changed };   ❷
        it->second = value;                            ❸
    }
}

❶ 检查键是否存在

❷ 形成新的选择对

❸ 更新查找

我们可以使用 choices 返回的 last_choices_t 来做出预测,即使对于初始无效状态也是如此。如果两个元素匹配,我们返回该值;否则,我们返回 Choice::Shrug 来表示我们无法做出预测。我们故意为无效状态返回了一对 Shrugs。因为它们匹配,所以对无效状态返回了 Shrug,这样心灵感应者就知道要随机选择。

列表 8.10 从状态中选择

Choice prediction_method(const last_choices_t& choices)
{
    if (choices.first == choices.second)    ❶
    {                                       ❶
        return choices.first;               ❶
    }
    else
    {
        return Choice::Shrug;               ❷
    }
}

❶ 匹配,因此返回任意值

❷ 不匹配,因此无法做出预测

现在我们已经准备好构建一个心灵感应者。它将使用我们的 State 类来进行预测。心灵感应者做出预测,玩家做出选择。然后我们更新状态表,准备进行新的预测。

8.2.3 心灵感应者游戏

我们可以使用列表 8.7 中创建的 State 类来创建一个 mind reader 类。对于某些状态,我们需要一个随机的翻转。我们已经使用生成器和分布使用随机数字几次了。我们可以创建一个模板类,接受这些类型,这样我们可以在测试中伪造它们。当我们测试列表 6.12 中的随机块时,我们使用了一个总是返回 0 的 lambda 作为生成器

[]() { return 0; }

并且可以在这里做同样的事情。对于实际游戏,我们使用一个合适的生成器和返回 01 的分布:

std::mt19937 gen{ std::random_device{}() };
std::uniform_int_distribution dist{ 0, 1 };

使用分布和生成器允许心灵感应者生成一个随机的 01

int flip() { return dist(gen); }

我们可以使用该函数来初始化一个预测变量:

int prediction = flip();

玩家走一步后,心灵感应者的prediction将更新,使用当前状态,因此我们需要一个初始化为

{Outcome::Unset, Choice::Shrug, Outcome::Unset}    

我们很快将定义更新函数。如果它返回一个bool,表示翻转而不是预测,我们可以在玩游戏时跟踪心灵感应者做了多少次猜测。我们的心灵感应器类看起来像这样。

列表 8.11 一个心灵感应器类

template <std::invocable<> T, typename U>
class MindReader {
    State state_table;
    T generator;
    U distribution;
    int prediction = flip();               ❶
    state_t state{                         ❶
        Outcome::Unset,                    ❶
        Choice::Shrug,                     ❶
        Outcome::Unset                     ❶
    };                                     ❶❷
    int previous_go = -1;                  ❶❷
    int flip()                             ❶
    {                                      ❶
        return distribution(generator);    ❶
    }
public:
    MindReader(T gen, U dis)
        : generator(gen), distribution(dis)
    {
    }
    int get_prediction() const
    {
        return prediction;
    }
    bool update(int player_choice);
}; 

❶ 最初做出随机选择

❷ 存储状态和玩家的回合

当玩家走一步时,我们更新心灵感应者,让它知道玩家的选择。首先,玩家的选择要么改变了,要么没有改变,因此它可以用来使用列表 8.9 中显示的函数更新当前状态。我们计算出这一回合是否改变

const Choice turn_changed = player_choice == previous_go ?
                            Choice::Same : Choice::Change;

然后相应地更新状态表:

state_table.update(state, turn_changed);

我们可以将当前的player_choice存储在previous_go中,以便下次使用。

当前状态已经改变,可以做出新的预测,为下一回合做好准备。我们更新状态,将先前的输赢移到元组的开头,并注明这一回合是否改变以及是否获胜:

state = {std::get<2>(state), turn_changed, 
    (player_choice != prediction) ? Outcome::Win : Outcome::Lose};

我们在表中查找该状态,state_table.choices(state),并使用列表 8.10 中的函数来决定一个预测方法。我们得到一个Choice。对于Shrug,我们抛硬币。对于Change,我们想要交换一个0和一个1或反之亦然,因此我们可以使用位运算符^,与1一起计算选择与1的异或,得到相反的结果。如果预测是 Same,我们知道玩家这次选择了什么,因此我们可以相应地更新我们的预测。我们可以在MindReader的新函数中这样做。

列表 8.12 更新预测

bool update_prediction(int player_choice)
{
    bool guessing = false;
    Choice option = prediction_method(state_table.choices(state));
    switch (option)
    {
    case Choice::Shrug:
        prediction = flip();
        guessing = true;
        break;
    case Choice::Change:
        prediction = player_choice ^ 1;
        break;
    case Choice::Same:
        prediction = player_choice;
        break;
    }
    return guessing;
}

update函数在更新状态表和当前状态后使用update_prediction

列表 8.13 心灵感应者的update方法

bool update(int player_choice)
{
    const Choice turn_changed = player_choice == previous_go ?
                                Choice::Same : Choice::Change;
    state_table.update(state, turn_changed);        ❶

    previous_go = player_choice;
    state = {std::get<2>(state),
             turn_changed,
             (player_choice != prediction) ?
                Outcome::Win : Outcome::Lose};      ❷

    return update_prediction(player_choice);        ❸
}

❶ 更新状态表

❷ 更新状态

❸ 做出下一个预测

游戏本身现在非常像我们在列表 8.2 中开始的便士游戏。在主游戏循环中,我们不需要随机选择01,而是需要咨询心灵感应者进行预测。我们还将跟踪猜测次数,并在玩家停止时报告。

列表 8.14 一个心灵感应器游戏

void mind_reader()
{
    int turns = 0;
    int player_wins = 0;
    int guessing = 0;

    std::mt19937 gen{ std::random_device{}() };
    std::uniform_int_distribution dist{ 0, 1 };
    MindReader mr(gen, dist);

    std::cout << "Select 0 or 1 at random and press enter.\n";
    std::cout << "If the computer predicts your guess it wins\n";
    std::cout << "and it can now read your mind.\n";
    while (true)
    {
        const int prediction = mr.get_prediction();              ❶

        auto input = read_number(std::cin);
        if (!input)
        {
            break;
        }
        const int player_choice = input.value();

        ++turns;
        std::cout << "You pressed " << player_choice 
            << ", I guessed " << prediction << '\n';

        if (player_choice != prediction)
        {
            ++player_wins;
        }
        if (mr.update(player_choice))                            ❷
        {
            ++guessing;
        }
    }
    std::cout << "you win " << player_wins << '\n'
        << "machine guessed " << guessing << " times" << '\n'    ❸
        << "machine won " << (turns - player_wins) << '\n';
} 

❶ 咨询心灵感应者

❷ 更新心灵感应者

❸ 报告猜测

main函数中调用此方法,看看你是否能比心灵感应者更聪明。如果你自己跟踪状态,你可以看到它会预测什么并赢得胜利,但没有纸笔,你很可能会忘记。结果证明,随机行为非常困难。

我们有一个心灵感应者,我们可以将其打包到协程中,以了解另一个新的 C++特性。

8.3 协程

协程是在 20 世纪 50 年代发明的,Melvin Conway 在 1958 年提出了这个术语。后来,在 1978 年,Tony Hoare 在 Communications of the ACM 的一篇论文中描述了一种名为 communicating sequential processes(CSP)的协程类型(参见 dl.acm.org/doi/10.1145/359576.359585),并在 1985 年撰写了同名的书籍。他开发了一种使用通过消息传递进行通信的顺序过程进行并发编程的语言。他的方法避免了并发代码中的一些常见问题,例如死锁。他的形式化语言允许进行数学证明,证明这些问题不会发生。在非常高的层面上,这些过程是具有输入和输出的函数。通过连接输入和输出,几个函数可以同时运行,而无需保护共享内存。

C++20 引入了协程(参见 mng.bz/5oEO)。对协程的支持相对较低级,因此 C++ 协程通常需要相当多的样板代码。我们可以编写一个协程来产生玩家的选择和预测。这既不会改变游戏,也不会发挥异步代码的全部威力,但我们将发现构建协程和修改第六章中学习的零规则所需的内容。即使我们不使用协程的全部潜力,了解所需的构建块也是值得的。

协程强大且灵活。挂起和恢复工作,可能在不同的线程上,提供了一种并行性。Lewis Baker 写了一系列博客文章,深入探讨了众多细节(参见 mng.bz/mjda),互联网上关于 C++ 协程的讨论和博客文章也很多,因为它们是一个可以以多种方式使用的大新特性。让我们学习基础知识。

8.3.1 如何创建协程

协程是一个包含一个或多个三个关键字之一的函数:co_yieldco_awaitco_returnYield 返回一个值并暂停函数。协程的状态被封装起来,允许挂起的执行稍后继续。await 表达式调用异步操作,并在该操作完成时恢复return 完成函数。与普通函数不同,协程的生命周期并不绑定到调用者。例如,恢复可以发生在不同的线程上。我们在这里不会使用该功能,而是学习将普通函数转换为协程所需的内容。协程函数返回一个提供所需样板的对象,允许编译器生成协程代码。

在大多数情况下,我们需要为返回的对象编写代码,尽管 C++23 引入了std::generator(mng.bz/7vmv),它提供了一个从简单的生成器协程返回的具体类型。CppReference 提供了从名为letters的协程输出字母表的示例代码。letters函数是一个协程,因为它使用了co_yield。该函数返回一个std::generator,它提供了初始化协程和处理co_yield所需的内容。该函数没有co_return,我们注意到它完成了协程,因此letters可能生成一个无限序列。我们可以多次调用它。例如,我们可以使用 range 的views通过take函数获取前 26 个字母。不幸的是,std::generator尚未得到广泛支持,但 Visual Studio 2022 在experimental/generator头文件中提供了一个experimental版本。

列表 8.15 使用std::generator

#include <experimental/generator>                                 ❶
#include <ranges>

std::experimental::generator<char> letters(char first)            ❷
{
    for (;; co_yield first++);                                    ❸
}

void generator_experiment()
{
    for (const char ch : letters('a') | std::views::take(26))     ❹
        std::cout << ch << ' ';
    std::cout << '\n';
} 

❶ 使用实验性头文件

❷ 协程返回生成器

co_yield使这个函数成为协程。

❹ 按照我们想要的次数调用协程

随着时间的推移,我们可能会看到更多由标准支持的协程的具体返回对象。目前,我们通常不得不自己编写样板代码,除非我们选择的编译器支持std::generator并且适用于我们的用例。

我们将编写一个co_yields玩家输入和心灵感应者预测的协程。调用代码将获取并显示结果。我们的游戏不需要协程版本,但了解如何使用这个新的 C++特性将是有益的。我们将逐步构建协程所需的代码。到目前为止,我们发现协程

  • 是一个包含co_yieldco_awaitco_return的函数

  • 返回一个提供所需样板代码的对象

列表 8.15 有一个co_yield,生成器提供了所需的样板代码。为了将我们的游戏转换为协程,我们将

  • 编写一个包含co_yieldco_return的函数(第 8.3.2 节)

  • 返回一个用户定义的名为Task的类,尽管可以使用任何其他名称(第 8.3.3 节)

  • 实现一个promise_type,必须这样命名,因为编译器期望它(第 8.3.3 节)

Taskpromise_type从协程函数中启动、停止并产生数据,因此我们将添加以下细节:

  • Taskpromise_type的创建和销毁(第 8.3.4 节)

  • 启动和停止协程以及如何co_yield数据或co_return(第 8.3.5 节)

  • 允许调用代码在协程挂起后继续执行,直到游戏结束(第 8.3.6 节)

我们以调用代码使用Task结束,这为我们提供了一个游戏的新版本。

8.3.2 协程函数

在列表 8.14 中,我们编写了一个 mind_reader 函数,处理用户输入,获取预测并显示结果。我们将提取用户输入和预测以形成一个协程。我们需要包含 coroutine 头文件,并且我们的新函数将返回一个提供协程所需样板代码的对象。让我们称它为 Task 并在下一节中实现它。我们将从协程本身开始。

和之前一样,我们创建一个 MindReader 对象,并在用户想要玩游戏时循环。如果玩家放弃,我们的协程将使用 co_return 停止。否则,我们 co_yield 玩家的选择和心灵感应者的预测。将 co_returnco_yield 添加到函数并返回一个合适的对象,就可以创建一个协程。

列表 8.16 我们的第一个协程

#include <coroutine>
struct Task;                                                ❶
Task coroutine_game()                                       ❷
{
    std::mt19937 gen{ std::random_device{}() };
    std::uniform_int_distribution dist{ 0, 1 };
    MindReader mr(gen, dist);
    while (true)
    {
        auto input = read_number(std::cin);
        if (!input)
        {
            co_return;                                      ❸
        }
        int player_choice = input.value();
        co_yield{ player_choice , mr.get_prediction() };    ❹
        mr.update(player_choice);
    }
} 

❶ 前置声明我们将要实现的 Task

❷ 返回合适对象的协程函数

❸ 如果玩家放弃,则停止

❹ 产生玩家的回合和心灵感应者的预测

编译器使用返回的 Task 中的函数来连接所需的生成和返回,以及创建一个协程帧。这封装了函数,允许它在遇到 co_XXX 函数时挂起。当我们生成一个选择和一个预测时,协程将挂起直到恢复。协程然后从下一行开始,状态与它暂停时相同,更新心灵感应者。如果我们调试协程,当我们恢复时,我们似乎会瞬间出现在 while 循环的中间。

协程状态通常是动态分配的,因此它通常被描述为无栈的。实际上,协程是一个捆绑成动态对象的功能,以便它可以暂停(挂起)和恢复直到完成。协程甚至可以在不同的线程上恢复。控制权在调用者和协程之间传递,如图 8.2 所示。

CH08_F02_Buontempo

图 8.2 协程可以根据需要暂停和恢复。

我们前置声明了一个 Task 以从我们的协程返回,所以让我们接下来实现它。

8.3.3 协程的返回对象

协程的返回对象通常被描述为承诺或任务,但我们有权使用我们喜欢的任何名称。我们需要为我们的协程添加几个函数才能使其工作。具体要求因协程而异,但我们总是看到两件事。首先是一个承诺对象,它用于将结果或异常报告给协程外的代码,其次是一个协程句柄,它用于协程内部在完成时恢复执行或销毁协程帧。

让我们逐步构建我们的 Task。编译器要求在 Task 内部有一个名为 promise_type 的东西。我们既可以单独定义一个类并将其添加到任务中的 using 声明中,也可以在 Task 中作为嵌套类内联定义一个类。我们将使用嵌套类,因此我们的协程返回的 Task 将像这样开始。

列表 8.17 连接协程的结构

#include <coroutine>
struct Task                ❶
{
    struct promise_type    ❷
    {
    };
};

❶ 列表 8.16 返回的任务

❷ 所需结构

编译器使用我们从列表 8.16 的 coroutine_game 返回的 Task 以及其 promise_type 来生成代码。我们需要在 promise_typeTask 中添加更多细节,以便我们的 coroutine_game 能够编译。我们可以为我们的返回类型使用任何名称,尽管 Task 是一个常用的名称;然而,我们必须有一个名为 promise_type 的相关类。Taskpromise_type 允许协程开始、停止和产生数据。让我们填写这些细节。

8.3.4 RAII 和零规则

在列表 8.16 中,我们编写了一个返回我们刚刚开始创建的 Task 的协程。编译器为协程生成的代码通过调用一个 get_return_object 函数从 promise_type 获取一个 Task,这个函数的伪代码如下:

promise_type promise;
auto task = promise.get_return_object();

我们没有直接创建一个 Task。只有 promise_typeget_return_object 函数中这样做。目前,我们可以在 promise_type 中添加一个函数:

Task get_return_object() {  return Task{}; }

然而,我们仍然可以在任何地方创建 Task,这对除了编译器之外的其他东西没有多大用处。如果我们给 Task 一个私有构造函数,promise_type 可以创建一个任务,因为我们将其作为内部类创建,但其他任何东西都不能。

此外,我们还注意到承诺对象将结果或异常发送到协程外的代码,我们使用协程句柄来恢复执行或销毁协程帧。协程提供了一个 from_promise 方法来获取 std::coroutine_handle,因此如果我们把 promise_type 的指针存储在 Task

promise_type * promise;

我们可以在需要时使用以下方式包含句柄

auto handle = std::coroutine_handle<promise_type>::from_promise(*promise);

现在,原始指针通常很麻烦。我们不需要删除指针,因为编译器会为我们处理协程的生命周期,但当我们完成时应该调用 destroy 方法。如果我们给 Task 添加一个析构函数,我们可以使用 RAII 执行必要的清理。在析构函数中,我们可以从 promise 创建一个句柄并调用

handle.destroy()

然而,第六章告诉我们,添加我们自己的析构函数阻止了隐式移动,但保留了复制操作。复制 Task 可能会导致资源泄露。我们可以显式删除副本并使移动操作成为默认,或者使用智能指针来处理承诺指针。使用智能指针意味着我们不再需要一个析构函数来为我们清理。

在第六章中,我们遇到了 std::unique_ptr。我们当时接受默认的 "delete" 是因为我们想要删除原始指针。现在我们想要不同的行为。智能指针需要一个类型和一个删除器,默认情况下会调用 delete

template<class T, class Deleter = std::default_delete<T>> class unique_ptr

我们的需要调用 destroy 方法来处理从 from_promise 获取的句柄,句柄与我们的 promise_type 指针相关。我们可以使用类模板编写一个更通用的函数,适用于任何 promise 类型。

列表 8.18 自定义 "deleter"

template<typename Promise>                               ❶
struct coro_deleter                                      ❶
{                                                        ❶
    void operator()(Promise* promise) const noexcept     ❶
    {
        auto handle =
            std::coroutine_handle<Promise>::from_promise(
                *promise
            );                                           ❷
        if (handle)                                      ❸
            handle.destroy();                            ❸
    }
};

❶ 任何 promise 类型的模板函数

❷ 从 promise 获取句柄

❸ 如果有句柄则调用 destroy

然后,我们可以使用我们之前遇到的 using 语句声明一个使用删除器的模板家族。我们使用任何类型 Tstd::unique_ptr,带有 coro_deleter<T>

template<typename T>
using promise_ptr = std::unique_ptr<T, coro_deleter<T>>;

现在,我们可以在 Task 中使用 promise_ptr 并依赖零规则。因为没有必要再定义析构函数,因为 std::unique_ptr 会为我们清理。

现在,我们可以在 Task 中填充更多函数。首先,我们添加一个接受 promise_type 指针的私有构造函数,并将其存储在 promise_ptr 中。然后,我们可以向 promise_type 添加一个返回 Taskget_return_object 函数。

列表 8.19 连接协程的结构

#include <coroutine>
#include <memory>
struct Task
{
    struct promise_type
    {
        Task get_return_object()              ❶
        {
            return Task(this);
        }
    };
private:
    promise_ptr<promise_type> promise;        ❷
    Task(promise_type* p) : promise(p) {}     ❸
};

❶ 只由 promise_type 创建 Task

❷ 用于 RAII 的智能指针

❸ 私有构造函数

我们已经编写了足够的代码来创建 Task 和在完成时销毁协程句柄。我们仍然需要添加一些更多函数来处理创建和销毁之间的操作。让我们填写细节,以便使列表 8.16 中使用的 co_yieldco_return 正常工作。

8.3.5 填充 promise_type

让我们从 promise_type 开始。编译器根据这个类中的函数注入代码。我们总是需要定义三个函数,说明以下情况会发生什么:

  1. 当我们首次启动协程时

  2. 如果抛出异常

  3. 当协程停止时

协程体中任何未捕获的异常都会调用 unhandled_exception 方法。最简单的实现是不做任何事情:

void unhandled_exception() {}

或者,我们可以记录问题甚至调用终止。

我们还需要名为 initial_suspendfinal_suspend 的方法来指示是否挂起。作为协程支持的一部分,C++20 引入了两个辅助类 suspend_alwayssuspend_never,分别用于挂起或不挂起。我们希望我们的协程能够获取用户输入和预测,以便调用代码使用,所以我们使用 suspend_never 来表示它应该最初运行:

std::suspend_never initial_suspend() noexcept { return {}; }

注意我们在 8.2.1 节中编写的 hash 函数时遇到的 noexcept。从不挂起有时被称为热启动,而最初暂停协程则称为冷启动。当我们完成时,我们总是挂起以标记我们已经完成:

std::suspend_always final_suspend() noexcept { return {}; }

这在协程句柄上设置了一个标志,这样 Task 就可以查看协程是否已完成。

我们已经处理了协程的开始和结束,但还没有提供处理 co_awaitco_yieldco_return 的代码。列表 8.16 中的协程提供了玩家的选择和预测:

co_yield { player_choice , mr.get_prediction()};

因此,编译器会在我们的 promise_type 中寻找返回一对 intyield_value 方法。如果我们没有使用 co_yield,我们就不需要这个方法。我们可以在承诺中存储 std::pairint,这样 Task 就可以访问它们并将它们返回到协程外的代码。

yield 之后,我们挂起协程,并通过从 yield_value 方法返回 suspend_always 来表示这一点:

std::suspend_always yield_value(std::pair<int, int> got)
{
    choice_and_prediction = got;
    return {};
}

控制权返回到调用代码。

当玩家放弃时,我们调用co_return,因此我们需要向promise_type添加另一个函数。co_return可以是空的,或者后面跟着一个表达式来返回。我们的co_return是空的,所以我们需要一个return_void方法:

void return_void() {}

如果我们想要返回一个值,我们需要一个return_value函数而不是co_return。我们的完整承诺类型如下。

列表 8.20 完整的承诺类型

struct promise_type
{
    std::pair<int, int> choice_and_prediction;                  ❶

    Task get_return_object()                                    ❷
    {
        return Task(this);
    }
    std::suspend_never initial_suspend() noexcept               ❸
    { 
        return {};
    }   
    std::suspend_always final_suspend() noexcept                ❹
    {
        return {};
    }   
    void unhandled_exception() {}                               ❺
    std::suspend_always yield_value(std::pair<int, int> got)    ❻
    {
        choice_and_prediction = got;
        return {};
    }

    void return_void() { }                                      ❼
};

❶ 数据

❷ 创建任务

❸ 启动

❹ 停止

❺ 异常处理

❻ 由 Task 的 co_yield 调用

❼ 由 Task 的 co_return 调用

我们几乎完成了。promise_type现在拥有了协程所需的所有方法。协程返回的Task为我们提供了一个地方来指示承诺中的数据,并将协程恢复到完成。让我们填补这些缺失的部分。

8.3.6 填充Task类型

要从Task返回选择和预测,我们提供一个获取器函数,从promise_ptr获取数据的std::pair

std::pair<int, int> choice_and_prediction() const
{
    return promise->choice_and_prediction;
}

我们可以通过调用句柄的done方法来检查协程是否完成。当promise_typefinal_suspend方法被调用并返回suspend_always时,此标志被设置为 true。我们使用from_promise方法来获取句柄,然后查看我们是否完成:

bool done() const
{
    auto handle =
        std::coroutine_handle<promise_type>::from_promise(*promise);
    return handle.done();
}

当我们在列表 8.16 中使用co_yield时,协程暂停。然后调用代码使用玩家的选择和心灵感应者的预测做它想做的事情,但它需要一种方法来恢复协程以获取下一对。我们通过调用句柄的operator()()来恢复协程。我们可以在Task中添加一个名为next的函数,以恢复协程:

void next()
{ 
    auto handle =
         std::coroutine_handle<promise_type>::from_promise(*promise);
    handle();
}

当调用代码使用之前的选项和预测时,它可以调用next。将这些新方法添加到Task中,我们得到以下内容。

列表 8.21 协程的Taskpromise_type

struct Task                                         ❶
{
    struct promise_type                             ❷
    {
    // ...
    };

    std::pair<int, int> choice_and_prediction()     ❸
    {
        return promise->choice_and_prediction;
    }
    bool done() const                               ❹
    {
        auto handle =
            std::coroutine_handle<promise_type>::from_promise(*promise);
        return handle.done();
    }
    void next()                                     ❺
    {
        auto handle =
            std::coroutine_handle<promise_type>::from_promise(*promise);
        return handle ();
    }
private:
    promise_ptr<promise_type> promise;              ❻
    Task(promise_type* p) : promise(p) {}           ❼
}; 

❶ 列表 8.16 中协程返回的任务

❷ 列表 8.20 中的 promise_type

❸ 让调用代码从承诺中获取数据

❹ 让调用代码知道我们是否完成

❺ 恢复协程

❻ RAII 的智能指针

❼ promise_type 可见的私有构造函数

我们的Task现在已经完成,我们可以使用协程。

8.3.7 一个协程心灵感应者

要使用我们的协程,我们可以使用类似于列表 8.14 中原始游戏的代码,但现在MindReader和用户输入现在都包含在coroutine_game中。我们通过调用以下内容来调用协程:

Task game = coroutine_game();

我们使用Task来控制协程。我们循环直到done,在每一轮获取玩家的选择和预测。这将在co_yield处暂停协程。然后我们的调用代码重新获得控制权并显示结果。通过在Task上调用next,控制权随后返回到协程,并从上次离开的地方继续。我们的调用代码如下所示。

列表 8.22 一个心灵感应者的协程版本

void coroutine_minder_reader()
{
    int turns = 0;
    int player_wins = 0;

    std::cout << "Select 0 or 1 at random and press enter.\n";
    std::cout << "If the computer predicts your guess it wins\n"
                         "and it can now read your mind.\n";

    Task game = coroutine_game();                     ❶

    while (!game.done())                              ❷
    {
        auto [player_choice, prediction] =
                    game.choice_and_prediction();     ❸
        ++turns;
        std::cout << "You pressed " << player_choice
                  << ", I guessed " << prediction << '\n';

        if (player_choice != prediction)
        {
            ++player_wins;
        }
        game.next();                                  ❹
    }
    std::cout << "you win " << player_wins << '\n'
        << "machine won " << (turns - player_wins) << '\n';
}

❶ 获取协程

❷ 检查用户是否停止

❸ 从协程中获取数据

❹ 允许协程恢复

使用协程对我们来说没有区别,但我们已经使用了 C++20 中经常讨论的一个特性。我们可以扩展这个特性,并编写另一个协程来 co_awaitstd::cin 输入,一个返回随机翻转的函数,甚至另一个心灵感应者。

协程可以在各种地方使用,包括等待输入或其他资源的异步操作。Andreas Fertig 的书 Programming with C++20: Concepts, Coroutines, Ranges, and More (Fertig Publications, 2021) 有一章专门介绍使用协程解析字节流。他在 2022 年在 Overload 上发表了概述(见 accu.org/journals/overload/30/168/fertig/)。Rayner Grimm 在他的博客上列出了几个可能的用例,包括事件驱动编程和协作多任务(见 mng.bz/qjPr)。如果一个协程被挂起,那么程序的其他部分就可以运行,因此协程提供了一个受限的并发模型。

我们已经了解了许多 C++ 特性,我们几乎完成了。现在我们已经多次使用了模板参数包,但我们还没有探讨它们是如何工作的。让我们以最后一章进一步探索模板来结束我们的学习。

摘要

  • 我们可以使用关键字 using 来给声明起别名,包括模板家族。

  • C++ 的无序容器使用哈希表。

  • 哈希表将元素存储在桶中,并使用 hash 函数来定位桶。

  • std::unordered_map 默认使用 std::hashstd::equal_to 作为键。

  • 我们可以将 hash 函数注入到 namespace std 中,以支持在 std::unordered_map 中的用户定义类型。

  • C++ 协程是一个包含一个或多个以下三个关键字之一的函数:co_yieldco_awaitco_return

  • 协程可以被挂起和恢复。

  • 协程的返回类型通常是用户定义的类型,包含启动和停止协程所需的函数,以及根据需要支持 co_yieldco_await 的函数。

  • C++23 引入了 std::generator 作为协程的返回类型,提供了一种可能无限长的序列,但对于其他用途,我们目前不得不自己编写承诺或任务,提供所需的样板代码。

  • 我们为 std::unique_ptr 使用了 自定义删除器,以便我们可以使用零规则。

9 参数包和 std::visit

本章涵盖

  • 使用算法和执行策略进行练习

  • 模板参数包

  • std::visit 方法与 Overload 模式

  • 可变 lambda

  • 使用变体、std::format 和范围进行额外练习

我们已经多次使用参数包(模板中的三个点),但我们没有停下来理解它们是如何工作的。在最后一章中,我们将填补这些点,以及练习我们迄今为止学到的许多东西。我们将生成三角数,并简要考虑它们的某些属性。三角数出现在各种地方(例如,如果每个人在人群中握手,我们可以计算会有多少次握手)。因为我们从帕斯卡三角形开始,回到数字序列感觉像是一个很好的结束方式。

我们将发现我们可以通过几行代码使用数值算法来创建三角数,然后我们将使用前几个三角数构建一个老丨虎丨机。我们首先构建一个简单的机器,它只旋转滚筒。然后我们将改进游戏,允许暂停、推动或旋转。为了实现这些选项,我们将学习 std::visitOverload 模式。我们将练习我们在前几章中学到的知识,这将帮助我们使用新特性编写更多的 C++ 代码,并自信地跟上任何未来的变化。

9.1 三角数

三角数是 1、3、6、10 等等,通过累加 1、1 + 2、1 + 2 + 3、1 + 2 + 3 + 4 等等得到。如果我们堆起这么多台球,我们就可以组成一个三角形。因此得名。为了在图 9.1 中显示的五个三角数上添加另一行,我们使用六个额外的台球。再下一行将增加七个,以此类推。

CH09_F01_Buontempo

图 9.1 堆叠的台球,形成包含 15 = 1 + 2 + 3 + 4 + 5 个台球的三角形

我们将在本章中使用前几个三角数,因此让我们创建一个名为 make_triangle_numbers 的函数。我们将接受一个 count 参数并返回一个 int 类型的 vector。自 C++20 起支持 constexprstd::vectorstd::string(参见 mng.bz/wjDP),因此我们可以将函数标记为 constexpr,这是我们首次在第三章中学习如何使用 static_assert 进行测试时看到的。我们也将能够在这里执行类似的检查。我们的新函数以以下签名开始:

constexpr std::vector<int> make_triangle_numbers(int count)

让我们添加细节。如果我们从数字 1、2、3 等开始,然后我们可以将这些数字相加以获得三角数。C++11 在 numeric 头文件中引入了 iota 函数,该函数使用递增的值填充容器,从选定的值开始。如果我们创建一个可以容纳 20 个数字的向量

std::vector<int> numbers(20);

然后,我们可以调用 iota,从值 1 开始,以创建 1、2、3 等数字:

std::iota(numbers.begin(),numbers.end(), 1);

或者,我们可以使用 C++23 中引入的范围版本:

std::ranges::iota(numbers, 1);

C++23 尚未得到广泛支持,所以你可能不得不等到你的编译器提供 ranges 版本。在任何情况下,这都将vector填充为从 1 开始,每次增加 1 的数字。这给我们 1, 2, 3,...20. iota函数来自 APL 编程语言,并在 C++11 之前提出,但直到后来才被包含进来。这是一个小但有用的函数。

如果我们找到这些数字(1, 1 + 2, 等等)的部分和或累积和,我们得到三角形数。为此,我们可以使用numeric头文件中的std::partial_sum函数:

std::partial_sum(numbers.begin(),numbers.end(),numbers.begin());

然后,我们就得到了我们想要的三角形数(1, 3, 6, 10, 15,...210)。

列表 9.1 制作前几个三角形数

#include <numeric>
#include <vector>
constexpr std::vector<int> make_triangle_numbers(int count)
{
    std::vector<int> numbers(count);                                      ❶
    std::iota(numbers.begin(), numbers.end(), 1);                         ❷
    std::partial_sum(numbers.begin(), numbers.end(), numbers.begin());    ❸
    return numbers;
}

❶ 默认初始化整数的容器

❷ 填充 1, 2,...

❸ 求和 1, 1 + 2, 1 + 2 + 3,...

我们已经使用了一个较旧的 C++函数std::partial_sum以及来自numeric头文件的较新的std::iota函数。还有许多其他算法我们没有机会在这本书中使用。查看algorithmnumeric头文件,尝试一个你之前没有使用过的算法,或者甚至自己实现一个。这是保持练习的好方法。

9.1.1 使用算法测试我们的三角形数

我们应该测试我们的三角形数,并且可以使用一些额外的算法来做到这一点。我们可以使用adjacent_difference来撤销partial_sum,它给出了容器中相邻元素之间的差值。如果我们为这些差值创建一个vector,我们可以将这些差值与由iota创建的从 1 到 20 的整数进行比较,并且我们可以使用assert来验证它们是否匹配。

列表 9.2 测试我们的三角形数

#include <cassert>
void check_properties()
{
    const int count = 20;
    const auto triangle_numbers = make_triangle_numbers(count);
    std::vector<int> diffs(count);
    std::adjacent_difference(triangle_numbers.begin(),
                                        triangle_numbers.end(),
                                        diffs.begin());          ❶
    std::vector<int> numbers(count);
    std::iota(numbers.begin(), numbers.end(), 1);                ❷
    assert(numbers == diffs);                                    ❷
}

❶ 查找差值

❷ 与 1, 2,...比较

让我们花点时间在我们的测试函数中添加更多的assert。如果我们再次找到adjacent_difference,我们应该得到一个全为1vector。我们可以使用带有 lambda 的all_of算法来检查这一点:

#include <algorithm>
std::adjacent_difference(diffs.begin(), diffs.end(), diffs.begin());
assert(std::all_of(diffs.begin(), diffs.end(),
                   [](int x) { return x == 1; }));

我们可以使用std::coun来计数,以检查我们是否得到了我们开始的数字:

assert(std::count(diffs.begin(), diffs.end(), 1) == count);

我们有一些小的测试,并将很快添加另一个。在我们这样做之前,值得补充一些更多的点。大多数算法都有各种重载。例如,std::count有三个版本(见en.cppreference.com/w/cpp/algorithm/count)。我们使用了第一个版本。第二个版本标记为constexpr,因此可以在编译时使用,第三个版本使用执行策略,允许算法的并行执行。

9.1.2 算法的执行策略

C++17 引入了几个存在于execution头文件中的执行类型策略。默认情况下,使用sequenced_policy,即std::execution::seq,这会导致算法按顺序操作,一次处理一个项目。我们也可以使用std::execution::parstd::execution::par_unseq以及 C++20 的std::execution::unseq。后三种允许并行执行,无序策略可能会以任何顺序执行。它们表明算法可以并行化,所以这是一个许可而不是要求。如果实现不能并行化,这些策略会回退到顺序策略,即使可以实现,代码也可能最终运行得更慢(参见 Bartlomiej Filipek 的博客mng.bz/JdGV)。并行版本给我们提供了一个简单的方法来指示可以将工作分发到不同的线程,但它们并不保证加快代码的执行速度。它们可能会,但设置新线程上的工作可能会有开销。

如果我们将std::execution::par作为第一个参数添加,我们使用并行执行的覆盖:

#include <execution>
assert(std::count(std::execution::par, diffs.begin(), diffs.end(), 1)
                                       == count);

请求并行执行很简单,可能会加快你的代码执行速度。进行实验并测量以查看会发生什么。线程和并行执行是一个很大的话题。安东尼·威廉姆斯的书籍《C++并发实战》(Manning Publications,2019;见mng.bz/PR5n)是一个极好的资源,你可以在互联网上找到他许多的演讲。

9.1.3 可变 lambda

我们到目前为止的测试是必要的,但并不充分。三角形数的封闭形式公式可以直接计算第 n 个数字,如下所示:

09_E01

我们可以利用这种关系使我们的测试足够充分,至少对于前几个数字,通过检查每个值是否与方程的值相匹配。

列表 9.3 检查每个值

for (size_t i=0; i< triangle_numbers.size(); ++i)
{
    const int n = i + 1;
    assert(triangle_numbers[i] == n*(n+1)/2);
} 

我们已经看到,我们通常可以使用算法而不是for循环,并且因为我们想检查关系对所有数字都成立,所以std::all_of将起作用。然而,当我们切换到算法时,我们不再有变量i用于计算。我们可以在 lambda 的方括号[]中声明一个变量,并将 lambda 标记为mutable,这允许我们增加变量。如果没有mutable关键字,我们会得到编译器错误,告诉我们不能在不可变的 lambda 中修改按值捕获的变量。

此外,mutable允许 lambda 修改通过复制捕获的对象,并调用通过复制捕获的对象的非 const 成员函数。使用具有可变 lambda 的std::all_of而不是列表 9.3 中的for循环,我们可以得到以下代码。

列表 9.4 使用可变 lambda 检查每个值

assert(std::all_of(triangle_numbers.begin(), triangle_numbers.end(),
    n = 0 mutable                 ❶
    {                                      ❶
        ++n;                               ❶
        return x == n * (n + 1) / 2;
    }
));

❶ n 设置为 0 且为可变,因为 n 会增加

我们有三角形数和一些测试。如果我们停下来看看更多的属性,我们可以在算法上得到更多的练习。我们还将发现一个有用的属性,使三角形数适合用于我们的老丨虎丨机。

9.1.4 三角形数的更多属性

首先,让我们考虑三角形数是奇数还是偶数。然后,我们将找到另一个我们可以用于老丨虎丨机的模式。在调查过程中,我们还将对算法和std::map进行更多的练习。前两个三角形数,1 和 3,是奇数,然后我们得到两个偶数,6 和 10。这个模式会继续吗?我们将通过将我们的vector转换为标记奇数(点'.')和偶数(星号'*')来找出答案。

我们可以声明另一个vector来保存转换。我们在第七章中使用了algorithm头文件中的std::transform算法来将std::string中的字符转换为小写。有各种重载,但每个都应用于输入范围并存储结果。原始版本接受一对输入迭代器,第一个和最后一个,一个输出迭代器,和一个一元函数:一个接受一个输入的函数,就像我们的 lambda。C++20 引入了范围版本,它接受一个输入源,而不是一对迭代器,以及输出迭代器和一元函数。还有一个接受两个输入范围和用于创建输出的二元函数的版本,以及一个接受执行策略的版本。

让我们编写一个名为demo_further_properties的函数。我们将为每个数字使用一个字符,因此我们可以使用charvector来存储结果:

std::vector<char> odd_or_even

我们可以编写一个转换函数的 lambda 表达式,它接受一个int并返回表示数字奇偶性的适当字符:

[](int i) { return i%2? '.':'*'; }

如果i%2不为零,我们有一个奇数,因此我们返回'.';否则,我们返回'*'。我们在转换中使用这个,使用back_inserter按需增长输出:

std::vector<char> odd_or_even;
std::ranges::transform(triangle_numbers,
    std::back_inserter(odd_or_even),
    [](int i) { return i%2? '.':'*'; });

我们可以使用基于范围的for循环来显示数字的奇偶性,但在第二章中,我们提到我们可以使用std::copy或范围版本的函数将容器的内容插入到流中。第一个参数是容器,或其beginend,第二个是一个使用流(在我们的情况下,std::cout)和分隔符(例如,空格)构造的std::ostream_iterator。一旦包含iostream头文件,我们就可以在单行代码中流式传输奇数或偶数标记:

std::ranges::copy(odd_or_even, std::ostream_iterator<char>(std::cout, " "));

我们的进一步属性函数看起来像这样。

列表 9.5 检查数字是否为奇数或偶数

#include <algorithm>
#include <iostream>
#include <iterator>

void demo_further_properties()
{
    const int count = 20;
    const auto triangle_numbers = make_triangle_numbers(count);
    std::vector<char> odd_or_even;                       ❶
    std::ranges::transform(triangle_numbers,
        std::back_inserter(odd_or_even),
        [](int i) { return i % 2 ? '.' : '*'; });        ❷
    std::ranges::copy(odd_or_even,
        std::ostream_iterator<char>(std::cout, " "));    ❸
    std::cout << '\n';
}

❶ 结果向量

❷ 检查奇偶性的 Lambda

❸ 复制到 cout

如果我们从main中调用它并查看输出,我们看到

. . * * . . * * . . * * . . * * . .

看起来我们确实得到了两个奇数后面跟着两个偶数,反复出现。Stack Exchange 的数学网站解释了为什么会这样(见mng.bz/1JBj)。

我们发现了一个有趣的模式。为了构建老丨虎丨机,我们希望在某个卷轴上显示一些项目。如果某些项目匹配,老丨虎丨机将支付。三角数的最后一位数字有另一个模式。一些数字出现的频率比其他数字高,因此我们可以使用三角数的最后一位数字来构建老丨虎丨机。出现频率较低的数字将提供更高的支付。通过在 std::map 中保持计数并计算 % 10 而不是 % 2,我们将看到每个数字出现的频率。我们需要将最后一位数字(一个 int)映射到一个计数,因此包含 map 头文件后,我们可以使用

std::map<int, size_t> last_digits;

在我们的 demo_further_properties 函数中。我们可以根据数字的可能性来决定老丨虎丨机的支付。我们将使用原始循环来找到每个三角数的最后一位数字。我们需要使用 operator[] 来查找数字 % 10,并增加我们获得的价值。我们了解到,在第七章中构建答案砸游戏时,operator[] 将在键不存在时将键值对插入到映射中。相应的值是值的默认类型,在我们的情况下,是一个 size_t 的 0。这正是我们所需要的。我们创建最后一位数字的计数如下:

for (int number: triangle_numbers)
{
    ++last_digits[number % 10];
}

我们可以输出计数,以便我们知道哪些数字发生得最频繁:

for (const auto& [key, value] : last_digits)
{
     std::cout << key << " : " << value << '\n';
}

将其拉入函数中,我们得到以下内容。

列表 9.6 将数字的计数添加到更多属性中

#include <map>

void demo_further_properties()
{
    const int count = 20;
    const auto triangle_numbers = make_triangle_numbers(count);
    // ... as before
    std::map<int, size_t> last_digits;                  ❶
    for (int number: triangle_numbers)
    {
        ++last_digits[number % 10];                     ❷
    }
    std::cout << 
        "Tallies of the final digits of the first 20 triangle numbers\n";
    for (const auto& [key, value] : last_digits)        ❸
    {                                                   ❸
        std::cout << key << " : " << value << '\n';     ❸
    }
}

❶ 使用地图存储计数

❷ 计算最后一位数字

❸ 输出结果

main 中调用这个函数,我们看到

0 : 4
1 : 4
3 : 2
5 : 4
6 : 4
8 : 2

8s 和 3s 不太可能;0156 的可能性是两倍。事实上,最后一位数字重复的模式

13605186556815063100

反复进行。如果我们选择任意三个三角数,我们不太可能得到三个 3s 或 8s 作为最后一位数字,所以这样的结果可能是一个游戏的奖金。

让我们使用三个三角数卷轴来构建一个老丨虎丨机。我们需要制作三个卷轴,将数字随机排列。我们还希望显示卷轴,并在每次转动时使它们旋转,决定是否支付。

9.2 一个简单的老丨虎丨机

我们需要三个数字卷轴来旋转。我们将展示当前行的数字,以及上方和下方行的数字。我们可以用这样的 '-' 符号来表示当前行:

   28  91 153
-  45 120  45-
   36   1   3

我们将首先在每次转动时旋转卷轴。如果两个最后一位数字匹配,我们将支付,如果三个都匹配,我们将支付更多。一旦我们有一个工作的游戏,我们将在 9.3 节中扩展它,如果得到三个 3s 或 8s,我们将颁发奖金。

9.2.1 constexpr 和 std::format 的修订

列表 9.1 生成三角数作为 std::vector<int>。如果我们利用上一章中遇到的 using 语句,我们就不必每次提到卷轴时都拼写 std::vector<int>

using Reel = std::vector<int>;

这可以放在 main.cpp 文件的顶部附近。现在我们可以为老丨虎丨机制作三个卷轴,每个卷轴有 20 个数字,在一个名为 make_reels 的新函数中:

constexpr int numbers = 20;
constexpr size_t number_of_reels = 3u;
std::vector<Reel> reels(number_of_reels, make_triangle_numbers(numbers));

游戏中的数字应该是洗好的。我们可以直接在 reel 上使用 std::shuffle

std::shuffle(reel.begin(),     
    reel.end(),std::mt19937(std::random_device{}()));

然而,我们知道测试具有随机行为的代码可能很困难。如果我们使用一个带有可调用函数的模板而不是随机数生成器,我们可以为测试替换生成器。可调用函数将两个迭代器放入 Reelvector 中,因此我们使用

std::invocable<std::vector<Reel>::iterator,         
    std::vector<Reel>::iterator>

而不是模板头中的 typename 关键字:

template<std::invocable<std::vector<Reel>::iterator,
         std::vector<Reel>::iterator> T>    

我们将得到

template<typename T>    

但使用概念而不是原始类型名称意味着如果我们没有为 T 提供合适的类型,我们可能会得到更清晰的诊断信息。

我们需要包含 concepts 头文件,并且我们可以将函数标记为 constexpr。我们的 make_reels 函数看起来像这样。

列表 9.7 设置 reels

#include <concepts>
template<std::invocable<std::vector<Reel>::iterator,
         std::vector<Reel>::iterator> T>                       ❶
constexpr std::vector<Reel> make_reels(int numbers,            ❶
                                       int number_of_reels,    ❶
                                       T shuffle)              ❶
{
    std::vector<Reel> reels(number_of_reels,
                            make_triangle_numbers(numbers));   ❷

    for (auto& reel : reels)
    {
        shuffle(reel.begin(), reel.end());                     ❸
    }
    return reels;
}

❶ 传递洗牌以允许测试

❷ 创建 reels

❸ 洗牌 reels

我们可以通过两种方式调用此代码。要使用我们将在不久后创建的游戏中的函数,我们需要一个已播种的生成器

std::random_device rd;
std::mt19937 gen{ rd() };

通过 lambda 引用捕获此生成器:

auto shuffle = &gen 
               { std::shuffle(begin, end, gen); };

然后,我们可以使用我们的 lambda 调用 make_reels

std::vector<Reel> reels = make_reels(numbers, number_of_reels, shuffle);

此外,由于函数是 constexpr,我们可以在第 9.2 列表中开始的 check_properties 函数中使用 static_assert,用无操作 lambda 模拟随机行为:

constexpr auto no_op = [](auto begin, auto end) { };
static_assert(make_reels(1, 1, no_op).size() == 1);

这并不测试很多,但表明了可能的情况。

拥有三组洗好的 reels,我们需要显示每组的数字。我们将显示上一行、当前行和下一行,用 '-' 标记当前行。我们在第二章中使用了 std::format,所以让我们再次使用它来练习。如果你的编译器不支持 std::format,请回顾第二章中关于使用 fmt 库的说明。数字最多三位,因此我们将它们右对齐到三个字符,并用空格填充。我们在冒号后放置一个格式说明符,使用 > 进行右对齐,使用 3 表示空格数,给出 {:>3}。我们传递 reels 和流,以便我们可以测试我们的代码。

列表 9.8 显示 reels

#include <format>
void show_reels(std::ostream& s, 
    const std::vector<int>& left,
    const std::vector<int>& middle,
    const std::vector<int>& right)
{
    s << std::format(" {:>3} {:>3} {:>3}\n", 
                left.back(), middle.back(), right.back());    ❶
    s << std::format("-{:>3} {:>3} {:>3}-\n",
                left[0], middle[0], right[0]);                ❷
    s << std::format(" {:>3} {:>3} {:>3}\n",
                left[1], middle[1], right[1]);                ❸
}

❶ 上一行

❷ 使用 - 标记当前行

❸ 下一行

我们已经设置了 reels,现在可以显示它们。为了制作游戏,我们需要决定当前行是否应获得某种类型的回报,然后我们需要旋转 reels。我们还想有一种停止游戏的方法。我们可以使用 getline,就像我们之前做的那样:

std::string response;
std::getline(std::cin, response);

如果 response 不是按下 Enter,我们将退出。让我们先旋转 reels,然后再构建游戏。

9.2.2 使用 std::rotate 旋转 reels

algorithm 头文件提供了一个 std::rotate 函数,我们可以用它来旋转。此函数对元素执行左旋转。给定一些元素

std::vector v{1, 2, 3, 4, 5}

我们可以将其可视化为一个可以旋转或旋转的 reel,如图 9.2 所示。

CH09_F02_Buontempo

图 9.2 元素排列在一个可以旋转或旋转的 reel

我们可以通过指定开始、中间(比如说,从开始起的数字 4,即三个位置)和结束来对元素执行左旋转:

std::rotate(v.begin(), v.begin() + 3, v.end());

使用 v.begin() + 3 作为中间位置将数字 4 移到前面,之前的元素移动到末尾,所以我们得到

4, 5, 1, 2, 3

就像数字盘旋转了一样。排列成盘状,数字会向左旋转,如图 9.3 所示。

CH09_F03_Buontempo

图 9.3 向左旋转将选中的中间位置旋转到开始位置。

初始时,我们有 1, 2, 3, 4 和 5。我们选择 begin + 3 的中间位置,将 4 移到前面。现在 1 在 begin + 2 的位置,因此我们可以再次旋转,使用 1 的位置。

std::rotate(v.begin(), v.begin() + 2, v.end());

并且元素最终回到了它们开始的位置。

我们希望随机旋转老丨虎丨机的盘,改变使用的中间值。参数是迭代器,因此我们可以向盘的开始添加一个随机数来选择要使用的中间值。我们有一个随机数生成器,我们用它来进行初始洗牌。我们现在还需要一个分布。我们希望盘上的每个数字都是可能的,同时也希望盘可以移动,因此我们需要从第二个元素到最后一个元素。我们可以使用从 1 到包括盘大小 − 1 的分布来生成要添加到 begin 的偏移量:

std::uniform_int_distribution dist(1, numbers - 1);

如果我们允许 0,盘就不会移动。然后我们可以旋转所有三个盘:

for (auto& reel : reels)
{
    std::rotate(reel.begin(), reel.begin() + dist(gen), reel.end());
}

我们将在下一节简单老丨虎丨机函数中直接使用这个方法。

旋转函数在 C++ 中已经存在很长时间了。如果我们查看 CppReference (mng.bz/27E0),我们会注意到一个接受执行策略的版本,这是 C++17 中引入的,还有一个 constexpr 版本,这是 C++20 中引入的,以及一个指向 ranges 版本的链接。我们现在已经习惯了这些新特性,在查找算法时经常会看到它们。我们需要一个额外的函数来计算支付金额。然后我们可以创建我们的游戏。

9.2.3 简单老丨虎丨机

为了决定支付金额,我们需要检查最后几位数字是否匹配。所有三个匹配的比两个匹配的值得更多,而没有任何匹配则什么也得不到,所以现在我们将为三个匹配奖励 2,为两个匹配奖励 1。

列表 9.9 计算支付金额

int calculate_payout(int left, int middle, int right)
{
    int payout = 0;
    if (left == middle && middle == right)    ❶
    {
        payout = 2;
    }
    else if (left == middle 
            || middle == right
            || left == right)                 ❷
    {
        payout = 1;
    }
    return payout;
}

❶ 三个匹配

❷ 两个匹配

现在,如果我们想给 38 这样的数字更高的支付金额,这些数字出现的概率较低,我们可能会陷入 ifelse 的混乱之中。我们将在添加更多游戏功能时再次讨论这个问题。现在,我们已经有了制作简单老丨虎丨机所需的所有部分。

我们设置盘,显示数字,如果一条线赢了就发放支付金额。玩家可以按 Enter 键继续或按其他任何键退出。如果他们继续,我们旋转盘并再次显示数字。

列表 9.10 简单老丨虎丨机

#include <iostream>
#include <random>
#include <string>
#include <vector>

void triangle_machine_spins_only()
{
    constexpr int numbers = 20;
    constexpr size_t number_of_reels = 3u;
    std::random_device rd;
    std::mt19937 gen{ rd() };
    auto shuffle = &gen 
                         { std::shuffle(begin, end, gen); };
    std::vector<Reel> reels = make_reels(numbers,
                                         number_of_reels,
                                         shuffle);            ❶

    std::uniform_int_distribution dist(1, numbers - 1);       ❷
    int credit = 1;                                           ❸
    while (true)
    {
        show_reels(std::cout, reels[0], reels[1], reels[2]);
        const int payout = calculate_payout(reels[0][0]%10,
                                         reels[1][0]%10,
                                         reels[2][0]%10);
        --credit;    
        credit += payout;    
        std::cout << "won " << payout
                  << " credit = " << credit << '\n';

        std::string response;                                 ❹
        std::getline(std::cin, response);                     ❹
        if (response != "")                                   ❹
        {                                                     ❹
            break;                                            ❹
        }
        for (auto& reel : reels)                              ❺
        {
            std::rotate(reel.begin(),
                       reel.begin() + dist(gen),              ❻
                       reel.end());
        }
    }
}

❶ 设置

❷ 随机整数旋转盘

❸ 跟踪信用值

❹ 允许玩家退出

❺ 旋转盘

❻ 随机整数旋转盘

如果我们从 main 中调用它,我们就可以玩游戏了。我们可能不会经常赢,所以看着我们的信用值慢慢减少。一个典型的输出可能看起来像这样:

  15   1  36
-136  78  91-
   6   3  15
won 0 credit = 0

 210   3  45
- 45   6  66-
  10 153 105
won 1 credit = 0

  36 210 171
-  1 171 153-
  15 190  28
won 1 credit = 0

   3   1 190
-210  78   6-
  45   3 171
won 0 credit = -1

  78  78 171
- 66   3 153-
  21   6  28
won 1 credit = -1

奖金并不非常公平,因为两个或三个匹配的最终数字不太可能。我们可以给出一个更公平的奖金。如果我们还允许滚筒被持有或轻推一个,我们就有更大的机会获胜。我们可以使用更多新的 C++特性,包括std::visit来实现这一点。让我们构建一个更好的老丨虎丨机。

9.3 更好的老丨虎丨机

我们将进行两项更改。首先,我们将提高奖金,然后我们将允许持有或轻推。让我们先处理奖金问题。奖金基于左、中、右数字的最后一位。我们知道38在最初的 20 个三角形数字中只出现两次,所以每个数字有 1/10 的机会出现。因此,得到三个3的概率是 1/10×1/10×1/10 = 1/1000,得到三个8的概率也是如此。其他数字出现的可能性更大。这次我们也将每次游戏收费两个信用点。在不进行全面分析的情况下,我们可以给得到三个38的玩家 250 个信用点,给其他任何三个匹配数字的玩家 15 个信用点。两个匹配数字出现的可能性更大,所以我们可以给两个3或两个8的玩家 15 个信用点,而其他情况只给 1 个信用点。

9.3.1 参数包和折叠表达式

在我们之前计算奖金时,我们没有使用加权,并指出如果我们添加更多条件,我们可能会需要几个ifelse。让我们采取不同的方法。如果我们找到最终数字的频率,然后我们可以选择最频繁的数字来计算奖金。我们使用三个滚筒,所以我们需要一个接受三个数字并返回从数字到频率的map的函数:

#include <map>
std::map<int, size_t> counter = frequencies(left, middle, right);

而不是编写一个接受三个数字的函数,我们可以做更通用的事情。我们已经使用了 STL 中的几个类,这些类接受不同数量的参数,包括一个variant。在第五章中,我们提到了它的定义:

template <class... Types>
class variant;

对于一个variant,我们使用一个类型。我们还可以使用非类型模板参数。例如,我们在第四章中遇到了std::ratio,使用int来形成分数,如std::ratio<3, 6>。我们接受variant中的三个点或省略号表示一个参数包,允许我们声明我们想要的任何类型。我们可以在函数模板以及类中使用参数包,还可以使用非类型模板参数包。我们可以使用具有非类型模板参数包的函数来查找频率。我们需要解包参数来找到频率。

通常,一个可变模板是一个至少有一个参数包的模板。这些在 C++11 中被引入,但随着语言的发展,它们的使用变得更加容易。在 C++11 中,我们需要使用递归来解包参数,使用一个项目然后再次调用函数,使用剩余的项目。C++17 引入了折叠表达式(见en.cppreference.com/w/cpp/language/fold),避免了递归的需求。

让我们尝试一个例子。我们可以编写一个折叠表达式来求和一项或多项。之后,我们将能够使用变长模板来找到改进的老丨虎丨机收益所需的频率。我们需要注意三个地方参数包。首先,我们说typename... Ts来表示零个或多个参数:

template <typename... Ts>

在这里通常使用Ts而不是T来引起对可能存在多个Ts的注意。我们可以自由地使用我们想要的任何名称。我们可以使用classtypename,然后是省略号,然后是我们的名称Ts。接下来,函数的参数是一个类型为Ts...tail。注意省略号现在出现在Ts之后了。最后,在实现中,我们再次使用三个点与operator+结合来找到和。返回类型取决于参数,因此我们可以使用auto,编译器会为我们计算出结果。

列表 9.11 折叠示例

template<typename... Ts>      ❶
auto add(const Ts&... tail)   ❷
{
    return (... + tail);      ❸
}

❶ 模板头中的点

❷ 函数签名中的点

❸ 在函数中解包点

... +解包了tail,被称为折叠表达式。这样的表达式告诉编译器对变长参数包中的每个元素重复操作符。我们可以使用operator-代替,或者任何适用于参数的其他操作符。我们还可以使用以下方式解包:

return (tail + ...);

我们可以检查几个数字的值:

assert(6==add(1, 2, 3));

参数123通过... + tail被解包为一个左结合表达式:

((1 + 2) + 3)

如果我们在右边有省略号,我们将有一个右结合表达式:

(1 + (2 + 3))

对于数字的加法,边没有关系。减法会有所不同,因为

((1 - 2) - 3) = -1 -3 = -4

whereas

(1 - (2 - 3)) = 1 - (-1) = 2

我们也可以使用一个单个数字:

assert(1 == add(1));

如果没有数字,我们的函数无法编译。如果我们尝试

assert(0 == add());

我们被告知,在+上的一个一元折叠表达式必须有一个非空展开。一元折叠有一个包和操作符,要么是一个右折叠

tail operator ...

或者一个左折叠:

... operator tail

一元折叠对空包不起作用。我们可以使用二元折叠代替,提供一个初始值init,要么是一个右折叠,初始值在右边

tail operator ... operator init

或者一个左折叠,初始值在左边:

init operator ... operator tail

我们可以将返回语句更改为使用二元折叠,提供一个初始值为0

return (0 + ... + tail);

然后,我们需要能够将尾部的值加到0上。

坚持使用一元折叠,我们还可以添加其他支持operator+的类型;例如,一些字符串:

using namespace std::literals;
assert(add("Hello"s, "again"s, "world"s)=="Helloagainworld"s);

注意,如果没有使用概念来约束模板,如果类型没有适当的operator+,我们将得到大量的编译器错误。此外,我们现在有三个add的实例化,因为我们有三个调用,一个使用一个int,一个使用三个int

auto add<int>(int)
auto add<int, int, int>(int, int, int)

以及一个使用三个字符串作为参数的例子:

auto add<std::string, std::string, std::string>(std::string, std:: string, std:: string)

折叠表达式功能强大,我们只是触及了表面。对于更多示例,请参阅www.foonathan.net/2020/05/fold-tricks/

9.3.2 使用参数包查找频率

回到我们的游戏。让我们编写一个函数来找出回报的数字频率,这样我们就可以找出当前行上哪个数字出现得最频繁。我们在 9.1.2 节中使用了std::map<int, size_t>来找出每个最后一位数字在三角数中出现的频率。现在我们可以使用另一个可变参数模板做类似的事情。在新函数中,我们不会计算最后一位数字,而是编写一个通用的频率函数。我们的游戏将发送最后一位数字,就像我们在列表 9.9 中调用之前的calculate_payout函数时做的那样。

我们需要一个可以接受可变数量数字的函数。我们只使用三个数字,但可以编写一个通用函数来练习。对于可变参数模板,我们注意到我们在typename之后放置三个点,然后在函数签名中的参数之前放置点:

template<typename... Ts>
std::map<int, size_t> frequencies(Ts... numbers)

我们可以调用函数,喜欢用多少数字就用多少,这意味着如果我们愿意,可以将我们的机器推广到拥有超过三个滚筒。回想一下,我们也可以使用auto而不是模板头:

std::map<int, size_t> frequencies(auto... numbers)

在实现函数之前,我们应该确保这些数字实际上是数字,使用一个概念。我们在add中没有这样做,因此我们可以专注于点,但注意如果没有概念,我们可能会得到很多编译器错误。我们正在做一个计数,所以我们需要一个整数或可以转换为整数的某种东西来计数,std::convertible_to<int>就做到了我们想要的事情。我们在auto之前添加要求如下:

#include <concepts>
std::map<int, size_t> frequencies(std::convertible_to<int> auto... numbers) 

现在我们可以实现这个函数。我们有一些数字,或者至少是可以通过static_cast<int>转换为int的元素。我们在上一节中使用了带点的运算符来解包参数。我们还可以将参数解包到一个初始化列表中:

{ static_cast<int>(numbers)... }

然后,我们可以使用初始化列表在基于范围的for循环中填充频率的map

列表 9.12 使用参数包查找频率

#include <map>
std::map<int, size_t> frequencies(std::convertible_to<int> auto... numbers)
{
    std::map<int, size_t> counter{};
    for (int i : { static_cast<int>(numbers)... })    ❶
    {
        counter[i]++;                                 ❷
    }
    return counter;
}

❶ 将参数解包到初始化列表中

❷ 保持计数

我们可以使用频率函数处理不同数量的数字:

auto tally_of_3 = frequencies(1, 3, 5);
auto tally_of_4 = frequencies(1, 3, 5, 999);

我们得到一个显示每个数字出现频率的映射。

我们的老丨虎丨机将发送左、中、右的数字,就像我们在列表 9.9 中计算回报时做的那样。我们将编写一个新的函数来计算更公平的回报,该函数将像之前一样从每个滚筒中获取最后一位数字:

int calculate_payout(int left, int middle, int right)

然后,我们可以计算当前行中每个数字出现的频率

std::map<int, size_t> counter = frequencies(left, middle, right);

并使用这些计数来决定回报。我们可以根据每个结果的可能性给出一个更公平的回报,而不是我们之前的方法,即三个匹配得2分,两个匹配得1分。

9.3.3 更公平的回报

我们有三个滚筒,所以最终数字出现一次、两次或三次。如果我们找到出现频率最高的数字,我们可以用它来决定支付。算法头定义了std::max_element,它使用默认的operator<按顺序在一个范围内找到最大的元素。我们的频率包含键值对,我们想要具有最大值的元素。键是这对的第一个元素,值是第二个,因此我们在 lambda 中用第二个元素进行比较:

auto it = std::max_element(counter.begin(), counter.end(),
     [](auto it1, auto it2) { return it1.second < it2.second; });

假设计数器不为空,我们得到一个元素的迭代器,并颁发适当的支付。我们现在将每次旋转的费用定为 2 分。正如我们注意到的,38出现的可能性较小。奖金是三个匹配的38的最终数字,因此我们用 250 分来颁发这个奖金。三个其他匹配的最终数字得到 15 分。两个38可以得到 10 分,任何其他匹配的对得到 1 分。如果最终数字是38,我们可以使用一个std::array,在索引对应频率的位置上有正确的支付:

constexpr std::array value = {0, 0, 10, 250};

零或一个给出0,而两个给出 10 分的信用,三个给出 250 分的奖金。同样,对于更可能的数字,我们可以使用

constexpr std::array value = {0, 0, 1, 15};

以获得 1 或 15 的支付。

列表 9.13 更公平的支付

#include <array>
int calculate_payout(int left, int middle, int right)
{
    std::map<int, size_t> counter = frequencies(left,
                                        middle,
                                        right);
    auto it = std::max_element(counter.begin(),
             counter.end(),
             [](auto it1, auto it2) {
                return it1.second < it2.second;
             });
    if (it != counter.end())
    {
        int digit = it->first;
        size_t count = it->second;
        if (digit == 8 || digit == 3)
        {
            constexpr std::array value = { 0, 0, 10, 250 };
            return value[count];
        }
        else
        {
            constexpr std::array value = { 0, 0, 1, 15 };
            return value[count];
        }
    }
    return 0;
}

我们现在有一个更好的支付函数,并且已经学习了更多的 C++。如果我们给旋转的磁铁添加保持和推动,我们将有一个更好的游戏,并且可以使用另一个新的 C++特性。

9.3.4 允许保持、推动或旋转

我们最初的游戏只提供旋转。在我们的改进游戏中,我们将做两件事之一。如果玩家赢了,他们可以退出或让滚筒在下一轮旋转。否则,每个滚筒他们有三个选项。在简单老丨虎丨机的输出中,第一次旋转给出

 210   3  45
- 45   6  66-
  10 153 105
won 1 credit = 0

如果我们被允许保持45,旋转中间的滚筒,并推动右侧的滚筒以将105向上移动,我们将有两个以5结尾的数字,因此我们将获得一些信用。例如,我们可能最终得到

 210 210  66
- 45 171 105-
  10 190  15

中间的滚筒旋转了,所以它可以是任何东西,但我们注定要在左边有45,在右边有105,至少有两个匹配的最后数字。

我们可以使用空的struct来指示如何移动每个滚筒,并在variant中保持一个这样的实例。我们之前已经使用过variant,所以一些额外的练习是有用的。我们包含variant头文件,并使用using指令命名我们的variant。它可以是三个空struct之一。

列表 9.14 允许更多选项

#include <variant>
struct Hold {};
struct Nudge {};
struct Spin {};
using options = std::variant<Hold, Nudge, Spin>;

如果玩家上次赢了,他们可以退出或按 Enter 键旋转所有三个滚筒。我们可以用一个optionsvector来表示这一点:

std::vector<options>{Spin{}, Spin{}, Spin{}}

我们可以使用std::getline,就像我们在列表 9.10 中的简单老丨虎丨机中做的那样,来填充一个std::string

std::string response;
std::getline(std::cin, response); 

如果响应是 Enter,我们得到一个空字符串,然后游戏应该旋转所有三个滚筒。我们可以将解析放入一个函数中。optional是一个合适的返回类型。我们还可以将函数标记为constexpr,允许我们在static_assert中使用它。

列表 9.15 Enter 的三个旋转

#include <optional>
#include <string>
#include <vector>
constexpr std::optional<std::vector<options>> 
                parse_enter(const std::string& response)
{
    if(response.empty())                ❶
    {                                   ❶
        return std::vector<options>{    ❶
            Spin{},                     ❶
            Spin{},                     ❶
            Spin{}};                    ❶
    }
    else
    {
        return {};                      ❷
    }
}

❶ 按下了 Enter,因此返回三个旋转

❷ 按下了其他键,因此返回一个空的 optional

如果玩家输入了某些内容,我们应该检查他们是否真的想退出。我们将询问,并给他们按下 Enter 继续游戏的机会。

列表 9.16 检查是否按下 Enter

std::optional<std::vector<options>> get_enter()
{
    std::cout << "Enter to play\n";
    std::string response;
    std::getline(std::cin, response);
    auto got = parse_enter(response);                    ❶
    if (!got)                                            ❷
    {                                                    ❷
        std::cout << "Are you sure you want to quit? "   ❷
                     "Press Enter to keep playing\n";    ❷
        std::getline(std::cin, response);                ❷
        got = parse_enter(response);                     ❷
    }
    return got;
}

❶ Enter 按下时旋转三次

❷ 检查玩家是否真的想退出

如果玩家没有赢,他们可以保持、轻推或旋转每个滚筒。我们可以得到之前那样的响应,并逐个检查字符,看看玩家想对每个滚筒做什么。我们可以用 'h''n''s' 分别表示保持、轻推或旋转。按下 Enter 可以表示旋转所有三个滚筒,就像赢之后那样。其他任何操作都表示玩家希望停止。首先,我们想要将一个字符映射到我们的一种结构中,所以我们使用一个 constexpr 函数并返回一个 optional

列表 9.17 将字符映射到动作

#include <optional>
constexpr std::optional<options> map_input(char c)
{
    switch (c)
    {
    case 'h':
        return Hold{};
        break;
    case 'n':
        return Nudge{};
        break;
    case 's':
        return Spin{};
        break;
    }
    return {};
}

我们决定接受 Enter 以进行三次旋转,以节省玩家一些按键。我们将每个字母映射,将相应的选项放入一个 vector 中。同样,我们使用 constexpr 并返回一个 optional

列表 9.18 检查保持、轻推或旋转

constexpr std::optional<std::vector<options>> 
                parse_input(const std::string & response)
{
    std::vector<options> choice;
    for (char c : response)
    {
        auto first = map_input(c);
        if (first)
        {
            choice.push_back(first.value());
        }
        else
        {
            return {};
        }
    }
    return choice.empty() ? 
        std::vector<options>{Spin{}, Spin{}, Spin{}} : choice;
}

如果玩家在上一次尝试中没有赢,我们可以使用我们的解析函数来检查他们的选项。如果输入无效,即空或过长,我们将检查他们是否想退出。

列表 9.19 检查选项

std::optional<std::vector<options>> get_input(size_t expected_length)
{
    std::cout << "Hold (h), spin(s), nudge(n) or Enter for spins\n";
    std::string response;
    std::getline(std::cin, response;
    auto got = parse_input(response);                      ❶
    if (!got || response.length()>expected_length)         ❷
    {                                                      ❷
        std::cout << "Are you sure you want to quit?\n";   ❷
        std::getline(std::cin, response);                  ❷
        got = parse_input(response);                       ❷
    }
    return got;
}

❶ 解析输入

❷ 检查他们是否想退出

在我们原始的游戏列表 9.10 中,我们在主游戏中检查响应,以确定是旋转还是退出。这次,我们将根据玩家是否赢了来调用适当的函数:

std::optional<std::vector<options>> choice = won ?
                                             get_enter() : get_input();

现在,我们需要根据玩家的选择适当地移动滚筒。之前,我们使用了 std::rotate 来旋转所有三个滚筒。现在,我们需要根据玩家的选择采取适当的行动。使用 variant 作为 options 允许我们使用另一个有用的 C++ 功能,即幸运的选择。

9.3.5 使用 std::visit 和 std::views::zip 旋转滚筒

在第五章中,当我们想要在我们的牌组中添加王牌时,我们使用了 std::variant,并且我们还使用了 std::holds_alternative 来检测王牌。现在我们有了三种可能类型之一。variant 头文件包含一个名为 std::visit 的方法,它允许我们提供一个可调用对象,该对象接受变体中每个可能的类型(见 mng.bz/RmoK)。我们可以使用大量的 ifelse 语句自己构建一些东西,基于 std::holds_alternative,但很容易忘记为变体中的某个类型添加一个分支。使用 std::visit 而不是意味着如果我们遗漏了一个替代方案,我们会得到一个编译错误。该函数将一个可调用对象应用于一个或多个变体:

template <class R, class Visitor, class... Variants>
constexpr R visit( Visitor&& vis, Variants&&... vars );

返回值 R 可以是 void。变体 vars 是参数包中的一个或多个变体。访问者 vis 是任何可以调用变体类型的可调用对象。可调用对象可以是一个 struct,每个类型都有一个重载的 operator()

列表 9.20 为 std::visit 提供可调用对象的一种方法

struct RollMethod
{
    void operator()(Hold)
    void operator()(Nudge)
    void operator()(Spin)
};

给定一个玩家的选项 opt,我们然后可以调用

std::visit(RollMethod{}, opt);

并且会调用适当的 operator()。这比构建一个长函数检查 std::holds_alternative 更干净,如果我们忘记了一个类型的重载,编译器会给出错误。

我们还可以结合另一个变长模板进行更多练习。Lambdas 是可调用的,因此有一个 operator()。通过创建一个从 lambda 派生的类模板,我们可以使用 using 语句暴露该 lambda 的 operator()

列表 9.21 在类中将 operator() 引入作用域

template <typename T>
struct Overload : T {       ❶
    using T::operator();    ❶
};

❶ 从 T 派生并引入 operator() 到作用域

在 C++17 和 v17 之前的 Clang 版本中,我们需要提供一个 模板推导指南,告诉编译器如何推导模板参数。指南显示了如何将一组构造函数参数解释为类的模板参数,因此对于我们的类型 T,我们希望 Overload(T) 推导出 Overload<T>。因此,我们写下

template<typename T>
Overload(T) -> Overload<T>;

自 C++20 以来,我们不再需要额外的推导指南。struct 允许我们使用 lambda 创建一个 Overload 并调用该 lambda。例如,我们可以在 check_properties 函数中添加一个 assert

auto overload = Overload{ []() { return 0; } };
assert(overload() == 0);

单独使用单个类型的重载并没有太大用处,因为我们只有一个函数。直接使用 lambda 更简单,但我们可以使用参数包来将几个 lambda 组合在一起。这将每个 lambda 的 operator() 引入作用域。再次提醒,我们可能需要推导指南,并且正如我们之前提到的,我们必须考虑参数包中的省略号,有三个地方需要考虑。

列表 9.22 Overload 模式

template <typename... Ts>              ❶
struct Overload : Ts... {              ❷
    using Ts::operator()...;           ❸
};
template<typename... Ts>
Overload(Ts...) -> Overload<Ts...>;    ❹

❶ 模板头中的点

❷ 结构基中的点

❸ 解包点以使用每个 operator()

❹ C++17 的推导指南(以及 v17 之前的 Clang)

我们可以创建一个滚方法,为每个滚筒执行正确的事情,使用列表 9.22 中的 Overload 和三个 lambda。保持(Hold)不做任何事情,微调(nudge)移动滚筒一个位置。旋转(Spin),就像之前一样,通过随机数量旋转,由 random_fn 函数提供。微调和旋转都需要通过引用捕获使用的滚筒。

列表 9.23 保持、微调或旋转 Overload

auto RollMethod = Overload{
    [](Hold) {
    },
    &reel {
        std::rotate(reel.begin(),
            reel.begin() + 1,
            reel.end()); 
    },
    &reel, &random_fn {
        std::rotate(reel.begin(),
        reel.begin() + random_fn(),
        reel.end());
    },
};

现在 std::visit 可以使用 RollMethod 中的适当函数。

列表 9.24 移动滚筒

template<typename T>
void move_reel(std::vector<int>& reel, options opt, T random_fn)
{
    auto RollMethod = Overload{
         [](Hold) {
         },
         &reel {
             std::rotate(reel.begin(),
                         reel.begin() + 1,
                         reel.end()); 
         },
         &reel, &random_fn {
             std::rotate(reel.begin(),
                  reel.begin() + random_fn(),
                     reel.end());
         },
    };
    std::visit(RollMethod, opt);
}

我们现在可以使用玩家的选项移动特定的滚筒。我们有三个滚筒,所以我们要将玩家的选择与滚筒配对。我们有一个滚筒的 vector 和另一个 optionsvector。我们可以在 for 循环中使用索引,但我们可以使用最后一个新特性,即 ranges 的 zip 视图。std::views::zip 是在 C++23 中引入的,所以一些编译器可能还不支持它,但你可以使用 Range-v3 库代替(见 ericniebler.github.io/range-v3/)或使用 for 循环:

for (size_t i = 0; i < reels.size(); ++i)
{
    move_reel(reels[i], choice.value()[i], random_fn);
}

当我们在第二章首次使用 ranges 时,我们遇到了 ranges 的视图,std::view。我们使用了 drop_whilefilter 来获取单个集合的视图。在包含 ranges 头文件后,我们可以使用以下方式将两个 vector 进行 zip

std::views::zip(reels, choice.value())

组合视图给我们每个 vector 中的项目元组,而不进行复制。如果我们对两个容器进行 zip 并迭代,元组会在两个向量之间移动,给我们每个向量中的一个项目。向量没有被连接,而是迭代器在如图 9.4 所示的每个输入集合上移动。

CH09_F04_Buontempo

图 9.4 迭代两个集合的 zip 视图显示了一对项目。

如果我们想的话,可以 zip 超过两个集合。迭代滚筒和选择的组合视图会给我们一个包含两个引用的元组,我们可以在循环中使用它来移动滚筒。我们可以使用结构化绑定来命名元组中的两个项目,并使用列表 9.24 中的 move_reel 方法适当地移动滚筒:

for (auto [reel, option] : std::views::zip(reels, choice.value()))
{
    move_reel(reel, option, random_fn);
}

将所有这些结合起来,就得到了我们的最终游戏。

列表 9.25 改进的三角形数字机

void triangle_machine()
{
    constexpr int numbers = 20;                                          ❶
    constexpr size_t number_of_reels = 3u;                               ❶
    std::random_device rd;                                               ❶
    std::mt19937 gen{ rd() };                                            ❶
    auto shuffle = &gen {                        ❶
                      std::shuffle(begin, end, gen);                     ❶
                   };                                                    ❶
    std::vector<Reel> reels = make_reels(numbers,                        ❶
                                 number_of_reels,                        ❶
                                 shuffle);                               ❶

    std::uniform_int_distribution dist(1, numbers - 1);
    auto random_fn = [&gen, &dist]() { return dist(gen); };
    int credit = 2;
    while (true)
    {
        show_reels(std::cout, reels[0], reels[1], reels[2]);             ❷
        const int won = calculate_payout(reels[0][0] % 10,
                                         reels[1][0] % 10,
                                         reels[2][0] % 10);              ❸
        credit -= 2;                                                     ❹
        credit += won;
        std::cout << "won " << won << " credit = " << credit << '\n';

        std::optional<std::vector<options>> choice = won ?
                           get_enter() : get_input(number_of_reels);     ❺
        if (!choice)                                                     ❺
        {                                                                ❺
            break;                                                       ❺
        }                                                                ❺

        for (auto [reel, option] :                                       ❺
                std::views::zip(reels, choice.value()))                  ❺
        {
            move_reel(reel, option, random_fn);                          ❻
        }
    }
}

❶ 如前所述设置

❷ 如前所述显示滚筒

❸ 改进的回报

❹ 为这款游戏收取更多费用

❺ 参与赢取;否则保持、轻推或旋转

❻ 适当地移动滚筒

我们从 main 中调用我们的新游戏,有更大的机会获得一些积分。一个示例游戏可能开始时没有匹配的行:

  28  21 171
-105   3  36-
 153 136  45
won 0 credit = 0
Hold (h), spin(s), nudge(n) or Enter for spins

如果我们保持 105,旋转中间滚筒,并轻推最后一个滚筒以将 45 向上移动,我们至少会有两个匹配的最后一位数字,所以我们可以至少赢得 1 个积分,尽管我们必须为这一轮支付 2 个积分:

hsn
  28  36  36
-105 120  45-
 153  45   1
won 1 credit = -1
Enter to play

然后,我们必须让所有滚筒旋转,因为我们刚刚赢得了一些东西:

  66 136 153
-  1  66  10-
   6 105  21
won 0 credit = -3
Hold (h), spin(s), nudge(n) or Enter for spins

我们的积分减少,但我们可以保持、旋转和轻推:

hsn
  66  21  10
-  1   3  21-
   6 136  91
won 1 credit = -4
Enter to play

我们有更大的赢面,所以游戏更有趣。我们还学习了更多的 C++。

我们还没有涵盖 C++ 中的每一个新特性,随着语言的不断发展,总有更多东西需要学习。从使用 vector 开始,并找到一些小游戏和项目,让我们有了大量的实践机会。现在,我们处于一个很好的位置来保持我们的技能更新。使用 CppReference,并通过添加你发现的缺失示例来帮助他人。尝试使用 Compiler Explorer 和 C++ Insights。关注 ISOCpp 网站以获取最新的新闻、文章和播客。持续学习和实践,最重要的是,享受乐趣!

摘要

  • 使用 std::iota 以递增的方式填充容器,从选定的值开始。

  • 许多算法支持执行策略,这为我们请求并行化提供了一个简单的方法。这是一个请求,可能无法实现,在这种情况下,执行将回退到顺序策略。

  • 我们可以将 lambda 标记为 mutable,以允许它修改由 copy 捕获的对象,并调用它们的非 const 成员函数。

  • 使用概念而不是模板中的原始类型名称意味着,如果我们使用模板时没有提供合适的类型,我们可能会得到更清晰的诊断信息。

  • 从 C++20 开始,我们可以几乎使用constexpr来处理任何事情,包括std::vectorstd::string。评估可能发生在编译时,但并不需要。我们可以使用static_assert来测试constexpr代码。

  • 可变参数模板是一个至少有一个参数包的模板,由省略号表示。

  • 我们再次使用省略号来解包参数包。在列表 9.11 中,我们使用了(... + tail)来解包尾部,我们也可以将一个参数包放入初始化列表中,就像我们在列表 9.12 中使用{ static_cast<int>(numbers)... }所做的那样。

  • 我们可以使用std::visit来为一个std::variant调用一个函数,这确保了我们为任何可能持有的类型都有一个合适的重载。

  • 使用std::visit的一种方法是Overload模式,它使用参数包将operator()引入作用域,允许我们将每个类型的 lambda 打包到一个std::variant中。

  • 最后,我们使用了来自ranges库的std::views::zip来配对两个集合。我们可以配对超过两个集合,然后可以遍历视图中的元素元组。

  • 持续学习和实践,最重要的是,享受这个过程!

附录。更多资源

本附录包含每章提到的资源列表,以便于查阅。

第一章

  • 国际标准化组织(ISO)的第 21 工作组(WG21)的一些详细信息可在 isocpp.org/std 找到。

  • IncludeCpp 群组有一个 Discord 服务器,并在会议上经常设立摊位(www.includecpp.org/).

  • ISOCpp 网站有一个常见问题解答部分(isocpp.org/wiki/faq),提供了关于一些最近的 C++ 变更和宏观问题的概述。

  • C++ Insights (cppinsights.io/) 将代码转换,使我们能够看到编译器为我们所做的工作。

  • Matt Godbolt 的 Compiler Explorer (godbolt.org/) 支持大量不同的编译器,使我们能够看到每个编译器的行为,而无需安装它们。

  • CppReference 列出了每个新特性的编译器支持列表(en.cppreference.com/w/cpp/compiler_support)。

第二章

第三章

  • 我们使用random_device为随机数生成器设置种子(en.cppreference.com/w/cpp/numeric/random/random_device

  • Angelika Langer 和 Klaus Kreft 合著了一本名为《Standard C++ IOStreams and Locales: Advanced Programmer’s Guide and Reference》(Addison-Wesley Professional, 2000)的书。

  • 将 lambda 复制到std::function可能效率不高。Scott Meyers 在他的书《Effective Modern C++》(O'Reilly Media, Incorporated, 2014)中的“第 5 项:优先使用 auto 而非显式类型声明”中提供了全部细节。

  • 我们提到了一个建议,即引入std::function_ref作为std::function的替代方案,以克服性能问题(www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p0792r10.html)。Open Standards 小组在 WG21 目录中收集了与 C++相关的各种建议和论文。

第四章

第五章

第六章

第七章

  • Tim van Deurzen 在 2019 年的 Meeting C++ 上就结构化绑定进行了闪电演讲(见 www.youtube.com/watch?v=YC_TMAbHyQU))。

  • 想要了解更多关于 std::string_view 的细节,请参阅 www.modernescpp.com/index.php/c-17-avoid-copying-with-std-string-view

  • Nico Josuttis 的著作 《标准库,第二版》(Addison-Wesley Professional,2005)是一本优秀的参考书,可以了解更多关于容器和算法等内容。

  • Donald Knuth 的著作 《计算机程序设计艺术》,第三卷(Addison-Wesley Professional,2008),详细介绍了二叉树的平衡重排。

第八章

第九章

posted @ 2025-11-09 18:02  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报