揭穿-C---的迷思-全-
揭穿 C++ 的迷思(全)
原文:
zh.annas-archive.org/md5/e19ec4b9c1d08c12abd2983dace7ff20译者:飞龙
前言
想象 C++是一种神话般的古老语言,源自那些在低级魔法的烈火中锻造的语言,经过高级咒语的精确锻造。它诞生于控制机器和提供抽象的需求,是那些寻求弥合原始机器和高级结构之间鸿沟的人使用的工具,同时仍然享有现代工具的奢侈。
想象一下这本书与您所遇到的其他任何书籍都不同。作者们踏上了一场大胆的征程,穿梭在 C++错综复杂的深处,以揭示其真正的本质。他们带着勇气和精确,旨在剥去长期以来围绕这种传奇语言的神化和神秘的面纱,同时应对其胜利和被感知的缺陷。
以开放的心态阅读这本书,因为它承诺了一段与众不同的旅程,一段不适合胆小的人的旅程。作者们一头扎进了 C++复杂的声誉中,面对其臭名昭著的指针和复杂的内存管理,甚至深入到低级 C++的深处,在那里汇编语言统治,指针仅仅是数字。我们审视了存在的不同 C++版本,C++的生态系统,以及如何今天学习它,以及你最好忘记的事情。通过每一章,我们剥去层层面纱,揭示这些强大构造背后的逻辑和优雅。通过 C++传奇故事和一点幽默(尽管有时有些可疑),我们旨在保持你的参与度,同时引导你穿越至高无上和荒谬。预期会遇到一些你见过的最糟糕的代码,故意展示给你,教你不要做什么,同时照亮 C++的真正潜力。这是一本不仅旨在教学,而且通过其优点和缺点揭示 C++灵魂的书。
本书面向的对象
这本书以破除神话、半幽默的方式,非常适合那些已经具备 C++基本知识但希望深入了解其细微之处和奥秘的程序员。它也可能吸引那些对 C++语言既强大又复杂的声誉感到好奇的学习者和计算机科学学生。
这个受众包括那些欣赏编程的艺术和哲学方面的人——那些不仅想使用 C++,还想理解为什么它如此运作,以及其最著名(和臭名昭著)的功能背后的传说的开发者。这是为那些将编程不仅仅视为一项技能,而视为一种工艺的人准备的,这种工艺由历史、怪癖甚至一点传奇所塑造。
本书涵盖的内容
第一章 ,C++ 非常难学,探讨了为什么会出现这种情况:是语言本身还是教学方法的问题?我们应该先从指针和内存管理等低级特性开始,还是或许从工作示例或面向对象(OOP)特性开始会更好?此外,每个 C++ 程序员都需要了解相同的 C++ 吗?本章讨论了学习语言的不同方法,重点关注 C++,并决定 C++ 是否在今天仍然难以学习……使用正确的方法。[亚历克斯]
第二章 ,每个 C++ 程序都符合标准,探讨了标题所暗示的问题。在一个理想的世界里,也许它们会是!在现实中,每个 C++ 程序都应该符合标准。然而,正如我们在本章中发现的那样,当它们稍微偏离左右,使用晦涩的编译器扩展、涉足未定义行为或依赖于特定平台的怪癖时,你可能会发现自己陷入只有古代神秘主义者才能解读的错误之中。所以,当然,每个 C++ 程序都是“符合”标准的……直到它不再符合![费伦茨]
第三章 ,只有一个 C++,它是面向对象的,考察了组织代码的不同范例,包括函数式编程、元编程和不太为人所知的极端多态。[亚历克斯]
第四章 ,main() 函数是应用程序的入口点,涵盖了标题中提到的主题。在实践中,正如我们将在本章中展示的,main() 函数就像应用程序的前门:它是一切开始的地方,但如果你窥视其后,你通常会发现一个错综复杂的依赖关系网、库和与操作系统相关的系统调用,这使得到达它感觉更像是在迷宫中导航而不是沿着一条直路行走。[费伦茨]
第五章 ,在 C++ 类中,必须有秩序,探讨了确实,在 C++ 类中必须有秩序,因为没有秩序会出现问题!方法、数据成员、构造函数,每个都必须找到自己的位置!是的,灵活性很重要,但忽视结构是不行的。不尊重有序成员的顺序请求,类就会崩溃!自由过度,行为未定义,随之而来的是错误、bug 和崩溃!混乱,C++ 不能容忍。尊重顺序,和谐才会统治!本章提出的最重要的规则是,C++ 概念的指定顺序很重要。或者它可能根本未指定,但仍然很重要。[ 费伦茨 ]
第六章 ,C++ 并非内存安全,探讨了在 C++ 中内存管理的挑战,现代语言构造的承诺及其失败,以及随着公众对软件可靠性的认识提高。[亚历克斯]
第七章 ,在 C++中做并行和并发没有简单的方法,探讨了并行和并行的需求,现代 C++如何处理它们,以及 actor 模型如何帮助设计产品中的并行性。[Alex]
第八章 ,最快的 C++代码是内联汇编,涵盖了一个我们三十年前就学到的事实。虽然汇编确实提供了低级控制,但现代编译器高度优化,通常生成的代码比手写的汇编更高效,正如我们将在本章中展示的。确实,内联汇编在某些情况下可以提高性能,但它牺牲了可读性和可移植性,因此应谨慎使用,并且只有在绝对必要时才使用。[Ferenc]
第九章 ,C++之美,断言 C++确实很美,因为你在哪里还能找到一个如此优雅地交织着尖括号、分号、花括号和句点的语言?这是一个关键词、模板、古老宏和重载运算符的诗意舞蹈,它们被巧妙地排列,甚至让经验丰富的程序员都会质疑自己的生活选择。确实,正如本章将展示的,如果美意味着一个谜题包裹在谜团之中,那么在预处理后再处理那些无法处理的代码后,只会留下一点困惑。[Ferenc]
第十章 ,C++中没有现代编程的库,评估了 C++库的需求和可用性,包管理的挑战,寻找目标版本和架构的库的困难,以及供应链攻击日益严重的问题。[Alex]
第十一章 ,C++与 C 向后兼容...甚至与 C,探讨了向后兼容性,因为正如我们将在本章中展示的,C++继承了家族的传家宝:一堆混乱的全局变量、尖锐的指针和未定义的行为。C++尽职尽责地让这些遗物保持活力,使得两种语言能够在尴尬但 somehow 功能性的拥抱中共存。兼容性,的确,因为谁不想体验将几十年前的 C 代码与现代 C++混合的刺激?或者与不那么现代的 C++混合?我们的意思是,嘿,传统很重要,我们必须为了生计而奋斗![Ferenc]
第十二章 ,Rust 将取代 C++,探讨了为什么我们有这么多编程语言,Rust 如何融入生态系统,它做得好的地方,C++的回应,以及 Rust 可能取代 C++的条件。[Alex]
要充分发挥这本书的效用
这本书的理想读者是中级到高级的 C++开发者和已经对编程基础有扎实理解并渴望深入了解 C++复杂性的学术学习者。
在实际应用中使用 C++ 的专业人士,对通过汇编语言或高级编译技术优化性能感兴趣的人,以及欣赏语言怪癖和复杂性的爱好者可能会觉得这本书很有趣。
寻求对 C++ 有更深入介绍的大学生,追求展示最新现代 C++ 技术的学者,或正在学习该语言的程序员,请考虑本书不涵盖 C++ 的基础知识,也不包括如何学习它的主题。有其他书籍肯定更适合这项任务,例如 Bjarne Stroustrup(该语言的创造者)所著的 《C++ 编程:原理与实践》(第 3 版)(或者,好吧,任何对你有用的书籍)。
经验丰富的 C++ 开发者想要了解最新的 C++ 标准概述,语言律师,或者那些没有幽默感的人,或者如果你阅读这本书是为了得到一个迫切问题的答案……好吧,你可能根本不会觉得这本书有趣,因为它可能无法回答你的任何问题。可能根本就没有答案。相反,你可能发现阅读后,你比之前有更多的问题。对你来说,我建议阅读 C++ 标准,你所有的疑问都有答案在那里。你已经得到了警告。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| 2025 年相关的各种 C++ 编译器 | Windows、macOS、Linux 或没有任何操作系统 |
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,网址为 github.com/PacktPublishing/Debunking-CPP-Myths。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个示例:“在经过几次迭代后,execve() 系统调用将离开用户空间,最终进入 Linux 内核并创建一个 linux_binprm 结构。”
代码块设置为以下格式:
#include <iostream>
typedef struct S {
int a;
} S, const *CSP;
int main() {
S s1;
s1.a = 1;
CSP ps1 = &s1;
std::cout << ps1->a;
}
任何命令行输入或输出都按以下方式编写:
$ g++ main.cpp a.cpp b.cpp -o test
$ g++ main.cpp b.cpp a.cpp -o test
提示或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 发送电子邮件,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了 Debunking C++ Myths,我们很乐意听听您的想法!请 点击此处直接转到此书的亚马逊评论页面 并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在移动中阅读,但无法携带您的印刷书籍到任何地方吗?
您选择的设备是否与您的电子书购买不兼容?
不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日访问权限。
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781835884782
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。
第一章:C++ 非常难学
如果你想要发挥其全部力量
C++ 的难点及其掌握方法
在本章中,我们将涵盖以下主要主题:
-
为什么 C++ 被认为很难学?
-
C++ 的难点及其掌握方法
-
斯特劳斯特拉斯学习 C++ 的方法
-
学习 C++ 的测试驱动方法
-
权力越大……
技术要求
本章中的代码示例可以在 GitHub 仓库 github.com/PacktPublishing/Debunking-CPP-Myths 的 ch1 文件夹中找到。代码使用 doctest(github.com/doctest/doctest)作为测试库,g++ 和 make 进行编译,并针对 C++ 20。你还需要 valgrind(valgrind.org/)来检查内存泄漏。
为什么 C++ 被认为很难学?
C++ 的早期被视为 C 的扩展,仅使用新的范式,面向对象编程(OOP),因此承诺解决不断增长的代码库中的许多问题。这个初始版本的 C++ 非常严格;你,程序员,必须深入理解内存分配和释放的工作原理以及指针算术的工作原理,同时要防范你可能会错过的一系列细微差别,这些差别通常会导致无用的错误信息。当时程序员普遍的文化氛围是,真正的程序员必须了解 CPU、RAM、各种汇编语言、操作系统的工作原理和编译器。标准化委员会几十年来几乎什么也没做来减少这种错误的可能性,这也不无帮助。难怪这种语言的声誉在几乎 40 年后仍然伴随着它。我学习它的经验仅有助于理解当时学习这种语言的困难。
在 20 世纪 90 年代,我在理工学院学习期间第一次接触到了 C++。它既让我着迷又让我困惑。我理解了这种语言的力量,尽管它正在与我作对——或者至少我是这样认为的。我必须努力编写出能工作的代码。我还不熟悉 STL,那时它还没有成为标准的一部分,所以我的大多数第一个 C++程序都涉及指针的使用。C++考试中常见的问题之一就是区分指针数组与数组指针。我只能想象这种语言的复杂性对于构建考试问题是多么有帮助!
为记录在案,请参阅以下指针到数组和指针数组之间的区别,这是 C++考试中常见的问题:
int(*pointerToArrayOf10Numbers)[10];
*int arrayOfTenPointers[10]
我通过实践和在网上知识对每个人开放之前能找到的书籍继续学习 C++。但对我对这种语言理解的最大的飞跃是在 2000 年左右的一个项目。项目负责人,一位非常技术性的比利时人,为我们设定了非常明确的指导方针和必须遵循的过程,以获得最佳的 C++代码。这种对卓越的需求并不仅仅来自他的愿望,而是来自项目的需求:我们在多年前就构建了一个 NoSQL 数据库引擎,而那时它们还没有被赋予这个标签。
对于这个项目,我必须学习和了解 Scott Meyers 的两本关于 C++的奠基性书籍《Effective C++》和《More Effective C++》中的所有规则。这两本书总共记录了 90 条针对 C++程序员的指南,从资源初始化和释放的问题到提高性能、继承、异常处理等细节。这也是我开始大量使用 STL 的时候,尽管与今天相比,标准库的范围要小得多。
这项新获得的知识使我的 C++程序更加可靠,并提高了我的工作效率。一个重要的贡献因素是我们与两本书的智慧相结合所采用的过程。我们编写了单元测试,进行了设计和代码审查,并精心编写我们的代码,因为我们知道在代码库接受之前,它将被同事剖析。这使得我们的代码几乎无错误,并帮助我们以合理的时间实现了高性能的复杂功能。
然而,这种语言仍在与我们作对。我们知道如何编写好的 C++代码,但这需要一种注意力和关怀,这不可避免地会减慢我们的速度。仅仅掌握 C++是不够的;这种语言必须有所回报。
在这个项目之后,我离开了 C++的世界,学习了 C#和托管 C++、Java、PHP、Python、Haskell、JavaScript 和 Groovy,仅限于那些我用于专业编程的语言。虽然每种编程语言都比 C++提供了更高的抽象层次和更少的烦恼,但我仍然怀念我的编程成长岁月。我知道 C++以及内存管理的所有复杂性,这让我对这些其他语言的内部运作有了深刻的理解,使我能够充分利用它们。Haskell 对我来说非常熟悉,因为它与我从 Andrei Alexandrescu 的奠基性著作《现代 C++设计》中学到的元编程技术密切相关。C++在我的脑海中不仅是我使用的第一个专业编程语言,而且也是我自那以后使用的每种其他语言的基础。
让我高兴的是,大约在 2010 年,消息传来,C++标准化委员会终于开始对语言进行大胆和频繁的改革。上一个 C++标准已经多年是 C++ 98;突然我们每三年就看到一个新版本。这种标准的滚动发布使得函数式编程范式、范围、新的并行和异步编程原语、移动语义的引入成为可能。但对于今天想要学习 C++的人来说,最大的变化是内存管理的简化以及auto类型的引入。这些变化带来的重大突破是,Java 或 C#程序员可以理解现代 C++程序,这是我们当初 Java 和 C#开始时不确定的。
这意味着与 90 年代相比,现在的语言学习要容易得多。这个变化的例子就是关于数组与指针或指针与数组之间区别的旧考试问题已经完全无关紧要;裸数组可以轻易地被vector<>或list<>所替代,而指针则被更精确的shared_pointer<>或unique_pointer<>所取代。这反过来又减少了与指针的分配和释放相关的担忧,从而既清理了代码,又减少了在 C++ 98 中普遍存在的难以理解的错误信息的可能性。
然而,我们无法说 C++语言像今天其他主流语言一样容易学习。让我们看看原因是什么。
C++的难点及其掌握方法
C++像 Java、C#、PHP、JavaScript 或 Python 一样容易学习吗?尽管语言有所改进,但答案可能是:很可能不是。重要的是:C++是否应该像所有这些其他语言一样容易学习?
C++的消亡已经被预测了很长时间。Java、然后是 C#,如今是 Rust,它们依次被吹捧为我们的尊贵辩论主题的完全替代品。然而,每个语言似乎都在开辟自己的领域,而 C++仍然在需要仔细优化的程序或工作在受限环境中的程序中处于领先地位。今天,数百万行 C++代码存在,其中一些已经存在了几十年。虽然其中一些可以被转换为云原生、无服务器或微服务架构,但总会有更适合由 C++提供的工程风格解决的问题。
因此,我们得出结论,C++在开发世界中有着自己的目的,任何新的编程语言都面临着一场艰难的挑战,以取代它。这一观察带来了其后果:C++的某些特定部分将必然比其他语言更难以掌握。虽然 Java 或 C#可以让你免于思考内存分配以及当将参数传递给另一个方法时内存会发生什么,但 C++需要直面这些问题,并允许你根据上下文优化你的代码。
因此,如果你想理解 C++,你无法逃避内存管理。幸运的是,这已经不像以前那样成为一个大问题。
让我们通过观察不同语言如何管理内存分配和释放来分析差异。Java 使用完全的面向对象(OO)方法,其中每个值都是一个对象。C#的设计者决定使用包括典型数值、字符、结构体和枚举在内的值类型,以及与对象相对应的引用类型。在 Python 中,每个值都是一个对象,类型可以在程序中稍后确定。所有这些三种语言都具备垃圾回收器来处理内存释放。Python 语言除了垃圾回收器外,还使用引用计数机制,因此可以可选地禁用它。
C++ 98 标准没有提供任何内置的指针释放机制,而是将内存管理的全部权力和责任交给了程序员。不幸的是,这导致了问题。假设你初始化了一个指针并为一个值分配了一个大内存区域。然后你将这个指针传递给其他方法。谁负责释放内存?
例如,看看以下简单的代码示例:
BigDataStructure* pData = new pData();
call1(pData);
call2(pData);
call3(pData);
调用者是否应该释放pData分配的内存?是call3来做吗?如果call3调用另一个具有相同pData实例的函数会发生什么?谁负责释放它?如果call2失败会发生什么?
内存释放的责任是不明确的,因此需要为每个函数或每个作用域指定,更准确地说。随着程序和数据流复杂性的增加,这个问题变得更加复杂。这会让大多数使用其他主流语言的程序员感到困惑,或者完全忽略责任,最终导致内存泄漏或调用已释放的内存区域。
Java、C# 和 Python 在不要求程序员小心的情况下解决了所有这些问题。两种技术是有帮助的:引用计数和垃圾回收。引用计数的工作原理如下:每次调用复制值时,引用计数都会增加。当超出作用域时,引用计数会减少。当引用计数达到 0 时,释放内存。垃圾回收器的工作原理类似,只是它们定期运行,并检查循环引用,确保即使复杂的内存结构也能正确释放,尽管可能会有延迟。
即使在 2000 年代,我们也没有阻止在 C++ 中实现引用计数。这种设计模式被称为智能指针,它允许我们不必过多考虑这些问题。
事实上,C++ 从一开始就还有一种更优雅的方式来处理这个问题:引用传递。有很好的理由说明为什么引用传递是 Java、C# 和 Python 中传递对象的默认方式:它非常自然和方便。它允许你创建一个对象,分配其内存,通过引用传递,最好的部分是:它的内存将在退出作用域时自动释放。让我们看看一个与使用指针类似的例子:
BigDataStructure data{};
call1(data);
call2(data);
call3(data);
...
void call1(BigDataStructure& data){
...
}
这次,call1 中发生的事情并不重要;数据初始化的作用域退出后,内存将被正确释放。引用类型的唯一限制是,为变量分配的内存不能被重新分配。就我个人而言,我认为这是一个很大的优点,因为修改数据可能会很快变得混乱;事实上,如果可能的话,我更喜欢用 const& 来传递每个值。然而,对于通过内存重新分配启用的高度优化的多态数据结构,其应用是有限的。
看看前面的程序,如果我们忽略 call1 中的 & 符号,并将函数重命名以符合相应的约定,我们也可以读懂 Java 或 C#。所以,C++ 本可以从一开始就接近这些语言。为什么它现在还不够相似呢?
好吧,在 C++ 中你无法逃避内存管理。前面的代码对 Java 或 C# 程序员来说并不会引起更多的思考;我们已经确定 C++ 是不同的。标准化委员会意识到,在某些情况下,我们需要在一个函数中分配内存,在另一个函数中释放它,并且避免使用指针来做这件事将是理想的。于是,引入了移动语义。
注意
移动语义是 C++11 中引入的一个关键特性,通过消除不必要的对象复制来提高性能。它允许资源从一个对象转移到另一个对象,而不创建副本,这对于管理动态内存、文件句柄或其他资源的对象特别有益。要利用移动语义,你需要实现一个移动构造函数,它通过从rvalue(临时对象)将资源转移到新对象来初始化新对象,以及一个移动赋值运算符,它将资源从 rvalue 转移到你的类中的现有对象。std::move函数是一个工具,它将对象转换为 rvalue 引用,从而启用移动语义。为了帮助,编译器在特定条件下创建移动构造函数。
在以下示例中,我们可以看到如何使用移动语义将变量的作用域移动到函数 process 中:
BigDataStructure data{};
process(data);
...
void process(BigDataStructure&& data){
}
除了使用两个井号符号之外,似乎没有太多不同。然而,行为却非常不同。data变量的作用域移动到被调用的函数中,以及process,内存将在退出时释放。
移动语义允许我们避免复制大数据值,并将释放内存的责任转移到被调用的函数中。这是我们迄今为止讨论的语言中独特的机制。据我所知,唯一其他实现这些机制的编程语言是系统编程的其他竞争者:Rust 和 Swift。
这证明了,尽管 C++现在与 Java 或 C#相似,但它确实要求程序员更详细地了解内存分配和释放的方式。我们可能已经克服了关注微小语法差异但影响很大的考试问题,但我们还没有克服学习比其他语言更多的需求。
尽管内存管理是讨论的大问题的一部分,但它并不是使学习 C++变得更困难唯一的原因。一些事情是不同的,对于新手来说可能有点烦人:
-
需要#ifndef预处理器指令或非标准的但通常支持的#pragma once来确保文件只包含一次
-
将.h文件与任意规则分开,规定什么应该放在.h中,什么应该放在.cpp中
-
使用virtual methodName()=0定义接口的非常奇怪的方式
虽然我们可以通过现代 IDE 自动应用规则和指南来确保我们使用所有这些装置,但它们的出现引发了一个问题:为什么它们仍然需要?
除了上述内容,更难以接受的是,没有简单的方法来构建程序并添加外部引用。尽管 Java 存在许多缺陷,但它有一个单一的编译器,以及 Maven/Gradle 作为标准工具,用于依赖关系管理,允许通过简单的命令下载和集成新的库。C#虽然长时间存在相同的问题,但已经基本标准化了社区创建的 NuGet 命令,用于获取外部库。Python 具有标准的pip命令,用于管理包。
使用 C++,你需要做更多的工作。与依赖于虚拟机的 Java 和 C#不同,你的 C++程序需要为每个支持的目标进行编译,并且每个目标都需要匹配正确的库。当然,有相应的工具。我听说最多的两个包管理器是 Conan 和vcpkg。对于构建系统,CMake 似乎相当受欢迎。问题是,这些工具都不是标准的。虽然 Java 的 Maven/Gradle 和 C#的 NuGet 都不是从标准开始的,但它们的工具集成和快速采用意味着它们现在是事实上的标准。C++还需要一段时间才能使这个语言部分成熟。我们将在单独的章节中更多地讨论这些问题,但很明显,C++的困惑部分也是由尝试简单程序时的这种复杂性产生的。
我们比较了 C++与其他语言的各种复杂性,并看到虽然语言变得更容易,但它仍然不像 Java 或 C#那样容易。但核心问题是:C++是否很难学?为了检验这一点,让我们看看初学者可以用来学习 C++的三种方法。
斯特劳斯特普学习 C++的方法
虽然 C++标准已经向简单化发展,但许多学习材料仍然保持不变。我想象着,鉴于 2010 年之后 C++标准变化的速度加快,跟上 C++标准可能很困难,而且总有一个问题存在:有多少代码使用了最新的标准?学生难道不是无论如何都需要学习 C++的旧方法,以便能够处理几十年前的代码库吗?
尽管存在这种可能性,但我们必须在某一点上前进,Bjarne Stroustrup 也有同样的想法。他的第三版书籍《使用 C++进行编程:原理与实践》(www.amazon.com/Programming-Principles-Practice-Using-C-ebook/dp/B0CW1HXMH3/),于 2024 年出版,面向编程初学者,并引导他们学习 C++语言。这本书是 C++的一个非常好的入门介绍,并附有示例和幻灯片,对任何想要教授或学习这门语言的人来说都很有用。
值得注意的是,斯特劳斯特普并没有回避指针和内存管理这个话题,反而讨论了必要的最小化内容,并立即展示了现代 C++避免这些问题的方法。
以 第十六章 相关的幻灯片为例,该章节专注于数组。它们从裸数组的解释开始,解释它们与指针的联系,以及在使用指针时可能会遇到的问题。然后引入了替代方案:vector、set、map、unordered_map、array、string、unique_ptr、shared_ptr、span 和 not_null。演示文稿以多种方式实现回文示例结束,比较了代码的安全性和简洁性。因此,整个章节的目的是展示数组与指针的各种问题,以及 STL 结构如何帮助避免这些问题。
产生的代码与 Java 或 C# 变体非常相似。然而,斯特劳斯特普指出,指针运算仍然对于实现数据结构是有用的。换句话说,要谨慎使用,并且只有在真正需要重型优化时才使用。
因此,我们得出结论,语言创造者并不回避指针和内存管理,而是专注于消除随之而来的许多潜在问题。这使得 C++ 程序员在 C++ 98 时代相比,对内存管理的关注较少,但仍然比 Java 或 C# 多一点。
问题是仍然存在:初学者能否在不过多考虑指针的情况下学习 C++?另一种教学方法似乎证明了这是可能的——如果我们想训练库用户而不是库创建者的话。
凯特·格雷戈里的方法——不教授 C
在 2015 年的 CppCon 讲座中(www.youtube.com/watch?v=YnWhqhNdYyk),凯特·格雷戈里指出,学习 C++ 并不需要 C 作为先决条件,而且一开始就教授 printf、裸数组以及字符指针,对初学者来说,这实际上是在损害学习过程。
相反,她的建议是从 STL 中可用的对象开始。字符串和向量类对初学者来说相当清晰,并且运算符重载也是使用这些对象的一种非常自然的方式。初学者期望 "abcd" + "efg" 将产生 "abcdefg";没有必要解释运算符重载的复杂性,以便他们可以编写简单的程序。此外,这种方法完全避免了讨论析构器和内存清理。
她继续争辩说,如果从示例开始,教初学者使用 lambda 表达式也很简单。考虑尝试在一个向量中查找一个值。第一种方法可能是使用一个可以快速浏览的 for 循环。第二种方法是使用 std::find。但如果我们想在 vector
她认为,使用这种方法,初学者将能够使用现有的库。他们将在知识上存在一些差距,在为特定代码库工作的程序员课程中,你可能需要有一个部分向他们介绍阅读对他们工作有用的特定习语。如果你想让这些程序员成为库的创建者,那么你需要一个更高级的课程,深入探讨内存管理和指针可能实现的优化。
我在复杂技能培训方面的 15 年经验告诉我,这种教学方法非常好。培训中的一个关键点是理解你的目标受众,并尽最大努力避免知识的诅咒——即你无法回忆起你今天非常熟悉的东西不知道时的感觉。这种方法通过提供快速胜利和良好的进步,以及给予学习者编写代码的勇气,来迎合初学者的心态。因此,它无疑是学习 C++ 方法的改进。
然而,学习语言的方法不止这一种。是的,这是一种结构化的方法,但探索是学习的一个重要部分。有一种通过探索学习 C++ 的方法,它使用通常与推特冲突相关的方法:测试驱动开发(TDD)。
学习 C++ 的测试驱动方法
从书籍或结构化课程中学习只是其中一种方法;另一种是通过个人探索。想象一下学习 C++,但不是先要查看一大堆代码示例,而是编写你认为应该工作的代码,并逐步学习你的直觉与实际语言之间的差异。实际上,人们在通过结构化学习课程时自然会结合这两种方法。
通过探索学习的一个缺点是难以理解你的进度,你可能会经常陷入困境。有一种方法可以解救:TDD。
TDD 是一种反直觉但有效的增量设计方法。其最简单的描述如下:
-
步骤 1,也称为红色:编写一个失败的测试,显示需要实现的下一个案例
-
步骤 2,也称为绿色:编写最简单的代码以使测试通过(并保持所有其他测试通过)
-
步骤 3,也称为重构:重构生产代码和测试代码以简化。
这种红-绿-重构周期在非常小的周期内重复(通常 5-10 分钟),直到所有与当前功能或用户故事相关的行为都已实现。
解决 TDD 误解
个人而言,我是一个 TDD 的粉丝,并且我已经成功使用了超过 10 年。实际上,我使用 TDD 来编写这本书的示例代码。然而,我知道 TDD 在业界受到了不同的评价。部分原因是想象力不足,一个常见的问题是:我该如何为一个不存在的函数编写测试?嗯,基本上和编写一个之前不存在的代码的方式一样:你想象它存在,并专注于期望的输入和输出。其他批评来自于对 TDD 真正是什么以及它是如何工作的理解不足。伪 TDD 失败的例子通常涉及从边缘情况开始,并显示当你应该从正常路径情况开始时,事情会迅速变得复杂。关于 TDD 会减慢开发速度的说法是可信的,但事实是,这种方法帮助我们更加彻底和有计划,从而避免了通常在后期才被发现并需要大量汗水和压力来修复的问题。最后,TDD 不是设计高性能算法的方法,但它可以帮助你找到一个初始解决方案,你随后可以通过测试套件的帮助来优化它。
要理解如何通过修改的 TDD 周期来学习编程语言,我们需要澄清关于 TDD 的两个问题。首先,TDD 是非直观的,因为它要求对问题领域进行长时间的专注,而大多数编程课程教我们如何处理解决方案领域。其次,TDD 是一种增量设计方法;也就是说,以逐步的方式找到一个解决特定问题的代码结构,而不是一次性解决。这两个特性使得 TDD 在有适当支持的情况下,成为学习新编程语言的最佳选择。
想象一下,在你能够运行一个程序之前,不是先学习关于 C++的所有内容,而是先学习如何编写测试。这其实很简单,因为测试通常只使用语言的一小部分。此外,运行测试会立即给你反馈:如果有什么不对的地方,会显示失败或红色,而当一切正常时,会显示成功或绿色。最后,一旦你有一个或多个测试,这让你可以探索一个问题,并找出如何编写代码,使得编译器能够理解它——这正是你学习一门语言时想要的。在 C++中,找出错误信息可能有点问题,但如果你有一个人(或者未来可能是一个 AI)可以求助,你会在学习过程中学到很多东西,并且每当学到新东西时,你都会看到绿色的进度条。
这种方法已经在小规模上进行了测试,并且效果显著。以下是一个 C++学习会议可能的工作方式。
设置
学习过程中至少涉及两个参与者;我们将它们称为教练和学生。我更喜欢使用教练而不是讲师,因为目标是引导学生走他们自己的学习路径,而不是直接教他们东西。
我将讨论剩余的会话,好像只有学生参与一样。类似的设置也可以用于多个学生。
行动者需要做的第一件事是设定一个目标。通常,目标是学习至少 C++,但也可以是更深入地了解某个特定主题——例如,std::vector 或 STL 算法。
在技术设置方面,两个人在同一个显示器上观看代码并并肩工作效果最好。虽然最好是面对面进行,但通过各种工具远程也是可能的。
首先,教练需要设置一个简单的项目,包括测试库、生产代码文件和测试文件。需要提供一个简单的方式来运行测试,无论是通过按钮点击、键盘快捷键还是简单的命令。我推荐的 C++设置是使用 doctest(github.com/doctest/doctest),这是一个仅包含头文件的测试库,它非常快,并支持生产所需的大量功能。
这是这个项目的最简单结构:
-
一个测试文件,test.cpp
-
一个生产头文件,prod.h
-
一个 doctest.h 文件
-
一个允许我们运行测试的 Makefile
根据学习目标,可能还需要一个 production cpp 文件。
教练还需要提供一个第一次测试失败示例,并展示如何运行测试。学生接管键盘并运行测试。这个测试可以非常简单,如下面的示例所示:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include "prod.h"
TEST_CASE("Test Example"){
auto anAnswer = answer();
CHECK(anAnswer);
}
生产头文件显示了以下内容:
bool answer(){
return true;
}
首要任务是让测试通过。教练会不断问学生的一个问题:“你认为这会怎么工作?写下你找到的任何直观的想法。”如果学生找到了正确答案,太好了!如果没有,展示正确答案并解释原因。
这个示例非常有用,因为它介绍了一些语言元素并展示了它们的工作:函数声明、变量、测试和返回值。同时,这个过程也非常好,因为它给学生提供了一个进度衡量标准:测试通过是好的,测试未通过则意味着有东西要学习。
完成所有这些后,就到了探索阶段。
探索语言
以这种方式探索编程语言有两种方法:通过简单的问题逐一引入概念,也称为禅宗,或者通过解决更复杂的问题。
无论哪种方式,方法都是一样的:首先,教练写一个简单的测试或帮助学生写一个失败的简单测试。然后,要求学生写出他们认为最直观的解决方案。运行测试,如果它们没有通过,教练需要解释哪里出了问题。无论是教练还是学生进行更改,当测试通过时,步骤以清晰的进度结束。
在此过程中,重要的是要关注学生的下一步自然步骤。如果学生有具体的问题或好奇心,下一个测试可以处理这些问题,而不是通过脚本化的过程。这种适应性学习方法帮助学生感到掌控全局,这个过程给他们一种自主性的错觉,最终变成现实。
关于内存问题呢?
我们在本章中花了一些时间讨论这样一个事实:与使用其他主流编程语言相比,C++程序员需要学习更多的内存管理知识。他们如何通过这种方法学习内存管理?测试不会捕捉到内存问题,对吧?
事实上,我们希望学生从一开始就意识到他们需要关注内存。因此,内存检查需要集成到我们的测试套件中。我们有两个选择来实现这一点:要么使用专门的工具,要么选择可以检测内存问题的测试库。
如 valgrind 这样的专用工具很容易集成到我们的流程中。请参见以下 Makefile 的示例:
check-leaks: test
valgrind -q --leak-check=full ./out/tests
test: test.cpp
./out/tests
test.cpp: .FORCE
mkdir -p out/
g++ -std=c++20 -I"src/" "test.cpp" -o out/tests
.FORCE:
test.cpp 目标正在编译测试。测试目标依赖于 test.cpp 并运行测试。第一个目标,check-leaks,会自动运行 valgrind,并带有仅当出现错误时显示错误的选项,这样学生就不会感到不知所措。在没有参数的情况下运行 make 时,第一个目标会被选中,因此默认情况下会进行内存分析。
假设我们正在运行带有内存泄漏的测试,如下例所示:
bool answer(){
int* a = new int(4);
return true;
}
我们立即看到了以下输出:
==========================================================[doctest] test cases: 1 | 1 passed | 0 failed | 0 skipped
[doctest] assertions: 1 | 1 passed | 0 failed |
[doctest] Status: SUCCESS!
valgrind -q --leak-check=full ./out/tests
[doctest] doctest version is "2.4.11"
[doctest] run with "--help" for options
==========================================================[doctest] test cases: 1 | 1 passed | 0 failed | 0 skipped
[doctest] assertions: 1 | 1 passed | 0 failed |
[doctest] Status: SUCCESS!
==48400== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==48400== at 0x4849013: operator new(unsigned long) ==48400== by 0x124DC9: answer()
此输出为学生提供了足够的信息进行讨论。
第二种选择是使用已经实现了内存泄漏检测的测试库。CppUTest (cpputest.github.io/) 就是这样一种库,它还有支持 C 和适用于嵌入式代码的优势。
使用这些工具,现在很明显,这种方法适用于向任何想要尝试或深入研究特定部分的 C++学习者教授 C++。
现在我们今天学习了两种学习 C++的方法,让我们回到理解 C++的利基是什么以及为什么它必然比其他语言更复杂。
有很大的力量……
如果我想要你从本章中带走的东西,那就是 C++是一个非常强大的语言,而随着这种力量的到来,程序员有责任使用适当的抽象级别。
我确信,今天开始一个新项目、解决特定商业问题、仅使用最新标准和特定库的 C++程序员团队可以安全地编写代码,并具有良好的性能,无需过多担心内存问题,比他们的 Java 或 C#同事还要少。事实上,他们的代码很可能与其他语言的代码非常相似,预期有更好的性能。
然而,即使是这样一个团队,偶尔也会面临选择:我们是使用 STL 提供的现有工具实现一个稍微低效的解决方案,还是通过递归到指针算术、移动语义或自定义内存管理来优化它?这就是 C++的力量需要同样高水平的责任、细心和深刻理解的时候。
注意
当我写下这些文字时,世界仍在 2024 年 7 月的 CrowdStrike 事件之后陷入混乱。尽管官方已经披露(www.scmagazine.com/news/crowdstrike-discloses-new-technical-details-behind-outage),但事件的起因仍然不十分清楚。无论如何,似乎是一个 C++程序中的内存访问错误导致了全球 Windows 系统的内核恐慌,导致飞机停飞、资金转账停止,以及——最可怕的是——紧急服务关闭。当然,这种变化本不应该进入生产环境,但这却是一个提醒,说明世界对软件的依赖程度有多大,以及滥用 C++力量的后果。
总结
在本章中,我们考察了一个声明:“C++非常难学”。那么,它是吗?
我们回顾了 C++的历史,以及它最初确实是一个挑战,即使是编写最简单的程序。我们看到了 Java、C#和 Python 如何处理程序员面临的某些问题,以及 C++标准在过去 15 年中是如何意外地快速发展的,以消除其障碍。
虽然你现在可以写出类似于 Java 或 C#的 C++代码,但你可能仍然需要理解内存管理,这一点我们通过使用移动语义进行了例证。我们还看到,随着语言和时代的发展,学习 C++的方法也在不断演变,Stroustrup 只是简单介绍了指针,然后迅速转向 STL 中可用的更高级结构。我们看到,修改后的 TDD 循环可以帮助人们以探索的方式学习 C++,而不会因为错误信息的复杂性和语言的复杂性而感到不知所措。
我们还指出,C++在工具和可移植性方面存在劣势。在 C++中安装一个新的依赖项是一项完整的工作,与 Java、Python 或 C#不同,它们提供了一个事实上的标准命令来管理包。这可能会让想要成为 C++程序员的初学者望而却步。
最后,尽管标准有了进步,但我们不能忘记世界上存在的大量 C++代码,这些代码还没有达到最新的标准。很可能,即使你学习了现代 C++,你的工作迟早也会涉及到处理旧代码。
因此,我们得出结论,C++仍然比 Java、C#或 Python 更难学习,但它比以往任何时候都更接近,而且对于程序员的一个子集来说,语言的强大功能仍然具有吸引力。
在下一章中,Ferenc 将探讨以下问题:每个 C++程序都是标准化的吗?或者,也许程序员们是被解决问题和选择最适合他们环境的解决方案所驱动,忽略了标准,甚至创造了最终会被纳入标准的习惯用法。
第二章:每个 C++程序都符合标准
除非它们不是
在 C++编程的世界里,符合标准的概念通常受到高度重视,C++标准的最新版本被视为编写正确和高效代码的终极指南。C++标准是由 C++委员会和国际标准化组织(ISO)精心制定并定期更新的,为开发者提供了全面的规则和最佳实践,以确保代码质量和互操作性。然而,软件开发的现实比这种理想状态更为复杂和微妙。
在本章中,我们将深入探讨由于各种限制,开发者无法始终遵守这些标准,并在理想标准与工作实际需求之间尖锐、微妙的边缘上谨慎平衡所面临的众多挑战。这些限制可能包括他们在开发环境中的限制,如过时的编译器、遗留系统,或要求使用非标准特性的特定项目需求。
当我们被迫使用以 C++为基础并提供一组扩展以满足特定用例的框架时,可能会出现复杂的情况。正如我们将在稍后阶段展示的,这些框架建立在现有的标准 C++之上,并引入了针对特定范围的特定功能,但这些功能与 C++标准没有任何共同之处。因此,我们可能会问自己:我们应该使用这些框架吗?正如我们将看到的,这个问题的答案并不像人们可能认为的那样简单。
在本章中,我们将涵盖以下主要主题:
-
遵守各种编译器、框架和环境中的标准
-
为什么不是每个人都能学习、使用或编写标准 C++?
-
离开标准的编译器扩展
技术要求
我们必须承认,阅读本章不会是一个简单的过程,但我们将尽力使其尽可能容易。我们的思绪将在平台、编译器和 C++语言的多种方言之间徘徊。然而,在某个时候,我们必须划清界限,得出结论,我们应该能够将所有这些理论信息的传递转化为生活的实际,并从中产生一些 C++代码。因此,我们在此恳请,在本书的这个阶段,您能够访问强大的互联网,以及实验 C++的必去之地:Matt Godbolt 的网站:
那个地方应该能为你提供保护,因为我们将在本章中讨论的所有编译器几乎都可以在那里找到。
目前不需要其他任何东西。这是因为在这个阶段,我们还没有产生足够的有价值代码,能够将任何有意义的代码放入本书的 GitHub 仓库中,而我们产生的代码也不应该被用于其他地方。
在遥远的加纳某处
当理查德·阿皮亚·阿科托在加纳的学校黑板上绘制了微软 Word 用户界面的几张图片后,他一夜之间成为了社交媒体现象 1 。他的学校很贫穷,没有工作的电脑,只有一块世纪之交的标准黑板,但这并没有阻止他履行教师的职责。他以非常富有创意的方式,尽其所能将改变生活的知识传授给学生,希望有一天,这些知识能对他们追求更好的生活有所帮助。其余的都是历史,但真正的问题是:这是教授微软 Word 的标准方式吗?
我们不要偏离我们的初始目标太远。我们想了解 C++程序的标准合规性。对于狂热的 C++程序员来说,标准的最新版本被视为神圣的经文,是话语,是他们应该遵守的规则的集合,任何偏离都应该受到擦除和重写非标准合规代码的惩罚。或者在一个被标记为维护 遗留代码 的拘留中心度过一周。
面对残酷的现实,事情与理想主义的环境相去甚远。一些开发者没有可能使用 C++标准的最新版本。这可能是因为他们的生计与需要特定编译器的现实生活项目紧密相连,或者是因为他们编程的环境不允许使用语言的特定功能。
或者,他们可能是因为在一个过去 20 年没有更新过的平台上工作而获得报酬,因为这个提供商十年前宣布破产,没有人接手他们的业务。然而,由于一切正常并且仍在产生收入,它仍然使用 20 年前的工具来维护和保持。
这肯定不包括支持最新 C++标准的编译器。那么,这意味着在这些平台上工作的开发者所编写的 C++代码不是标准合规的吗?
在世纪之交,本章的作者发现自己在一所大学的教室里,参加一门名为C++程序设计入门 的课程。这是那里唯一提供的 C++课程,老师用一本书来传授知识给 30 多个学生。
路尽头的小复印店老板在有一天老师决定把书借给一个学生时非常高兴。这本书是 Kris Jamsa 的 C/C++ 程序员圣经 的翻译和大幅缩减版,我们称之为“有斑点的狗的书”。
书的本地版只包含了 C++ 部分,但它附带了一个非常重要的附录:一张标准的 1.44 MB 软盘,上面有 Turbo C++ Lite IDE 和相应的编译器。对于那些不熟悉这个名字的人来说,Turbo C++ Lite 是 Borland 公司流行的(并且非常用户友好)的 IDE 和编译器 Turbo C++ 的简化版。编译器本身是相同的,但是为了将整个环境放在单个 1.44 MB(兆字节)的软盘上,移除了很多功能和工具。
这是我们第一次接触到编译器、链接器和语法的复杂世界。我们中的一些人发现它如此迷人,以至于现在,20 年后,我们仍然在日常工作中使用它。所以,正如你可以想象的那样,我们的第一个 C++ 程序看起来就像下面截图中的那样。

图 2.1 – 程序员生活中的著名代码屏幕,如图 1997 年的《程序员生活》所示
哦,你脸上的恐惧!我可以清楚地想象出来,亲爱的 C++ 学徒。仅仅是看到:
-
iostream.h:嗯,你好,现在是 1999 年,C++98 标准去年才发布。你为什么不使用它,你这个异端?它的编号是 ISO/IEC 14882:1998,只需 200 瑞士法郎就可以买到。…哦,那是你在这里学习期间三个月兼职洗碗工的工资?
-
void main(void):哦,亲爱的,这从来就没有在任何标准中出现过,无论是 C 还是 C++。你刚刚挖出了什么样的黑暗混合物?…或者这是他们称之为… Java 的新东西?
-
cout:从未遇到过使用指令,这怎么可能呢?
在这里,你很容易放弃试图理解这一点的尝试,如释重负地叹气,但请再忍受我一下。
与理查德·阿皮亚·阿科托在那时所面临的情况非常相似,在我们接受教育的那个阶段,我们也拥有了一个带有黑板、一位专职教师以及几本书(正如之前提到的,有几本副本)的教室。即便如此,我们还是学习了 C++。也许,从标准的角度来看,这些条件是理想的,因为 C++标准非常宽松,考虑到环境因素,它不需要你能在现代计算机中找到的任何东西——没有键盘,没有屏幕,甚至没有操作系统。确实,唯一非常严格的环境要求是char的大小至少为 8 位。这是为了确保char可以容纳基本执行字符集中的任何成员(包括标准 ASCII 字符)。而且,sizeof(char) == 1这一事实也由 C++标准保证,包括它的有符号和无符号版本。所有其他内容都是建立在这些基础之上的。
因此,我们可以说,在我们获得进入计算机实验室的权限之前,我们学习标准 C++的条件是理想的。没有令人烦恼的系统依赖,没有电脑崩溃,也没有硬件在代码无法编译时因沮丧而踢出。由于我们没有在黑板上运行编译器,我们的老师很快意识到在黑板上编译更复杂的 C++代码并不太可行,所以我们被分配了周五早上早些时候的时间段在计算机实验室。所有的麻烦从此开始。
解释相当简单:你们知道,我们大学当时用于教授 C++的计算机实验室由一帮 80286 IBM AT 克隆机组成。
你们读得没错。当时有 30 名学生,分配了 8 台电脑(每台都配有当时可能算是高端的 80286 处理器,尽管在十多年后显得相当过时),这些电脑是从某个援助机构那里得到的,该机构可能进行了升级,并决定将旧设备捐赠给大学以获得公司的税收优惠。四个人一台机器,每人一本书(以及几本副本),试图学习 C++。
尽管情况并不像二十年后理查德·阿皮亚·阿科托的学校那样糟糕,但条件并没有更好。那些机器只能运行纯 DOS,而且没有比 Turbo C++ Lite 更好的编译器可用,它是十年前发布的。这意味着我们有意学习编写非标准 C++代码吗?显然不是。我们编写了我们能够编写的代码。
然而,我们不要把时间倒退得太远。截至 2024 年,本书的写作日期,Stack Overflow 上有 46 个问题(stackoverflow.com/),包含令人恐惧的void main(void)短语。最新的一条令人惊讶地来自 2023 年。其中一些与iostream.h的内容有关,但主要是教育背景,我们不敢计算那些包含cout但没有遇到使用指令或命名空间限定符的问题,因为那将是徒劳的。这难道意味着即使在 2024 年,仍然有程序员在编写依赖于非标准 C++的代码?或者有学生在以非标准的方式学习 C++?
在 Stack Overflow 上进一步挖掘,另一个关于旧版 C++方言的有趣片段出现了:conio.h。这个头文件在语言官方标准化几年前就随 Turbo C(和 C++)一起发布了,但考虑到在 2024 年仍有年轻的新手对其提出问题,我们可以说,对于之前问题的答案很可能为是。
根据他们的环境和可能性,无论他们是否需要使用黑板学习,用粉笔绘图,或者通过共享键盘,在过程中轻轻拍打彼此的手,今天仍然有一些程序员不自觉地被强加了一个学习和编写非标准 C++的过程。
微软的迷你、紧绷的 C++
现在向后看已经足够了。让我们暂时换个方向,考虑一下曾经是 C++之王的编译器,但随着时间的推移,它的光芒已经消退。OpenWatcom 是一个开源的集成开发环境,以及 C 和 C++(以及 Fortran,但在这本书中该语言不是重点)的编译器套件,最初由 Watcom 国际公司开发,并于 2003 年由 Sybase 开源发布。
它支持多个操作系统,包括 DOS、Windows、OS/2,以及 Linux,并且是那些对为复古平台创建有趣、休闲项目感兴趣的程序员的默认编译器。
并非一定是为了钱,而是因为当他们面对 80x25 屏幕时,那种甜蜜的怀旧感会让他们脊背发凉。也许这就是为什么今天大多数资深程序员都使用在终端中运行的 VI 编辑器网格,这些编辑器被拼接到 6x4 的窗口上,在巨大的 WQUXGA(或更大)屏幕上。
但让我们回到 OpenWatcom 编译器。在浏览项目的发布说明 2 时,我们遇到了以下,可以说相当引人入胜的短语(在与版本 10.0 的主要差异部分,第 29 项):
2 open-watcom.github.io/open-watcom-v2-wikidocs/c_readme.html
我们已经复制了一个必需的 Microsoft Visual C++扩展,用于解析 Windows 95 SDK 头文件。
示例:
typedef struct S {
} S, const *CSP;
^^^^^- 不允许在 ISO C 或 ISO C++ 中使用
哎呀……我刚刚读得正确吗?Visual C++ 有一个扩展,允许编译非标准代码?
是的,我们确实正确地阅读了这一点。以下这段示例代码在今天的任何主流 C++ 编译器中都无法编译,除非是 Visual C++(以及根据他们的评论,OpenWatcom 的 C++ 编译器):
#include <iostream>
typedef struct S {
int a;
} S, const *CSP;
int main() {
S s1; s1.a = 1;
CSP ps1 = &s1;
std::cout << ps1->a;
}
...由于一些作者未能解密的神秘原因,这段代码序列也被几个版本的 ICC(英特尔强大的但遗憾的是已停产的 C++ 编译器)接受。因此,我们可以再次提出以下问题:既然一个主要编译器和两个相对晦涩的编译器接受这种代码,这意味着我们应该使用它吗?它是标准的吗?
对于第二个问题的答案是明确的 不。然而,对于第一个问题,情况要复杂一些。这是因为在我们回答之前,我们必须再次考虑背景、需求和其他可能影响开发决策的相关因素。
我们是否尽可能坚持标准 C++?是否有可能在不使用供应商特定扩展的情况下提供所需的解决方案?我们是否绑定到编译器或操作系统,并且我们不担心将来可能需要访问外国土地?
使用微软平台提供的针对 C++ 的托管扩展,能帮我们省去很多麻烦,还是我们更愿意坚持我们熟悉和了解的古老语法(以及类型)呢?
微软以其为 C 和 C++ 语言提供平台特定扩展而闻名,以至于有一个专门的部分用于微软特定的 C++ 关键字 3。这告诉我们,非标准 C++ 有一个市场,并且有充分的理由,因为其中一些扩展非常实用,尽管这需要我们绑定到平台、编译器和工具链。
3 learn.microsoft.com/en-us/cpp/cpp/keywords-cpp?view=msvc-170
微软的一个扩展体现在 __declspec 关键字中。C 和 C++ 中的 __declspec 关键字是微软扩展 C++ 语法的一部分,它允许开发者为某些 C++ 构造指定微软特定的存储类属性。
这个关键字提供了对诸如 DLL 导出和内存对齐等行为的额外控制,这些行为在标准 ANSI 关键字(如 static 和 extern)中并未涵盖。通过使用 __declspec,开发者可以轻松且不遵循标准地应用这些特定于微软自己编译器的特性(看吧:接下来会有惊喜!)到他们的代码中,从而增强代码的功能和性能,如下面的代码序列所示:
struct person {
void set_age(int page) { m_age = page; }
int get_age() const { return m_age; }
__declspec (property(get = get_age, put = set_age)) int age;
person() = default;
private:
int m_age;
};
int main() {
person joe;
joe.age = 12;
std::cout << "Hello " << joe.age;
}
使用微软的__declspec(property(...))语法,前面的代码序列创建了一个age属性,允许通过提供的方法间接与m_ age进行交互,正确封装年龄数据,同时提供了一个简化的接口来访问和修改它。
可以利用__declspec扩展利用的属性列表相当长且实用,__declspec在编译器开发界似乎也很受欢迎。事实上,它如此受欢迎,以至于Clang提供了一个专门的参数来理解这个微软特定的扩展。这个标志-fdeclspec使得在 Clang 编译的代码中也能使用__declspec关键字。因此,自然而然地产生了这样的问题:这还是微软特定的扩展吗,或者我们正在见证跨平台功能的出现?
在核心 C++程序员圈子中仍然被视为禁忌的一个事实是,在现实生活中,真正需要编写真正的跨平台代码的情况是很少的。大多数程序员为特定的公司工作,开发或支持特定的产品。他们主要使用一个操作系统,一个编译器工具链,遵守雇主施加的限制,并愉快地使用编译器支持的扩展来编译他们的代码。
这并不意味着他们不想编写符合标准的 C++代码。不,恰恰相反,我相信他们能写出他们能想到的最高质量的代码。这仅仅意味着他们只是使用了特定编译器提供的可能性:他们必须与之工作的那个编译器。在他们下一家公司,有很大可能性他们会使用运行在不同操作系统上的不同编译器,从而忘记他们在前一个地方的前编译器提供的所有优势。这是因为特定编译器的语法和扩展并不局限于一个编译器。
让我们考虑以下代码示例,例如:
char arr[6] = {'a', 'b', "cde"};
除了刺痛我们的眼睛,这个序列显然尽可能标准和非规范。谁会理智地尝试以这种方式初始化一个 6 个字符的数组呢?然而,Microsoft Visual C++编译器却乐于接受它。让我们从一些普通字符开始,当我们厌倦了输入所有的撇号和逗号时,我们就可以把其他所有东西都扔到一个常量字符串字面量中,因为为什么不呢?而且它在这方面相当聪明,检测到请求的数组大小,并将其与各部分的累积长度相匹配,如果存在任何不匹配,则发出错误信号。
当涉及到添加标准中找不到的功能或允许会打破语言律师舌头的代码时,微软的 C++编译器是一个非常创新的编译器。让我们以以下代码为例:
class person {
public:
int age;
class {
public:
std::string name;
};
};
这段代码序列根本不是标准的 C++。它的存在甚至让我们能够编写出如下所示的代码:
int main() {
person joe;
joe.name = "Joe";
std::cout << "Hello " << joe.name;
}
在使用微软自己的 C++编译器编译时,先前的示例编译和运行没有任何问题。请仔细观察匿名类,它包含一个名称成员,是一个具有构造函数的对象。这是一个具有构造函数、析构函数和许多其他有趣特性的对象。这是另一个(如果我可以这么说的话,非常实用的)微软对标准的偏离,因为匿名联合体是 C++中众所周知的一种野兽。然而,匿名结构体仅存在于 C 语言中(从 C11 开始),其他编译器不接受上述代码。
作为旁注,如果你不熟悉 C 语言中匿名结构体的概念,它们是简化嵌套结构声明的有用特性。当它们在其他地方不需要命名内部结构时,它们不需要命名内部结构,并且使代码更加简洁易读。尽管成员被包含在结构体中,但仍可以直接访问它们。通过在匿名结构体中封装相关字段,并在这些成员中引入逻辑块,可以减少代码中不必要的类型定义的杂乱。
免费编译器的领域
目前三大主要编译器中的两个是以开源方式开发和维护的。这意味着,从理论上讲,任何人都可以为其选择的编译器贡献并添加有用的新功能。然而,在实践中,这意味着只有一小部分具备必要知识和奉献精神的职业程序员,以及一个大型公司的支持,该公司从上述编译器的发展中获益,他们负责这项工作。
按照无特定顺序,截至 2024 年,GCC 和 Clang(以及我们在上一节中讨论的 MSVC)是最符合标准的编译器。然而,这种标准兼容性并不意味着这些编译器没有自己的优点,这些优点曾经被认为是开发者想要整合的绝佳想法。
以 GCC(当然,Clang 也是如此;这两个往往是一起使用的)的计算 goto特性为例。我们都在学校学到,goto是纯粹的邪恶,永远不应该使用。如果你在学校没有学到这一点,请不要从这本书中学习。因为,这同样是不正确的。相反,让我们关注一下我们可以想到的计算goto。如果goto是邪恶的,那么计算goto是计算出来的邪恶吗?那么以下代码序列是纯粹的邪恶,还是计算出来的邪恶?让我们看看:
int main() {
std::vector<void*> labels = { &&start, &&state1, &&state2, &&end };
int state = 0;
goto *labels[state];
start:
std::cout << "In start state" << std::endl;
state = 1;
goto *labels[state];
state1:
std::cout << "In state 1" << std::endl;
state = 2;
goto *labels[state];
state2:
std::cout << "In state 2" << std::endl;
state = 3;
goto *labels[state];
end:
std::cout << "In end state" << std::endl;
return 0;
}
第一行没有问题。问题从那以后开始。这个非常实用的特性可以通过允许根据指针的值跳转到标签来有效地以非标准方式实现解释器或状态机。这个指针是从标签本身的地址初始化的。由于我们正在处理指针,完全可能使用可怕的指针算术并在地址上进行一些计算。
此外,如果不正确使用,这可能会是一个危险特性。与标准goto的情况不同,计算出的 goto 不会考虑在离开特定作用域时生命周期结束的对象。因此,不会调用析构函数。请务必注意这一点!
另一个相当有用的与标准 C++语法的偏差来自 GCC(而且,Clang 也实现了它,真是个惊喜),这使得以下代码序列可以用这两个编译器编译:
int y = ({ int x = 10; x + 5; });
真是整洁,不是吗?这个特性被称为表达式中的声明和定义,它拥有你所能想到的所有好处:声明内部对象的良好封装,如果使用得当,则宏更为安全。遗憾的是,它并不是标准 C++。
Clang,这个新加入的成员(好吧,如果我们能称一个 15 岁的编译器为“新”,尽管与 1987 年出生的 GCC 相比,Clang 在领域中仍然是一个非常年轻但技艺高超的玩家)在特性竞争中更进一步。以下代码片段仅能在 Clang 中编译,得益于一个特殊的库和一个新的编译器命令行选项:
#include <iostream>
int main() {
int (^square)(int) = ^(int num) { return num * num; };
int y = square(12);
std::cout << y << std::endl;
}
这个特性在 Clang 中被称为块。为了正确使用它,你需要安装BlocksRuntime 4 库,然后指定一个特殊的-fblocks标志给 Clang,完成所有这些阻塞操作后,我们最终可以编译前面的代码。
4 github.com/mackyle/blocksruntime
这在很大程度上类似于标准 C++11 lambda 的行为,但考虑到这个特性是在 2008 年由 Clang 创建并引入的,我们或许可以称其为标准 C++ lambda 之父。如果你对此好奇,提供相同功能的标准 C++ lambda 如下:
auto square = [](int num) ->int { return num * num; };
这不是黑魔法,与以下代码片段不同:
auto generate(int n) -> std::vector<int>{
int array[n] = {0};
for(int i=0; i<n; i++) array[i] = i;
return std::vector<int>{array, array + n};
}
所以,如果你想知道那里发生了什么,这里只是对你 C++大一记忆的小提醒:在没有任何情况下,int array[n] = {0};是标准 C++。变量长度数组是 C 语言中存在的一个特性,但由于各种安全考虑,C++标准不包括它。无论如何,前面的代码被 GCC 编译器接受,但 Clang 会对此提出抱怨:
error: variable-sized object may not be initialized
5 | int array[n] = {0};
根据错误信息,修复方法很简单:
auto generate(int n) -> std::vector<int>{
int array[n];
for(int i=0; i<n; i++) array[i] = i;
return std::vector<int>{array, array + n};
}
现在,即使是 Clang(以及一些其他编译器,如 ICC)也接受它,无论代码的标准性状态如何……或者更确切地说,是缺乏标准性。
对属性的致敬
然而,GCC 和 Clang(以及微软的 Visual C++)可以在一个非常具体的 C++语言扩展上达成共识:我们需要一种方法来将元数据附加到某些语言构造(如类型、函数、变量等)。这些元数据随后可以被编译器和其他工具用来生成优化代码、执行检查或提供其他功能。
在现代 C++(即 C++11)引入使用双方括号语法 [[attribute]] 指定属性的标准方法之前,每个编译器都有自己的方式来指定这些属性,因此需要这些属性:
-
GCC 和 Clang 使用 attribute((attribute-name))
-
Microsoft Visual C++ 使用 __declspec(attribute-name)
然而,随着 C++11 的发布,标准化委员会意识到了这些特性的有用性,并将最适用的属性提升到语言中(例如 [[noreturn]]),而标准的后续改进又添加了更多属性(例如 [[fallthrough]],[[nodiscard]] 等)。然而,许多这些属性仍然局限于引入它们的编译器。以下代码片段展示了其中的一些:
void old_function() __attribute__((deprecated));
void fatal_error() __attribute__((noreturn));
int pure_function(int x) __attribute__((pure));
int x __attribute__((aligned(16)));
void old_function() {
std::cout << "This function is deprecated.";
}
void fatal_error() {
std::cerr << "This function does not return.";
exit(1);
}
int pure_function(int x) {
return x * x;
}
上述代码序列包含了一些 GCC 和 Clang 共享的属性,例如以下内容:
-
attribute((deprecated)) 将旧函数标记为已弃用
-
attribute((noreturn)) 用于指示 fatal_error 不会返回
-
attribute((pure)) 用于指示纯函数除了返回值外没有副作用
-
attribute((aligned(16))) 用于将 x 变量对齐到 16 字节边界
这些编译器提供的属性列表非常庞大 5,我们强烈建议,如果您处于在特定平台上使用这些编译器,并且您的主要关注点不是代码的可移植性、平台独立性和标准兼容性的情况,那么您应该去查看它们。这是因为通过正确使用编译器提供的工具,您可以充分利用许多功能。
5 gcc.gnu.org/onlinedocs/gcc/Function-Attributes.html
6 clang.llvm.org/docs/AttributeReference.html
当头文件甚至不是 C++ 时
标准不兼容但仍然可用且有用的特性列表并不止于前面的例子。然而,如果我们只关注那些,我们仍然可以用它们填满几本书。遗憾的是,目前我们只为此主题奉献了一章,所以让我们将注意力转移到一些更为奇特的功能上。
Qt 已经成为 GUI 应用程序(但不仅限于此)的事实上的跨平台编程框架有一段时间了。在其命运多舛的历史中,自从 1994 年成立以来,Qt 框架已经发生了显著的变化,每个版本都为 C++(但不仅限于此)编程社区带来了新的功能集。然而,有一个功能基本上保持不变:信号/槽实现和 元对象编译器( MOC)。框架的支柱,MOC 使得将组件(即,信号)的事件连接到接收器(即,槽)以进行适当处理成为可能。
然而,这个非常实用的功能是以必须支持几个非 C++ 构造为代价的,这使得将应用程序中看似无关的元素连接起来成为可能。例如,必须响应事件的对象的类声明通过几个非标准的“访问修饰符”扩展,例如 signals:,private slots: 等。此外,还有一个新的 关键字 叫做 emit,这使得发出信号成为可能。
简而言之,以下是从一个头文件中摘录的内容,使得以下代码的编译成为可能:
#ifndef MYCONTROL_H
#define MYCONTROL_H
#include <QObject>
#include <QPushButton>
#include <QWidget>
class MyControl : public QWidget {
Q_OBJECT
public:
MyControl(QWidget *parent = nullptr);
private slots:
void onButtonClicked();
signals:
void nameChanged(const QString &name);
private:
QPushButton *myButton;
};
#endif
我们是否应该采用 Qt 提供的奢侈功能,使用非常方便的信号/槽机制,尽管我们必须编写非标准的 C++ 代码?或者我们宁愿坚持传统,像在 GTK 中那样通过编写纯 C++ 代码来创建每一个小按钮和连接?
这一章节无法回答这个问题,因为最终,这取决于每个项目的具体要求。这些要求包括环境强加的、项目利益相关者期望的,以及开发团队决定前进道路的方式。然而,不要绝望:即使这看起来并不像标准的 C++,它解决了非常现实的问题。幕后隐藏着一个尖端实现,它已经经过测试、批准、改进,并在几个小型和大型项目中使用。它经受了时间的考验。
微软对 C++ 语言的自身大规模扩展采用了一种不同的方法。虽然它不是一个像 Qt 的 MOC 这样的特定工具,但 C++/CLI 通过 .NET 特定的语法扩展了 C++。C++/ CLI(即,通用语言基础设施,而非命令行界面)的 Visual Studio 编译器可以解析这种扩展语法,并生成有效的通用中间语言(这是一个由 .NET 框架使用的低级、平台无关的指令集)和本地代码。以下代码序列是这种托管 C++ 的一个示例。它并没有做任何特别的事情;它只是将字符串数组的元素连接起来并打印结果:
#include <iostream>
#include <atlstr.h>
#include <stdio.h>
using namespace System;
int main() {
array<String^>^ args = { "managed", "world" };
String^ s = "Hello";
for each (String ^ a in args) s += " " + a ;
CString cs(s);
wprintf(cs);
}
我完全同意;这根本不是标准 C++。它看起来不像标准 C++,感觉也不像标准 C++,甚至听起来也不像标准 C++。所以,它肯定不是那个。具有相同功能的符合标准的 C++代码看起来如下:
#include <array>
#include <iostream>
#include <string>
int main() {
std::array<std::string, 2> args = { "unmanaged", "world" };
std::string s = "Hello";
for(const auto& a : args) {
s += " " + a ;
}
std::cout << s;
}
它不是比之前的一个更简洁、更短、更简洁吗?更不用说它也是符合标准的了。
观察 C++管理扩展在未来如何演变将会很有趣。
目前,它充当原生代码和管理代码之间的桥梁,目前这是一个非常狭窄的领域。然而,从长远来看,它的生存高度依赖于开发者社区是否接受它(或者不接受),它所创建的生态系统是否足够有用以维持其活力,或者是否其他技术,如 P/Invoke 或 COM Interop,将接管 C++/CLI 目前处理的特定用例。
确实,前方有有趣的时光在等待。
C++被锁在盒子里的奇特案例
迄今为止,我们已经观察到一些情况,其中标准符合性是由开发者自行决定的。他们可以选择他们的平台,使用他们最喜欢的编译器提供的扩展,或者选择纯标准 C++。然而,在这个广阔的世界中,有一些情况下,由于环境对我们施加的限制,我们无法完全遵守标准,因为某些 C++标准中发现的特性被禁止使用。
不考虑低俗场景,当我们必须维护那些在 C++的黄金时代编写的几十年老代码(即,在标准化委员会接管并因要求符合标准而破坏了所有乐趣之前,为了防止 C++方言如BASIC那样无法控制的扩散),我们可能会遇到一些超出我们控制的情况,使得无法使用完整的 C++标准特性。例如,可能会有某些要求禁止使用异常。其他环境可能缺乏适当的内存分配支持,而其他环境则可能强制我们直接写入硬件地址以使某些事情发生。然而,这最后一种情况也可以以符合标准的方式发生。
例如,一些嵌入式系统积极鼓励使用它们平台特定的汇编指令。众所周知,没有平台无关的汇编语言,因为那是今天 C++可以到达的最低级别。在其下方是纯十六进制机器代码,但那些在 C++代码中必须使用那种代码的时代已经一去不复返了。
也可能存在这样的情况,我们代码的硬件要求需要确定性行为。根据定义,这排除了异常(因为谁愿意在执行过程中每纳秒都无法跟踪代码流程呢?)和内存分配(因为分配延迟、内存碎片化以及代码再次不按确定性方式行为的一系列其他问题)。因此,大量 C++标准的内容都不在我们的考虑范围内。
对于嵌入式系统中的内存分配问题,有一些解决方案,例如使用内存池、对象池、编译时内存分配以及各种其他资源,这些资源可能甚至是平台特定的。然后还有异常。在 Bjarne Stroustrup 的出色论文 7 中,他讨论了用确定性异常等替代方案替换 C++异常所涉及到的挑战、成本和风险。然而,正如论文的结论所述,目前没有明确的理由用其他机制替换当前的异常处理机制。这可能会在 C++开发者社区中造成进一步的碎片化,就像现在已经有足够的碎片化一样。相反,论文主张重视增强当前的异常处理系统,而不是通过额外的机制使语言复杂化,强调尽管异常存在不完美之处,但它们已经有效地为数十年的大量开发者服务。
7 www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1947r0.pdf
过去和未来的 C++
我们将要探讨的最后一个关于你编写的代码标准合规性的场景,与 C++生态系统中最基本的项目有关:编译器本身。
你看,编译器也是程序,由数百万行代码组成。全球有多个贡献者正在从事这项工作,添加新功能、修复错误、提高其标准合规性、发布最新版本,并确保你的编译器能够正常工作。
这些编译器也有一个开发时间表。功能的实现并非一蹴而就,可能存在某些情况下,在某个时间点,某些编译器可能不支持标准中的某些功能,因为缺乏足够的人力来实现它。
在所有 C++知识来源处有一个非常实用的文档 8,它详细说明了各种 C++标准特性的支持情况以及哪些编译器支持特定的功能。
8 en.cppreference.com/w/cpp/compiler_support
在标准转折点(或者当被迫使用尚未实现某些功能的过时编译器时),C++开发者社区已经采用了几个技巧来弥补即将到来的各种编译器版本中功能缺失的问题。
当在 C++98 中引入mutable关键字时,某些编译器的实现比其他编译器的实现要慢一些。对于使用这些编译器的程序员来说,在const成员函数中修改成员变量(该功能是在同一标准中引入的)是一项挑战。
在这种情况下,必须使用以下(相当丑陋)的技巧来应对缺失的关键字:
class Counter {
int viewCount = 0;
public:
void view() const {
const_cast<Counter*>(this)->viewCount++;
}
void print() const {
std::cout << "Count: " << viewCount << std::endl;
}
};
假设你的计算机支持const_cast,前面的代码没有问题。然而,如果const_cast不在支持的关键字列表中,那么你基本上回到了标准的 C 风格转换,例如((Counter*)(this))->viewCount++;。这应该可以解决你的所有问题。
mutable关键字并不是第一个在编译器中不支持导致开发者遇到麻烦的关键字。在 C++11 引入constexpr(以及在那之后几年,对于 Microsoft Visual C++程序员来说也是如此),编译时常量表达式必须使用各种模板技巧(或者只是宏,但众所周知,它们是邪恶的,所以让我们尽可能地避免它们)来评估。
例如,以下代码片段在constexpr之前(但仍然是在编译时)计算了某个数的著名阶乘:
template <unsigned int N>
struct Factorial {
static const unsigned long long value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static const unsigned long long value = 1;
};
const unsigned long long fac5 = Factorial<5>::value;
当前使用支持相同函数constexpr的编译器的标准实现当然更短,更容易理解:
constexpr unsigned long long factorial(unsigned int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
const unsigned long long fac5too = factorial(5);
当然,如果我可以这么说的话,代码的可读性有了巨大的提升。
摘要
正如本章所展示的,编写标准 C++确保代码在不同平台和编译器之间的可移植性、兼容性和可维护性。我们了解到,通过遵循 ISO/IEC C++标准,我们可以创建行为可预测且不太容易出现错误和平台特定问题的代码。符合标准的 C++代码还受益于通用的编译器优化和未来的语言增强,同时确保长期的相关性和性能,正如我们在本章所学到的。
另一方面,使用 C++编译器特定的扩展可以提供针对特定平台和编译器的性能优化,访问尚未标准化的高级功能,以及与供应商特定工具的集成。然而,扩展可能会引入可移植性问题,对特定编译器版本的依赖,以及与标准 C++实践的偏差,这可能会影响代码在不同平台和编译器之间的维护和互操作性。我们也在本章中讨论了这一点。
因此,我们了解到采纳(adoption)应该基于项目需求仔细考虑,在增强功能的好处与兼容性和长期支持相关的潜在缺点之间取得平衡。在这一阶段,我们相信您能够做出最佳决策,这对您的项目和代码库产生最佳影响,同时让您能够交付所需的产品。即使它是在您业余时间用一台 30 年前的机器编写的个人项目。使用 30 年前的编译器编译。
我们的下一章,由 Alex 提供,将进行深入探索,试图揭示 C++是否真的是另一种面向对象的语言的基本真相,或者是否在表面之下隐藏着更多的东西...
第三章:只有一个 C++,它是面向对象的
只有当你忽略所有 其他所有内容
C++ 是在 C 的基础上加入了对象特性而诞生的,这使得许多开发者仍然认为它是一种面向对象编程(OOP)语言。在本章中,我们将看到 C++ 允许多种编程范式,并且可以安全地将其描述为一种包含多种编程语言的单一语言。我们将探讨 C++ 支持的几种范式,包括结构化编程、面向对象编程、函数式编程和元编程,以及强类型与准可选类型的选择。
在本章中,我们将涵盖以下主要主题:
-
C++ 的多重面相
-
C++ 中的函数式编程
-
元编程
-
极端强类型
-
那么,忽略类型呢?
技术要求
本章的代码可以从 GitHub 仓库 github.com/PacktPublishing/Debunking-CPP-Myths 中的 ch3 文件夹获取。它使用 Makefile、g++ 和 doctest 库(github.com/doctest/doctest)进行单元测试。代码是为 C++20 编译的。
C++ 的多重面相
如果你像我一样,经常在不同的组织、团队和技术会议上穿梭,你很快就会注意到两件事:与其他开发者相比,C++ 程序员有独特的兴趣,C++ 社区更准确地描述为小型的、专业的 C++ 开发者群体。这与其他社区不同;如果你讨论 Java,你可能会最终谈到 Spring 框架和 REST API 或 Android 工具包。C# 主要围绕 Microsoft 库进行标准化,而 JavaScript 主要与 React 相关。但是,如果你把来自不同组织的 100 名 C++ 程序员召集到一起,你很快就会注意到差异。嵌入式 C++ 专注于控制所有资源,因为为销售数百万台的设备额外增加 1 MB 的内存会迅速推高成本。游戏开发者处于光谱的另一端,他们关注如何从下一代 GPU 和 CPU 中挤出额外的帧率。高频交易人士对避免 CPU 缓存未命中以及如何从自动化交易算法中消除皮秒级的延迟了如指掌,因为最小的时分数值可能意味着数百万欧元。工程软件开发者更为轻松,但仍担心复杂渲染模型中变更的有效性。然后你还会发现处理铁路、汽车或工厂自动化系统的程序员,他们的主要关注点是弹性和健壮性。
这张图片虽然远非完整,但足以展示 C++程序员巨大的多样性,与使用其他任何语言的同行相比。我们几乎可以说,从某个角度来看,C++是最后剩下的实际通用语言,因为其他主流语言在实践中主要用于特定类型的程序:Java 用于企业后端服务和 Android 开发,C#用于 Web 和 Windows 应用程序和服务,JavaScript 用于丰富的 Web 前端和无服务器后端,Python 用于脚本、数据科学和 DevOps。但 C++用于嵌入式软件、工厂系统、交易、模拟、工程工具、操作系统等等。
旧话“形式追随功能”是关于设计适用于人们建造的每一件事物,包括编程语言,对 C++同样适用。项目类型和程序员的大幅变化,以及斯特劳斯特鲁普希望使其尽可能强大的愿望,都融入了 C++语言。C++不是一种单一的语言;每个程序员使用的 C++子集通常与他们所在组织中的同事不同。
是的,C++最初是以具有对象的 C 语言为基础发展起来的,那时面向对象编程(OOP)正处于兴起阶段。但是,与此同时,C++与 C 语言向后兼容,这意味着你仍然可以在 C++中编写结构化编程。然后,模板变得必要。接着,lambda 表达式变得有用。虽然 C++始终是一系列不同语言的集合,但如今这种趋势更加明显。为了证明这一点,让我们看看你可以在 C++中使用的一些范式,从函数式编程开始。
C++中的函数式编程
我记得在大学时,对编程非常着迷,并且已经相当擅长编写 BASIC、Pascal、Logo 和简单的 C++。我认为是在我二年级的时候,我选修了一门关于函数式编程的课程。老师非常热情,渴望向我们展示这种范式的奇妙之处,解释了许多我无法完全理解的概念。这门课程对我来说完全是一次失败,因为我唯一学到的是如何在 Lisp 中编写命令式代码,以及如何将我已知的习惯用法翻译成在这个奇怪的语言中可以工作,其括号位于表达式外部的东西。
我在作为软件工程师开始职业生涯后,试图回归函数式编程。网上有很多资源,但它们解释范式的方式并没有帮助。“这基本上是范畴论,”他们说。一切皆函数,甚至数字(查看 Church 编码)。由于它们是端内函子的范畴中的幺半群,你可以轻松理解单子。这种解释风格使用更复杂的概念来解释实际的概念,并不利于理解。
这也是为什么我花了好几年时间才理解函数式编程是什么以及它如何帮助软件开发。我成为了这个范式的粉丝,但不是狂热者。像任何工程师一样,我喜欢解决问题,在我的情况下,我通常用代码来解决。拥有更简单的代码总是很好的,尽管通常更简单并不意味着更熟悉。
如果我今天要解释函数式编程,我会关注三个重要的事情:不可变性、纯函数和函数操作。也许出乎意料的是,C++非常适合所有这些特性。与其它主流编程语言相比(尽管不如 Rust,但我们在最后一章会谈到这一点),C++在不可变性方面表现出色。
然而,有一个问题:函数式编程是一个不同的范式,有其自身的权衡。我发现 C++程序员发现思考 lambda 表达式很困难,因为他们将 lambda 表达式视为不是基本概念,而是建立在现有语言之上的东西。这是公平的,因为 lambda 表达式是对象,而不是 C++中的第一级设计元素。然而,以函数式范式思考要求程序员暂时忘记这些知识,并接受函数式设计元素。当你实现了一些有效的东西并寻求改进时,你可以回到这些知识。
让我们更详细地解释这三个特性,然后讨论使用函数式编程对我们软件架构的影响。
不可变性
不可变性从根本上意味着每个变量都初始化为一个值,但无法将新值赋给变量。在 C++中,这可以通过const或constexpr来实现,具体取决于我们希望值在运行时还是编译时不可变。
虽然不可变性对于简单类型来说容易理解,但集合和对象引入了挑战。一个不可变集合是在每次更改时返回一个新的集合。例如,以下代码展示了一个可变集合:
vector<int> numbers {1, 2, 3};
numbers.push_back(4);
assert(numbers == vector<int> {1, 2, 3, 4});
将这个例子与下一个代码示例中的假设不可变集合进行对比,该集合在添加元素时返回一个新的集合:
immutable_vector<int> numbers {1, 2, 3};
immutable_vector<int> moreNumbers = numbers.push_back(4);
assert(numbers == immutable_vector<int> {1, 2, 3});
assert(moreNumbers == immutable_vector<int> {1, 2, 3, 4});
这个特性保证了你使用的是所需数据结构的正确版本。但 C++大脑中的内存优化警钟可能会响起。似乎为不可变集合分配了大量的内存!这不是一种浪费吗?
在不可变集合中执行更改时,确实可能会暂时使用比预期更多的内存。然而,函数式语言已经找到了避免这种情况的智能方法,C++也完全有能力使用相同的机制。这取决于实现方式。
优化不可变集合内存的方法是使用智能指针。记住,一旦值被分配给变量,它就是不可变的。因此,当集合首次初始化时,为集合的每个元素分配内存,并将每个内存区域分配给特定的值。当添加新元素时,每个元素的指针被复制,并为新值分配新的内存区域。如果从集合中删除元素,除了指向被删除元素的指针外,所有指向现有元素的指针都被复制。一旦内存区域不再被任何指针引用,它就会被删除。
虽然 STL 中没有实现不可变集合,但像 immer(github.com/arximboldi/immer)这样的库允许你使用这种模式,而不必过多担心内部细节。
好的,但不可变对象怎么办?面向对象的全部目的不是将行为与数据混合吗?
关于这一点,我有三件事情要说。
首先,好问题!
其次,面向对象编程被误解为关于封装、继承和多态,而实际上它是关于消息传递。不幸的是,C++是我喜欢称之为“面向类编程”的趋势的引领者:一种关注类及其关系而不是对象及其关系的编程风格。
第三,函数式编程实际上并不排斥对象。实现不可变对象非常简单:要么我们使用const实现不可变的数据结构,要么每个改变数据的函数返回一个包含修改后数据的新的对象。
在这里值得提一下的是,你不需要在程序中完全使用不可变性来从函数式编程中受益。我写的代码足够多,最大化了常量性,但仍然使用了标准 STL 集合和会改变其内部数据的对象。然而,你需要意识到,之前描述的不可变性的水平使得你更容易将并行性引入到你的程序中。如果值不能改变,你将不会有临界区的问题。每个线程都使用自己的值,改变值只会对特定的线程产生影响。实际上,这是不可变性的一个附带好处。我说附带好处,因为不可变性结合纯函数和良好的命名,一旦你习惯了构建块,程序就更容易理解。所以,让我们看看纯函数。
纯函数
纯函数是一个对于相同的输入返回相同输出且不会改变任何上下文值的函数。根据定义,纯函数不能进行输入/输出(I/O)操作。然而,任何非平凡的程序都可以写成纯函数和 I/O 函数的组合。
纯函数是你能想到的最简单的函数类型。它们易于理解,非常可预测,并且由于缺乏副作用,可以缓存。这导致数据驱动的单元测试变得容易,以及可能的优化,例如在第一次调用时缓存特定输入的函数结果,并在以后重用。
纯函数是函数式编程的核心。在 C++中,它们可以通过对不可变性的支持轻松实现。
在纯函数式语言中编写函数的原始方式是 lambda 表达式。自 C++11 以来,lambda 表达式已经成为了标准的一部分。然而,C++中的 lambda 表达式可以是可变的,因为它们可以改变它们在上下文中捕获的变量。因此,在 C++中编写纯函数,即使使用 lambda 表达式,也需要你确保所有涉及的变量的 const 属性。
在函数式范式下,一切要么是函数要么是数据结构,而在纯函数式语言中,这两者是可以互换的。那么,我们如何从简单的函数中创建复杂的行为呢?当然是通过使用各种操作来组合函数。
函数上的操作
由于函数是函数式编程的主要设计元素,思考函数如何通过操作进行变化是理所当然的。最常见的函数式操作是偏应用和组合。
偏应用指的是通过将函数的一个参数的值绑定到特定值来创建一个新的函数。例如,如果我们有一个函数add(const int first, const int second),我们可以通过将second参数绑定到值1来获得increment(const int)函数。让我们花点时间考虑一下后果:无论函数接收多少参数,都可以通过后续的偏应用减少到不接受任何参数的函数。这为我们提供了一个通用的语言,可以用来在代码中表达任何事物。
要在 C++中实现偏应用,我们可以使用来自
#include <functional>
auto add = [](const int first, const int second){ return first + second; };
auto increment = std::bind(add, std::placeholders::_1, 1);
TEST_CASE("add"){
CHECK_EQ(10, add(4, 6));
}
TEST_CASE("increment"){
CHECK_EQ(10, increment(9));
}
这是从函数式编程的角度来看的一个整洁的解决方案。然而,返回值很复杂,近似于一个函数而不是一个函数。这是 C++程序员在尝试函数式编程时遇到的心理障碍之一。我已经远离这个语言足够长的时间,可以让自己用高级概念来思考,而不是总是分析实现。所以,当我使用std::bind进行偏应用时,我会把结果当作一个函数,并希望实现者已经完成了优化并提供必要的功能。
函数的另一个基本操作是函数式组合。你可能已经在数学中遇到过这个结构。函数式组合指的是从两个函数,g 和 h,创建一个函数 f,使得对于任何值 x,f(x) = g(h(x))。在数学中,这通常表示为 f = g∘h。
不幸的是,C++ 标准中没有函数或操作来实现函数式组合,但使用模板很容易实现这个操作。再次强调,C++ 中这个操作的结果可能很复杂,但我鼓励你将其视为一个函数,而不是实际的数据结构。
让我们看看 C++ 中函数式组合的一个可能实现。compose 函数接受两个类型参数,F 和 G,分别表示要组合的函数 f 和 g 的类型。compose 函数返回一个 lambda 表达式,它接受一个参数 value,并返回 f(g(value):
template <class F, class G>
auto compose(F f, G g){
return ={return f(g(value));};
}
注意
上述例子是从 Alex 的另一本关于该主题的 Packt 出版物书籍中借用的,书名为 Hands-On Functional Programming in C++ 。
让我们通过一个简单的例子来看看如何使用这个函数。让我们实现一个价格计算器,它接受价格、折扣、服务费和税费作为参数,并返回最终价格。我们先来看一个命令式实现,使用一个函数来直接计算所有内容。computePriceImperative 函数接受价格,减去折扣,加上服务费,然后再加上税费百分比:
double computePriceImperative(const int taxPercentage, const int serviceFee, const double price, const int discount){
return (price - discount + serviceFee) * (1 + (static_cast<double>(taxPercentage) / 100));
}
TEST_CASE("compute price imperative"){
int taxPercentage = 18;
int serviceFee = 10;
double price = 100;
int discount = 10;
double result = computePriceImperative(taxPercentage, serviceFee, price, discount);
CHECK_EQ(118, result);
}
这是一个简单的实现,足以给出结果。当需要添加更多类型的折扣、根据项目修改税费或更改折扣的顺序时,这类代码通常会出现挑战。当然,当需要时,我们可以应用命令式或面向对象风格,并提取多个函数,每个操作一个函数,然后按需组合它们。
但现在让我们看看函数式风格。我们可以做的第一件事是使用 lambda 表达式来表示每个操作,并为最终计算使用另一个 lambda 表达式。我们实现了一些 lambda 表达式:一个用于从价格中减去折扣,第二个用于应用服务费,第三个用于应用税费,最后一个通过链式调用之前定义的所有 lambda 表达式来计算价格。最终我们得到了以下代码:
auto discountPrice = [](const double price, const int discount){return price - discount;};
auto addServiceFee = [](const double price, const int serviceFee){ return price + serviceFee; };
auto applyTax = [](const double price, const int taxPercentage){ return price * (1 + static_cast<double>(taxPercentage)/100); };
auto computePriceLambda = [](const int taxPercentage, const int serviceFee, const double price, const int discount){
return applyTax(addServiceFee(discountPrice(price, discount), serviceFee), taxPercentage);
};
TEST_CASE("compute price with lambda"){
int taxPercentage = 18;
int serviceFee = 10;
double price = 100;
int discount = 10;
double result = computePriceLambda(taxPercentage, serviceFee, price, discount);
CHECK_EQ(118, result);
}
这段代码更好吗?好吧,这取决于。一个因素是对这种范式的熟悉程度,但不要让这阻止你;正如我之前说的,熟悉性常常被误认为是简单性,但这两者并不相同。另一个因素是将 lambda 表达式视为函数而不是数据结构。一旦你克服这两个挑战,我们会注意到一些事情:lambda 表达式非常小,易于理解,并且是纯函数,这在客观上是最简单的函数类型。我们可以以多种方式链式调用,例如,在含税价格上应用折扣,因此我们有了更多的选择。尽管如此,我们仍然可以用命令式编程做到现在为止我们能做的任何事情。
那么,让我们再进一步,使其完全功能化。我们将使用我们创建的 lambda 表达式,但不是返回一个值,我们的实现将使用部分应用和函数组合来返回一个函数,该函数能给出我们想要的答案。由于前面的 lambda 表达式有两个参数,在应用函数组合之前,我们需要将其中一个参数绑定到相应的输入。因此,对于discountPrice lambda 表达式,我们将折扣参数绑定到传递给computePriceFunctional函数的值,并得到一个只接受一个参数(初始价格)的 lambda 表达式,返回带有折扣的价格。对于addServiceFee lambda 表达式,我们将serviceFee参数绑定到传递给computePriceFunctional函数的值,并得到一个只接受一个参数(服务前的价格)的函数,返回带有服务费的价格。对于applyTax lambda 表达式,我们将taxPercentage参数绑定到传递给computePriceFunctional函数的值,并得到一个只接受一个参数(不含税的价格)的函数,返回带有税的价格。一旦我们得到这些只接受一个参数的函数,我们就可以使用之前展示的compose函数将它们组合起来,从而得到一个只接受一个参数(价格)的函数,当调用时,计算正确的最终价格。以下是结果:
auto computePriceFunctional(const int taxPercentage, const int serviceFee, const double price, const int discount){
using std::bind;
using std::placeholders::_1;
auto discountLambda = bind(discountPrice, _1, discount);
auto serviceFeeLambda = bind(addServiceFee, _1, serviceFee);
auto applyTaxLambda = bind(applyTax, _1, taxPercentage);
return compose( applyTaxLambda, compose(serviceFeeLambda, discountLambda));
}
TEST_CASE("compute price functional"){
int taxPercentage = 18;
int serviceFee = 10;
double price = 100;
int discount = 10;
auto computePriceLambda = computePriceFunctional(taxPercentage, serviceFee, price, discount);
double result = computePriceLambda(price);
CHECK_EQ(118, result);
}
这种编程风格乍一看与面向对象编程(OOP)或结构化编程截然不同。但如果你稍微思考一下,你会意识到一个对象仅仅是一组紧密相连、部分应用的函数集合。如果你从对象中提取函数,你需要传递对象中使用的成员数据,这对于那些曾经用 C 语言编程的人来说是一种熟悉的风格。因此,将方法包含在对象中相当于将一些参数绑定到由构造函数初始化的对象数据成员上。因此,面向对象编程和函数式编程并不是真正的敌人,只是表达相同行为的不同且等效的方式,各有不同的权衡。
作为后续“元编程”部分的序言,让我们先看看如何在编译时使所有这些函数可用。我们需要用模板做一点魔法,并将值参数作为模板参数传递,还需要添加很多constexpr,但以下代码同样有效:
template <class F, class G>
constexpr auto compose(F f, G g){
return ={return f(g(value));};
}
constexpr auto discountPriceCompile = [](const double price, const int discount){return price - discount;};
constexpr auto addServiceFeeCompile = [](const double price, const int serviceFee){ return price + serviceFee; };
constexpr auto applyTaxCompile = [](const double price, cons t int taxPercentage){ return price * (1 + static_cast<double >(taxPercentage)/100); };
template<int taxPercentage, int serviceFee, double price, in t discount>
constexpr auto computePriceFunctionalCompile() {
using std::bind;
using std::placeholders::_1;
constexpr auto discountLambda = bind(discountPrice, _1, discount);
constexpr auto serviceFeeLambda = bind(addServiceFee , _1, serviceFee);
constexpr auto applyTaxLambda = bind(applyTax, _1, t axPercentage);
return compose( applyTaxLambda, compose(serviceFeeLa mbda, discountLambda));
}
TEST_CASE("compute price functional compile"){
constexpr int taxPercentage = 18;
constexpr int serviceFee = 10;
constexpr double price = 100;
constexpr int discount = 10;
constexpr auto computePriceLambda = computePriceFunctionalCompile<taxPercentage, serviceFee, price, discount>();
double result = computePriceLambda(price);
CHECK_EQ(118, result);
}
通过这种方式,我们已经看到了 C++中函数式编程的基本块。现在让我们看看它们在哪里以及为什么有用。
函数式风格的架构模式
让我们先看看如何实现一个完全采用函数式风格的程序。我们无法讨论这种应用程序的所有可能的设计模式,但我们可以展示一些示例。
我们首先注意到,函数式编程对我们的设计提出了一些约束。我们倾向于不可变性和纯函数。我们使用数据结构,但它们是不可变的,这意味着对数据结构的任何更改都会给我们一个新的版本。最后,I/O 部分需要尽可能分离和瘦,因为它需要进行更改。
使用这些约束的一个简单设计模式是管道模式。让我们想象我们收到一个 XML 格式的文件,并使用其中的数据调用 Web 服务。我们有一个输入层读取 XML 文件,一个输出层写入 Web 服务,以及一个中间层使用函数式风格。我们现在可以考虑输入和输出数据,并在输入上实施后续转换,以产生所需的输出。这些转换中的每一个都是对不可变数据结构工作的纯函数。
由于缺乏更改,这种过程高度可并行化。事实上,C++17 引入了
这种模式可以扩展到数据转换之外,到更宽松定义的功能核心,命令式外壳架构,由 Gary Bernhardt 恰当地命名。如果您想了解更多具体细节,可以查看具有功能核心的六边形架构。
这不仅表明我们可以使用函数式范式在 C++中设计程序,而且还表明在某些情况下这种架构是合适的。它还表明我们可以采用这种编程风格的一些部分,并将其应用于我们的实现中。
元编程
似乎有一件事将程序员团结在一起,无论他们如何不同:对递归笑话的喜爱。程序员心中有一种欣赏某种类型对称性的东西。当涉及到编程语言和编程范式时,你很难找到一个比能够理解自己的语言更对称的类型。
对应的编程范式被称为元编程,而将这一理念推向极限的编程语言被称为同构语言,这意味着一个程序可以操作另一个程序或其自身的表示或数据。具有这种特性的编程语言包括 Lisp 及其衍生方言,最新的是 Clojure。
元编程非常强大,但也非常难以掌握,并且可能会在大型项目中引入许多问题。与元编程相关的一些功能在现代语言中可用,例如工具、反射或指令的动态执行。但除了使用注解之外,实践中很少使用所有这些功能。
然而,C++却有所不同。元编程的一个特性是将计算从运行时移动到编译时,C++通过模板元编程完全接受了这一点。在语言更近的版本中,通过引入constexpr和consteval的泛化常量表达式,编译时计算的实现已经得到了简化。
这种技术的典型例子是阶乘实现。在运行时计算的递归阶乘实现看起来是这样的:
int factorial(const int number){
if(number == 0) return 1;
return number * factorial(number – 1);
}
同样的实现可以使用模板元编程来完成。C++模板的一个可能不太为人所知的特性是它们可以接受一个值作为参数,而不仅仅是类型。此外,既可以是泛型模板,例如,接受任何整数值作为参数的模板,也可以是特化,它只接受特定的值,都可以提供。在我们的例子中,我们可以实现一个接受整数和针对值0的特化的阶乘模板,从而得到以下代码:
template<int number>
struct Factorial {
enum { value = number * Factorial<number – 1>::value};
};
template<>
struct Factorial<0>{
enum {value = 1};
};
这种实现与之前的一个实现达到相同的目标,唯一的区别是对于Factorial<25>这样的调用,将在编译时而不是运行时进行计算。从 C++11 开始,随着泛化常量表达式的引入,我们可以完全避免使用模板,而是使用constexpr和consteval来告诉编译器哪些值需要在编译时计算。以下是对同一代码的简化实现,使用了常量表达式:
constexpr int factorial(const int number) {
return (number == 0) ? 1 : (number * factorial(number - 1));
}
可供 C++程序员使用的这些元编程技术使得与编译时和运行时发生的事情相关的决策更加灵活。它们在 CPU 周期与可执行文件大小之间提供了一个权衡。如果你有大量的内存可用,但计算需要非常快,那么在可执行文件中缓存结果可能是可行的,而constexpr和consteval将成为你的朋友。
但可能性并不止于此。我们可以在 C++程序中创建从编译时就可以验证的有效程序。我们只需要将强类型推向极限。
强类型推向极限
软件开发中最大的挑战之一是避免错误。这是一个普遍存在的问题,以至于我们习惯于用暗示代码出了问题的名称来称呼它。然而,实际上,我们应该称之为“错误”,因为这就是它们的本质。
既然我们有编译器,为什么不能对代码施加足够的限制,以便它们在出现错误时告诉我们?我们可能能够做到这一点,但不是免费的。我们在上一节讨论了模板元编程,但我们遗漏了一个重要的特性:模板元编程是图灵完备的。这意味着对于我们可以用常规方式编写的任何程序,我们也可以使用模板元编程来编写。
这个想法非常强大,并且随着时间的推移在各个环境中都有所讨论。如果你想尝试一个完全围绕这个概念构建的编程语言,可以尝试 Idris(www.idris-lang.org/)。许多程序员可能熟悉 Haskell 在编译时验证方面的支持。但我的第一次接触这个想法是在 2001 年安德烈·亚历山德鲁斯库的奠基性著作《现代 C++设计:泛型编程和设计模式应用》中。
让我们考虑一个简单的问题。常见的错误和代码恶臭的来源之一是所谓的原始类型迷恋,即使用原始类型来表示复杂数据的迷恋。原始类型迷恋的一个典型例子是将长度、金钱、温度或重量表示为数字,完全忽略它们的计量单位。与其这样做,不如为金钱使用一个值,它允许根据上下文具有特定的精度,例如会计和银行以及货币的七位小数。即使在程序只处理单一货币的情况下,这在软件开发中通常也很有用,因为你可以肯定的是,在功能方面,最终总会有一些变化——会有一个时间点,你的客户会要求你添加第二种货币。
与原始类型迷恋相关的一个典型挑战是限制原始类型。例如,考虑一个可以存储一天中小时数的类型。这个值不仅是一个无符号整数,而且只能是从 0 到 23,假设为了简单起见采用 24 小时制。如果能告诉编译器,0-23 范围之外的任何值都不应被视为小时数,并在传递例如 27 这样的值时给出相关错误,那就太好了。
在这种情况下,枚举可以是一个解决方案,因为值的数量很少。但我们将忽略这个选项,首先考虑如何在运行时实现它。我们可以想象一个名为Hour的类,如果传递给构造函数的值不在 0 到 23 之间,它会抛出一个异常:
class Hour{
private:
int theValue = 0;
void setValue(int candidateValue) {
if(candidateValue >= 0 && candidateValue <= 23){
theValue = candidateValue;
}
else{
throw std::out_of_range("Value out of range");
}
}
public:
Hour(int theValue){
setValue(theValue);
}
int value() const {
return theValue;
}
};
TEST_CASE("Valid hour"){
Hour hour(10);
CHECK_EQ(10, hour.value());
}
TEST_CASE("Invalid hour"){
CHECK_THROWS(Hour(30));
}
如果我们想在编译时进行检查呢?好吧,是时候使用constexpr的力量来告诉编译器哪些值在编译时定义,以及使用static_assert来验证范围了:
template <int Min, int Max>
class RangedInteger{
private:
int theValue;
constexpr RangedInteger(int theValue) : theValue(theValue) {}
public:
template <int CandidateValue>
static constexpr RangedInteger make() {
static_assert(CandidateValue >= Min && CandidateValue <= Max, "Value out of range.");
return CandidateValue;
}
constexpr int value() const {
return theValue;
}
};
using Hour = RangedInteger<0, 23>;
在前面的实现中,以下代码运行完美:
TEST_CASE("Valid hour"){
constexpr Hour h = Hour::make<10>();
CHECK_EQ(10, h.value());
}
但如果我们尝试传递一个超出范围的值,我们会得到一个编译错误:
TEST_CASE("Invalid hour"){
constexpr Hour h2 = Hour::make<30>();
}
Hour.h: In instantiation of 'static constexpr RangedInteger<Min, Max> RangedInteger<Min, Max>::make() [with int CandidateValue = 30; int Min = 0; int Max = 23]':
Hour.h:11:87: error: static assertion failed: Value out of range.
11 | static_assert(CandidateValue >= Min && CandidateValue <= Max, "Value out of range.");
| ~~~~~~~~~~~~~~~^~~~~~
Hour.h:11:87: note: '(30 <= 23)' evaluates to false
这个错误告诉我们,我们不能有一个值为 30 的小时,这正是我们所需要的!
这只是 C++程序员工具箱中的一种技术,他们希望创建在编译时可以证明有效的程序。正如我们提到的,模板元编程是图灵完备的,这意味着我们可以在理论上在编译时实现任何我们可以在运行时实现的程序。但总是有权衡。注意,小时值必须是constexpr,这意味着值将被存储在可执行文件中。这是设计上的考虑,因为将类型约束到最大值唯一的方法是将它们编译到单元中。
在实践中,我发现这种技术很容易导致难以理解和修改的代码。修改这种代码需要很强的纪律性,因为修改现有代码仍然可能引入我们通过强类型否则已经排除的 bug。基本技术始终是添加,而不是修改,除非是为了修复问题。我们一直保持代码的整洁,但类型可以很快变得非常抽象,这使得在六个月之后重建导致它们的推理变得非常困难。从积极的一面来看,这种技术在创建专注于非常特定领域的库时效果最好。
虽然我发现这种技术很有趣,但我倾向于在编程时更喜欢自由。我在编码时使用自己的纪律——测试驱动开发、无情重构、极端关注命名和简单设计。我更希望有一种编写代码的方式,让编译器处理细节,这就是为什么我将要讨论的最后一种范式尽可能地忽略类型。
那么忽略类型呢?
几年前,我领导了一个团队,使用名为 Groovy 的语言和名为 Grails 的框架构建了一些 Web 应用程序。Groovy 是一种可选类型和动态语言,这意味着它在运行时分配类型,但你可以为编译器提供类型提示。它也可以静态编译,并且由于它是建立在 JVM 上的,代码最终会变成 Java 单元。
我在之前的 Web 项目中注意到,类型在系统的边缘很有用,用于检查请求参数、与数据库交互以及其他 I/O 操作。但在 Web 应用的核心中,类型往往会使事情变得更复杂。我们经常不得不更改代码或编写额外的代码来适应已经实现的行为的新用法,因为 Web 应用的用户通常会注意到一个有用的场景,并希望它在其他上下文或其他类型的数据中也能工作。因此,我从一开始就决定,我们将使用类型进行请求验证,以确保安全和正确性,以及与外部系统的交互,以确保简单性。但我们没有在核心中使用类型。
计划一直是使用一种合理的自动化测试策略,以便通过测试证明所有代码的有效性。我预计没有类型会使我们编写更多的测试,但我遇到了一个巨大的惊喜:测试的数量与之前相对相同,但我们有更少的代码。此外,我们编写的代码,因为不涉及类型,迫使我们非常小心地命名事物,因为名称是我们作为程序员了解函数或变量正在做什么的唯一线索。
这至今仍是我最喜欢的编程风格。我想按自己的意愿编写代码,尽可能表达清晰,然后让编译器处理类型。你可以将这种方法视为极端的多态:如果你传递一个具有所需方法的类型的变量,代码应该能够根据你传递的类型正常工作。这不是一个我会推荐给每个人的风格,因为它是否有效并不明显,仅与特定的设计经验相结合,但它是一种你可以尝试的风格。然而,第一个挑战是放弃控制编译器做什么,这对非常注重细节的 C++程序员来说是一个更难达成的成就。
这在 C++中是如何工作的呢?幸运的是,自从 C++11 以来,C++引入了auto关键字,并在后续的标准中逐渐改进了其功能。然而,不利的是,C++在动态类型方面不如 Groovy 那么宽容,所以我偶尔需要使用模板。
首先,让我用你可以编写的最多态的函数来让你惊叹一下:
auto identity(auto value){ return value;}
TEST_CASE("Identity"){
CHECK_EQ(1, identity(1));
CHECK_EQ("asdfasdf", identity("asdfasdf"));
CHECK_EQ(vector{1, 2, 3}, identity(vector{1, 2, 3}));
}
这个函数无论我们传递给它什么都能正常工作。这不是很酷吗?想象一下,你有一堆这样的函数可以用在系统的核心部分,而且不需要修改它们。这听起来就像是我心目中的理想编程环境。然而,现实生活比这要复杂得多,程序需要的不仅仅是恒等函数。
让我们来看一个稍微复杂一点的例子。我们将首先检查一个字符串是否是回文,也就是说,它正向和反向读起来都一样。在 C++中一个简单的实现是取一个字符串,使用std::reverse_copy来反转它,然后比较原始字符串与其反转:
bool isStringPalindrome(std::string value){
std::vector<char> characters(value.begin(), value.end());
std::vector<char> reversedCharacters;
std::reverse_copy(characters.begin(), characters.end(), std::back_insert_iterator(reversedCharacters));
return characters == reversedCharacters;
}
TEST_CASE("Palindrome"){
CHECK(isStringPalindrome("asddsa"));
CHECK(isStringPalindrome("12321"));
CHECK_FALSE(isStringPalindrome("123123"));
CHECK_FALSE(isStringPalindrome("asd"));
}
如果我们使这段代码对类型的兴趣减少呢?首先,我们会将参数类型改为 auto。然后,我们需要一种方法来反转它,而不会限制我们只能使用字符串输入。幸运的是,ranges 库有一个 reverse_view 我们可以使用。最后,我们需要比较初始值和反转后的值,再次不太多地限制类型。C++ 为我们提供了 std::equal。因此,我们最终得到以下代码,我们可以用它不仅用于字符串,还可以用于表示短语的一个 vector<string>,或者用于在枚举中定义的标记。让我们看看极端多态的实际应用:
bool isPalindrome(auto value){
auto tokens = value | std::views::all;
auto reversedTokens = value | std::views::reverse;
return std::equal(tokens.begin(), tokens.end(), reversedTokens.begin());
};
enum Token{
X, Y
};
TEST_CASE("Extreme polymorphic palindrome"){
CHECK(isPalindrome(string("asddsa")));
CHECK(isPalindrome(vector<string>{"asd", "dsa", "dsa", "asd"}));
CHECK(isPalindrome(vector<Token>{Token::X, Token::Y, Token::Y, Token::X}));
}
也许我现在已经向你展示了为什么我觉得这种编程风格非常吸引人。如果我们忽略类型,或者使我们的函数具有极端的多态性,我们可以编写适用于未来情况的代码,而无需进行更改。权衡是代码在其推导出的类型中有限制,并且参数和函数的名称非常重要。例如,如果我向 isPalindrome 传递一个整数值,我将得到一个复杂的错误,而不是一个简单的错误,告诉我参数类型不正确。这是在我尝试传递整数时,我的计算机上 g++ 编译器输出的开始:
In file included from testPalindrome.cpp:3:
Palindrome.h: In instantiation of 'bool isPalindrome(auto:21)
[with auto:21 = int]':
testPalindrome.cpp:30:2: required from here
Palindrome.h:14:29: error: no match for 'operator|' (operand t
ypes are 'int' and 'const std::ranges::views::_All')
14 | auto tokens = value | std::views::all;
| ~~~~~~^~~~~~~~~~~~~~~~~
现在取决于你:你更喜欢强类型还是极端多态的行为?两者都有其权衡和各自的应用领域。
摘要
在本章中,我们了解到我们可以使用多种范式来用 C++ 编程。我们简要地看了一些:函数式编程、元编程、确保编译时验证的类型以及极端多态。所有这些方法,以及标准的面向对象和结构化编程,在构建库或特定程序的各种情况下都是有用的。它们各自都有提供给那些想要尽可能多地了解自己技艺的程序员的东西。它们各自都有其权衡和软件开发世界中的实现。
我们已经表明,C++ 程序员可能只使用了语言的一个子集,而且不一定是面向对象的。相反,最好是尝试所有这些,充分利用 C++ 足够强大,可以提供如此多的选项的事实,并根据手头的任务进行选择和选择。
在下一章中,我们将看到 main() 函数可能实际上并不是我们应用程序的入口点。
第四章:main()函数是应用程序的入口点
在 main 之前发生的事情都留在 main 中
对于在各个操作系统上使用 C++的程序员来说,应用程序的入口点是一个需要深入了解底层架构的概念。在本章中,我们将分析应用程序的启动方式,重点关注在我们达到用户定义的main()函数之前执行的初始化代码。
在 Linux 下探索这个过程时,我们将分析可执行和链接格式(ELF),详细说明execve()系统调用如何加载和执行_start()函数,该函数在调用main()之前准备运行时环境。我们还将探讨一些编译器特定的扩展,我们可以使用这些扩展来操纵这个过程。然后,我们将把注意力转向 Windows,详细检查 Windows 上的可移植可执行文件(PE)文件部分。
我们还将使用一个名为Ghidra的工具来分析两个平台下的可执行文件,因为这是提供关于支撑应用程序启动的低级操作实用见解的工具之一。
完成本章后,你将更深入地理解以下方面:
-
可执行文件格式以及 Linux 和 Windows 下的进程启动
-
如何篡改应用程序的启动过程
什么是 Ghidra?
Ghidra是由 NSA 开发的开源软件逆向工程套件,用于分析各种格式和平台上的编译代码。它提供了反编译、反汇编和调试二进制文件的工具,使用户更容易理解和分析机器代码。
main()函数
当我们在学校或可能在大学的第一门 C++课程中学习 C++时,我们的老师告诉我们:“亲爱的同学们,这是 main 函数:void main(void)。你的程序将从这里开始。”就这样。
章节完成——翻到下一页,我们下一章见。
然而,这个说法并不正确。我写下void main(void)只是为了激发你的好奇心,让你保持警觉。在这个职业阶段,所有 C++程序员都应该知道void main(void)与标准 C++的距离,就像 Nemo 点与最近的陆地一样遥远。
哦——你还在这里!这意味着你一定读过了细则。太好了——我们程序员应该始终关注细节,比如我们的应用程序是如何被底层操作系统加载和执行在内存中的。
由于我们生活在一个自由的世界,我们可以根据自己的意愿选择使用几个操作系统,因此我们选择展示这个应用程序在 Linux 和 Windows 下是如何加载的。
在这两个操作系统之间,关于它们加载和执行编译后的二进制文件的方式存在显著差异。在其中一个(不难猜出是哪一个)中,我们可以追踪到这个特殊过程的代码路径,直到底层内核的最深层次,而对于另一个,我们必须依赖现有的文档、书籍和各种信息来源,这些信息将由热衷于底层研究的学者收集。
由于 Linux 处理这种操作的方式与 BSD 家族(FreeBSD、NetBSD 等)的操作系统处理相同问题的方法非常相似,因此在我们接下来的段落中讨论问题时,我们将避免主动提及这些操作系统。由于我们希望在追求知识的同时让您保持兴趣,我们仍然想提供最新的信息,因此我们决定不为 MS-DOS 等特殊操作系统提供信息,这些操作系统自 2024 年起不再在活跃的生产环境中使用(除非您恰好在大德意志铁路公司工作 1)。
1 www.theregister.com/2024/01/30/windows_311_trundles_on/
但在我们深入探讨之前,我们将展示本章将使用的测试应用程序,以展示上述功能:
#include <cstring>
#include <cstdio>
struct A {
A(const char* p_a):m_a(new char[32]) { strcpy(m_a, p_a);
printf("A::A : %s\n", p_a);
}
~A() {
printf("A::~A : %s\n", m_a);
delete[] m_a;
}
volatile const char* get() const {return m_a;}
private:
char* m_a;
};
const char* my_string= "Hello string";
A my_a(my_string);
const char* my_other_string = "Go away string";
A my_other_a(my_other_string);
int main() {
printf("Hello, World, %s, %s\n", my_a.get(), my_other_a.get()); }
当在符合标准规范的系统上编译并运行上述应用程序时,它会产生以下预期的输出,这对于遵循标准规范的程序员来说是显而易见的:
A::A : Hello string
A::A : Go away string
Hello, World, Hello string, Go away string
A::~A : Go away string
A::~A : Hello string
是的,我们故意没有使用cout和其他流操作,因为我们希望保持代码的简洁。我们不希望污染生成的代码,因为我们计划深入研究编译后的可执行文件。
此外,请注意,这段代码是专门为本章编写的合成代码,用以展示我们想要展示的特性。作者完全清楚 strcpy 可能引起的潜在内存溢出错误,因此建议读者按照作者的建议去做,而不是模仿作者的做法:“不要使用 strcpy。”
回到我们的初始目标,让我们展示操作系统如何加载和执行应用程序。如果,亲爱的读者,您觉得下面的讨论过于底层,请记住:C++程序在运行时编译成本地代码,以尽可能高的速度执行,这是底层操作系统分配的速度。
考虑到这一点,我们认为任何 C++程序员都有必要了解操作系统是如何处理他们的代码的,以及编译器消化了他们的源文件并输出可执行文件后会发生什么。我们将尽量排除最低级细节,只展示真正必要的内容,以便全面理解这一情况的严重性。
企鹅农场
当 Linux 加载和执行应用程序(例如,我们想要执行一个应用程序,而不是 shell 脚本或其他东西)时,通常通过一个 fork() / execve() 系统调用对来启动应用程序的执行。
这些系统调用负责复制当前进程(fork())并用新的进程映像(要执行的应用程序,即execve())替换当前进程映像。
这些 API 调用在 Mark Mitchell、Jeffrey Oldham 和 Alex Samuel 所著的《高级 Linux 编程》中有详细的介绍,但还有无数在线资源专门介绍这个主题。所以,如果你对这个主题感兴趣,你可能会在那里找到很好的信息来源。
但让我们继续加载可执行文件。在经过几次迭代并离开用户空间限制后,execve() 系统调用最终会进入 Linux 内核并创建一个 linux_binprm 结构 2。
2 github.com/torvalds/linux/blob/master/include/linux/binfmts.h
根据文档,这个结构在加载二进制文件时使用,并包含在加载和执行二进制文件时所需的所有主要详细信息。
如果你有很多空闲时间,手里拿着一大杯茶,并且对 C 语言的复杂性有深入的了解,你可以轻松地阅读 do_execveat_common 函数的冗长实现,以了解更多关于当前 Linux 内核源树中此函数幕后情况的信息 3。
3 https://github.com/torvalds/linux/blob/master/fs/exec.c
内核随后确定可执行文件的格式。在 Linux 系统中,最常用的可执行文件格式是 ELF。
所有字段都在官方标准文档 4 中进行了描述,但与我们用例相关的字段的快速总结如下:
4 https://refspecs.linuxfoundation.org/elf/elf.pdf
| 字段名称 | 偏移量 | 描述 |
|---|---|---|
| MAGIC | 0x00 | 一个表示文件是 ELF 文件的魔数(ASCII 中的“ELF”和 0x7F) |
| CLASS | 0x04 | 指定 ELF 文件的类(32 位或 64 位) |
| e_type | 0x10 | 识别对象文件类型(例如,可执行文件、共享对象等) |
| e_machine | 0x12 | 指定文件编译的架构 |
| e_entry | 0x18 | 系统首先将控制权转移到该虚拟地址,以启动进程 |
请记住这个表格,因为我们很快就会参考它。但现在,让我们继续加载程序。现在是内核读取 ELF 头以了解可执行文件结构的时候了。在这个阶段发生以下操作:
-
内存分配:内核为新进程分配内存。这包括设置进程的地址空间,它由不同的段组成,如文本(代码)、数据、堆和栈。
-
段映射:内核将可执行文件的段映射到进程的地址空间中。例如,文本段(包含可执行代码)被映射为只读,而数据段(包含全局变量)被映射为读写。
-
动态链接:如果可执行文件依赖于共享库,则调用动态链接器/加载器(ld.so)来加载必要的共享库并解析符号引用。动态链接器还将这些库映射到进程的地址空间中。
这些操作都在 Linux 内核深处进行,但如果你对这个领域感兴趣,我们鼓励你阅读源代码——也许你能在其中发现一些有趣的东西。
一旦所有这些有趣且非常底层的操作都成功执行,内核就会为进程设置初始环境栈。这个栈包含以下内容:
-
参数向量(argv):命令行参数数组。
-
环境变量(envp):环境变量数组。
-
辅助向量(auxv):程序需要的附加信息,例如系统页面大小、程序入口点等。
所有这些都在之前提到的相同内核源文件(binfmt_elf.c)中发生,在以下函数中:
static int create_elf_tables(struct linux_binprm *bprm,
const struct elfhdr *exec, unsigned long interp_load_addr,
unsigned long e_entry,unsigned long phdr_addr) { ... }
在创建运行时环境后,内核设置 指令指针(IP)指向程序的入口点(如 ELF 头部中指定)。CPU 寄存器也被初始化为所需的值。最后,内核将 CPU 返回到用户模式并将控制权转移到程序的入口点。
在 Linux 中,控制权转移主要发生在 start_thread() 函数中,该函数是架构特定的。在撰写本文时,对于 x86,该函数在 arch/x86/include/asm/processor.h 中定义,并在 arch/x86/kernel/process_64.c 中实现。程序从这个点开始执行。现在来到有趣的部分——至少从 C++ 开发者的角度来看。
首先,执行程序的初始化代码(通常是 C 运行时库的一部分)——通常是 _start() 函数,而不是 main()。ELF 头部的 e_entry 字段列出了程序开始执行的位置在文件中的偏移量。通常,它是 _start() 方法的偏移量,或者至少如果可执行文件是用标准 GNU 工具链编译的。这段代码负责设置任何运行时环境变量并调用程序的 main() 函数。从这一点开始,程序按照编写的指令运行。
那么,让我们来检查一下初始化代码究竟是什么。我们将使用我们手头的工具Ghidra,它允许我们剖析 Linux 可执行文件并检查它们的内部工作原理。此工具为我们几乎空白的程序提供了以下摘要:

图 4.1 – 我们合成应用的架构
当查看ELF 源文件部分时,我们可以看到我们的初始main.cpp文件;然而,还有一些我们还不熟悉的其他项目 – 例如,crtstuff.c。此文件是libgcc的一部分,可以在libgcc仓库 5 中找到,其顶部有如下注释:
5 github.com/gcc-mirror/gcc/blob/master/libgcc/crtstuff.c
/* Specialized bits of code needed to support construction and destruction of file-scope objects in C++ code.
这样,一个谜团已经解开,注释也就不言自明了。然而,另一个谜团仍然存在:Scrt1.o。要理解这一点,我们需要了解固定地址可执行文件和位置无关可执行文件(PIEs)之间的区别。
固定地址可执行文件被编译为在特定的、预定的内存地址上加载,这使得它们更简单但安全性较低且灵活性较差,因为它们的地址是可预测的且容易受到攻击。这是在嵌入式设备和一些较老的平台(如也具有此“特性”要求.com应用程序在特定偏移量加载的 MS-DOS)上加载可执行文件的首选方式。
另一方面,位置无关可执行文件(PIE)可执行文件在编译和链接时被设计为位置无关,允许它们在任何内存地址上加载。
当你编译一个程序时,你可以使用各种标志来控制编译器如何生成代码。-fPIE、-pie和-fPIC标志与代码在内存中的定位和处理方式有关。以下是每个标志的快速概述:
-
-fPIE(位置无关可执行文件):-fPIE标志告诉编译器为可执行文件生成位置无关代码。这对于创建支持地址空间布局随机化(ASLR)的可执行文件很有用,这是一个安全特性,它随机化可执行文件加载到的内存地址,使得攻击者更难预测特定代码的位置。
-
-pie(位置无关可执行文件链接器标志):-pie标志在链接阶段使用。它指示链接器生成位置无关的可执行文件。这意味着最终输出文件(可执行文件)将能够在支持 ASLR 的任何内存地址上加载。它与在编译阶段使用的-fPIE标志相辅相成,确保可执行文件中的所有代码都是位置无关的。
-
-fPIC(位置无关代码):-fPIC标志告诉编译器为共享库生成位置无关代码。共享库的位置无关代码意味着库可以加载到内存中的任何地址。这对于共享库是必要的,因为它们可能被加载到不同程序的不同内存位置。
现在我们已经了解了这些重要概念,让我们回到我们之前中断的地方,并讨论我们二进制文件中剩下的一个谜团:Scrt1.o。你还记得_start()函数吗?由于你没有自己编写它,它必须来自某个地方。对于我们来说,它来自这个神奇的Scrt1.o。crtX.o有几个变体,一些以 S 开头,一些没有,但对我们来说,Scrt1.o的存在告诉我们我们的应用程序是一个 PIE 可执行文件。还可以将几个其他文件链接到我们的应用程序:
-
crt0.o、crt1.o 等等:这些文件包含_start符号,这对于引导程序执行至关重要。它们的具体命名约定可能因libc实现而异。
-
crti.o:这为.init和.fini部分定义函数前缀,触发链接器生成的动态标签(DT_INIT 和 DT_FINI),以支持我们将在这里讨论这些概念,所以不要担心未知术语。
-
crtn.o:这为.init和.fini部分提供函数结尾,补充crti.o。
-
Scrt1.o、gcrt1.o和Mcrt1.o:这些是crt1.o的变体,在不同情况下使用,例如生成 PIEs 或包含性能信息。
-
crtbegin.o、crtbeginS.o和crtbeginT.o:这些由 GCC 用于定位构造函数及其变体(crtbeginS.o用于共享对象/PIEs 和crtbeginT.o用于静态可执行文件)。
-
crtend.o和crtendS.o:与crtbegin.o类似,这些由 GCC 用于定位析构函数(crtendS.o用于共享对象/PIEs)。
现在我们已经揭开了可执行文件内容的神秘面纱,我们需要了解另一件事:ELF 文件中的.init_array部分用于存储一个函数指针数组,操作系统运行时加载器在程序启动时自动执行这些函数。
这些函数通常被称为“初始化函数”或“初始化函数”。它们在main()之前被调用,并负责初始化全局数据。对于我们的合成应用程序,这是经过Ghidra分析后该部分的外观:

图 4.2 – 全局变量的.init_array 部分
如我们所见,这里有两个函数——一个是哑函数,另一个叫做_GLOBAL__sub_I_my_string。名字选择很有趣,所以让我们使用工具的汇编到 C-like 代码功能来看看它做了什么:

图 4.3 – 根据 Ghidra 创建全局对象的方式
有趣,不是吗?这正是你期望在全局命名空间中发生的事情。
在这里,正在创建 my_a 和 my_other_a 对象,调用它们的构造函数,并为 __cxa_atexit 调用类 A 的析构函数。尽管这是一个相当有趣的观察,但幕后构造函数的调用是如何工作的。
从这种令人不安的反汇编中,你可能觉得构造函数为它正在构建的对象获得了一个不可见的参数。这是真的:这是 this 变量,并且它被隐式地添加到类的所有方法中,而不需要显式要求。这就是我们如何访问对象本身的方式。
如其名称所示,__cxa_atexit 函数就像 atexit 一样。然而,你不必担心它,因为它不是应该在它所在的库之外处理的函数。
现在我们已经了解了这里发生的事情,是时候捡起我们之前提到的一个线程:臭名昭著的 _start() 函数。
如前所述,这个函数应该做一些清理工作并启动我们的 main 函数。根据 Ghidra,它确实可以在 ELF 头中找到。根据 ELF 规范,它占据了 ELF 条目列表中的 e_entry 字段::

图 4.4 – 根据 Ghidra 的 ELF 头
现在,经过一些反汇编魔法的应用,得益于 Ghidra,它看起来是这样的:

图 4.5 – _start 例程函数,反汇编并转换为 C 伪代码
看起来可怕的 __libc_start_main 函数并没有它看起来那么可怕,它负责加载我们的 main() 函数以及操作系统提供的参数。这个函数是 glibc 的一部分,可以免费获得 6 ,就像其他所有行为良好的免费软件一样,这样我们就可以研究其内部结构。
6 git clone git://sourceware.org/git/glibc.git
在这个阶段,随着 __libc_start_main 的结果,我们已经到达了实际的 main 函数。这是我们期望程序驻留的地方。
这些细节提供了对程序执行、优化机会和调试能力的更深入见解。掌握 ELF 文件格式可以使你通过利用特定的链接器选项和理解动态链接的复杂性来优化性能。此外,它通过跟踪初始化序列和识别启动相关的问题,有助于有效的调试。
哦,还有更多!
现在我们已经在这里,在我们最喜欢的 Linux 机器上打字,让我们不要浪费时间,更深入地了解一下这个伟大操作系统中一些编译器的内部机制。例如,让我们深入研究 ELF 文件的.init_array部分。如前所述,它负责在主函数之前启动不同的函数。
但在我们继续穿越这些沼泽地之前,必须提到一个警告:我们接下来要讨论的内容并不适合胆小的 C++程序员,甚至它都不是标准的 C++。请阅读第二章有关 C++标准性的内容。如果你能忍受编译器扩展的邪恶教条,那么请继续阅读。
GCC(以及 Clang)有一个非常方便的扩展,可以在main()之前执行函数。这些函数被称为构造函数,并且需要使用特定的属性来生成:
__attribute__((constructor)) void welcome() {
printf("constructor fun\n");
}
如果我们将这段特定的代码添加到我们的合成应用程序中,我们可以期待以下输出:
constructor fun
A::A : Hello string
A::A : Go away string
Hello, World, Hello string, Go away string
A::~A : Go away string
A::~A : Hello string
如您所见,构造函数在全局初始化代码之前执行。如果我们用我们最喜欢的九头铲深入可执行文件,我们将在. init_array部分看到以下内容:

图 4.6 – 带有构造函数的 .init_array 部分
通过这些知识,我们现在拥有了两种在 C++应用程序中main()函数之前执行代码的方法:构造函数和全局变量。
到目前为止,我们已经开始触及一些危险的东西:静态初始化顺序灾难。这是一个多次在各种地方讨论过的话题。这些讨论总结说,这个问题源于不同翻译单元中静态或全局变量初始化的未定义顺序。有各种技术可以解决这些问题,但我们的建议是避免它们。
以下示例说明了为什么这可能会引发危险的情况。在这里,我们创建了几个简短的文件,再次使用合成内容,试图模拟现实生活中的情况:
a.h
#ifndef A_H
#define A_H
class C;
extern C a_c;
#endif
b.h
#ifndef B_H
#define B_H
class C;
extern C b_c;
#endif
C.h
#ifndef C_H
#define C_H
#include <cstring>
#include <cstdio>
struct C {
C(const char* p_c) : m_c(nullptr) {
m_c = new char[32];
strcpy(m_c, p_c);
printf("C::C : %s\n", p_c);
}
~C() {
printf("C::~C : %s\n", m_c);
delete[] m_c;
}
private:
char* m_c;
};
#endif
a.cpp
#include "C.h"
C a_c("A");
b.cpp
#include "C.h"
C b_c("B");
main.cpp
int main()
{
}
这段代码并不特别复杂——它只是一个用于打印一些调试信息和创建上述诊断类对象的单独 C++文件的诊断C类。
通常,这些文件是用gcc编译的,所以让我们编译它们并执行生成的文件:
> $ g++ main.cpp a.cpp b.cpp -o test
> $ ./test
C::C : A
C::C : B
C::~C : B
C::~C : A
这里没有什么特别之处——我们编译并创建了一个可执行文件,它执行了它应该执行的操作:在创建和销毁特定对象时打印出来。
但如果我们以不同的顺序指定文件会发生什么呢?
> $ g++ main.cpp b.cpp a.cpp -o test
> $ ./test
C::C : B
C::C : A
C::~C : A
C::~C : B
真是令人惊讶。现在,b.cpp 中的 b_c 对象在 a.cpp 中的 a_c 对象之前创建。现在,想象一下我们的程序由依赖于某些其他全局对象正确初始化的全球对象组成的灾难性情况。
幸运的是,Linux 下的编译器生态系统为我们提供了必要的工具,帮助我们实现应用程序在这个问题上的合理状态,借助一个非常方便的扩展。这个扩展用于指定全局成员的初始化顺序,并使用 attribute((init_priority(XXX))) 语法体现出来。
gcc 和 clang 都提供了这种方式来控制跨翻译单元的 namespacescope 对象的初始化顺序,使用 init_priority 属性。这个属性允许用户为初始化分配一个相对优先级,优先级值从 101 到 65535(包含)不等。数字越小,优先级越高,这意味着具有较低 init_priority 值的对象将更早初始化。
带着这些知识,让我们修改我们的合成示例文件,以便它们使用这个扩展:
a.cpp
#include "C.h"
__attribute__((init_priority(1000))) C a_c("A");
b.cpp
#include "C.h"
__attribute__((init_priority(1001))) C b_c("B");
现在,无论 a.cpp 和 b.cpp 的引入顺序如何,结果都将相同:
> $ g++ main.cpp a.cpp b.cpp -o test
> $ ./test
C::C : A
C::C : B
C::~C : B
C::~C : A
> $ g++ main.cpp b.cpp a.cpp -o test
> $ ./test
C::C : A
C::C : B
C::~C : B
C::~C : A
现在,让我们回到我们的第一个合成应用程序——那个尝试在同一个翻译单元中创建全局对象的应用程序。同时也引入了“构造函数”的概念。让我们看看如果我们为其中一个全局对象指定初始化优先级会发生什么,以及在这种情况下顺序会是什么:
__attribute__((init_priority(1000)))
A my_other_a(my_other_string);
令人惊讶的是,输出结果如下:
A::A : Go away string
constructor fun
A::A : Hello string
Hello, World, Hello string, Go away string
A::~A : Hello string
A::~A : Go away string
为了深入理解幕后机制,并了解为什么会出现这种场景,我们运行了我们钟爱的工具在编译后的二进制文件上。结果证实了我们的发现,如下截图所示:

图 4.7 – 根据 gcc 指定的初始化优先级显示的 .init_array 部分
输出之所以如此,是因为 .init_array 部分增加了一个新成员,它需要在构造函数和标准全局初始化代码之前执行。
并不难猜测新函数的名称包含初始化优先级。然而,让作者感到困惑的是,为什么 gcc 决定持续使用 my_string 作为变量名称的后缀。这必须是 gcc 的特性,因为用 clang 编译的相同可执行文件产生了以下 . init_array 部分:

图 4.8 – Clang 对于相同初始化优先级的不同的 .init_array 部分
作者发现,为什么gcc和clang在处理对象文件的这个关键部分时存在如此大的差异,这很有趣。然而,在没有进一步分析这些编译器的源文件的情况下,这仍将是一个谜。
库是意外行为和思想诞生的分娩室
到目前为止,我们一直是单一应用程序的快乐的父母。现在,我们的爱情结晶成熟并准备结婚……意味着,为了遵循一些常识和更高级的编程实践,我们希望将合成代码中的一些非常有用的功能提取到一个合成库中,并将其命名为synth。抱歉——我的意思是libsinth。
由于本章的主要焦点仍然是main()之前的代码执行分析(1),并且我们愉快地提倡 gcc(和 clang)的扩展(2),让我们看看如果我们将所有这些组合在一起会发生什么,这是一种代码和数据的不神圣的婚姻。
作为旁注,我们将使用我们的第二个合成示例,其中a.cpp和b.cpp在其最后阶段保持未变,这包括所需的初始化顺序。我们将创建一个新的main.cpp文件来利用库本身,并且我们还将引入库的源代码。
我们的库将由以下代码构建:
synth.cpp
#include "C.h"
#include <cstdio>
__attribute__((init_priority(2000))) C synth_c("synth");
__attribute__((constructor)) void welcome_library() {
printf("welcome to the library\n");
}
void print_synth() {
printf("print_synth: %s\n", synth_c.get());
}
synth.h
#ifndef SYNTH_H
#define SYNTH_H
void print_synth();
#endif
除了定义全局对象synth_c,其类型为C(如C.h头文件中定义的)并且具有2000的初始化优先级之外,我们还定义了一个名为welcome_library的函数,带有attribute((constructor))标记,确保它在main()之前运行并打印“欢迎来到库。”
此外,print_synth函数打印一条消息,说明从synth_c.get()获得的值。C.h头文件是几页前的那个——它定义了类C,以及创建对象所需的所有方法和构造函数。
要使用这个库,我们需要为其创建相应的底层基础设施。这包括上述两个文件和一个使用其公开特性的应用程序。
为了保持进度,我们需要修改我们的主文件,使其使用库的功能。然而,我们还想保留为这种场景创建的测试源文件。
因此,我们的应用程序将包含上述a.cpp和b.cpp文件,以及我们的新main.cpp文件:
main.cpp
#include "synth.h"
#include "C.h"
__attribute__((constructor)) void welcome_main() {
printf("welcome to the main\n");
}
C main_c("main") ;
int main() {
print_synth();
return 0;
}
为了使一切正常工作,我们需要将这些项目链接起来,并将它们转换成一个可工作的应用程序:
> $ g++ -c -o synth.o synth.cpp
> $ ar rcs libsynth.a synth.o
> $ g++ -o main main.cpp a.cpp b.cpp -L. -lsynth
如您所见,在这个阶段,我们已经创建了一个静态库,libsynth.a,并将我们的主应用程序链接到它,以正确地包含库中的所有代码。
请注意,没有c.cpp文件,因为为了尽可能紧凑,我们在头文件中提供了类的所有实现。对于更大的项目,这并不是最佳实践,因为对类中任何函数实现的任何小更改都要求重新编译包含头文件的所有文件。然而,对于这种情况,我们可以接受。
由于我们对我们所创建的各种构造的执行顺序感兴趣,在运行结果应用程序后,我们得到以下输出:
> $ ./main
C::C : A
C::C : B
C::C : synth
welcome to the main
C::C : main
welcome to the library
print_synth: synth
C::~C : main
C::~C : synth
C::~C : B
C::~C : A
要深入了解新编译的可执行文件内部结构,我们将使用我们喜爱的工具Ghidra打开它,并定位我们最感兴趣的章节:. init_array章节。
经过快速检查,我们可以看到打印顺序与. init_array章节中函数的顺序相对应:

图 4.9——不同文件中不同初始化优先级的.init_array 章节
在这里,_GLOBAL__sub_I_welcome_main是创建main.cpp中全局对象的函数——即C main_c("main") ;。有趣!在这个时候,我们确信全局对象的初始化顺序即使在库之后——至少是静态库——也是有效的。
但我们还没有完成。让我们看看如果我们创建一个共享库会发生什么。这并不复杂。在移除生成的文件——即synth.o、libsynth.a和main——以便我们有一个干净的起点后,我们需要运行以下命令来创建一个共享库:
> $ g++ -fPIC -c -o synth.o synth.cpp
> $ g++ -shared -o libsynth.so synth.o
> $ g++ -pie -o main main.cpp a.cpp b.cpp -L. -lsynth
现在,我们可以看到从本章开始创建共享库和应用程序的整个过程是如何轻松地实现那些神奇开关的。
在所有这些部分就绪后,我们可以看到 Ghidra 如何展示应用程序概述的一个有趣的变化:

图 4.10——Ghidra 中显示的作为依赖项的 synth 库
在这里,我们可以看到对刚刚创建的libsynth.so库的依赖。现在,我们可以检查关于可执行文件最感兴趣的部分——. init_array:

图 4.11——.init_array 章节中没有对 libsynth 的引用
我们的 synth 库中的对象和函数完全没有引用……难怪——它是一个库。但至少我们可以看到我们的应用程序正确地链接到库:
> $ LD_LIBRARY_PATH=. ldd ./main
linux-vdso.so.1 (0x00007fff17387000)
libsynth.so => ./libsynth.so (0x00007ea84ee45000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6
请注意,我们必须明确指定LD_LIBRARY_PATH=.以找到库(注意,我们已截断不必要的输出行以保持内容清晰)。
在这一点上,我们很好奇当执行应用程序时会发生什么:
> $ LD_LIBRARY_PATH=. ./main
C::C : synth
welcome to the library
C::C : A
C::C : B
welcome to the main
C::C : main
print_synth: synth
C::~C : main
C::~C : B
C::~C : A
C::~C : synth
首先,根据单应用程序测试设定的预期,具有指定优先级(优先级)的对象在库中创建。然后,调用库中的构造函数。如果库中还有其他非优先级的全局对象,它们将在这些对象之后创建,在主应用程序的优先级对象创建之前,以及主应用程序的构造函数被调用之前。所有这些操作都是在 main() 函数甚至有机会说“安静”之前完成的。
几乎就像我们预期的那样发生。只是这些函数构造扩展的一个阴暗角落,我目前还没有找到解决办法——如果 a.cpp 和 b.cpp 包含以下行?
__attribute__((constructor)) void welcome_a() {
printf("welcome to the 'a' file\n"); }
__attribute__((constructor)) void welcome_b() {
printf("welcome to the 'b' file\n"); }
这不神圣的神秘代码添加了两个更多的构造函数到我们的可执行文件中。现在,我们有三个了。如果你也想为这些构造函数指定一个可预测的执行顺序,你需要使用 attribute((constructor(205))) void welcome_b() 来指定它们的优先级。这将保证这些函数也将按照特定的顺序执行,并且你不会遇到全局构造函数调用顺序的混乱。
当库被动态加载(dlopen / dlclose)时的行为是人们预期的,即它遵循主应用程序的执行流程,在库被加载的点,它将跳转并执行库中的各种构造函数和对象初始化。
最后的话
本章讨论了在主函数之前执行的代码。然而,对于在主函数之后执行的代码也需要同样的关注,但这个讨论将在另一章、另一本书中进行。
但为了给你一些提示,这里有一个小提示:就像有构造函数一样,也有析构函数。它们不像 C++ 的析构函数——更像是 attribute((destructor))。
用标准的程序退出例程来增加这些,我们比启动时更有乐趣,因为我们必须考虑无数的其他替代方案,例如注册给 std::atexit(甚至 std::quick_exit)的函数,或者程序的非正常终止。例如,假设在析构函数中抛出一个异常,或者我们使用 std::terminate 或 std::abort。
gcc 和 clang 的文档提供了一个从标准世界中的美好逃避,任何关于 C++ 的好书都会提供一个关于标准终止例程的全面概述,所以请前往它们那里听一场精彩的讲座。这两个的结合将提供关于应用程序如何启动和退出的最佳概述。
在我们从 Packt 的人那里得到满意的反馈之前,我们将把注意力转向其他平台——而不是本章节约定的 16 页,我们目前已经达到了 22 页,但只覆盖了承诺主题的一半。
让我们打开 Windows(除非你在 ISS 上)
在我们深入探讨 Windows 下应用程序执行的内幕以及我们必须采取的步骤以到达主函数之前,请注意,从 C++ 的角度来看,与 Linux 或任何其他操作系统之间不应有真正的区别。仅 C++ 的标准功能(应该是)与前面页面中展示的功能相同,因此我们在这里不会重复相同的信息。
然而,我们将展示应用程序在 Windows 下如何以及为什么以这种方式启动,并介绍一些可以直接影响这种行为的技巧,就像我们在 Linux 下做的那样。我们还将使用 Visual Studio 编译器,因为 Windows 下的 gcc 和 clang 行为相同,所以没有必要再次展示它们。
由于其封闭性,要理解 Windows 下的进程创建,我们需要求助于处理这类信息的少数可用资源。其中之一是我在这个领域找到的最好的书:*《Windows 内部机制,第 7 版(第一部分)》,7 ,由 Pavel Yosifovich、Alex Ionescu、Mark E. Russinovich 和 David A. Solomon 撰写。
7 learn.microsoft.com/en-us/sysinternals/resources/windows-internals
从那本书中获得的信息得到了从全球互联网收集的各种零散信息的补充,并经过筛选,以便为读者提供对 Windows 进程创建方面的轻量级介绍。然而,我们将回溯参考本章 Linux 小节中遇到的一些概念,因此阅读它会有所帮助。此外,一个小观察:与 Linux 相比,Windows 在安全性、线程处理和用户管理方面更加精细,所有这些都反映在处理进程的方式上。如果你对这个领域感兴趣,有几种资源可用,例如 James Forshaw 的优秀作品 《Windows 安全内部机制:深入 Windows 认证、授权和审计》。如果你对这个领域感兴趣,我们建议你阅读它。
让我们回到进程上来。Windows 中的进程创建机制涉及几个阶段,这些阶段由操作系统的不同组件执行:Windows 客户端库、kernel32.dll、Windows 执行器和 Windows 子系统进程(csrss.exe)。由于我们没有访问这些 Windows 组件源代码的权限,我们对这个问题的介绍将非常高级。
Windows 中的进程是通过 CreateProcess 家族的函数创建的,该家族有几个亲戚和叔叔(以不同用户创建进程、以各种安全许可创建进程等……)但经过几次迭代后,扩展家族例程的所有成员最终都会进入kernel32.dll中的 CreateProcessInternalW 函数,该函数首先验证并转换一些参数和标志为内部表示(遗憾的是我们无法访问)。
新进程的优先级类别由CreationFlags参数确定。在 Windows 中,有六个优先级类别:空闲、低于正常、正常、高于正常、高和实时。如果没有指定优先级类别,则默认优先级类别为正常。如果请求实时优先级但调用者缺乏必要的权限,则优先级将降级为高。
接下来,如果进程需要调试,kernel32.dll将启动与本地调试接口的连接,并设置默认的硬错误模式(如果指定)。用户指定的属性列表被转换为它的本地格式,并添加任何额外的内部属性。进程和初始线程的安全属性也被转换为内部表示。
下一步是打开要运行的可执行映像。这个任务由NtCreateUserProcess系统调用处理。首先,该函数再次验证参数以确保它们没有被篡改。然后,它尝试找到并打开适当的 Windows 映像并创建一个将在稍后日期映射到新进程地址空间的部分对象。
如果映像不是一个有效的 Windows 可执行文件,该函数将搜索一个支持映像来运行它。例如,如果可执行文件是一个 MS-DOS 或 Win16 应用程序,它将使用ntvdm.exe(用于 32 位 Windows)来运行它。这确保了较老的 DOS 或 Win16 应用程序可以在 Windows 环境中正确执行。然而,这一特性在现代 Windows 系统中已被逐渐弃用,因此您需要启用它才能使其正常工作。
可执行映像一旦被打开,下一个阶段就是创建 Windows 执行进程对象。这涉及到设置进程的虚拟地址空间和其他关键结构。执行进程对象作为所有进程所需资源的容器,包括内存、句柄和线程。
在设置进程对象之后,创建初始线程。这一步包括设置线程的堆栈、上下文和执行线程对象。线程负责执行程序的入口点并管理进程的执行流程。
在创建初始线程之后,Windows 执行子系统特定的初始化任务。这些任务对于将新进程集成到 Windows 子系统至关重要,该子系统为进程正确运行提供环境和资源。
然后启动初始线程,除非指定了CREATE_SUSPENDED标志,在这种情况下,线程将保持挂起状态,直到显式恢复。启动线程涉及切换到用户模式并执行进程的入口点。
最后,在新的进程和线程的上下文中,地址空间被初始化。这包括加载所需的 DLL 以及执行任何其他必要的设置任务。一旦这些步骤完成,进程开始执行其代码,进程创建被认为是完成的。
是 PE 还是非 PE
与其他具有特定意义的文件一样,构成基于 Windows 的可执行文件的字节也有特殊含义。
Windows 可移植可执行文件(PE)格式是用于在 Windows 操作系统中的可执行文件、对象代码、DLL 和其他系统文件的文件格式。它是 DOS(以及 FreeDOS)、Windows 和 ReactOS 中可执行文件的标准文件格式,并包括可执行文件(EXE)和动态链接库(DLL)文件类型。
PE 格式被设计为可扩展的,并且能够支持现代操作系统功能。如果您对这个领域感兴趣,网上有很好的学习机会,所以我们鼓励您学习这个主题,因为这本书由于空间有限无法涵盖所有必需的信息。
这里是对其结构和组件的过滤解释,主要与本章相关的内容:
-
DOS 头(IMAGE_DOS_HEADER):
文件以MZ开头,这是创建此格式的工程师 Mark Zbikowski 的缩写,当时他在微软工作。接着是一个 DOS 头,这是 MS-DOS 时代的遗物。此头包括一个小的 DOS 存根程序,如果可执行文件在 DOS 环境中运行,则会显示消息(“此程序不能在 DOS 模式下运行”)。DOS 头的最后部分包含指向 PE 头位置的指针。
-
PE 头(IMAGE_NT_HEADERS):
-
签名:这标识了文件是一个 PE 文件。签名是一个 4 字节值——也就是说,PE\0\0。
-
文件头(IMAGE_FILE_HEADER):这包含有关文件的基本信息,例如目标机器类型、节的数量、文件的创建时间和日期以及可选头的大小。
-
可选头(IMAGE_OPTIONAL_HEADER):这提供了加载和运行程序所需的基本信息。尽管其名称如此,但此头对于可执行文件是必需的,并包括以下方面:
-
魔数:标识格式(例如,PE32 用于 32 位和 PE32+用于 64 位)
-
AddressOfEntryPoint:执行开始的地址
-
ImageBase:可执行文件在内存中的首选基本地址
-
SectionAlignment:内存中节的对齐方式。
-
SizeOfImage:图像在内存中的总大小
-
子系统:标识所需的子系统(Windows GUI 或 CUI)
-
-
-
节 标题 (IMAGE_SECTION_HEADER) :
在 PE 头部之后,有一个或多个节标题,每个标题描述文件的一个部分。这些部分包含程序的实际上传数据和代码。以下是一些常见的部分:
-
.text : 包含可执行代码。
-
.data : 包含初始化的全局和静态变量。
-
.bss : 包含未初始化的数据。
-
.rdata : 只读数据(例如字符串字面量和常量)。
-
.idata : 导入表,列出可执行文件所依赖的函数和 DLL。
-
.edata : 导出表,列出可执行文件暴露给其他模块的函数和数据。
-
-
数据目录 :
可选头部分的一部分,这些目录提供了关于可执行文件内部各种表和数据结构的位置和大小信息,包括:
-
导入表 : 列出可执行文件导入的 DLL 和函数。
-
导出表 : 列出可执行文件导出的函数和数据。
-
资源表 : 包含嵌入到应用程序中的资源,如图标、菜单和对话框。这些资源根据其类型存储在资源树中。同时,也支持同一资源的多语言变体。
-
异常表 : 包含异常处理的信息。
-
重定位表 : 用于地址修正。
-
-
节 :
实际的节跟在标题后面,包含可执行代码、初始化数据和程序运行所需的其他组件。
每个节都是根据可选头部中指定的 SectionAlignment 值对齐的。
对于我们来说,这个节和子节列表中最重要的和有趣的部分是 AddressOfEntryPoint 字段。
深入实践
我们最初的方案将是一个非常干净的应用程序,一个经典的“Hello World!”
#include <iostream>
int main() {
std::cout << "Hello World!\n";
}
这将使我们能够理解一个非常简单的应用程序在 Windows 下的加载和执行过程。然而,在进一步探讨之前,有一个小注解:在 Windows 下,有不同类型的应用程序,如 PE 头部中的 OptionalHeader / Subsystem 字段所示。
对于我们的目的,即剖析应用程序以检查其启动过程,我们将创建一个控制台应用程序。我们还可以查看其他类型的应用程序,但它们过于复杂。例如,如果它们有 GUI,那么我们必须实现复杂的信息循环和依赖关系,所以我们将坚持使用简单的东西。
假设我们已经成功编译了我们的合成控制台应用程序,我们可以启动 Ghidra 并看到文件的一个大节与之前显示的标准 PE 头部相似:

图 4.12 – PE 头部的内容
这是一个需要消化的信息量,但对我们来说,有趣的是 AddressOfEntryPoint 字段。目前,它指向一个名为 entry 的方法。这就是我们的应用程序将开始执行的地方,所以让我们更详细地检查这个函数。如果我们进一步挖掘并查看入口是什么,我们会到达以下函数:
ulong __cdecl entry(void *param_1) {
ulong uVar1;
uVar1 = __scrt_common_main();
return uVar1;
}
这本身就是一个有趣的发现,因为它似乎是基于控制台的 Windows 应用程序的入口点。让我们进一步探索。接下来运行的函数如下:
int __cdecl __scrt_common_main(void) {
int iVar1;
__security_init_cookie();
iVar1 = __scrt_common_main_seh();
return iVar1;
}
微软的页面 8 包含了 __security_init_cookie() 函数的详细描述。然而,另一个函数则是一种不同的生物。它执行大量的初始化,例如设置终端和处理初始化错误。在某个时刻,以下代码片段被执行:
8 learn.microsoft.com/en-us/cpp/c-runtime-library/reference/security-init-cookie?view=msvc-170

图 4.13 – main() 的调用
如你所猜,invoke_main 负责调用 main():
int __cdecl invoke_main(void) {
char **_Argv;
char **_Env;
undefined4 *puVar1;
int *piVar2;
int iVar3;
_Env = (char **)__get_initial_narrow_environment();
puVar1 = (undefined4 *)___p___argv();
_Argv = (char **)*puVar1;
piVar2 = (int *)___p___argc();
iVar3 = main(*piVar2,_Argv,_Env);
return iVar3;
}
到目前为止,我们已经到达了调用我们的 main() 函数的阶段。即使是简单的“Hello World!”应用程序,也需要执行大量的样板代码。
现在,我们需要更进一步,并让我们的合成应用程序在 Ghidra 中运行一次(为了简洁起见,我们将省略创建项目、编译和链接应用程序的步骤;让我们假设应用程序通过魔法自行启动)。
由于我们主要感兴趣的是确定在 main() 之前的函数调用顺序,并且我们知道我们全局初始化了 my_a 和 my_other_a 变量,因此我们需要检查二进制文件。在某个时刻,我们会发现以下有趣的数据:

图 4.14 – 根据 Ghidra 的 .CRT$XCU 部分
嗯,这看起来很有趣,特别是那个神秘的 .CRT$XCU 文本。这让我们回到了几段之前的讨论,即 PE 文件的各个部分:部分是可执行文件中的不同数据类型和代码的独立区域。
每个部分都服务于特定的目的,并具有定义其行为以及操作系统如何处理它的属性。在微软网站上有一份出色的文档 9,讨论了负责初始化 CRT 的部分,以下是对其的简要总结。
9 learn.microsoft.com/en-us/cpp/c-runtime-library/crt-initialization?view=msvc-170
根据文档,默认情况下,CRT 库通过链接器包含,这确保了 CRT 被正确初始化,全局初始化器被调用,随后执行用户定义的main()函数。当编译器遇到全局初始化器时,它创建一个动态初始化器并将其放置在.CRT$XCU部分。
CRT 在.CRT\(XCA**和**.CRT\)XCZ初始化部分使用特定的指针,如__xc_a和__xc_z,来定义初始化器列表的开始和结束,确保它们按正确的顺序调用。我们之前讨论过的__scrt_common_main_seh()函数负责正确设置这些。
这些名称是由 CRT 预定义的,链接器将这些部分按字母顺序排列。这种排序确保用户定义的初始化器在.CRT$XCU标准部分之间执行。
为了操纵初始化顺序,开发者可以使用特定的编译器指令将初始化器放置在未使用的保留部分,如.CRT\(XCT**(在编译器生成的初始化器之前)和**.CRT\)XCV(在编译器生成的初始化器之后),具体细节请参阅前面提到的 CRT 启动文档,但在采用这种技术之前,请阅读以下内容,因为事情比看起来要复杂得多。
根据微软的说法,这个主题非常依赖于平台和编译器,我们不希望探索这些领域,特别是考虑到官方网站发出的警告:
“目前,编译器和 CRT 库都没有使用.CRT\(XCT**和**.CRT\)XCV这两个名称,但无法保证它们将来不会被使用。此外,你的变量可能仍然会被编译器优化掉。在采用这项技术之前,请考虑潜在的工程、维护和可移植性问题。”
因此,我们再次重复官方警告的内容:除非你真的需要进行这种黑客行为,否则请避免使用这些半文档化的“特性”和编译器的语言,因为(正如官方警告中提到的)没有保证如果今天它能工作,明天或下一次系统更新后它仍然能工作。
相反,让我们将注意力转向我们在.CRT$XCU部分“发现”的函数,看看这个非常明确的名字背后隐藏着什么样的魔法,毫无疑问这不是标准的 C(也不是 C++):
void __cdecl `dynamic_initializer_for_'my_a''(void)
{
int iVar1;
uchar *unaff_EDI;
undefined4 *puVar2;
puVar2 = (undefined4 *)&stack0xfffffffc;
for (iVar1 = 0; iVar1 != 0; iVar1 = iVar1 + -1) {
*puVar2 = 0xcccccccc;
puVar2 = puVar2 + 1;
}
__CheckForDebuggerJustMyCode(unaff_EDI);
A::A(&my_a,my_string);
atexit(`dynamic_atexit_destructor_for_'my_a'');
return;
}
在执行一些维护任务(例如使用0xcccccccc值初始化堆栈)之后,我们可以看到对类A构造函数的函数调用,其中第一个参数是this对象,并且为特定对象的类析构函数注册了一个atexit函数,再次。
这种0xcccccccc模式是 Visual C++编译器标记未初始化堆栈内存的典型方式,这使得在调试会话中更容易检测到未初始化内存的使用。有趣的是,循环似乎并没有执行。然而,如果我们深入挖掘具有较大 C 风格数组的函数的调试构建,我们会看到这个堆栈保护方案的实际应用,以及一些设置得很好的堆栈看门狗。
堆栈看门狗是一种安全机制,通过在函数的局部变量和其堆栈上的控制数据(如返回地址和保存的帧指针)之间放置一个特殊值(称为看门狗),旨在检测和防止基于堆栈的缓冲区溢出攻击。
如果发生缓冲区溢出,看门狗值会改变,这表明发生了某种破坏行为。这允许程序采取纠正措施,例如终止执行以防止利用。
这个术语的起源有些晦涩,它追溯到历史上在煤矿中使用看门狗的情况。矿工会把金丝雀带入矿井以检测一氧化碳等有毒气体。由于金丝雀对这些气体比人类更敏感,如果鸟儿生病或死亡(即停止唱歌),它就会作为矿工撤离的早期预警信号。这并不完全是神话般的比例,但它是实用的——尤其是如果你是矿工,而不是金丝雀。
在这些概念确立之后,我们对应用程序在 Windows 下的加载过程有一个概述,但仅限于控制台。但不要忘记,Windows 是一个 GUI 环境。它创建窗口和对话框,有一个消息循环,并处理大量的事件。
然而,Windows GUI 应用程序的启动过程与基于控制台的应用程序并没有太大的不同。主要区别在于,在调用 GUI 特定的WinMain函数之前,invoke_main函数会调用两个不同的函数,处理窗口的显示状态和命令行选项。
第一个函数允许我们以不同的方式显示应用程序的窗口。
第二个函数是应用程序的命令行,以宽字符串格式。
其余的只是调用WinMain,从那里,我们进入了熟悉的领域,至少对于在这个领域有经验的程序员来说是这样。
在结束本章时,除了鼓励读者在破解二进制文件时进行实验之外,别无他法——这是真正理解特定功能如何在您的系统上运行的唯一方法。
概述
在本章中,作者试图提供一个并非全面概述的 Linux 和 Windows 上的应用启动过程。对于执行初始阶段,包括到达main()函数之前的临界步骤,所提供的见解并不像平台本身所要求的那样完整,但既然这是一个巨大且非常狭窄的话题,它不会吸引广泛的程序员,所以这本书的名称也就不会是别的了。
通过在 Linux 上探索 ELF,理解execve()系统调用,并检查_start()函数,您获得了关于底层架构和初始化例程的宝贵知识。同样,关于 Windows 的讨论突出了基于控制台和 GUI 应用程序的启动序列,强调了各个部分的作用以及它们如何组合起来启动您那令人烦恼的程序,尤其是如果它不起作用的话。
为了进一步深化对这个主题的理解,我们建议您通过创建和分析二进制文件、修改启动程序以及观察不同操作系统上的效果来进行实际操作实验。您甚至可以手动更改可执行文件头部的各种地址,看看会发生什么以及它们是如何崩溃的。
这种实用方法不仅将加强本章所涵盖的概念,还将为您提供对应用启动过程的更深入和实用的理解。通过积极探索和实验,您将提高在软件开发领域进行故障排除、优化和创新的能力,同时了解有关软件及其运行环境的有趣和有用的事实。
在我们下一章中,我们将讨论类成员声明的正确顺序。通过一位程序员追求编写无 bug 代码的冒险经历,我们将看到 bug。请继续阅读,您将会的。
第五章:在 C++类中,顺序必须存在
当法律和秩序 扼杀创造力
在各个领域对物品进行排序是确保组织、效率和清晰性的关键。无论是在图书馆或通过字母顺序排列的联系人列表,客户服务队列或使用数字排序的数据分析,按时间顺序排列的时间表或预约,任务管理或紧急响应优先级,库存或数字文件分类,比赛排名,服装尺码排列,旅行中的地理路线或邮件投递,制造或软件开发中的顺序步骤,或者在组织机构和生物分类学中的层级结构,排序有助于简化流程,提高可访问性,并增强决策能力。
通过应用不同的标准,如字母顺序、数字顺序、时间顺序、优先级、分类、排名、大小、地理顺序、顺序或层级,排序在多种环境中促进更好的管理和最优化的运作。
在本章中,我们将探讨为什么 C++类成员的特定顺序很重要,以及当我们正确或错误地声明类成员时,我们可以获得什么和失去什么。
此外,我们还将快速了解 C++中运算符优先级的顺序,因为这是一个在一定程度上可能相当令人困惑的话题,即使是对于更高级的程序员来说也是如此。
通过本章,你将学习以下内容:
-
正确声明类成员的特定顺序的重要性
-
按照所需顺序初始化类成员的重要性
-
正确执行运算符的顺序
大小确实很重要
我们在学校学过关于字母表的知识,它将所有字母按照特定的顺序排列,比如如果你是英语使用者,顺序是 A, B, C;如果你是罗马尼亚人,顺序是 A, Ă, Â(是的,罗马尼亚字母表中 A 字母的开头有令人惊讶的大量变体)。但并不是今天所有活着的人都能确定这种顺序的推理,但既然今天的字母表是基于更古老的字母表,比如希腊人的Α, Β, Γ,或者我们古埃及祖先的𓀀, 𓁐, 𓁣,甚至还有𓁷,我们实际上无法确定为什么这种字符的顺序会出现。
字母表是一个非常方便的工具;它帮助我们组织和分类所有可以命名的物品。从昆虫,蚂蚁的分类在蜜蜂之前,到你的橱柜里的香料(除非你根据颜色或,甚至更好的,使用频率来组织它们……可怜的津巴布韦的 mufushwa,你现在会放在后面),它极大地帮助我们保持日常生活整洁有序。
然而,在我们离题太远之前,让我们记住,这是一本关于编程的书(更具体地说,是 C++编程),因此我们需要专注于我们的主题,不要被谈论蜜蜂和鸟儿(当然,按字母顺序,蜜蜂排在鸟儿之前)所分散注意力。
组织 C++ 概念可能是一个非常令人畏惧的话题。在这里,我所说的概念是指函数、类和变量,而不是 C++20 中引入的非常实用的概念特性,遗憾的是,这并不是本书的主题。
你实际上不能按照你想要的方式去做,因为一些函数需要看到其他函数,一些代码块需要访问你确保在它们之前定义的变量。因此,精心构建一个 C++ 程序可能非常困难。
然而,当讨论转向 C++ 类时,情况就变了。你看,在一个类中,这些与可见性相关的问题实际上并不重要。一个类的所有方法都可以看到该类的所有其他方法,所有成员函数都可以在所有成员函数中直接访问,所以在类中的生活很轻松……
现在,亲爱的 C++ 学徒,我听到你喊道:“但你永远不应该在类内部调用类的析构函数或构造函数!”我大部分同意,但没有任何阻止我编写如下方法的事情:
struct a_class {
void reboot() {
this->~a_class();
new (reinterpret_cast<void*>(this)) a_class();
}
};
然而,如果你像这样编写代码,亲爱的读者,你将承担后果。但回到我们最初的主题:排序。
人类的大脑天生渴望秩序。我们需要能够对正在处理的内容有一个全面的了解,知道这些信息在哪里,以及如何尽可能容易地找到它们。轻松快速地找到所需信息是至关重要的,即使它像类成员的位置这样微不足道。
因此,在无尽地寻找类中丢失的成员之后,一位游戏程序员(让我们称他为 Joe)在 BigGameDev 公司愉快地工作时,突然意识到类中的所有成员都应该按字母顺序组织。太棒了,现在每个人都可以轻松地找到他们需要的成员。看看代码有多漂亮:
struct point {
bool active;
double x;
double y;
double z;
};
这不是一个特别复杂的使用场景;它只是游戏中某个点的一个位置,通过提供 x、y 和 z 坐标来告诉我们点的位置,并给出对点工作原理的一些小洞察,告诉我们这个特定的点是否活跃。生活很美好。游戏运行得井井有条,玩家们都很开心。
然而,在某个时候,游戏项目的首席程序员认为对该点的某些操作花费了太多时间(我将省略所有关于这些操作是什么以及为什么需要它们的奇特细节),并且这些操作只有在该点记录了所有三个坐标值的变化时才应该执行。我们的程序员 Joe 是一个好人,而且非常有条理,他知道一个解决方案是存储三个其他的 double 类型的值,代表之前的 x、y 和 z 坐标,在有任何变化时更新这些值,并且只有在值发生变化时才执行请求的操作。
然而,他放弃了那个想法,迅速想出了另一个主意:他将保留一个用于记录每个所需坐标变化的布尔标志,因为他知道布尔通常只占用 1 个字节,而他们平台上的双精度浮点数最多占用 8 个字节。这样节省了……好吧,21 个字节。所以,这是乔的新类:
class point {
bool active;
double x;
bool x_changed;
double y;
bool y_changed;
double z;
bool z_changed;
};
美丽——就像他写的所有代码一样,它几乎就像诗歌。他将新编写的代码提交到仓库,那里将在夜间构建,第二天将交付新鲜出炉的二进制文件进行测试。然后他不会去度假,因为他是一个勤奋的程序员;夏天还早,所以他将在测试团队批准代码后再预订机票。
那一夜,自动化测试爆炸了,每个测试套件都失败了,整个仪表板像某些共产主义国家的旗帜一样变成了红色。第二天,整个测试部门面临了致命的失败,游戏崩溃了,99.9%的错误在某个时刻都与内存不足问题有关。
应用程序突然消耗了几乎是其预期消耗量两倍的内存,测试机器努力保持所需的帧率,一切都在变慢,除了内存分配检查,它稳定地显示应用程序现在使用的内存比昨天多得多。
除了乔自己对其点类的全面重写之外,并没有太多变化;有其他开发者将主菜单的背景颜色从深灰色改为了黑色(遗憾的是,本应实施乔所请求的具有里程碑意义的变更的开发者那天不得不在家照顾生病的孩子),因此开发团队聚集在一起讨论新发现的问题。
主开发人员(让我们称他为吉米,以表彰他在编程语言方面的精通)看了看代码,并迅速宣布,“乔,伙计,我真的很欣赏你代码的整洁性,以及你按字母顺序组织成员的方式,但我将不得不礼貌地请你改变它们的顺序。”
乔的脸色几乎和持续集成监控屏幕上的测试失败指示器一样红,但他是一个理性的人,所以他友好地询问为什么他应该那样做。吉米看不到代码中的美吗?!
吉米的回应让他震惊。这是吉米的解释。
C++类的内存布局由多个因素决定,包括其成员的大小和对齐要求、继承层次结构,以及编译器为了满足对齐约束而添加的填充。当谈到大小的时候,每个数据成员根据其类型占用一定数量的字节。
我相信 Joe 意识到了这一点;然而,从他的解决方案来看,他可能并没有完全理解每个成员的对齐方式。每个数据成员必须存储在满足其对齐要求的内存地址上。对齐要求通常是类型的大小,但可以通过编译器特定的指令进行调整。
现在,来看填充,为了满足这些对齐约束,编译器可能会在成员之间插入填充字节,并且为了确保类的大小是最大对齐要求的倍数,可能需要在类的末尾添加填充。
现在,当在内存中设置时,团队最初可能看起来如下,考虑到在他们架构上,双精度浮点数的大小是 8 字节:

图 5.1 – 初始类布局
使用这种对齐方式,添加的类大小总计为 32 字节。但现在,由于 Joe 添加了三个额外的bool类型,每个 1 字节长,编译器可能会根据以下布局组织内存:

图 5.2 – 新成员顺序错误的类布局
因此,每个bool的字节都必须填充到 8 字节,以便将随后的双精度浮点数放置在正确的内存地址上。这使得类的大小增加到 56 字节,因为 4 个填充到 8 字节的bool加上 3 个 8 字节的双精度浮点数,这些加起来总共占用 56 字节。Clang 编译器有一个开关允许我们检查生成的类的内存布局:-fdump-record-layouts。为了在这个情况下充分利用它,我们创建了一个简单的源文件,其中包含了之前的类定义,并将其传递给编译器以进行检查:
> $ clang -cc1 -fdump-record-layouts main.cpp
*** Dumping AST Record Layout
0 | struct Point
0 | _Bool active
8 | double x
16 | _Bool x_changed
24 | double y
32 | _Bool y_changed
40 | double z
48 | _Bool z_changed
| [sizeof=56, dsize=56, align=8,
| nvsize=56, nvalign=8]
前面的数据清楚地表明了我们最初怀疑的情况,即原本应该占用 1 字节的bool现在正式占用了 8 字节(注意,在幕后,我们创建了一个名为main.cpp的文件,其中包含了point结构的内容)。
现在,为了纠正这种不幸的情况,我们显然需要采取一些进一步的行动,因此让我们考虑以下方式重新组织类的成员:
class point {
bool active;
bool x_changed;
bool y_changed;
bool z_changed;
double y;
double x;
double z;
};
除了伤害 Joe 的感情,成员没有按字母顺序组织之外,这不是一个巨大的变化。我们已经将bool值组合在一起,使类尽可能紧凑。
我们已经使用了前面的信息,特别是考虑到每种类型的大小要求,并得出结论,将小型类型组合在一起总是更好的(我们所说的“小型类型”是指类型将占用最少的字节数的变量;例如,我们知道bool变量的大小是 1,至少对于我们所使用的实现)。
通过这样做,即通过重新组织成员的呈现顺序,我们创建了以下内存布局(或者说是针对我们架构更优化的类似布局):

图 5.3 – 成员按正确顺序排列的类布局
事实上,再次使用 Clang 检查后,类的内存看起来与之前的版本非常不同(请再次忽略幕后我们修改了main.cpp以包含前面的结构的事实):
> $ clang -cc1 -fdump-record-layouts main.cpp
*** Dumping AST Record Layout
0 | struct Point
0 | _Bool active
1 | _Bool x_changed
2 | _Bool y_changed
3 | _Bool z_changed
8 | double x
16 | double y
24 | double z
| [sizeof=32, dsize=32, align=8,
| nvsize=32, nvalign=8]
因此,正如我们现在所看到的,四个bool值在内存中依次排列,并且只需要一个填充部分来填充所需的空间,以便double值对齐到所需的内存地址。假设我们有一个大小为4的字段,我们可以在最后一个bool之后、第一个double之前很好地放置它,并且不需要任何填充。
听了吉米的解释后,乔现在明白了这个问题。他以前从未遇到过对齐问题,但他决定阅读有关这个主题的内容。他读到的内容非常有趣。
它解释说,由于硬件要求、性能优化和架构约束的组合,内存中变量的对齐是必要的。大多数现代处理器设计为当数据对齐到某些边界时,更有效地访问内存。
例如,一个 8 字节的 double 类型通常最好在 8 的倍数地址上访问,当数据未对齐时,处理器可能需要执行多次内存访问来读取或写入数据,这可能会显著减慢速度。
一些架构,例如较老一代的 ARM 处理器、PowerPC 和较老的 MIPS 处理器,无法正确处理未对齐的访问,在这些情况下,它们会生成一个SIGBUS故障,这会导致产生故障的应用程序提前终止。例如,以下应用程序在编译后,在一代处理器的结果二进制文件上执行将生成一个SIGBUS故障:
#include <cstdlib>
int main(int argc, char **argv) {
char *cptr = (char*)malloc(sizeof(int) + 1);
int* iptr = (int *) ++cptr;
*iptr = 42;
return 0;
}
在操作系统没有准备好处理对齐错误的情况下,这种高度不愉快的情况往往会产生相当严重的后果,例如应用程序崩溃。较老的系统甚至可能产生系统崩溃。

图 5.4 – 较老系统在看到未对齐数据时大发雷霆
你可能会问错误类型 7是什么意思。答案是简单的:7 是分配给SIGBUS错误的魔法数字。在作者的 Linux 机器上,它可以在/usr/include/x86_64-linux-gnu/bits/signum-arch.h文件的第 34 行找到:
/* Historical signals specified by POSIX. */
#define SIGBUS 7 /* Bus error. */
一些其他处理器,例如较新的 x86_64 处理器,或者甚至较老的 80286(以及两者之间的所有处理器,大多数遵循 x86 平台规范以及更远),处理这些情况非常优雅,只是在性能上略有时间损失,但它们可以通过以下汇编指令轻松地被说服,变成一个非常多变的个性:
| AT&T ( 64 bit) | Intel ( 32 bit) |
|---|---|
| pushforl $0x40000,(%rsp)popf | pushfd****or dword ptr [ esp], 40000h****popfd |
上面的代码使用位或操作修改了EFLAGS寄存器中的特定位。具体来说,十六进制值40000h对应于设置EFLAGS寄存器中的AC(代表对齐检查)标志,这个标志用于控制对齐检查。当此标志被设置且CR0寄存器中的AM(代表对齐掩码)位也被设置时,处理器会检查数据是否对齐在自然边界上。如果检测到数据对齐错误,则会生成一个故障。
EFLAGS 寄存器是 x86 架构 CPU 中用于特殊目的的寄存器,其中包含反映处理器状态的几个标志。这些标志可以控制或指示各种条件,例如算术条件、控制功能和系统设置。英特尔开发者中心 1 包含有关这些低级编程特性的大量信息。我们鼓励对这一主题感兴趣的人去浏览该网站以获取更多信息。
1 www.intel.com/content/www/us/en/resources-documentation/developer.html
当前面的代码被插入到应用程序的源代码中时,我们可以看到SIGBUS信号在起作用。我们在这里省略了那段代码,因为没有人应该编写故意使他们的应用程序崩溃的代码,而是让我们来检查一下我们的朋友乔与类成员奇特出现顺序的另一次遭遇。
必须遵守的顺序
在 BigGameDev 工作期间,乔被分配了另一个与角色发展略有相关的工作——游戏中的角色,即不是他自己的角色。这项任务很简单:只需要返回一个格式化的字符串,表示角色拥有的生命值。
为了实现这一点,乔创建了以下类:
#include <string>
#include <format>
#include <iostream>
#include <string_view>
struct life_point_tracker {
life_point_tracker(std::string_view player, int points) {
m_player = player;
m_points = points;
m_result = std::format("{} has {} LPs",
m_player, m_points);
}
std::string get_data() const {
return m_result;
}
private:
std::string m_result {""};
std::string m_player {""};
int m_points {0};
};
int main() {
life_point_tracker lpt("Joe", 120);
std::cout << lpt.get_data();
}
这是非常直接的。它只是接收输入数据并将结果存储起来,以防将来需要再次访问。乔非常高兴;类成员按类型组织得很好,但他不再坚持按字母顺序排列(他从对齐讨论中吸取了教训)。他甚至使用了现代 C++,比如格式库或成员的类内初始化,以防某些成员没有被初始化(我们可以争论,字符串在默认构造函数创建时被初始化为空字符串,所以对它们来说,这并不那么相关。对于int来说就不是这样了),他对所编写的代码总体上感到满意。
他本可以直接将这些代码提交到他们的仓库中,但常识占了上风。他进行了一些快速测试,并在确保一切按预期工作后,请他的主管(即我们在上一节中介绍过的 Jimmy)快速审查一下代码。代码看起来没问题;它编译并执行了所需的操作,只有两个微小的观察需要添加。乔得到了以下反馈:在构造函数的体内分配成员而不是使用初始化列表。此外,由于他无论如何都要使用初始化列表,所以他应该将成员设置为 const,以实现一些编译器可能在某个阶段决定提供的微小优化。
因此,他应该这样做:
const std::string m_result {""};
const std::string m_player {""};
const int m_points {0};
在 C++中,由于几个关键优势,通常在构造函数中使用初始化列表而不是体内初始化:它更高效,因为它直接初始化成员变量,而不是先默认初始化然后赋值。此外,它确保了const和引用成员的正确初始化,这些在构造函数的体内无法得到妥善处理。
乔高兴地修改了代码,由于变化不大,他“忘记”测试它了。相反,他迅速提交了以下序列以供审查:
life_point_tracker(std::string_view player, int points)
: m_player(player), m_points(points),
m_result(std::format("{} has {} LPs", m_player, m_points)) {}
响应不会花费太多时间就到达了,而且出人意料地并不是他预期的表扬。
“乔,你测试过这段代码吗?”
他不得不承认,他没有认为这是必要的,因为变化不大。他只是将几行代码向上移动了一点,将一个等号改为一对括号,然后他就完成了。
“哦,我明白了……”吉米说,他从后口袋里掏出一份最新可用的 C++标准的新版印刷本。
标准在[ class.base.init]部分中如下所述:
在非委托构造函数中,初始化按照以下顺序进行:
首先,并且仅对于最派生类的构造函数,虚拟基类将按照在基类指定列表中从深度优先左到右遍历基类有向无环图时出现的顺序进行初始化。
然后,直接基类将按照它们在基类指定列表中出现的顺序进行初始化(不考虑初始化器成员的顺序)。
然后,非静态数据成员将按照在类定义中声明的顺序进行初始化(再次不考虑初始化器成员的顺序)。
最后,执行构造函数体的复合语句。
这在实践中意味着,无论你在初始化列表中指定成员初始化的顺序如何,它们仍然会按照在类中声明的顺序进行初始化,所以m_result将是第一个被初始化的,由于它使用了其他两个尚未初始化的数据成员,所以最佳情况下结果将是未定义行为。在最坏的情况下,在测试期间,你将得到默认值,在生产中,代码将失败得非常明显。
现在,凭借这些知识,乔终于能够按时并以他能够实现的最高标准交付预期的代码:
life_point_tracker(std::string_view player, int points)
try :
m_result(std::format("{} has {} LPs", player, points)),
m_player(player),
m_points(points)
{
}
catch(...) {throw;}
他了解到,虽然使用初始化列表在特定情况下可能是一种神赐,但如果忽略了 C++标准设定的某些基本规则,它也可能将你的代码抛入神话般的七个编译器地狱的深渊。
C++标准规定,成员对象必须按照在类中声明的顺序进行初始化,无论构造函数初始化列表中指定的顺序如何,因为如果没有初始化列表,或者其中只初始化了部分元素,会发生什么?
这个顺序确保了对象设置过程中的一致性和可预测性。当一个对象被构造时,按照声明顺序初始化成员有助于避免如果成员初始化顺序错误可能出现的潜在问题,特别是如果某些成员依赖于其他成员先被初始化。
这种规定的初始化顺序直接影响析构顺序,析构顺序与初始化顺序相反。确保成员以初始化顺序的相反顺序被销毁,确保在析构阶段需要时依赖成员仍然有效。这种一致且可预测的清理过程防止潜在错误并保持对象生命周期的完整性。
基于语言的这个要求,我们可以通过使用一个有趣的功能——称为指定初始化器,来轻松地提供一个优雅且更简洁的解决方案,这个功能是在 C++20 中引入的。让我们简化我们的结构,使其看起来像以下这样:
struct life_point_tracker {
std::string get_data() const {
return m_result;
}
std::string m_player {"Nameless"};
int m_points {0};
const std::string m_result
{std::format("{} has {} LPs", m_player, m_points)};
};
这些简单的结构满足用作聚合的要求,这对于编译指定的初始化器特性是必需的,正如你所见,m_result 成员在自身构造过程中使用了已经初始化的 m_player 和 m_points 成员。现在,在我们想要使用类的位置,我们只需做以下操作:
int main(int argc, char **argv) {
life_point_tracker lpt {
.m_player = "Joe",
.m_points = 120
};
std::cout << lpt.get_data();
}
通过遵循这个方便的特性,我们明确指定了哪个成员应该初始化为哪个值(如果需要初始化的整数超过两个,这可能会非常有帮助)。此外,该特性要求成员按照其声明的顺序指定,从而提高了代码的可读性和可维护性。唯一的缺点是我们不得不将我们的类简化为 聚合体,因此没有虚拟函数,没有构造函数,没有封装——所有这些将 C++ 类提升到神话般名望的好东西都没有了。但如果这对乔来说足够好,我们可以忍受它。
深入思考关于秩序
我们的朋友乔的冒险并没有结束,因为在他了解到类成员的正确顺序不一定是字母顺序之后不久,他被分配了一个涉及以并行方式执行一些代码的任务。由于他通过观看 TikTok 上某个人的快速入门教程学习了有关线程及其相关特性的所有内容,他觉得自己能够胜任这项任务,不久之后,以下代码被提交到仓库中(请在这个问题上宽容作者;由于版权和知识产权诉讼的一些病理表现,我们无法展示整个开发团队花费两周时间调试和修复的原始代码。示例代码实际上只是在尝试重现乔成功实施的场景):
#include <cstdio>
#include <thread>
#include <chrono>
using namespace std::chrono_literals;
struct bar {
bar() : i(new long long) {
*i = 0; printf("bar::bar()\n");}
~bar() {printf("bar::~bar()\n"); delete i; i = nullptr;}
void serve() {
while(true) {
(*i)++;
if(*i % 1024768 == 0) {
std::this_thread::sleep_for(200ms);
(*i) = 0;
printf("."); fflush(stdout);
}
if(stopRequest) break;
}
}
long long* i = nullptr;
bool stopRequest = false;
};
struct foo {
foo() : thread(&foo::threadFunc, this) {
printf("foo::foo()\n");
}
~foo() {
printf("foo::~foo()\n"); b.stopRequest = true;
}
void threadFunc() {
b.serve();
}
std::jthread thread;
bar b;
};
int main() {
foo f;
std::this_thread::sleep_for(2000ms);
printf("main returns\n");
return 0;
}
给定的 C++ 程序试图尽可能接近乔创造的简单多线程混乱,使用两个友好的结构,命名为 bar 和 foo(我们让 baz 休息一会儿,但如果你想念他,你可以将函数命名为 baz),为了在单独的线程上执行任务,创建有意义的交互。bar 结构管理一个动态分配的 long long 变量,i(因为我们会叫一个具有索引角色的变量叫什么呢?),它在 serve 方法中不断递增。当递增计数达到 1024768(让我们忽略 1024x768 也是一个屏幕分辨率的事实),然后它暂停 200 毫秒,将计数器 i 重置为 0,并在控制台打印一个点(在现实生活中的应用程序中,发生了其他事情,但这超出了本书的范围)。
这个循环会无限期地继续,直到 stopRequest 被设置为 true,向线程发出退出信号。bar 的构造函数初始化计数器 i,并且出于我们唯一的调试目的,它打印一条消息,而析构函数处理内存清理并打印另一条消息,确保资源得到适当管理。至于乔为什么没有使用智能指针,又是另一个故事了,所以我们现在就先不关注那个部分。
foo 结构负责启动和停止运行 bar 实例的 serve 方法的线程。在创建时,foo 初始化一个 std::jthread 来运行其 threadFunc,它反过来调用其 bar 实例的 serve 方法。这种设置允许 serve 方法与 main 程序并发运行。foo 的析构函数将 stopRequest 设置为 true,确保线程优雅地退出。再次,乔为什么决定选择这种优雅地结束线程的方式仍然是个谜,但既然它已经工作(在已经提到的两周的调试和故障排除会议之后),工程团队决定永远不再提及这段代码。
在 main 函数中,创建了一个 foo 实例,在创建时启动线程,然后程序休眠两秒钟以允许线程运行。为了简洁起见,我们只需假设在原始应用程序中没有提到任何类型的休眠;解决方案真正的美在于 main 和 bar 线程中执行的一些长时间操作。
亲爱的经验丰富的 C++ 程序员:请不要关注这段合成代码是如何处理线程同步的,或者它分配和释放内存的事实,因为那不是它的目的。这段代码的唯一目的是崩溃。对于 std::jthread,有足够的机制来正确处理执行,例如 std::stop_source 和 std::stop_token,所以请随意阅读它们,让乔现在就忍受他天真地处理线程的方法。
当代码执行时,以下结果是作者在 Linux 系统上至少得到的结果:
> $ ./a.out
bar::bar()
foo::foo()
.........main returns
foo::~foo()
bar::~bar()
然而,有时输出如下:
> $ ./a.out
bar::bar()
foo::foo()
.........main returns
foo::~foo()
bar::~bar()
[1] 93622 segmentation fault (core dumped) ./a.out
乔也遭遇了同样的事情。偶尔,应用程序会在退出时出现混乱并崩溃。最初,这并不是太大的麻烦,因为,嗯,如果应用程序在结束时崩溃,那并不是结束。然而,过了一段时间,乔编写的代码被引入到一个更大的模块中,那里就出现了混乱、混乱,以及前面提到的两周的调试会议。
犯罪的原因相当简单。吉米,这位大师程序员在查阅了他的口袋版 C++ 标准后,特别是它的 [class.dtor] 部分:
在执行析构函数的主体并销毁在主体内部分配的任何具有自动存储期的对象之后,类 X 的析构函数调用 X 的直接非变体非静态数据成员的析构函数,X 的直接非虚基类的析构函数,如果 X 是最派生类,则其析构函数调用 X 的虚基类的析构函数。所有析构函数都像使用限定名引用一样被调用,即忽略在更派生类中可能存在的任何可能的虚覆盖析构函数。基和成员的销毁顺序与它们构造完成时的顺序相反。析构函数中的返回语句可能不会直接返回到调用者;在将控制权传递给调用者之前,会调用成员和基的析构函数。数组元素的析构函数以它们构造的相反顺序被调用。
关键在于对象被销毁的顺序与它们被创建的顺序相反,就像它们在创建时被推入栈中,在销毁时以优雅的方式从栈中弹出一样。导致错误行为的罪魁祸首很快就被识别为以下内容:
std::jthread thread;
bar b;
因此,在构建过程中发生的情况是线程被创建并开始执行其线程方法:void threadFunc() { b.serve(); }。只有在不可预测的操作被启动之后,bar b 对象才被创建。然后,在退出时,根据 C++语言的设计,bar b 对象被删除并且其资源被释放。当线程仍然可能在长时间操作中被阻塞时,突然它开始在一个已经被删除的对象上运行。
线程对象创建、实际启动线程例程和bar b对象创建之间的延迟如此之小,以至于在创建阶段捕捉到错误几乎是不可能的。但让我们修改bar的构造函数,使其大致如下:
bar() { std::this_thread::sleep_for(200ms);
i = new long long; *i = 0; printf("bar::bar()\n ");}
在一瞬间,我们可以看到线程正在运行一个在其开始使用时创建并未完全完成的对象。当然,这个问题可以通过简单地交换成员的顺序来轻松解决:
bar b;
std::jthread thread;
线程是 C++的一个有趣方面。虽然它带来了许多好处,但也引入了额外的复杂性。正确编写正确且高效的线程代码需要仔细考虑各种线程之间的同步和协调。
调试多线程应用程序可能具有挑战性,因为存在诸如竞争条件、死锁和非确定性行为等问题,或者简单的事实是线程被调试器停止,因此在检查时没有实际的工作发生,有时应用程序的成功或失败实际上取决于类成员声明的顺序。但就目前而言,让我们和 Joe 及他的朋友们说再见。让我们希望他们已经得到了他们的 AAAA 标题,让我们将注意力集中在其他事情上。
C++ 的黑暗秩序
C++ 语言中有一个很少被阳光照耀的阴暗角落,如果这些深层次中的代码偶然浮出水面,一群核心开发者会立即跳上去,将其重构为可消化的比特和字节。让我们以一个非常简单的例子来考虑一下,为什么在 C++ 中,a[2] 和 2[a] 表达式是等价的,并且 a 是一个对象数组:
int main() {
int a[16] = {0};
a[2] = 3;
3[a] = 4;
}
上述代码,尽管看起来很丑陋,实际上是可以编译的。原因如下:在 C++ 中,operator[] 数组下标是通过指针算术定义的。a[i] 表达式被编译器翻译为 (a + i),其中 a 是数组第一个元素的指针,i* 是索引。最后的 i[a] 表达式也被翻译为 (i + a) 表达式,其中 i 是索引,a* 是我们数组第一个元素的指针。
由于对于编译器来说加法是交换的,所以哪个先来并不重要。
因此,我们在 C++ 中找到了一个特定的案例,其中顺序并不重要。但这仅适用于旧式 C 数组;std::vector 和 std::array 不接受这种无序语法。这有一个非常具体的理由;std::vector 和 std::array 的下标运算符不支持在原始数组中看到的交换行为,即以下内容:
-
运算符重载:对于 std::vector 和 std::array 的 operator[] 是一个成员函数,这意味着它需要在类的实例上调用。它不能通过索引首先调用,因为成员函数要求对象位于调用左侧。
-
无指针算术:std::vector 和 std::array 的内部实现不依赖于原始指针算术进行索引。它们以不同的方式管理内存和边界检查,确保对元素的更安全访问。
在当前阶段,我们能够最接近模拟类型为 std::vector 的对象的前述不神圣语法的代码如下:
#include <vector>
#include <iostream>
struct wrapper {
wrapper(int p) : i(p) {}
int operator[](const std::vector<int> v) {return v[i];}
int i = 0;
};
struct helper {
helper() = default;
wrapper operator << (int a) { return wrapper {a}; }
};
#define _ helper()<<
int main() {
std::vector<int> vec = {10, 20, 30, 40, 50};
int b= (_ 2) [vec];
std::cout << b << std::endl; // Outputs 30
return 0;
}
然而,经过快速检查后,我们(好吧,实际上并不是两位作者,因为 Alex 至少在这段代码中是无辜的,所以请将此视为皇家我们),我们决定我们对此感到羞愧,不敢将其实现为 std::array 或任何其他容器。
但仔细观察后,我们发现那里有一些有趣的代码。我们的主要目标是重新创建向量和数组无序索引访问的顺序,但在我们沉浸于相信这是可能之前,有一个残酷的现实检查:这是不可能的。原因如下:如果我们尝试编译表达式 2[vec];,我们会得到以下错误:
error: no match for 'operator[]' (operand types are 'int' and 'std::vector<int>')
这用普通英语翻译过来,意味着编译器找不到应用于整数并接受整数向量作为参数的索引运算符。只要 C++ 还是 C++,这不会发生,主要有两个原因。第一个原因是 operator[] 必须是一个类的成员函数。不可能有一个独立的 [] 运算符。
第二个原因是叫做运算符优先级的一个奇特现象。这并不是一个叫做 优先级 的运算符,而是以下内容:在 C++ 中,运算顺序,也称为运算符优先级,决定了运算符之间的解析方式。优先级较高的运算符先于优先级较低的运算符进行评估。当运算符具有相同的优先级时,它们的结合性决定了评估的顺序。
虽然在最新的标准中,第七章 表达式(特别是 [expr.pre] 部分)提到“运算符的优先级没有直接指定,但可以从语法中推导出来”,但也有一些官方信息来源 2 2,它们包含了它们的精确顺序,所以我们真心鼓励你花时间去研究这些来源之一。
2 en.cppreference.com/w/cpp/language/operator_precedence
最重要的问题
现在你回来了,亲爱的读者,我们非常确信你可以轻松回答以下问题。以下程序的输出是什么?
#include <iostream>
int main() {
auto a = 4;
std::cout << sizeof(a)["Hello World"] << std::endl;
return 0;
}
然而,在你急忙将代码输入编译器之前,请暂停,坐下来,仔细思考这里到底发生了什么。本节为你提供了所有必要的提示、方向和可能的线索,以便正确回答。我们故意不会立即给出答案,也不会对代码进行完整的解释,只是快速分析一下正在发生的事情,这应该足以让你弄清楚:
-
在 auto a = 4; 表达式中,变量 a 被声明为 int 类型并初始化为 4。这正是现代 C++ 中 auto 和数字工作的方式。
-
现在来到了棘手的部分。在我们的大脑中解析代码,很明显,sizeof(a) 表达式评估为 std::sizeof 类型,并且通常情况下,sizeof(int) 在大多数系统中是 4 个字节。当然,较老的 16 位系统中的 sizeof(int) 是 2 个字节;一些奇特的系统可以将 sizeof(int) 设置为 8 个字节,但作者从未见过这样的系统。
这是所有我们的推理都陷入困境的关键点。起作用的是 C++的运算符优先级。以下是从前一个表中提取的微小部分,我们只保留了与我们的案例相关的部分:
| 优先级 | 运算符 | 描述 |
|---|---|---|
| 1 | :: | 命名空间解析运算符 |
| 2 | a++ a-- | 后缀递增和递减 |
| a() | 函数调用 | |
| a[] | 下标 | |
| 3 | ++ a --a | 前缀递增和递减 |
| + a -a | 一元加和减 | |
| ! ~ | 逻辑非和位非 | |
| *a | 解引用 | |
| &a | 地址运算符 | |
| sizeof | 大小运算符 |
现在,我们终于可以看到,在我们的代码中,表达式 sizeof(a) 永远不会被评估。由于 C++编译器的工作方式,[] 运算符的优先级高于 sizeof,所以首先会被评估的是 ( a)["Hello World"];。
由于在 C++中 (a) 几乎总是与 a 相同(除了当你处理 最令人头疼的解析 时,但关于这一点将在稍后讨论),表达式与 sizeof a["Hello World"]; 相同。
现在,正如我们所看到的,这会产生与 sizeof "Hello World"[a]; 相同的结果,考虑到今天,a 很可能为 4,这将给我们字符 'o'。因此,整个表达式现在简化为 sizeof 'o',考虑到 sizeof 的工作方式,它将始终返回 1。
我们,作为作者,认为在这个阶段,我们问题的答案已经很明显了。
当顺序不重要时
在结束这一章之前,我们不应该忘记提到一件小事。实际上,有两件。第一件是,在 C++中,函数参数评估的顺序是不确定的。这意味着当你用一个具有多个参数的函数调用时,编译器可以自由地以它选择的任何顺序评估这些参数。如果参数有副作用,如修改变量,这可能会导致意外结果。
让我们以以下程序为例:
#include <iostream>
int f (int a, int b, int c) {
std::cout << "a="<<a<<" b="<<b<<" c="<<c<<std::endl;
return a+b+c;
}
int main() {
int i = 1;
std::cout<<"f="<<f(i++, i++, i++)<<std::endl<<"i="<<i<<std::endl;
}
无论你认为这个程序的结果是什么,它都会是错误的。
这种原因再次是,如前所述:参数评估的顺序没有指定。你可能会问,为什么?这个原因有点复杂且具有历史性。但在深入探讨这一点之前,让我们先娱乐一下,看看各种编译器通过 gcc.godbolt.org 和其他来源提供的输出。
| 编译器 | 输出 |
|---|---|
| Microsoft Visual C++ ( after 2005) | a=1 b=1 c=1f=3i=4 |
| Microsoft VS.NET 2003 | a=3 b=2 c=1f=6i=4 |
| Microsoft Visual C++ 6 | a=1 b=1 c=1f=3i=4 |
| ICC 和 Clang 对此达成一致… | f=a=1 b=2 c=36i=4 |
| GCC, after 6.5 | f=a=3 b=2 c=16i=4 |
| GCC, before 6.5 | a=3 b=2 c=1f=6i=4 |
| Turbo C Lite 和 Borland C++55 | a=3 b=2 c=1f=6i=1 |
因此,我们有大量的选项可供选择,一些更直接,一些更奇特。所有这些奇怪值都声称自己是正确的,是统治它们的那个,不管事实是即使是同一供应商的同一编译器的不同版本也会提供不同的结果。而且它们都相信自己的正确性。
简单来说,推理是允许编译器自由选择求值的顺序,使其能够进行优化,从而提高性能,这些性能是我们程序员可能注意不到的。编译器可以重新排序指令以利用 CPU 流水线,最小化寄存器使用,并提高缓存效率。指定严格的顺序将限制这些优化机会。
不同硬件架构可能具有不同的最佳求值策略。由于没有指定求值的顺序,C++代码可以更容易地为各种架构优化,而无需对代码本身进行更改。
此外,由于没有指定求值的顺序,C++语言规范保持得更简单。为所有表达式指定严格的顺序会增加语言定义的复杂性,并增加编译器开发者的负担。更不用说当前的标准几乎有 2,000 页长,所以也许不添加几百页详细说明参数求值顺序的复杂性是个好主意。
然而,我们在本节开头承诺提到的第二件事出现了:虽然运算符优先级和结合性决定了表达式如何分组和解析,但它们并不决定求值的顺序。这意味着即使你知道表达式将如何分组,表达式各部分实际求值的顺序仍然可能变化。
让我们考虑以下简短的应用程序:
#include <iostream>
int main() {
int i = 4;
i = ++i + i++;
std::cout << i << std::endl;
return 0;
}
它真的很短——再短也没有了——而且包含了一些相当糟糕的代码,尤其是看那++i + i++。这段代码如此糟糕,以至于编译器都无法真正同意执行它的顺序。
其中一些人选择先执行++i(使i变为5,并将其用作加法的左侧),然后执行i++(这将使用已经增加的新值i,然后再次增加它以达到6,但由于后增加的工作方式,将使用5的值作为加法的右侧),然后将此值赋回i。所以,结果是 5 + 5 = 10。
然而,其他编译器决定先执行i++,从而保持操作右侧的值4,同时将i的值增加到6。现在,++i被评估,它已经看到了增加的值6,决定使用它,然后增加它,从而获得加法左侧的7。因此,这将给出 7 + 4 = 11。
现在,稍微回顾一下,没有指定评估顺序会鼓励开发者意识到这一奇特特性,编写不期望特定评估顺序的代码。这可以导致更健壮和可移植的代码,因为开发者必须避免对评估顺序的不当依赖。因此,对前述情况的正确修复将是一些类似于以下内容的代码:
#include <iostream>
int main() {
int i = 4;
int preIncrement = ++i; // i is now 5
int postIncrement = i++; //postIncrement is 5, i is now 6
i = preIncrement + postIncrement;
std::cout << i << std::endl; // Output will be 10
return 0;
}
虽然这可能是罕见的情况,因为前面的代码有点人为,但这确实是一个问题,尤其是如果我们处理的是以下情况:
int f() { std::cout << "f"; return 1; }
int g() { std::cout << "g"; return 2; }
int result = f() + g();
result的值无论如何都将为3,但输出将取决于编译器如何决定执行两个函数调用,可以是"fg"或"gf"。
考虑到所有这些,我们可能会认为我们已经了解了 C++中关于顺序的所有内容。虽然在本章中我们试图涵盖所有可能的后果,但我们不能保证您不会发现任何顺序混乱的情况。C++是一种具有非常广泛范围和独特语法的语言,所以如果有人真的想的话,他们可能会触及某些编译器的痛脚。
摘要
通过本章,我们希望您已经掌握了遵循所有与 C++相关内容指定顺序的重要性,以确保代码执行的预测性和无错误。您还应该理解没有指定执行顺序的重要性。
考虑到这一点,我们鼓励您去尝试使用 Compiler Explorer 提供的在线游乐场。它提供了一大批编译器。只需记住,如果您编写的代码在两个编译器上生成了不同的结果,那么您可能已经进入了未指定/未定义行为的领域。
下一章将探讨 C++中内存管理的挑战。
第六章:C++不是内存安全的
如果你仍然像 2000 年一样写 C++
C++在安全性方面存在问题,内存可能是其中一部分。存在两种类型的内存问题:空间和时间。空间问题指的是访问超出边界的内存,而时间问题指的是在不确定或已释放的状态下访问内存。现代 C++试图通过避免使用裸指针以及使用std::span或概念来避免许多陷阱。尽管如此,我们仍需努力;在本章中,我们将展示当前的 C++机制仍然不完整,并探讨安全配置文件作为可能的未来改进。
在本章中,我们将涵盖以下主要主题:
-
内存安全很重要
-
较早版本的 C++的内存安全问题
-
现代 C++的拯救
-
现代 C++的局限性
-
还有更多的工作要做
技术要求
本章的代码可在 GitHub 上找到,地址为github.com/PacktPublishing/Debunking-CPP-Myths,位于ch6文件夹中。测试函数使用了 doctest(github.com/doctest/doctest),它包含在代码中。
内存安全很重要
我们大多数生活在现代世界的人都期望事情能够正常工作。我们期望有电力、清洁的水和卫生设施,以至于它们已经淡入背景。我们没有注意到或考虑保持电力流动所需的工作;这只是预期的。
软件是这个舞台上的新来者。我想人们没有意识到软件在人们做的几乎所有事情中有多么重要,从支付到娱乐,从救命紧急服务到从一个地方到另一个地方。
然而,在现代世界的所有无处不在的服务中,软件是那个尽管有其好处,但真的可能让你的生活变得困难的。想想那些身份信息泄露和有时被盗的人,他们的钱被隔离,因为受到勒索软件影响而无法获得或延迟获得医疗援助的人。软件无处不在,软件必须做得更好。
然而,我们,程序员,似乎对这些问题视而不见。软件很复杂,我们告诉自己。用户经常因为自己的错误而受骗。没有没有错误的程序。是的,这是正确的。软件越来越复杂,越来越多的人在小部分上工作。用户没有像他们应该的那样小心。更糟糕的是,技术不断变化,以至于 6 个月前工作得很好的代码库可能现在不再工作。
但这并不能免除我们在过程中的责任。航空公司可能会说同样的话:飞机是复杂的机器;当然,它们可能会出现缺陷,偶尔会从天空中坠落。乘客不阅读或听不到紧急指示;让我们责怪他们。相反,航空系统是这样的,飞行的风险在多年中持续降低,成为最安全的旅行方式。
因为我们,程序员,对这些问题的忽视,所以我们中很少有人期待白宫就我们应使用的语言提出技术建议。2024 年 2 月 26 日发布的这份技术报告指出,软件行业有忽视安全问题的常见根本原因的历史,并且为了国家安全,应用程序应该用内存安全语言编写。内存安全语言的列表包括 Java、C#、Python 和 Rust,不包括 C 和 C++。你可以在以下链接中了解更多信息:www.whitehouse.gov/oncd/briefing-room/2024/02/26/press-release-technical-report/。
对这份报告的反应是惊讶、好笑和困惑的混合。然而,几个月后,2024 年 7 月 19 日,当大约一半的世界受到 CrowdStrike 产品系列中内存问题的困扰,导致全球 Windows 系统出现内核恐慌时,报告的重要性得到了重新确认。这一事件导致飞机停飞,紧急系统无法正常工作,支付系统离线,并对数百万人的生活造成了破坏。我相信这可能是有很多人第一次意识到软件有多么重要,这意味着他们可能会开始关注谈论软件的政客。
所以,是的,内存安全很重要。内存管理错误在最坏的情况下可能导致软件重启的微小不便,但在最坏的情况下可能导致黑客利用漏洞或整个系统需要手动干预。就像软件中的所有事情一样,对内存安全的需要是情境性的;与单人游戏相比,生命攸关的应用程序需要不同类型的关注。然而,我认为,无论你正在构建什么软件,关注这个问题都很重要。我相信程序员有责任编写不仅应该按预期工作,而且尽可能保护用户免受危险,有时甚至让他们感到愉快的代码。我们,程序员,需要记住,我们编写的代码是由人使用的,人是重要的。尽管这不是我们唯一的问题,但直面内存安全问题是一个很好的进步方式。
较早版本的 C++的内存安全问题
在我们继续讨论旧版和现代 C++中的内存安全问题之前,让我们先尝试定义它。引用白宫报告中的内容:“内存安全问题是一类影响内存如何以非预期方式访问、写入、分配或释放的漏洞(...)内存安全问题分为两大类:空间和时间。空间内存安全问题源于超出内存中变量和对象“正确”边界进行的内存访问。时间内存安全问题出现在内存访问超出时间或状态时,例如在对象释放后访问对象数据,或者当内存访问意外交织时。”
要查看引用的来源,您可以点击以下链接查看文档的第 7 页: www.whitehouse.gov/wp-content/uploads/2024/02/Final-ONCD-Technical-Report.pdf .
任何 C++程序员都应该熟悉这两种类型的问题。空间内存问题在 C++中的裸数组中最为常见。例如,尝试执行这个程序,它创建一个数组,向其中添加一些值,然后尝试在其分配的内存之外写入和读取元素:
int doSomeWork(int value1, int value2, int value3, int value4) {
int array[3];
array[0] = value1;
array[1] = value2;
array[3] = value3;
array[4] = value4;
return array[0] + array[1] + array[3] + array[4];
}
上述代码令人惊讶的地方在于它是不确定的操作。也就是说,您的编译器可能会以不同的方式对读取或写入数组边界之外的操作做出反应,从忽略它到编译错误。此外,根据操作系统和上下文的不同,代码可能工作并覆盖未指定的内存块。
注意
对于攻击者来说,这类代码就像是金子。为什么?好吧,有一定几率这个过程会在某个时刻被放置在 RAM 中,紧挨着一个执行有价值操作的过程。如果攻击者能够向这个函数发送正确的值,并在恰好正确的时间捕捉到这个过程,他们可能能够覆盖检查您银行应用程序身份验证的代码,安装键盘记录器,或者向您的系统添加恶意软件。当然,这种攻击并不保证一定会成功,但黑客有足够的时间,因为这些事情都是自动化的。只需要成功一次。
我在我的电脑上测试了这段代码,运行的是 Ubuntu Linux 操作系统,使用clang和g++进行编译。g++编译器愉快地编译了程序,即使开启了所有警告也没有发出警告。与此同时,clang在编译时给出警告,指出数组越界访问。当我尝试运行程序时,我收到消息"* stack smashing detected *: terminated"。因此,我在运行时有一些保护措施,但代码仍然以未知可能的副作用运行。
然而,请注意,这是一个非常简单的例子。如果我在代码的某个地方创建一个数组,将其传递给各种函数,并根据一些复杂的公式计算索引,我敢打赌没有任何编译器能发现这个问题。因此,我们只能依靠测试和操作系统保护。
这种问题是不是语言的错? 很少人可能知道,但在内存安全列表上的编程语言中可以编写这种类型的代码。例如,C# 有不安全代码的概念,并且有指针。你可以标记代码的一部分为不安全,创建指针来访问数据,并使用指针运算,有一些限制。区别在于你需要做很多工作才能实现这一点,而且它仍然没有 C++那样的效果。此外,在 C#中,以类似方式处理内存的代码非常明显,因为它需要成为不安全块的一部分。C++的问题并不在于可以这样做;问题在于默认情况下做这些事情非常容易 。
既然我们在谈论指针,让我们看看如何误用它们。以下代码使用一个void实例通过一些类型转换和指针运算访问int**类型分配值之外的内存:
int pointerBounds() {
int *aPointerToInt;
void *aPointerToVoid;
aPointerToVoid = new int();
aPointerToInt = (int*)aPointerToVoid;
*aPointerToInt = 234;
aPointerToInt = (int*)((char*)aPointerToVoid + sizeof(int));
*aPointerToInt = 2423;
int value = *aPointerToInt;
delete aPointerToVoid;
return value;
}
TEST_CASE("try pointer bounds"){
int result = pointerBounds();
CHECK_EQ(2423, result);
}
再次,我们遇到了一些未定义的行为:将值赋给由指针运算得到的指针由编译器来决定。这次,g++和clang都给了我警告,但只是关于删除 void 指针。两个编译器都没有任何问题,因为我试图在分配区域之外写入和读取。更有趣的是,测试运行得非常好,函数的结果是预期的,每个人都感到高兴!甚至操作系统也没有对这种胡闹表示不满——希望如此,因为我没有超出进程分配的内存。
希望如此。
我们到目前为止已经看到了空间内存安全问题的例子,情况并不乐观。那么时间内存安全问题又如何呢?
任何使用过指针的人都必须处理在不再需要它们之后需要记住做两件事的需求:释放分配的内存并将它们重置为NULL。这两件事都很重要,因为忘记其中之一会导致时间内存安全问题:一个悬垂指针仍然可以访问已被释放的内存区域,或者当指针没有被释放时,可能将指针重置,使得内存区域不再可访问。
以以下函数为例,它初始化一个指向int的指针并赋予一个值,释放内存,然后返回存储在内存中的值:
int danglingPointer() {
int *aPointerToInt = new int(234);
delete aPointerToInt;
return *aPointerToInt;
}
TEST_CASE("Try dangling pointer"){
int result = danglingPointer();
CHECK_EQ(234, result);
}
再次,程序编译良好。在g++或clang中没有警告。它也运行了,但测试失败,因为存储在那个地址的内存中的值不是预期的那个:
test.cpp:8: ERROR: CHECK_EQ( 234, result ) is NOT correct!
values: CHECK_EQ( 234, 721392248 )
那个地址存储的值在每次后续调用中都会改变,给我带来其他结果,如下所示:
test.cpp:8: ERROR: CHECK_EQ( 234, result ) is NOT correct!
values: CHECK_EQ( 234, 1757279720 )
test.cpp:8: ERROR: CHECK_EQ( 234, result ) is NOT correct!
values: CHECK_EQ( 234, -1936531037 )
在代码的后续计算中使用这个值意外地容易,并返回一个奇怪的结果。如果你对所执行的计算有些了解,并且可以传递重复的输入,这也是找出内存区域内容的一种方法。
时间内存安全问题更严重,因为在大型代码库的迷宫中跟踪指针的生命周期比确保我们不会超出其界限要困难得多。所以,是的——不幸的是,内存问题在 C++中可能是一个大问题。
然而,你会发现,所有之前的例子都采用了旧的 C++风格。我们使用了裸数组、裸指针和指针算术。这些都是你应该在现代 C++中非常谨慎使用的结构,原因就在于此。我无法说永远不要使用它们,因为有些特定情况下我们需要裸指针和裸数组,但如今,它们通常限于内存优化或底层编程。即使在这些情况下,你通常也可以在不安全的部分和现代 C++之间引入一个清晰的边界。
那么,现代 C++解决了所有这些问题吗?
现代 C++的拯救
让我们回顾一下前面的例子,但用现代 C++中推荐的 STL 等效项替换裸数组和裸指针。
首先,数组边界示例。我们只需将裸数组替换为vector
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values;
values[0] = value1;
values[1] = value2;
values[3] = value3;
values[4] = value4;
return values[0] + values[1] + values[3] + values[4];
}
TEST_CASE("try vector bounds"){
int result = doSomeWork(1, 234, 543, 23423);
CHECK_EQ(1 + 234 + 543 + 23423, result);
}
不幸的是,运行此示例的结果并不理想。既没有g++也没有clang抱怨,当运行测试时,我们得到以下结果:
TEST CASE: try vector bounds
test.cpp:5: FATAL ERROR: test case CRASHED: SIGSEGV - Segmentation violation signal
std::vector<>不安全吗?嗯,我们仍然需要关注为其分配的空间。我们有几种选择:正确初始化它,使用提供的方法向集合中添加元素,或者请求为特定数量的项目保留内存。前两种是我通常会使用的,因为它们不太可能导致问题。但即使是第三种选择也会导致通过测试:
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values;
values.reserve(5);
values[0] = value1;
values[1] = value2;
values[3] = value3;
values[4] = value4;
return values[0] + values[1] + values[3] + values[4];
}
一个令人愉快的惊喜是std::vector在 reserve 之后的行为,至少在g++上是这样。我尝试访问values[2],在这个例子中没有设置,我得到了值 0。这比访问之前存储在该内存块中的值要好得多,我想这可能是std::vector使用的默认分配器的特性。这种差异是由于operator[]的未定义行为,可以通过使用vector::at()方法来避免。尽管如此,我们还是做了一些工作。所以,即使是在现代 STL 中,我们仍然可以编写可能导致内存问题的代码。当然,如果我们停止胡闹,并使用其中一种简单的方法,这个问题就会完全消失。如果我们使用初始化器语法,向量将基于传入的数据创建,而无需我们进行任何额外的计数:
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values{value1, value2, 0, value3, value4};
return values[0] + values[1] + values[3] + values[4];
}
当然,这种语法让我们将所有元素添加到向量中,而不是其中的一些,从而防止了偏移 1 的错误。另一种选择是逐个添加元素:
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values;
values.push_back(value1);
values.push_back(value2);
values.push_back(0);
values.push_back(value3);
values.push_back(value4);
return values[0] + values[1] + values[3] + values[4];
}
如预期的那样,这个版本也工作得很好。教训:使用无聊的结构,你将 99%的时间得到预期的行为。这是一条适用于任何编程语言的很好的座右铭,但在 C++中尤为重要。
让我们再次看看使用指针算术和void*访问超出范围的内存的例子。它看起来是这样的:
int pointerBounds() {
int *aPointerToInt;
void *aPointerToVoid;
aPointerToVoid = new int();
aPointerToInt = (int*)aPointerToVoid;
*aPointerToInt = 234;
aPointerToInt = (int*)((char*)aPointerToVoid + sizeof(int));
*aPointerToInt = 2423;
int value = *aPointerToInt;
delete aPointerToVoid;
return value;
}
我已经尽力将这段代码转换为使用std::unique_ptr或std::shared_ptr,我相信这是可能的,但过程极其复杂。第一个问题是处理我们使用的所有指针转换。没有简单的方法可以将std::unique_ptr
第二个问题是void没有直接转换为std::shared_ptr
在悬挂指针的例子中,我们遇到了相同的情况:
int danglingPointer() {
int *aPointerToInt = new int(234);
delete aPointerToInt;
return *aPointerToInt;
}
没有一种直接的方法可以删除智能指针并返回其值。我们可以通过调用unique_ptr::reset来重新分配,但这会再次使用指针。将纯智能指针转换为以下形式的转换看起来如下:
int danglingPointer() {
unique_ptr<int> aPointerToInt = make_unique<int>(234);
return *aPointerToInt;
}
只有这样做才能完全符合预期:值被正确返回,内存被释放。默认情况下没有悬挂指针!
如果我们手动分配并传递一个对std::unique_ptr<>不执行任何操作的删除器,我们就可以将其变成一个悬挂指针。在大多数情况下,没有必要这样做,因此我预计大多数程序员会完全避免这个问题。如果需要多个所有者来管理内存块,你可以选择使用std::shared_ptr<>,这样你最常见的场景就得到了解决。
从所有这些可以得出结论,现代 C++的安全性很高,默认情况下减少了大量潜在的问题。但仍然有其局限性,这是我们接下来要关注的。
现代 C++的局限性
假设我们只使用 STL 集合,避免使用指针,当真正需要时,我们使用标准库中实现的智能指针,并且编写类型时考虑到内存安全性。我们就完成了吗?
Herb Sutter,C++ 标准化委员会的知名成员之一,在 2024 年 3 月 11 日发表的一篇名为 C++ 安全性,在上下文中(herbsutter.com/2024/03/11/safety-in-context/)的博客文章中,探讨了这个问题以及避免 C++ 中安全问题的更一般性问题。他的结论是,编写默认具有安全性和安全漏洞的 C++ 代码太容易了。文章确定了需要更多关注的四个领域:类型、边界、初始化和生命周期。然后,他指出 C++ 20 中已经有一些机制:span、string_view 概念和边界感知范围。正如文章接下来讨论的,语言中缺少的是默认启用但程序员可以在需要时关闭的安全规则。
让我们解开所有这些信息,并给出一些示例。
列表中的第一项,C++20 中引入的新 std::span。它表示从裸数组、std::array、具有大小的指针(std::vector)或 std::string 中提取的连续对象序列。它的一个重大优势是它自动推断序列的大小,从而消除了常见的 off-by-1 错误。因此,我们现在有一种安全地将集合的子集传递给函数的方法,而不用担心会搞乱序列长度。此外,它还允许我们完全禁止指针算术,并使用 std::span 代替。
第二,string_view。一个 std::string_view 实例允许我们对字符串有一个只读视图,从而消除了另一个潜在的安全问题,即字符串在不应该修改的地方被修改,或者对字符串进行可能导致不安全的操作。
第三,概念。概念允许 C++ 程序员在泛型函数和类上定义约束,从而增强类型的安全性。例如,可以要求传递给泛型函数的值具有同时具有加法和减法方法的数据类型。概念仍在开发中,C++ 26 将带来改进,但它们已经帮助解决了许多潜在的安全问题。
第四,边界感知范围。ranges 库允许 C++ 程序员编写高效的函数式编程灵感驱动的操作,这些操作适用于集合,从而消除了另一个潜在误用的来源。范围知道它们的边界,并保护开发者免于在每次函数调用时传递开始和结束迭代器。
如果使用这些改进,它们已经从 C++ 98 走得很远了。然而,还有一些东西是缺失的。还记得访问未预留任何内存的 std::vector 中的索引并导致运行时出现内存错误的代码吗?让我们看看:
int doSomeWork(int value1, int value2, int value3, int value4) {
vector<int> values;
values[0] = value1;
values[1] = value2;
values[3] = value3;
values[4] = value4;
return values[0] + values[1] + values[3] + values[4];
}
这段代码的问题在于,我们可以在不初始化索引 2 的情况下,愉快地访问分配的向量大小之外的索引。解决这个问题的一个可能方案如下:
-
启用 safemode 编译器标志
-
编译器在每次索引访问时都会生成一个范围检查,以验证0 <= index < collection.size()
-
在尝试调用此代码时我们没有在运行时得到错误,因为没有发生任何操作
这样的编译选项可以在不改变现有代码的情况下启用,并防止未知的问题。当然,一些程序员可能会因为可能降低性能而对此有意见。这正是为什么这样的选项应该通过编译器标志来启用,或者,更好的是,默认启用,但可以通过编译器标志来关闭。
这表明还有更多的工作要做来使 C++内存安全。
还有更多的工作要做
标准化委员会目前正在制定一个名为安全配置文件的提案,该提案允许采用一种结合编译增强、静态分析和分析工具的综合方法来消除大部分这些安全问题。完成时间尚不明确,我个人对他们的工作并不羡慕。目前使用的 C++代码有数百万甚至数十亿行,任何提案都需要对现有代码的影响降到最低,除了指出潜在的安全问题。同时,考虑到许多现有应用程序的重要性,它还必须尽可能少地影响性能。
另一方面,紧迫性是显而易见的。C++在内存安全方面存在问题,它可能会被列入美国政府项目的黑名单,以及其他政府也是如此。只有时间才能告诉我们问题何时得到解决以及它如何影响语言的使用。
摘要
在本章中,我们已经看到在 C++中默认编写不安全代码是多么容易。尽管后续标准引入了改进,包括 STL 集合和智能指针,但程序员仍然有可能犯下代价巨大的错误。当然,有方法可以捕捉这些错误:自动开发者测试、探索性测试、渗透测试等等。但语言默认设置很重要,而 C++的默认设置仍然是不安全的。
在审查这些问题之后,我唯一的选择是得出结论,C++默认情况下仍然是不安全的,编写内存安全的代码需要持续的关注和适当的工具。希望很快就会出现在标准中的安全配置文件可能会缓解许多问题,但世界上仍然有大量的 C++代码像 2000 年一样编写,所以这是一个混合的局面。
在下一章中,我们将探讨 C++中的并行性和并发状态。
第七章:在 C++中实现并行和并发没有简单的方法
除非我们重新思考面向对象编程 和函数式编程
要在 C++中实现并行和并发,我们过去通常需要单独的库(例如,Boost)或操作系统原语。随着函数式编程结构的引入,在一定的约束下,并行和并发变得更加容易。
在本章中,我们将涵盖以下主要主题:
-
定义并行和并发
-
并行和并发中的常见问题
-
函数式编程来拯救!
-
Actor 模型
-
我们目前还无法做到的事情
技术要求
本章的代码可在 GitHub 上找到,地址为github.com/PacktPublishing/Debunking-CPP-Myths,位于ch7文件夹中。代码是用 g++和 C++ 20 编译的 Makefiles。关于 Actor Model 的示例使用了C++ Actor Framework(CAF)(www.actor-framework.org/),因此在开始工作之前您需要安装它。在 Ubuntu 上,可以通过运行apt install libcaf-dev来安装。示例中使用的 CAF 版本是稳定的 Ubuntu 库版本:0.17。
定义并行和并发
我的第一台电脑是 HC-90,这是一款在罗马尼亚制造的 ZX-80 克隆。我拥有两个版本:第一个版本需要磁带播放器来加载程序。尽管这种不便,但它与当时的主要竞争对手 CHIP 电脑(又是另一款在罗马尼亚制造的 ZX-80 克隆)相比有一个很大的优势。您知道,CHIP 电脑需要磁带来加载其操作系统,而 HC-90 有足够的 EPROM 内存可以直接引导到 BASIC 解释器。我拥有的第二个版本要好得多:它有一个 5 英寸软盘驱动器,这意味着您可以更快地加载程序。
在两个版本中,BASIC 解释器都是您与计算机的接口,由于除了游戏之外可用的程序不多,我在高中时花了一些时间编写 BASIC 程序和玩游戏。最终,我意识到我想要的不仅仅是 BASIC。我尝试了一些图形和声音,但问题是所有东西都非常慢。这让我学习了 ZX 80 汇编器,这是一次冒险。在汇编器中犯错非常容易,这会导致重启并丢失所有工作。这不是一种可持续的编程方式,但它让我更加珍视今天的编程便利。想象一下:我可以在我的电脑上编译和运行程序的测试,并将我的更改保存到源代码控制系统中。
我当时就知道,我想让图形和声音感觉更快。我没有意识到的是,我有一个根本的限制:只有一个 CPU(或者说,就像我们今天所说的,一个核心),这意味着图形、声音和逻辑代码必须顺序运行。CPU 可以接收一个播放声音的命令,然后转到显示一些图形,然后进行一些计算,由于指令和实际声音播放或图像显示之间的延迟非常短,所以这些任务看起来像是并行运行的。但它们并不是:它们是并发的。如果你将系统加载到最大,图像和声音就不再同步了,你可以观察到这一点。
如果我有更多的处理器或更多的核心来工作,会发生什么情况呢?嗯,我可以定义各种可以在单独的处理器上运行的任务。一个有能力的调度器可以接管这些任务,并将它们并行运行以填充空闲 CPU 的容量。如果任务定义得很好,我们可以从可用的核心中榨取大量的性能,并更快地得到答案。这就是并行性。
这个定义的一个细微差别,将在本章中变得有用,来自 Haskell 社区(见wiki.haskell.org/Parallelism_vs._Concurrency)。他们在一个并行函数程序和一个并发函数程序之间做出了很大的区分,因此假设这两个程序都使用不可变性。并行函数程序使用核心来执行得更快,但它们是确定性的,程序的意义在顺序执行或并行执行时是不变的。与此相对比的是,并发函数程序运行并发线程,每个线程执行 I/O 操作,并且是非确定性的,因为我们不知道操作顺序。
不幸的是,正如软件开发中常见的那样,这些术语有自己的生命周期。你可能会遇到一些人认为并发和并行是完全不同的东西。在研究这个问题时,我在 StackOverflow 上发现了一场关于并发生与并行性是不同事物的争论。有人认为并发是并行的超集,因为并发指的是一组用于管理多个线程的方法。这可能是某些计算机科学书籍处理这个主题的方式。
需要清晰性的要求迫使我们选择一个定义。我将选择与我编程形成期最接近的定义:并发是指多个操作似乎同时运行,而并行是指它们确实如此。这种差异,虽然看似简单,但在设计程序时会导致意图上的差异。当我们设计一个预期将并行运行的程序时,我们定义可以并行运行的运算,并确定它们的顺序。我们试图通过将更大的任务分割成可以独立运行的部分来从 CPU 中挤出时间,这些部分不会相互影响太多。当我们设计一个预期将并发运行的程序时,我们通过将较长的任务推入 CPU 的空闲时间来优化响应时间。
这两种编程模型都具有挑战性,尽管方式不同。让我们提醒自己使用它们时面临的常见问题。
并行和并发中的常见问题
我坚信软件开发的基本问题是将系统的静态视图——代码——在心理上转化为其动态行为,或者程序运行时所做的操作。程序员每次考虑更改时都会在脑海中运行代码,通常是自动的,但总是以精神能量为代价。这就是我相信像测试驱动开发(TDD)和增量设计这样的实践是有用的原因;它们允许我们将部分精神能量支出从大脑转移到重复运行测试上。
这个基本问题对于单个线程来说已经很困难了,但对于并行或并发设计来说,它又增加了一个新的挑战层级。我们不仅需要想象代码会做什么,还需要想象代码将如何与其他同时运行的代码部分交互。因此,想象力和理解并行执行所需的脑力是第一个挑战。
然后,还有纯粹的技术挑战。
当资源被共享时,资源管理变得更加困难,尤其是在多个线程可以修改值的情况下。一个线程可能正在使用另一个线程已经更改的值,从而导致错误的结果。一个内存地址可能被一个线程释放,然后另一个线程尝试读取或写入它。
共享相同的基础设施也不容易。一个线程可能因为一个错误而占用所有资源,从而长时间阻塞其他线程。当程序由使用多个核心的独立任务组成时,这个问题不那么严重,但仍然会导致性能降低,因为独立任务需要在某个点上收敛。线程可能会无限期地等待彼此,或者直到超时发生。
从零开始实现处理并行或并发任务的程序是作为程序员可能需要做的最困难的事情之一。我记得有一次我花了整整一周时间调试线程间的同步问题,我知道我的技术领导和项目经理开始怀疑我的能力。我没有怀疑自己,但我不喜欢最终解决问题所花费的时间那么长。
因此,出现了库和模式,帮助我们实现并发和并行程序。大多数都邀请我们向一个方法传递一个表示线程的函数,该方法解决线程同步的一些复杂性。这是通过将我们可能需要的任务分成任务类型来实现的。此外,由 Hadoop 实现并由函数式编程启发的架构模型 MapReduce 也帮助我们处理大规模并行化。
正如我们所见,如果不讨论函数式编程方法,我们就无法讨论现代并行编程的途径。
函数式编程拯救了!
正如我们所见,并行和并发任务的一个问题是资源共享。在纯函数式编程中,通过不可变性直接解决了这个问题。由于一切都是不可变的默认值,并且任何对值的更改都是通过指向更改的值而不是修改初始值来实现的,因此线程永远不会面临修改其他线程使用的数据的风险。我们在讨论 C++中可用的不同范例时讨论了如何实现这一点。
但还有更多:函数式编程直接提供了可并行化的算法。当引入函数式算法和允许你在并行集合上运行操作的执行策略时,C++标准化委员会也认识到了这一点。
让我们来看一个简单的例子:我们想要计算一个集合中值的平方和。这种算法的函数式版本是一个典型的 map-reduce:首先,我们传入初始集合,将其映射到一个包含值平方的集合,然后通过添加所有元素来减少它。在 STL 中,这些操作分别由std::transform和std::reduce实现。一个结合它们的版本在std::transform_reduce中可用,但我们现在忽略它,以便使例子更相关。
函数看起来是这样的:
long long sumOfSquares(const vector<int> numbers){
vector<long long> squaredNumbers(numbers.size());
auto squareNumber = [](const long it ){ return it * it; };
transform(numbers.begin(), numbers.end(), squaredNumbers.begin(), squareNumber);
return reduce(squaredNumbers.begin(), squaredNumbers.end(), 0);
}
TEST_CASE("sum of squares in parallel") {
vector<int> numbers{234, 423, 345, 212, 112, 2412};
CHECK_EQ(6227942, sumOfSquares(numbers));
}
为了并行运行这些操作,我们只需要向函数式算法添加一个参数,该参数指定了执行策略。我们将使用的执行策略是std::execution::par,这是标准库提供的std::execution_parallel的一个实例,它指定算法需要并行运行:
long long sumOfSquares(const vector<int> numbers){
vector<long long> squaredNumbers(numbers.size());
auto squareNumber = [](const long it ){ return it * it; };
transform(std::execution::par, numbers.begin(), numbers.end(), squaredNumbers.begin(), squareNumber);
return reduce(std::execution::par, squaredNumbers.begin(), squaredNumbers.end(), 0);
}
从这个例子中,我们可以注意到几点。
首先,当你使用函数式编程时,在不同的执行策略之间切换非常容易。这使得我们能够更轻松地修复与并行化相关的问题,并优化代码。在所有情况下,并行运行算法并不一定比顺序执行更好。对于小数量或短集合,启动线程和管理它们所使用的资源可能比节省的时间更大。
第二,我们可以将执行策略作为参数或作为通用配置。这将使我们能够测试算法在独立于线程同步的情况下顺序执行。它还允许我们根据与输入数据相关的几个因素在运行时决定使用哪种策略。
第三,这些执行策略中的每一个都对你的代码施加了限制。例如,我们在这里使用的并行策略要求迭代器在过程中不被无效化,因此禁止写访问和std::back_inserter的使用。STL 中除了std::execution::parallel_policy、std::execution::sequenced_policy、std::execution::parallel_unsequenced_policy和std::execution::unsequenced_policy之外,还有其他执行策略,需要注意的是标准化委员会可能会为std::parallel::cuda和std::parallel::opencl添加内置策略。每个策略都有其局限性和约束,因此最可移植的代码是用于最大不变性和函数式算法的代码。
第四,算法按顺序运行,但每个算法都是并行化的。如果我们需要从我们的计算资源中获得更多,我们要么使用组合的std::transform_reduce算法,要么编写自己的算法将这两个算法结合起来。再次强调,并行运行代码是一个权衡:一些计算资源将用于启动和同步线程,对于某些配置,这可能不会带来很大的好处。
最后,第五点是映射-归约模式非常强大。任何一元函数都可以用于map,任何二元函数都可以用于reduce,我们可以绑定需要更多值的函数的参数,直到我们得到一元或二元函数。映射和归约可以以多种方式链式连接。如果你开始将你的程序视为输入/输出数据,你会发现我们所有的程序都可以写成输入/函数式转换/输出,其中许多函数式转换是map/reduce操作。这种认识导致了一个非常强大的编程模型,因为我们可以为算法的所有部分或部分开启或关闭并行化。偶尔,我们可能想要编写自己的算法,以优化代码中重要部分的并行化,但我们大多数情况下都可以免费获得。
唯一的缺点是我们需要使用不可变数据和函数式算法。
我们迄今为止讨论的设计风格是数据驱动的,因为它关注数据结构和它们的转换。正如在软件设计和架构方面,我们总是可以选择关注行为。确实,一种将程序分割成行为并允许并行编程的设计风格以 Actor Model 的形式出现。
Actor Model
我们周围的世界以非常自然的方式并行移动。每一棵树、每一株植物或每一个人都在做自己的事情,偶尔它们会互动,涉及的事物会发生变化。因此,我们已经有了一个关于并行程序如何工作的心理模型:独立的实体封装它们的行为,并以某种方式在确保适当同步的基础设施上进行通信。
这个想法导致了 1973 年卡尔·休伊特(Carl Hewitt)创建 Actor Model。这个模型将程序分割成可以进行以下三种操作的 actor:
-
向其他 actor 发送消息
-
创建新的 actor
-
定义 actor 接收的下一条消息的行为
每个 actor 都有一个地址,在概念上类似于电子邮件地址,actor 只能与它们有地址的 actor 通信。这个地址可以包含在消息中,或者通过创建一个新的 actor 来获得。
Actor 模型将通信机制与每个 actor 的功能性分开。这导致了允许我们编写高度可并行化代码的实现,而无需处理线程原语。
C++最古老且最稳定的实现是 CAF(www.actor-framework.org/)。一个较新的替代方案是来自阿里巴巴的Hiactor(github.com/alibaba/hiactor)。然而,最知名的实现来自 Java 世界:Akka 工具包(akka.io/)。
让我们看看使用 CAF 实现两个 actor 之间聊天的一个简单示例。以下代码定义了一个 actor 的行为为一个 Lambda 表达式,创建了两个聊天 actor,并在它们之间发送消息。每个 actor 将它们的消息写入控制台:
behavior chatter(event_based_actor* self, const string& name) {
return {
[=] (const string& msg) {
cout << name << " received: " << msg << endl;
}
};
}
void caf_main(actor_system& system) {
auto alice = system.spawn(chatter, "Alice");
auto bob = system.spawn(chatter, "Bob");
scoped_actor self{system};
self->send(alice, "Hello Alice!");
self->send(bob, "Hello Bob!");
self->send(alice, "How are you?");
self->send(bob, "I'm good, thanks!");
sleep_for(seconds(1));
}
CAF_MAIN()
运行此代码会导致不同的输出。最好的输出是我们预期的:
Bob received: Hello Bob!
Alice received: Hello Alice!
Alice received: How are you?
Bob received: I'm good, thanks!
然而,重复运行代码会导致各种结果,如下所示:
Bob received: Hello Bob!
Bob received: I'm good, thanks!
Alice received: Hello Alice!
Alice received: How are you?
我们还可以收到更糟糕的输出:
Alice received: Hello Alice!
BobAlice received: How are you? received: Hello Bob!
Bob received: I'm good, thanks!
这些结果清楚地表明 actor 是并行运行的。它还表明,并行编程可以在这些框架的魔法之下掩盖其复杂性。
然而,actor 模型为我们提供了一种以对象的形式来思考并行编程的方法,这些对象响应请求,并允许我们选择所需的 actor 类型以及最适合我们系统的通信类型。前面的示例展示了一个基于事件的 actor,它接收异步消息,但该框架支持阻塞消息和多种类型的 actor,这取决于它们的生命周期。
actor 模型的一个优点是我们可以在不同的计算机上分布 actor,从而相对容易地扩展模型。当然,这意味着我们直接面对分布式系统的挑战,从使用此模型的第一行代码开始。
有了这些,我们已经看到了在标准库中使用以及通过使用值得尊敬的 actor 模型今天可以实现的可能性。但仍然有什么是不可能的?
我们目前还无法做到的是
正如你所见,使用并行和并发代码并不像“编写你想要的代码,让工具和编译器来理解它”那样简单。也许在 AI 的干预下,我们将来能够做到这一点,尽管根据我目前使用代码助手的经验,我必须说这看起来还非常遥远。
相反,你必须为选择的编程模型结构化你的代码。如果你一开始就把代码库作为一个单线程应用程序编写,并且不使用函数式结构,那么改变它将会很困难。我看到对象和 actor 之间有相似之处,从理论上讲,可能将每个对象转换成 actor,每个方法转换成事件,但这似乎过于理想化。现实是,当我们从同步系统切换到基于事件的系统时,还有很多事情可能会出错,其中很多都非常难以调试,并且需要深入理解 actor 模型以及你为 actor 使用的框架。
你最好的选择是在你选择的范式内重新设计应用程序:要么是数据中心的函数式,要么是行为中心的 actor 模型。
在数据中心的范式下,你查看输入的数据以及到达所需输出的所需转换集合。这些转换中的每一个都是不可变的,因此以数据作为输入并返回另一个数据结构作为输出。正如我们所见,这些转换中的每一个都是可并行的。偶尔,我们需要自己的算法或优化一些现有的算法,然后我们可以编写遵循相同模式的自己的实现。我们可以使用执行策略来微调系统,最终得到一个高度可定制且相对容易优化的系统。
使用以行为中心的范式,你将你的对象视为接收消息的演员。这更接近艾伦·凯对面向对象编程的原始观点。正如在www.purl.org/stefan_ram/pub/doc_kay_oop_en的电子邮件交流中所述,这是一种专注于消息而不是类的愿景,并在 Smalltalk 中得到了最紧密的实现。你从底层开始构建你的应用程序,使用演员及其消息机制,并测试输出是否符合预期。你需要详细了解可用的演员类型和消息类型,以便你可以选择适合你问题的那些。正如这个例子所示,演员并不保证执行顺序,这可能是或可能不是你系统的一个问题。这导致了一个高度可扩展的系统,但同时也更难以理解和调试。
这意味着我们不能自动将编写为同步和单线程的应用程序转换为并行或并发系统。大多数情况下,需要重新设计。
摘要
在 C++中实现并行性和并发性有简单的方法吗?这比过去容易,因为我们很少需要创建自己的线程并处理它们的同步,除非我们正在构建基础设施代码。我们不一定需要外部库或工具,因为 STL 支持许多算法的并行执行。
然而,我们无法避免并行和并发编程的基本复杂性。利用它的程序需要以不同的方式构建,有额外的约束,需要不同的思维方式和不同的设计范式。这不是 C++的问题——这是任何尝试并行化的一个问题。
因此,结论是,如果我们做出正确的选择,事情可以比过去更简单,但它仍然非常复杂。
在下一章中,我们将探讨最快形式的 C++是否是内联汇编。
第八章:最快的 C++代码是内联汇编
低于这个水平,你不应该 达到
在 C++开发者快速发展的世界中,效率至关重要,优化代码以榨取最后一滴性能始终是一个迷人的挑战。这次旅程经常将开发者带到计算的根源,在那里 C++遇到汇编语言,每个 CPU 周期都至关重要。
大约三十年前,在 90 年代的狂野时期,程序员经常不得不手动编写每字节的可执行代码,经常深入到汇编语言(甚至更低级别)的浑浊水域,以实现所需的性能。这些早期的优化先驱们开发了虽然现在看来基础,但为理解 C++和汇编的强大功能和局限性奠定了基础。
这次探索深入研究了优化一个看似简单的任务——在屏幕上点亮一个像素——的具体细节,通过比较三十年前手工编写的优化汇编程序与现代高级编译器(如 Clang、GCC 和 MSVC)的输出。随着我们穿越编译器的演变历程,我们将看到人类直觉与机器生成优化之间的平衡是如何变化的,这为我们提供了关于我们编写的代码与最终运行程序的机器之间不断演变的关系的新的见解。作为旁注,在本章中,我们将专注于英特尔 x86 处理器系列,深入探讨特定功能,而将 ARM 架构的覆盖留给另一本书,可能由不同的作者撰写。
在本章中,你将学习以下内容:
-
如何使用汇编代码来加速你的程序
-
如何不使用汇编代码,并信任编译器的优化器来提供最快的解决方案
点亮一个像素
大约 30 年前,在 90 年代末期这个狂野的时期,本文作者花费了大量时间优化代码,使其尽可能快地运行,消耗最少的资源,同时在屏幕上显示令人难以置信的旋转图形(还涉及到滚动和其他不相关的计算)。
这些应用程序被称为演示(开场白等),展示了某些令人惊叹的图形效果,背后有强大的数学基础,并拥有自家的图形引擎;在当时,没有 DirectX 来处理所有那些低级细节,所以所有这些都必须手动完成。像素颜色计算、颜色调色板设置、CRT 屏幕的垂直回扫和前后缓冲区的翻转都是用 90 年代的 C++和一些汇编语言例程编写的,用于时间敏感的部分。
这些方法之一是在屏幕上放置一个像素,在其方法最简单的形式中,看起来是这样的:
void putpixel(int x, int y, unsigned char color) {
unsigned char far* vid_mem = (unsigned char far*)0xA0000000L;
vid_mem[(y * 320) + x] = color;
}
我将省略 30 年前如何工作的一些非常低级细节,例如段/偏移内存是如何工作的。相反,想象以下内容适用:
-
你正在使用 DOS(在 1994 年,在狂野的东欧部分,几乎每个拥有 PC 的人都使用 DOS – 向早期 Linux 用户的 0.1%致敬)
-
你还在使用一种特殊的图形模式,0x13(几乎所有的游戏都使用这种模式,因为它允许在屏幕上使用神秘的 320×200 分辨率绘制 256 种颜色,其起源只有 40 年前的 IBM 工程师知道)
在这种情况下,如果你在 0xA000 段和特定的偏移量处放置一个字节,显卡将在特定的坐标处点亮一个像素,这可以从前面的公式中获得。
现在,经过几轮代码迭代后,上述程序员观察到该程序流程并不那么优化,它可以从一些优化中受益。
请耐心等待;由经济型编译器生成的代码(就是你刚刚从磁盘上复制的那份,我们在书中 第二章 中提到的)在以下屏幕截图里:

图 8.1 – 30 年前大家最喜欢的 Turbo Debugger
现在,考虑到它的年代,这看起来相当疯狂,但再次强调,我们只需要一点耐心,围绕为什么它在这里的所有谜团都将揭晓。你看,我们正在讨论编译器生成的代码远非最优。
让我们花点时间考虑这段代码。经过一番思考,尤其是从熟悉汇编语言的人的角度来看,这在当今越来越少见,他们可能会清楚地意识到编译器并没有像我们预期的那样挣扎。
以下是为 putpixel 程序生成的汇编代码:
putpixel proc near
push bp ; Save the base pointer on the stack
mov bp, sp ; Set the BP to the current stack pointer
sub sp, 4 ; Reserve 4 bytes for local variables
mov word ptr [bp-2], 40960 ; Store 0xA000 at [bp-2]
mov word ptr [bp-4], 0 ; Store 0 at [bp-4]
mov ax, word ptr [bp+6] ; Load the y-coordinate into AX
mov dx, 320 ; Load the screen width into DX
imul dx ; Multiply AX (y-coord) by DX (screen width)
mov bx, word ptr [bp+4] ; Load the x-coordinate into BX
add bx, ax ; Add y*screen width (AX) to BX (x-coord)
mov es, word ptr [bp-2] ; Load 0xA000 into ES
add bx, word ptr [bp-4] ; Final pixel address in BX
mov al, byte ptr [bp+8] ; Load the color value into AL
mov byte ptr es:[bx], al ; Light the pixel!
mov sp, bp ; Restore the stack pointer
pop bp ; Restore the base pointer
ret ; Return from the procedure
对于不熟悉这种表示法的人来说,[] 代表括号内地址的数据,因此参数是这样传递的:
-
像素点的 x 坐标(从 [bp+4])
-
像素点的 y 坐标(从 [bp+6])
-
需要设置的色彩值(从 [bp+8])
事实上,当前的代码包含了很多不必要的内存访问来移动数据,而这些操作本可以保留在寄存器中,而且有很多不必要的对各个内存区域的访问,这些是可以跳过的。当时编译器生成的代码易于调试,但可以编写得更整洁。今天的编译器在调试模式下编译时生成相同类型的代码,性能非常相似,但一旦切换到优化的发布模式,它们就会施展魔法。
现代 CPU 是非常复杂的生物;在保护模式下运行时,它们采用各种技术,如乱序执行、指令流水线和其他技术,使得现在对真正低级性能的分析相当困难……但旧机器要简单得多!或者,你可以在现代计算机上使用 DOS,你会有同样的感觉。
没有考虑到保护模式是在早期的 80286 处理器中引入的,DOS 根本无法处理它(现在仍然不能),所以它坚持它最擅长的事情:在实模式下运行程序。在实模式下运行时,处理器只是依次执行指令,甚至有一个指令表解释每个指令将占用多少周期 1。
在花费了大量时间查阅那些表格后,我们得出结论,在那个时代的处理器上,一个imul可能比两个移位和一个加法操作花费更长的时间(全世界有成千上万的程序员在查阅了那些表格后得出了同样的结论,但我们感觉我们一定是某种地方英雄,因为我们发现了这个特性)。
考虑到 320 是一个非常不错的数字,因为它等于 256 和 64 的和,经过几轮优化后,我们为这个例程提出了以下稍微更优化的版本:
void putpixel(int x, int y, unsigned char c) {
asm {
mov ax, 0xA000 // Load 0xA000 (VGA mode 13h) into AX
mov es, ax // Set ES to the video segment (0xA000)
mov dx, y // Load the y-coordinate into DX
mov di, x // Load the x-coordinate into DI
mov bx, y // Copy the y-coordinate into BX
shl dx, 8 // Multiply DX by 256 (left shift by 8 bits)
shl bx, 6 // Multiply BX by 64 (left shift by 6 bits)
add dx, bx // Add those, effectively multiplying y by 320
add di, dx // Add the calculated y to DI (pixel offset)
mov al, c // Load the color value into AL
stosb // Light the pixel
} }
这不是为这个目的可以想到的最优例程,但就我们的具体要求而言,已经足够了。
直接内存访问量显著减少(即使在旧时代,这也被认为是慢的),使用imul进行的长时间乘以 320 的操作改为乘以 256(这是左移 8 位操作:shl dx,8),然后是 64(同样左移 6 位),然后求和,这样仍然比消耗电力的乘法操作少用几个周期。
因此,为“如果你真的想要快速代码,你必须以尽可能低的级别自己编写它”这一神话奠定了基础。
作为一项有趣的心智练习,让我们跳过 30 年的时间,跳过几代编译器。如果我们把 C++例程直接输入到现代编译器中(为了我们的目的,我们使用了 Clang——写作时的最新版本是 18.1——但使用 GCC 也会得到非常相似的结果,只是使用不同的寄存器集),我们得到以下输出:
putpixel(int, int, unsigned char):
movzx eax, byte ptr [esp + 12]
mov ecx, dword ptr [esp + 4]
mov edx, dword ptr [esp + 8]
lea edx, [edx + 4*edx]
shl edx, 6
mov byte ptr [edx + ecx + 40960], al
这段代码比我们为特定目的编写的代码要短得多,我们当时认为它是最优的,针对的是 30 年前的处理器,但处理器在过去 30 年里发展了很多,新增了很多更高级的功能,包括新的命令(关于新命令的更多内容将在本章稍后介绍,所以请耐心等待),我们发现编译器的优化例程如何处理那个令人愉悦的数字 320,这一点让我们感到非常满意。
C++ 编译器在过去几十年中经历了显著的发展,从最初作为 Turbo C++ 或 Watcom C++ 的朴素起点,发展成为极其复杂且能够执行一系列以前由于硬件限制而难以想象的优化,因为,嗯...640 KB 应该对每个人来说都足够了。
现代编译器不再仅仅是人类可读代码到机器代码的简单翻译器;它们已经成为复杂的系统,能够以可以显著提高性能和内存使用率的方式分析和转换代码,同时考虑一些旨在帮助开发者发挥其源代码最佳性能的方面。
GCC、Clang 和 MSVC 都采用了高级优化技术,如内联函数、循环展开、常量折叠、死代码消除以及跨越整个模块或程序的激进优化,因为在这个阶段,它们对整个应用程序有一个全面的了解,这使得这些高级优化成为可能。
顺便提一下,这些编译器还利用现代硬件特性,如向量化和平行化,生成针对特定处理器的非常高效的机器代码。我们将在下一节中展示这些优化是如何实现的,我们将展示一个平凡的任务,并让我们的编译器对它进行处理。
但在我们达到那个阶段之前,再举一个 30 年前的用例。本章的副标题是低于这个水平你就不应该尝试。当然,我们的意思是说在更低级别进行编码,而不是其他事情,而现在,我们将再次自豪地与自己唱反调。这里的矛盾是:在某些情况下,你真的应该达到比汇编语言更低的级别。
如果你熟悉图形编程,那么我想你一定熟悉双缓冲和后缓冲的概念。后缓冲是一个离屏缓冲区(内存区域,大小与屏幕相同),所有渲染(图形绘制)首先在这里发生。当渲染完成后,后缓冲被复制到屏幕上以显示图形,然后清空后缓冲,渲染重新开始。在历史上某个时刻,加拿大程序员汤姆·达夫发明了一块绝妙的代码,旨在完成这项任务;它的名字叫达夫设备,在多个论坛上讨论过几次,我们现在不会讨论它。相反,我们将向您展示我们用来从后缓冲区复制数据到屏幕的“高度优化”的代码:
void flip(unsigned int source, unsigned int dest) {
asm {
push ds // Save the current value of the DS register
mov ax, dest // Load the destination address into AX
mov es, ax // Copy the value from AX into the ES
mov ax, source // Load the source address into AX
mov ds, ax // Copy the value in AX into the DS
xor si, si // Zero out the SI (source index) register
xor di, di // Zero out the DI (destination index)
mov cx, 64000 // Load 64000 into the CX register
// (this is the number of bytes to copy)
rep movsb // Run the`movsb` instruction 64000
// times (movsb copies bytes from DS:SI to ES:DI)
pop ds // Restore the original value of the DS
} }
之前提到的技巧包括 rep movsb 指令,它将实际复制字节(movsb),重复(rep)64,000 次,如 CX 寄存器所示(我们都知道 64,000 = 320 x 200;这就是为什么它们是魔数)。
在这种情况下,这段代码运行得非常完美。然而,还有一点可以微调;你看,我们使用的是一个相当不错的处理器——至少是一个 80386。与它的前辈 80286(一个纯 16 位处理器)相比,80386 是一个巨大的进步,因为它是英特尔推出的第一个 32 位 x86 处理器。所以,我们可以做的是以下这些:我们不是使用rep movsd来复制 64,000 字节,而是利用我们高端处理器提供的机会,利用新的 32 位框架、关键字和寄存器。我们移动 16,000 个双字(我们都知道一个字节是 8 位,两个字节称为一个字,测量 16 位,两个字称为一个双字,总共 32 位),因为这正是新处理器所支持的:对 32 位值的操作。新引入的movsd命令正是这样做的:一次复制 4 个字节,这样与我们的旧代码相比,速度可以提高 4 倍。
我们在本书开头介绍的轶事 C++编译器是 Turbo C++ Lite。不幸的是,对于我们的编译器来说,Turbo C++无法编译 80286 以下处理器的代码,所以我们只能使用 16 位寄存器和一些非常低效的寄存器处理。
正是在这里,C++代码中任何人都能看到的最低级别的黑客行为出现了——我们只是在代码中将rep movsd命令的字节作为十六进制值添加进去:
xor di,di
mov cx,16000
db 0xF3,0x66,0xA5 //rep movsd
pop ds
没有什么比在生产代码中看到这一点更简单和令人眼花缭乱的,对吧?现在,尽管我们的编译器无法编译 80386 的代码,因为它还停留在石器时代(几乎就像你现在正在阅读的章节的一半一样),我们仍然可以生成在您的处理器上运行最优化的代码。请千万不要这样做。
关于过去的说明
现在,你可能会问,为什么我们甚至在 2024 年还要提及汇编语言,当时的主要趋势已经耗尽,关于 AI 驱动开发工具的广泛应用、低代码/无代码平台的增长以及各种 JavaScript 模块 N 次迭代的持续上升,这些模块的输出与之前完全相同,只是语法不同。
无论这些是目前 IT 世界中最引人注目的事件,汇编语言仍然没有过时。它可能不会像大家最喜欢的 Rust 语言(如果一切按计划进行,亚历克斯将在下一章辩论 Rust)那样受到太多关注,但仍然有一些主要商业部门必须使用汇编语言,并且在需要精确控制、性能优化或直接硬件访问的几个硬件环境中仍然至关重要,例如以下这些:
-
嵌入式系统:微控制器和物联网设备通常使用汇编语言进行高效的底层编程。在这些小型设备上,电力并不充足;每个比特都很重要。
-
操作系统(OS)开发:引导加载程序和操作系统内核的关键部分需要汇编来进行硬件初始化和管理。要实现这一壮举,要么你为一个大公司工作,要么开始自己的项目。Linux 基本上已经涵盖了。
-
高性能计算(HPC):汇编用于优化性能关键代码,尤其是在科学计算或定制硬件(例如,FPGA)中。为了追求这一点,你必须找到愿意付钱让你这样做的人。
-
安全和逆向工程:分析和利用二进制文件通常涉及理解和编写汇编。这是最有利可图的,也是进入汇编编程的最现实方式,不幸的是。
-
固件开发:BIOS/UEFI 和低级设备驱动程序通常用汇编编写,以实现直接硬件交互。在这里,你同样必须在大公司的工资单上,尽管也有一些开源项目(如 coreboot、libreboot,或者只需谷歌免费 BIOS 即可获得一个相当不错的列表)。
-
遗留系统:维护较旧的系统或与复古计算一起工作通常需要汇编。这是将乐趣和痛苦融合到一个体验中的理想机会。
-
专用硬件:DSP 和定制 CPU 架构可能需要汇编进行专用、高效的处理。
请不要立即放弃汇编语言。只要计算机存在,它就仍然相关,并将继续如此。对于那些对这个主题感兴趣的人来说,它有自己的位置。否则,你可以坚持使用标准的 C++。
所有数字的总和
亲爱的尊敬的读者。这是一个普遍公认的事实,即所有开发者在其生活的某个阶段都必须经历一次技术面试。有各种程度的审问:有些只是“请告诉我一些关于你的事”(这些是最难的),而有些则更深入,甚至可能要求你在黑板上或甚至在电脑上编写一些代码。
在面试问题中经常出现的一个程序是编写一些代码来计算一系列具有特定特性的数字的总和,例如,所有偶数的总和,所有可以被,比如说,五整除的数字的总和,或者特定区间内奇数的总和。
为了简单起见,让我们坚持简单的东西:所有奇数到 100 的总和。以下快速程序正好提供这一点:
#include <cstdio>
int main() {
int sum = 0;
for (int i = 1; i <= 100; ++i) {
if (i % 2 != 0) { // Check if the number is odd
sum += i; // Add the odd number to the sum
}
}
printf("The sum is: %d\n",sum);
return 0;
}
不是一个过于复杂的程序:只需遍历数字;检查它们是否为奇数;如果是,将它们的值加到最终的总和中;最后,打印出总和(对感兴趣的每个人来说,从 1 到 100 的所有奇数的总和正好是 2,500)。
但我们的清晰思维被众所周知的事实(至少,对于 C++ 程序员来说)所蒙蔽,即最快的 C++ 代码是内联汇编,因此我们决定在速度的祭坛上牺牲我们程序的便携性和可理解性,并决定用汇编语言重写其主要部分。因为,嗯,那是最快的。以下是我们尝试的示例,使用 AT&T 汇编语法,只是为了展示我们可以嵌入到非标准兼容的 C++ 程序中的广泛可用的汇编方言:
#include <cstdio>
int main() {
int sum = 0;
int i = 1; // Start with the first odd number
__asm__ (
"movl $1, %[i]\n" // Initialize i to 1
"movl $0, %[sum]\n" // Initialize sum to 0
"loop_start:\n"
"cmpl $100, %[i]\n" // Compare i with 100
"jg loop_end\n" // If i > 100, exit the
"addl %[i], %[sum]\n" // sum += i
"addl $2, %[i]\n" // i += 2
"jmp loop_start\n" // Repeat the loop
"loop_end:\n"
: [sum] "+r" (sum), [i] "+r" (i)
);
printf("The sum is: %d\n", sum);
return 0;
}
这里只是简要说明汇编代码的功能,因为我希望其他代码行是自解释的。
这里是汇编代码的分解:
-
"movl $1, %[i]\n" : 这条指令将 i 设置为 1。尽管 i 在 C++ 代码中已经被初始化为 1,但在汇编中我们再次明确地设置它以提高清晰度。
-
"movl $0, %[sum]\n" : 这将和设置为 0,确保汇编代码中的和从 0 开始。我们必须承认,这两个初始化不是必需的,但我们希望它们是对汇编代码的温和介绍,以免吓到你。
-
loop_start : 这只是一个标签,无需进一步说明。
-
"cmpl $100, %[i]\n" : 比较 i 与 100。这个比较用于检查 i 是否已达到或超过 100。
-
"jg loop_end\n" : 如果 i 大于 100,程序将跳转到 loop_end,退出循环。
-
"addl %[i], %[sum]\n" : 将 i 的当前值加到 sum 上。这会将所有奇数的和累加到 99。
-
"addl $2, %[i]\n" : 将 i 增加 2 以移动到下一个奇数(例如,1 → 3 → 5 等)。
-
"jmp loop_start\n" : 跳回到循环的开始以重复该过程。
-
loop_end : 当 i 超过 100 时,程序会跳转到这个标签,从而有效地结束循环。
看起来奇怪的 "+r" (sum) 和 "+r" (i) 部分是约束条件,告诉编译器将 sum 和 i 作为可读可写变量处理,意味着在汇编操作期间它们的值可以被读取和写入。
作为第一个缺点,代码的可读性和可理解性受到了指数级的损害。我们故意使用 AT&T 语法进行汇编,因为它更加繁琐且难以理解,我们希望你也能经历这个过程,并记住除非你确切知道自己在做什么,否则不要在你的代码中使用汇编,那时你就可以免责。
其次,这段代码不再具有可移植性,因为在 Visual C++中没有asm这样的东西;他们过去使用过__asm(或者更近一些,在本章的开头,Turbo C 展示了asm关键字的引入)。既然我们在这里,C++标准也没有包含一个通用的汇编块标识符,因为汇编语言语法是编译器和平台特定的,内联汇编是语言的扩展而不是核心部分。我已经警告过你。我真的希望前面的陈述能够完全阻止你考虑在任何情况下在你的 C++函数体中编写汇编代码,无论是否存在非标准的关键字来允许你这样做。
但现在,得益于gcc.godbolt.org,我们已经要求主要的编译器在各个优化级别上处理原始的 C++程序(完全没有汇编侵入),因为我们迫切希望向您展示,确实,在这个阶段完全跳过汇编语言是您可以做出的最明智的决定。
第一个展示编译器在生成最优 C++代码方面效率如何的是微软的 Visual C++。微软自己的小巧、紧凑的 C++编译器有几个选项可以生成和优化生成的代码 2,但我们这里有一个说法:代码越短,运行越快。因此,我们明确告诉编译器生成最短的代码(/O1),如下所示:
2 learn.microsoft.com/en-us/cpp/build/reference/o-options-optimize-code?view=msvc-170
`string' DB 'The sum is: %d', 0aH, 00H ; `string'
_main PROC
xor ecx, ecx ; Clear the ECX register (set ECX to 0)
xor edx, edx ; Clear the EDX register (set EDX to 0)
inc ecx ; Increment ECX, setting it to 1
$LL4@main:
test cl, 1 ; Test the least significant bit of CL
; (ECX) to check if ECX is odd or even
lea eax, DWORD PTR [ecx+edx] ; Load the effective
; address of ECX + EDX into EAX
cmove eax, edx; If the zero flag is set
; (ECX was even), move EDX into EAX
inc ecx ; Increment ECX by 1
mov edx, eax ; Move the value in EAX to EDX
; (update EDX for the next iteration)
cmp ecx, 100 ; Compare ECX with 100
jle SHORT $LL4@main ; Jump to the start of the loop
; (loop until ECX > 100)
push edx ; Push the final value of EDX (the sum)
; after the loop onto the stack
push OFFSET `string' ; Push the offset of the string
call _printf ; Call the printf function
pop ecx ; Clean up the stack (remove string)
pop ecx ; Clean up the stack (remove EDX)
ret 0 ; Return from the _main function
_main ENDP
有趣的是,MSVC 的汇编输出与我们手工编写的非常一致;它有一个循环,根据我们当前处理的是奇数还是偶数,对各种寄存器进行了一些不同的处理,但除此之外,它与我们所写的代码相似。
使用其他优化标志组合(/Ox、/O2 和 /Ot)为 MSVC 生成的代码并没有很大不同,只是寄存器的分配略有不同,但没有任何使我们大喊“哇!”的东西。
在切换到 GCC(14.1)以便它处理我们的简单代码后,我们注意到对于–O1和–O2优化级别,生成的代码与 MSVC 生成的代码非常相似:它有一个变量,遍历数字,并对奇偶性和和进行了一些测试。就是这样,不是黑魔法...与为–O3生成的代码不同。
使用这个标志,我们惊讶地看到编译器如何通过单指令多数据(SIMD)指令来提高速度,而这个编译器引入的意外特性是它计算了一个不断变化的 4 元素数组的元素总和,从值{1, 2, 3, 4}开始,并在 25 次迭代中通过 SIMD 指令将每个元素增加 4。累计的总和存储在一个 SIMD 寄存器中,循环结束后,它被减少到一个单一的整数,提供了正确的结果。
为此生成的汇编代码太长了(超过三页),我们决定不在这里发布它,因为它将毫无用处,但作为一个好奇的事实,我们提到了它。
我们检查的下一个编译器是 Clang,以处理我们的简单 C++程序。在这个阶段(意味着在 GCC 使用–O3选项后的长 SIMD 指令输出之后),我们并没有期待任何惊人的结果,但我们却遇到了惊喜。
即使在–O1选项下,Clang 也向我们展示了以下,可以说相当简短的代码:
main:
push rax
lea rdi, [rip + .L.str]
mov esi, 2500
xor eax, eax
call printf@PLT
xor eax, eax
pop rcx
ret
.L.str:
.asciz "The sum is: %d\n"
真是令人惊讶!看起来 Clang 在幕后做了所有的计算,只是简单地将结果放入编译后的二进制文件中。这已经是最优化的了。我们真的很激动,编译器已经成熟并成长得如此聪明,这让我们好奇是否其他编译器也能这样聪明。
GCC 在–O3选项下表现出相同的行为,但令人惊讶的是,只有当我们想要总结到 71 的奇数时才会如此。到了 72,内部出现了问题,再次生成了长长的 SIMD 汇编源代码列表。
我们无论如何都无法说服 MSVC(在任何情况下),通过数字和参数的组合走向 Clang 的道路,并预先计算打印奇数和所需的数字,所以我们得出结论,这是不可能的。也许它将在下一个版本中实现,微软 Visual C++开发者们,你们怎么看?
展望未来
在 C++开发者中流传着一句俗语,大意是“今天的编译器优化是我们迄今为止所能拼凑出的最好的,也是一个不太温柔的提醒,它们本可以变得更好”。
考虑到这本书是在 2024 年(希望它能在 2025 年出版,如果一切按计划进行,那么在 2027 年就会过时,我们将获得一个更新版本的编写任务),我们对当今世界正在发生的事情有一个相当清晰的了解。
然而,如果你在阅读这本书的时候,有人试图在另一个星球上种植土豆,而你所在建筑的墙壁上被涂鸦的猴子覆盖,那么你可能已经对编译器在过去 10 年里取得了多大的进步有所了解。实际上,甚至可能发生的情况是,微软自己的(是的,我们知道,小巧,柔软的)C++ 编译器设法成长到能够计算编译前几个数字之和的程度,而 GCC 在 72 的时候也没有发怒。即使是像我们有的这个简短程序这样的短程序也是如此。
欢迎来到未来。
一条指令统治一切
亲爱的读者。在我们本章的前一节中,不幸的是,我们已经用尽了从各种文化来源借来的关于技术面试、职业和生活选择,以及我们应该选择红色药丸还是蓝色药丸的唯一浮夸介绍,所以让我们将注意力集中在我们的候选人可能在技术面试中遇到的一些更技术性的问题上(在这个简短的介绍段落中,“技术”这个词出现了四次)。
这些问题中的一个,几年前被这些文字的作者提出,是要编写一个简短的代码片段来计算一个 32 位整数中 1 位的数量。让我们草拟一个快速应用程序来完成这个任务:
int countOneBits(uint32_t n) {
int count = 0;
while (n) {
count += n & 1;
n >>= 1;
}
return count;
}
这是发生的事情。首先,我们初始化一个计数器,从 0 开始。下一步是遍历位。当 n 非零时,我们将 n 的最低有效位添加到计数器中(n&1 给出这个值)。随后,我们将 n 右移一位(丢弃最低有效位)。
一旦处理完所有位(当 n 变为 0 时),返回 1 位的总数。这不是一个很复杂的过程,只是直接的工作。
看起来,在数字中计算位的这种程序在计算领域必须具有非常特别的兴趣,例如用于错误检测和纠正、数据压缩、密码学、算法效率、数字信号处理、硬件设计和性能指标,所以它成功渗透到 STL(C++ STL,即标准模板库)中也就不足为奇了,它以 C++ 20 中的 std::popcount 的形式存在。
故事中有趣的部分在于,我们不仅在 STL 中找到了这个方便的操作,而且它被认为非常有用,以至于它甚至存在于处理器的层面上,在臭名昭著的 POPCNT 助记符下。臭名昭著,是因为在 2024 年,它被有效地用于阻碍在未获得官方支持的旧机器上安装 Windows 11 3。
3 www.theregister.com/2024/04/23/windows_11_cpu_requirements/
但这对我们的候选人来说意味着什么,他们需要编写代码来给面试官留下深刻印象,那就是他们可以简单地用以下非常实用的代码片段替换之前复杂的代码:
int countOneBits(uint32_t n) {
return std::popcount(n);
}
在将前面的程序输入到gcc.godbolt.org的编译器后,我们得到了一个奇怪的混合结果。无论优化级别如何,GCC 编译的代码总是生成以下内容的变体:
countOneBits(unsigned int):
sub rsp, 8
mov edi, edi
call __popcountdi2
add rsp, 8
ret
因此,某些级别的代码从我们的视线中消失,进入 GCC 提供的库中的某个奇怪的调用,称为__popcountdi2 4。为了说服 GCC 充分利用我们在其上运行代码的处理器的能力,我们需要利用一些不太为人所知的命令行选项,例如-march(或针对此特定目的的-mpopcnt)。
4 gcc.gnu.org/onlinedocs/gccint/Integer-library-routines.html
根据官方文档,5 此命令将选择合适的处理器指令集,以便使用特定处理器的可用扩展。由于在此阶段,我们知道POPCNT指令是在早期的 Core i5 和 i7 处理器中引入的,在 Nehalem 系列中,我们应该简单地指定以下内容给 GCC:-march=nehalem。现在,不出所料,编译器生成了以下内容:
5 gcc.gnu.org/onlinedocs/gcc/x86-Options.html
countOneBits(unsigned int):
popcnt eax, edi
ret
有趣的是,如果我们只向编译器提供-mpopcnt标志,那么它会生成一个额外的xor eax, eax(意味着它清零 EAX 寄存器),因此我们可能已经见证了通过选择 Nehalem 架构而进行的某些处理器特定的额外优化:
countOneBits(unsigned int):
xor eax, eax
popcnt eax, edi
ret
我们无法从 GCC 中挤出更多内容;对于此功能,没有更低级别的实现,因此我们将注意力转向我们列表中的下一个编译器。
即使没有明确要求优化代码,Clang 也会生成对某个库中某个位置的std::popcount函数的通用调用;然而,明确要求优化生成的代码,Clang 在各个优化级别上会产生以下结果:
countOneBits(unsigned int):
mov eax, edi
shr eax
and eax, 1431655765
sub edi, eax
mov eax, edi
and eax, 858993459
shr edi, 2
and edi, 858993459
add edi, eax
mov eax, edi
shr eax, 4
add eax, edi
and eax, 252645135
imul eax, eax, 16843009
shr eax, 24
ret
虽然看起来令人惊讶,但对此代码有一个完全合理的解释,可以在斯坦福的 Sean Eron Anderson 的位操作网站上找到 6。不考虑这个额外的绕道,Clang 在处理架构和指定生成代码时要使用的 CPU 扩展子集时,与 GCC 的行为完全相同。
6 graphics.stanford.edu/~seander/bithacks.html#CountBitsSetParallel
在三大编译器中最后一位,微软自己的(我们知道,小巧、柔软的)C++编译器处理情况与 Clang 非常相似。当我们要求在指定不支持POPCNT指令的架构时优化代码,它会生成类似于 Clang 生成的代码,使用低级位操作,而如果架构支持POPCNT指令,它将调整到正确的类型,并调用POPCNT以正确的参数(/std:c++latest / arch:SSE4.2 /O1)。
做得很好,小巧、柔软的编译器。
摘要
与 C++编程相关的神话是由语言随时间演变的历程、语言用户之间的差异和掌握水平,以及开发者社区中的心理需求所塑造的。早期的 C++编译器,通常生成的代码不如现代编译器优化,导致了关于语言效率低下和需要手动优化的神话,例如使用特定平台的汇编语言重写整个例程。
随着编译器和语言特性的进步,这些神话依然存在,有时甚至掩盖了现代最佳实践。这一点,加上 C++程序员中的精英主义文化和掌握感,强化了过时的观念,尽管 C++仍然被视为一种强大且多功能的语言,适用于严肃且性能关键的应用。
在即将到来的章节中,我们将举办一场编程语言的选美大赛,迅速淘汰所有除了我们最喜欢的语言之外的所有语言,整个过程将以无争议的皇后,C++的加冕而告终。诚然,我们对这种语言的钦佩如此之深,以至于人们可能会怀疑比赛从一开始就被操纵了。
第九章:C++ 美丽
根据墙上的镜子
亲爱的读者。在本章中,我们不会专注于教授你特定的概念、技术或实用技能。相反,我们的目标是引导你体验一种不同类型的学习,这种体验让你能够从细节中抽身,沉浸于编码的美学方面。
本章旨在激发你以新的眼光看待代码,认识到当我们深思熟虑、细致入微地编写代码时,可能出现的模式、对称性,甚至诗意。
美是一种独特且个人的体验,因为它源于个人感知、情感共鸣、文化影响和个人身份的复杂相互作用。每个人都会通过自己的感官和认知过滤器来解释美,这些过滤器由他们的生活经历、记忆和文化背景塑造。情感联系、情绪和个人品味进一步影响一个人认为什么是美的,使其成为一种深刻的主观体验,反映了一个人对世界的独特视角。
有些人可能在地中海岛屿上日落时那炽热的色彩中找到美,而其他人可能欣赏斯堪的纳维亚峡湾的清新、寒冷的魅力。这完全是个人感受。
因此,虽然本章可能没有具体的课程或目标,但它提供了一个独特的机会,让你在更情感和智力层面上与编码技艺建立联系。通过这次旅程,我们希望你能从新的角度看待代码,认识到当我们深思熟虑、细致入微地编写代码时,可能出现的模式、对称性,甚至诗意。
在本章中,你将体验到:
-
这里没有新的东西要学习...
-
...除了欣赏美...
-
...在构思代码时,可能会使你在企业环境中无法坐在键盘前编写专业代码的技术
寻找美
每种编程语言都是一件独特的艺术作品,其设计、哲学和提供的可能性都各具特色;使用它们的程序员也同样多样,每个人都将自己的偏好、创造力和个性带入这门技艺。
一些开发者被 Python 的优雅简洁和结构所吸引,享受着其整洁的空白字符带来的清晰和表现力。然而,其他人却在像 Forth 这样的语言中找到快乐,他们可以精确地自由地推和弹出堆栈,拥有对这种低级操作的全权和简洁性,同时似乎享受着用简约语法构建强大系统的挑战。现在,真的还有人使用 Forth 吗?
然后还有那些拥抱 Lisp 神秘世界的勇敢冒险家,他们使用其臭名昭著的长列表的乏味和固执的括号。对这些程序员来说,语法的明显单调是通往一个丰富和表达性元编程景观的门户,在那里他们可以像处理数据一样处理代码,这种感觉几乎像是炼金术,更不用说他们可以直接访问 Emacs 了。
最后但同样重要的是,我们中的一些人,C++程序员的部落,认为一个程序的魅力可以完全通过以下一行表达出来:
auto main()->int{return<:]<class _>(_)->_<%return 7;}(1);%>
我们在这个星球上还需要 C++的哪些美感呢?一行代码看起来像一系列眨眼的笑脸,最后,它将幸运数字七返回给调用者。在这段代码中并没有多少技术性,只是一行简单的 lambda 函数返回一个数字,为了迷惑你,亲爱的读者,我们使用了main的尾随返回类型,因为为什么不呢?
此外,为了给我们的代码片段增加更高的神秘感,我们仅仅是为了它们的纯粹美感,使用了臭名昭著的 C++二分图。不幸的是,C++17 中已经弃用了臭名昭著的三分图,所以我们无法使用它们来为我们的代码片段增添色彩。实际上,我们可以使用它们,但我们只是不敢。
所有的混乱和困惑都在一行中完成。真正的问题是,我们能否让这对你,亲爱的读者,读起来更加繁琐而美丽?当然,这个问题的答案是一个确定的“是”。我们能构建它吗?是的,我们可以!几乎...但首先,我们必须要摆脱数字,因为嘿...
谁喜欢数字?
...或者更具体地说,谁需要数字呢?由于数字具有抽象的本质,以及在我们日常生活中我们并不真正需要它们,这些数字不需要高级思维和掌握符号表示的能力,这使得它们几乎毫无意义。
也许这就是为什么亚马逊丛林中的一些部落甚至没有发明出所有这些(是的,我在指你们,Munduruku 部落 1)。你们有零、一、二、三、四、五的概念,然后是许多。如果这对你来说足够了,我可以接受。
1 https://www.amazon.com/Alexs-Adventures-Numberland-Alex-Bellos/dp/1408809591
让我们应用美洲原住民的古老智慧到我们的编程探索中:创造出世界上今天可以看到的最美的 C++代码片段。所以,让我们摆脱那些讨厌的数字,只保留 0 和 1(为了至高无上的比特,这样它们就不会感到被排除在外),然后让我们用以下代码片段来部落化:
#define __(...)sizeof(int[]){0,\
##__VA_ARGS__}/sizeof(int)-1
auto main()->int{return<:]<class _>(_)->_<%return
__(_(), _(), _(), _(), _(), _(), _()) ;}(__());%>
哦,它的纯粹之美。它让我们眼中充满喜悦的泪水,不是吗?可以说,一些挑剔的程序员可能会对可读性、可维护性、标准合规性等方面发表一些尖酸刻薄的评论……尤其是如果他们使用的是微软的(小巧,柔软的)C++编译器,它明确拒绝编译前面的代码。但我们在颤抖中欣喜若狂,因为我们让其中一个编译器崩溃了,而所有其他的主要玩家都愉快地消化了它并编译了它。
但不幸的是,代码中包含了很多重复的部分,我们并不真的喜欢。我们也不需要重复,所以我们也应该至少去掉其中的一部分,不是吗?或者全部去掉,为什么不呢?
因为这就是 C++语言的真正之美。总是能够重新定义自己,不惜一切代价提供更好的代码版本,不考虑所做出的牺牲。可读性万岁。可维护性万岁!自由代码的混乱、混乱和困惑万岁!
那么,勇敢的战士们,我们的任务已经布置,准备好武器(我的意思是键盘),让我们像以下代码片段所展示的那样,节省那些字节吧:
#define $$ sizeof
#define $ return
#define $_ int
#define __(...)$$($_[]){0,##__VA_ARGS__}/$$($_)-1
auto main()->$_<%$<::><class _>(_)->_<%$
__(_(), _(), _(), _(), _(), _(), _()) ;%>(__());%>
再次看看它的纯粹之美。再次,C++的力量穿透了乌云,就像一千颗超新星,使得我们心中的所有愿望都成为可能,比如替换语言的一个关键元素,例如,将return关键字替换为$符号。这并不是因为它属于语言指定的有效字符集的一部分,但稍后我们将会讨论这个问题,以及一些熊,一点点。
但看看玻璃的明亮面。至少我们没有写出以下代码:
#define return(...) main
#define main(...) int
main(7)(return(7))(){
return 7;
}
我们必须承认,我们曾考虑过写下来并把它加到书中,但展望未来后,我们只是认为一切都有其极限。即使是经验最丰富的开发者面对无意义的(但无论如何,有趣)代码时,也会感到痛苦。
这可能是本书中展示的最邪恶的代码片段之一,因为邪恶的程度与尝试编写一个正确括号的 Lisp 程序时所感受到的痛苦程度相当。因为如果你因为括号太多而移除一个括号,或者,天哪,如果你认为再加一个括号是个好主意而添加一个括号,会发生什么?相信我,亲爱的读者,不要冒险。
所以,请假装前面的代码不在书中,即使它在书中,你也没有看到它。即使你看到了,你也不敢改变其中的括号数量。
足够的邪恶代码了;现在是我们回到之前代码的时候了,它是本书可以展示的最美的 C++代码片段的竞争者。
如果我们能够让它变得更短一些,更简洁,更富有表现力,比如移除那些丑陋的define,并用一些更能表达美的事物来替换它们,就像以下这样:
#ifndef MINK
#define MINK
#include __FILE__
DD $$ sizeof
DD $ return
DD $_ int
DD _$ _()
DD __(_...)$$($_[]){0,##_}/$$($_)-1
auto main()->$_<%$<::><class _>(_)->
$_<%$ __(_$,_(),_$,_(),_$,_(),_$) ;%>(__());%>
#endif
#ifdef MINK
#define CAT(x, y) CAT_I(x, y)
#define CAT_I(x, y) x ## y
#define HH CAT(%, :)
#define DD HH define
#endif
哦,这真的刺痛了我的眼睛。对此表示歉意,并提前道歉,但我们无法在不让作者头疼的情况下使它变得更美。
在阅读这段内容时,我们突然意识到,尽管已经有了混淆 C 代码竞赛,我们实际上并不需要过度混淆其 C++对应版本。根据定义,C++可以足够混淆,无需我们主动尝试混淆它,但现在,在展示了前面的奇美拉之后,我们主动向你,亲爱的读者(在另一端,奇美拉也可以很美;你只需要有正确的眼光)道歉和解释。
我们得出的第一个重要观察结果是,这段代码不能单独编译。如果我们尝试编译它,GCC 会报错,如下所示:
error: stray '%:' in program
15 | #define HH CAT(%, :)
ICC 会报错,如下所示:
error: "#" not expected here
DD $$ sizeof
MSVC 不喜欢这样,正如你所见:
error C2121: '#': invalid character: possibly the result of a macro expansion
Clang 也没有成功:
error: expected unqualified-id
4 | DD $$ sizeof
| ^
所以,基本上,编译器普遍同意它们不能就一个共同的错误消息或失败原因达成一致,但至少它们中的任何一个都不能编译那块代码。有一些非常具体的错误消息,比如#可能是宏展开的结果(但这些行的作者希望看到一个展开为#的宏,因为#define D #不管D是什么都不起作用)或者关于程序中那个散乱的%:的另一个消息。
所有这些宏展开等等只是引导我们走向宏。如果你,亲爱的读者,不熟悉 C 或 C++宏,请去拿一本关于它们的书,比如 Bjarne Stroustrop 的《C++编程语言》,因为这本书(你现在正在读的这本书)只涉及神话般的宏,而那本书(即由语言的父亲和创造者编写的 C++书)教你除非你真的、真的需要,否则不要使用它们。即使那样,也要稀疏地使用。
但让我们回到我们的代码。所有正规的编译器都有提供预处理后的 C++文件结果的能力,所以让我们检查一下我们的程序。通过使用带有–E标志的g++(或者使用相同标志的 Clang;如果你使用命令行,则 MSVC 使用/P标志;否则,它们将可以从你在 Visual Studio 中工作的项目的构建目录中访问),我们得到以下列表:
%: define $$ sizeof
%: define $ return
%: define $_ int
%: define _$ _()
%: define __(_...)$$($_[]){0,##_}/$$($_)-1
auto main()->$_<%$<::><class _>(_)->
$_<%$ __(_$,_(),_$,_(),_$,_(),_$) ;%>(__());%>
我们现在只展示必要的部分,并跳过编译器特定的行信息,这些信息也被添加到预处理输出中。所以,正如我们所见,预处理输出看起来像一个非常有效的 C++文件(尽管不太易读)...然而,令我们惊讶的是,我们可以在文件中看到几个活跃的define指令。它们以%:符号开头,经过双字符替换后,将转换为井号符号(#)并产生一个有效的程序。
为了进一步理解这里发生的事情,我们必须了解编译器如何处理宏。
C 编译器(当然也包括 C++)通过预处理器管理的方法过程展开宏,从对源代码进行标记化和识别替换宏开始。对于对象型宏,发生直接的文本替换,而函数型宏则涉及替换宏调用中提供的参数。函数型宏(那些有一对括号的宏)在参数预扫描过程中进行替换,其中宏参数在替换到宏体之前被完全展开。这个预扫描确保了参数内的嵌套宏调用被正确展开,并且最终宏体被重新扫描以捕获任何进一步的宏进行展开。
然而,当参数被字符串化或连接时,预扫描不适用,它也不会影响已被标记为不可重新展开的宏。这种行为要求为了正确展开,我们必须强制编译器对连接宏进行第二次遍历,如下所示:
#define CAT(x, y) CAT_I(x, y)
#define CAT_I(x, y) x ## y
前面的代码片段确保了所有必要的参数都得到了正确的展开。
特殊宏如 LINE 和 TIME 被独特处理,以防止进一步的意外展开。在所有展开完成后,预处理器确保在将最终代码传递给编译器之前,没有可展开的宏被遗漏。这一全面的过程确保了宏能够高效且正确地展开,即使在涉及嵌套宏和字符串化操作的复杂场景中也是如此。
现在我们已经尝试解释了宏替换是如何在一个对初学者来说并不那么明显的情况下工作的,现在是时候回到我们的程序并最终编译它了。正如你所能记得的,预处理后的源代码仍然包含一些包含 define 指令的语句。
现在,带着这些知识,我们将向你,亲爱的读者,揭示一个神秘的知识点。最终,这是一本关于 C++ 神秘主义的书。这个神秘的知识点被称为双重预处理。在继续之前,我们先简要了解一下编译器是如何处理你的代码的。
在编译 C++ 源文件的初始阶段,编译器首先进行预处理和编译。在预处理阶段,编译器展开宏(就像我们之前所展示的那样),处理条件编译指令(#ifdef、#ifndef 等),包含头文件,并移除注释,从而生成一个包含所有外部文件和宏已完全解析的完整翻译单元。随后,在编译阶段,预处理后的代码在称为词法分析的阶段被分解成标记,然后根据语言的语法规则进行检查,以构建解析树或抽象语法树(AST)。
这之后是语义分析阶段,编译器会验证类型、变量和函数的正确使用,并可能进行早期优化。最后,编译器将抽象语法树(AST)转换为中间表示(IR),为后续优化和最终机器代码生成做好准备,但这超出了本书所涵盖的主题。然而,我们愿意将那些对此话题感兴趣的人引导到著名的“龙书”,也称为《编译原理、技术和工具》,由 Alfred Aho、Jeffrey Ullman、Ravi Sethi 和 Monica Lam 所著。这是每个对开发编译器感兴趣或只是对学习这些技术感兴趣的程序员必读的书。
但回到我们的双重预处理技术。通过使用这项技术,我们将之前的预处理源文件传递给编译器,使用 Linux 中称为管道技术,在 Windows 中称为黑客技术的技巧。
以下是在 Windows 上完成此操作的命令:
cl /P test.cpp & cl /Tp test.i
第一部分是生成预处理文件,在 Visual C++领域,通常具有.i扩展名,第二部分将预处理输出放置在test.i中,并将其作为 C++文件编译(/Tp开关负责此操作)。结果是预期的test.exe,执行后正好符合预期。
在 Linux 下,命令序列也非常相似:
clang++ -E test.cpp | g++ -w -x c++ -std=c++20 -
在管道之前的第一部分,使用clang++生成预处理代码,利用 Linux 管道魔法,将其发送到g++,因为为什么不呢 ☺。在这个简单场景中,如果我们反过来使用,也不会有任何区别,因为这两个编译器是相辅相成的,它们共享基本的命令行选项,例如–x c++来指定要编译的代码是某种 C++代码,或者代码遵循的 C++标准版本。第二个编译器调用最重要的参数是最后的-符号,它告诉编译器从 stdin 读取代码,而不是从文件中读取。
就这样。使用这种神秘的技巧,我们可以编译我们认为不可能的代码,但...请不要使用它。这段代码近乎疯狂;它之所以被展示出来,仅仅是因为这本书是关于非常规、神话般的技术,针对的是高级 C++程序员社区,所以不要让这部分内容破坏你的编程风格,或者让你远离键盘。我们不想让读者在书的中途就放弃。相反,在接下来的章节中,让我们把心思放在纯粹的虚无上。
零的定义
零在数字中是独一无二的。这个概念在古埃及就已经存在,而且在古巴比伦的数字系统中找到了它的痕迹,作为占位符,但那时并没有将其视为真正的数字。
古希腊人对它有所反感,因为他们虽然最初知道其重要性,但由于一些哲学限制,他们最初并没有将其作为合适的数字使用,因为不是,存在与否,而是如何使无成为可能,这是古代市场中的问题。
突破发生在公元 5 世纪左右的印度,当时数学家布拉马古普塔将零定义为数字,并为其算术使用确立了规则。这一概念通过阿尔-花拉子米等人的作品传播到伊斯兰世界,然后传到欧洲,其中斐波那契在 12 世纪对其采纳发挥了关键作用。感谢维基百科。
零有几个重要的属性:它是加法恒等元,意味着将零加到任何数字上都不会改变该数字。任何数字乘以零的结果都是零,而除以零是未定义的。零是一个偶数,在数轴上作为中性元素,既不是正数也不是负数。在指数中,零的任何正数次幂都是零,而任何非零数的零次幂等于一。
这些属性使零在数学中成为基本元素,因此我们可以一致认为零是历史上最重要的(如果不是最重要的)数字之一;它的位置紧挨着π,或e,或i,这是我们所有人都知道的,是所有邪恶的平方根,或-1。
现在我们已经提供了没有其他数字像零这样的具体证据,我们也给出以下声明:C++是一种独特的语言。在其最新的迭代版本中,截至 2024 年,在当前的 C++中,有六种不同的方式将值初始化为零,以纪念零是最重要的数字。看看以下内容:
int z;
int main()
{
int z1 = 0;
int z2(0);
int z3{0};
int z4 = {0};
int z5{};
int z6();
}
让我们逐行分解,因为行数并不多:
-
int z; – 这里,一个全局变量z被声明为int类型。由于它是全局变量,编译器会自动将其初始化为0(如果全局int变量没有明确初始化,则默认为零)。这是我们值得信赖的。
-
int z1 = 0; – 复制初始化。z1变量被声明为int类型,并使用复制初始化设置为0。这涉及到在创建后将其值赋给z1。
-
int z2(0); – 直接初始化。z2变量被声明并使用直接初始化设置为0,这涉及到直接将0的值传递给int类型的构造函数。虽然它没有其他含义,但你可以理解这个概念。
-
int z3{0}; – 花括号初始化(统一初始化)。z3变量被声明并使用花括号初始化设置为0。这有助于防止诸如窄化转换等问题,并为初始化不同类型提供一致的语法。这是一种特殊的初始化,我们将在下一章稍后回到这种语法。
-
int z4 = {0}; – 复制列表初始化。变量 z4 使用复制列表初始化声明并初始化为 0,这是一种复制初始化和花括号初始化的组合。它与 z3 类似,但明确使用了赋值语法,当我们谈论像数字这样的简单事物时,实际上并没有什么区别。
-
int z5{}; – 值初始化。变量 z5 使用空花括号 {} 初始化,这被称为值初始化。对于像 int 这样的基本类型,这会导致 z5 被初始化为 0。这种方法通常用于确保变量被零初始化,而无需显式分配值。
这样考虑,让变量对应于数字零的数量,难道不美吗?所以,人们可能会问:为什么 C++的局部变量不是初始化为零(或其默认值)?
这个问题的答案部分是历史的,部分是实用的。由于 C++ 基于 C 语言,而 C 语言被设计得尽可能接近金属(硅),编译器不会浪费宝贵的处理器周期来初始化一个值为其默认值,如果后来它被用来设置程序员需要的不同值。亲爱的读者,正如一位最著名的侦探所说,这是基本的。
最后但同样重要的是,没有我提供更多细节,我真的希望你已经识别出 int z6(); 中的“最令人头疼的解析”。
“最令人头疼的解析”是一个用来描述 C++中特定问题的术语,这个问题涉及到由于语法中的歧义,编译器可能会误解对象声明的声明。它通常发生在你使用括号声明变量时,这有时可能被解释为函数声明而不是变量定义,就像在我们的具体例子中一样。
关于括号的旁白
现在我们在这里,我们必须提到,在这一章中提到了很多关于括号的内容。因此,我们在这里展示的是你在这本书的阅读过程中可能会遇到的最重要的一对括号。
请看以下两个函数:
static int y;
decltype(auto) number(int x) {
return y;
}
decltype(auto) reference(int x) {
return (y);
}
这两个函数看起来几乎相同,除了围绕 return 值的微小括号对。但这两个括号的存在造成了最大的区别。C++14 中引入的看起来很奇怪的 decltype(auto) 是一个类型说明符,它结合了 decltype 的功能和自动类型推导,允许你声明一个变量,其类型由初始化它的表达式确定,同时保留该表达式的某些属性。与基于值类别推导类型的 auto 不同,decltype(auto) 保留了基于的表达式的值类别(例如,引用或非引用)。
更平凡的是,函数 number 返回一个 int,而函数 reference 返回 int&。
为了验证我们之前所写的正确性,以下代码片段可以提供极大的帮助:
using namespace std;
if (is_reference<decltype(number(42))>::value) {
cout << "Reference to ";
cout << typeid(typename
remove_reference<decltype(number(42))>::type).name() << endl;
} else {
cout << "Not a reference: " << typeid(decltype(number(42))).name() << endl;
}
前面的代码片段检查了number函数提供的返回类型。正如其名匆忙暗示的那样,它将返回,嗯...一个数字。当使用 MSVC 编译并执行时,以下是代码的输出:
Not a reference: int
其他编译器也有相同的行为,只是它们不会打印出变量的完整类型,因为gcc和clang对于int类型只返回一个i,这不会那么引人注目。
现在,让我们检查以下代码序列:
if (is_reference<decltype(reference(42))>::value) {
cout << "Reference to: ";
cout << typeid(typename
remove_reference<decltype(reference(42))>::type).name() << endl;
} else {
cout << "Not a reference: " << typeid(decltype(number(42))).name() << endl;
}
这几乎与之前的完全相同,只是它使用reference方法而不是number。不出所料,执行的结果(再次,引用 MSVC)如下:
Reference to: int
因此,使用前面的代码,我们刚刚证明了成对额外的括号与decltype(auto)结合可以提供一些惊人的结果。警告一下。假设我们省略了decltype,如下所示:
auto reference(int x) {
return (y);
}
编译器随后忽略括号,只返回一个正常的数字。C++标准在[dcl.type.decltype]部分指定了这种行为,作者强烈建议阅读它,以便全面了解幕后发生的事情以及合理的推理。
现在,因为我们都是 C++程序员,总是追求速度、高质量和清晰的代码,你可能会问为什么我们必须重复代码来识别我们是否有引用。难道不能像以下这样写就完全有效吗?
template <typename T>
void printType(T&& var) {
if (std::is_reference<T>::value) {
if (std::is_lvalue_reference<T>::value) {
printf("lvalue ref ");
} else {
printf("rvalue ref ");
}
printf("%s\n", (typeid(typename
std::remove_reference<T>::type).name()));
} else {
printf("%s\n", typeid(var).name());
}
}
这几乎与上面的一样(上面上面就像上面一样,但指的是实际上面之前的一个上面),除了我们增加了一个额外的检查来验证引用的类型(并且用printf代替了std::cout,因为它生成更干净的汇编代码,并且将其放在函数体中)。确实,让我们假设我们将其放入这个上下文中并调用以下代码:
printType(number(42));
printType(reference(42));
我们得到了正确和预期的输出:
int
lvalue ref int
作为旁注,我们用其他,不那么小而柔软的编译器也得到了相同的结果。
这个函数模板使用前向引用(T&& var)来处理左值引用和右值引用,使其能够推断并保留传递变量的引用类型。通过使用类型特性库,我们使用is_reference
如果它是一个引用,我们使用remove_reference
如果它不是一个引用,我们就直接打印变量的类型。这种方法之所以有效,是因为 C++中的完美转发机制,允许T被推导为传递变量的确切类型,保留其引用性质。
请注意,必须使用转发引用T&& var;如果我们只使用T var,它对于引用类型就不会以相同的方式工作。这是因为,在这个形式中,T会被推导为非引用类型,所以函数内部的var始终是原始参数的副本,而不是引用。
作为对你这位亲爱的读者的额外礼物,这里有一些编译器(在我们的例子中是 GCC)的汇编输出摘录。你可以看到它如何生成两个不同的函数,最重要的是,这些函数内部发生了什么:
|
void printType <int>(int&&):
|
void printType <int&>(int&):
|
|
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edi, OFFSET
FLAT:typeinfo
call std::type_info::name()
mov rdi, rax
call puts
nop
leave
ret
|
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov edi, OFFSET
FLAT:typeinfo
call std::type_info::name() mov rdi, rax
call puts
nop
leave
ret
.LC0:
.string "lvalue ref "
|
表 9.1:比较各种 printType 实例化的汇编列表
我们可以看到两个函数的printType实例化,每个函数返回的类型都有两个,以及在每个实例中,各种类型特性调用都成功地在源代码级别实现,从而消除了不必要的分支。我们还可以观察到不必要的字符串的删除(现在生成的代码中找不到"rvalue ref",因为编译器确定包含它的分支在最终代码中无处可寻)。
C++难道不美吗?
C++uties
是时候让这些文字的作者承认一些事情了。他已经厌倦了试图在 C++之美竞赛中获胜的丑陋代码。无论我们如何努力说服自己,之前几节中展示的代码是美丽的,值得记住,好吧,它不是。它是丑陋的,令人厌恶的,亲爱的读者,请忘记你曾经不得不阅读这样的东西。抱歉。
从现在开始,我们庄严地承诺,我们不会再有任何恶作剧,只会用美丽的代码来对待你。没有更多的丑陋宏,没有更多的可疑替换,没有更多的神秘技巧。只有纯粹、快乐、可爱的 C++。
由于我们(作为美丽 C++代码的编写者)的自我革新,我们向您展示下一个程序,这可能是你能得到的最好看的程序之一:

请为了简洁,我们省略了std::string、std::cout和std::unique_ptr的包含。谁说 C++不能可爱?
但遗憾的是,前面的代码并不被广泛认为是标准的 C++(似乎编译器开发者之间对于在源代码中应考虑哪些 Unicode 标识符为有效并没有明确的共识,尽管最新的 C++标准中提到了[tab:lex.name.allowed]),但并非所有的希望都破灭了,因为 GCC 接受它。也许在他们开发者队伍中有一个拥抱者。
作为旁注,展示的代码并没有做太多,只是根据熊的营养需求、饮食要求和与熊国中各种饮食模式和当前烹饪趋势的关联,给熊喂食适当的食物。我们没有交付一个可爱的小程序,一个可能是最美丽 C++代码竞赛的获胜者吗?
我们鼓励我们亲爱的尊敬的读者阅读一些书籍,如果他们想让他们的程序遵循常识性指南,易于阅读、稳定、易于维护并符合最新标准。遗憾的是,这些书籍中没有一本详细说明如何编写有趣的程序,因为编写有趣的程序或为了乐趣编写程序涉及不同的思维方式,而且很少为了利润而做。
编程可以是一种艺术形式,产生令人惊讶和愉悦的代码,拥有代码可以包括彩蛋、幽默的输出,应用俏皮的用户交互或非同寻常的视觉呈现。俏皮可能简单到使用表情符号作为标识符(如我们的熊示例)或制作逻辑古怪的应用程序。有趣的编程通常拒绝正式实践的僵化,转而选择创造性的解决方案,这些解决方案可能只是为了乐趣而不太高效或过于复杂,例如制作一个加密的代码片段,仅仅因为我们觉得它很有趣。
编程中的乐趣也可以来自解决引人入胜的谜题或探索非常规的编程范式(如函数式、晦涩的语言如 Brainfuck 或 LOLCODE),或者纯粹出于好奇心来构建项目。
虽然关于“美观”或“简洁”代码的正式书籍强调正确性、安全性和可读性,但有趣的编程打开了即兴、艺术和娱乐的大门,因此我们本章的最后一项活动就是参与最后一项活动。它很短,很可爱,看起来像是从童话故事中出来的东西。因为有熊。因为谁不喜欢熊呢?
摘要
并非所有闪亮的东西都是金子,也并非所有看似令人兴奋且具有复杂功能的代码必然是高质量的。光鲜、复杂的代码有时会掩盖定义良好编程实践的基本品质。好的、稳定的代码通常以其简单性和可预测性为特征,而不是其风格。与更有趣的构造相比,这种类型的代码可能看起来平淡无奇或平凡,但正是这种简单性确保了其健壮性和易于理解。当你不得不这样做时,请尽量编写无聊、简单的代码,因为半年后阅读起来会容易得多,但每次你有机会时,请尽量在你的有趣侧项目中加入一两只熊。除非你也打算阅读它。
在下一章中,亚历克斯将发起一场十字军东征,倡导正确使用现代 C++库,以驳斥 C++库也陷入石器时代的神话。
第十章:在 C++ 中没有现代编程的库
或者也许有太多,而且它们并不 容易获得?
C++ 是现代软件开发中使用最古老的语言。尽管有多次尝试取代它,但它仍然在偏好和实用性方面保持领先。然而,这种遗产也带来了自己的挑战。开发风格随着时间的推移而演变,包括更容易被开发者理解的结构,用更少的代码解决问题,或者有时看起来更美观。
任何技术生态系统中的一部分是可用的库列表,包括并补充标准库。由于 C++ 已经存在很长时间,它有库。然而,它们与其他技术使用者的体验相比如何?它们是否满足现代开发者的需求和期望,这些开发者可能正在考虑市场上可用的替代解决方案?
这些是我们接下来将要探讨的一些问题。
在本章中,我们将涵盖以下主要主题:
-
现代开发者体验
-
常见需求
-
兼容性
-
供应链安全
我们如何判断?
当思考像本章标题这样的问题时,我们面临着选择的大挑战。任何项目的库选择完全取决于上下文,完全取决于项目试图解决的问题。当然,有些功能无论项目做什么都是需要的,比如日志记录或单元测试,但除了这些之外我们应该选择什么?
最后,将 C++ 中的 Web 开发与 Java 中的 Web 开发进行比较似乎非常不公平,就像将 C++ 中的系统编程与 Java 中的系统编程进行比较一样。C++ 并没有广泛用于 Web 开发,Java 也没有用于系统编程。事实上,C++ 很早就有了自己的细分市场,尽管它已经被 Java、C#、Rust 和 Python 慢慢侵蚀,但它仍然在游戏开发、固件、高频交易、工程应用、汽车、系统编程以及可能的其他用例中占据着阵地。其他语言在这些领域几乎没有影响力,这有很多很好的原因,与 C++ 的灵活性、性能和控制力有关。
另一个问题是为上述语言(尤其是 C++)存在的库的数量。值得尊敬的 C++ 的一个优点是程序员有足够的时间开发像 Boost 这样的巨无霸库,它在 Java 或 C# 的世界中(不包括标准库)没有对手,在 Python 的世界中绝对没有竞争对手。我们可以争论说,JavaScript 有类似 React 及其周围生态系统的类似东西。然而,在可用的库数量方面,C++ 看起来占主导地位。
这些观察让我们意识到一个需要考虑的特征:这些库集合有多现代?我们希望从一门现代编程语言及其生态系统中得到什么?从这一角度来看,C++处于什么位置?让我们来探讨这些问题。
现代开发者的体验
让我们暂时跳出 C++的世界,变成一个观察其他技术使用者的“旁观者”。我们将陪伴他们从开始一个新项目,到后来向团队添加新成员。可能的第一步是他们将启动一个 IDE,创建一个新项目或项目结构。IDE 可能是来自微软的,如 Visual Studio .NET 或 Visual Studio Code,或者是来自 JetBrains 的,如 Java 的 IntelliJ IDEA,Python 的 PyCharm,或 C#/.NET 的 Rider。一小部分像我这样的奇怪程序员会使用命令行和 neovim。更奇怪的程序员甚至会使用 Emacs。我当然是在开玩笑;我们都知道真正的程序员会利用大气电的变化来直接操纵位,正如著名的 xkcd 漫画《真正的程序员》(xkcd.com/378/)所展示的那样。然而,让我们回到我们的故事。
在创建新项目时,IDE 会建议安装一些集成和库。一旦创建,项目就可以运行,尽管它不会做很多有用的事情。在创建过程中,将选择一个源代码控制仓库,很可能是基于 git 的现有仓库。然后可以在本地提交项目并将其推送到共享仓库。
在完成这些步骤之后,团队成员将有一些事情要做:启动 IDE,在本地克隆仓库,并让 IDE 按照项目配置获取必要的依赖项。
在这一点上,项目可能已经包含了日志记录和单元测试库。让我们暂停一下,来检查所使用的库。
Python 在其标准库中提供了日志记录功能,而 Java 有开源的 Log4J,.NET 则使用微软构建的 Microsoft.Extensions.Logging 或开源的 Log4Net。对于单元测试,Python 提供了单元测试和模拟的标准实现,但程序员往往更喜欢像 pytest 这样的开源扩展(docs.pytest.org/en/stable/)。Java 需要单元测试库,通常是 JUnit 或 TestNG,以及模拟库,通常是 Mockito 或 JMock。最后,.NET 提供了一个标准的测试框架,但知识渊博的技术负责人更有可能选择 NUnit 或 xUnit,以及 Moq。
C++在这里处于什么位置呢?嗯,C++中日志库的选择并不少,这并不令人惊讶,因为日志系统与 C++同时成熟。我们可以认为日志库几乎是标准化的,它们在 API 上有着非常相似的行为和功能,只有细微的差别。除非你正在使用自带日志功能的技术,否则选择一个 C++的日志库几乎是一件非常困难的事情。我想,许多项目都使用 Boost 及其内置的日志功能。快速浏览 GitHub 可以发现,spdlog库(github.com/gabime/spdlog)有 24k 个星标,尽管它只支持 C++ 11。
单元测试怎么样呢?这是一个有趣的话题。C++中存在多种形式的单元测试库。有 Google 启动的 GTest 和 GMock,这两个库具有通常的功能集。同样,CppTest 遵循标准的 xUnit 结构进行单元测试。然后是doctest(github.com/doctest/doctest),这是一个无依赖的单头库,这也是为什么我更喜欢用它来展示示例和本书伴随的代码。最后,值得一提的是Cpputest(cpputest.github.io),因为它允许嵌入式开发,这得益于它的小型足迹和识别内存管理问题的功能。对于模拟,FakeIt(github.com/eranpeer/FakeIt)是另一个非常容易集成的单头框架。
所有这些库都可能会通过包管理器来设置,该管理器将依赖项列表存储在一个文本文件中,该文件可以是纯文本、标记格式或脚本。这个文件会被推送到中央仓库,并可用于重新创建依赖项,包括安装的库所需的依赖项。
在开发过程中,如果团队需要额外的库,他们可以简单地将其添加到依赖项中。由于安全考虑,这个过程在企业环境中可能更为受限:可能有一个预先批准的包列表,可能每个包都需要获得批准,或者可能只有特定的人可以添加依赖项。
无论哪种方式,当新开发者加入时,他们都会克隆中央仓库并运行安装命令,通常是通过在 IDE 中加载项目并让它自行处理,一切应该都会顺利。这就是我们飞行的故事的结尾。
让我们深入了解如果您使用包管理器时幕后发生的事情。由于我经常在 Ubuntu Linux 上结合使用命令行和 neovim 进行编程,我对这些技术中的每个技术的处理过程都有一些了解。对于 Python,建议使用虚拟环境,这样操作系统就不会被所有所需的库所污染。一个名为 pipenv 的工具结合了标准库提供的 pip 包管理器和 venv 虚拟环境,以便于轻松设置。命令行步骤如下:
pipenv init
pipenv install [library name]
在新环境中,你可以简单地运行以下命令来安装所有依赖项:
pipenv install
Java 和 .NET 有类似的流程,只是没有虚拟环境。它们都使用开源的包管理器;对于 Java,使用 Maven 或 Gradle,对于 .NET,则是 NuGet。
对于所有这三项技术,都存在一个所有库的中心位置:Python 的 Pypi(pypi.org/),Maven 的 Maven Central(mvnrepository.com/repos/central),以及 NuGet 网站(www.nuget.org/)。如前所述,大型公司可能会更加关注所使用的库,并在将第三方代码用于其系统之前进行更彻底的安全检查。这些公司往往提供自己的仓库,例如,在 Java 中使用 Artifactory(jfrog.com/artifactory/)。
因此,使用事实上的标准工具,任何使用该技术的开发者都可以轻松地搜索库、更新它们并在新环境中安装它们。
自从我在 2000 年代初成为 C++ 程序员以来,C++ 已经走了很长的路。当时,添加一个新的库需要下载所需目标的二进制文件,或者更可能的是,自己编译它,这本身也带来了一系列挑战。如今,C++ 通过 Conan 和 vcpkg 正在缩小差距,许多程序员在 C++ 上的体验可能与我在 Java、Python 和 .NET 上描述的类似。大型公司的程序员最有可能认识到这一点,因为组织提供了一个包含经过批准的库的 Conan 或 vspkg 仓库,可以轻松找到并安装。将新的库添加到白名单可能有些麻烦,可能需要很长时间,但这是可以理解的。
没有这个基础设施,事情并不容易。库不是在单一位置提供的,工具似乎也不太好用。至少这是我的经验:当我尝试在一个简单的项目中使用 Conan 时,它给出了一堆错误,而我不知道如何修复它们。虽然我不喜欢 Maven,因为它在设置最简单的情况下也会无端下载很多包,但它始终如一且可靠,这正是我们从包管理器中需要的。所以,我恐怕不得不这么说:尽管有尝试将 C++包管理与其他技术相提并论,但它仍然感觉还不够成熟。
话虽如此,我相信许多在大公司工作的开发者不会感受到这些问题。因此,我们假设包管理器运行良好。接下来我们该做什么呢?根据项目和技术的不同,我们可能需要更多的库来帮助我们。接下来,让我们看看几个类别。
常见需求
这里是一些许多开发者都有,但顺序并不重要:
-
数据库连接、读取和写入
-
CSV 文件处理
-
压缩,例如,gzip
-
日期/时间增强
-
各种计算,例如:矩阵、复数、数学方程求解等
-
UI,用于桌面和移动应用
-
HTTP 客户端
-
HTTP 服务器
-
异步编程
-
图像处理
-
PDF 处理
-
后台任务
-
密码学
-
网络通信
-
序列化
-
发送电子邮件
-
JSON 处理
-
配置文件读取和写入:ini、yaml等
可以肯定的是,对于这些需求,都有相应的 C++库。让我们随机挑选几个:
-
zlib 用于 zip 和 gzip 压缩
-
Rapidcsv (
github.com/d99kris/rapidcsv) 用于 CSV 处理 -
对于数据库访问,可以使用 ORM 如 TinyORM (
www.tinyorm.org/) 或 SQLPP1 (github.com/rbock/sqlpp11),用于类型安全的 DSL 查询和结果 -
Poco 库 (
pocoproject.org/) 包含大量用于网络、发送电子邮件、数据库访问、JSON、OpenSSL 等的实用工具 -
UI 库包括 Qt、GTK、wxWidgets 或 Dear ImGui
-
HTTP 客户端由 Boost、Curl++或 cpp-netlib 实现
-
为了实现 Web 应用,Crow (
crowcpp.org/master/) 受 Python 的 Flask 启发,而 Oat++ (oatpp.io/) 和 Drogon (drogon.org/) 为 Web API 和微服务提供了快速解决方案
我们可以继续说,但我认为我们已经表达了我们的观点:C++ 有库。它有 很多库。其中一些库启发了其他技术的实现,而另一些则从替代方案的最佳解决方案中汲取了灵感。在速度和低内存占用方面,C++ 实现的优势是显而易见的。其中一些库将许多功能打包到几百 KB 中。而且,存在许多仅包含头文件的实现,这使得它们具有可移植性和简单性。
C++ 也有框架。我们之前已经提到了一些,我们还可以添加其他一些:GTK、QT、Boost、POCO、WxWidgets 和 Unreal Engine,例如。库和框架的列表在网上有维护,我找到的最好的是 awesome-cpp(github.com/fffaraz/awesome-cpp)。
即使是细分编程风格和实践也有它们的库:
-
不变集合?使用 Immer(
github.com/arximboldi/immer)。 -
响应式编程?使用 RxCpp(
github.com/ReactiveX/RxCpp)。 -
微服务?当然,CppMicroServices(
github.com/CppMicroServices/CppMicroServices)可以帮忙(不,微服务不是细分领域,但它们很少用 C++ 实现)。 -
网络汇编?是的,有 Emscripten(
github.com/emscripten-core/emscripten),这是一个不错的选择。 -
无服务器?有 aws-lambda-cpp(
github.com/awslabs/aws-lambda-cpp)。
我想现在很明显,我们很难找到一个领域,C++ 在其中没有库或框架。然而,我们能使用它们吗?
兼容性
假设你找到一个非常有前途的库,并决定将其添加到你的项目中。它是否工作?它会 如何 工作?
这就是 C++ 分裂展现其丑陋一面的地方。以下任何不希望发生的事情都有可能发生:
-
该库使用的 C++ 版本比你的代码新,并且你无法编译它
-
图书馆使用的 C++ 版本比你的代码旧
-
你会因各种原因收到很多警告
-
该库与你的 C++ 版本兼容,但其接口使用较旧的构造
-
该库在你的项目目标的所有平台上都不工作
-
该库与你的编译器不兼容
-
该库与你的编译过程不兼容
-
该库与你的项目目标的所有平台兼容,但在特定平台上存在不同的行为或性能问题或错误
我希望您永远不要遇到上述任何问题。此外,值得一提的是,在我们使用的比较技术中,您不太可能遇到这些问题:Python、Java 和.NET 都没有这些问题。嗯,几乎都没有;例如,创建使用 C++模块的 Python 程序可能会遇到相同的问题。或者,您可能创建了一个使用操作系统原语的 Java 程序,它在不同的操作系统上会有不同的问题。总的来说,在这些领域,人们一直在努力实现一致性。
公平地说,成熟的 C++框架和库,如 Boost 或zlib,也做出了同样的努力,并提供了一致的行为。只是在使用虚拟机的语言中创建一致的库要容易得多。
假设你的库运行良好:没有警告,没有奇怪的问题,并且它与你的代码和工具集很好地协同工作。最后一个问题是:我们能信任它吗?
供应链安全
对于关注的人来说,软件一直存在安全问题应该是显而易见的。随着软件使用的持续增加,它覆盖了我们日常生活的更多领域,这个问题正在变得越来越严重。
提高安全性的两个方面包括:网络安全专家,他们可以找到漏洞并构建保护工具,以及软件开发者,他们需要在发布前找到安全问题并管理相关的风险。我们知道没有绝对安全的软件,但我们也知道事情可以变得更好。
这种增加保护的具体领域是管理我们使用的库可能带来的潜在漏洞。有两种情况:要么漏洞是无意中引入的,要么是由恶意行为者故意注入的。
客观地说,任何技术都可能发生这种情况,许多知名的 C++库都由使用它们的大型公司进行安全审查。此外,如果您在大型公司工作,您有团队处理所有这些关注点。然而,并非所有开发都在大型公司进行,并非所有库都受到同等对待,正如我们将通过xz 后门案例所看到的那样。让我们暂时讨论第二种情况。恶意行为者可以通过多种方式注入漏洞:
-
他们可以通过向开源项目的代码做出贡献来实现这一点。
-
他们还可以分叉开源项目,并在有用的功能中添加漏洞。
-
他们有时甚至可以逃脱成为开源项目维护者的角色,然后在有用的功能中注入漏洞。请参阅我在
mozaicworks.com/blog/xz-backdoor-and-other-news上详细评论的 xz 后门故事。 -
他们还可以用有漏洞的版本替换二进制文件,例如,通过在另一个网站而不是原始网站上提供它或成功劫持发布过程。
-
他们也可能尝试劫持下载,例如,通过 DNS 攻击。想象一下一个潜在的攻击者成功修改了你的本地主机文件,将你的仓库 URL 指向互联网上的另一个 IP 地址。
前面列表中的所有项目都是严重问题。在大公司中,安全部门和 IT/Ops 通常会担心这些问题,但在小公司中,你可能需要额外注意。我们知道的解决方案是验证所有二进制文件与其数字签名或哈希值。虽然编程语言和 Linux 的包管理器会自动执行此操作,但手动从 GitHub 下载二进制文件需要手动验证签名,希望签名与库文件一起提供。
第一种情况甚至更复杂。你怎么知道一个库是否有漏洞?对于开源代码,普遍的看法是,许多眼睛查看代码就能发现所有问题。然而,这非常依赖于贡献者的数量和他们的专业知识。
提到的 xz 后门案例令人毛骨悚然,特别是问题是由开发者 Andres Freund 发现的,他通过微基准测试期间sshd使用过多的 CPU 而产生了怀疑(mastodon.social/@AndresFreundTec/112180406142695845)。这使得开源库过度工作的维护者问题变得明显,但很快又回到了隐秘状态。
让我们假设大多数开源库不会被获得维护者状态的恶意行为者攻击。漏洞仍然可能逃逸,尤其是在 C++中,因为它在安全性方面有自己的挑战。一个小团队需要了解他们使用的库报告的漏洞,或者许可自动为他们执行此操作的许可证安全工具。
假设一切正常,仍然最好将应用程序中使用的库列表存储起来,这样运维人员就知道定期检查所有库中的漏洞。在这个领域,推荐的实践是为你的产品创建所谓的软件物料清单(SBOM)。SBOM 包含所有库及其依赖关系的列表。有特定的工具可以创建 SBOM 并基于它们扫描漏洞;然而,大多数工具都与 docker 容器一起工作。例如,考虑 Grype(github.com/anchore/grype)及其配套工具 Syft github.com/anchore/syft)。
这引出了本章的结论。
摘要
在本章中,我们了解到 C++拥有众多库和框架,涵盖了我们所可能需要的所有功能。与其他技术相比,获取它们的过程并不简单。由于它们不在一个中心位置,因此它们并不容易被发现,并且可能带来额外的麻烦,例如与编译器或旧代码风格的不兼容性。我们在这章中学到了这一点。
同样地,与其他技术一样,C++库容易存在漏洞,并可能受到供应链攻击。为了防范这些攻击,团队需要跟上发现的漏洞流,并在下载时验证二进制文件。正如我们在本章所学,额外的审计和扫描总是很有用的。因此,大型组织在安全性方面具有优势,因为它们有专门的团队来关注这些问题,但这是以灵活性为代价的。
那么,C++中是否有现代编程的库呢?当然有,只是它们更难找到,并且与其他广泛使用的技术相比兼容性较差。
在下一章中,我们将探讨 C++是否与其自身以及更广泛的技术向后兼容。
第十一章:C++向后兼容...甚至与 C
当然,还有 C,B...甚至 A...和@ 也许吧?
起初,有语言,语言在 BCPL 中使用。发音为 Basic Combined Programming Language,而不是巴尔的摩县公共图书馆。它是第一种以铁的语法统治编译器王国的语言,经过几轮迭代。然而,时间的考验并不善待它。新特性、教义和语法通过,不久一个新的继承人从比特中崛起:B。不是很多人考虑B的无类型性质和优势,不久B就消失了,因为一个新的编程语言竞争者取代了B:C 1。
1 www.bell-labs.com/usr/dmr/www/chist.html
其余的都是历史。C成为了底层系统编程的事实上的语言,其语法渗透到上个世纪和这个世纪的几乎所有流行编程语言中(你好,花括号)。C就像胶水,将各种编程语言粘合在一起,在计算机王国中执行神圣的仪式。
程序员们看了看,觉得它很好。
除了一个普罗米修斯 2 ,一个将类引入C的人,很快就会给人们带来C with classes和Cfront,这是第一个能够消化 C++代码并吐出 C 代码的编译器,遗憾的是它已经从我们的领域消失,但它的遗产仍然存在。这种语言,数十个 C++标准兼容的编译器(每个在其时代都是标准兼容的...或多或少),数百个未定义行为案例,以及过去三十年中标准的各种迭代(最后一个有效的是 C++23,而委员会正在努力最新的 C++26)都在这里,构成了我们所有人都爱的编程语言:C++。
2 是的,Bjarne,我们在谈论你
本章将让你坐得如痴如醉——就像高峰时段的交通——以下是一些主题:
-
C++真的与 C 向后兼容吗?
-
C++真的与 C++向后兼容吗?
C 真的与 C++向前兼容吗?
本章将进行一些探索,涵盖大多数关于 C++是否真的与 C 向后兼容的陈词滥调。正如我们几十年来被导师、教师和培训师灌输的那样,C++主要与 C 向后兼容。这意味着大部分 C 代码可以在 C++中经过少量修改后编译和运行,因为它们具有相似的语法和标准库。
<banalities reason="these were discussed somewhere else">
C 和 C++ 虽然可能关系密切,就像一个功能失调的家庭中的两个兄弟姐妹,但仍然存在许多差异,导致在兼容性方面产生爱恨交加的关系。然而,随着时间的推移,这两种语言已经显著分化。根据核心规则,C 在类型规则上更为宽松,特别是关于指针的部分,允许像隐式指针转换这样的结构,而 C++ 则严格禁止。例如,在 C 中,你可以将 void* 赋值给任何其他指针类型而不需要类型转换,而 C++ 则会要求显式转换以保持类型安全。
同样,C++(尤其是语言的新版本)在枚举方面有更严格的规则,使它们成为不同的类型,而在 C 中,枚举只是被简单地视为 int 。这种差异扩展到许多其他领域:变量初始化、类型限定符,甚至内存分配(alloc())在两种语言中工作方式也不同。这尤其适用于像 malloc、calloc* 等函数。在 C 中,它们只是普通的函数,就像你早晨的一杯咖啡一样平凡,但如果它们出现在任何 C++ 代码中,突然就像打开了一个通往开发者地狱七圈的传送门。这在代码审查期间尤其如此,当时年轻的 C++ 程序员们紧张地抓着键盘,指出你不应该在有完全有效的 new 和 delete 的情况下使用 C 函数。他们还可能问为什么你需要分配内存。现在是 2024 年。我们有智能指针。或者至少,如果你能控制自己,我们恳求你不要使用 C 风格的类型转换。那是因为 C++ 标准在十多年前就引入了完全功能的类型转换运算符。
根据刚才讨论的内容(但不仅限于此),虽然年轻的 C++ 强调更严格的类型规则和更可预测、更安全的编程实践,但老牌的 C 仍然是一个实用且灵活的选项,尽管风险更大。
让 C++ 程序员感到恐惧的是,这两种语言经常一起使用,尤其是在需要使用用 C 编写的库的 C++ 项目中,但确保两种语言之间的代码兼容性。哦,软件开发的噩梦。
为了帮助上述情况,开发者通常必须使用 extern "C" 声明,这防止了 C++ 的名称修饰,并允许在不同方言编写的库之间平滑地链接函数。这是因为,尽管它们有相似之处,但 C 和 C++ 编译器生成的目标文件的处理方式不同(是的,我们谈论的是名称修饰)。
在之前的平实事实之上,此外,还有许多 C99 特定的关键字,如_Alignas、_Alignof、_Atomic、_Bool、_Complex、_Generic、_Imaginary、_Noreturn和_Static_assert,它们不是标准 C++的一部分,尽管一些可能有 C++等价项或可以通过编译器扩展获得。为了使生活更有趣,这些关键字实际上从 C23 开始就被废弃了,这是为了使 C 语言更接近 C++。
我们甚至还没有提到指定初始化器。对他们来说太晚了。
</banalities>
然而,C 语言的设计肯定不是基于这样的想法:将来会有一种名为 C++的编程语言。这就是为什么下面的 C 代码完全有效,而所有遵纪守法的 C++编译器(以及纯 C++开发者)都会对它感到难以忍受:
int template(int this) {
int class = 0, using = 1, delete;
if (this == 0) return class;
if (this == 1) return using;
for (int friend = 2; friend <= this; friend++) {
delete = class + using;
class = using;
using = delete;
}
return delete;
}
虽然看起来像是 C 语言深坑中的噩梦,但不管怎样,这段纯 C 代码是完全有效的,而且令人惊讶的是,它甚至可以计算斐波那契数列。但让我们不要对你这个亲爱的读者太过苛刻(尽管考虑到你在这本书中不得不忍受的其他神话般的代码片段,直到你到达这一章,我几乎怀疑这段代码可能会给你带来震惊……不用担心,这是倒数第二章,所以痛苦几乎结束了……然而:你还记得在第九章中我们定义 main 为 return,并将 return 定义为 main 吗?),让我们再介绍 C 语言的一个有趣特性,这个特性没有被移植到 C++中。
不,我们不是在谈论变长数组,尽管仅仅由于void funny_fun(int n, int array[][*])这种特殊的语法,它们也值得有一系列自己的规则(这种语法是说明如何在函数原型声明中传递二维变长数组的例子)。变长数组在上一个十年中已经被权威人士详细讨论过 3,他们比我们这个谦逊的人更有资格讨论这个问题。不管那些讨论如何,它们(变长数组)仍然没有进入 C++标准,所以这个决定背后肯定有合理的理由(不仅仅是潜在的与栈相关的問題,假设理论上无限的栈,以及非编译时类型推导与变长数组可能引起的类型混乱,还有在 C++中存在处理这种特定用例的更好机制)。
3 www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3810.pdf
4 nullprogram.com/blog/2019/10/27/
对于本书的这一章节,我们将讨论一些作者认为非常有用的 C 特定特性,但遗憾的是,这些特性在其原始形式中仍未纳入 C++标准。
参数列表的魔法
让我们从最简单的函数开始,那就是int foo()。这不是一个非常复杂的函数,但它按照预期完成了工作,无论那是什么。
当以 C 语言编译时,空参数列表的函数意味着该函数可以接受一个未指定的参数数量,如果传递参数给函数,可能会导致歧义和潜在的错误。这是因为编译器不会强制参数约束。
在 C 语言中,要明确指定一个函数不接受任何参数,我们必须在参数列表中使用void,例如int foo(void),这清楚地表明该函数不接受任何参数,传递任何参数都会导致编译时错误。
相比之下,C++通过将空参数列表视为指定void的等效方式来简化这一点,这意味着 C++中的int foo()表示一个不接受任何参数的函数,就像int foo(void)一样,这使得在 C++中使用void变得可选。
这使得 C++的语法更简洁,其中无参数的函数可以简单地通过空括号声明。虽然 C 仍然需要void以保持清晰和正确性,但 C++允许两种形式,尽管典型做法是省略void并使用更简单的int foo()。这不是很整洁吗?
然而,如果我们想给我们的函数添加一些参数呢?让我们以int foo(int array[static 10])的形式修改它。
int foo(int array[static 10])的声明是 C99 引入的特性,它向编译器提供了有关传递给函数的参数的额外信息,特别是在处理数组时。
在这种情况下,数组参数中的static关键字向编译器表明,fun函数预期将使用至少包含 10 个元素的数组进行调用。数字 10 指定了将传递给函数的数组的最低大小,这可以帮助编译器做出某些假设,例如基于数组的保证大小启用优化。
此外,当以这种方式在数组参数中使用static时,编译器假定数组指针不能是NULL。这是因为空指针意味着没有有效的元素,这违反了数组必须至少包含 10 个元素的条件。这提供了一层额外的安全性和清晰性,因为它消除了函数在继续之前检查数组是否为NULL的需要,这可以减少运行时开销。
近期版本的clang(基本上是 3.1.0 以上的版本)甚至会在你调用具有这种非常特定声明的函数时发出警告,使用臭名昭著的NULL指针:
warning: null passed to a callee which requires a non-null argument
很遗憾,这个非常实用的特性并没有被任何 C++标准所采纳,而且并非所有的现代 C 编译器都能消化它(我们无法说服 MSVC 成功编译这段代码,无论请求的 C 标准是什么)。无论如何,对于不针对这些平台的程序员来说,在需要的时候这确实可能是一种巨大的帮助。
另一个仅限于 C 程序员圈子中的实用特性是 C99 中引入的restrict关键字,它是一个类型限定符,为编译器提供有关优化涉及指针的内存访问的提示。它告诉编译器,应用了restrict的指针是当前作用域中访问引用对象(内存)的唯一方式。这允许编译器进行积极的优化,因为它可以假设没有其他指针会别名或引用相同的内存。
当你在指针上使用restrict限定符时,你是在向编译器承诺,在指针的生命周期内,它所指向的对象不会被任何其他指针访问。这使编译器能够通过避免不必要的内存重新加载或重新检索来生成更高效的代码,否则这些操作可能由于潜在的别名(多个指针指向同一内存)而需要。
没有使用restrict,编译器必须假设任何两个指针都可能引用相同的内存,这限制了它优化代码的能力。
例如,让我们考虑以下代码:
void update1(int *a, int *b) {
*a = *a + *b;
*b = *b + *a;
}
在这种情况下,编译器必须假设a和b可能会相互别名,因此它可能需要从内存中重新加载a或b。
这是它的restrict对应版本:
void update2(int *restrict a, int *restrict b) {
*a = *a + *b;
*b = *b + *a;
}
在这种情况下,我们告诉编译器a和b不会别名,因此它可以优化而不必担心内存别名。
以下列表(由GCC 14.2 生成,使用–O3优化)是两个不同函数生成的汇编代码,其中包含一些解释:
update1:
mov eax, DWORD PTR [rsi]; Load b from [rsi] into eax
add eax, DWORD PTR [rdi]; Add a from [rdi] to eax
mov DWORD PTR [rdi], eax; Store eax into [rdi] (a)
add DWORD PTR [rsi], eax; Add eax to [rsi] (b)
ret ; Return
这是另一个例子:
update2:
mov eax, DWORD PTR [rsi]; Load b from [rsi] into eax
mov edx, DWORD PTR [rdi]; Load a from [rdi] into edx
add edx, eax ; eax + edx (result in edx) - a
add eax, edx ; edx + eax (result in eax) - b
mov DWORD PTR [rdi], edx; Store edx into [rdi] - a
mov DWORD PTR [rsi], eax; Store eax into [rsi] - b
ret ; Return
令人惊讶的是,带有restrict的那个版本有更多的指令,但一旦我们查看生成的代码,我们就可以轻松地发现restrict关键字的效果。据称,函数的参数位于由[rsi]和[rdi]指向的内存位置。第一个(没有restrict)必须在内存中完成所有加法工作,这导致代码略微变慢,而第二个可以将这些昂贵的操作委托给两个基于寄存器的超快速加法操作。
此外,这两个标准之间的一大区别是,第二个(update2,带有restrict)可以假设第二个参数的值在第一次操作后不会改变,因此精心设计的寄存器初始化和加法可以发挥至关重要的作用。第一个需要考虑a = *a + *b;操作可能会改变b的值(见[rsi])。因此,它需要在内存中执行操作,始终使当前值对即将进行的操作可用。
对于像这些简单的加法这样的简单操作,效果和结果可能不如更大示例那样引人注目,这个更大示例在这个书中没有足够的空间,但我们仍然有足够的证据表明restrict关键字对生成的代码有实际的影响。遗憾的是,这个特性也没有被纳入 C++。
然而,对 C++及其与 C 的不兼容性的批评已经足够了。它们从未打算相互竞争,而是相互补充。让我们转向更有趣的领域。C++真的与自身兼容吗?
空白字符很重要——直到它们不重要
以下代码片段并不特别复杂:
#include <cstdio>
#define STR_I(x) #x
#define STR(x) STR_I(x)
#define JOIN(x,y) (x y)
#define Hello(x) HELLO
int main(void){
printf("%s\n", STR(JOIN(Hello, World)));
printf("%s\n", STR(JOIN(Hello,World )));
}
这段不太复杂的代码定义了一系列宏来操作字符串和连接标记符。STR_I(x)将它的参数字符串化,STR(x)确保在字符串化之前进行完整的宏展开,JOIN(x,y)通过空格连接两个参数,而Hello(x)被定义了,但奇怪的是,没有被使用。
在这个简短程序的生命周期中,最关键的两次printf调用出现了。在第一次printf调用中,JOIN(Hello, World)展开为(Hello World),然后被字符串化为"(Hello World)"。这并不复杂。
然而,有趣的部分现在来了:在第二个printf调用中,JOIN(Hello,World)(在逗号和World之间没有空格)的行为取决于 GCC 版本。
在 GCC 9.4(及以下版本)中,这会导致(HelloWorld)没有空格,而在 GCC 9.5(及以上版本)中,预处理程序在标记符之间添加空格,使得两个printf调用都产生"( Hello World)"。
GCC 9.4 和 9.5 之间的这种差异源于每个版本处理标记符连接和宏参数之间的空白字符的方式,GCC 9.4 在没有明确给出空白字符的地方不会插入空格,而 GCC 9.5 通过在宏调用中省略空格时也添加空格,使其处理更加一致。
虽然 C 和 C++标准没有明确说明“宏参数和逗号之间的空白字符被忽略”,但这是通过预处理程序处理标记化和宏展开的方式暗示的。无论如何,规则规定参数通过逗号分隔,空白字符不影响这种分隔。似乎 GCC(在 9.4 之前)对缺乏规定的解释比较宽松,这在 GCC 9.5 及以后版本中被重新解释。
整个误解是由一个名为Hello的宏的存在引起的,它被定义为一个函数式宏,但被用作一个普通的替换宏。高度可能的是,主要问题(或者更确切地说,曾经是)是较旧 GCC 的一个错误,因为我们确实不再使用它了,因为我们都清楚,新的编译器更符合标准,而且我们当然都编写符合标准的代码,不是吗?
这是一段有趣的历史性向前兼容性。
第 11 位客人
C++11 带来了一系列新特性,同时保持了与 C++98 的向后兼容性,确保开发人员可以增量地采用现代功能,而不会破坏现有代码。其中最具变革性的新增功能之一是移动语义,它引入了一些 C++98 编译器无法处理的语法。这得益于右值引用的语法,这种语法同样不被旧编译器支持。
同样,auto关键字通过自动推断类型简化了类型声明,但开发人员仍然可以继续使用它来显式指定变量具有自动存储,就像他们在 C++98 中从未这样做过一样。这个选择是因为,让我们承认吧,没有人真正使用过 auto,就像它在 C 语言中(它从中继承而来,即使在 C 语言中它也毫无用处,除非在它起源的 B 语言中,它正确地表示变量的存储:栈)。
新的语法,如基于范围的 for 循环,允许对容器进行更简洁的迭代,但幸运的是,C++98 的经典for循环仍然完全有效,因为很多人还在使用它们。nullptr的引入用类型安全的替代品替换了旧的NULL宏,尽管为了向后兼容,NULL仍然被支持,尽管它与 0 并没有太大的不同。
除了这些核心语言改进之外,C++11 引入了现代函数式编程特性,如lambda 表达式,这使得匿名函数可以内联编写,从而简化了代码,使其更加简洁。
新的constexpr特性允许某些函数在编译时进行评估,从而提供性能改进,但开发人员仍然可以通过使用过于复杂的模板递归来依赖 C++98 的运行时函数评估方法,因为,嗯,constexpr也不被旧编译器支持。
然而,对于 C++ 的老用户来说,没有一个突破性的变化比 C++ 模板的双右尖括号解析变化更令人困惑。在 C++98 中,当使用嵌套模板参数时,解析器需要在连续的右尖括号(>>)之间要求空格,以区分它们与位移运算符(>>)。这是必要的,因为在 C++98 中,解析器会将两个连续的>符号解释为位右移运算符,而不是两个嵌套模板的结束。
在 C++11 及以后的版本中,编译器足够智能,能够识别在这个上下文中,>> 指的是关闭两个嵌套模板括号,而不是执行右移操作。这使得语法更简洁,更不容易出错,因为开发者不再需要在嵌套模板表达式中手动添加空格。然而,这也不幸地意味着以下程序将根据它是否是用支持 C++11 标准的编译器还是只支持 C++98 的编译器编译而显示不同的值:
#include <iostream>
const int value = 1;
template <class T>
struct D {
operator bool() {return true;}
static const int value = 2;
};
template<int t> struct C {
typedef int value ;
};
int main() {
const int x = 1;
if(D<C< ::value>>::value>::value>::value) {
std::cout << "C++98 compiler";
} else {
std::cout << "C++11 compiler";
}
}
当我们深入挖掘程序的复杂性时,它为什么会有这种奇怪的行为是非常清晰的。如果还不清楚,让我们来分解一下。
好吧,我们就不分解整个程序了。那会太长了。相反,我们将专注于 D<C< ::value>>::value>::value>::value ,这是所有功能识别的关键。
使用 C++98 语法,这将按以下方式解析:
if(static_cast<bool>(D<int>::value)) { ... }
因此,这一切都归结为 D
由于我们将其定义为 2 ,if 当然是 true ,并且我们已经执行了 C++98 识别分支。
然而,当用符合 C++11 标准的编译器解析代码时,表达式将被解析为以下稍微复杂一些的表达式:
if((static_cast<int>(D<C<1> >::value > ::value)) > ::value) { ... }
由于长而复杂的右尖括号集合可能不明显,最终,这将被解析为两个比较。这是因为接下来的 D<C<1>>::value 结果为 2(因为 C<1> 也是一个类型,所以我们最终进入了一个具有 C<1> 的 D 类的特化)。然后,它与 ::value 进行比较,结果为真 (2>1) 。从这个结果出发,经过一系列有趣的转换,最终结果看起来像 1>1 。这结果是 false;因此,我们进入 C++11 分支。
在这种情况下,我们有一种既简洁又短,尽管过于复杂且无用的方法来识别我们的代码是否是用符合 C++11 标准的编译器编译的。然而,检查 __cplusplus 的值比这要简单得多,并且应该在任何生产就绪的代码中使用。
自动惊喜
如果你,亲爱的读者,还记得在 第九章 中,我们有一个有趣的章节叫做 零的定义 ,那么一切都很顺利。那是因为我们的下一个话题将再次涉及这个极具影响力的数字。如果你不记得那章,那么生活仍然很好,因为希望你已经购买了一本包含所有章节的全书,你可以翻到那一页再读一遍(再次)。
让我们考虑以下程序:
#include <iostream>
#include <typeinfo>
#include <string>
template<typename T> std::string typeof(T t) {
std::string res = typeid(t).name();
return res;
}
int main() {
auto a1 = 0;
auto a2(0);
auto a3 {0};
auto a4 = {0};
std::cout << typeof(a1) << std::endl
<< typeof(a2) << std::endl
<< typeof(a3) << std::endl
<< typeof(a4) << std::endl;
}
程序并不是特别复杂。它只是使用了花哨的 auto 关键字,并使用各种机制将变量初始化为 0,这些机制主要在前面提到的章节中介绍。如果你不知道 auto 关键字的作用,这里有一个简短的回顾:C++11 中的 auto 关键字是从 C 中劫持的,它的新角色是允许自动类型推断,使编译器能够根据初始化器推导出变量的类型。这通过消除显式类型声明的需要来简化代码,并缩短处理复杂或冗长的类型(如迭代器或模板类型)的处理。
总之,回到我们的代码。经过仔细考虑,我们可以得出以下结论:
-
auto a1 = 0; : 对于这个简单的情况,a1 被推导为 int 类型,因为 0 是一个整型字面量。这是一个直接的复制初始化。
-
auto a2(0); : 再次,这是一个简单的例子,a2 也被推导为 int 类型,因为 0 是直接初始化为一个整型字面量。
-
auto a3 {0}; : 然后,a3 被推导为 int 类型,因为 {0} 列表初始化将其初始化为一个整数。
-
auto a4 = {0}; : 然而,这个例子有点棘手。在这种情况下,a4 被推导为 std::initializer_list
类型,因为使用花括号初始化的 auto 会推导出一个 initializer_list。这是 auto 与花括号包围的初始化器一起使用时的一个特殊规则。
使用 MSVC 编译的程序输出如下:
int
int
int
class std::initializer_list<int>
使用 GCC(较新/较好的版本),输出将稍微简洁一些,但你的想法应该是这样的:
i
i
i
St16initializer_listIiE
然而,有一个陷阱。如果我们用版本低于 5.0 的 GCC 编译这段代码,我们会得到一个令人不快的惊喜。输出如下:
i
i
St16initializer_listIiE
St16initializer_listIiE
多么意外的向后兼容性惊喜。真正的帮助来自 clang(3.7)。如果我们用它编译程序,我们会得到以下相当有用的消息:
<source>:19:13: warning: direct list initialization of a variable with a deduced type will change meaning in a future version of Clang; insert an '=' to avoid a change in behavior [-Wfuture-compat]
auto a3 {0};
因此,似乎在其演变过程中,{x} 与 auto 结合但不是 = 的意义在特定场景下发生了变化(大约在 C++17 诞生之时)。然而,幸运的是,早期的编译器已经考虑了这种特殊场景,并给出了非常具体且直接的警告。这不是非常向后兼容,对吧?
所以,考虑到所有这些,以下代码甚至无法编译(考虑到我们仍然在我们之前的简短程序的限制内)并不令人惊讶:
std::cout << typeof( {0} );
它为什么会这样?考虑到前面的语法混乱和混乱,{0} 会推导为哪种类型?会是 int 类型吗?或者可能是 initializer_list 类型?它会是空指针(nullptr)吗?或者是一个可以从数字构建的对象,如下所示:
struct D { D(int i) {} };
void fun(D d) { }
fun({0});
或者,这不再那么有趣了吗?
总结
在本章中,我们了解到 C++ 已经与其谦逊的 C(以及 B 和 BCPL)起源发生了显著的发展和分歧。我们了解到 C++ 引入了现代特性和更严格的规则,以增强安全性和效率,并支持现代编程范式。尽管它保持了 C 的大部分语法,但随着时间的推移,这两种语言已经严重分叉,导致兼容性挑战,尤其是在将较老的 C 代码与需要较新 C++ 标准的功能混合时。我们在这章中对此进行了广泛讨论。
在现代 C++ 本身中,引入了诸如移动语义、更严格的模板解析以及诸如 auto 等关键字行为的变化,这些都增加了复杂性(尽管之前并不缺乏)。我们也在本章中学到了这一点。
尽管面临这些挑战,我们探讨了这样一个事实:C++ 仍然在构建其丰富的遗产之上,为开发者提供强大的工具,同时需要仔细关注不断发展的标准和向后兼容性,而不会与其先前的自我产生太多矛盾。它仍然是一种传统与创新交汇的语言,常常以意想不到且令人着迷的方式相遇。
但这会持续多久呢?它能否经受住新来者的威胁?Rust 会取代 C++ 吗?这取决于您,亲爱的读者,而 Alex 将在下一章中详细讨论这一点。
第十二章:Rust 将取代 C++
如果 4 件事情 同时发生
Rust 在过去几年中作为系统编程和 C++的竞争者崛起。这有很好的理由:Rust 是一种现代语言,提供了一套良好的工具集、简单的语法以及有助于推理代码的创新。因此,Rust 是否会取代 C++的问题,许多希望了解自己职业未来投资方向的程序员都在思考。接下来,我们将探讨 Rust 的有趣之处以及它需要发生什么才能取代 C++。
在本章中,我们将涵盖以下主要主题:
-
为什么有竞争?
-
Rust 的核心特性
-
Rust 的优势
-
C++的优势在哪里
-
C++还需要什么
技术要求
本章的代码可在 GitHub 仓库(github.com/PacktPublishing/Debunking-C-Myths)中的ch12文件夹找到。要运行代码,您需要 Rust,并按照他们网站上的说明操作:
www.rust-lang.org/tools/install
为什么有竞争?
作为大约 2001 年在巴黎工作的初级 C++程序员,我最大的挑战是让我的代码完成所需的功能。该项目是一个工业印刷机的知识库,允许操作员识别打印错误的原因。当时,此类桌面应用的主要选项是在 Windows 下使用 C++,通过 Visual C++、微软基础类库(MFC)和 Windows API 开发,后者是微软推广的模型-视图-控制器(Model-View-Controller)的一个较弱的分支。这个项目让我面临了极大的挑战:我不仅要与 C++的内存管理作斗争,还要处理 MFC 和 Windows API 的怪癖。那时的支持主要来自官方文档、codeproject.com网站以及一位很少有空闲的资深同事。基本上,我必须作为一个独立开发者,在没有太多支持的情况下处理复杂的技术。欢迎来到 21 世纪初的软件开发!请别误会,我并不是在抱怨:正是因为其挑战,这段经历对我来说非常有帮助,并且具有教育意义。
在那个阶段,我唯一关注的是我正在使用的科技。我听说过 PHP,之前也用 Java 开发过小程序和 Web 应用,但 C++、MFC 和 Windows API 占据了大部分精力。通勤大约需要 90 分钟,足够一年内在公共交通工具上读完整本《指环王》。
在我的职业生涯中,第二个重要的项目完全不同:仍然是 C++,但在一个 NoSQL 数据库引擎被命名之前,采用了一种非常结构化和指导性的方法来构建。当时,我学会了如何编写测试,因为我们没有为 C++编写自己的测试引擎。通过编写设计文档并与同事审查它们,我学到了很多关于软件设计的东西。我学习了代码审查。通过深入研究包括 Scott Meyers 的《Effective C++》和《More Effective C++》以及 Andrei Alexandrescu 的《Modern C++ Design》等经典书籍,我深入了解了 C++的方方面面。因此,我甚至更深入地研究了同一技术。
然后,C#出现了,我决定转换技术。在做过一些 Java,对 C++有深入的了解,并以一种结构化的方式学习 C#之后,我意识到两件事:转换技术做得越多,就越容易,每种技术都有其自身的优缺点。在 C#中构建桌面应用程序要容易得多,因为我们不必过多关注内存管理和其潜在问题。编程更有趣,更重要的是,我们开发速度更快。我们用这两个好处交换了两个缺点:更少的控制和编程方法上的不够严谨。
在我的职业生涯后期,我开始思考市场上可用的众多编程语言。据我估计,我们可能需要大约 5-7 种编程语言,纯粹出于技术原因:一种用于网页开发,一种用于系统编程,一种用于脚本编写,其余的用于各种细分市场,如人工智能、工作流程、解方程等。假设我错了,我们可能需要 20 种。然而,现实是,今天我们可以使用数百种编程语言,包括主流、细分和古怪的语言,如 Brainfuck 或 Whitespace。我们可以在 TIOBE 编程社区指数中看到许多这样的语言,该指数监控编程语言的流行度。为什么会有这么多?
我的最佳猜测是,这不仅仅是一个技术需求的问题,而是一个文化问题。当然,技术方面很重要。面向对象和后来的函数式编程特性被引入到所有主流语言中。安全性、并行性和并发性、编程的易用性、社区和生态系统都是编程语言的重要方面。然而,决定创建一种新的编程语言来自人们,他们在设计语言时所做的决定来自他们的个人偏好。文学和哲学的趋势遵循相同的模式:主流和逆流或反动。在文学中,浪漫主义是对古典主义的反应,现实主义是对浪漫主义的反应。在编程语言中也有类似的情况:Java 是对 C++的反应,Ruby on Rails 是对 Java 的反应。在文学中,主流部分由社会变革决定,而在技术中,主流既由景观中的运动决定,也由年轻一代程序员的偏好决定,这些偏好以非常高的速度增加。技术景观变化的例子是互联网的兴起,这有利于 Java 作为对 C++的回应,用于 Web 应用程序。有趣的是,计算从服务器到客户端的移动现在似乎有利于 Web Assembly 应用程序的出现,这些应用程序目前需要用 C++或 Rust 进行低级编程。至于新一代程序员,Ruby on Rails 在很大程度上是对感知到的旧式 Java 语言的反应。Rails 提供了 Java 没有的表达自由,以及随着进步而感到的满足感。这种感觉几乎没有技术基础,但技术方面对人们来说并不全是,甚至对软件开发者来说也是如此。
你现在应该能看出这是怎么回事:Rust 是对 C++的反应。它是对 C++当前技术烦恼的反应,也是对 C++做事方式的反应。因此,让我们看看 Rust 带来了什么。
Rust 的核心特性
我们可以用来理解 Rust 核心特性的第一个地方是官方网站,www.rust-lang.org/。该网站非常出色地强调了 Rust 最重要的特性:
-
快速且内存高效
-
无运行时
-
无垃圾回收器
-
与其他语言集成
-
通过丰富的类型系统和所有权模型实现内存安全和线程安全
-
优秀的文档
-
友好的编译器,带有有用的错误信息
-
集成软件包管理和构建工具
-
自动格式化
-
智能多编辑器支持,包括自动完成和类型检查
仅从这一描述中,我们就可以看到一些与 C++的相似之处,以及 C++当前状态的改进。这些相似之处在于控制级别:原生编译、没有垃圾回收器、速度和内存效率是 C++也吹嘘的品质。而不同之处则指向我们在本书中详细讨论过的事情:标准包管理器、标准工具和友好的编译器。最后这个品质对于收到大量错误信息的任何 C++程序员来说都是美妙的;我记得在 2000 年代,我在 Visual C++中遇到了一个错误,错误信息的大致内容是“错误信息太长,我们无法显示它们”。而今天的 C++更加友好,但在使用模板时找出哪里出了问题仍然是一件痛苦的事情。
然而,让我们超越网站首页上所写的内容。接下来,我们将看看一些我认为与 C++相比非常有用和有趣的功能。
项目模板和包管理
作为一名热衷于使用命令行和 neovim 代码编辑器的用户,我喜欢那些允许我从命令行直接创建项目的技术。Rust 附带了一个cargo工具,允许创建项目、构建、运行、打包和发布。要创建一个新的项目,只需调用cargo new project-name。你可以用cargo run运行它,用cargo check检查它是否有编译错误,用cargo build编译它,用–你猜对了!–cargo package打包它,并用(敲锣打鼓)...cargo publish发布它。
我们当然可以用cargo创建库和可执行文件。不仅如此,我们还可以使用位于cargo-generate.github.io/cargo-generate/的 cargo generate 工具,从项目模板开始。
我知道这对大多数 C++开发者来说可能看起来不多,因为你们很少创建新的项目。这是我教 C++程序员单元测试或测试驱动开发时的一个惊喜:我们必须共同努力设置一个与生产项目相对应的测试项目以及相应的引用,这是我理所当然的事情。当我说这不仅在项目开始时很有用,而且在小型实验、个人或练习代码库中,以及减少编译时间方面也非常有用时,请相信我。如果你发现项目编译得太慢,C++为你提供的一个简单方法是创建一个新的编译单元,由你正在修改的少数文件组成,并将其余部分作为二进制文件引用。在我使用 SSD 硬盘大大加快编译速度之前,我广泛地使用了这种技术。
新项目已经足够了。让我们来写一些代码。让我们修改一些变量...或者也许不。
不可变性
Rust 默认使用不可变性。文档中的说法是 “一旦值绑定到名称,就不能更改该值。” 让我们看看一个简单的例子,我将一个字符串值赋给一个变量,显示它,然后尝试修改它:
fn main() {
let the_message = "Hello, world!";
println!("{the_message}");
the_message = "A new hello!";
println!("{the_message}");
}
尝试编译这个程序会导致 无法将不可变变量 the_message 赋值两次 的编译错误。幸运的是,错误信息中包含了 关于此错误的更多信息,请尝试 rustc –explain E0384 的提示。错误信息的解释包含了一个错误示例,以及如何使变量可变的非常有帮助的提示:
"默认情况下,Rust 中的变量是不可变的。要修复此错误,在声明变量时在 let 关键字后添加关键字 must。"
以下是一个代码示例,当进行适配时,可以使程序编译:
let mut the_message = "Hello, world!";
println!("{the_message}");
the_message = "A new hello!";
println!("{the_message}");
如您所见,可变变量必须指定为 mut,因此默认是不可变的。正如我们在前面的章节中看到的,这有助于解决许多问题,例如并行性和并发性、自动化测试以及代码的简洁性。
复合类型的简单语法
Rust 从像 Python 或 Ruby 这样的语言中借鉴了数组和解构的语法。下面是它的样子:
let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
println!("{:?}", months);
let (one, two) = (1, 1+1);
println!("{one} and {two}");
这可能看起来不多,但它有助于简化代码。
值得注意的是,C++ 在 C++ 11 中引入了类似的语法,并在后续版本中通过列表初始化和花括号进行了改进:
std::vector<string> months = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"};
我很希望看到这方面的进一步改进,但 C++ 的语法已经相当复杂,所以我不期望它会有所改变。
可选的返回关键字
Rust 中的函数允许返回函数中的最后一个值。下一个示例使用这个结构来增加一个数字:
fn main() {
let two = increment(1);
println!("{two}");
}
fn increment(x:i32) -> i32{
x+1
}
我通常避免在前面这样的函数中使用它,但避免使用 return 关键字可以简化闭包,正如我们接下来将要看到的。
闭包
让我们增加向量的所有元素:
fn increment_all() -> Vec<i32>{
let values : Vec<i32> = vec![1, 2, 3];
return values.iter().map(|x| x+1).collect();
}
对于函数式编程结构,就像 C++ 中的 ranges 库一样,我们需要获取一个迭代器,调用 map 函数——在 C++ 中相当于转换算法——使用闭包,然后调用 collect 来获取结果。闭包有一个非常简单的语法,这是由可选的返回语句实现的。
标准库中的单元测试
单元测试是软件开发中非常重要的实践,令人惊讶的是,只有少数语言在标准库中提供了对它的支持。Rust 默认提供,而且使用起来相当简单。让我们添加一个单元测试来验证我们的 increment_all 函数是否按预期工作:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
assert_eq!(vec![2, 3, 4], increment_all());
}
}
作为一个加分项,我喜欢在同一个编译单元(在 Rust 中称为 crate)中编写单元测试,就像生产代码一样。如果你把单元测试看作是一项义务,这可能看起来不多,但我不经常使用单元测试来实验和设计,所以我非常喜欢这个功能。
特性
Rust(或 Go)与其他主流语言的一个重大区别是,Rust 不支持继承,而是更倾向于组合。为了在不使用继承的情况下实现多态行为,Rust 提供了特质。
Rust 特质在面向对象语言中类似于接口,因为它们定义了一组需要为从它们派生的每个对象实现的方法。然而,Rust 特质有一个特定的特性:你可以将特质添加到你并不拥有的类型上。这类似于 C#中的扩展方法,尽管并不完全相同。
Rust 文档通过使用两个结构体来举例说明特质,一个代表推文,另一个代表新闻文章,并将Summary特质添加到两者中,目的是创建相应消息的摘要。正如以下示例所示,特质的实现与结构体的实现和特质的定义是分开的,这使得它非常灵活。
让我们先看看这两个结构体。首先,NewsArticle包含了一些字段:
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
然后,Tweet结构体包含它自己的字段:
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
独立地,我们定义了一个带有单个方法summarize返回字符串的Summary特质:
pub trait Summary {
fn summarize(&self) -> String;
}
现在让我们为Tweet结构体实现Summary特质。这是通过指定这个特质的实现适用于结构体来完成的,如下所示:
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
测试工作得非常完美:
#[test]
fn summarize_tweet() {
let tweet = Tweet {
username: String::from("me"),
content: String::from("a message"),
reply: false,
retweet: false,
};
assert_eq!("me: a message", tweet.summarize());
}
最后,让我们为新闻文章实现这个特质:
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
#[test]
fn summarize_news_article() {
let news_article = NewsArticle {
headline: String::from("Big News"),
location: String::from("Undisclosed"),
author: String::from("Me"),
content: String::from("Big News here, must follow"),
};
assert_eq!("Big News, by Me (Undisclosed)", news_article.summarize());
}
Rust 中的特质具有更多功能。我们可以实现默认行为,指定参数的类型需要是单个或多个特质类型,在多个类型上泛型实现特质,等等。实际上,Rust 特质是 OO 接口、C#扩展方法和 C++概念的组合。然而,这超出了本章的范围。值得记住的是,Rust 对继承的处理与 C++非常不同。
所有权模型
Rust 的一个有趣特性,也许是最受宣传的特性,就是所有权模型。这是 Rust 对 C++中内存安全问题的一种反应,但与 Java 或 C#中的垃圾收集器不同,设计者通过更明确的内存所有权来解决这个问题。我们将看看 Rust 书籍中的一段引文([https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html]):)
“内存通过一套规则进行管理,这些规则由编译器检查。如果违反了任何规则,程序将无法编译。在程序运行期间,所有权的任何特性都不会减慢你的程序速度。”
Rust 中有三个所有权规则:
-
每个 Rust 中的值都有一个所有者
-
一次只能有一个所有者
-
当所有者超出作用域时,值将被丢弃
让我们首先看看一个与 C++中相同工作的示例。如果我们有一个在栈上分配的变量,比如一个整数,那么复制变量将以非常熟悉的方式工作:
#[test]
fn copy_on_stack() {
let stack_value = 1;
let copied_stack_value = stack_value;
assert_eq!(1, stack_value);
assert_eq!(1, copied_stack_value);
}
两个变量具有相同的值,正如预期的那样。然而,如果我们尝试用堆上分配的变量执行相同的代码,我们会得到一个错误:
#[test]
fn copy_on_heap() {
let heap_value = String::from("A string");
let copied_heap_value = heap_value;
assert_eq!(String::from("A string"), heap_value);
assert_eq!(String::from("A string"), copied_heap_value);
}
当运行这个程序时,我们得到错误[E0382]: borrow of moved value: heap_value错误。发生了什么?
好吧,当我们将heap_value的值赋给copied_heap_value时,heap_value变量就失效了。这和 C++中的移动语义行为相同,只是程序员不需要做任何额外的工作。在幕后,这是通过使用两个特质:Copy和Drop来实现的。如果一个类型实现了Copy特质,那么它就像第一个示例中那样工作,而如果一个类型实现了Drop特质,那么它就像第二个示例中那样工作。没有类型可以同时实现这两个特质。
为了使上述示例工作,我们需要克隆值而不是使用默认的移动机制:
#[test]
fn clone_on_heap() {
let heap_value = String::from("A string");
let copied_heap_value = heap_value.clone();
assert_eq!(String::from("A string"), heap_value);
assert_eq!(String::from("A string"), copied_heap_value);
}
这个示例工作得很好,所以值被克隆了。然而,这表明这是一个新的堆分配,而不是对相同值的引用。
移动语义对于函数调用也是相同的。让我们初始化一个值并将其传递给一个返回它未更改的函数,看看会发生什么:
fn call_me(value: String) -> String {
return value;
}
#[test]
fn move_semantics_method_call() {
let heap_value = String::from("A string");
let result = call_me(heap_value);
assert_eq!(String::from("A string"), heap_value);
assert_eq!(String::from("A string"), result);
}
当尝试编译这段代码时,我们得到与之前相同的错误:错误[E0382]: borrow of moved value: heap_value。值是在堆上创建的,移动到call_me函数中,因此从当前作用域中删除。我们可以通过指定被调用的函数应该只借用所有权而不是接管它来使这段代码工作。这是通过使用引用和解除引用运算符来实现的,这与 C++中的相同:
fn i_borrow(value: &String) -> &String {
return value;
}
#[test]
fn borrow_method_call() {
let heap_value = String::from("A string");
let result = i_borrow(&heap_value);
assert_eq!(String::from("A string"), heap_value);
assert_eq!(String::from("A string"), *result);
}
C++引用和 Rust 引用之间的重要区别是,Rust 引用默认是不可变的。
当然,关于 Rust 中的所有权模型还有很多东西要学习,但我相信这已经足够让你了解它是如何工作的,以及它是如何旨在防止内存安全问题的。
Rust 的优势
总结来说,Rust 相对于 C++有一些优势。作为一个较新的语言,它有从其前辈学习并使用最佳模式的优势。我发现将不可变性与所有权模型结合起来,对于默认工作良好的代码来说非常好。由于它不是典型的内存管理风格,所以学习起来可能需要一点时间,但一旦你了解了如何使用它,它就允许你编写几乎无挑战性的代码。
标准库中的单元测试支持、包管理器和多编辑器支持应该是任何现代编程语言的一部分。当涉及到闭包和复合类型时,语法更优雅。
在这个阶段,我们可能会想:C++有机会吗?为什么,在哪里?
C++的优势在哪里
C++是一种非常强大、先进的编程语言,正在不断改进。语言进步很快。很难与 C++生态系统相提并论:其社区、可用的库和框架数量惊人,以及教你如何以各种方式使用 C++解决任何可能问题的文章、博客和书籍。尽管有这些好处,与 C++相比,Rust 是一种较新的语言,这在考虑系统编程技术选择时应该让你三思。然而,Rust 已被用于 Linux 和 Android 的子系统,这证明了它是一个值得尊敬的竞争对手。
C++标准化委员会一直致力于简化语法和减轻程序员在处理各种代码结构时的心理负担。部分努力源于竞争,许多在 C++17 及以后版本中引入的特性是对 Rust 设计选择的回应。虽然我不期望 C++的语法会像 Rust 那样简单,但这里提到的其他因素也必须对选择产生同样甚至更大的影响。
C++仍需改进之处
在本书中,我们看到了 C++的一些挑战。一个标准的包管理器将非常有帮助,即使社区效仿 Java 和 C#,选择一个开源的既定标准。一个标准的单元测试库将非常有益,即使现有的代码可能需要很长时间才能迁移,如果它真的迁移了的话。
Unicode 和 utf-8 的支持仍需改进。标准的多线程支持才刚刚开始。安全配置文件将非常有助于最小化内存安全问题。
从这份清单中可以看出,C++有很多需要改进的地方。好消息是标准化委员会正在努力解决这些问题。不那么好的消息是,定义这些改进需要时间,适应编译器需要更多时间,适应现有代码则需要更多时间。希望通用人工智能能够足够强大,以加快这些改进的速度,同时保持代码的完整性。
摘要
在本章中,我们看到了 Rust 是一个非常有趣的编程语言,其设计者知道如何利用前辈们积累的知识,并在正确的地方进行创新。结果是语法简洁,处理内存的方式更自然,无需使用垃圾回收器,整体开发体验现代化。我们在这章中探讨了这一点。
然而,C++很难与之竞争。世界上关于 C++的库、框架、博客、文章、代码示例、书籍和经验数量庞大,短时间内无法匹敌。Rust 在 Web Assembly 应用和各种工具中找到了自己的 niche,但它还远未取代 C++。
然而,我们还得记住,语言的选择并不一定基于技术原因,文化因素也同样重要。新一代的程序员可能比 C++更喜欢 Rust,而且随着国家安全局和白宫将焦点放在内存安全语言上,Rust 可能在新的项目中获得优势。
结论是什么?预测未来很难,但我们可以想象 Rust 如何接管。在我看来,这需要四个因素:越来越多的程序员选择 Rust,它受到法规的要求,C++在内存安全方面的进化速度不够快,以及生成式 AI 在将 C++转换为 Rust 方面变得足够好。
因此,存在机会,但我认为可以安全地说,至少在未来十年内,C++还将继续存在。


浙公网安备 33010602011771号