ACCU-2024-笔记-全-

ACCU 2024 笔记(全)

001:与 Björn Fahller 的访谈

在本节课中,我们将跟随 Kevin Carpenter 与资深开发者 Björn Fahller 的对话,了解 ACCU 2024 大会的亮点、Björn 的编程生涯以及他对团队协作的思考。我们将学习到一位 C++ 专家的成长路径、对多语言编程的看法,以及即将到来的主题演讲的核心思想。


早上好,晚上好。因为 Björn 在瑞典,而我在亚利桑那州,所以问候大家早晚安。欢迎各位,我是 Kevin Carpenter,算是一名职业志愿者。今天早上我们将与 Björn 聊聊 ACCU 大会和他的压轴主题演讲。Björn,欢迎你,你那边晚上过得怎么样?

非常感谢邀请我。我很好。我正在享受复活节假期,所以很棒。

这很有趣,因为开始前我问过,我知道你那边背景里有雪,而今天我这里的气温……我不太擅长华氏度转摄氏度,但凤凰城今天大约有 68 华氏度。我提到世界两端是为了引入话题,总之,很高兴和你聊天。

Björn,我想我们第一次见面是在 CppCon 的某张桌子旁。

是的,我想那是正确的。我们是在 CppCon 认识的。

对所有人来说,这是我第一次参加 ACCU 大会,所以我非常兴奋。但我从 2018 年开始在 CppCon 做志愿者,而你的第一次演讲是在 2017 年的 ACCU。

也许我不该查这个,但听起来合理,可能是对的。我想那次是关于你的模拟库,它叫什么名字?我可能发音不准,因为我不太说法语,是 “trompe l’oeil”。

好的,它的字面意思是“欺骗眼睛”,所以这是一种艺术形式,即创造一种让你相信它是别的东西的假象。我认为这对于一个模拟框架来说是个合适的名字。

确实,我不得不笑,因为我记得看到过这个名字,但我不想尝试发音,所以谢谢你帮我念出来。这很酷,你现在还积极维护它吗?我在日常工作中用得不多。

是的,我还在维护它。现在工作量不大了,因为它已经成熟了,只是偶尔有些更新。实际上,它的十周年纪念日快到了,我查看了 Git 仓库,第一次提交是在 2014 年 10 月。十周年纪念日即将到来。

恭喜,这真的很酷。我都没有那么老的项目。

那么,是什么让你进入计算机领域的?

我成长于 80 年代。是的,Commodore、Sinclair,我们还有一些没人知道的瑞典机器。我不知道,我的大多数朋友拿到它们只是为了玩游戏。我也玩过一些游戏,但主要是觉得让机器为我做事非常有趣。这很有趣。

这很有趣,因为我在想我刚开始的时候……你提到了 Commodore 64,我们学过一些汇编语言,当然还有 BASIC,但我最早接触的是 Turbo Pascal 和 CP/M 机器,后来是 Turbo C。这很有趣,因为我为了我们在 Meeting C++ 上的演讲,还特意打开了 Turbo C。我不怀念那个界面。

在我们都从事这行这么久之后,你已经在现在的公司工作了相当长一段时间,对吗?

是的,首先我得说,你提到 Turbo Pascal 和 CP/M 很有趣,你是我认识的除了我自己之外唯一用过它的人。哈哈,是的。

我讨厌这个问题,因为它有点奇怪。是的,我在 Net Insight 工作,正如你提到的,已经有一段时间了。我最初是在 1998 年春天加入的,所以那是很久以前了。但我实际上离开过公司两次,去做了别的事情,然后又决定回来。

我是在 LinkedIn 上看到你的信息才问的。我想引导到的另一个部分是,考虑到 Net Insight 的业务,你写的代码可能经常需要处理各种高延迟环境下的性能问题。或者你能谈谈你具体做什么工作吗?

我几乎参与了 Net Insight 研发部门的所有工作。我们制造自己的数据传输硬件,并且大部分处理都在 FPGA 上完成。所以大部分软件实际上只是用来配置 FPGA 开始工作。因此,大多数代码在带宽方面并不是性能关键的,尽管在某些事件的响应时间上可能是。但是,确实有一些情况性能至关重要,而且我们的一个产品系列是关于软件视频流的,我参与过这方面的工作,在那里,考虑内存布局以提高缓存效率等就超级重要了。所以这取决于你做什么。

是的,没错。我工作的领域是交易性的,信用卡处理,所以我只需要在刷卡的那个瞬间快速响应。但当每小时处理超过 10 万笔交易时,规模就成了问题。不过有趣的是,有些性能关键的代码理想情况下永远不应该运行,因为它是用来处理灾难性事件的,你需要迅速反应,但灾难性事件当然不应该发生。所以所有常规的性能调优建议都不适用了。

确实如此。然后它就成了你最需要高性能的代码,因为如果你做对了,它就永远不会发生,对吧?但它可能发生,因为完全超出你控制的事情,比如有人用挖掘机切断了光纤,你必须对此做出反应。

哦,天哪。这和我处理信用卡的层面不同,信用卡失败我会回复,有人可能会难过一天,但只需要快速处理。我们这边有其他人负责处理网络,如果他们挖断了电缆。

那么,说说 ACCU。我提起 Pascal 这些东西是因为我认为 ACCU 的不同之处在于……再次说明,我是在 CppCon 开始做志愿者的,之前没听说过 ACCU,毕竟我在世界的这一边。但 ACCU 已经举办很久了,我知道这个会议的历史肯定比 YouTube 早。你还记得你第一次去 ACCU 是什么时候吗?

我记得,但那不是很久以前。它当时还在牛津的老场地。现在它在布里斯托尔,已经好几年了。那是我第一次去,我恳求我的经理允许我去参加一个会议,我去了,被那么多事情淹没了。但之后隔了几年我才又能去,然后说,哦,看,Sean 也在那里。好吧,但我不太确定我是什么时候在那里的。只是谈谈这个机构,ACCU 会议一定是这样的,因为我可以告诉你,我去过 Defcon,我从 Defcon 7 开始去的,我最后一次去 Defcon 大概是 Defcon 13。所以看到会议如何塑造了我们的一些聚会很有趣,Defcon 当然是关于安全的,但 ACCU 如何塑造了它。我认为 ACCU 对我来说至少独特的一点是,我想说是语言无关的,但它不是只关于 C++ 的会议。

确实,这是 ACCU 我非常喜欢的一点。可以说,在所有我去过的会议中,我最喜欢的两个是 ACCU 和 NDC TechTown,它们的共同点是它们都不是语言特定的。它们往往有很多关于 C++ 及相邻问题领域的焦点,但它们不是关于那些语言的。所以你可以去听关于组织文化、如何用好 Git,或者关于 Python 的演讲。

这对我来说很有趣,因为就像我在目前公司有段时间连续一年写 C++,但现在我实际上在做 C++,同时我有个项目完全用 Go,除此之外,帮助会议志愿者的项目现在用 Python,我以前从没做过 Python。我在上次演讲中开玩笑说我不太确定这个 self 东西,但学习它很有趣,多亏了 Python,午餐订购问题解决了。

那么,除了 C++,你还有其他喜欢的语言吗?我很惭愧地说,C++ 真的是我唯一感到舒适的语言,因为这是我工作了很长时间的语言。所以我认为可以公平地说,我相当了解它,尽管它是一种相当奇特且难以完全掌握的语言。我 Python 足够好,可以完成工作,但不算精通。目前,我实际上正在一头扎进 TypeScript,我完全不懂,所以这会非常有趣。你在语言之间发现了很多可以互相学习的地方吗?比如开始学习 TypeScript 后,有没有学到什么让你觉得“我在 C++ 里不知道这个”的东西?

还没有。我问这个是因为 Go 语言,感觉很多东西都以元组形式返回,因为 Go 里很多函数会返回一个值和一个错误,或者其他值。这很有趣,因为我以前在 C++ 里不怎么用元组作为返回值,然后在 Go 里待了六个月,现在我开始用返回元组了。

你的主题演讲将为我们闭幕会议,这很酷。是的,我总是对做演讲感到紧张,所以当我做完演讲,我才能放松下来开始享受会议,但这在闭幕演讲那天不太行得通。哈哈。

学习、教学就是分享,我开玩笑地补充说这就是 Karma。你能给我们透露一些你主题演讲的亮点吗?

好的。标题是“如何共同改进”,我试图探讨我们如何与同事一起,作为一个团队或一个研发部门,改进工作方式。我认为其中一个关键是信息共享。你需要有想法和……尴尬经历的自由流动,因为我们都会犯错,当然,除了那些什么都不做的人,他们不会犯错。分享不好的信息对你来说很重要。但我也将用一个我目前不在其中工作的环境作为类比,那是一个……致命的、字面意义上致命的文化,因为那种文化真的导致了人员死亡。我还会用一个非常奇怪的例子,我将展示一点关于如何操作第一次世界大战战斗机引擎管理的知识。

好的。我开始感觉到……我看过你做的一个闪电演讲,我觉得很棒,因为我在凤凰城外,我非常喜欢它,这是我最喜欢的你做的演讲之一。我认为从 C++ 的角度来看也很有趣,因为深入一门语言的好处就是,你关于 C++ 的演讲总是让我学到新东西,所以我感谢你在所有演讲上投入的时间。我会很期待看到你在 ACCU 的闭幕主题演讲。但我确实为你感到有点紧张。

是的,我也思考了很多我想谈什么,因为对我来说,做主题演讲时一个意想不到的、有点奇怪的事情是,会议组织者说“嘿,你想做个主题演讲吗?”,然后你说“好的,我可以”,然后……就完了,得由我自己找话题。既然我们几分钟前谈到 ACCU 不是语言特定的,而且我几乎所有的演讲都非常技术性,我不想做一个 C++ 主题演讲,因为主题演讲是面向整个会议的。在其他分论坛,人们可以根据兴趣选择是否去听你的演讲,但主题演讲是面向所有与会者的,我不想只针对一部分听众,而忽略其他人,那感觉不公平。所以我想这就是为什么我经过思考后,决定分享一些关于我认为团队协作中哪些有效、哪些不那么有效的经验。

这很酷,也很有趣,因为……我喜欢用“有趣”这个词。当我做这些采访时,我觉得自己是个不错的开发者,我喜欢我的工作,但我总是喜欢做采访,因为这样我能更多地了解,尤其是与演讲者或培训师,了解他们生活中的那部分、个人部分。如果你想看一个具体介绍为什么应该使用某个 C++ 特性的视频,通常演讲就是关于这个的。我喜欢了解你从哪里开始编码,你第一次去 ACCU 是什么时候,因为这些部分……我希望人们来 ACCU 并享受它。这是一个很棒的会议。但我想让人们也感受到这部分。我有点跑题了,但我们有太多语言特定的东西了,所以把我们作为开发者的人性展现出来真的很好,我不知道我们是否经常展现这一点。Kate Gregory 在这方面做得很多,她做过一些非常好的相关演讲。

我必须大声感谢 Kate,因为她是我开始参加 CppCon 的原因,她说我需要去。我的第一次闪电演讲也是因为她,我们当时一起工作,她说“嘿,这里有个 bug,你得做个闪电演讲讲讲它”。我有几年没见到 Kate 了,今年需要见见。

是的,我去年去了 C++ on Sea,强烈推荐。但现在我们还在谈 ACCU。它于 4 月 15 日星期一开始,有会前课程,我知道 Matus 和 Nikco 在授课,Kate 实际上也在教两门课,所有这些都……我很兴奋,因为我喜欢看他的演讲。然后主会议在 17 日开始,一直持续到你的闭幕演讲。

你期待去那里吗?是的,绝对期待。这总是一个很棒的会议,有那么多有趣的演讲和那么多有趣的人。正如你所说,我去过 ACCU 很多次了。当你多次回到同一个会议,并且其他人也多次回到同一个会议时,会发生什么?我有一些可以算是“A 级朋友”的人,我只在那里见到他们,我期待着见到他们。

是的,我完全理解,因为在我做志愿者的会议上也是如此,这绝对是我们得以见面、叙旧、闲逛、聊点工作但更多聊生活的地方。所以,说到聊生活,感谢你今天早上抽出时间,希望你享受剩下的假期。

谢谢你,很荣幸来到这里。哦,绝对一样,我想我们不到三周后见。是的,想想有点吓人,我还没做完我的幻灯片呢。等等,那会……就像你说的,周一你可能还在赶工。这不是容易的部分,我得说。在 Meeting C++,我五年来的第一次演讲就被安排在了……Bjarne 的演讲之后,但这至少是第二次……第一次演讲就在 Bjarne 之后很吓人,因为就在 Bjarne 之后,但不管怎样,它总会被完成。我想我睡过了下一场,因为所有精力都用完了。哦,是的,就是这样。是的,演讲后的大脑不工作了。是的,它不工作了。

非常感谢,祝你下午/晚上愉快。会议上见。会议上见。


本节课中,我们一起学习了 ACCU 2024 大会的概况,了解了演讲者 Björn Fahller 的编程背景、他对 C++ 及其他语言的看法,以及他即将发表的关于团队协作与信息共享的主题演讲核心内容。我们看到了技术社区中个人成长与经验分享的重要性。

002:ACCU 2024 主题演讲预览 - 与 Herb Sutter 对话 🎙️

在本教程中,我们将跟随 Kevin Carpenter 与 C++ 专家 Herb Sutter 的对话,了解即将到来的 ACCU 2024 大会主题演讲内容、C++ 语言安全性的最新进展,以及 Herb 的个人实验项目 Cppfront。我们将学习如何准备一场高质量的演讲,并探讨 C++ 标准委员会的工作方式。

演讲准备的艺术 🎤

上一节我们介绍了本次访谈的背景,本节中我们来看看 Herb Sutter 分享的关于准备高质量演讲的心得。

Herb 指出,站上舞台进行一场糟糕的演讲很容易,但准备一场优秀的演讲则绝非易事。这需要充分的准备和练习。

以下是准备一场优秀演讲的关键步骤:

  1. 确保要点有序:确保你的主要观点逻辑清晰,层层递进,相互关联。
  2. 准备简洁的开场和结尾:避免用一半的演讲时间来做介绍。
  3. 进行完整的排练:在完成所有准备工作后,必须将整个演讲从头到尾大声排练一遍。

Herb 强调,排练是尊重听众的表现,能确保演讲内容是经过深思熟虑的,而非草率之作。他建议在演讲前一天晚上进行排练,这样能使演讲过程更加流畅,减少犹豫和重复。

C++ 标准与沟通的演进 📜

上一节我们探讨了演讲技巧,本节中我们来看看 Herb 如何看待 C++ 标准委员会工作与沟通方式的变化。

Herb 认为,在制定新特性与完善现有文档之间取得平衡,是委员会长期面临的挑战。如今,委员会在沟通方面做得比过去好得多。

一个重要的改进是 Trip Report(会议报告)的撰写。现在,每次委员会会议结束后,通常会在 Reddit 等平台出现由多位委员会主席和成员共同创作的、内容详尽的众筹报告。这有助于社区了解各项提案的进展。

随着对 C++ 的兴趣持续增长,委员会规模也在扩大。最近的东京会议是有史以来规模最大的会议之一,约有 220-230 人参加,而过去 60 人已算大型会议。会议也允许受邀的新人(包括高中生班级)远程观摩,让更多人了解标准制定的过程。

Cppfront:一个关于安全与演进的实验 🔬

上一节我们了解了标准委员会的工作,本节中我们来看看 Herb 的个人实验项目 Cppfront。

Cppfront 是 Herb 的一个实验性编译器,采用实验性语法,旨在探索一些新想法。他于 2022 年底首次宣布该项目,但其部分构想可追溯至 2015 年。

Herb 创建 Cppfront 的初衷是进行一些尝试、学习新知识,并作为一个公开的个人实验。他惊讶于该项目获得的关注度。在项目一周年时,社区投票显示最迫切的需求是文档。因此,Herb 花费了数月时间学习 MkDocs 并编写了项目文档。

目前,Cppfront 仍被定位为个人实验项目,但其目标是将探索出的成果通过演进提案反馈给今天的 ISO C++ 语法。该项目的发展已超出 Herb 最初的预期。

总结与预告 📝

本节课中,我们一起学习了以下内容:

  1. 演讲准备:优秀的演讲需要结构化的要点、简洁的开场结尾以及完整的大声排练。
  2. C++ 标准工作:委员会通过 Trip Report 等方式改善沟通,会议规模扩大且更加开放。
  3. Cppfront 项目:这是一个旨在探索 C++ 安全性与语法演进可能性的实验性编译器和个人项目。

Herb Sutter 将在 ACCU 2024 大会的主题演讲中,进一步探讨 C++ 安全性 这一核心议题。如果你想了解 C++ 的最新前沿动态,请关注此次大会。

003:ACCU 2024会议前瞻 - 与Mike Shah的对话

概述

在本教程中,我们将跟随Kevin Carpenter与Mike Shah教授的对话,了解即将到来的ACCU 2024会议、现代C++教学理念、不同编程语言的比较以及软件开发的迭代本质。我们将学习如何通过图形化方式教授C++、性能的重要性,以及学习新语言如何改变我们的编程思维。

会议介绍与教学理念

欢迎各位,我是Kevin Carpenter。我是一名专业志愿者,因为我已为众多会议提供志愿服务。今天和我在一起的是Shaw教授。我记得是在你的YouTube频道上看到你的,Mike Shaw拥有自己的YouTube频道,订阅者超过16000人。我的视频只是为会议发布的。我们今天在这里讨论将于4月17日开始、持续到20日的ACCU 2024会议。会议有一些很棒的会前课程,我们稍后会讨论。Mike,欢迎你。

谢谢你,Kevin。我也很期待ACCU,期待我们的对话,很高兴来到这里。

说实话,我今早还在想。我不记得我们第一次见面是什么时候了,Mike,我只是感觉认识你很久了。是在CPP con还是Core C++大会?我想是Core C++。你今年没去Meeting C++大会?是的,去年没去,但以前去过。我想已经有好几年了,我们共同经历了几次冒险,所以和你交谈总是很棒。

是的,绝对如此。

那么,ACCU会议。我知道你去年也在那里,并且做了一个关于教授C++的演讲。Shaw教授,这算是你的主要工作,对吧?请谈谈这个,谈谈C++教学和你的主要工作。

是的,我在ACCU做了一个关于如何教授现代C++的演讲,名为“一次一个像素地教授现代C++”。这实际上是借鉴了Kate Gregory多年前在会议上的一次演讲,关于她如何教授C++。我基本上是从Kate这位出色的演讲者、程序员、培训师那里“偷”了许多技巧,并将它们融入我多年的课程中。通过加入图形元素,让C++编程变得真正有趣,学生们能理解他们所做事情的目的,我们使用C++提供的工具和抽象,保持他们的积极性,让C++编程变得有趣,并看到这门语言的力量。

这让我想起了Logo编程语言。我猜这比你开始教学时要早得多?是的,确实如此。我最初是从基础编程和Logo开始的。有趣的是,在我的演讲中,有人提出了关于如何将编程与Logo和图形学联系起来的问題。

好的,我喜欢从图形角度使用C++的想法。我的意思是,即使是今年的CPPcon大会,他们也将有一个大型游戏开发专题,我认为这真的很酷。说真的,可能没有一款游戏不……我知道有很多游戏不是用C++,但你明白我的意思。当涉及到性能时,我想我在你的一篇视频中读到,即使是像2D卷轴游戏这样的东西,从节省手机电池的角度来看,你最近做的关于轻量级模式的视频也提到了这一点。是的,就是那个。这是一个很好的观点,你在节省电池。无论如何,你仍然在处理有限的资源,虽然不像Z80的28K内存那么紧张。

没错。有一句很棒的名言,我第一次在2019年ACCU会议上做演讲时引用了,是关于性能和缓存无关算法的深入探讨。但其中一句名言来自Charles Leiserson教授,他是一位比我睿智得多的教授,他给出了这样的金句:性能是一种货币。所以,即使你不需要它,即使是一个简单的2D卷轴游戏,你也能获得更长的电池寿命、更流畅的体验,也许代码更正确,因为你没有走捷径。所以这总是值得思考的,这也是C++的一个优点,对吧?在性能方面没有任何限制,无论好坏。

确实如此。这很有趣,因为想到Kate Gregory,我第一次和她合作时,她正在帮助我当时的公司做承包商。她经常对我说的一句话是“能运行就是一个特性”,因为我写了一些东西,我会说“嗯,它能运行”,她就会说“是的,能运行就是一个特性”。即使在性能方面,你发现你写的代码有多少是迭代式的?你完成了那部分,然后你不断地改进它。你的经验也是如此吗?我认为随着我们经验的增长,你可以跳跃一两个层次,但一开始不总是这样吗?

我想至少在我的开发过程中,总是如此。也许我们有点偏见,你看像我这样的人做YouTube视频,因为我对一个问题思考了很长时间,足够让我舒服地录制并公开分享。但开发是如此迭代。我的意思是,很多时候,比如我需要一个排序算法,你直接去标准库里找,甚至有时我只是快速写一个暴力排序,因为我想看到它工作。但我们知道那性能不好。是的,开发是如此迭代,理解你为什么做某事的原因非常重要。我认为随着你在特定领域获得更多开发经验,情况会改变。因为即使领域变了,你为性能和架构代码所使用的技巧也会变,然后你转到其他东西,可能完全不同。这很有趣。

确实如此。因为我们刚举办了CPP在线会议,我 unfortunately 没看到你的演讲,因为我们的时间冲突了。是的,没错。我做完演讲后……我从未做过完全在线的演讲,所以我必须说,我比做其他任何事情都更紧张做完全在线的演讲。

抱歉,我不知道我刚才说到哪里了。但就像你准备演讲时,那和你平时解决问题是不同的。对于CPP在线会议,我的演讲是关于我们使用工具时的直觉。这是我第二次做这个演讲,第一次是在Meeting C++。但这次期间,我开始学习一点Python。我以前从未写过Python。我回顾了Basic、Turbo Pascal、PHP、Perl。我在演讲中开玩笑说,我仍然不太擅长self。但关键是,我感觉自己像个新手。当我谈论直觉时,演讲中有一部分提到,我做C++有一段时间了,所以当我第二次讲这个演讲时,我花时间学习了一套新工具、新布局,缩进实际上很重要,这带来了不同的感受。相比我用C++写代码,这是一种更迭代的方法。

100%同意。就像你说的,学习一门新语言是我鼓励每个人都做的事情,因为它会改变你的思维方式。正如你提到的,在Python或其他语言中,高效做事的方式可能非常不同。

教育、会议与职业发展

对于ACCU,我想问这个,因为Gail Ollis正在举办一个为期一天的早期职业日活动,针对初级开发者,在4月16日星期二。作为一名教师,你认为这对于……即使是学习一门新语言,即使我拥有关于算法如何工作的所有知识,我从一开始就感觉很原始。所以像Gail的会前研讨会这样的会议,对于学生,你认为怎么样?

非常棒,非常棒。如果学生能去,无论是作为会议志愿者还是刚刚开始职业生涯,这都非常棒。我能稍微谈谈Gail,因为我认识她,她非常友好,我在ACCU会议上见过她,也听过她的CppCast采访。所以了解她的一些背景,理解软件心理学——她曾作为一名经验丰富的软件工程师工作多年,然后回去研究我们如何构建软件、团队如何协作。所以听起来她是指导初级或刚起步的人获得这些建议的合适人选,这些建议可能会为你节省数年时间,或者也许只是……是的,或者也许能帮你通过第三次Facebook面试。我不是计算机科学专业的,我是自学成才的。所以经历编码面试,至少是在线的,而不是在白板上,说实话我宁愿是在线面试。100%同意。

但是,是的,如果你是新开发者或刚毕业,想提升自己的表达能力,你可能想看看Gail的会前研讨会。关于学习和不同语言的教育,你今年的演讲是关于D语言以及D如何帮助你理解C++。我很欣赏这一点,因为我现在用Go更多了,并且以一种以前从未有过的方式在C++中使用元组,因为Go每次都返回两三个值。请谈谈这个演讲,是什么启发了它?

是的,我大约两年前开始认真研究D编程语言,参加DConf并在那里演讲。但D本身是由Walter Bright创建的。对于那些不了解历史的人,我会在演讲中简要介绍。他是创建Zortech C和C++编译器的人之一,这些编译器在当时以高度优化而闻名。他基本上是一个单人团队,非常聪明。然后他还有与D编程语言相关的历史,与Andrei Alexandrescu和Dr. Dobb博士合作,后者在C++社区也非常受欢迎,仍然在做精彩的演讲,总是既有趣又有见地。无论如何,在整个历史中,他们某种程度上从C++的经验教训中学习。我认为是在90年代,现代C++之前。所以这是一条有点不同的道路。但人们认为它是C、C++,然后玩笑说D语言紧随其后。

但有趣的是,学习这门语言,就像你谈论Python一样,不同的语言让你以不同的方式思考,对吧?所以D让我思考它强调的不同范式。比如模板和元编程,它们在D中是有意义的,因为它们有时间向其他语言学习或尝试新事物。学习或进行模板元编程和内省编码,即在编译时做事,这些其他范式现在才真正成为C++的重点,但作为D语言成熟的一部分,学习这些东西非常有启发性。这几乎像一个游乐场,让我学习这些东西,然后将其转化到C++中,无论我工作的领域是什么。

编程语言比较:D、Rust与Go

我现在必须问一下。因为D是C++的内存安全版本吗?那Rust呢?它们如何比较?

这很有趣,我开始有一些见解。正如你在YouTube上提到的,如果有人感兴趣,可以深入研究。我一直在做一个“第一印象”系列,我花一小时研究一门语言,我研究了Rust和其他许多语言,现在大约有20种了。

但即使当我想到Rust,一旦我开始使用它,我必须立即思考所有权之类的事情,这是Rust著名的特性,借用检查器等等,具有各种编译时保证。所以对我来说,Rust几乎介于D和C++之间。现在你问,好吧,看看……不,不,是的。所以我不想让你为难,但更多的是因为我们最近都看到了那篇文章。有趣的是,我认为有趣的是,就像我说的,最近我一直在做一些Go,因为Go有一个很好的JWT接口,使用Jose,加密等都已经内置好了。而我发现没有那么多C++库能很好地处理JWT,而不必使用OpenSSL。但不管怎样,我被要求用Go写这个。我听另一个做Go示例的YouTube博主谈论Go的最新版本,他里面有一句话,基本上说,当你为HTTP写一个路由器时,它不会返回错误,这是一个问题。但让我印象深刻的一句话是,嗯,Go他们说他们不会这样做,因为他们想为之前每个版本的Go保持向后兼容性。这让我思考,因为……

好吧,Go现在大概10岁了?C++呢?快50了,我想是的,50或接近50。所以这让我对这个问题有了不同的看法。我想,好吧,所以你会有破坏性变更这一点。如果Go这门新语言都遇到这个问题,我知道Rust已经存在一段时间了,但这让我思考,每门语言在某种程度上都会遇到同样的问题吗?否则你就必须有破坏性变更。我的意思是,这就是为什么我们从Python2转到Python3,如果我理解正确的话。

是的,正确。这真的很有趣。这里面有几个要点。第一,我认为有趣的是,我们的计算机科学领域是多么年轻。是的,谈论C++,那快50年了,我想C可能超过50年了。但我们看看生物学、化学、数学等,它们有数千年的历史,那还只是有记录的历史,可能更多已经失传了。但正如你谈论Go和Python这些不同的语言,这很有趣。也许这是一个卖点,如果我能推销我的演讲让大家来听。

绝对应该来听演讲。这不是一个比较性的演讲,最后我不会说这门语言更好所以用它。但正如你在工作中谈到的,有时你用Go,有时用C++或Python或其他,为正确的工作选择正确的工具很重要。而且通常你在一门语言中体验不到不同的范式,所以你必须深入Rust,它强制安全,说好吧,这是关于所有权之类的。然后在C++中,那就是对所有东西都用std::unique_ptr,对吧?然后你写,好吧,在这一点上它是一门相当安全的语言,这很有趣。是的,我们获得了相同的保证。我的意思是,在某种意义上,如果你实际上在写现代代码的话。但当那篇文章出来时,我并不介意,因为这意味着就像COBOL程序员一样,我可以一直拿到更多报酬直到退休。

是的,直到ChatGPT能为我做所有优化,好吧,那我就坐着好了。所以,我必须深入问一下,从你写ASP.NET网页到今天,你的经验有多少?你这里做了一些很好的挖掘。我试了一点我能做的。

我想我第一个正式的实习,在纸上,是做了一些游戏写作和自由职业,但我想第一个正式的是做ASP.NET。我只能说,我写代码的方式和我学到的东西改变了很多。我认为这是一个有趣的事情,我们如何开始开发。也许知道我的旅程对其他人来说是一种安慰:看教程,看Stack Overflow,把东西粘在一起,尝试一点点迭代学习。我可能甚至没有在范式、最佳实践、不可变性这些层面上思考。参加不同的演讲可能会让人望而生畏,但同样,只是一次只取一小部分并尝试应用它们。

是的,所以在我做了一点游戏编程自由职业之后,我开始做网页开发,我很享受,快速的视觉反馈对我很有用。是的。

你知道,这很有趣,因为……我不想说这是年龄问题,但那就是……当我年轻的时候,一切都在焦点上。而当你最终获得那个弧线时,我记得当我第一次做C++时,大概前八年,模板就是一个禁区。我更像是一个应用程序开发者。当Kate和我在那个项目上工作时,我甚至没怎么使用标准库,因为我用的是MFC,那是一个小应用程序。但关键是,那些渐进的小片段,就像直觉演讲中我第二次讲的时候,我认为有趣的是,我们作为开发者学到的每一点,都增加了整体经验,然后只是让你成为更好的开发者。一些你认为最终与代码无关的小事。

软件开发的经验与迭代

甚至,作为一名教师,你可能……因为我注意到你大部分时间都在学术界。我做所有这些志愿者工作是因为我喜欢帮助别人,喜欢看到人们,尽我所能帮助人们成长。我没有像你一样获得博士学位,恭喜你,因为那需要很多学习和教育。但你知道我的意思,我们成长的部分,我认为这正是我试图引导人们参加会议、演讲的部分。你知道,努力研究那段代码,你正在学习一个教程并试图定制它,因为我不想要那个功能,我需要这个功能,然后你花一个半小时盯着SQLAlchemy,因为它有一些你不理解的自引用。

不,我不是在说个人经历,一点也不。好的。

是的,Mike。

不,抱歉,请继续。哦,我想说,我在想,换个角度,因为我在想在这种情况下问学生一个问题。你是这个理想的学生,这很好。不,我不这么认为,但请继续。

我在学校的那些年只是一场马拉松,那需要更长的采访时间。因为说起来话长。但我在想,关于这个话题,有没有ACCU或其他会议的演讲,你很多年前听过,实际上又重新回顾?有没有哪些演讲让你印象深刻,当你再次听时,你的视角改变了,或者你学到了全新的东西?我指出这一点是因为你从未……我不想开这个玩笑,但当我想到像你这样的人,还有Patrice Roy,你知道,那些真正经历过博士课程的人,他们是志愿者,和我合作很多,正在攻读博士课程。我哥哥也完成了博士课程,创作了新的艺术作品。我会说,你的论文和作品集,对我来说简直不可思议,让我震惊,因为我 literally 是从Commodore 64开始的学校,在CP/M机器上学习Turbo Pascal,就像我前几天谈论VR一样。

所以对我来说,我认为这指向你和Kate,回到基础的东西真的……那些是我最终更经常重温的演讲。我可能会说最近几年不那么多了,但当我们第一次推出那些演讲时,因为模板让我害怕,不仅仅是我。我工作的公司,当我刚开始在那里时,前四年我们有一些甚至更老的开发者,他们像我一样从Turbo C开始,模板——你最好不要在我们的东西里放任何模板代码。讽刺的是,我为一家信用卡处理器工作,美国运通、万事达、Visa、花旗、Eurocard之间没有太大区别,这非常适合模板,对吧?你知道,那是你想使用它的理想场所。但直到你能实现那个飞跃,我认为这是关键部分。

所以对我来说,回到基础的演讲,你的演讲,Klaus的演讲,我一直喜欢听Klaus的,他解释模式和东西做得很好。我也喜欢Kate的演讲,因为你提到了Kate,Kate提出了那些不只是……比如“命名很难”,那是我最喜欢的演讲之一,因为你真的……就像,哦,我只是随便起个名字,你不会多想。但是,是的,当你真正回过头思考我们如何试图编写自文档化代码时,命名真的很难。所以对我来说,那些就是……我仍然喜欢的演讲。但因为所有这些入门级、初级水平的演讲,你知道,同样,我没有在大学学习计算机科学,我学到的一切都是……拿起书,拿起教程,按照你的观点,让我们把算法写出来看看它是如何工作的。我知道它不如标准库里的好,但我理解了基础。再退一步,对于一个新人去找工作,当你必须坐下来,有人说,好吧,我想让你从头编写一个互斥锁。我为什么要从头编写互斥锁?我的意思是标准库里有一个,那不是他们想要的答案。我的意思是,你应该使用那个,但你最好还是能从头编写一个互斥锁,对吧?

都是为了积累知识。是的,是的。这真的很迷人,正如你所说,没有一种绝对正确的方法。因为经常重温,向他人学习,参加会议,你获得了很多这些知识。我作为教授尽力提供尽可能多的行业经验,但有时你只是必须深入体验一下,和这些能给你好建议的专家谈谈,轻轻推你一下,朝这个或那个方向。所以,Gail的研讨会听起来很棒,只是给出那些小小的建议金块。我在这里快速给你一个,Titus Winters总是给出关于软件工程的最伟大的建议。

他教了我一些我一直在更多思考的东西,这在我们行业确实需要更多思考人和他们如何协作。那就是思考当你写一段代码时,它预期存活多久。当然,他是从事标准库和这些伟大项目的人。这是我从未真正想过的事情。当我们谈论迭代代码和性能时,开始这个演讲时想,好吧,也许快速拼凑一些东西是可以的,但我最好在这里加个小注释,这只是暂时的,我们可能想重新审视这个,在git日志里创建一个小条目,以获得性能或切换到标准库或其他什么。所以这真的改变了我处理自己软件工程的方式,我花多少时间规划,这又是我们被教导要做的,但我觉得你必须亲身体验一下才能明白规划的重要性。

是的。所以我想用这个来结束,因为你提到关于东西存活多久很有趣。你知道,我在一次演讲中举了BCD(二进制编码的十进制)的例子,对吧?如果你不知道,查一下,你可以在一个字节里存储两个单数字。这仍然在ISO标准中,所有信用卡处理器都使用,因为在过去通过调制解调器拨号的时代,任何能节省字节的事情,包括二进制编码的十进制,你知道,16位卡号就变成了8个字节,在300波特率下,这很重要。但向后兼容性是Go、C++和所有其他语言在某个时候都必须处理的。我这么多年后仍然在处理BCD。太好了,我要剪辑这个给我的学生看,因为他们仍然在拖延。

给你。我很感谢你今天早上的时间,我期待在ACCU见到你。我从未去过,所以你知道,我们到那里后你可以给我指点指点,非常感谢。是的,非常感谢你的时间,Kevin,这很愉快,希望能在ACCU见到大家。到时候再聊,谢谢。

总结

在本教程中,我们一起学习了即将到来的ACCU 2024会议的亮点,探讨了Mike Shah教授通过图形化方式教授现代C++的理念。我们理解了性能作为一种货币的重要性,以及学习新编程语言(如D、Rust、Go)如何能拓宽我们的思维并影响我们在C++中的编码方式。我们还讨论了软件开发的迭代本质,从基础开始、逐步积累经验的价值,以及像Gail Ollis的研讨会这样的活动对初级开发者的重要性。最后,我们认识到向后兼容性和代码的长期维护是软件开发中持续存在的挑战。

004:与 Laura Savino 的访谈

在本节访谈中,我们将与 Laura Savino 进行对话,了解她如何从语言专业背景转向编程领域,以及她对教学、编程和即将在 ACCU 大会上发表主题演讲的看法。

从写作到演讲 🎤

大家好,我是 Kevin Carpenter,CPPcon 和 ACCU 的志愿者。我很荣幸能与 Laura 在这里交谈,她似乎经常发表主题演讲。

我没有在现场看到你在 CppCon 的演讲,但你讲得非常好。这是如何做到的?你是如何走到今天这一步的?

实际上,我更像一个写作者,而不是演讲者。当我经历一些事情,尤其是艰难的经历,或者让我困惑、觉得自己搞砸了、或者觉得别人需要改进的事情时,我喜欢把它写成故事,然后整理成能让其他人理解的东西。

我的许多演讲,其未言明的副标题就像是“人们,请做得更好”。例如,关于本地化或教学的演讲,我很喜欢讲的一个主题是:朋友们,我们一直在试图向他人传授信息,而实际上,外面有一整个关于如何教学的行业和学科,让我们从中学习吧。

教学是困难的。疫情期间,当各大学校都在追赶进度时,我被邀请编写一门 Linux 课程。我虽然用过不少 Linux,但没有计算机科学学位,所以他们请我写课显得更奇怪。

我编写了这门课程,并且它被使用了相当长一段时间。但编写完课程后,他们又请我去教这门课。

我二十多岁时有些自负,认为没有接受过任何实际教学培训也能教课。现在年纪大了,有了智慧,我为当时的学生感到抱歉。教学是一项技能,它很难,我非常尊重那些会教学的人。当然,现在我能教的东西肯定更多了。

教学的挑战与实践 🧑‍🏫

教学绝对是一项技能,不是吗?是的,而且总是很神奇,当你第一次把东西教给一个真正的学生时,你会发现:“哦,不,这完全没讲明白。”

我有时间也喜欢做志愿者。我曾为“女孩编程营”做志愿者,该项目用一周时间教初中女生在 Xcode 中用 Objective-C 制作一个应用。光是打开 Xcode 可能就需要两周,更别说在一周内学会了。我们有一些脚手架,Swift Playgrounds 让事情变得好了一些。

有一次,我坐火车从西雅图去波特兰教课,路上我写好了如何向孩子们介绍这个 Swift Playground。我觉得准备得不错了。碰巧我旁边坐着一个大约 10 岁的女孩,我问她:“嘿,你想学用 Xcode 吗?”然后尝试教她这些东西。结果我发现她甚至不知道应该把光标放在哪里来在函数里输入代码。我写好了函数体,但她不知道代码应该放在大括号 {} 之间。当你第一次看到代码时,这并不直观。所以,是的,正是不断的试错让老师变得优秀。

确实,在你尝试把某件事教给别人之前,你永远不会真正知道自己对它的了解有多不足。

我另一个例子是关于“变化”的。你知道,从理智上你知道一切都在变化,但只有当你真正意识到随时间发生的变化时,才会有深刻的体会。我的另一个类比是试图教我十几岁的女儿开手动挡汽车,她为此很讨厌我。

作为技术人员,我一开始会说:你知道,你踩下离合器,这样做,那样做。但最后,这变成了更多是感觉上的东西:你需要感受这个过程,慢慢抬起脚,感受特定事情发生的时刻。当你看到某人真正掌握它时,那种感觉非常棒。我想,在规模上给予一辆车的感觉,这至少是你某次演讲的一部分内容,那也是一个很好的演讲。

工作的意义与动力 💼

那个演讲的灵感来源于我在可汗学院工作的时候。那是一个拥有巨大影响力的非营利组织。有时我会想:我真的必须修复这个 bug 吗?我们真的要第三次重写数据层吗?我该如何在一件从宏观层面(比如为所有人提供教育机会)对我很重要的事情上,保持投入和兴奋?

是的,1000%同意。但日常工作中,你并不总能感受到你所构建的东西的重要性。

我同意。我喜欢我的工作,我为一家信用卡处理商工作。我的客户是你在加油站或别处刷卡的终端。这确实让我感觉很好,是的,这是信用卡交易,但你能因此买到杂货或在餐厅付款。

但我确实也喜欢在工作之外做些其他事情,来填补我们谈话中提到的那些空白。

关于 ACCU 大会 🇬🇧

我们来谈谈 ACCU,我觉得这很有趣,因为你和我在同一个“泡泡”里——我们俩都没去过 ACCU。

你兴奋吗?你去过伦敦吗?当然,我去过伦敦。实际上,我和我丈夫的第一次约会就是在伦敦,当时我们参加一个合唱团旅行,我们的第一次约会恰好是去看音乐剧《吉屋出租》。

但我没去过 ACCU。当有人联系我关于演讲的事时,我想:这听起来不错。但我不知道 ACCU 是什么,不过我没告诉他。我实际上联系了 Adobe 的其他 CppCon 演讲者,比如 Dave Sanko、Abrahams 和 Sean Parent,问他们:“我应该去吗?”他们说:“是的,如果你有机会,一定要去。”

我有一位英国同事坐在我附近,我跟他提到我要去布里斯托尔参加一个会议。他查了一下说:“哦,ACCU,我90年代末还在英国时去过,很酷。”

我很兴奋能去了解一个拥有自己历史的、长期运行的会议。是的,很抱歉打断你,但这也是同样的感觉。我开始在 CppCon 做志愿者,我想我第一次去是2014年,那时你大概刚开始演讲,至少从你的演讲列表看是这样。

ACCU 以及它运行了这么久这件事,对我来说是不可思议的。对于任何正在观看的人,如果你还没有行动,会议于4月17日开始,快去订票吧,还有会前课程。

从语言到代码的转变 🔄

让我问一下,当我试图更多了解你时,你是如何从法语和宗教研究转到 C++ 的?

是的,我主修法语和宗教研究,辅修日语,我的论文是关于应用语言学的,也就是语言教学。

我最初是一名语言教师,在小学教法语,在法国和韩国教英语。然后我意识到我不想再站在教室前面了,因为那每天都需要耗费巨大的精力。如果你状态不好,你所有的学生状态也会不好,这很累。

于是我开始在一所学校办公室工作。这个办公室有一个系统,他们每两周打印一次报告,然后打印一份新的,再从旧报告上把重点内容抄到新报告上——是物理意义上的抄写。我当时想:天哪,我们可以做得比这更好。因为我的工作就是抄写重点,我觉得这不能再继续下去了。

我当时的男朋友,现在的丈夫,他是一名程序员,他总是说:“Laura,你应该试试学编程,你会喜欢的。”我总是说:“不,我太有创造力了,那不是我的事。”但后来在用 Excel 时,我注意到:哦,我在做这些非常重复的任务。我写了越来越复杂的公式,学会了制作按钮来自动完成我的大部分工作,并开始用 VBA 编程。

VBA 不是一门优美的语言,但它让我实现了目标。那是我工作中最有趣的时光,我想熬夜完成它,因为我投入其中。我想,也许我丈夫是对的,也许我应该看看编程这东西。于是他教我用 C++ 学习数据结构和算法。

哇,这真是个跳跃。我常谈论从 Basic 到 Pascal 再到 Turbo C++,而你直接从 VBA 跳到了算法和 C++。

我真的以为,如果我不能随手画出一棵红黑树,我就找不到工作。我以为那是成为程序员的入门标准。

嗯,这某种程度上也是真的,取决于不同的面试流程。我所在的地方不使用那种流程,但我确实被一两次 Bloomberg 的面试难住了。他们让我从头实现一个 mutex。显然,答案是:我手头就有 mutex,为什么要从头写一个?这不是他们想要的思路,但我理解了他们考察的重点。

ACCU 主题演讲预告 🎯

我很兴奋能去 ACCU 见到你,你的主题演讲将关于什么?

演讲题目是“应对他人的代码”。我最初的标题是“处理你对他人的设计模式的感受”,然后我发现人们总是期待我真正深入讨论具体的设计模式。我说:不,重点不是代码本身,代码就是代码。但这是关于一个人类过程:看到一些你非常讨厌的代码和模式,你要求那个人改,他说不,然后你该怎么办?以及在你对代码应该是什么样子有了清晰认识之后,如何与不完美共存,或者与不符合你期望的代码共存?这很痛苦。

想到别人读我的代码时可能承受的所有痛苦……我肯定会去听你在 ACCU 的演讲,那是在周五。你是周五的开场主题演讲。所以,所有观看的人,你们应该来现场听 Laura 的演讲,在 ACCU 的大厅里和大家交流,来认识那些可能已经参会很久的人,我们会意识到我们错过了什么,或者认识那些新来的人,他们会想:哦,这就是这个机构的精髓所在。

我很兴奋能去那里,还有不到两周时间。是的,会很棒的。我很紧张,但我是一个截止日期驱动的开发者。

是的,即使是管理层规定的、比他们认为我们能做到的更快的截止日期。我很幸运,我的同事们非常通情达理。是的,我很期待。这也是给所有正在观看、但他们的同事不那么通情达理的人一个提示:去 Adobe 申请工作吧,Laura 可以帮你。

Adobe 确实是一个非常棒的地方,我已经在这里五年了。这与 ACCU 有一个有趣的相似之处:当我找工作时,我对人们在 Adobe 待的时间之长印象深刻。这种数据是无法粉饰的,你不能说“我们要加一张乒乓球桌”就了事。不,我们拥有待了10年、20年的熟练员工。这就是为什么我对去 ACCU 如此兴奋,因为一个会议能运行这么久绝非偶然,一定是某种极其有意、极其有价值的东西,让人们不断抽时间聚在一起。我迫不及待想成为其中一员。

总结 📝

在本节访谈中,我们一起了解了 Laura Savino 从语言教学到软件开发的独特职业路径,她对于教学作为一项技能的深刻见解,以及她对在 ACCU 2024 大会上发表主题演讲的期待。她的演讲“应对他人的代码”将探讨在协作开发中处理代码分歧和个人感受这一重要主题。我们期待在 ACCU 大会上与她相见。

005:ACCU 2024会议前瞻 - 与Mike Shah的访谈

在本节课中,我们将一起回顾一段关于C++、编程教育以及ACCU 2024会议的访谈内容。我们将跟随Kevin Carpenter与Mike Shah教授的对话,探讨现代C++教学、不同编程语言的比较、开发者成长路径以及即将到来的ACCU会议亮点。课程将涵盖从初学者到资深开发者的多个视角,帮助你理解编程语言学习的迭代过程以及行业会议的价值。

概述:访谈背景与嘉宾介绍

Kevin Carpenter以专业志愿者的身份主持本次访谈。他提到自己为许多会议提供志愿服务。

他正在与Mike Shah教授交谈。Kevin是在Mike的YouTube频道上了解到他的,该频道拥有超过16000名订阅者。

他们此次对话的主题是即将于4月17日至20日举行的ACCU 2024会议。会议之前还有一些精彩的会前课程。

Kevin欢迎Mike的到来。Mike表示感谢,并表达了对ACCU会议的期待以及参与对话的愉快心情。

章节 1:相识与会议经历

Kevin表示感觉认识Mike很久了,但记不清初次见面的具体场合,可能是在CPP Con或C++ Now等会议上。

Mike确认他们几年前在C++ Now会议上见过,并且之后有过多次交流合作,每次交谈都很愉快。

Kevin提到Mike去年参加了ACCU会议并发表了演讲,主题是关于C++教学。他询问Mike作为教授,教学是否是其主要工作。

章节 2:现代C++教学理念与实践

Mike确认了去年ACCU演讲的主题是“如何教授现代C++:一次一个像素”。这个主题灵感来源于Kate Gregory多年前在ACCU会议上的一次演讲。

Mike坦言自己从Kate Gregory这位出色的演讲者、程序员和培训师那里借鉴了许多教学技巧,并将其融入自己的课程中。

他的教学核心是通过图形化元素让C++编程变得有趣。这能帮助学生理解他们所做事情的目的,使用C++提供的工具和抽象,保持学习动力,并看到这门语言的强大之处。

Kevin联想到Logo编程语言,猜测那可能是Mike更早的编程启蒙。Mike确认了这一点,并提到自己的编程起源确实是Basic和Logo,这个点在他的演讲中也曾被问及。

Kevin非常认同从图形化角度切入C++教学的理念。他提到今年CPPcon会议将有一个大型游戏开发专题,并指出几乎所有游戏都离不开C++,尤其是在追求性能的场景下。

Mike补充道,即使是简单的2D卷轴游戏,在手机等设备上运行也需要考虑节省电量,优化算法。他引用了一句名言:“性能是一种货币”,强调即使当前不需要极致性能,考虑性能也能带来更长的电池寿命、更流畅的体验和更正确的代码。他认为这是C++的一个优点,即不会在性能上设限。

章节 3:开发中的迭代与经验积累

Kevin联想到Kate Gregory常说的“能运行就是一个特性”,并询问Mike在开发中是否也经常经历代码的迭代改进过程。

Mike认为开发过程总是迭代的。即使是经验丰富的开发者,在录制教学视频前,也通常对问题进行了长时间的思考。在实际开发中,比如需要一个排序算法时,可能会先使用标准库中的现成方案,或者为了快速验证而编写一个简单的暴力排序,尽管知道其性能不佳。

他强调理解“为什么”要这样做非常重要。随着开发经验的增长和在不同领域(如图形学、编译器)的深入,所使用的性能技巧和代码架构方式都会发生变化,这非常有趣。

Kevin分享了自己为CPP Online准备演讲时的经历。他第二次讲授关于“工具使用直觉”的演讲时,中间学习了一些Python,这让他对编程有了新的感受。与C++相比,用Python编写代码时迭代速度更快,思维方式也不同。

Mike完全赞同,并鼓励大家学习新语言,因为这能改变思维方式。在不同语言中,实现高性能的方法可能截然不同。

章节 4:面向初学者的会议与职业建议

Kevin提到Gail Ollis将在ACCU会议前一天(4月16日)主持一个为期一天的“早期职业日”研讨会,面向初级开发者。他询问Mike,作为一名教师,认为这类活动对初学者有多重要。

Mike对此表示大力支持。他认为对于刚开始职业生涯或作为会议志愿者的学生来说,参加这类活动非常有价值。他特别提到Gail Ollis拥有多年的资深软件工程师经验,后来又研究了团队协作和软件构建心理学,是给初级开发者提供建议的绝佳人选,这些建议可能为他们节省数年时间。

Kevin以自己的经历补充道,对于非科班出身、自学成才的开发者,面对编码面试(尤其是白板编程)可能充满挑战。他建议新的开发者或应届生可以关注Gail的会前研讨会。

章节 5:从D语言看编程范式与语言演进

话题转到Mike今年在ACCU的演讲,主题是关于D语言如何帮助理解C++。Kevin表示理解,因为他自己使用Go语言后,也开始以新的方式在C++中使用元组。

Mike解释了他开始认真研究D语言大约是在两年前。D语言由Walter Bright等人创建,其设计借鉴了C++的经验教训,尤其是在90年代现代C++兴起之前。

学习D语言让他以不同的方式思考编程范式。D语言强调模板和元编程,编译时内省和编码等范式在D中已经成熟,而这些正是C++当前重点发展的领域。D语言就像一个游乐场,让他可以学习这些概念,然后将其理解转化到C++中。

Kevin询问D语言与同样注重内存安全的Rust语言相比如何。Mike分享了他对Rust的初步印象:Rust强制开发者思考所有权等问题,通过借用检查器在编译期提供保证。他认为Rust在某种程度上处于D和C++之间。

Kevin提到最近关于编程语言的一些讨论,并分享了Go语言在保持向后兼容性方面遇到的挑战。这引发了一个思考:是否所有语言最终都会面临类似“破坏性更新”的问题,就像Python 2到Python 3的过渡一样。

Mike同意这个观点,并指出计算机科学领域相对年轻。与数学、化学等拥有数千年历史的学科相比,编程语言几十年的发展史还很短暂。他再次强调,他的演讲不是要比较语言的优劣,而是像Kevin在工作中根据需求选择Go、C++或Python一样,重要的是为任务选择合适的工具。通常,单一语言无法让你体验所有范式,因此需要深入不同的语言去学习。

章节 6:开发者成长路径与知识回顾

Kevin询问Mike,从早期编写ASP.NET网页到今天的教授生涯,他的经验和代码风格发生了怎样的变化。

Mike承认自己的代码和所学知识已经发生了巨大变化。他回顾自己的起步阶段也是查看教程、浏览Stack Overflow、将代码拼凑起来,并尝试逐步学习更多。当时可能并没有深入思考范式、最佳实践、不可变性等如今在技术演讲中听起来可能令人生畏的概念。他只是每次汲取一点点知识并尝试应用。

Kevin深有同感,他分享了自己早期回避使用C++模板的经历,以及后来在项目中逐步学习和应用标准库的过程。他认为开发者积累的每一点经验,无论是否与代码直接相关,都会增加整体经验,从而使其成为更好的开发者。

章节 7:重温经典演讲与持续学习

Mike反问Kevin,是否有多年前听过、如今重温后又有新领悟的技术演讲。

Kevin坦言,由于自己非科班出身,是通过看书和教程自学,因此“回归基础”类的演讲他经常重温。特别是在职业生涯早期,模板等概念让他和团队中的一些老开发人员都感到畏惧。

他特别提到了Mike、Klaus Iglberger和Kate Gregory的“回归基础”演讲。这些演讲帮助他跨越了理解上的障碍。他也非常喜欢Kate关于“命名很难”的演讲,因为这提醒我们,编写自文档化代码时,命名至关重要。

Kevin认为,对于新手和准备面试的人来说,理解基础知识至关重要。例如,面试中可能要求徒手实现一个互斥锁,虽然实际开发中应该使用标准库的实现,但理解其原理是必要的。

Mike赞同这种持续学习和向他人学习的方式。他分享了一个从Titus Winters那里学到的重要观点:在编写代码时,需要考虑这段代码预期存活多久。这个观点改变了他对软件工程的看法,影响了他花在规划上的时间。他意识到,有时快速拼凑一个临时解决方案是可以的,但最好加上注释,标明未来可能需要重构或替换为标准库实现。

Kevin以一个生动的例子结束了这个话题:二进制编码十进制(BCD)。由于历史原因(如早期调制解调器传输需要节省字节),BCD格式至今仍在使用于信用卡处理等ISO标准中。这说明了向后兼容性是Go、C++等所有语言最终都必须面对的问题。

总结:会议展望与结束语

在本节课中,我们一起学习了Kevin Carpenter与Mike Shah教授的对话。我们探讨了Mike通过图形化教学让C++变得有趣的方法,理解了软件开发本质上是一个迭代和学习的过程。我们比较了C++、D和Rust等不同语言的设计哲学,认识到学习多种语言可以拓宽思维。我们还回顾了开发者从新手到专家的成长路径,以及重温基础知识和行业专家建议的重要性。最后,我们了解了ACCU 2024会议为初学者和资深开发者提供的丰富内容,包括Gail Ollis的会前研讨会和Mike关于D语言的演讲。

Kevin感谢Mike的时间,并期待在ACCU会议上见面。Mike也表示感谢,并希望能在ACCU见到大家。

006:手握AI机器人进行编程

大家好,下午好。希望你们午餐愉快。

我是Amir Kirsh,我们将一起讨论手握AI机器人进行软件编程的未来。

首先简单介绍一下我自己。我是特拉维夫大学学术学院的讲师、培训师和顾问,也是特拉维夫核心C++会议和同名聚会小组的组织者。

本次演讲的目标是,正如我们所说,讨论AI机器人辅助编程的未来,进行一些实践,并共同思考如何为我们所处的这个新时代做好准备。

演讲分为七个章节。我不会在这里一一列举,我们将逐一探讨。在每个章节结束时,欢迎大家评论。我们将进行简短的讨论或问答。

在每个章节结束时进行。请大家做好准备。

让我们从引言开始。

引言:什么是大型语言模型?

我们正在谈论大型语言模型。它们是什么?

在一位朋友(ChatGPT)的帮助下,我得到了这个定义:我们谈论的是生成自然语言文本的人工智能系统。它们也许能够理解和生成类人语言文本,可以跟踪对话并考虑先前的互动,模拟真实的人类对话。

最终,它对于编程也很有用。

OpenAI的首席执行官Sam Altman认为,语言模型只是被编程来尝试预测下一个词,这是真的。是的,如果你听说过,它只是一台试图预测下一个词的机器。是的,这就是它的本质。

但不要仅仅把它看作只是那样。它比那要聪明得多。我的意思是,也许我们人类也是这样工作的。动物也可能是某种试图繁殖和生存的原始机器。

而Grady Booch则非常保守,对AI的真正力量持怀疑态度。他认为,将大型语言模型的简单性与具身感知生物的复杂性进行比较是相当可笑的。

Chris Dancy则说,我的配偶从我们结婚那天起就一直在预测我下一个要说什么。

所以,预测下一个词并不完全是这些机器正在做的事情。它们预测的是下一个标记(token),实际上,一个词可能会被拆分成几个标记。这很重要,因为如果你使用非免费模型,你需要按标记付费。所以,拥有更少的标记或返回更少的标记成本会更低。

无论如何,你可以看到左边的问题:“C++之后最好的软件编程语言是什么?”它被分成了11个标记。我想向你展示这个问题的结果。无论如何,这来自一个开源的标记器,你可以在链接上看到。在右边,你可以看到一个真实的C++程序。你会看到标记器将单词拆分的方式,很可能和编译器会做的方式类似。我的意思是,我看到了inmainpars。是的,我的意思是,如果我必须为此付费,我可能会直接去掉std::。无论如何,这就是标记器读取标记,然后预测下一个标记。

它能思考吗?它能感受吗?Grady Booch说,不,除非一台机器能写一首十四行诗、创作一首协奏曲,或者也许写一首歌。

而Eric Niebler,也许你们从C++社区认识他,则争论道:你怎么知道我们的思想和情感不是由于符号的偶然排列而产生的呢?

所以这是一个相当哲学性的讨论。你知道吗,维基百科上97%的文章,如果你跟随第一个链接,经过一些步骤后,你都会到达“哲学”条目。所以最终一切都指向哲学。你可以阅读相关内容。

因此,LLM的用途相当广泛。我的意思是,我们可以用它来提问和回答,比如“什么是LLM?”我们可以用它来写文章、写带有押韵的歌曲、文本摘要和编码。

让我们短暂地偏离一下,写一首歌。

我有点好奇,也许想和大家分享一首歌。让我们在ChatGPT的帮助下完成。

我向ChatGPT提问:“我正在ACCU(一个C++会议,虽然它不限于单一语言,但主要关注C++)上做演讲。我想要一首与会议相关的短诗,押韵会更好。”你可以理解,我在这里不是按标记付费的,我使用的是免费版本。

我得到了这个回复,我真的很喜欢,第一次尝试就成功了。我不需要修改它。这就是我得到的:“这听起来像是一个有创意的想法。”它总是会给出好的反馈。“这是为你在ACCU演讲准备的短诗:在现实世界中,代码在ACCU相遇,思想在此竞争。从专家到新手,这是一个所有人闪耀的天堂。语言各异,但心之所向一致,在追求解决方案的道路上。以代码为画布,我们愉快地创作,白天创新,夜晚分享洞见。让我们聚集在一起,组成强大的社区,品尝我们共同归属的地方。在这学习和乐趣的旅程中,我们庆祝编码的核心,直到工作完成。”这太棒了。哇,相当有艺术感。顺便说一下,这张图片是真实的,不是AI生成的。我在来这里的路上拍的。在座的英国朋友能猜出这是哪里吗?

那么,有什么新东西呢?我的意思是,神经网络以前就有了,自然语言处理以前也有了。新的是什么?

新的东西是性能的提升、新型模型、更大更强大的模型、无需人工干预即可构建、对上下文有更好的理解以及多语言支持。所以最终,这里有一些新东西,那就是模型的能力,最终让我们得到了通过图灵测试的结果,即与我交谈的人类。

然而,我们目前必须考虑一些限制。首先是计算能力需求。我的意思是,NVIDIA没有足够的机器,或者台积电没有足够的能力来生产我们未来所需模型所需的硬件。所以这里存在某种计算能力的限制。它依赖于数据质量,这是一个问题。我的意思是,你怎么知道你给模型喂的是高质量数据而不是垃圾数据呢?垃圾进,垃圾出。对因果关系的理解有限,创造力也有限,尽管我会说我写不出这么好听的歌。理解情感的能力有限。产生幻觉。我们人类也会这样。但模型没有答案时,它会觉得有义务回答些什么。所以你会看到某种幻觉。你可以在提示词中说:“如果你不确定,就说无法完成。”然后你可能会得到更好的结果。它可能被诱导给出虚假或不道德、种族主义、非政治正确的答案。

它能取代程序员吗?我的意思是,我们都在这里寻找这个问题的答案。我认为问题不在于它能否在未来几年取代程序员,很可能不能。问题是,它能否让程序员更高效或显著提高效率?这就是我们想要回答的问题。如果它能让我们更高效,那就意味着我们中的一些人需要成长,因为其他人会借助LLM来完成工作。

在OpenAI或其它LLM出现之前,开发者编码两小时,然后调试六小时。有了ChatGPT,你编码五分钟,然后调试24小时,至少漫画里是这么说的。我的意思是,如果情况是这样,那我们就安全了,好吧,没什么可担心的。但很可能不是这样。我将和大家讨论一些例子,展示我们并非完全处于那种境地。

那么,让我们尝试探索一下。它是一个好工具还是坏工具?它会改变工作方式吗?我们如何利用它?这就是第一章的结束。如果有人有任何评论或问题。

是的,这里有一个。谢谢。非常有趣。我之前想过这个。其中一个限制是,AI会限制人类的创造力吗?例如,你甚至没有尝试去创作那首诗,它就已经完成了。所以它限制了你的创造力。这是一个好问题。结果会是人类的创造力受到影响、受到限制吗?因为,我的意思是,今天如果你开车去某个地方,即使是以前知道怎么去的地方,你还能做到吗?不是所有人都能。你还记得电话号码吗?我以前记得电话号码,今天不行了。所以,是的,它会改变我们的思维方式。我想,在某种程度上,是的,可能会。我们应该意识到,也许我们应该谨慎,不要走得太远。我的意思是,不要失去我们的创造力。这是一个很好的观点。还有其他评论吗?

是的,我想补充一点。在教学中,我教没有编程背景的商科学生编程。在ChatGPT出现之前的几年里,我布置给他们的任务效果很好。有了ChatGPT之后,他们基本上都失败了。不是课程不及格,而是他们被给的任务失败了,因为他们使用ChatGPT,并试图用ChatGPT来调试ChatGPT一开始就弄错的东西。所以从这个意义上说,是的,我们实际上面临着风险,即人们在学习初期就失去了学习的力量,因为他们可以轻易获得好工具。这是另一个非常好的观点,它既关系到教学,也关系到我们与初级开发者合作的方式,我们对使用这类工具的期望是什么。我会在后续章节中涉及其中一些要点。

我将继续我们的下一章,这一章将探讨:我们这里有一个银弹吗?

银弹是否存在?

Sautter刚刚提到了“银弹”这个词。我不知道你们是否知道这个术语。我指的是,可能你们处理过狼人,所以从那个神话中知道。但在软件工程领域,Fred Brooks在1986年的论文《没有银弹》中讨论过。他预测从1986年起的十年内,软件开发的生产力不会有数量级的显著变化。他阐述的原因是,当你观察软件开发所需的工作量时,你会发现工程师需要处理两个障碍:偶然性(accident)和本质性(essence)。偶然性部分就像是复杂的语法。好吧,也许我们可以用语法更简单的另一种语言。但本质性是将问题领域转化为形式化的东西。他的论点是,这种将问题领域转化为形式化语言的本质,在他论文发表后的未来十年内不会被解决或显著改进。

这张照片是2005年在奥斯陆拍的,他们当时在讨论同一篇论文。我们的现状如何?我当时在那里做了一个关于Java的演讲,请原谅我。开发的本质和偶然性仍然是讨论的话题。它仍然是一个问题:我们解决的是本质问题还是偶然问题?我们改变的是事物的核心还是外围?所以,是的,更好的质量和调试工具很重要。它们真的改变了我们的工作方式吗?能改变本质部分的东西可能需要一些外部技术。可能需要一些不仅仅是想法的东西。

其他可能改变但并未显著提高生产力的东西,比如新的范式。面向对象编程在当时正在发展,但它并没有显著改变生产力。新的软件语言和快速应用开发、更好的编译错误、互联网。互联网改变了其他行业,但对软件开发来说改变不大。我的意思是,我们可以更容易地获取示例和文档,但它并没有让我们高效得多。

在其他领域存在银弹。如果你想比较一下,银弹存在吗?是的,存在。火药是赢得战争的银弹。工业革命、机动运输、疫苗、计算机。互联网在某种程度上是其他领域的银弹。卫星。这个列表的共同点是什么?一些技术,因为通常如果没有技术,那么如果只是做事方式的改变,很可能已经有人做过了。门槛太低了。哦,我们刚刚做了。所以需要某种外部技术来改变事物。并不总是这样,并不总是。你可以想想那些没有外部技术却显著改变做事方式的东西,比如福步里跳(Fosbury Flop,背越式跳高)。没有任何外部技术,只是做事的方式,我们跳得更高了。写作、民主,也许。或者也许就像技术一样。社会发展有银弹吗?现在,这是一个问题。我的意思是,如果答案是肯定的,我们将在未来几年看到某种显著的生产力变化。我们仍然不知道。我不知道答案。我稍后会问你们,你们怎么想?

所以这是一个问题。我们到了第二章的结尾,也许你们可以分享你们的想法。你们认为我们拥有某种银弹吗?

我认为,在我看来,这是另一种语言。在某种程度上,这是另一个抽象层次。这是我认为的一种方式。这是另一种语言,这意味着又是另一种语言。是的,它需要你,就像任何技术一样,需要你多花一点精力去使用它。可能它太容易了,就像我们提到的那样,容易让人睡着。这意味着,就像面向对象编程出现时并没有显著改变我们的生产力一样。

哦,你说面向对象编程是一个重大变化。拥有……好吧,好吧,是的,我有想法。

是的,对我来说,测试驱动开发、有意识的单元测试和测试自动化是生产力的重大变化,不仅对我自己,对我教过的所有学生都是如此。关于面向对象编程的评论:面向对象编程是第一个让程序员能够编写自己的类型,并将他们想要的任何东西建模为实体的概念,而不仅仅是约定俗成。我认为这是一个大事。这不是小事,是大事。类型的建模。这是一个突破,掌握在更多人手中。

所以我们听到了两个观点:首先是广义上的测试,其次是拥有自己的类型,这是重要的。问题是,对于测试来说是一个点,对于另一个,它是否真的改变了行业的生产力?我们会保留这个问题。是的,我评论一下。谢谢。

我认为关键的一点是,AI能否识别出XY问题?有多少次你遇到过这样的情况:你和同事或朋友谈论,你试图用某种方式做某事。他们会想,你为什么用那种方式做?有更好的方法。这是一个很好的观点。我的意思是,它可能会让我们偏离方向。它关系到创造力和思考。如果它阻止我们思考,可能会解决我们要求它解决的问题,但这些可能不是我们代码中真正需要解决的问题,这就是XY问题。我们试图解决我们头脑中的问题,我们需要做的事情。但不,不,这不是做事的方式。应该有另一种方式。

是的,抱歉。我的观点是,另一个显著改变事物、可能促成我们今天所知的面向对象编程的东西,是Parnas等人的工作:模块化、信息隐藏。你知道,这为库、框架打开了大门,我们可以重用这些东西,而不必再次编写相同的代码。所以模块化、库,我认为它们都出现在Fred Brooks的论文之前。所以他指的不是那个时期。

第二个。计算能力。哦,说司。调查中反。都的话。好吧,那是个好观点。是的,我需要重读那本书,因为我认为他没有说十年。是的,他论证生产力仍然成立。我需要重读它。我们线下再讨论。我们会检查一下。我们会检查的。好的,我们回到我最初想看的:Brooks将其定义为10倍的改进。就我个人而言,我一直对这个10倍着迷。我尝试研究不同的东西,但我从未找到过10倍。我找到过1点几倍,好吧,2点几倍。你们认为LLM会是10倍吗?我不认为我们能达到那里,但这取决于我们如何衡量。我的意思是,一个使用LLM的程序员能取代十个不使用的吗?这就是问题。

好吧,让我们尝试探索一下。我的意思是,让我们尝试探索一下,因为接下来的问题是,我们如何利用大型语言模型来改善我们的生活和开发周期?这不仅仅是编程。

我会从需求开始。所以从需求开始,AI模型能验证需求吗?我会从验证需求开始,这非常重要。AI有能力处理相当多的标记,其中一个模型可以处理150万个标记,最终可能比我们能处理的上下文还要多。

让我们用非常小的例子来尝试一下。识别不一致性。我在这里用ChatGPT 3.5试过。稍后我会用更新的版本和其他东西再试。我会给你几个句子,你应该告诉我是否有不合理的地方或矛盾。“ACCU会议每年举行一次。”我认为它每年不止举行一次,也许现在是一年一次。无论如何,这是句子。你应该按原样理解。“今年,ACCU在四月举行。”“今年,ACCU在夏季举行。”LLM会怎么说?这是回复:“ACCU会议每年举行一次”是一个合理的陈述,表明会议每年举行。第二个也是合理的。然后第三个与前一个陈述矛盾,等等,因为四月不在夏季。这很好。我的意思是,它很小,但可以扩展。这是一个开始。我可以用更大的语料库来检查它。它会以某种方式改变。它会朝那个方向发展。在某种程度上,比我们做得更好。我想。

好吧,让我们尝试挑战它一下。让我们尝试挑战它。好吧,这次:“ACCU每年举行两次,在四月和夏季。”这听起来是可行的。我的意思是,那应该没问题。然后第三个句子说:“这个陈述仍然与前一个矛盾。”为什么?它每年举行两次。你没跟上吗?是两次,不是一次。不,它把我弄糊涂了。好吧,也许它把我弄糊涂了,因为先前的上下文还在。但不,你有了新的上下文。我不知道。它把我弄糊涂了。

所以我想,好吧,让我们用ChatGPT 4试试,它内置于Copilot中。所以我去了Copilot聊天。我们稍后会一起玩玩这个,然后把同样的东西给了Copilot,它运行的是GPT-4。这是同样的东西。然后Copilot给出了这个说法:“作为一个AI编程助手,我在这里帮助处理软件开发主题。恐怕我无法验证会议的日程安排。”其余合理。好吧,我必须换种方式。

好吧,我想在编写代码之前检查以下需求的有效性。这是一个编码作业,好吧。好吧,这是一个编码作业。我现在和你在一起了,作为一个AI编程助手。我没有实时数据或验证现实世界事件的能力,但因为它是一个编程作业,需求可以在编程上下文中解释。这里和那里。最后没有矛盾,因为没有矛盾,因为每年两次,GPT-4对我来说没问题。顺便说一下,问题在于,我是一个编码助手,因为我通过Copilot来做。如果我回到Copilot,用“一次”,它会弄错。我猜第一个需求和第二个、第三个之间似乎存在矛盾,即使我……哦,不,它弄对了。弄对了。有一次。是的。好吧,所以我们可以用这些工具进行需求验证。这是一个非常小的例子。但是,是的,我们会继续。

顺便说一下,Copilot提出了一个建议。你想让我引用什么吗?它非常积极。我说,它刚刚给了我这个。我说,是的,它说,你想问我如何用代码表示这个吗?我说,是的,让我们试试这个提示。然后它提出了我认为pair会很好。

好吧。我们也可以验证完整性。我会给你一组服务需求。告诉我它们是否合理和完整。“服务应获取城市符号。”“布里斯托尔,服务应返回该城市的天气。”一个不错的服务,对吧?不错,合理。去编程吧。然后,当然,要改变,因为需求不完整。我会说,所以提供的命令不完全清楚,可能需要一些额外的信息。现在,这很好,因为它帮助我创建了回复给发送这组需求的系统工程师的电子邮件。我的意思是,我需要打字。我做不到。我的意思是,需求太狭窄了。我准备好了电子邮件。我缺少什么?城市符号是什么?有城市符号列表吗?输入的格式是什么?输出的预期格式是什么?我的意思是,这里有很多问题。好问题。它提出了。它自己提出了。频率是多少?我没想到那个。但是,是的,这是一个频率问题。我们应该缓存它吗?我们应该每次都去天气服务吗?我不知道。总的来说,提供更详细的信息会很有帮助。我的意思是,这很完美。直接把它发回给项目经理,或者随便谁。

“我会给你一组服务需求。”另一个:“服务应该获取两个数字,表示速度和时间。服务应该返回给定速度和时间的行驶距离。”我会称这个服务为一个函数。但是,哦,你提供的需求是合理和完整的。它们指定了输入,等等,等等。我不认为它是完整的。所以我再次争论。假设他们希望需求非常严格。你会建议修改它们吗?如果是,哦,我的意思是,我在推动它。请让我知道那里是否缺少什么。好吧,如果你确实希望它非常严格,那么也许你应该指定测量单位,也许你应该指定输入值范围的任何限制。我没想到那个。但是,这是一个合理的补充。添加到需求中:特定的数据类型和格式,指定服务应处理的错误条件,等等。所以,是的,我认为它比我想得更好。我会使用它。

那么,AI模型能可视化需求吗?对于我使用的玩具示例,我会说是的。对于更大的需求文档,我也会说是的,但我们必须检查一下。我们必须做到那一点。而且,是的,我推测它会做到。

它能编写需求或设计吗?所以人们尝试过。Jonathan Müller,我不知道他今年是否在ACCU,但他让ChatGPT为intstring的转换创建了一个文档提案。我的意思是,C++缺少这个。有这个需求。ChatGPT提出了一个相当好的提案。他说也许我只需要稍微修改一下。是的,提案的格式是提案。他不是唯一尝试过同样事情的人。他们彼此不知道。Vittorio Romeo尝试了完全相同的事情。我的意思是,人们都缺少intstring的转换。所以他问了非常相似的事情,又得到了一个提案。是的,它可以通过非常简单的提示编写文档。

所以对于需求,我认为理由相当充分。你们怎么想?不是专门关于需求,而是当你说它能做或不能做的事情时,它的可重复性如何?如果我尝试同样的事情,我会得到相同的结果吗?它受上下文影响。所以它记住了当前讨论中先前讨论的上下文。你可能得到其他结果。我认为如果上下文清晰,提示相同,我们应该得到相同的结果,但可能取决于模型。我看到有人摇头说不。可能取决于其他因素,但我不认为可预测性或确定性的问题对于它有多高效的问题非常重要。我们不是确定性的。我倾向于认为我们偶尔也会有保护性。所以它应该是,即使它不是确定性的,也许甚至不具确定性是件好事。你可以再试一次,它会再思考一次。是的。

还有其他评论吗?Amir,是的,应该记录下来。谢谢。是的,所以我认为它是一个非常强大的工具,可以验证某些事情。但从你的例子来看,你不得不多次推动它才给出正确答案。所以我会说,也许它不是独立正确的。所以你必须持续监控。是的,我在工业界担任团队领导和开发经理时,也不得不推动我的员工。我的意思是,他们不会在第一次提示时就做对事情。所以我已经习惯了。我的意思是,这对我来说不是什么新鲜事。但是,是的,在某些情况下,你需要问不止一次。你有孩子吗?我的意思是,我们知道。好吧,这对我们人类来说并不是一个真正的挑战。如果我们没有得到我们想要的,我们只需要再问一次,通常用另一种语气、另一种措辞。

让我们继续下一章,关于编码和测试。我们将在这里花一些时间,因为这是核心。它真的能编码吗?它真的能帮助完成编码任务吗?

编码与测试

让我们从基于需求生成系统这个概念开始,这并不是什么新鲜事。Ville添加了一些关于这个主题的论文。他和他的团队创建了一个系统,可以以非常正式的方式处理需求,非常正式的需求,并生成一个系统。正式的需求。但这和我们在这里看到的不一样,因为根据相当模糊、不那么正式或不接近实际代码的需求来创建代码,这是一个挑战。所以,我不会把它当作“哦,事情已经做过了”的例子。是的,但方式不同。如果你有一种非常正式的需求语言,那么也许它本身就是一种软件编程语言。好吧,所以我不会深入讨论那个。我们想要更开放一点的东西。

然后Sheper写了关于人工智能和这些模型的文章。他试图基于GPT API创建ChatGPT。所以有GPT API,他想为它创建Web界面。他说他给自己设定了挑战:我能用10个提示完成吗?我会告诉你紧张感。他在10个提示内成功了。他有了一个基于GPT API模仿ChatGPT的Web应用程序。我的意思是,他还没有用1个提示创建GPT。但那个可以。所以,是的,它可以帮助我们创建实际的代码、实际的应用程序。我们稍后会玩一下GPT,然后玩一下Copilot。

让我们从GPT开始,计算距离。我们之前讨论过距离。所以,你能实现一个获取时间和速度并返回距离的函数吗?不需要主函数。因为如果我不说不需要主函数,我也会得到主函数,你知道,还有很多我不需要的东西。所以,是的,当然。在这里:double time, double speed, time * speed。但是这不是我想要的,因为我想要类型安全。我这里没有类型安全。我的意思是,测量单位是什么?我想使用测量单位。当然,添加类型安全的一种方法是有一个用于距离的结构体,包含三个double。不,我必须再问一次。这不是我想要的。我想要一些能容纳测量单位的东西。所以我回来了。不,不,不,你想要像Chrono库那样的类型安全。我的意思是,我就像在和一个人说话。如果我在和人说话,我会说,就像在chrono库中,你知道的。然后它提出了:“哦,我为误解道歉。如果你想要那样,也许我会添加chrono。”是的,有timer。对于公里,我会有type def。类型不是类型安全,typedef只是类型的另一个名字。我想要类型安全。duration是类型安全的。所以我有点担心公里每小时。我知道那听起来像,好吧,我需要那个作为公里每小时。但如果有人提供英里每小时,代码不会发现。所以我想要更强一点的东西。我被建议不要使用整个命名空间。我对ChatGPT说。这是一件事。我还希望typedef被替换成更类型安全的东西。

好吧,所以你是对的。考虑到这一点,这是更新后的示例。我得到了某种类,这不是我要求的。我不知道。不,不,你明白这是一次讨论。现在,可能更新的模型会更快速地理解我。但仍然,我很急切。我不会放弃。所以我想基于全局乘法函数计算距离。我的意思是,我没有要求一个类来改变整个东西。只是,你知道,一个全局函数。如果我需要类型安全,只需要维度单位的类型。然后它提出了,好吧,我明白了。距离,好吧,距离被模板化在类型上,可能是测量单位速度。我不知道。最终它提出了看起来相当符合我想要的东西。我想我仍然有一些保留意见。你看到我对代码中这些T1T2的保留意见吗?它实例化T1T2,它们可能没有默认构造函数。使用declval会更好。但我停在这里了。我说,好吧,听起来像是我想要的。让我们看另一个。这相当困难。我的意思是,我自己写可能更快,我不知道。也许不。

与0比较。我遇到了这个与0比较的问题,我需要一个效率函数来检查向量中的所有元素是否为0。该函数应该适用于任何可平凡比较的元素类型,这意味着它们可以按位比较。所以我得到了带有类型特征检查“可字面比较”的东西。语言中没有这样的类型特征。有一个提案,但没有。所以我不能用它。我的意思是,我可以试试。编译器不会接受。其余的也不是我想要的,因为其余的是一个循环。我的想法是我不想循环遍历元素。我的意思是,这不是按位比较的有效方式。好吧,所以我必须在提示中更有说服力,试图让它理解我想要什么。似乎与更大的块(例如64位,使用memcmp)进行比较,我只是在填充它,可能更高效。哦,是的,我可以做类似的事情。然后某处有一个比较调用。仍然,我认为夏天有is_comparable,但我没有。所以我告诉它is_comparable的使用不是标准的。你能用别的东西替换它吗?是的,也许可以用is_copyable。不,那不是一回事。它不会工作。我的意思是,它会检查一些东西。它会编译。这不是你需要检查的东西,好吧。它提供的代码并不差。我的意思是,分块的想法,使用了不存在的类型特征,还有一个注释,关于第一个元素在缓冲区上是否对齐的问题。如果不对齐,使用memcmp可能会有问题。也有关于这个的提案,我想是之前由Inbal Levi提出的。无论如何,无论如何,它有帮助。哦,我为什么到这里了?因为我按了什么。让我们回到这里,再做一次,好吧,从这里继续。我的意思是,你可以理解它并不总是有帮助,这需要努力。

可变参数包短路求值。我需要处理可变参数包但要短路求值。所以,如果函数调用包中的某个函数返回true,那么就在那里停止。好吧,相当简单的任务。但是,你知道,我宁愿让别人为我写。所以我得到了类似这样的东西,但最终没有工作。我的意思是,如果我们看代码,那里有些东西坏了。所以我回来说:“似乎停止条件stop_condition_met没有设置为true。”有一个标志,但没有任何东西把它设为true。所以它真的不工作。所以,你知道,我承担了审查代码的工作。我在和团队里的一个初级成员合作,如果你明白我的意思。所以在某种程度上,我可能放弃了一个初级成员,又找了另一个。我不知道他是否更好。所以我得到了别的东西。现在,stop_condition_met被设置为true了,但不是在它所在的上下文中,在它被访问的地方不可访问。我的意思是,代码不会编译。它会编译代码。它会看到错误。然后也许它可以获取错误并自我修正。我们会讨论那个。如果这能做到,我们将达到另一个阶段。我们已经在那里了,因为有工具正在做这件事。所以最终,它提出了一个可行的东西。然后我创建了,我问它,你能用C++17的折叠表达式做吗?因为它不是。它是C++14。我更喜欢C++17。我在用C++20。所以,哦,这是用C++17的,可以工作。那好吧。我不认为我需要那里的变量。但如果他喜欢,我可以接受变量,那可以工作。最终,好吧,这并不简单,我的意思是,如果需要写的话,有点复杂。我用它作为我在课堂上展示的一些例子。好吧,不完全一样,但现在我有了代码,我可以轻松使用它。

让我们谈谈解释代码。在某些情况下,我看到代码。也许我必须维护代码,但我不理解那里有什么,我需要解释代码。或者我得到一个任务,要对一段代码做些什么,调用它。但我首先需要理解那里有什么。所以我给了它一段代码,你看不懂。它相当小,但有些复杂,是一个基于算法创建算法的工厂代码。我不认为它以前见过这段代码。我不认为,你知道,哦,它知道它看到了什么,因为它以前在别处见过代码。不,那是新的。我得到了这个解释,相当好。我的意思是,当我读它时,我说,哦,你解释得比我好得多。除了一些错误。所以一个错误是,它错误地说那里有一个函数指针。那不是函数指针,我认为是一个函子,一个函数对象。好吧,在另一个地方,它说某种东西包含一个公共向量。那不是公共向量,是私有向量。好吧,每个人都会犯一些错误。我们不会仅仅因为这个就放弃它。就这样。最终,解释相当好。然后我继续另一个关于使用该代码的问题。我说,好吧,关于上面的代码,你能解释一下下面的吗?有一个带宏的函数。很复杂。好吧,我得到了相当好的解释。你会得到幻灯片,你可以读,不是现在,因为它相当长。所以最终,它不是废话。它不是,你知道,只是堆砌词语。你读它,它是有价值的。

所以让我们总结一下:为一个简短但相当复杂的代码片段提供解释相当好。有两个可以轻易发现和修复的小错误。这些错误没有改变事物的本质。所以我认为用它来解释代码是有价值的。我会使用它。

关于修复代码呢?在我将要展示的代码中,有一个故意的错误。好吧,这是一个故意的错误,因为我添加了这个错误。我修复了错误,然后我说,哦,让我们问问Jepity(可能指ChatGPT)看它是否能修复它。所以我不知道你是否会看到。也许我会,你知道,指给你看错误。你现在能看到错误吗?我正在为公共返回类型创建一个提取器。在里面,公共返回类型使用类型特征std::common_reference<Ts...>。它编译了。但这个类型实际上并不代表我想要的类型。它代表别的东西。它不代表公共类型,它代表改变公共类型的类型特征。我缺少的是_t调用。好吧,它编译是因为最终,这是一个类型,但不是我想要的基础类型,即类型特征内部容纳的类型。我的意思是,这是关于可以做到的。所以我把它给了ChatGPT。它给出了错误的解释。解释不是实际问题。一些,你知道,这个那个,一切都是错的,但是。它提出了新代码。在正确的地方改变或添加了两个字符_t。所以我得到了结果。我的意思是,我得到了应该修改的东西,但解释是错的。如果它能编译,我不在乎解释。我现在可以解释它。所以我猜,你知道,我不知道。我更喜欢能工作的代码。我会放弃解释。最终,它提供的代码修复了问题。我认为在某种程度上,它看到,哦,当我看到类似这样的东西时,当我看到,你知道,类似这样的东西时,通常会在那个地方加上_t。所以无论如何。

再次,我每次都在按错键。你看到同样事情的历史。所以让我们回去再按一次。好吧。让我们继续。

好吧,这里有什么问题?我问ChatGPT,你能检查下面的代码吗?这个山谷。他们有一个两个元素的向量,1和2。我压入3。然后我来到第三个元素。不,不是第三个元素。我把42放在那里。问题是越界,越界。我们讨论过那个,对吧?它要么被编译器越界检查,要么被ChatGPT检查。我的意思是,你应该选择其中一个或静态分析。所以我问ChatGPT,你看到这里有什么问题吗?它提出了,是的,你试图访问不存在的元素。那很好。它理解代码。在某种程度上,我会用它作为我的静态分析或代码审查过程的一部分。看看代码,你看到有什么不对劲的吗?它可能会指出一些没问题的地方。好吧,我会,你知道,加入我的逻辑,但是。那很好。

让我们总结一下:第一个案例的代码被完美修复了,但解释是错的。案例2,代码被完美修复了,解释也完美。我的意思是,它非常,你知道,你看到问题,但它也看到了。让我们想想,我们能有一个系统让代码自我修复吗?我的意思是,你有代码,你会问ChatGPT或LLM提供代码。然后它会编译代码,看到错误,反馈给模型,最终给你能编译甚至通过测试的代码。这是个好主意。人们已经开始这样做了。所以有一个Python项目,Bryce明天会在这里演讲。我想也许在C++ On Sea会议上,我是组织者之一,他在C++中创建了一个项目。所以,是的,它正在朝那个方向发展。也有商业项目在做类似的事情。是的,它将比仅仅获得未测试或未编译的代码强大得多。是的,如果你有某种插件或额外的系统,它可以编译代码。

关于测试呢?这次我用Java,使用BDD(行为驱动开发),我想,并尝试将一些需求转换为JBehave。这个例子归功于Ron Rosenman,他想出来的。哦,你看不懂。无论如何,它提出了一个非常好的测试例子。然后我说,哦,这不是我想要的,因为你没有参数化测试。然后它提出了你仍然看不懂的东西,但确实参数化了测试。最终,我有了一些可以使用的测试。我可以问,你能给我运行这个JBehave代码的Java代码吗?是的,它可以工作。所以它可以为你创建测试。

现在,让我们为GitHub Copilot做一个现场演示。对于Copilot,我们有两个不同的独立扩展。我将在Visual Studio中使用。一个是Copilot Chat,它的工作方式与ChatGPT非常相似。另一个是Copilot本身,它在IDE中。你只需输入,就像自动完成一样,它会建议代码。所以是时候进行现场演示了。让我们做一些。

我有一个预先准备好的项目,名字很花哨,Project 1。已经有一个MyString类,带有pair。好吧,甚至有一些移动语义。我这里还有。你看到了吗?也许你现在看到了。好吧,这是我的字符串类。我还有一个mainmain是空的。

我们能对我们的空main做什么?开始输入main,对吧,这就是我要做的。我得到了些东西。出于某种原因,Copilot决定我的main需要包含几个MyString对象。我想这是因为它有MyString类的上下文,对吧。除此之外。所以,哦,你有一个MyString类。所以也许你想在main里放这些。是的,那会是使用MyString的好例子。好吧,那很好,但它不编译。既然它不编译,也许我可以按Alt+/。然后salfix。我认为问题是包含。好吧,所以。谢谢,是的,也许我可以让它编译。非常感谢。哦,是的,好吧,所以它说。我在main里。它给我看了一些东西。这不是我之前有的,因为我在这里看到它实际上把整个MyString类放到了main里面,这不是我之前尝试的,这是一件好事。你知道,现场演示总是会得到新东西。只是把整个MyString字符串类放进去。让我们接受它。这不是我想要的,但让我们接受它看看。好吧,现在在main里,我有#include <iostream>#include <utility>和整个MyString字符串类。不,那不是我要的。现在我几乎可以肯定,当我之前尝试修复包含时,我的意思是,它不是确定性的。也许它记得我之前尝试过,然后我删除了包含。它记得它做错了什么。所以现在它试图取悦我,不,在演示期间,我会做别的事情。无论如何,哦。是吗?好吧,我确实想要MyString。我不认为我需要utility。现在我认为我们有了一个不错的main。通常给出的例子是is_prime函数。检查数字是否是质数,是的,正是我想要的。是的,这就是我想要的。所以我只是输入了注释,然后我得到了函数。我不喜欢它,因为它不是实现is_prime最有效的方式。但它是有效的。我的意思是,这是你会使用的代码。习惯它。好吧,你会在你的代码库中有机器注入的代码。它有效,但并不总是你会写的最佳代码。是的,它会随着时间的推移而改进。

我们还能有什么?哦,我们可以开始,你知道,用斐波那契写代码。是的,它知道他们想要斐波那契。我得到了斐波那契。好吧,这些很简单。但我用它来做更复杂的事情。比如,我需要翻转一个矩阵,我开始写函数,然后,我得到了代码。不是我想要的代码。然后我知道,我不想要这种折叠矩阵系统点的方式。然后我得到了修复,是的,它工作得很好。所以这是一个非常简短的例子。

让我们看看MyString。哈。这里缺少一些东西。我不知道你是否会注意到。我会快速过一遍。好吧,你必须非常敏锐。我快速过一遍。缺少什么?是的,析构函数,好吧。数你的点数,我会按回车。Copilot会同意。是的,你缺少一个析构函数。那很好。我有时会忘记这个。我现在有了。那很好。

我能做的其他事情,我可以按Alt+/,然后问它,文档。你能请为它写文档吗?我会得到什么吗?你不认为。我猜我会得到文档说“这是一个析构函数”。然后我可以取消接受,如果我接受,它会进入,哦,我们记录了整个类。好吧,另一件我不喜欢的事情是sizeint。我会用size_t。所以这次让我们用Copilot。Copilot知道上下文。所以我可以在这里问Copilot。修复。我也可以问优化。你知道吗?我这里确实有一个优化。如果我要求优化,也许它会两者都做。你看到需要的优化了吗?或者STLn不工作。让我们添加正确的包含。包含,是的,包含iostream。给我iostream。让我们和sling。是的,cstring是为了,好吧,所以。哦,我想让你看看是否有可以更高效的地方。你能在哪里看到它?你能在哪里看到它?这是委托。代码字体太大,跟不上,但某处我们会看到它。哦,是的,是的。这一行不是最有效的方式。因为我们已经知道大小了。如果你知道大小,你不需要寻找终止符。你可以直接内存复制,或者你可以调用std::copy,它可能做同样的事情

008:D语言如何改进我的现代C++编程,反之亦然

概述

在本教程中,我们将探讨D编程语言如何与现代C++相互借鉴和促进。我们将首先了解D语言的基本概念和特性,然后分析这两种语言在理念、语法和工具上的异同,最后总结学习多种编程语言对开发者思维和技能的提升作用。

D语言与C++:P08:编程语言思考与D语言预览

编程语言的选择与学习价值

上一节我们概述了本课程的目标。本节中,我们来看看为何要学习多种编程语言。学习新语言能带来新视角、新范式,并帮助你更深入地理解原有语言的概念。例如,接触函数式编程可能让你在C++中更自然地使用算法和范围。

以下是学习新编程语言的一些常见原因:

  • 接触新范式:例如函数式、逻辑式或并发编程模型。
  • 扩展视野:理解不同语言如何解决同类问题。
  • 成为更好的程序员:在新语言中学到的概念(如Rust的所有权模型)可以应用于主用语言。
  • 好奇心与乐趣:探索本身就是一种动力。
  • 为工作选择合适工具:不同领域(如游戏、数据科学、系统编程)有各自的主流语言。

D语言初探

D语言是一种静态类型、拥有系统级访问能力且语法类似C的通用编程语言。它设计目标是让代码写得快、读得快、运行快。D语言由Walter Bright于1999年创建,后来Andrei Alexandrescu也加入了开发。它拥有多个编译器:参考编译器DMD(编译快),以及用于生成优化代码的GDC(GCC后端)和LDC(LLVM后端)。

D语言内置了丰富的工具链,包括包管理器dub、静态分析工具和代码格式化工具。它在游戏开发(如《量子破碎》的动画系统)、金融交易、科学计算等领域有实际应用。

一个简单的D语言“Hello, World!”程序如下:

import std.stdio;

void main() {
    writeln("Hello, World!");
}

编译命令为:dmd hello.d && ./hello

D语言的一个强大特性是编译时函数执行(CTFE)。许多计算可以在编译期完成,提升运行时性能。例如,以下代码在编译期对数组进行排序:

import std.algorithm, std.stdio;

void main() {
    // 编译时排序
    immutable sorted = [5, 1, 4, 2, 3].sort;
    pragma(msg, "Finished compilation: ", sorted);
    writeln("Starting program.");
}

运行此程序只会输出“Starting program.”,因为排序和打印信息都在编译期完成。

D语言与C++:P09:D语言核心特性及其与C++的互补

D语言的核心特性

上一节我们预览了D语言。本节中,我们将深入探讨其核心特性,并对比C++中的相应概念。

1. 内存安全与默认行为
D语言在安全方面提供了良好的默认设置。变量默认被初始化,原始类型大小有明确定义(如int总是4字节)。它支持内存安全子集@safe,并可通过属性控制垃圾回收(GC)和内存管理。

@safe void safeFunction() {
    int x; // 默认初始化为0
    // ... 安全操作
}

@system void unsafeButNeeded() {
    // 可能包含指针运算等“不安全”但必要的操作
}

@nogc void noGarbageCollectionHere() {
    // 此函数及其调用的函数不得分配GC内存
}

2. 切片(Slices)
切片是D语言中一个关键特性,它提供了一种不拥有数据的数组视图,类似于C++中的std::spanstd::string_view

int[] arr = [0, 1, 2, 3, 4, 5];
int[] slice = arr[2..5]; // 包含元素 2, 3, 4
writeln(slice); // 输出 [2, 3, 4]

3. 统一函数调用语法(UFCS)
UFCS允许a.fun(b)被解释为fun(a, b),使代码链式调用更清晰。

import std.string, std.stdio;

void main() {
    string name = "  Mike  ";
    // 传统调用
    auto result1 = strip(strip(toUpper(name), " "), " ");
    // UFCS 调用 (更清晰)
    auto result2 = name.strip.toUpper.strip;
    writeln(result2);
}

4. 纯函数(Purity)
函数可被标记为pure,保证其没有可观察的副作用,这有助于优化和并发。

pure int add(int x, int y) {
    return x + y; // 无副作用,易于在编译期求值
}

5. 模板与编译时编程
D语言的模板语法更简洁,使用!代替尖括号,并支持强大的编译时内省和代码生成。

// 模板函数
T add(T)(T a, T b) {
    return a + b;
}
// 使用
auto result = add!int(5, 3);
// 或类型推导
auto result2 = add(5.0, 3.0);

6. 混入(Mixins)
混入允许在编译时将代码字面量插入到指定位置,是强大的元编程工具。

// 定义一个包含代码的字符串
mixin(`int extraVariable = 42;`);
writeln(extraVariable); // 输出 42

7. 范围(Ranges)
D语言的标准库大量使用范围(Ranges)作为算法的基础,取代了传统的迭代器对。

import std.algorithm, std.range, std.stdio;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/accu-2024/img/ef5b6f24a376103fd27e06e91d834599_10.png)

void main() {
    auto numbers = [1, 2, 3, 4, 5];
    // 使用范围链式处理
    numbers.filter!(n => n % 2 == 0) // 过滤偶数
           .map!(n => n * 2)         // 每个元素乘2
           .writeln;                 // 输出 [4, 8]
}

8. 结构体与类的明确区分
在D语言中,struct是值类型(栈分配,无继承),class是引用类型(堆分配,有继承)。这比C++中仅默认访问权限不同更语义化。

struct Point { // 值类型
    int x, y;
}

class Button { // 引用类型
    void draw() { /* ... */ }
}

D与C++如何相互促进

D语言如何改进我的C++编程:

  • 更注重默认安全:D默认初始化变量、强调不变性(immutable)等特性,促使我在C++中更关注资源管理和初始化。
  • 切片与视图:熟练使用D的切片后,我能更自然地理解和使用C++的spanstring_view
  • 范围抽象:D的范围模型更简单(只需emptyfrontpopFront),降低了理解C++迭代器类别和范围概念的难度。
  • 契约式设计:D内置的inout代码块用于前置/后置条件检查,启发了我在C++中更规范地使用断言或期待未来的契约特性。
  • 强大的编译时计算:D默认积极的CTFE让我在C++中更积极地思考和使用constexpr
  • 丰富的标准库:D标准库包含JSON、Zip等处理模块,让我意识到C++生态中类似工具库的价值。

C++如何改进我的D语言编程:

  • 性能至上思维:C++对零开销抽象和底层控制的强调,让我在D编程中也会关注汇编输出和性能分析。
  • 手动内存管理经验:在C++中管理内存、资源生命周期的经验,让我能更好地在D中控制GC或使用@nogc
  • 与C语言的互操作知识:C++与C的紧密关系,帮助我理解并高效使用D语言内置的C编译器来集成现有C库。
  • STL算法思想:“信任标准算法”的C++哲学,让我能直接应用到D的丰富算法库中。
  • 庞大的生态系统经验:配置构建系统(如CMake)、使用分析工具(如Valgrind)等C++生态技能,同样适用于D项目。

总结

本节课中,我们一起学习了D编程语言的核心特性,并探讨了它如何与现代C++相互补充和促进。关键要点在于,学习D语言能让你通过不同的“默认设置”和范式来思考问题,例如其强大的编译时功能、默认安全倾向和统一函数调用语法。反之,C++的底层控制能力、性能优化思维和庞大的生态系统知识也能提升你的D语言实践。掌握多种语言能让你成为一个更全面、更具适应性的开发者。鼓励你花一小时尝试D语言之旅,亲身体验这种跨语言学习带来的思维拓展。

009:窄契约与 noexcept 在 C++ 中天生不兼容

欢迎来到 ACCU 大会。我是 John Lakos。我在 Bloomberg 工作。这是我首次在公开场合进行这个演讲。

这个演讲最初是在 C++ 标准委员会上,在 EWG 和库工作组的一次联合会议上提出的。它对于改变一些人对重要问题的看法起到了关键作用。我认为这是我在标准委员会上反响最好的演讲之一,即使是不太认同我观点的人也给予了肯定。你们即将听到的内容可能颇具争议,这很好。通常我会花很多时间展示幻灯片,但这次我会少讲一些,留出时间供我们讨论。我认为这很重要,但至少你们会先了解我要说的内容。

这是另一张幻灯片。这里有一条叫做 Lakos 规则 的准则。

有多少人听说过 Lakos 规则?前面有一些人知道。很好。

Lakos 规则不是我命名的。它是事实上的叫法,出现在马德里会议上。当时有人提出了一个叫做 noexcept 的“疯狂”操作符。它是在 2010 年的匹兹堡会议上提出的,因为没有它,移动语义就无法工作。

它的作用是允许我们在编译时修饰一个移动操作,并声明我们知道它不会抛出异常,这样你就可以编写更好的算法。仅此而已。

直到人们开始变得“聪明”,并注意到如果加上 noexcept,编译器生成的代码会少一点。当然,如果代码更小,它一定运行得更快。

于是,我们得到了一个神话:如果我给东西加上 noexcept,它们会运行得更快。在某些古老的异常机制上,这可能是真的。但在今天,这并不正确。事实上,零开销异常模型中的“零”,并不意味着小,而是意味着零开销。关键在于,在某个点上你必须进行分支,要么走这条路,要么走那条路。这就是那种“零”。当发生异常时,你分支到的代码在磁盘上;当没有异常时,你分支到的代码就在缓存里。所以没有额外的指令。我要直接告诉所有相信“noexcept 加得越多越好”的人:不。

这基本上就是整个演讲的内容。现在,我来解释为什么。

但我想让你们知道,我来这里是为了告诉你们:noexcept 不好,除非它能提高程序速度。如果它不能提高程序速度,那就不要用它。这就像你不会服用没有帮助的药物一样,因为它有副作用。这就是整个演讲。现在,我们将快速浏览其余部分,以便我们可以就这个话题进行辩论。

本次演示的目标是阐述 Lakos 规则。哦,我得告诉你们 Lakos 规则是什么。Lakos 规则是:不要在窄契约上使用 noexcept。就这么简单,不要这样做,因为那会变得非常糟糕。

除了移动操作,noexcept 在标准库规范中是不需要的。

这并不是说实现不能加上它。那是另一回事,有利有弊。

我们将讨论的是:对于标准库规范,我们想做什么?对于标准库的实现,我们想做什么?对于第三方库,我们想做什么?以及我们自己在什么时候想使用 noexcept

在开始之前,我要说明,这个演讲有一半内容与异常无关。事实上,一半内容只涉及普通的语言契约。现在,我要提一下,如果你对 C++26 的契约感兴趣,我们的朋友 Timur 稍后会给你们做一个精彩的演讲,讲述我们过去六年所做的疯狂工作,甚至包括自 2012 年以来所做的工作。当时我首次为标准引入了基于宏的契约。我要告诉你们,我当时一直推进到了全体会议,然后它崩溃了,因为有人第一次听到了“宏”这个词。下一次我们尝试时,我们把它放入了语言,然后又引发了争议,因为我们想尝试做出改变,而一旦某样东西进入了语言,要改变就极其困难,于是又失败了。这都是在达成真正好东西的过程中发生的。John Spicer 和 Timur Doumler 两人共同主持了 SG21,并在管理一群非常聪明但并非总是意见一致的人方面做得非常出色。这很有趣。

另外,在契约讨论期间出现的一个直接结果是,我们一直在尝试解释的“原则设计”。这是对另外两篇论文 P3004 和 P3005 的宣传。它们解释了“原则设计”这一概念如何用于解决工程问题和其他问题,以及在 LWG 中制定策略。你可能会觉得这很复杂。是的,但如果你试图与 500 条反射器评论争论,那也很复杂。所以在某个时候,你必须撕开创可贴,真正分析它。但这次演讲不是关于原则设计的,然而,我认为原则设计对于解决涉及许多有强烈意见的人和众多解决方案的难题至关重要。

现在,我们来讨论适用于所有软件工程的东西。

什么是函数契约?

函数契约是一种双边协议。它通常用简单的自然语言书写。它代表了在满足前提条件的情况下所承诺的前提条件和基本行为。

现在,我想问一个问题。我会比平时讲得快一点。对于真正的初学者,我们可以把整个时间花在演讲的前半部分。前半部分相对简单明了。后半部分则一点也不简单明了。事实上,我昨天在 Bloomberg 内部讲的时候,自从写完这些材料后就没再看过。我站在 Bloomberg 的人面前,心想:我得读幻灯片了,因为我记不起为什么把这段放这里。所以昨晚,我花了大约六个小时复习那些我从未讲到的幻灯片,并添加了一些内容。哦,是的,看吧。所以这些东西很难。但都是真的。当我们讲到那些难的部分时,我会说,所有这些难的东西都是为了向你们证明这一点。如果你们想休息一下,在我用大约 20 张幻灯片来证明我刚才说的话时看看手机,请便。但请在最后我讲到真正要点时回来,然后我们会有时间辩论,这会很有趣。

函数契约示例

我将通过一个函数契约的例子来说明,例如 half。这是一个声明,这是一个契约:“返回数值上为指定 x 值一半的值,向零舍入。”

这是 sqrt 的契约:“返回一个值,其表示在数值上尽可能接近指定 x 的正平方根。”

这里有两个英文契约。你不知道它是如何实现的,这没关系。如果它不这样做,我们就有问题。然而,第二个有一个前提条件。它说:“除非 0 <= x,否则行为未定义。” 我说“行为未定义,除非”并非偶然。如果你说“行为已定义,如果”,你就是在把自己置于风险之中,因为当你说“除非”时,它可能仍然因为其他原因是未定义的,你只是说这绝对是坏的。另一种方式你说这绝对是好的。几乎总是,你会是错的。我向你保证,这非常微妙。所以请注意,“行为未定义,除非”是有目的的,并且能保护你。

基本行为

基本行为是什么?它必须符合明示的后置条件,但涉及更多。它涉及结果。它还遵守任何承诺的行为。例如,函数隐式地以 O(1) 时间运行,或者函数是线程安全的。所以,如果你没有说一个函数在多线程下不工作,如果出于某种原因它不工作,你其实不必说;如果它工作,你也不必说。因为某些事情错得太离谱,你必须告诉我们它是否那么“坏”。所以某些事情是隐含的。

实现定义的行为

实现定义的行为是指在有效域内未指定或未强烈暗示为基本的行为。那么,它的例子是什么?这里我们有一个 point 和一个 sort。契约是:“对从指定起始地址开始、长度至少为指定对象数量的连续 point 对象范围进行排序,使其 X 坐标值的表示按非递减顺序排列。”

在这个契约中,是否有实现定义行为的空间?我看到有人在点头,很好。我们会快一点。是的,这里有一个例子。在我们调用这个之后,这是一种可能的解决方案。这是另一种。

这两种都是完全符合要求的有效结果。这对大家来说有意义吗?好的,这部分我会加快速度。似乎大家都明白了。如果这对你来说很困惑,那么你可能需要回去看视频,放慢速度看。但我确实想快一点,因为我说过,我想在最后辩论。

那么这个呢?在 half 中是否有实现定义行为的空间?快速看一下,然后告诉我有没有空间。有人举手吗?我看到没有手。如果认为没有空间,请举手。好的,我看到后面有一些人举手,让我们看看我们是否正确。不,没有空间,好的。

那么 sqrt 函数呢?是否有实现定义行为的空间?好的,我看到后面有人。他的名字是 Kevlin Henney,如果他说有,那一定有。我说“不”,但带了一个星号。

那么为什么我们会这么想呢,Kevlin?哦,好的,赌注是什么?这与 double 的预期精度有关,“在数值上尽可能接近”可能相当模糊。好的,“在数值上尽可能接近”意味着你有一个 double 的离散表示,它们是量化的。如果你在一侧或另一侧,换句话说,哪个数字最接近或同样接近。这就是我们的意思。所以我要说的是,你可能在它的任一侧。至少在有些情况下,你会发现有两个同样接近的数字。我要说它们是相等的。有两个是相等的,但你必须证明这一点,而我实际上可以证明相反的情况。等等。

它可能返回正零或负零。所以你是对的。那是关于……是的,你已经赢了。你已经赢了。但我要证明这不是你想的那样。相信我。但是你没有涵盖 double 中一些有趣的情况。好吧,让我,让我讲到这个。我会让你知道的,但结果会出来。永远不要打断别人正在做错事的时候。好的,很好。Kevlin,如果我能同意你,我会立刻向你承认我错了。

让我们看看这个,注意 floatlong double。这是一个略有不同的签名。我们假设 floatdouble 短,long doubledouble 长。我们就做这个假设。如果这是真的,那么绝对有空间,因为我们可以证明这是可能的,这是证明。

换句话说,我们可以选择最大的 Z,其平方可以精确表示为 floatlong double 可以精确表示 9 * Z * Z,但 float 只能表示 2 * Z4 * Z。所以我们有一个例子,它们距离相等。所以如果我们选择正确的签名,是的,但是 doubledouble,没有。我可以证明,我确实证明了。但你是对的,因为 -0.0。我被抓住了。

这是另一个。如果你传入 inf 会发生什么?如果没有指定会发生什么,对吧?inf 是一个数字,而 NaN 不是。NaN 会失败。所以变得非常复杂。你明白我的意思吗?我在这上面错了很多次,昨天还被指出来,因为我从未想过 NaN。但 NaN 不是问题。你赢了。现在你赢了。但我想指出,这很微妙。就在边缘,对吧。

观察

函数的声明告知了契约,这确实是重点。而且,Kevlin 又赢了。

关于这一点,稍后还有更多内容。

前提条件

前提条件必须为真:任何输入、任何相关对象或程序状态。否则,调用该函数的行为是未定义的。

未定义行为是指没有要求的行为。现在,请记住我即将谈到的矛盾之处在于:未定义行为没有要求。我们不想做的是,有一个不允许的域,然后对其施加要求,导致我们以后无法更改。记住这一点。

继续。

关于 vector 的前提条件。vector 的构造函数有任何前提条件吗?Kevlin,让你先说。vector 的构造函数,默认构造函数,有任何前提条件吗?我想不出。宇宙在运转,宇宙在按预期运转,你懂的,你懂的。我不再为难你了,但我必须给你这个,因为当有人说,等等,如果它分配内存怎么办?让我们希望它能。即使它分配了,它仍然是已定义的,因为如果它分配内存但失败了,它必须抛出一个 bad_alloc 异常。所以标准已经涵盖了这一点。这不可能发生。

vectorcapacity 呢?有任何前提条件吗?答案是没有。它是一个 const 函数。

push_back 呢?这个难一点。push_back 有任何前提条件吗?你们怎么想?谁认为它有前提条件,是什么?没有吗?有人吗?前提条件,有。唉。

我能想到的一个前提条件是,如果 vector 达到了其理论上的最大容量,你就不能添加另一个元素。所以如果 vector 达到了最大理论容量……我不太确定那具体是什么意思。嗯,我是说,假设 vector 由两个元素组成,比如 point 或两个 int,其大小受其类型限制,比如 uint64_t,所以不能超过……我不熟悉如何告诉 vector 它的最大大小是这个。

不,我不是说你能告诉它,而是它受其实现限制。无论如何,因为 vector 必须存储其大小计数。vector 可以自发增长。无论如何,这个问题的答案是,要么 vector 会做,要么它会和原来完全一样,因为我们有强异常安全保证的属性,并且会抛出 bad_alloc。就是这样。所以答案是没有。但我想向你们展示,事情并不像看起来那么容易。

front 呢?front 是一个 const 函数,对吧?它做什么?返回对第一个元素的引用。那么前提条件是什么?好的,所以必须有一个第一个元素。所以答案是肯定的。

这个呢,operator[]?它有任何前提条件吗?是的,显然,索引必须小于 size

最后,这个呢,我最不喜欢的函数 operator at?它有任何前提条件吗?有人认为它有前提条件吗?好的,答案是没有。它是完全定义的。没有留下任何空间。你对它做的任何事情,它都会给你一些东西。如果你越界了,它会抛出异常。

所以这些是宽契约。

猜猜是谁命名了“宽”和“窄”契约?有人知道是谁吗?是我。在 2012 年,我被告知:“John,没人会使用这些术语。” 嗯。对吧?

有人听说过“层级化”吗?猜猜是谁命名的?好吧。

显式前提条件

它有显式前提条件吗?有,没有。没有。那么 sqrt 呢?它有显式前提条件吗?看看颜色。有。

你们怎么想?这里我们不能做什么?如果我们传入 NaN 会怎样?如果我们检查 x 是否小于等于 0 会怎样?我们会返回 false。有趣的是,我们写这个的时候没想到 NaN,但它对我们有利,因为如果我们用另一种方式说,如果它不是……那么我们就失败了。所以总是用双重否定的方式写你的代码。

这个呢?它有隐式前提条件吗?我不会伤害你。有。有人能猜到是什么吗?这很愚蠢,甚至没人会谈论这个,但是……如果你传入一个不确定的值呢?哦,不能这样做。所以这很糟糕。我们不谈论这个,但它是一个前提条件,你不能这样做。这是未定义行为。事实上,即使函数根本没有使用它,仅仅是尝试传递它的努力,也是未定义行为。我们现在说这个吗?

所以这里有一堆我甚至不会讨论的事情。我只是把它们放在幻灯片上,不要做这些事,因为它们很愚蠢。

隐式前提条件

并非我们可能陈述的所有事情都必须明确陈述。要写入的参数必须处于已构造状态,要读取的参数必须处于已初始化状态。我们知道这一点,所以我们不用再谈论它了。

声明

好的,接下来。声明是否影响契约?显然,当你看到不同长度的东西时,你看到它会影响契约,对吧?double sqrtfloat long double sqrt 是不同的。

声明提供了许多快速方便的信息,这就是为什么人们想把 noexcept[[noreturn]] 之类的东西放在签名中,以便快速获取信息。问题是,有时这行得通,有时行不通;有时这太多了,所以我们将看到什么时候可以,什么时候不行。

每个参数、返回类型都是显而易见的。所以看看这个,看到所有这些噪音了吗?它们可以消失,显然这样更好。因为我们在签名中看到的任何东西,都是一个事实。那是契约的每一个部分。先写英文契约没有错,但我总是把它写在签名下面。所以那是标题,这里是文章故事的第一段。不幸的是,由于工具的原因,我们必须这样做。这对工具更好。但你必须记住,你在这里读到的内容在逻辑上遵循签名。所以你顺着 /// 的列表往下看,你会发现 /// 指的是什么。这是我们在 Bloomberg 的新编码标准。我不高兴,但我必须接受我不高兴的事情,因为大多数人都高兴。好吧。

继续。

契约式设计

有人听说过“契约式设计”吗?好的。有人听说过“里氏替换原则”吗?有没有非常、非常、非常勇敢的人想告诉我它们有什么相似或不同之处?啊哈。好的举动。

顺便说一下,维基百科帮不了你。它会对你撒谎。我不是在开玩笑,我有证据。我稍后再告诉你们。

契约式设计是一种理论。它通常应用于虚函数。它说,派生类型中的函数应该具有……不更窄的域,并且范围应该不更宽。对吧?这是基本思想。他用不同的词说过,但就是这个意思。现在,作为一个启发式方法,这通常是正确的。但它绝对不总是正确的。我们可以轻易证明。我可以给你一个类比。例如,我可以有一个在基类中具有窄契约的东西。基类函数 f 只对非负数有定义。并且函数的名字是返回其参数。所以函数的范围只有非负值。现在,我从中派生一个函数,其域定义在整个整数范围内。因此,其范围定义在整个整数范围内。这完全没问题。然而,有些人会说,等等,这不符合契约式设计,因为范围比域大。这就是事情出错的地方。

我再给你一个例子。假设我有一个基类,叫做 Car。现在,如果我从中派生 ToyCarCar 可以从 0 加速到 60,ToyCar 不能从 0 加速到 60。所以人们会说这是一个糟糕的设计。我倾向于同意。但在实践中,我们一直这样做,而且有充分的理由。我可以派生 RaceCar,速度可以达到 180。所以显然它可以达到 60。没问题。但是如果我派生 ElectricCar 呢?我们有很多从 Car 派生的电动汽车。例如,因为我有一些东西,在我打开数据库并读入数据之前它不工作。这就像给电动汽车充电。所以我们发现,层次结构在程序的整个生命周期内并不总是有效的。但这没关系,因为有一个初始化阶段,我们给汽车充电,获取数据,然后释放线程。所以在程序的大部分时间里,它完全没问题,但在启动时不行。但编译时包括启动,所以这是无稽之谈。

所以不要相信这是一个绝对规则。把它当作你教高中生思考的东西。记住这一点。

事实证明,如果你有一个深的继承层次结构,中间可能有各种各样的混乱,但你有调用接口。假设我们有 A、B、C、D 和 E,每一个都派生自前一个。调用接口可能是 B,而实现可能是 D,派生类 D。这就是实际实现 B 的东西。无论 A、C 和 E 用英文说什么都不重要,因为它们的代码都没有被合并。所以忽略它们。只关心这两件事,然后在调用发生时在运行时执行检查。这就是 C++26 契约最终会做的,尽管我还没有说服小组里的每个人他们会这样做。那将是他们会做的,因为没有其他答案。再说一遍,这是预测,尚未达成一致。但它会发生,因为没有其他答案有意义。

好吧,我跑题了。

关键点

有没有人相信,如果我从一个抽象基类派生两个不同的类,那么这两个类中的函数必须表现相同?必须?不,绝对不。

那这是怎么回事?Bertrand Meyer 谈论的,实际上只是关于虚函数。当我们谈论非虚函数时,它们遵循不同的规则。在给定域内,行为必须相同。

所以虚函数是关于行为变化的。我们通常说超集,但通常当你派生一个类时,域是相同的,除非出于某种原因,你计划直接调用那个派生类,那样的话它可以有一个更宽的域,完全没问题。在基类可调用的域之外,任何事情都可能发生。

所以我想强调一下。派生类从调用者的角度来看,就像一个宽实现。就像传统的头文件和 CPP 文件一样。头文件告诉你契约是什么,而实现可以做更多。这没问题。如果一个实现在其域之外工作,这没有任何问题。它只是没有承诺它会这样做。当它没有承诺它会这样做时,不要依赖它,即使它可能救了你的命。把它想象成一个马戏团的网。你不知道网在不在那里。所以相应地行动。但如果你掉下去了,你可能还活着。这就是你应该对待契约的方式。

所以在实践中,这通常是相同的,我们很快就会讨论宽实现。

契约式设计的实际应用

如果你应用契约式设计,你谈论的是虚函数。你不是在谈论我们所说的结构继承。你能用它做什么?当 Stroustrup 在学校教契约时,他不能使用条件编译,因为那很糟糕,很难解释。所以他没有一个合适的契约设施。他做的是派生一个检查索引值的类,它只是隐藏了另一个类。我稍后会展示。这实际上给了你一个穷人的契约检查,但你会遇到切片问题。在这种情况下,我们谈论的是契约式设计,我们有三个不同的形状,它们计算顶点数量的方式将根据派生类型而不同。所以简单的函数 num_vertices 会给你不同的值。这有意义吗?

显然,当有人说 LSP,并开始谈论这些东西时,他们不知道自己在说什么,因为这不是 LSP。这是契约式设计。非常清楚,它有什么用?它是一个很好的启发式方法。它用于行为变化。这早就为人所知了。契约设施是否应该在编译时自然支持这个?答案当然是“不”,因为我们有一个多元宇宙。多元宇宙说,如果你想严格遵循契约式设计,那就去做。但如果你需要做一些不同的事情,你也可以这样做,对吧?标准必须支持多元宇宙。所以即使有人想写非常糟糕的代码,并且我知道如果他们有采用这种风格的遗留代码库并且需要继续使用它,这很糟糕,我们必须允许他们继续,并且我们必须确保契约能在上面工作,这样他们才能将糟糕的代码库迁移到更好的代码库。如果我们说,在你的代码库看起来像我的代码库之前,你不能使用契约,那我们就错过了重点。契约既是一种安全工具和正确性工具,也是一种进化工具。所以我们必须始终考虑多元宇宙。

我提到契约式设计的唯一原因是为了与我要真正谈论的内容进行对比。

我建议我们看看 Cargill,他在 90 年代初就谈到了这个。这是他在他的书中多次说过的话,他是对的。他说:“继承是为了行为的变化,数据成员是为了值的变化。” 这就是可替换性。现在,有没有人想告诉我,里氏可替换性可能与契约式设计有什么不同?没有。

好吧,我告诉你们。任何子类型 D 的对象,作为其超类型 B 的子类型,可以在任何可以使用 B 的上下文中使用,甚至更多。这是简短版本。D 在 B 的域中的行为是相同的。不是相似,不是做那种相同的事情。相同,是的,相同。所以,如果你曾经使用过 LSP 并说,嗯,行为是兼容的,不。它不是相同的。它根本不满足里氏替换原则。

所以,如果我有一个基类,我只是派生一个派生类并隐藏基类函数,就像我刚才说的,只要它们在基类的域内做同样的事情,就没有问题。

所以,如果里氏替换原则适用于任何东西,它将适用于非虚函数,而不是虚函数。我希望这很清楚。

现在,我不是在编造这些东西。就像他们说的,我有证据。

Stroustrup 的教学方法

这是 Stroustrup 教它的方式。他创建了一个 checked_vector。他从中派生。他隐藏了 operator[],用另一个实际上是 atoperator[] 来隐藏。他就是这样做的。这些是非虚函数,checked_vector 会捕获意外的错误。让我们看看会发生什么。

这是这里发生的情况。出了什么问题?什么?没什么,因为我还没有展示错误。现在问同样的问题,这里会出什么问题?我排序了。现在我试图打印出这些值。会打印出什么?好吧,我给你们看这个,这是打印出来的内容。咩。这是未定义的,绝对是。它并不总是打印这个,但对我来说是这样。为什么?为什么它是未定义的?我……

好吧,那是。所以这是越界,这是未定义行为。为什么是未定义行为?因为人们会犯错,我也犯过这个错误。这就是为什么我每次都使用 for (int i = 0; i < size; ++i) 这个习惯用法。我甚至从不想把它改成 <=,除非有五个人审查它。无论如何,如果我那样做了,那么会发生什么?checked_vector 会这样做。

所以如果我放入 checked_vector,它会发现这个错误。我可以修复它。问题是,如果你总是检查,你会在不需要的时候消耗 CPU 周期。所以你在测试版本中使用 checked_vector,然后在生产版本中把它去掉,因为一旦错误被修复,事情往往会继续工作。这不是最好的答案。最好的答案不是使用 checked_vector,而是使用一个采用 C++26 契约的标准 vector 实现。那样就好了。但无论如何,那是真正的答案。

为什么关心里氏替换原则?

我们为什么关心里氏替换原则?这与我们关心与 C++ 标准的向后兼容性的原因相同,对吧?我们的目标一直是让 C++03 中正确的程序在 11 中工作,11 的在 14 中工作,14 的在 17 中工作。在这个意义上,标准 11 是 03 的里氏可替换。这有意义吗?意味着每一个用 03 编写的程序表面上都将在 C++11 中运行。这是真的吗?不。但这是目标。我们同意这是目标吗?显然,这是目标。

事实上,在原则设计中,有两个原则是最重要的。第一,当我们转向新版本的标准时,我们不会改变已定义代码的运行时行为。这是不允许的。另一个是,如果我们在以前的版本中有已定义的行为,现在我们让它无法编译,这也是不允许的。为什么?因为有人会采用新标准,非常兴奋,把它放在他们的代码库上,然后他们完全正确的代码停止编译了。他们会说,见鬼,我们不采用这个了。然后你就与新版本不同步了,等等。所以这些原则就像是强制性的,并且优先级非常高。有时我们不得不这样做,但我们尽量避免,如果你明白我的意思。

实际的里氏替换原则

实际的里氏替换原则是什么?我打算稍微重写它,并将其应用于程序的版本,因为这是最容易理解的方式,否则当我们进入语言时,会变得很奇怪。所以,如果对于所有当前正确编写的、针对库 L 的当前版本 V 的程序 P,用 L 的 V+1 版本替换 V 不会导致任何 P 的可观察行为发生变化,那么 V+1 是 V 的可替换版本。这有意义吗?换句话说,我们说的是,如果我升级我的版本,所有旧程序都能编译并做完全相同的事情,完全相同的事情。那么新版本是旧版本的里氏可替换。我们可以做一些其他的事情,因为可以编译更多的东西。

我真的希望你们理解,这就是里氏替换。在先前版本的域内,没有行为变化的余地,一点都没有。这是 80 年代基本的软件工程。没什么花哨的,但你需要知道这些东西。

问题。你能在这里引用任何参考资料吗?我不熟悉这个定义。我有证据。是的,嗯,那不是……我有证据证明我是对的,但我现在还不打算透露。你承认这是对这个概念非常激进的重新表述吗?Barbara Liskov 在 1987 年有一篇论文,她说得非常清楚。一旦你读了 20 遍,好吧,谢谢。我确实读了 20 遍。但就像我说的,我有证据。我会讲这个故事,但我想等到辩论环节再讲。

谢谢,好的。

两种里氏替换

我们有两种里氏替换,我们有马基雅维利式的里氏替换,就像我要想办法,我要用人工智能,找到一种方法来证明这两件事不是你想的那样。然后还有一种,我们说,如果有人用 C++03 写一个程序,然后用 C++11 写另一个程序,并且含义不同,那将是病态的。你可以这样做,但你必须非常努力地工作才能做到,以至于在实践中永远不会发生。所以我说有理论上的里氏替换,连马基雅维利都无法获胜,然后有实践中的里氏替换,我们关心的是墨菲法则。所以如果你只是无能,不,我们需要的是有能力的人。为了原谅那一点点,必须是故意的。如果有意义的话。

无论如何,让我们看看这个。这是一个句柄。我只想说,这是一个窄契约。这是另一个不那么窄的窄契约。这是另一个宽契约。注意,我只是在契约的基础上不断构建。我没有破坏过去的任何东西。这些是里氏可替换的添加。可替换地累积使用。

所以这里有一些适用于 A1 的用法。这里有一些适用于 A2 的用法。这里有一些适用于 A3 的用法。注意,我们使用得越来越多,这是我昨晚添加的,因为我想不出我试图用这张幻灯片解释什么。每次我们使用多一点功能,契约就宽一点,你看到了吗?

看看这张幻灯片。了解一下大意。你明白了。现在这并不意味着什么。这只是我写的代码。重点是,那些红色的东西随着时间推移,使用了句柄越来越多的功能。

这是软件工程。这就是我们所做的。这是里氏替换。这不是契约式设计。

[[noreturn]] 属性

现在我们有了这个美妙的 [[noreturn]] 属性。[[noreturn]] 属性是否影响函数的类型?不。

所以我要做同样的事情。看看我们这里有什么。我不能放 [[noreturn]],因为在这第三个中,我决定我想返回。所以我必须去掉 [[noreturn]]。现在,[[noreturn]] 是一个优化。[[noreturn]] 对人们如何查询代码没有任何影响。你不能在代码中问这个东西是否从不返回。你不能问那个问题。这很好。所以这就像一个优化提示,仅此而已。所以我们把它去掉。它只是一个优化提示。你可以看到我们稳定地累积东西。

我希望我们能这样做。我们不能。但如果我们能这样做,那会更好,说:我在一个不返回的上下文中使用这个句柄。我在一个不返回的上下文中使用这个句柄。我在一个返回的上下文中使用这个句柄。就这样。这也是我昨天添加的,以便解释。这是它变得困难的地方。所以如果你们觉得这很难,我昨天也觉得很难。我也在倒时差,我的包丢了四天,前一天不得不出去更换我所有的药。别提了。但无论如何,这很有压力。丢东西。

考虑这两个契约

现在考虑这两个契约。哪个是另一个的里氏可替换?这不是一个陷阱问题。哪个是另一个的里氏可替换?你说都不是,那将是一个陷阱问题。是第一个还是第二个可替换另一个?Guy。

答案是第二个,因为这个唯一给你的是可能稍微好一点的代码生成。就这样。对此有什么问题吗,有?为什么?是的。它是书面的。它是契约的一部分。这里没有什么新东西。这里没什么可看的。它们是同一个函数。一个为看不到实现的人稍微优化了一下。如果编译器能看到这个的实现,它可以写出完全相同的代码。这是针对另一个翻译单元的。它只是一个优化提示。好的,好的。我只是想确保每个人,我必须为难你。我想感谢你。当然,我很乐意之后请你喝一杯,作为你在观众中的“托儿”。我需要辩论。我想念它。我真的很想念它。

稍微好一点的代码生成。

实现的契约

我撒谎了。实现的契约。公共契约的符合实现。这些是我们要讨论的东西,宽实现。

那么,让我们谈谈这些东西。实现的隐含契约是什么?基本上,它说的是,我看实现,弄清楚它会保证什么,然后写下来。我们不那样做。这是实现,隐含契约是什么?好的,这是一个实现。隐含契约是什么?你明白了,对吧?你看实现,写下一些东西。根据实现,你写下其他一些东西。这不是我们编码的方式,我们走另一条路,以防有人关心。

接下来我需要做什么?接口的符合实现。这是什么意思?实现实现了接口。任何可以进入接口的东西,实现都会处理。所以我把它放在那里,你们可以读,而我休息一下,喝点水。有人对这些直接的东西有什么问题吗?这是暴风雨前的平静。

这是一个符合的实现吗?我知道 Kevlin 想评论这个。我知道他想。他们会看着它说,但你可以说,那是一个符合的实现吗?谢谢。为什么不?溢出,溢出。绝对地。很多人会写这个,如果 C++29 在语言中内置了一种在运行时检查溢出的方法,那不是很棒吗?也许基于语言中的契约。是的,那将是一个好主意。也许我们可以用一个叫做标签的东西来打开它,这个标签是众所周知的,并且对所有人发布,这样任何想在不做任何工作的情况下检查他们遗留代码中这个问题的人,只需用不同的标签集重新编译他们的代码,就可以知道他们的程序中有整数溢出。我们为什么不这样做呢,Timur?我们为什么不这样做?我们为什么不继续把契约放进语言里?你会主持并让那发生吗?这样我们就可以捕获这个溢出。

这个呢?是的,好的。这个呢?这是符合的吗?这取决于舍入。如果 a > b 呢?你的观点很好,我们会讲到你的。这个呢?很难。我必须写一个严肃的测试驱动程序,查看从 -2 到 +2 的低范围、重叠范围和高范围,直到某个值,并确保它对每一个都有效。然后我才确定,但只有那时。但话又说回来,我已经做了几年了。所以,是的,那很快就能工作。这个对吗?是或否。是的,这个呢?是的。这个呢?所以仅仅因为它存在于库中,如果你不读契约,你可能不会成功。所以记住这一点,重用是好的,但你必须 RTFM,意思是读手册。

窄契约的宽实现

窄契约的宽实现是什么?宽实现说,我已经满足了契约,如果我们要在未来的语言版本中扩展契约,我也会满足那个。也许。所以你可以有一个预见未来变化的宽契约。你可以使用契约来轻松进入那些事情,就像你可以通过一个肯定的声明来缩小或扩大契约。今天你不能开这辆车超过每小时 80 英里,但我要警告你,过一会儿你不能开超过 60,这样我们就可以开始在派生类型中实现更高效的汽车。

想想看,契约可以很好地用于进化代码,这是我们的一位好朋友 Vlad 一直在使用的,这是一个重要的功能,我们不能以只遵循一个人用例的方式来限制契约。

这个呢?是的。它是。这个呢?顺便说一下,这是 C++20 契约的旧括号表示法。它现在不是这样了,但我把它留在这里,因为它非常清楚地展示了那里发生了什么。那是为什么?它是一样的。

顺便说一下,标准库函数,如果你在契约外调用一个标准库函数,这意味着你违反了前提条件,它不必调用 std::terminate。它实际上可以假设这永远不会被调用,并优化你的代码,进行时间旅行,并导致坏事发生。一般来说,没人在乎这个。我们一直这样做。这很好。但一旦有人开始说,等等,你要把它放在用户函数上。哦,我的天哪。然后头发就着火了。

嗯,你不会一开始就那样做。你从使用检查契约开始,强制执行。实际上,你从观察开始。如果你把它放入旧代码中,然后你发现一切都在工作,然后你把它改为强制执行。在那之后,你把它改为忽略,因为它正在工作。最后,你可以说假设,并真正从契约中获得加速。这不在 26 中,但应该是。我们还有一个叫做优化屏障的东西。一旦我们有了优化屏障,所有害怕用户代码会被时间旅行吞噬的人就能放松了。

所以就是这样。无论如何。

这是另一个。这是另一个,只是为了给你一个概念。有许多不同的可能的符合宽实现。好的是,只要它满足要求,你可以以任何你想要的方式加宽它。这就是拥有窄契约的美妙之处。

你可以等到以后的版本,当你知道应该是什么时再加宽它们。我们在契约中确定的一个原则是,如果我们到达一个点,我们同意了,然后我们想更进一步,房间里一半的人坚持必须是这种方式,另一半人坚持必须是那种方式,我们就在这里停止,说这个版本不适合,等我们有更多信息后再争论。这个原则已被证明可以解决最困难的问题,并使 MVP 得以发布,因为如果我们没有这个,我们就会停滞不前。对吧,Timur。好的,Tim。

记住,Timur 在这次演讲之后会讲 C++26 的这些内容。所以现在你有了所有的背景,并且你理解了我们是如何做出这些决定的,你实际上可以听到这些决定是什么。所以我强烈建议你留下来听接下来的演讲。

所以,再次,这是契约属性表示法。我想我之前的表示法是错的。但别担心。这已经不在里面了。我昨天放了这张幻灯片。不是这张。这是契约中的新内容,你可以把表示法放在签名上,函数上。与人们可能认为的相反,哦,是的,我们可以以不同的方式使用相同的旧契约。不,这些契约允许多元宇宙工作,因为处理契约违反有许多不同的方式。我们稍后会更多地讨论这些。以及为什么我们可能需要以不同的方式恢复。以及为什么我们可能根本不想恢复,而是立即退出。所有这些都是有效的。

是的。这就是它看起来的样子。这是今天的正确表示法吗?好的,这就是今天在 C++26 中的样子。我只是想展示一下。我昨天也放了这个。幸好我看了我的代码。所以这就是今天在 C++26 中的样子。我想每个人都能读懂。很容易写。看起来不像外星 C++,只是东西。然而,这段代码。与其它代码不同。绿色的代码不在那里。如果你在调用这个函数,你不必看那个,因为从契约中你知道我最好不要调用这个东西。

这是为了检查契约是否正确。所以你可以放在这里的所有东西通常是契约本身的一个子集,通常是一个真子集。契约管辖着 noexcept 说明符,这是本次演讲的一部分。

我们需要确保函数不抛出异常,这就是这个的作用,或者确保函数不抛出异常。它通常意味着函数也不会失败。但那是另一个话题,一个我不想涉及的麻烦事。我要提一下,实际上,它叫什么,你不记得我的书名了吗?是什么,现代 C++?第一个是什么?拥抱。谢谢。哦,我的脑袋满了。

所以在《安全地拥抱现代 C++》中,我们有三个章节,noexcept 说明符是一个第 3 章的特性,这意味着它

010:学习不同编程语言的好处

在本节课中,我们将跟随 Francis Glassborow 的演讲,探讨学习不同编程语言(包括人类语言和计算机语言)所带来的深刻见解和实际好处。我们将从人类语言学习的经验出发,逐步过渡到计算机语言,并最终通过一个具体的 APL 语言实例来展示不同编程范式如何影响问题解决的方式。

概述:为何学习新语言?

学习一门新语言,无论是人类语言还是计算机语言,其动机多种多样。可能是工作需要、个人兴趣、提升技能,或是为了改善解决问题的方式。不同的语言会塑造我们不同的思维方式,而接触多种语言能极大地拓宽我们的视野和理解能力。

从人类语言中学习

在深入计算机语言之前,让我们先看看从学习不同人类语言中能获得什么。每一种语言都教会了我一些独特的东西。

阿拉伯语:发音与语义结构

阿拉伯语让我理解了发音的困难。它是一种闪米特语,其音节结构(辅音-元音或辅音-元音-辅音)与印欧语系不同。更重要的是,在阿拉伯语中,辅音承载核心含义,元音更多是修饰。这揭示了语言如何通过不同的结构来表达意义。

意大利语:使用与遗忘

意大利语的经验表明,如果没有使用的动机,语言很容易被遗忘。这强调了在实践中巩固学习的重要性。

法语:语序与记忆

法语和英语虽然同属印欧语系,但基本句子的语序不同(英语是主谓宾,而法语是主宾谓)。这让我意识到语言结构的多样性。多年后,在比利时的一次迷路经历中,残存的学校法语知识帮助了我,这说明了在极端情况下,记忆可以成为救星

拉丁语与希腊语:词汇焦点与语法结构

拉丁语中有大量关于战斗的词汇,而阿拉伯语则有至少七个表示“花园”的词。这表明某些语言会对特定生活领域有丰富的词汇聚焦。古典希腊语则引入了“双数”的概念(介于单数和复数之间),并使用了不同的字母表,这展示了语法和书写系统的多样性。

中文(汉字):非字母文字的优势

虽然我不会说中文,但为了阅读围棋文献,我需要认识汉字。这让我认识到基于字符的语言不依赖于发音,其符号与声音没有直接联系。不同方言的人可以通过文字沟通。此外,中文词典通过笔画数和主笔形来组织,证明了非字母文字也可以有效索引。中文有限的音节也使其成为双关语的沃土,而机器翻译很难处理双关语。

日语:注音与表达

日语文本有时会在汉字上方标注假名(注音)。这启发了我,在文字处理中,或许可以将两种可能的表达上下并列,让读者自行选择,为表达提供了新的可能性。

Pirahã 语:语言与思维

Pirahã 语(亚马逊部落语言)没有表达第三方经验的机制,你只能陈述自己的直接经历。这意味着这种语言无法讲述历史。这强烈地证明了语言结构会限制所能表达的思想范畴

世界语与逻辑语:人工语言的局限

世界语基于印欧语系,并未真正全球化。逻辑语(Lojban)旨在测试“语言决定思维”的假说,但它最初十年都缺乏描述基本人类功能的词汇,这显示了设计者可能脱离日常需求。此外,认为逻辑语不能说谎也是误解,逻辑正确不等于诚实

手语:被忽视的通用技能

手语应该成为每个人的第二语言。在嘈杂环境、工程场合或聚会中,它能实现无声的有效沟通。推广手语能带来巨大的社会效益。

上一节我们从人类语言的多样性中看到了思维如何被塑造。接下来,我们将把视角转向计算机语言,看看不同的编程范式如何影响我们解决问题的方式。

计算机语言类型与经验

计算机语言大致可分为低级和高级语言,但这只是一个连续的频谱。我曾用多种语言编写过程序,每种都带来了独特的教训。

低级语言:贴近硬件

我学习的第一种低级语言是 IBM 1130 汇编器。在仅有 8K 16位字内存、输出只能靠行式打印机的条件下,编程是巨大的挑战。我通过阅读控制台指示灯来调试占满 99.99% 资源的程序。这次经历教会我:低级语言编程艰难,容易出错,需要极大的毅力和强烈的动机

Cecil 语言:教育的启示

Cecil 是一种用于学校计算机教育的入门语言,极其简单(只有 IN, OUT, LINE, PRINT 等指令)。然而,当我让学生用 Cecil 解决一个“反转相加”回文数问题时,一位学生出色地完成了。这证明了:解决问题的最大限制往往不是语言本身,而是程序员自身的洞察力和规划能力

Plan 语言:理解系统细节

为了给 Cecil 制作一个交互式前端,我学习了 ICL 1900 的 Plan 汇编语言。我的第一个版本产生了海量的错误,因为我没理解该机器前四个和后四个累加器的指令集是不同的。这次教训是:必须深入理解目标系统的架构细节

Forth 语言:逆波兰表示法

Forth 使用逆波兰表示法(RPN),没有括号和运算符优先级。许多同事认为这很疯狂,但我发现,对于年轻人(20岁以下)来说,这根本不是问题。这挑战了“可读性纯粹是语言问题”的观念。我用 Forth 实现了一个教育程序,学生们选择了“引擎”、“活塞前进”这样的函数名,使得代码像故事一样可读。Forth 还引入了“不可命名”的概念(如用空格命名的变量),这是系统保护机制的一部分。

高级语言的经验教训

  • FORTRAN:我吃过“编译器扩展”的亏。一个使用了 IBM 扩展字符处理功能的程序在另一台“标准”FORTRAN 机器上完全无法运行。教训是:如果希望代码可移植,请避免使用编译器特定扩展
  • BASIC:它有无数方言。我曾用一系列小型 BASIC 程序操作一个共享数据结构,成功管理了赛艇比赛结果。这展示了简单语言结合清晰架构的威力。SuperBASIC 则是一个被误认为不能编译的有趣变体。
  • Pascal:它旨在通过严格限制来防止错误。但这产生了两种程序员:一种是盲目信任编译器的人;另一种是想方设法“破解”限制的人(例如著名的“崩溃开关”)。这导致许多 Pascal 程序员转向 C 时,写出了糟糕的代码,因为他们不了解底层、不熟悉工具、不理解“未定义行为”和“实现定义行为”
  • Haskell:它教会我纯函数和避免副作用的重要性。虽然副作用(如I/O)是必要的,但能区分纯函数是巨大的优势。
  • Prolog:它要求你彻底扭转思维方式,从命令式转向声明式和逻辑编程。
  • Logo / Scratch:Logo 的“海龟绘图”很棒,但教学常常止步于此。Scratch 则是一个杰出例子,它展示了教师应该设定基础规则,然后让学生自由探索。过度教学会扼杀创造力。
  • C++:它融合了多种范式(过程式、面向对象、泛型、函数式),这使得它强大但也复杂。在早期,许多 C 背景的“专家”并不真正理解 C++,造成了社区隔阂。将不同范式优雅地结合起来是一项挑战

上一节我们回顾了各种编程语言的特点与教训。现在,让我们聚焦于本次演讲的起点——APL 语言,看看一种截然不同的编程范式如何优雅地解决特定问题。

APL 语言:以数组为中心的思想

APL(A Programming Language)诞生于 1960 年代,使用独特的符号系统。它打破了我们惯常的循环思维。

APL 的核心特点

  • 无显式循环:熟练的 APL 程序员不写显式循环,操作直接作用于整个数组。
  • 两种数据类型:只有标量数组(数组可以有零维,即空)。
  • 右结合,无优先级:表达式从右向左计算,运算符没有优先级。
  • 丰富的符号:使用许多数学符号和图形符号,每个符号对应一个操作,意图明确。

实例:康威生命游戏的 APL 实现

生命游戏本质上是基于二维数组的演化。APL 的数组操作特性与之完美匹配。以下是生成下一代的核心逻辑(已翻译为中文注释):

生成 ← {              ⍝ 定义‘生成’函数
    局部 上移 下移 左移 右移 计数 存活
    上移 ← 宇宙 + 1 ⌽ 宇宙    ⍝ 将宇宙数组向上旋转一行并相加
    下移 ← 上移 + ¯1 ⌽ 宇宙   ⍝ 将宇宙数组向下旋转一行并加到‘上移’结果上
    左移 ← 宇宙 + 1 ⊖ 宇宙    ⍝ 向左旋转一列并相加
    右移 ← 左移 + ¯1 ⊖ 宇宙   ⍝ 向右旋转一列并相加
    计数 ← 上移 + 下移 + 左移 + 右移  ⍝ 计算每个细胞的总邻居数
    存活 ← (宇宙 ∧ 计数=3) ∨ (计数=4) ⍝ 应用生命游戏规则:存活= (原存活且邻居为3) 或 (邻居为4)
    宇宙 ← 存活                   ⍝ 更新宇宙为新一代
    显示 宇宙                    ⍝ 调用显示函数
}

代码解读

  1. 通过旋转数组并相加,巧妙地计算出每个细胞的八个邻居之和(计数)。
  2. 生命游戏规则被浓缩为一行布尔逻辑。
  3. 没有出现一个显式的循环

优势

  • 简洁:核心逻辑只有几行。
  • 易于修改规则:例如,如果想增加“有8个邻居也能存活”的规则,只需添加一行代码。
  • 匹配问题域:用数组操作直接模拟了基于网格的细胞自动机。

这个程序运行在一个约 1000x1000 的“环绕”宇宙中,通过鼠标移动或按钮点击来步进一代,性能表现良好。

总结与核心启示

本节课我们一起探讨了学习不同编程语言的深远价值。

  1. 突破思维定式:无论是人类语言的 Pirahã 语,还是计算机语言的 Prolog 或 APL,学习它们能强迫我们用全新的方式思考世界和问题
  2. 理解抽象与细节:低级语言(如汇编)教会我们计算机的实际工作方式和对细节的苛求;高级语言让我们专注于抽象和逻辑。
  3. 工具服务于思想:Cecil 和 Scratch 的例子表明,语言的限制不等于程序员能力的限制。强大的思想能在简单的工具上创造奇迹。
  4. 语境至关重要:演讲开头的 APL 小测验警示我们,永远不要假设你理解了一段代码,尤其是在切换语言环境时。运算符含义、求值顺序、优先级都可能完全不同。
  5. 选择适合的工具:APL 生命游戏的例子完美展示了当编程范式与问题域高度契合时,解决方案会变得异常优雅和简单

最后,Francis Glassborow 用他向 ChatGPT 发起挑战的经历提醒我们:对于生成式 AI 提供的代码,必须保持警惕——看起来合理并不意味着它正确或高效。深入理解编程语言和问题本身,始终是程序员不可替代的核心能力。


附:开场测验答案与解析

  1. 5 × 3 + 15 × (3 + 1)20 (APL 右结合,无优先级)
  2. 3 * 53^5243* 在 APL 中是幂运算)
  3. 3 × 5 + 23 × (5 + 2)21 (无优先级,右结合)
  4. 2 + 5 × 3 * 72 + (5 × (3^7))2 + (5 × 2187)10937
  5. 3 + 2 ÷ 53 + (2,2,2,2,2)(5,5,5,5,5)÷ 在此上下文是“复制”操作符,2 ÷ 5 生成5个2的数组,然后标量3与数组相加)

012:正确理解与使用过滤器视图

在本教程中,我们将深入探讨 C++20 中引入的过滤器视图。我们将了解其内部工作原理、性能特性以及在使用时必须注意的陷阱,特别是由缓存行为引发的问题。通过本教程,你将学会如何安全、高效地使用过滤器视图。

第12章:视图与过滤器视图简介

上一节我们介绍了本教程的主题。本节中,我们来看看 C++20 中引入的视图和过滤器视图的基本概念。

视图是一种轻量级的适配器,它允许你以不同的方式处理一个数据范围,而无需创建新的临时容器。它们遵循“惰性求值”原则,只在需要时才计算值。

一个简单的视图示例是 take_view,它只处理前 N 个元素。

std::vector<int> vec{1, 2, 3, 4, 5};
auto v = vec | std::views::take(3); // 创建一个只包含前3个元素的视图
print(v); // 输出:1 2 3

视图可以使用管道操作符 | 进行链式组合,这类似于 Unix 管道,但底层模型不同。

auto result = vec | std::views::take(3) | std::views::transform([](int i){ return std::to_string(i) + "s"; });
print(result); // 输出:1s 2s 3s

过滤器视图 filter_view 是其中一种强大的视图,它只允许满足特定谓词的元素通过。

以下是使用过滤器视图的一个例子:

std::map<std::string, int> composers{{"Bach", 1685}, {"Mozart", 1756}, {"Beethoven", 1770}};
// 过滤出出生年份大于 1700 的作曲家,取前三个,然后只取名字(键)
for (const auto& name : composers | std::views::filter([](const auto& p){ return p.second > 1700; })
                                  | std::views::take(3)
                                  | std::views::keys) {
    std::cout << name << '\n';
}
// 输出:Mozart Beethoven

视图也可以是生成的,例如 iota_view 可以生成一个无限的整数序列。

// 生成从1开始的整数,过滤出3的倍数,丢弃前3个,再取8个,转换为字符串
for (auto s : std::views::iota(1)
            | std::views::filter([](int i){ return i % 3 == 0; })
            | std::views::drop(3)
            | std::views::take(8)
            | std::views::transform([](int i){ return std::to_string(i) + "s"; })) {
    std::cout << s << ' ';
}
// 输出:12s 15s 18s ...

视图的设计目标是轻量且可廉价复制。然而,正如我们将在后面看到的,过滤器视图的一些设计决策带来了复杂性和潜在陷阱。

第13章:视图的内部工作原理与性能考量

上一节我们介绍了视图的基本用法。本节中我们来看看视图,特别是过滤器视图的内部“拉取模型”及其性能影响。

拉取模型

与 Unix 管道不同,C++ 视图采用“拉取模型”。当你创建一个视图链时,你只是声明了一系列包装器。计算只发生在你真正请求元素时(例如,在迭代循环中)。

考虑以下视图链:

std::vector<int> vec{42, 8, 15, 23, 16};
auto v = vec | std::views::filter([](int i){ return i < 20; })
             | std::views::transform([](int i){ return -i; });

print(v) 开始迭代时,会发生以下步骤:

  1. 调用 v.begin()。这个调用经过 transform_view -> filter_view -> 原始向量。
  2. filter_view 需要找到第一个满足谓词 i < 20 的元素。它从向量的迭代器开始,检查值(调用 *it),发现 42 不满足,于是递增迭代器(++it),再检查 8,满足条件。
  3. 这个位置被传递给 transform_viewtransform_view 只是简单地传递它(此时尚未计算变换值)。
  4. 外层循环现在有了一个迭代器。当它解引用该迭代器以获取值(*it)时,调用再次经过链:transform_view 收到请求,它需要值,于是向 filter_view 请求,filter_view 向向量请求,得到值 8。然后 transform_view 对这个值应用变换(取负),返回 -8。
  5. 循环递增迭代器时,filter_view 会继续寻找下一个满足谓词的元素。

关键点:过滤器为了找到第一个元素,可能需要对多个元素调用 *(解引用)和 ++(递增)。更重要的是,同一个元素的值可能被查看多次:一次由过滤器用于判断是否让其通过,另一次由最终用户(或 transform_view)用于获取值。

如果 transformfilter 之前,情况会更复杂:

auto v = vec | std::views::transform([](int i){ return -i; })
             | std::views::filter([](int i){ return i < 20; });

在这种情况下,为了判断元素是否通过过滤器,filter_view 需要值,这会触发 transform_view 提前计算变换值。然后,当外层循环最终要打印值时,transform_view 可能不得不再次计算同一个变换。对于昂贵的变换操作,这是一个性能问题。

性能启示

  • 将昂贵的操作(如复杂变换)放在过滤器之后可能更高效。
  • 如果过滤器前的变换成本很高,并且过滤掉很多元素,考虑先将变换结果存入临时容器,再过滤。

操作的复杂度

对于标准容器,begin()end()size()empty() 通常是常数时间操作。但对于 filter_view,情况并非如此。

假设有一个向量 vec 和一个过滤器 filter_view(v, pred)

  • begin():必须线性搜索,直到找到第一个满足谓词的元素。复杂度:O(N)
  • end():通常只是传递底层范围的 end()复杂度:O(1)
  • size():需要遍历整个范围并计数满足谓词的元素。复杂度:O(N)。因此,filter_view 不提供 size() 成员函数
  • empty():需要调用 begin() 并检查是否等于 end()复杂度:O(N)
  • front():需要调用 begin() 并解引用。复杂度:O(N)
  • back():需要从末尾反向搜索找到最后一个满足谓词的元素。复杂度:O(N)

为了优化,filter_view 采用了缓存策略:第一次调用 begin()(或 empty()front(),因为它们内部调用 begin())后,它会将找到的起始位置缓存起来。后续调用 begin() 将直接返回缓存值,变为常数时间。

这被称为均摊常数复杂度。就像 std::vector::push_back 的扩容成本被均摊一样。然而,对于视图,一个常见的模式是只调用一次 begin()(例如在范围 for 循环中),因此这种缓存带来的好处可能不如预期。

第14章:过滤器视图的陷阱与正确使用指南

上一节我们分析了过滤器视图的性能特征。本节中我们来看看由其缓存等行为导致的具体陷阱和必须遵守的使用指南。

陷阱一:常量性与编译错误

由于 filter_view 会在第一次调用 begin() 时修改其内部缓存状态,因此调用 begin() 是一个非 const 成员函数

这会导致一个令人困惑的编译错误:

void print(const auto& coll) { // 参数是 const 引用
    for (const auto& elem : coll) { // 这里需要调用 coll.begin()
        std::cout << elem << ' ';
    }
}

std::vector<int> vec{1, 2, 3, 4};
auto v = vec | std::views::filter([](int i){ return i > 9; });
print(v); // 编译错误!不能在 const 对象上调用非 const 的 begin()

但是,直接在范围 for 循环中使用却能编译:

for (const auto& elem : v) { ... } // 可以编译,因为 `v` 不是 const

原因:在泛型函数 print 中,coll 被声明为 const auto&,因此尝试在其上调用非 const 的 begin() 会失败。

解决方案

  1. 使用 std::ranges::subrange:在构造时即计算起止迭代器。
    #include <ranges>
    auto sv = std::ranges::subrange(v.begin(), v.end()); // 注意:这里已经触发了 begin() 调用
    print(sv); // 现在可以工作,因为 subrange 的迭代器是值,其 begin() 是 const
    
  2. 使用转发引用(万能引用):使函数同时接受左值和右值,并避免添加 const 限定。
    void print(auto&& coll) { // 转发引用
        for (const auto& elem : coll) {
            std::cout << elem << ' ';
        }
    }
    
  3. 按值传递视图:因为视图设计为廉价拷贝。但需注意,这会对大型容器产生拷贝成本。可以通过约束和重载来区分视图和容器。

陷阱二:修改元素与未定义行为

通过过滤器视图的迭代器修改元素是危险的。标准规定:如果修改导致元素不再满足过滤谓词,则行为未定义

std::vector<int> vec{1, 2, 3, 4};
auto is_even = [](int i){ return i % 2 == 0; };
auto v = vec | std::views::filter(is_even);

// 第一次使用:找到偶数 2, 4 并修改
for (auto& elem : v) {
    elem += 2; // 2->4 (仍为偶数), 4->6 (仍为偶数)
}
// vec 变为 {1, 4, 3, 6}

// 第二次使用:缓存了 begin() 指向 4
for (auto& elem : v) {
    elem += 2; // 4->6 (仍为偶数), 然后... 缓存的下一个位置是原vec[3]即6?逻辑混乱!
}
// 结果不可预测,未定义行为!

第一次迭代后,元素值变了,但缓存的迭代器位置可能不再指向“下一个满足谓词的元素”。第二次迭代会产生错误结果。

核心建议不要通过过滤器视图修改元素,除非你能确保修改后元素依然满足谓词,并且只对视图进行一次从头到尾的遍历

陷阱三:缓存与程序状态

由于缓存依赖于首次 begin() 调用的时机,读取操作也可能意外地改变程序状态。

std::list<int> lst{1, 2, 3, 4};
auto v = lst | std::views::drop(2); // drop_view 在某些情况下也会缓存

// 一个看似无关的只读操作
std::cout << "Is empty? " << std::ranges::empty(v) << '\n'; // 这里调用了 begin(),触发了缓存!

lst.push_front(0); // 修改底层容器

// 现在迭代视图,结果取决于缓存是否在 push_front 之前发生
for (auto i : v) { std::cout << i << ' '; }

empty(v) 的调用改变了 v 的内部缓存状态,从而影响了后续迭代的结果。这使得程序行为难以推理。

陷阱四:常量性传播不一致

视图的常量性语义不一致。对于引用底层容器的视图(如通过左值创建的 filter_view),视图本身的 const 不会传播到底层元素(你仍然可以修改元素)。但对于拥有数据的视图(如通过右值创建的,会使用 owning_view),const 会传播,使得元素变为 const。

std::vector<int> vec1{1,2,3};
auto v1 = vec1 | std::views::filter(is_even); // v1 引用 vec1
const auto cv1 = v1;
// 可以通过 cv1 的迭代器修改 vec1 的元素吗?实现定义/未指定,通常可以。

auto v2 = std::vector{1,2,3} | std::views::filter(is_even); // v2 拥有数据(通过 owning_view)
const auto cv2 = v2;
// 不能通过 cv2 的迭代器修改元素,因为 owning_view 传播了 const。

这破坏了容器中“const 容器不提供非 const 迭代器”的常规模式。

使用指南总结

  1. 将过滤器置于管道早期:尽可能先过滤,减少后续昂贵操作的计算量。
  2. 即用即创建:避免保存过滤器视图对象并重复使用。每次需要时重新创建视图链。
  3. 避免修改元素:不要通过过滤器视图修改元素。如果必须修改,确保不违反谓词,并且仅进行一次遍历。
  4. 注意性能特征:了解 begin()empty() 等操作可能是线性复杂度。在性能关键处,考虑手动缓存起止迭代器(例如使用 subrange)。
  5. 避免在并发中使用:绝对不要在多个线程中同时调用同一个过滤器视图的 begin()empty()front(),因为缓存修改不是线程安全的。
  6. 小心常量性:意识到 const 视图的语义可能不一致,依赖于视图的创建方式。
  7. 理解编译错误:遇到关于 const 和 begin() 的编译错误时,知道是因为缓存机制,并采用上述解决方案之一。

结论:过滤器视图是一个强大的工具,但它的设计(特别是缓存)引入了显著的复杂性、非常规的语义和潜在陷阱。在使用时,必须充分了解其机制,并严格遵守上述指南,以避免运行时错误和未定义行为。对于团队项目,需要评估是否将这些知识普及给所有开发者,或者限制其使用范围。


本节课中我们一起学习了

  1. C++20 视图和过滤器视图的基本概念与语法。
  2. 视图内部使用的“拉取模型”及其性能影响,特别是元素可能被多次求值。
  3. 过滤器视图各项操作的时间复杂度,以及缓存带来的“均摊常数”复杂度。
  4. 使用过滤器视图时的主要陷阱:由缓存导致的常量性问题、修改元素引发的未定义行为、缓存对程序状态的意外影响,以及常量性传播的不一致性。
  5. 安全高效使用过滤器视图的一系列具体指南和最佳实践。

通过理解这些底层原理和约束,你将能够更好地驾驭 C++ 标准库视图,编写出既强大又健壮的代码。

013:C++合约提案详解

概述

在本教程中,我们将学习C++合约(Contracts)的概念、历史、设计原则以及当前提案(P2900R6)的具体内容。合约是一种用于指定软件组件接口的正式、精确且可验证的规范,主要包括前置条件、后置条件和不变式。本教程将详细介绍如何通过语言特性在C++代码中表达这些合约断言,并探讨其运行时检查机制、设计考量以及当前委员会讨论中的开放性问题。


什么是合约?

合约是软件设计中的一个概念,由Bertrand Meyer在80年代提出。它允许为软件组件的接口指定正式、精确且可验证的规范。这些规范主要包括前置条件、后置条件和不变式,它们共同构成了“合约”,类似于商业合同中的条件和义务。

合约是一组表达对正确程序期望的条件。如果合约未被满足,则程序不正确。

函数合约是函数调用的一种合约,分为两部分:前置条件和后置条件。

  • 前置条件:是合约中调用者一侧的部分。调用者必须满足这些条件,程序才是正确的。这些条件是对传入参数或函数调用时可见的其他状态的要求。
  • 后置条件:是函数实现本身负责满足的合约部分。它规定了函数保证的内容,可能是返回值或函数返回后可观察到的其他状态。

还有其他类型的合约,如类不变式和循环不变式,但本提案目前不包含它们,因此我们将重点讨论函数合约。

术语

  • 宽合约:函数没有前置条件。
  • 窄合约:函数有前置条件。
  • 合约内调用:满足前置条件的调用。
  • 合约外调用:不满足前置条件的调用。合约外调用是一个错误,也称为合约违规

合约违规与错误处理

合约违规不是错误。错误是来自外部的、不依赖于代码的问题,如数据损坏或内存不足。合约违规是代码中的错误,是程序中的缺陷。因此,必须将错误处理与合约违规处理分开,它们是完全不同的事情。

谁对合约违规负责?

  • 如果是前置条件违规,责任在于函数的调用者
  • 如果是后置条件违规,责任在于函数的实现者
  • 对于类不变式,责任在于类的实现。
  • 对于循环不变式,责任在于循环体的实现。

C++标准库中的合约违规

在C++标准库中,违反前置条件会导致未定义行为。标准不区分库的未定义行为和语言的未定义行为,都是未定义行为。例如,使用越界索引调用 vector::operator[] 会导致未定义行为。对于后置条件违规,那是标准库实现中的错误,超出了标准文档的范围。


如何指定合约?

纯语言合约

通常,合约在文档或函数规范中以纯语言形式指定。常见方式包括源代码注释、单独的规范文档,或隐含的约定(如线程安全)。我们称之为纯语言合约

合约断言

另一种方式是在代码中指定合约。当使用某种语法将合约的一部分在代码中表达出来时,我们称之为合约断言

区分“合约”(泛指)、“纯语言合约”和“合约断言”非常重要。合约断言是使用编程语言特性在代码中表达的合约规范。支持这种功能的语言或库特性称为合约设施

当前提案(P2900R6)的目标是为C++添加一个作为语言特性的合约设施,允许在代码中表达合约断言。

合约断言与纯语言合约的关系

合约断言通常并不表达函数的全部纯语言合约,这在一般情况下是不可能的。合约断言表达的是纯语言合约中特定条款的一个子集。

我们通过一个算法来验证合约的某个条款是否被遵守或违反,这称为合约检查。在当前提案中,表达这个算法的方式是一个布尔表达式,它评估为真或假,我们称之为合约谓词

有时,检查合约在计算上非常昂贵,或者检查行为本身会违反合约的其他部分(例如,检查二分查找的输入是否已排序是O(n)操作,违反了函数的对数复杂度保证)。有时,检查甚至不可能(例如,验证指针是否指向有效对象,或验证函数是否会停止)。

因此,合约断言通常只指定纯语言合约的一个子集,这一点非常重要。


C++为何需要合约设施?

C语言已经有一个合约设施:assert 宏。它可以表达前置条件、后置条件断言并在代码中检查它们。但它非常有限:

  • 无法在函数声明(接口)上放置前置条件检查。
  • 无法自定义检测到违规时的行为。
  • 没有关于违规的程序化信息。
  • 它是宏,存在诸多问题(如宏展开、ODR违规等)。

因此,C++需要一个作为语言特性的合约设施,而不是基于宏的库特性。这样我们可以:

  • 在声明上放置前置/后置条件。
  • 实现跨代码库、跨API的可移植使用。
  • 自定义行为而不违反ODR。
  • 避免宏忽略代码导致的位腐烂问题。
  • 获取程序化的违规信息。
  • 让工具(如IDE、静态分析器)能够识别和处理它们。

历史与动机

将完整的合约设施作为语言特性引入C++的努力已有约20年历史:

  1. 第一阶段(约2004年):由Torsten Ahlers首次提出,灵感来自D语言,但最终未能推进。
  2. 第二阶段:由John Lakos等人推动,尝试标准化类似其BDE库中的宏/宏类实体,但最终因委员会不希望标准库中出现类似宏的东西而失败。
  3. 第三阶段(至2019年):多方合作,提出了使用 [[expects]][[ensures]][[assert]] 等属性的语法。该特性曾进入C++20工作草案,但在科隆会议上因一些问题被激烈争论后,最终被整体移除。
  4. 第四阶段(当前):即“合约MVP”(最小可行产品)提案。在科隆会议后,委员会成立了专门研究合约的研究组SG21。SG21首先收集了196个合约用例,并将其归纳为五大类:
    • 代码自文档化:在代码而非纯语言中记录合约。
    • 运行时检查:像 assert 一样在运行时检查。
    • 静态分析:供静态分析工具使用。
    • 形式化验证:用于形式化证明。
    • 优化假设:告诉编译器假设条件为真以进行优化。

委员会意识到无法一次性满足所有需求(“下金蛋的羊毛猪”问题),也需避免各方意见不一导致无法前进(“天鹅、梭子鱼和虾”的寓言)。因此,决定先推出一个最小可行产品(MVP),它不满足所有用例,但为可扩展性而设计,为足够多的人提供直接价值,其余功能可在后续版本(如C++29)中添加。

当前提案的最新公开版本是P2900R6(截至2024年4月)。SG21已同意该设计,并已提交给更高级别的演进工作组(EWG/LEWG)审议。目标是在C++26中纳入此特性。


设计原则

合约MVP的设计遵循以下核心原则:

1. 合约断言用于发现现有程序中的错误

添加合约断言不应改变程序的编译时语义,否则你检查的将是另一个程序。具体包括:

  • 合约注解不应被概念看到:不应影响概念是否满足。
  • 不应影响重载决议:添加断言不应导致选择不同的重载。
  • 不应影响 noexcept 运算符的结果
  • 零开销原则:被忽略的合约断言不应导致额外的对象拷贝/析构或其他代码执行。
  • 语义独立性:标准中不应提供在编译时检测检查是否开启的设施(反射除外,但C++26的反射不计划包含此功能)。

2. 与现有代码兼容

  • 不引入新的未定义行为:添加合约不应引入原本不存在的UB。
  • 不破坏ABI:在函数声明中添加前置/后置条件不应改变其ABI。

3. 合约断言与纯语言合约的关系

  • 前置/后置条件说明符指定的是该函数纯语言合约的一个子集,而非其他函数的合约。
  • 对于模板特化,不会自动继承主模板的合约断言。
  • 合约断言既不是接口的一部分,也不是实现的一部分,而是连接两者的桥梁。
  • 合约断言不用于控制程序流:它们用于发现错误,而非进行错误处理或输入验证。如果需要总是执行的操作,请使用 if 语句。合约断言的关键区别在于,是否关闭检查的决定是在编译程序时做出的,而不是在代码中。

4. 为可扩展性而设计

当遇到不确定如何实现或可能耗费大量时间的功能时,提案选择暂时不包含它,但保持设计开放,以便未来添加。当无法在两种方案中做出选择时,则将其定为病式,而非未指定或未定义行为。


提案详述:语法与语义

合约断言种类

提案引入了三种合约断言:

  1. 前置条件断言:使用 pre 关键字。
    void f(int x) pre(x > 0);
    
  2. 后置条件断言:使用 post 关键字。可以可选地为返回值命名(如 r),以便在表达式中引用。
    int g() post(r: r >= 0); // ‘r’ 指代返回值
    bool empty() post(size() == 0); // 不涉及返回值
    
  3. 断言语句:使用 contract_assert 关键字。它是一个语句,不能用作表达式。
    void h(int* p) {
        contract_assert(p != nullptr);
        // ...
    }
    

prepost上下文关键字,仅在特定语法位置被识别为合约断言,不会破坏现有代码。contract_assert 是一个完整关键字

术语

  • 前置条件断言和后置条件断言统称为函数合约断言
  • 所有三种统称为合约断言

函数合约断言详解

前置条件后置条件是概念性的纯语言合约。
前置条件断言后置条件断言是代码实体。
它们的区别在于评估时机和责任方。有时可能用后置条件断言来检查概念上的前置条件(如果事后检查更便宜),这是允许的。

放置位置

  • 可以放在函数和函数模板上。
  • 必须放在函数的首次声明上。如果已有声明,后续定义上可以放置,但可选。
  • 如果返回类型是 auto,则必须放在首次声明(也必须是定义)上。
  • 可以放在lambda表达式上,应用于其闭包类型的 operator()
  • 不能放在 =delete 的函数、首次声明即 =default 的函数、虚函数、函数指针或协程上。(虚函数支持是当前开放问题)。

语义细节

  • 名称查找和访问控制:对于 prepost,名称查找和访问控制如同它们是函数体的第一条语句。因此,可以访问私有成员。
  • 局部变量隐式为 const:在合约断言谓词中,局部变量被视为 const,不能修改。
  • 后置条件中引用返回值:使用 post(r: ...) 中的 r。它类似于结构化绑定中的名称,是返回对象的另一个名称。它也是隐式 const 的。
  • 后置条件中引用参数:任何在后置条件中引用的参数必须声明为 const,以防止函数体修改参数值使得后置条件无意义。
  • Lambda捕获:如果合约断言中的名称会触发lambda的隐式捕获,则该程序是病式的。这是为了防止添加未检查的断言也改变lambda类型(如使其非可平凡复制)或引入额外拷贝。显式捕获或引用静态变量是允许的。

评估时机

  • 前置条件断言:在函数调用时,参数初始化之后,函数体执行之前评估。
  • 后置条件断言:在函数返回时,返回值初始化之后,局部变量析构之后,参数析构之前评估。
  • 断言语句:在程序执行到该语句时评估。

评估语义

每个合约断言可以具有以下四种评估语义之一:

  1. 忽略:不执行任何操作。
  2. 观察:检查布尔表达式,如果失败,调用合约违规处理程序,然后继续执行
  3. 强制:检查布尔表达式,如果失败,调用合约违规处理程序,然后终止程序
  4. 快速强制:检查布尔表达式,如果失败,立即终止程序,不调用处理程序。

忽略语义是不检查的语义。观察强制快速强制是检查的语义。强制快速强制是强制执行的语义。

选择哪种语义是实现定义的。标准不规定构建模式或标志。但提供了推荐实践:实现应提供一种所有合约断言都被忽略的模式,一种所有断言都被强制执行的模式,并且强制模式应为默认模式(类似于 assert 在调试构建中的行为)。

谓词评估结果

评估合约谓词时,可能发生四种情况:

  1. 谓词评估为 true:无违规。
  2. 谓词评估为 false:发生合约违规。
  3. 谓词评估抛出异常:评估未完成。这被视为合约违规(调用相同的处理程序)。
  4. 控制流离开(如 longjmp):程序行为随之而定。

合约违规处理程序

当在观察强制语义下检测到违规时,会调用合约违规处理程序

处理程序是一个名为 handle_contract_violation 的全局函数,接受一个 const std::contract_violation& 参数,返回 void。标准库不提供声明,但有默认实现(推荐实践是打印信息到标准错误)。该函数是可替换的(类似于 operator new),用户可以在链接时提供自己的定义。

用户自定义的处理程序可以记录违规、上报、设置断点、抛出异常(需注意 noexcept 边界)等。也可以选择调用默认处理程序。

常量求值中的合约断言

如果函数在常量求值上下文中被调用,合约断言也会在编译时检查。如果谓词不是常量表达式,断言本身仍是常量表达式,但违规会导致程序病式。有一系列规则确保添加合约断言不会改变某个初始化是在编译时还是运行时发生。

递归合约违规

提案选择不阻止递归违规。如果处理程序中的代码触发了另一个合约违规,将递归调用处理程序。这比在违规处理期间禁用所有检查更安全(后者会使处理代码在无检查状态下运行)。


库API

提案包含一个最小的库API,主要用于实现自定义违规处理程序。

所有内容位于命名空间 std 中(是否使用嵌套命名空间 contracts 尚有争议)。

核心类是 std::contract_violation,其API包括:

  • source_location location():违规发生的位置。
  • string_view comment():违规谓词的文本表示。
  • contract_violation_detection detection():违规是如何检测的(谓词为假?抛出异常?)。
  • contract_semantic semantic():断言语义(观察或强制)。
  • contract_kind kind():断言种类(前置、后置、断言语句)。

此外,还有函数 std::default_contract_violation_handler() 供自定义处理程序调用默认实现。

标准库本身不要求使用合约断言,但允许实现内部使用。


当前开放设计问题

SG21同意当前设计,但演进工作组(EWG)提出了一些需重新讨论的关切点:

  1. 未定义行为(UB)与合约

    • 情况A:函数体有UB(如解引用空指针)。如果使用观察语义,编译器可能基于“UB不会发生”的假设,将包括前置条件检查在内的所有代码优化掉。
    • 情况B:谓词本身包含UB(如整数溢出)。编译器可能基于UB假设,推断谓词永远为真,从而消除检查。
    • 问题:是否及如何缓解?可能引入“优化屏障”概念,或限制谓词为无UB的子集。
  2. 是否允许省略或重复断言评估

    • 当前提案允许编译器在能证明结果时省略谓词评估,并且评估可能发生多次(例如,调用方和被调用方都检查)。
    • 反对理由:如果谓词有副作用(如递增计数器),行为将不可预测,无法从现有断言宏迁移。
    • 支持理由:允许重复评估是实现跨二进制兼容性所必需的,且能阻止依赖副作用的谓词。
  3. 是否允许违规处理程序抛出异常

    • 支持理由:某些场景(如金融交易)不能直接终止,抛出异常是唯一可移植的恢复方式。
    • 反对理由:任何断言都可能抛出,且与现有 noexcept 代码不兼容。
  4. 是否允许在编译时观察或忽略合约违规

    • 如果编译时已知违规,是否应总是报错?关闭编译时检查可能需要宏,但开启可能增加编译时间。
  5. 虚函数支持

    • 当前MVP不支持虚函数。EWG认为没有虚函数支持可能不够“可行”。解决方案复杂,但正在讨论中,可能加入MVP。

未来扩展

以下功能明确不在当前MVP中,但计划未来添加:

  • 类不变式。
  • 循环不变式。
  • 按断言种类、来源文件等细粒度控制语义。
  • 编译时断言(static_assert 风格)。
  • 假设模式(用于优化)。
  • 条件谓词中的 requires 子句。
  • 更多自定义选项。

总结

本节课我们一起学习了C++合约提案的核心内容。我们了解了合约的基本概念、历史背景以及当前MVP提案的设计原则和具体规范。提案旨在提供一种强大的语言特性来替代传统的 assert 宏,支持前置条件、后置条件和断言语句,并允许自定义违规处理。虽然还有一些开放问题需要委员会进一步讨论,但该特性有望为C++程序员提供更佳的错误检测和代码文档化工具,并可能成为C++26标准的一部分。

014:性能恶魔、巫师、战士与审计员

在本节课中,我们将学习C#性能优化的核心概念,特别是关于值类型、哈希码和集合性能的关键知识。我们将通过实际示例,对比不同实现方式的性能差异,并理解如何避免常见的性能陷阱。

概述:性能优化的挑战与误区

优化代码性能是一项困难的任务。过早优化是万恶之源,这句话常被误解。其本意是警告我们,在编写代码初期就试图写出最优化的代码,往往会导致代码复杂且未必更快。相反,我们应该先编写清晰、可读的代码,然后通过测量和分析来识别真正的瓶颈。

与“优化”相对的是“劣化”,即无意中编写了性能低下的代码。我们将探讨C#中一个典型的劣化案例,并学习如何避免它。

C#的起源与性能批评

C#在设计上更像是Java的继承者,而非C++的替代品。它和Java都源于C++被认为过于复杂和难以学习的观点。C#旨在提供比Java更复杂的类型安全性。

C# 2引入泛型后,情况变得有趣起来。C#对泛型的处理方式与C++和Java都不同,旨在让程序员更容易理解和学习,并试图消除当时C++中一些明显的缺陷。

然而,C#(以及Java)常受到来自C/C++程序员的批评,认为有四个主要因素阻碍了高性能计算:

  1. 垃圾回收:C#运行在托管内存环境中,自动垃圾回收器在程序之外运行,其触发时间不可预测。
  2. 装箱:C#在值类型和引用类型之间有严格区分。将值类型赋值给引用类型变量(如object)会导致“装箱”,即值被复制到托管堆上,成为垃圾回收的对象。
  3. 反射:C#从开始就支持反射,允许在运行时内省和修改代码行为,但这通常与高性能无关。
  4. 缺乏确定性析构:与C++的RAII模式不同,C#对象的生命周期由垃圾回收器管理。

随着.NET的发展,这些批评的影响已经减弱。垃圾回收器经过高度优化,采用了分代回收等策略来减少停顿时间和对程序流的干扰。

值类型:性能优化的关键

值类型是C#类型系统中最有趣的部分。它们不单独受垃圾回收管理,这是减少垃圾回收器压力、提升效率的重要机制。

微软文档长期建议在以下情况定义struct(值类型):

  • 类型的实例较小(通常建议在24-32字节以内)。
  • 实例通常是短命的。
  • 实例通常嵌入在其他对象中。

这样建议的原因是值类型按值复制。如果类型很大,频繁复制会导致性能和内存开销。让值类型保持小巧、短命并嵌入集合中,有助于减轻垃圾回收器的负担。

然而,许多C#程序员对值类型的语义理解不深,从而错过了利用它们优化性能的机会。

字符串:一个特殊的“值类型”

C#中的string是引用类型,但具有“值语义”。字符串是不可变的,复制字符串变量只是复制引用,而不是内容。然而,比较字符串时(使用Equals方法),比较的是内容而非引用地址,这使得字符串在行为上像一个值类型。

理解这种“按引用复制,按值比较”的语义,对于理解后续的哈希和集合性能至关重要。

装箱的成本与可读性权衡

装箱发生在需要将值类型视为引用类型时。例如,将值类型传递给接受object参数的方法。

// 示例:导致装箱
Guid id = Guid.NewGuid();
Console.WriteLine($"ID: {id}"); // `id` 被装箱

// 示例:避免装箱
Console.WriteLine($"ID: {id.ToString()}"); // 提前调用ToString,避免装箱

第一个例子会导致装箱,在堆上创建一个副本。第二个例子避免了装箱,但代码稍显冗长。在这个特定场景中,由于控制台输出的开销远大于装箱成本,性能差异可以忽略不计。因此,有时代码清晰度比微小的性能提升更重要。性能优化需要考虑多方面因素,包括可读性、可测试性和内存使用。

一个性能“劣化”的典型案例

让我们通过一个具体例子来探讨性能陷阱。假设我们有一个表示颜色的结构体:

public readonly struct Colour
{
    public int Red { get; }
    public int Green { get; }
    public int Blue { get; }
    // ... 构造函数 ...
}

我们创建10,000个随机颜色对象,并将它们放入一个HashSet<Colour>中。测试耗时约15毫秒。

现在,有人建议为了节省内存,将int类型的属性改为byte类型,因为每个颜色分量范围是0-255。

public readonly struct Colour
{
    public byte Red { get; }
    public byte Green { get; }
    public byte Blue { get; }
    // ... 构造函数 ...
}

重新运行相同的测试,耗时激增至约577毫秒!性能下降了近两个数量级。

探究性能下降的根源

通过性能分析工具(如JetBrains dotTrace)进行追踪分析,我们发现时间主要消耗在HashSet的创建上,尤其是EqualsGetHashCode方法被调用了惊人的次数。

对于使用byte属性的版本,Equals方法被调用了近5000万次!而对于10,000个对象,这显然极不正常。

问题根源在于默认的结构体行为。所有结构体都隐式继承自ValueType类,该类提供了默认的EqualsGetHashCode实现。

  • 对于包含int字段的结构体,.NET运行时使用一种优化算法来生成哈希码。
  • 对于包含byte字段的结构体,默认的GetHashCode实现只考虑第一个非静态字段(即Red属性)。由于Redbyte,其值范围是0-255,因此最多只能生成256个不同的哈希码。

哈希表的工作原理是将对象根据哈希码分配到不同的“桶”中。如果10,000个对象只有最多256个不同的哈希码,就会导致大量对象被塞进少数几个桶里。

哈希冲突的灾难性后果

考虑最坏情况:所有对象哈希码相同,落入同一个桶。哈希表需要保证元素的唯一性,因此在插入新元素时,需要与桶内现有元素逐一比较(调用Equals)。

  • 插入第1个元素:比较0次。
  • 插入第2个元素:需要与第1个元素比较1次。总比较次数=1。
  • 插入第3个元素:需要与前2个元素比较2次。总比较次数=1+2=3。
  • 插入第4个元素:需要与前3个元素比较3次。总比较次数=1+2+3=6。
  • ...
  • 插入第n个元素:总比较次数约为 n²/2。

这就是为什么插入10,000个元素需要大约5000万次比较(10000² / 2 ≈ 50,000,000)。算法复杂度从预期的接近O(n)退化到了O(n²)。

解决方案:正确实现 GetHashCode 和 Equals

我们需要为结构体提供自定义的、分布良好的哈希码实现。哈希码的规则是:

  1. 如果两个对象相等(Equals返回true),它们必须具有相同的哈希码。
  2. 哈希码在对象的生命周期内应保持不变。

对于我们的Colour结构体,可以利用构造时的位运算来生成一个完美的、唯一的哈希码:

public override int GetHashCode()
{
    // 将RGB分量组合回一个整数
    return (Red << 16) | (Green << 8) | Blue;
}

同时,我们也需要重写Equals方法:

public override bool Equals(object obj)
{
    return obj is Colour other && Red == other.Red && Green == other.Green && Blue == other.Blue;
}

实现自定义哈希码后,性能立即恢复到正常水平。.NET也提供了HashCode.Combine方法来帮助生成组合哈希码,但在本例中,我们的自定义方法更简单高效。

进阶优化:实现 IEquatable 避免装箱

即使重写了Equals(object),在哈希表比较时,作为参数传入的object obj仍会导致值类型的装箱。为了解决这个问题,我们可以实现IEquatable<T>接口:

public readonly struct Colour : IEquatable<Colour>
{
    // ... 属性 ...

    public bool Equals(Colour other) // 类型安全,无装箱
    {
        return Red == other.Red && Green == other.Green && Blue == other.Blue;
    }

    public override bool Equals(object obj) { ... } // 保留重写
    public override int GetHashCode() { ... } // 保留重写
}

.NET集合类很智能,如果发现类型实现了IEquatable<T>,会优先调用类型安全的Equals(T)方法,从而完全避免装箱开销。

现代C#语法:Record 和 Record Struct

从C# 9/.NET 5开始,我们可以使用record(引用类型)和C# 10/.NET 6引入的record struct(值类型)来进一步简化代码。

// C# 10: record struct (值类型)
public readonly record struct Colour(byte Red, byte Green, byte Blue);

// C# 9: record (引用类型)
public record Colour(byte Red, byte Green, byte Blue);

编译器会自动为record/record struct生成包括EqualsGetHashCode在内的样板代码。这些生成的实现通常非常高效,类似于我们手写的优化版本,同时极大提升了开发效率。

基准测试对比

使用BenchmarkDotNet库对不同实现进行基准测试(针对10,000个对象创建HashSet):

  • IEquatable<T> 结构体:性能最佳,无装箱开销。
  • 仅重写 Equals(object) 的结构体:性能次之,存在装箱开销但影响比预期小。
  • record struct:性能与手写IEquatable<T>的结构体相当甚至略好,代码最简洁。
  • record (引用类型) / 实现 IEquatable<T> 的类:由于涉及堆内存分配和垃圾回收,性能明显慢于值类型版本。

基准测试证实,通过正确实现值类型的相等性比较,可以避免严重的性能劣化,而record struct是现代C#中兼顾性能与代码简洁性的优秀选择。

趣味对比:C++ 版本的性能

出于兴趣,我们使用C++实现了类似的功能(使用std::unordered_set和自定义哈希函数)。在特定测试场景(构建集合)下,优化后的C#版本(值类型)甚至可能比朴素的C++版本表现更好。

这说明了几个重要观点:

  1. 不要盲目相信“C++总是比C#快”:具体性能高度依赖于算法、数据结构和实现细节。
  2. 测量是关键:必须通过实际测量来验证性能假设,而不是依赖直觉或道听途说。
  3. 运行时优化的力量:.NET的即时编译(JIT)可以在运行时进行深度优化,有时能产生意想不到的好结果。

当然,在查找等操作上,经过高度优化的C++标准库容器通常仍有优势。但这个对比旨在打破成见,强调具体分析的重要性。

总结与核心建议

本节课我们一起学习了C#性能优化中的重要一课:

  1. 理解值类型:善用struct可以减少垃圾回收压力,是性能优化的关键工具。
  2. 警惕默认行为:依赖结构体默认的EqualsGetHashCode实现可能导致严重的性能“劣化”,尤其是在用于集合键时。
  3. 正确实现哈希:为用作哈希键的类型提供分布良好的自定义GetHashCode实现至关重要。
  4. 使用 IEquatable<T>:对于值类型,实现此接口可以消除集合操作中的装箱开销。
  5. 拥抱现代语法record struct能自动生成高效、正确的相等性实现,是值得推荐的现代实践。
  6. 始终测量:性能优化必须建立在准确的测量之上。使用单元测试计时、性能分析器(如dotTrace、PerfView)和基准测试框架(如BenchmarkDotNet)来识别瓶颈和验证改进。
  7. 避免劣化:与其追求“过早优化”,不如先确保不引入已知的、严重的性能陷阱。正确实现值类型的相等性比较就属于此类。

记住,最大的性能瓶颈往往来自于程序员的错误假设。通过理解语言和运行时的特性,并借助工具进行实证分析,我们可以有效地编写出既清晰又高性能的C#代码。

工具链接

015:类模板参数推导 (CTAD) 完全指南

在本节课中,我们将要学习 C++17 引入的一个重要特性:类模板参数推导。我们将了解它是什么、它如何工作、它的优势以及在使用时需要注意的事项。

概述:什么是类模板参数推导 (CTAD)?

在 C++17 之前,创建类模板的实例时必须显式指定所有模板参数。例如,要创建一个 std::vector<int>,你必须写成 std::vector<int> v。类模板参数推导允许编译器根据你提供的初始化器,自动推导出模板参数,从而简化代码。

从函数模板参数推导到类模板参数推导

上一节我们介绍了 CTAD 的基本概念,本节中我们来看看它的起源。

函数模板参数推导自 C++ 诞生之初就已存在。当你调用一个函数模板时,编译器可以根据函数参数的类型推导出模板参数。

template<typename T>
void my_func(T param) {}

my_func(1); // 编译器推导出 T 为 int

然而,对于类模板,情况则不同。在 C++17 之前,你必须显式指定模板参数。

template<typename T>
struct MyStruct {};

MyStruct<int> s; // C++17 之前必须这样写

为了绕过这个限制,库作者们通常会编写“工厂函数”(make functions),利用函数模板的参数推导来间接实现类的推导。

template<typename T>
MyStruct<T> make_my_struct(T t) { return MyStruct<T>(t); }

auto s = make_my_struct(1); // 推导出 MyStruct<int>

这种方法带来了大量的样板代码。因此,C++17 引入了类模板参数推导,允许编译器直接根据构造函数参数推导类模板的模板参数。

MyStruct s(1); // C++17 起,编译器推导出 s 的类型是 MyStruct<int>

CTAD 的优势与应用场景

了解了 CTAD 的由来后,本节我们来看看它如何让代码变得更简洁、更易读。

以下是 CTAD 带来便利的几个例子:

  • 简化标准库容器的声明std::vector v{1, 2, 3}; 推导为 std::vector<int>
  • 推导非类型模板参数std::array arr{1, 2, 3}; 推导为 std::array<int, 3>
  • 处理复杂类型:对于 std::pair<std::map<std::string, std::vector<int>>::iterator, bool> 这样的长类型,使用 CTAD 可以简化为 std::pair(it, true)
  • 结合新特性:在 C++20 的 Ranges 库中,std::ranges::join_view 等视图类型可以无缝使用 CTAD,降低了学习新库的入门门槛。

CTAD 的工作原理:隐式推导指南

上一节我们看到了 CTAD 的便利性,本节中我们来深入其工作原理。

CTAD 的核心机制是“推导指南”。当你写下 MyClass obj(arg); 时,编译器会为该类生成或查找一组“推导指南”,并尝试使用函数模板参数推导的规则来匹配这些指南,从而确定 MyClass 的模板参数。

对于大多数类,编译器会根据其主模板的构造函数自动生成“隐式推导指南”。

考虑以下类模板:

template<typename T>
struct S {
    S(const T&);
    S(std::optional<T>);
};

当你写下 S x(1); 时,编译器会为 S 生成两个隐式推导指南,它们类似于以下虚构的函数模板:

// 基于 S(const T&) 生成的指南
template<typename T> S<T> Deduce_S(const T&);
// 基于 S(std::optional<T>) 生成的指南
template<typename T> S<T> Deduce_S(std::optional<T>);

然后,编译器尝试用 1 去调用这些“函数”。第一个指南匹配成功,推导出 Tint。最终,x 被初始化为 S<int>

除了基于构造函数的指南,编译器还会自动生成两个特殊的隐式推导指南:

  1. 复制推导指南:模仿拷贝构造函数,形如 template<typename T> S<T> Deduce_S(S<T>);。它使得 S y = x; 中的 yx 类型相同。
  2. 条件性推导指南:如果类没有声明任何构造函数,则会生成一个模仿默认构造函数的指南。

聚合类与 CTAD

上一节我们介绍了基于构造函数的推导,本节中我们来看看没有构造函数的聚合类如何处理。

聚合类(例如简单的 struct)使用聚合初始化,而不是构造函数。从 C++20 开始,CTAD 也支持聚合类。

对于聚合类,编译器会生成复制推导指南和条件性推导指南。此外,当你进行聚合初始化时,编译器会根据你提供的初始化列表生成一个特殊的隐式推导指南,该指南尝试将初始化器的每个元素与聚合体的每个成员进行匹配和推导。

template<typename T, typename U>
struct Aggregate {
    T t;
    U u;
    int i;
};

Aggregate a{1, ‘c‘, 3}; // C++20 起,推导出 Aggregate<int, char>

用户定义的推导指南

有时,隐式生成的推导指南无法满足需求,或者会产生我们不希望的推导。这时,我们可以编写“用户定义的推导指南”。

以下是需要用户定义推导指南的常见场景:

  • 构造函数参数无法推导模板参数:例如 std::vector 的迭代器范围构造函数。迭代器的类型 InputIt 可以推导,但我们需要的是 iterator_traits<InputIt>::value_type
  • 为特定特化提供推导:当主模板未定义,只有特化模板有构造函数时,隐式指南无法工作,需要用户为特化提供指南。
  • 覆盖或细化推导行为:例如,希望 S s1(s2) 进行转换而非复制时,可以定义一个与复制推导指南签名相同但推导结果不同的用户指南,用户指南优先级更高。

用户定义推导指南的语法如下:

template<typename InputIt>
vector(InputIt, InputIt) -> vector<typename iterator_traits<InputIt>::value_type>;

重要注意事项

  1. 不要为标准库类型或其他不属于你的类编写推导指南,这会导致未定义行为。
  2. 对于聚合类,一旦提供了用户定义的推导指南,编译器将不再为该类生成基于聚合初始化的特殊隐式指南。

CTAD 的细节与注意事项

掌握了 CTAD 的基本用法后,本节我们探讨一些关键细节和潜在陷阱。

CTAD 与转发引用
在函数模板中,T&& 在模板参数推导时可能是转发引用。但在类模板的隐式推导指南中,如果 T 是类模板参数,T&& 就是普通的右值引用,而不是转发引用。转发引用行为仅出现在模板构造函数的推导中(此时 T 是构造函数自己的模板参数)。

“全部或无”的推导
与函数模板可以部分指定参数(如 func<int>(3.14))不同,类模板参数推导是“全部或无”的。一旦你显式指定了任何一个模板参数,CTAD 就会被完全禁用。这是因为部分推导的语义(尤其是涉及默认模板参数和可变参数模板时)非常复杂且存在歧义,标准委员会尚未找到完美的解决方案。

复制优于转换
这是 CTAD 的一个重要设计选择。考虑以下代码:

std::vector<int> a{1,2,3};
std::vector b(a); // b 的类型是?

直觉上,大多数人认为 b 应该和 a 类型相同,即 std::vector<int>。CTAD 通过复制推导指南(按值接受同类对象)优先于其他需要转换的指南,确保了这一点。如果你在泛型代码中需要“容器化”行为(即 make_vector(a) 得到 vector<vector<int>>),则不应依赖 CTAD,而应显式编写代码或使用工厂函数。

潜在的意外推导
如果你的库是在 C++17 之前编写的,并且用户在新编译器下使用了 CTAD,他们可能会“意外地”获得推导。如果你后续修改了库,意外地改变了推导行为,可能会导致用户代码编译失败。因此,库作者应当测试并考虑 CTAD 对用户代码的影响,必要时使用“非推导语境”来禁用特定构造函数的 CTAD,或者提供明确的用户定义指南并加以文档说明。

总结与最佳实践

本节课中我们一起学习了 C++17 的类模板参数推导特性。

总结如下

  • 是什么:CTAD 允许编译器根据初始化器自动推导类模板的模板参数。
  • 如何工作:基于隐式生成或用户定义的“推导指南”,利用函数模板参数推导的变体规则。
  • 优势:极大简化了代码,特别是使用标准库容器和新特性(如 Ranges)时。
  • 注意事项:理解其“复制优于转换”的原则;注意在泛型代码或容器转换场景中谨慎使用;知道它无法进行部分推导。

给开发者的建议

  • 对于使用者:CTAD 是一个有用的便利特性,可以积极使用以简化代码。但在涉及容器转换或需要精确控制类型的泛型代码中,最好显式指定类型。
  • 对于库作者:你无法阻止用户使用 CTAD。应当主动测试你的类在 CTAD 下的行为,确保推导结果是合理且符合预期的。如有必要,通过用户定义推导指南来引导正确的推导,或使用非推导语境来限制不希望的推导,并将这些设计决策清晰地记录在文档中。

CTAD 并非魔法,也非解决所有问题的银弹,但它是一个设计良好、可以显著提升代码简洁性和可读性的工具。理解其机制和边界,就能让它成为你 C++ 工具箱中的得力助手。

016:原则、误区与意外发现

在本节课中,我们将要学习绿色软件架构的核心概念、实践中的注意事项以及一些可能令人意外的发现。我们将探讨如何通过软件设计和开发实践来减少碳排放和能源消耗。

概述

绿色软件是指能够减少温室气体排放和降低能耗的软件。当前,信息技术行业消耗了全球约10%的电力,预计到2030年将增至20%。其二氧化碳排放量与航空业相当,约为2-3%,预计到2040年可能增至14%。因此,开发更环保的软件至关重要,这不仅有益于环境,也能为公司节省运营成本,并为开发者带来有趣的工程挑战。

绿色软件的定义与分类

上一节我们介绍了绿色软件的重要性,本节中我们来看看如何定义和分类绿色软件。

绿色软件的核心目标是减少温室气体排放和降低能耗。需要注意的是,能耗减少与碳排放减少并非完全等同。如果软件运行在使用可再生能源的数据中心,即使能耗较高,其碳排放也可能很低。然而,在当前能源结构下,能耗是衡量碳排放的一个有效代理指标。

绿色软件基金会将绿色软件分为两类,这两类并非互斥:

  • 碳效率:指软件本身更高效,从而排放更少的碳。这可以通过提高能源效率(使用更少电力)或硬件效率(需要更小的硬件,从而减少生产碳排放)来实现。
  • 碳感知:指软件在运行时考虑碳排放因素。例如,选择部署在使用可再生能源的数据中心,或者根据电网的碳强度调整运行模式。

此外,从更广义的“绿色IT”角度来看,还可以分为:

  • 绿色在IT中:指让IT本身变得更环保,例如制造更节能的硬件、开发更高效的软件。
  • 绿色通过IT:指利用IT技术帮助其他系统减少碳排放,例如汽车中的节能控制系统。

核心概念:绿色能力与可持续性

在理解了绿色软件的分类后,我们需要引入两个核心概念:绿色能力和可持续性。

绿色能力 是一种软件质量属性,用于衡量应用程序的环保程度,类似于安全性、可扩展性等质量属性。提高绿色能力意味着让软件更环保,消耗更少的能源和碳排放。

可持续性 则是一个需要权衡的方面,包含多个维度:

  • 经济可持续性:软件项目需要在成本效益上可行。
  • 社会可持续性:软件应具有包容性,例如确保残障人士的可访问性。有时为了可访问性可能需要在能效上做出妥协,但这对于构建一个公平、可持续的社会至关重要。
  • 环境可持续性:这正是“绿色在软件中”所关注的核心,即软件本身对环境的影响。

当前可采取的行动:需求阶段

了解了基本概念后,我们来看看在软件开发的各个阶段可以采取哪些具体行动。首先从需求阶段开始。

在需求阶段,我们可以通过明确和精简需求来为绿色软件奠定基础。以下是具体建议:

  • 将绿色能力作为一等需求:像对待性能、安全性一样,明确将降低能耗和碳排放作为系统需求。
  • 只做必要之事:严格管理需求,只实现绝对必要的功能。编写更少的代码意味着更少的编译、测试和运行能耗。
  • 协商合理的服务等级协议:例如,是否真的需要“五个九”(99.999%)的可用性?降低可用性要求可以显著减少为冗余所付出的资源和能源。
  • 避免过度指定的目标:不要盲目追求谷歌级别的可扩展性。允许服务在资源紧张时适度降级(如视频会议降低画质),或根据能源的绿色程度调整服务质量。
  • 减少数据保留时间:缩短日志等数据的保留期限,可以节省存储和处理这些数据所需的能源。

然而,定义绿色需求面临一个挑战:我们缺乏参考基准。我们知道如何衡量每秒处理多少用户请求,但不知道完成某个操作“只应消耗10千瓦时”是否合理,因为我们过去很少测量这些指标。

当前可采取的行动:设计与工程阶段

明确了需求后,接下来我们进入设计与工程阶段。这个阶段的核心是提高能源效率,但需要注意,追求高效率可能带来成本、可维护性和弹性方面的挑战。

在软件设计层面,绿色能力必须是有意为之的,不能指望事后补救。设计时需要根据权衡,决定是侧重碳感知、能源效率还是硬件效率。以下是一些设计考量:

  • 减少数据传输:使用高效的协议(如Protocol Buffers替代REST/JSON),避免过于“健谈”的交互,只发送必要数据。
  • 将计算推送到客户端:利用智能手机等设备进行计算,可以减轻服务器负载,并可能利用夜间充电时更绿色的能源。但需注意确保软件能向后兼容,避免迫使用户升级设备。
  • 优化数据库:合理使用索引等基础优化手段,常常能大幅提升效率,避免不必要的缓存层。
  • 减少空闲时间:处于空闲状态的服务器可能仍消耗高达60%的峰值功率。因此,应尽量提高硬件利用率,例如通过多租户方式让硬件保持忙碌。
  • 实施节俭的数据存储策略:只存储必要的数据。
  • 需求塑形:根据本地电网的碳强度调整服务功能(如之前提到的视频画质调整)。

在实现层面,编程语言的选择是一个常见话题。研究表明,不同语言在执行相同算法时的能耗差异很大(例如C语言通常比Python更高效)。但必须谨慎看待这类研究:

  • 结果高度依赖于具体任务类型。
  • 语言速度与能耗并非总是直接正相关。
  • 选择最高效的语言不一定是实现绿色软件的最佳途径。开发效率、团队技能和业务可持续性同样重要。

其他实现建议包括:

  • 清理未使用的代码和数据:避免编译、测试和加载永远不会执行的代码。
  • 优化常驻任务:对于持续运行的任务,应优先考虑其效率。
  • 权衡技术栈迁移:将系统从低效语言迁移到高效语言可能带来能效提升,但需要评估其时间成本和对业务可持续性的影响。

一个反直觉的事实是:低效的代码仍然可以变“绿”。例如,可以将其部署在使用可再生能源的数据中心。

当前可采取的行动:测试与运维阶段

完成了设计与实现,我们需要通过测试来验证,并通过运维来维持绿色状态。目前,测试阶段缺乏直接测量软件能耗的实时工具,这是一大挑战。

在测试阶段,我们主要能让测试活动本身变得更环保:

  • 只运行相关的测试:当代码变更时,只执行受影响的测试用例。
  • 限制测试环境数量:避免为每个功能分支都创建完整的测试环境。
  • 优化流水线:设置流水线在代码无变化时不触发测试。
  • 利用绿色能源运行自动化测试:如果可能,安排在能源更绿色的时段运行测试。

运维阶段是当前实现碳减排最大收益的领域。提高运营效率可以削减5到10倍的碳排放,且通常不需要特殊工具,利用现有的监控和部署工具即可。以下是一些有效的运维策略:

  • 关闭未使用的服务:在非工作时间或周末关闭测试环境。
  • 清理闲置应用:定期检查并删除云平台上无人使用的部署实例。
  • 提高机器利用率:通过使用更小的实例规格、实现多租户,让服务器保持高负载运行,减少空闲能耗。
  • 使用托管服务和云平台:云服务商通常能更好地实现动态扩展和资源优化。
  • 时间转移:将批处理作业安排在电网碳强度较低的时间段运行。
  • 位置转移:将工作负载部署在使用可再生能源的数据中心(对于大型云平台用户,这通常是一个可选项)。

机器学习模型的特殊考量

最后,我们简要探讨一个日益重要的领域:机器学习。训练和运行机器学习模型,特别是大型生成式AI模型,能耗极高。

一些研究发现:

  • 训练一个大型模型的能耗可能相当于五辆美国汽车整个生命周期的能耗。
  • 生成式AI(如图像生成)比判别式AI(如图像分类)能耗高得多。
  • 模型训练比单次推理能耗高,但如果模型被海量调用,总推理能耗会非常可观。
  • 专用模型比通用大模型更环保。
  • 准确性与能耗之间存在权衡:略微降低模型准确度(例如1%),有时能大幅降低能耗(例如77%)。因此,需要根据应用场景(如电影推荐 vs. 医疗诊断)来决定可接受的准确度水平。

未来展望与总结

本节课中我们一起学习了绿色软件架构的各个方面。尽管面临工具缺失、实践矛盾等挑战,但采取行动至关重要。

展望未来,我们期待以下改进:

  • 实时能耗测量工具的出现,使开发者能够精确评估和优化代码。
  • 绿色开源库和框架的普及。
  • 编译器和语言的持续改进,自动生成更高效的代码。
  • 更环保的机器学习模型
  • 软件工程原则与实践的更新,将绿色能力纳入核心考量。

总结:构建绿色软件虽然困难但意义重大。在当前工具和模型尚不完善的情况下,我们依然可以利用现有知识和近似方法采取行动。通过在设计、实现、测试和运维各阶段有意识地关注能效与碳排放,我们能为建设一个更可持续的数字未来做出贡献。

017:过去、现在与未来

概述

在本教程中,我们将探讨C++中文件输入/输出(I/O)的演变历程。我们将从早期计算机的存储方式开始,逐步了解文件系统的概念,分析缓冲式I/O的局限性,并探索现代无缓冲I/O的优化技术。最后,我们将展望C++标准未来可能提供的帮助。本教程旨在为初学者提供一个清晰、直白的文件I/O知识框架。


第一部分:为什么我们需要文件?

在计算机发展的早期,程序和数据通常作为一个连续的字节流存储在磁带或软盘上。例如,在20世纪80年代初的ZX81计算机上,程序和数据被录制到盒式磁带上,整个程序就是一个数据流。这种方式非常不便,因为每次修改程序都需要重新录制整个流。

随着存储容量的增加(例如Atari ST的720KB软盘),开发者意识到可以将数据流划分为不同的部分,并为这些部分命名,从而形成了“文件”的概念。文件本质上是存储介质上的一段数据,而文件名则是这段数据的书签。这使得在程序运行时可以动态地加载和交换不同的数据块(如代码、音效、图形),极大地提高了开发的灵活性和效率。

核心概念:文件是存储介质上具有标识符(文件名)的一段数据,用于在程序需要时被加载到内存中。


第二部分:什么是文件系统?

上一节我们介绍了文件的概念,本节中我们来看看如何组织和管理这些文件。

文件系统是一种组织文件的机制,它通过某种标识系统(如路径和文件名)来方便地访问文件。它本质上是一系列指向存储介质上数据起始和结束位置的书签,并提供了方便的搜索功能。

一个早期的例子是FAT16(文件分配表)。它使用存储在磁盘上的索引表(即文件分配表)来标识与单个文件关联的存储区域链。根目录包含每个文件的第一个簇号,操作系统通过遍历FAT链表来查找文件的后续部分。

POSIX(可移植操作系统接口)标准定义了文件系统应具备的行为,例如强一致性(写操作后的读操作必须返回写入的数据)、原子操作以及通过权限控制文件访问等。

需要注意的是,文件系统(如FAT16、NTFS)与磁盘格式是分离的。文件系统定义了如何组织和访问文件,而磁盘格式定义了数据在物理介质上的布局方式。

核心概念:文件系统是用于组织、存储和检索计算机文件数据的结构和逻辑规则。


第三部分:为什么应该避免缓冲式文件I/O?

在了解了文件系统的基础后,我们来看看从文件读取数据到内存的具体方式。传统上,C和C++标准库提供了缓冲式I/O。

C运行时库(如fopenfread)和C++的流库(如fstream)在底层使用缓冲区。例如,当调用fread时,库会请求操作系统内核将一些数据读入内核内存,然后复制到你的文件缓冲区中,最后你再从缓冲区复制到目标位置。虽然这支持了灵活的字符级或格式化I/O操作,但带来了额外的内存复制开销。

随着存储技术(如NVMe SSD)的速度飞速提升,从存储设备到内存的数据传输时间大大缩短。此时,在内存缓冲区之间进行不必要的数据复制所花费的时间,在总I/O时间中所占的比例显著增加,成为了性能瓶颈。

核心概念:缓冲式I/O涉及多次内存复制,随着存储设备速度提升,其相对开销变得不可忽视。


第四部分:如何优化无缓冲文件I/O?

既然缓冲式I/O存在性能问题,本节我们来看看更高效的替代方案:内存映射文件。

现代操作系统(如Windows、Linux)支持将文件直接“映射”到进程的地址空间。这意味着文件中的数据看起来就像在内存中一样,你可以通过指针直接访问它,而无需通过read/write系统调用和用户空间的缓冲区。

其工作原理依赖于处理器的内存管理单元(MMU)和虚拟内存系统。当程序访问一个被映射的地址时,如果对应的数据页不在物理内存(RAM)中,会触发一个缺页中断,操作系统负责将对应的文件页加载到内存。这个过程是透明的。

优化技巧
以下是使用内存映射文件进行预加载的策略:

  1. 在知道需要某个文件后,立即映射它。
  2. 在另一个线程中,以页面大小(如4KB)为间隔“接触”(访问)映射区域的地址,触发操作系统异步地将文件内容预加载到内存。
  3. 当主线程真正需要访问文件数据时,有很大概率数据已经在内存中,从而避免阻塞。

这种方法将I/O操作与计算重叠,特别适用于减少游戏等应用程序的加载时间。

代码示例(概念性)

// 伪代码,展示内存映射的思路
void* mapped_region = os_map_file_to_memory("large_data.bin");
std::thread preload_thread([mapped_region] {
    touch_memory_pages(mapped_region, file_size); // 在后台线程预接触内存页
});
// ... 执行其他计算 ...
preload_thread.join();
// 此时直接使用 mapped_region 指针访问数据,延迟极低
process_data(static_cast<const char*>(mapped_region));

第五部分:未来C++标准如何帮助我们?

最后,我们展望一下C++标准库未来可能提供的解决方案,以简化高效文件I/O并提升开发体验。

1. #embed 指令
这是一个提议加入C语言标准(并有望影响C++)的预处理器指令。它允许在编译时将二进制文件(如图片、音频)的内容直接嵌入到程序中,生成一个静态的字节数组。

// 提议中的语法示例
static const unsigned char font_data[] = {
    #embed "font.ttf"
};

这消除了程序运行时读取这些资源文件的需要,将数据加载工作完全转移到编译期,是“左移”思想的典范——尽可能在更早的阶段(构建时而非运行时)完成任务。

2. 标准化的内存映射文件
提案P1883(std::file_handlestd::mapped_file_handle)旨在为内存映射文件操作提供可移植的C++标准接口。目前,开发者需要使用平台特定的API(如Windows的CreateFileMapping/Linux的mmap),该提案将统一这些操作,提高代码的可移植性和易用性。

3. 更底层的I/O库
像P1031这样的提案试图全面改革C++的I/O流库,提供一个更适应现代存储硬件(如高速SSD)的低开销、异步友好的I/O库。虽然这类提案庞大复杂,但它们指明了标准库需要演进的方向。

未来趋势:直接存储访问
虽然可能超出标准库范围,但像Windows DirectStorage这样的技术代表了另一个方向:允许数据直接从NVMe SSD传输到GPU显存,完全绕过CPU和系统内存,这将进一步革命性地减少加载时间。


总结

在本教程中,我们一起学习了文件I/O的核心知识:

  1. 文件的存在是因为程序无法在编译时容纳所有数据,或者数据量超过了可用内存/地址空间。
  2. 文件系统是组织文件的机制,提供了一种通过路径和名称查找数据的结构化方式。
  3. 应避免缓冲式I/O,因为额外的内存复制开销在现代高速存储设备上变得越来越显著。
  4. 优化无缓冲I/O的关键技术是使用内存映射文件,并结合预取策略,将I/O操作与计算重叠。
  5. 未来的C++标准可能通过#embed、标准化的内存映射文件接口以及新的底层I/O库来帮助开发者编写更高效、更可移植的文件操作代码。

理解文件I/O的过去和现在,能帮助我们在未来选择更合适的技术,构建性能更卓越的应用程序。

018:欢迎来到元世界v0.1 🚀

在本节课中,我们将要学习C++26中备受期待的静态反射特性。我们将探讨反射的基本概念、其历史发展、核心语法,并通过实际例子展示其强大功能。最后,我们将讨论反射对现有代码库和库设计带来的挑战与思考。

什么是反射?🤔

反射是软件暴露其内部结构的能力。我们希望通过这种能力,基于现有代码(无论是他人还是自己的代码)来构建新功能。本节课讨论的是静态反射,即在编译时暴露代码结构。

需要明确的是,虽然技术上可以将编译时信息保留到运行时,但这可能带来性能、安全等问题。目前针对C++26的反射提案完全不涉及运行时,仅专注于静态反射。

课程结构 📚

本节课分为两部分:

  1. 反射入门:我们将回顾反射的历史,深入了解C++26的反射提案P2996,并查看一些使用示例,特别是从库设计的角度。
  2. 对代码库的影响:我们将讨论反射如何作为一种定制点机制,如何融入我们的开发流程,并进行总结。

本教程的所有示例均来自反射提案P2996、关于函数参数名的提案P3096,以及已在Compiler Explorer上实现的EDG编译器。

反射简史 📜

以下是反射在C++社区发展过程中的关键节点:

  • 2006年:首次出现通过模板元编程实现反射的库(由Matthias Troyer编写)。
  • 后续发展:Mike Spertus等人提出了反射的用例。随后出现了基于“Mirror”反射库的提案,这些提案主要关注类型特征。
  • 2015年:Louis Dionne发布了Brigand库,它重度依赖模板元编程,但也让人们意识到模板元编程在编译时开销较大。
  • 2017年:提案激增,引入了元对象的概念,即保存代码信息的对象。
  • 2018年:出现了“基于值的反射”、“T4和单态类型”等术语,最终导向使用单一类型(std::meta::info)来表示反射信息。同年,David Sankel提出了反射技术规范(TS)。
  • 2023年10月:提出了P2996提案,这是目前针对C++26的主要反射提案。此后,又出现了许多基于反射的提案(如Python/JS绑定),显示了社区对反射特性的广泛兴趣。

C++26反射提案核心 🎯

P2996提案包含几个核心支柱:

  1. 反射运算符(Reflection Operator):曾用名“lift”。它的作用是将代码从C++领域“提升”到反射领域,以便进行元编程操作。
    // 示例:将类型`int`提升到反射领域
    std::meta::info refl_int = ^int;
    
  2. 拼接器(Splicer):作用与反射运算符相反,将信息从反射领域“拼接”回C++领域
    // 示例:将反射信息拼接回代码,定义一个值为42的int变量a
    std::meta::info r = ^int;
    typename(r) a = 42; // 等价于 `int a = 42;`
    
    注意:typename(...)是一种占位符语法,用于提示编译器我们希望将反射信息解释为一个类型。
  3. 元信息对象(std::meta::info:这是一个单态类型,用于保存所有被反射实体的信息。可以将其理解为编译器抽象语法树(AST)节点的一个句柄。
  4. 元函数(Meta Functions):一系列用于查询和操作std::meta::info对象的函数,功能类似于现有的类型特征(type traits),但更强大。
    • 查询类:例如获取名称、位置、父类型、判断是否为私有成员等。
    • 结构类:例如获取类的成员列表、模板参数、函数调用信息等。
    • 定义类:一个非常有趣的功能,允许在反射领域中动态构建新的类或类型(也称为“生成式反射”)。

需要注意的是,修改类型的元函数(例如,直接通过反射注入代码)目前不在提案中,因为实现复杂且可能干扰编译器模型。

反射示例 ✨

让我们通过几个简单例子看看反射的实际应用。

示例1:反射日志库

这是一个基于反射的日志库,可以自动打印任意结构体的成员。

以下是库代码的实现思路:

// 库函数:打印对象的成员
template<typename T>
void log_members(const T& obj) {
    // 1. 将类型T提升到反射领域
    std::meta::info type_info = ^T;
    // 2. 获取所有非静态数据成员
    auto members = std::meta::nonstatic_data_members_of(type_info);
    // 3. 遍历并打印成员名和值
    for (auto member : members) {
        std::cout << std::meta::name_of(member) << ": " << /* 获取成员值 */ << std::endl;
    }
}

用户代码可以这样使用:

struct Student {
    std::string name;
    int id;
};

Student s{"Alice", 123};
log_members(s); // 输出:name: Alice, id: 123

库函数通过反射运算符^T获取了Student类型的元信息,进而遍历其成员,无需用户手动指定要打印哪些字段。

示例2:命令行参数解析

这个例子展示了编译时反射与运行时行为的结合。用户可以通过定义结构体来声明期望的命令行参数。

用户代码:

struct Args : Clap { // 继承自某个反射库提供的基类
    Option<std::string> name{*this, ‘n’, “name”}; // 短选项-n,长选项--name
    Option<int> count{*this, ‘c’, “count”, 1}; // 短选项-c,长选项--count,默认值1
};

int main(int argc, char* argv[]) {
    Args args;
    args.parse(argc, argv);
    std::cout << args.name << “, “ << args.count << std::endl;
}

库(Clap)在内部使用反射来解析用户定义的Args结构体,自动将命令行参数与对应的成员绑定。

示例3:函数参数名反射

这个例子揭示了反射带来的一个新挑战。反射允许获取函数参数的名称,但如果函数的声明和定义中的参数名不一致怎么办?

考虑以下代码:

// 声明
void func(int first, int last);
// 定义
void func(int n, int l) { ... }

// 反射库函数:打印参数名
print_parameters(^func);

如果反射发生在声明之后,可能得到[“first”, “last”];如果发生在定义之后,可能得到[“n”, “l”]。这带来了一致性问题

提案P3096讨论了三种解决方案:

  1. 无保证:编译器可以自由选择打印哪个名字,不同编译器或不同位置可能结果不同。
  2. 强制一致:如果声明和定义的参数名不一致,则程序编译失败。
  3. 使用属性标记:使用类似[[canonical]]的属性指定哪个声明是“规范”的,未标记的则视为非法。

每种方案都有其优缺点,涉及到对现有代码的兼容性、库的易用性等。这体现了反射特性在引入强大功能的同时,也需要仔细考虑其语义和边界。

反射作为定制点机制 ⚙️

定制点是指库暴露给用户、让用户能够根据自己类型定制库行为的机制。传统机制包括虚函数重写、模板特化、依赖参数依赖查找(ADL)等。

反射提供了另一种强大的定制点机制。库作者可以通过反射来“探查”用户类型的结构,并自动生成相应的行为,而无需用户显式特化模板或定义特定函数。这更加灵活,但也更“魔法”,需要清晰定义反射操作的保证和限制。

反射的集成与挑战 🧩

反射库的集成与传统库不同,带来新的挑战:

  • 编译与链接的分离:一个反射库可能独立编译成功,但在链接使用了特定用户代码时才会因反射语义问题(如参数名不一致)而失败。这与传统库基于标准版本或特性测试宏的兼容性不同。
  • 保证与兼容性:我们需要决定为反射操作提供何种程度的保证:
    • 不同标准库版本之间是否需要保证反射行为的兼容性?
    • 对现有源代码是否需要保证反射能正常工作?
    • 当反射操作“失败”时(如上述参数名不一致),程序是编译失败、未定义行为,还是抛出编译期异常?
  • 错误与警告:反射发生在编译的早期阶段。如何提供清晰易懂的错误信息?是否应该提供警告来提示用户代码中可能导致反射问题的模式(如参数名不一致)?这几乎相当于在C++编译器之上又构建了一层“元编译器”。

总结 🎓

本节课我们一起学习了C++26中即将到来的静态反射特性。

我们首先了解了反射的基本概念——在编译时暴露和操作代码结构的能力。接着回顾了其漫长而丰富的发展历史。然后,我们深入探讨了当前提案P2996的核心:反射运算符拼接器用于在代码领域和反射领域间转换;std::meta::info 作为承载元信息的统一对象;丰富的元函数用于查询和操作。

通过日志库、命令行解析和函数参数名反射等示例,我们看到了反射如何简化代码、实现强大功能,同时也暴露了如一致性问题等新的挑战。最后,我们从库作者的角度探讨了反射作为定制点机制的潜力,以及它给构建工具链、错误处理和向后兼容性带来的深刻影响。

反射是C++迈向更高层次元编程的关键一步,它功能强大,但也需要社区仔细考量其设计细节,以确保它能稳健地融入C++生态系统。

019:安全、安全性与C++演进

概述

在本节课中,我们将学习C++语言在安全性和安全性方面的现状、挑战以及未来的演进方向。我们将探讨编程语言安全、软件安全与软件安全性之间的区别,分析C++当前面临的主要内存安全问题,并了解旨在通过“安全配置文件”等机制来默认提升C++代码安全性的社区努力。课程内容基于Herb Sutter在ACCU 2024大会上的主题演讲。


什么是安全、安全性与安全性?

首先,我们需要明确几个关键术语。这些并非随意定义,而是有ISO标准参考的行业术语。

软件安全网络安全 是指保护我们的软件免受攻击,以保护我们的资产、秘密和机密信息。

软件安全性 则关乎确保软件不会造成意外的伤害。我们希望医院的设备正常工作,自动驾驶汽车不会撞到人。其目标是使软件免于因缺陷导致不可接受的风险。

编程语言安全 可能是我们提到“安全”时首先想到的。它指的是语言如何帮助保证程序的正确性。内存安全是其中的一部分。当我们不加修饰地说“安全”时,通常指的就是编程语言安全。

这三者之间的关系至关重要:编程语言安全有助于提升软件安全和软件安全性。它不仅能保护软件免受入侵,还能提高程序员的生产力,使程序更健壮、缺陷更少。因此,编程语言安全是达到目的的手段,而非目的本身。我们关心它,是为了获得更安全的软件、更可靠的系统以及更高的开发效率。


C++面临的核心安全问题

上一节我们介绍了不同类型的安全概念,本节中我们来看看C++语言当前面临的具体安全问题。

我的论点是,问题不在于追求完美,不在于实现100%可证明的形式化正确类型系统。问题在于我们需要达到与C#、Rust、Go、Python等其他内存安全语言同等的水平。

我们可以通过改进四个方面来实现这一目标:类型安全边界安全初始化安全生命周期安全

目前正在进行的一项努力(我希望它能成功,并愿意帮助其在WG21中取得成功)是建立一个安全配置文件框架。这个框架可以帮助我们在编译时默认强制执行这些规则。由于需要向后兼容,默认设置可能是关闭的,但我们希望让启用变得容易。在启用的代码区域内,安全将成为默认设置,只有在需要时我们才选择退出。


数据揭示的问题

为了理解问题的严重性,我们可以参考一些数据来源,例如MITRE的CWE列表。

以下是2023年排名最严重的十大常见弱点枚举。其中哪些与编程语言安全相关?

答案是:CWE-119(缓冲区溢出)CWE-125(越界读取)CWE-787(越界写入)。注意,其中两个是关于边界安全的。我们之前提到的四个需要改进的安全领域中,边界安全是首要问题,是低垂的果实

下图展示了一年半前我在CPPCon上展示过的幻灯片,它再次说明了内存安全漏洞的细分情况(暂时排除其他类型)。问题依旧是老生常谈的:生命周期、初始化、类型和边界安全。

我们当前最直接的问题是:在C/C++代码中,太容易在这四个领域意外地写出bug,因为默认情况下代码会编译通过,并且看起来能工作(就像竞态条件一样)。这并不理想。


安全执行时机的重要性

上一节我们明确了C++需要改进的四个安全领域,本节中我们来看看如何以及何时执行安全规则。

强制执行安全规则的时机至关重要。今天,我们可能有一个静态分析工具,但也许它太昂贵,无法在开发者的本地机器上每次构建时都运行。因此,它可能在CI流水线后期才运行。这样,就会有更多缺陷溜进来。我们知道我们需要“左移”,即尽可能早地发现错误。在构建时发现错误是理想的。

我想引用David Chisnall(来自微软研究院)的话。大约三个月前,他在FreeBSD黑客论坛上回应了一个关于将Rust作为系统语言或采用Rust的讨论。他是微软内部一份推荐默认使用Rust的战略论文的合著者之一。他根据在那里的经验提供了背景。

以下是David的原话(已公开):

“在现代C++(配合静态分析器)和Rust之间,就微软内部的评估而言,安全性差距很小。然而,推荐仍然倾向于在新代码中使用Rust。这主要是基于人为因素的决策:防止人们提交无法编译的代码要容易得多。”

所以,尽早进行安全检查很重要,在构建时进行是最理想的

他还指出,即使只是使用现代C++的智能指针和范围,与C语言相比也存在巨大的安全性差距。

因此,我们应该做的第一步是:将我们已经拥有的、存在于现代C++和其他工具中的检查,移到构建时进行


默认安全的重要性

上一节我们讨论了安全检查的时机,本节中我们探讨另一个关键概念:默认安全。

今天,C++(以及过去30年)的构建哲学是:默认追求性能,安全始终可用。默认是什么?是性能。你想从C++程序员冰冷的手中夺走那些性能“利器”吗?我们想要所有这些利器。

但我们也必须承认,如果一个语言能早期拒绝错误并早期提供内存安全保证,总体上我们将拥有更少的bug。这不仅关乎安全和安全性,也关乎生产力。

那么,在不拿走“利器”的情况下,我们能做什么?我们可以说:以默认安全为目标。找到一种方法,在编译时默认强制执行这些已知规则,但仍然提供退出机制。你仍然可以拿到所有“利器”,不仅仅是像Rust的unsafe那样只给你五六把。我们想要所有“利器”,我们想要时间旅行、未定义行为,但请不要默认提供。我们根本不想要未定义行为的时间旅行。但如果我们能看到它何时发生,那么我们就知道我们为此付出了什么代价。是我们主动选择这样做,并会格外小心。

我想用一个比喻来说明。下图展示了两种厨房:

左边的图片代表 “默认性能,安全始终可用”。小心脚下。
右边的图片是同一个厨房,同一位厨师,同样的刀具,但刀具放在了抽屉里。这代表 “默认安全,性能和控制始终可用”

很多厨师手指都不全,你知道,这确实会发生。所以,我认为的一种思考方式是:你穿着盔甲。你想要默认安全。一个在战斗中的人当然想一直穿着盔甲。但上下文很重要。默认安全是好的,但你能想到一个你不想穿着重盔甲的场景吗?

是的,如果你在水下正在下沉,你想做的第一件事就是说:“我爱这盔甲,但现在我真的想快点脱掉它。”这个想法灵感来源于电影《明日边缘》中的机甲场景。即使是汤姆·克鲁斯,有时也需要退出安全模式。


关于安全性的常见误解

在深入探讨解决方案之前,我们需要澄清一些关于C++安全性的常见误解。

误解一:问题不在于定义什么是安全。
我们应该首先瞄准哪些安全领域?类型、边界、初始化、生命周期安全。这是低垂的果实,是我们当前的痛点,也是其他语言取得主要优势的地方。

误解二:问题不在于C++不是形式化可证明类型安全的。
令各地的语言设计者懊恼的是,目前还没有一个商业上成功的编程语言是形式化可证明的。有些语言仍在尝试。但这既非必要也非充分条件。不必要,因为我们的内存安全语言已经在不完美的情况下设定了相当高的标准。不充分,因为有很多漏洞与内存安全无关,我们也必须解决它们。

所有设计都关乎权衡。这里我想称赞一下Rust。Rust的安全代码专门使用基于树的数据结构(是的,你有Rc和其他方式来做非树状数据结构)。这赋予了它一个超能力:它是迄今为止唯一一个具有强大并发安全、线程安全保证的主要商业语言(尽管我知道Swift等语言也在紧追不舍)。这值得称赞。
但这也带来了成本:在Rust中,你比其他语言更频繁地需要使用unsafe。因为在其他语言如C#中,你可以在完全安全的代码中拥有循环数据结构,而在Rust中不行。你需要使用底层用unsafe实现的Rc,或者使用索引作为反向指针等。这不是批评。请注意,我把对勾和美元符号都标成了绿色,因为这是一个工程和设计的权衡。现实世界就是如此,我们接受权衡。

误解三:没有我认识的安全专家相信,如果我们能挥舞魔杖,让世界上每一行C/C++代码一夜之间变成内存安全语言,就意味着我们将减少70%的漏洞。
例如,我们已经看到十大CWE中有三个是关于内存安全的。所以,即使一夜之间我们让这三个都消失,我们就完成了吗?我们是否应该期望我们已经减少或消除了世界上70%的漏洞?远非如此。不要误解我的意思,摆脱那三个会很棒,我们应该尝试。但不要目光短浅。当我们想要应对软件面临的所有威胁时,不要过于关注一个问题。

为什么你家的前门不像下图这样?(我很喜欢“Fort Knox”这个制造商标签。)这会是相当好的前门,相当安全。是的,总存在可用性权衡,你自己进出可能不方便。但是,为什么我们不把所有的家庭安全预算都花在前门上呢?因为窃贼会寻找下一个最薄弱的入口点。如果你有一扇没有栏杆的不安全窗户,就在前门旁边或房子后面,他们可以安静地进去而不被邻居听到。你花了很多钱在前门上,但并没有保护好你的房子。

所以,我们想要保护所有的攻击点。攻击者会攻击兽群中最慢的动物。如果我们加固了一个地方,他们会转向下一个地方。我们已经看到攻击者从恶意软件转向其他载体,因为端点安全是有效的。我们还有很长的路要走,但我们已经看到攻击者的转移。他们会寻找你的窗户,即使我们忽略了它,他们也会寻找。

是的,过去几年绝对存在与内存安全相关的严重攻击。但许多最严重的攻击(并非全部)与内存安全无关。

  • SolarWinds 是关于劫持更新系统来传递恶意软件。
  • Log4J 是用安全语言(Java)编写的。它是关于清理输入。
  • 2023年的Kiwii Security(一款家长控制应用)暴露了敏感信息,因为他们没有为Elasticsearch启用身份验证,留下了3亿条记录,包括成千上万的电话号码和电子邮件。
  • DarkBeam(一个数据泄露警报数据库)也通过(鼓点)没有为Elasticsearch启用身份验证而暴露了其记录。

所以,我想说的是,就像Rust一样,给我们的C++用户一个默认提升安全性的模式不会拯救我们。这不会解决世界上所有威胁行为者的问题。但它绝对会帮助我们,我们最好去做

误解四:我听过一些人说:“这是你的错,C++程序员,因为你应该使用我们一直在给你的工具,你们这些不知感恩的懒骨头。”
这可能会激起你的愤怒,尤其是如果你曾尝试使用那些工具,知道它们有多难获取和使用。

我不会通读这张幻灯片,但我在我写的关于C++安全的文章中讨论过这一点。今天我们在交付这些已知的好规则方面存在真正的缺陷。因此,开发者需要的是低误报的检查,最好能随编译器一起提供,在构建时、提交前运行,并且是确定性的、易于采用的。这应该是我们努力的目标。


C/C++的现状与挑战

现在,让我们坦诚地看看C/C++作为一个安全问题的现实情况,它是真实存在的。

C++的两个支柱,简而言之:

  1. 强大的抽象能力,且除非使用否则无需付出代价。 一个好的简称是什么?零开销抽象原则。我们大体上遵循这一点(异常和RTTI除外),这是C++的超能力之一。
  2. 与旧版C++和C近乎完美的向后兼容性。 你可以在同一个C++项目中混合使用C、旧版C++和现代C++,而且它能正常工作。一个好的简称是什么?遗产

现实是,我们大多数C++项目中的代码(即使因为第三方依赖),都是一个光荣的、模糊的风格混合体。这包括了所有的C/C++。

我知道有人认为“C/C++”这个术语很糟糕。我认为这是一个非常好的术语,用来描述我们作为C++标准委员会成员负责的代码集合。显然,能同时作为C和C++编译的代码显然属于交集。

但这里有一个小测验:以下哪一个是C程序?哪一个是C++程序?

// 代码A
#include <stdio.h>
int main() {
    printf("Hello\n");
    return 0;
}
// 代码B
#include <iostream>
int main() {
    std::cout << "Hello\n";
    return 0;
}

正确答案是:它们都是C++程序。如果你怀疑,ISO/IEC 14882(C++标准)说这两者都是合法的。这不是C和C++交集的准确描述。更接近的描述是,C/C++是C++的C子集,这个名称是合适的。

我们有各种各样的风格,甚至还没有包括内部风格和使用C++的方式。它们都是有效C++项目的一部分。是的,我们有现代、良好的C++(如果你能找到它的定义的话)。我想指出的一个点是:所有这些风格都“超出了标准范围”。

  • 左边,有标准C的部分(如restrict、VLA)还不是标准C++的一部分。
  • 如果你使用没有RTTI的C++,那么你就在使用自己的扩展来代替dynamic_cast
  • 如果你使用没有异常处理的C++,那么你就在使用其他非标准的东西。
  • 即使你是C++核心指南的作者,告诉人们使用良好的现代C++,你仍然会说并使用GSL span,因为std::span还没有边界检查。

我们都或多或少地超出了标准范围。但我感兴趣的是,我们能为圆圈内的所有代码做些什么?因为如果它是合法的C++,它就是我们的问题,我们有责任去解决和改进,包括当我们谈论安全配置文件时。


安全配置文件的采用与影响

上一节我们探讨了C++代码的多样性,本节中我们来看看安全配置文件如何能够被广泛采用并产生最大影响。

我老板的老板(大致如此),John Cunningham(微软负责所有编程语言工程的公司副总裁),他是这样对我说的(他允许我引用):

“我们的C++安全改进工作不能是我们挂在墙上欣赏的艺术品。我们是工程师。我们制造的东西可以是美丽的,但我们不想设计一些挂在博物馆里的东西。我们希望人们四处走动并使用它。”

这是在讨论如何使安全配置文件具有可采纳性的背景下。所以我真的想多谈谈这一点。

我想最大化适应性。我们如何交付它们,以便它们能够大规模采用?我也想最大化影响力。我有良好的代码和“狡猾”的代码(指风格老旧或复杂的代码)。安全配置文件能为我做什么,也能为那些“狡猾”的代码做什么吗?还是我们就把它抛在后面?因为有很多“狡猾”的代码,如果我们能改进它,我们将为我们的文明做一件好事,因为我们的文明也依赖于那些“狡猾”的代码。

假设我们有了这些配置文件,它们定义了众所周知的、可静态执行的、确定性的、低误报的规则。

一个选择是简单地拒绝不遵循这些规则的代码。例如,如果你使用reinterpret_cast,它会被标记,你必须选择退出才能使用reinterpret_cast。问题是这需要你更改代码,所以至少存在一些摩擦。你们中有多少人在职业生涯中艰难地认识到更改代码是昂贵的?更改代码即使是为了消除bug,也会在某个百分比(希望较低)上引入新的bug。这是不可避免的。

那么我们能做得更好吗?因为有时答案就是必须更改代码。有时你不得不说:“对不起,把那个reinterpret_cast改掉以符合配置文件。”

但我假设,并且我已经在cppfront中实现了这些(以我简化的语法),但你也可以在标准C++中做同样的事情:当配置文件被强制执行时,类型安全被强制执行。如果我们能让不安全的代码做正确的事情,那就直接做,而不需要代码更改

例如,如果我有一个static_cast指针向下转型,我知道他们本意是想写dynamic_cast。只要RTTI启用并且基类中有虚函数表,我就可以直接为他们生成dynamic_cast代码(如果他们选择了安全配置文件)。

我们为什么要这样做?因为这样做有缺点。代码现在可以说是在“撒谎”,因为它写着static_cast,而我们实际上在做dynamic_cast。但首先,如果我们走这条路,我们会记录安全配置文件会这样做。其次,现在我们做了一件非常重要的事情:我们改进了现有代码。所有那些在20世纪90年代编写的、我们无法更改的代码,我们至少修复了其中一部分。通过使用配置文件而无需代码更改,这是关于适应性和影响力,甚至是对旧代码的影响力。

另一件我们可以做的事情是插入运行时检查以捕获动态错误。边界检查就是一个很好的例子。如果我的边界安全配置文件是开启的(这是cppfront已经做的,并且它已经多次捕获了我自己的边界错误),当你看到a[b]这种形式的表达式,并且std::size(a)可用时(包括对C数组、vectorspan等),只需在调用点插入 0 <= b && b < std::size(a) 的检查。突然之间,从那行代码开始(就像今天在cppfront中一样,也包括未来用边界安全配置文件编写的代码),你所有现有的标准库(无需任何ABI破坏,无需任何更新)都进行了边界检查。我不必在vectorspan中放入检测代码,而且它对C数组也有效。

然后,所有这三种选项(修复、检查、现代化)也可以提供我称之为 “现代化” 的功能,即提供一个“修复建议”。你希望我把那个static_cast改成dynamic_cast吗?你仍然需要程序员说“是”。但超能力仍然是:这不是手动代码更改。如果你能提供开发者可以信赖的可靠修复建议,这就是我们推动代码向前发展的方式。

所以,在一个轴上,从C到闪亮的C++,我们能帮助多少这样的代码?在另一个轴上,从最顶端全新的、没有依赖和互操作性的项目,一直到不能更改的第三方库(没人理解且不敢碰,因为它是关键的),如果我们所做的只是部署静态分析规则说“我要拒绝你的代码”,我们能帮助多少?我们可以帮助其中一部分。代码越现代,需要的更改就越少,因此可采纳性就越高。但随着你接触到更旧的代码,基本上就像是说“好吧,用std::span重写它”之类的。那么,为了可采纳性,我们将主要关注易于更新的、没有很多相互依赖或全新的项目。

我的希望是,通过修复、检查和现代化,我们可以将一些安全改进应用于所有我们用安全配置文件重新编译的C和C++代码。当然,我们不会获得全部好处。我们无法摆脱你的reinterpret_cast,我不知道用什么替换它们。但我们将能够做一些事情,比如边界安全,这很重要。

请记住,我们的竞争对手在上面。如果我们所做的只是“拒绝”并且已经要求代码更改,你首先会得到的一个问题是:“好吧,如果我要更改我的代码,特别是如果我无论如何都要重写它,为什么我不直接用Rust或C#或Python或其他语言重写它呢?你告诉我我已经得去重写我的代码了。”

以下是一个我最近向某人展示的关于我在cppfront中所做事情的例子:

// 使用C数组
int a[3] = {1, 2, 3};
// 或者使用 std::array
std::array<int, 3> a = {1, 2, 3};

// 下面的三行代码如果作为cpp2代码编译(我们也可以为未来的ISO C++做同样的事情,在调用点注入边界检查)
// 你会得到错误,告诉你bug的确切行号、最小/最大值以及你试图访问的内容。
std::cout << a[0] << "\n";
std::cout << a[1] << "\n";
std::cout << a[2] << "\n"; // 如果数组只有2个元素,这里会报错
std::cout << a[3] << "\n"; // 明显的越界访问

仅仅通过重新编译。所以这不是要求边界检查开销。你希望在紧循环中有办法退出,你想要所有这些。但我希望默认边界安全。这个功能已经保护了我,因为我写了一个非常像这样的例子,我实际上把索引搞对了(0,1,2),因为我做过一两次了。但我犯的错误是:在我的第一个例子中,我有三个元素。然后我决定,你知道这是一个例子,我去掉其中一个。我没有更新循环,没有更新其余部分。然后它说:“你试图访问第三个元素,没有第三个元素。” 好吧,谢谢,你抓住我了。这可能发生在我们所有人身上。

顺便说一下,我展示Visual C++和GCC的原因是因为这已经通过cppfront在所有主流编译器上工作了。所以这是我们也可以在C++编译器中做的事情。


行动呼吁

无论你使用什么语言,请使用你的消毒剂。Rust支持消毒剂,它们很棒。Go支持消毒剂,它们很棒。使用它们。总有一些安全检查因为太昂贵而无法在构建时运行,消毒剂可以在这方面提供帮助。

请保持所有工具的更新。例如,我希望你已经安装了Rust 1.77.2(几天前发布的),因为那个补丁修复了一个关键漏洞(仅当你的目标是Windows系统,并且你使用command库来调用批处理文件,并且你有不受信任的输入时,你才可能在那里有漏洞)。再次强调,这个漏洞也适用于许多其他语言,不仅仅是Rust。Rust在报告和修复方面很主动,这值得称赞。

请保护你的软件供应链。为你的库依赖使用包管理,以便你能获得修复和更新。
请跟踪SBOM(软件物料清单)。是的,这是管理上的痛苦,有很多文书工作。但所有知道XZ Utils是他们系统一部分的人,都会因为做了保持SBOM最新的工作而感到庆幸,这样他们就能立刻知道:“好吧,我需要回退到XZ Utils的先前版本,或者至少不使用那个tarball(它实际上与提交到GitHub的源代码不同)。”

看在上帝的份上,不要将秘密存储在代码中,更不要存储在公共的GitHub仓库中。
启用身份验证,更改默认密码。我很抱歉这需要说出来,但DarkBeam、Kiwii Security...
通过教育来开启。
加密静态和传输中的数据,并保持你的威胁建模与时俱进,因为攻击者会在你加固一个区域时改变方向。

这些都是对我们时间的“征税”。我理解。它们并不便宜,当我们本可以编写新功能时,它们却是无聊的管理细节。但为了我们明年还有工作,我们的公司还能存在,这是必要的卫生习惯

请记住,我们需要加固所有这些。是的,让我们从4号和7号木头(比喻弱点)开始,但我们需要加固所有它们。


关于cppfront的更新与实验

让我用剩余的时间来更新一下cppfront以及与之相关的事情(我的个人项目)。

首先,再次感谢所有提交issue或PR的人(名单越来越长,字体越来越小)。还有更多人评论和潜水等等。但感谢所有这些积极贡献了超过1000个issue和PR、超过160位贡献者的人。我真的很感激,这确实帮助项目变得更好。

在CPPCon 2023上,有一个小组讨论,他们压倒性地投票让我去写文档。所以我基本上“消失”了两个半月,学习了MkDocs和Material主题(很棒的东西)。总是要学习新技术,对吧?我们有时必须这样做,然后写文档。他们还促使我开始正式的版本发布。所以现在有了编号版本,因为我觉得初始功能集已经完成(包括文档),可以放心地加上版本号了。这将继续下去。目前它们仍在非商业使用许可下,因为目前这仍然是一个实验。

但让我告诉你我学到的一些东西。有一系列关于我学到的东西的幻灯片,但这里有一个可以过渡到接下来几个月将要发生的事情。

我还没有尝试优化cppfront,因为在我自己的使用中,它已经足够快了。大多数时候,大部分时间花在C++编译器上。所以我一直没觉得需要优化它。但我总是有点好奇。我使用I/O流处理所有I/O。我很懒,我知道以后可以用stdio代替。我用大量指针追逐的数据结构构建AST。一个表达式可能像10或50个节点,取决于它在表达式语法树中的深度。这些都是小内存分配,我们知道分配是昂贵的。

所以当Yoel Peña提交了两个PR,提供了50到120倍的性能提升(取决于用例,整体上cppfront快了两个数量级)时,我想:好吧,我想知道是哪一个(我提到的两个可能原因)起了作用,因为他觉得有必要做这个。

以下是差异看起来的样子。如果你看不到那些小的绿色条,让我稍微扩展一下,这样你就能看到那里确实有绿色。这是“之后”的图。它比我写的代码快一点。那么你认为是什么原因?我给了你一点提示。你认为这两个中的哪一个?答案是:都不是。他移除了我代码中四处使用std::regex的地方。因为我不常用std::regex。我想,我应该树立一个榜样,让我使用标准库,而且我不希望cppfront有非标准库依赖。所以我用了std::regex。我真傻。我没有意识到,现在我明白这意味着什么了:那四处使用正则表达式的地方(例如,为了匹配下一个东西是不是关键字,相当短、简单的正则表达式),你知道,也许我用错了。比如,我不知道,也许我只创建了一个regex对象并重用了它,但我不确定。也许我做错了。那四处使用主导了整个运行时的I/O、内存分配、整个编译过程的一切。

所以,谢谢你,Yoel。

为了说明背景,我们失去了图像。能恢复图像吗?哦,回来了,好。那些红色的条一直延伸到流的顶部。

所以谢谢你,Yoel。但也要谢谢你,Hana。Hana今天不能来,她要在接下来两周参加另外三个会议。但非常感谢你,Hana,感谢你在过去七年左右在编译时正则表达式方面所做的所有工作,它使用constexpr函数和模板来解析正则表达式(如果你在编译时知道它),然后在编译时解析它。你知道运行时工作有多少,然后就会少很多,对吧?然后它有编译时和运行时匹配,并输出非常高效的汇编代码。而且它有效。C、CTRE和Rust在图的左侧并驾齐驱,例如。C++和PHP则远远在右边。我相信C++是std::regex

所以谢谢你,Hana。

这与cppfront的关系是:来自德国的Max Sagebaum正在研究,既然在cppfront(CPP2)中我们有反射和源代码生成,我们能否在不使用太多模板和模板元编程constexpr函数的情况下,做Hana正在做的事情?我不认为Hana做了很多TMP。但想法是:我们使用模板做了太多编译时工作,这是为什么C++编译时间随着我们试图改进标准而变得越来越慢的一个主要原因。但我们正在将更多花哨的工作移到一个从未设计用于计算的模板实例化引擎中。你不应该在那里写逻辑。但结果证明它太不完整了,所以你可以,所以每个人都这样做,对吧?但这是一种非常低效的运行方式。顺便说一下,那些已经使用过constexpr函数(就像在编译时运行的常规函数)的人,是的,它本质上需要在编译器中有一个C++解释器。你会想:哦,不,我在我的编译器里添加了第二个编译器。是的,你是。而且它几乎总是比TMP等价物快得多。因为你是在声明你的意图,你的编译器会回报你,而不是滥用模板实例化机制(它必须做很多其他事情,并且从未为通用计算而设计)。

所以Max所做的是(至少最初是为了解析),他用反射和代码生成替换了模板栈,因为CPP2有这些东西,所以你可以反射代码,并且可以生成更多的源代码,这些源代码会被flex解析并添加到你的项目中。

所以调用代码的源代码看起来与CTRE(左侧)和cppfront(右侧)非常相似。主要区别在于,代替CTRE的consteval和模板代码,很多变成了一个编译时元函数,他称之为regex,当你在编写类型时应用它。这是他选择的方式,其中x是类型的一个成员。但有不同的方法可以做到这一点,这是他选择的方式。

示例运行时结果表明,这仍然不完整,还不能从中得出结论。但它似乎与CTRE相当。在这个特定的运行中,它快了一点,但不要下结论。我们还不能说它更快。测试仍在进行中。Max的版本还不支持Unicode,例如,所以CTRE比Max的版本做了更多工作,因为它支持更多功能。但这仍然是一个初步的、有希望的结果,表明我们处于同一个水平,而不是那个水平。

那么编译时间呢?这是编译428个正则表达式,这是Haskell正则表达式一致性测试套件。再次强调,现在下性能结论还为时过早。但我没必要用对数刻度。在编译时间方面,它处于同一个水平。

现在请注意,然后让我说明一点。首先,我们正在探索是否可以使用反射和生成来获得相同的编译时好处,但代码更清晰,甚至可能编译时间更少,并且仍然获得相同的运行时好处。所以,以Hana的工作为基础并继续推进,说如果我们越来越多地使用反射和代码生成而不是模板会怎样?早期结果是有希望的,我们可以学习这一点,并将同样的东西用于ISO C++的演进。这并不依赖于CPP2和cppfront。我只是碰巧写了那个实现作为一个测试平台和实验,在这里效果很好。

但我想指出一些事情,因为我经常听到这个。很多人会对我说:是的,但cppfront很棒。但你在运行另一个完整的编译器。而我的C++编译器已经太慢了。所以我完全理解。我的编译器已经很慢了,而你告诉我甚至在我到达我的编译器之前还要运行另一个编译器。嗯。但你必须计算正确的东西。在这个特定的例子中,那个小条(CTRE当然没有CPP2步骤,所以条是0)。但这是整个条,它更像是一条线,表示cppfront编译器生成正则表达式的速度有多快。大部分工作是在中间的C++编译器中。但看看我们交换了什么,至少到目前为止:我们获得了这么多的改进,减少了C++编译器的运行时间,代价是这么多。所以当你说“是的,我正在做以前没有做的额外工作”时,它花费的成本必须包括它为你节省了你原本必须做的工作。这意味着做额外的工作可能更快。我们所有人都学过缓存、缓存结果。比如当你做重复查找时。你们中有多少人在过去一个月里写过“我要缓存你的结果”?所以你做了更多工作,你使用了更多空间。但因为你没有做它替换的工作,你的程序运行得更快。constexpr代码现在本质上是在你的C++编译器中运行一个完整的C++解释器。所以你会想,嗯,那更慢。是的,它增加了成本,但它取代了更大的成本,因为用模板元编程表达相同的东西甚至更慢。所以那是净速度提升。

所以我认为这是一个很好的思考方式。这里的经验是:是的,你在运行另一个编译器。但它是一个简单得多的编译器,即使你在做很多元函数工作,比如正则表达式所做的源代码生成和反射。

所以Max在未来几个月的目标(也许我今年晚些时候会有更新)是进一步推进这一点,使用反射和代码生成,不仅用于解析,也用于引擎,并在实际生成的代码中使用更少的模板来进行正则表达式匹配,因为我们可以生成代码,我们可以用更少的可重用模板制作普通代码。

这将是一个有趣的实验。我认为圣杯,就像Hana,我知道,同意这一点。圣杯是语言内置的正则表达式,编译器可以直接为你优化所有这些。但我是一个大粉丝,特别是现在我们即将拥有反射和编译时代码生成,并且至少已经有了一些工作原型。我们能在多大程度上使用这个通用功能,而不是将又一个功能硬编码到一门大语言中?我们能否将其表达为一个使用反射和生成的编译时普通constexpr函数?这将很好理解。如果我们能通过反射和源代码生成获得相同的可用性,甚至可能更好的编译时间,那将比等待我们所有的编译器供应商去实现C++29的内置正则表达式功能更可取。顺便说一下,目前还没有这样的提案。但你知道,如果我们没有即将到来的反射和生成,可能会有。

所以这仍然是我的个人实验,用来尝试一些东西。我很高兴看到其他人也在用它来尝试一些东西。

目标是你今天听到很多的内容。所以这大致是我以前展示过的一张幻灯片。但我们今天讨论了很多关于安全的内容。我们讨论了很多关于改进我们的工具以使安全在编译时成为默认设置。但我们也讨论了工具性,就编译时间而言。我们如何解决编译器慢的问题?我已经知道如何用一种更简单的语言做到这一点,但我也想为ISO标准C++做出贡献,特别是当我们正在生成现在已经很深入(甚至可能用于C++26,祈祷吧)的提案时,用于反射和一些代码生成。

我之前提到过,我在CPP2中所做的工作(甚至在cppfront这个特定编译器存在之前),我大约从2015年就开始做了,并向标准贡献东西。所以第一件事是operator<=>(三路比较运算符),它来自那项工作,已经被标准化,现在在C++20中。我没有告诉人们(有几个人知道,但我没有告诉人们它来自CPP2)。我只是说,嗯,我口袋里有一个设计,要我写一个提案吗?所以我写了,委员会喜欢它,然后修复了它。谢谢。但紧接着consteval就来了。所以consteval在C++20中。它来自CPP2工作,因为Lock3(Andrew Sutton和他的团队)正在构建一个CPX编译器(当时还不叫CPP2),并看到了对consteval的需求。所以去提议了它。所以我们已经在标准中拥有consteval的原因(甚至在我们拥有反射之前两个标准)是因为我们将其用于反射。Andrew意识到在C++中,你需要一种方式来说这个函数在编译时运行。在CPP2中,你放一个@,所以你已经可以分辨出来。但在今天的C++中,等价物还不存在,所以我们需要consteval。即使是C++26的反射提案(有两个主要的,Andrew Sutton的Lock3和David Vandevoorde在EDG实现中做了很多工作,谢谢你,David),Lock3来自CPP2。它是为了开发CPP2而获得资助的。所以我致力于以合作的方式将这些贡献回ISO C++演进。我对分裂和拥有一个分支语言或竞争性的后继语言不感兴趣。这些词从未出现在我的脑海中。但是,我们可以合作做些什么来推动C++向前发展?所以我最后在这里谈到了反射。但边界检查、自动边界检查已经在cppfront中实现了。我可以实现它,我相信一个真正的C++编译器实现者可以实现它。我知道这并不难。因为你需要做的就是看到一个下标表达式(你在解析树中很容易知道它就在那里),然后你就可以直接发出代码。如果你愿意,你可以从cppfront的支持库中获取它,它说:如果std::size对该类型存在,则插入此检查。这很容易做到。


总结

在本节课中,我们一起学习了C++在安全领域面临的挑战与机遇。

我们明确了软件安全软件安全性编程语言安全的区别与联系,认识到提升语言安全是获得更安全软件和更高生产力的重要手段。

我们分析了C++当前在类型安全边界安全初始化安全生命周期安全这四个核心领域存在的缺陷,这些是导致大量内存安全漏洞的根源,也是其他内存安全语言的主要优势所在。

我们探讨了通过安全配置文件框架来默认强制执行已知安全规则的愿景,强调了在构建时检查默认安全的重要性。同时,我们也必须清醒地认识到,内存安全只是软件安全全景图中的一部分,绝不能忽视凭证管理、供应链安全、社会工程学等其他攻击向量。

最后,我们了解了cppfront项目作为实验平台,在探索边界检查、反射与代码生成以改善安全性和编译时性能方面的努力。这些实验旨在为ISO C++标准的未来演进提供可行的思路和贡献。

记住,攻击者无处不在,他们正在给我们造成巨大的痛苦。在讨论C++及其他语言的安全性和其他安全方式时,让我们时刻关注所有这些威胁。

020:使用GDB提升C/C++开发者的调试能力

概述

在本教程中,我们将跟随Dawid Zalewski的演讲,学习如何使用GDB(GNU调试器)来提升C/C++程序的调试能力。我们将从调试的基本概念开始,逐步深入到更高级的功能,如断点管理、栈帧导航、核心转储分析以及时间旅行调试。本教程旨在让初学者也能轻松理解并掌握这些强大的调试技巧。


什么是调试?🤔

上一节我们介绍了本课程的目标,本节中我们来看看调试的核心定义。

调试不仅仅是“识别和移除计算机硬件或软件中的错误”。对于程序员而言,调试是一种“涌现性活动”。它发生在程序的心智模型(我们预期程序如何运行)与观察到的运行时行为不一致,并且这种不一致的原因无法轻易被发现时。

例如,考虑以下简单程序:

int numbers[] = {017, 025, 1764};
int result = numbers[2] / (numbers[0] + numbers[1]);
return result; // 预期返回42

我们的心智模型是 1764 / (17+25) = 42。但程序实际返回49。仅通过 printf 打印结果无法找到原因,这时就需要启动调试器。


GDB入门:编译与启动 🚀

上一节我们了解了调试的时机,本节中我们来看看如何为调试准备程序并启动GDB。

为了有效调试,我们需要在编译时包含调试符号。使用 -g 标志告诉编译器生成这些符号。为了获得最佳GDB体验,建议使用 -g3 并保留帧指针。

以下是编译调试版本程序的命令:

g++ -g3 -fno-omit-frame-pointer -O0 -o my_program my_program.cpp

编译完成后,我们可以启动GDB:

gdb ./my_program

在GDB中,使用 start 命令(它会在 main 函数入口处设置一个临时断点并开始运行)比直接使用 run 命令更便于开始调试。

启动后,GDB会显示即将执行的下一行代码。你可以使用以下基本命令控制执行:

  • next (n): 执行下一行代码(不进入函数内部)。
  • step (s): 执行下一行代码(会进入函数内部)。
  • print (p): 打印变量或表达式的值。
  • list (l): 显示当前位置附近的源代码。

控制执行流与栈帧导航 🧭

上一节我们学会了如何开始调试和执行下一步,本节中我们来看看如何在函数调用间穿梭并理解调用栈。

当程序调用多个函数时,nextstep 的区别至关重要。step 会进入被调用的函数内部,而 next 则将其作为一个整体执行。

在函数内部,你还可以:

  • finish: 继续执行,直到当前函数返回。
  • return <value>: 强制当前函数立即返回指定值(用于修改程序行为进行测试)。

程序运行时,每个函数调用都会在调用栈上创建一个栈帧。GDB允许你查看和导航这些栈帧。

以下是相关的命令:

  • backtrace (bt): 显示完整的调用栈(所有活动的栈帧)。
  • frame <n>: 切换到编号为 n 的栈帧。
  • up/down: 在调用栈中向上或向下移动一层。
  • info args: 显示当前栈帧的函数参数。
  • info locals: 显示当前栈帧的局部变量。

当程序崩溃(如段错误)时,backtrace 命令是定位问题发生位置的首要工具。


高级断点与观察点 🎯

上一节我们学习了如何在崩溃后分析现场,本节中我们来看看如何主动设置断点来捕获特定事件。

除了在函数入口设置断点 (break function_name),GDB还支持更精细的控制:

以下是设置和管理断点的方法:

  • break file.c:line_number: 在特定文件的特定行设置断点。
  • tbreak: 设置临时断点(只触发一次)。
  • condition <breakpoint_id> <condition>: 为断点设置触发条件(例如 condition 1 x > 100)。
  • ignore <breakpoint_id> <count>: 忽略断点前 count 次触发。
  • enable/disable/delete: 启用、禁用或删除断点。
  • info breakpoints: 列出所有断点。

GDB还可以设置特殊的捕获点来拦截异常:

catch throw std::logic_error # 当抛出 std::logic_error 时中断
catch catch std::logic_error # 当捕获 std::logic_error 时中断

数据检查与“打印调试” 📊

上一节我们掌握了控制程序执行的方法,本节中我们来看看如何有效地检查和输出数据。

print 命令功能强大,GDB内置了“漂亮打印”功能来优雅地显示复杂数据结构(如STL容器)。

除了简单的 print,还有更强大的数据输出方式:

  • 打印数组的多个元素:print *array@10 (打印数组的前10个元素)。
  • display <expression>: 每次程序暂停时自动打印指定表达式的值。
  • printf “格式字符串”, 表达式1, 表达式2, …: 像C语言的 printf 一样格式化输出。
  • dprintf <location>, “格式字符串”, 表达式…: 在指定位置(如函数入口)动态注入一个 printf 语句,无需修改源代码即可进行“打印调试”。例如:
    dprintf add_deposit, “size is %d, deposits are %v\n”, size, deposits
    

你甚至可以在调试会话中调用程序中的函数来测试逻辑:

call to_lower(some_string)

注意:调用函数时需确保参数类型完全匹配,因为不会发生运行时转换。


分析核心转储文件 💾

上一节我们学习了实时检查数据,本节中我们来看看如何分析程序崩溃后生成的内存转储文件。

核心转储是程序崩溃时内存状态的快照。首先,需要确保系统允许生成核心转储:

ulimit -c unlimited

程序崩溃后,核心文件(如 corecore.<pid>)会被生成。你可以用GDB加载它进行分析:

gdb ./my_program core

加载后,GDB会停在程序崩溃的位置。此时,你可以像调试活进程一样使用 backtraceprintframe 等命令来调查崩溃原因,例如空指针解引用、数组越界等。


时间旅行调试 ⏳

上一节我们分析了静态的崩溃现场,本节中我们来看看如何动态地回放程序执行历史。

GDB内置了反向调试功能,允许你“倒带”执行历史。这对于理解导致崩溃的一系列事件特别有用。

基本工作流程如下:

  1. 在可能发生问题的地方设置断点。
  2. 开始记录执行轨迹:record
  3. 继续执行直到崩溃:continue
  4. 使用反向命令回溯:
    • reverse-next (rn): 反向执行一行(不进入函数)。
    • reverse-step (rs): 反向执行一行(会进入函数)。
    • reverse-continue (rc): 反向继续执行直到上一个断点。
    • reverse-finish: 反向执行直到当前函数被调用时。

注意:GDB的 record 功能可能对使用了特定CPU指令(如SSE)的程序支持不佳。一个更强大的替代工具是Mozilla的 RR,它提供了更稳定和易用的录制与回放功能。

使用RR的基本步骤:

rr record ./my_program # 录制执行
rr replay # 回放并进入GDB调试会话,之后可以使用所有正向和反向调试命令

总结

在本教程中,我们一起学习了GDB调试器的强大功能。我们从调试的基本概念和GDB入门开始,逐步掌握了控制执行流、导航栈帧、设置高级断点、检查数据、分析核心转储文件,最后甚至体验了“时间旅行调试”——反向执行程序。

记住,GDB不仅仅是一个简单的步进工具。通过条件断点、命令脚本、dprintf、反向调试以及像RR这样的外部工具,你可以系统地调查和解决复杂的软件缺陷。将这些技巧融入你的工作流程,将极大地提升你诊断和修复C/C++程序问题的能力。

021:通过良好设计减少C++编译时间

概述

在本教程中,我们将学习如何通过一系列设计和技术手段,显著减少C++项目的编译时间。我们将从理解C++编译模型开始,探讨一些无需修改代码即可应用的“快速见效”方法,然后深入分析如何识别编译瓶颈,并应用具体的设计模式和技术进行优化。最后,我们将总结关键要点,帮助初学者系统性地提升项目的构建速度。


动机:为何要关注编译时间?

首先,我们需要理解为何减少编译时间至关重要。

提升开发体验:如果本地编译耗时过长,会导致频繁的上下文切换。例如,你启动编译后去喝咖啡、处理邮件或被同事打断,之后再重新进入工作状态会非常困难。这还会导致“重构惰性”——如果一次小的改动需要本地编译一小时,CI流水线再运行一小时,那么开发者进行代码改进的意愿会大大降低。减少编译时间能让新功能开发、重构和问题修复更加流畅。

加速持续集成:更快的构建意味着更快的产品上市时间。无论是发布新功能还是紧急修复生产环境的问题,漫长的编译过程都会成为瓶颈。

解锁工具链:我们特别希望启用静态分析工具(如clang-tidy)来检查代码质量和最佳实践。但如果基础编译就需要一小时,运行静态分析可能再需要两小时,这在实际中是无法接受的。

资源利用:在大公司中,减少编译时间可以直接降低对计算资源的需求,从而节省成本和能源。


C++编译模型快速回顾

在深入优化之前,我们需要对C++的编译过程有一个基本的了解。C++编译主要分为几个阶段:

  1. 预处理器:处理#include#define宏、#ifdef等指令。预处理器会将头文件内容“复制粘贴”到源文件中,生成一个单独的翻译单元。
  2. 编译:编译器对翻译单元进行解析、词法分析、语法分析(生成抽象语法树)、模板实例化,最终生成汇编代码和目标文件(.o.obj)。
  3. 链接:链接器将多个目标文件以及所需的库文件组合在一起,解析符号引用,生成最终的可执行文件或库。

关键点:头文件中#include的内容可能会远远超过你实际编写的源代码量。编译器在解析和构建抽象语法树时,大部分时间可能花在处理这些依赖项上,而不是你的业务逻辑代码。

用公式表示一个翻译单元的生成:

翻译单元 = 源文件(.cpp) + 所有`#include`的头文件内容(递归展开)

快速见效:无需修改代码的优化

在动手修改代码之前,我们可以先检查并优化构建工具链本身。以下是几个可以直接“套用”的改进方案。

构建系统:使用Ninja

如果你在使用CMake,强烈建议将构建系统生成器切换为Ninja。只需在CMake配置命令中添加-G Ninja即可。

优势

  • 更优的依赖追踪:在增量构建时,Ninja确定需要重建哪些文件的速度远快于传统的Make。
  • 为元构建系统设计:Ninja本身不用于手写构建文件,而是与CMake等工具完美集成。
  • 自动并行:默认使用所有可用的CPU核心。

根据一些性能分析,在增量构建或无操作构建(确定无需编译任何东西)时,Ninja可以比Make快一个数量级。

链接器:升级到LLD或Mold

链接阶段也可能成为瓶颈。考虑使用更现代的链接器。

  • LLD:LLVM项目的一部分,比默认的LD或Gold链接器快得多,具有更好的并行性和自定义内存分配策略。在许多情况下,速度可以达到LD的10倍。使用方式简单,可通过-fuse-ld=lld参数启用。
  • Mold:一个全新的现代链接器,由曾参与LLD开发的Rui Ueyama创建,速度甚至比LLD更快。它采用了更极致的并行设计和高效数据结构。需要注意的是,它在Unix平台是开源的,在macOS和Windows上可能需要付费订阅,并且比LLD占用更多内存。

示例对比:在构建Clang项目时,使用不同链接器的耗时可能为:标准LD(42秒)-> Gold(33秒)-> LLD(5秒)-> Mold(约1秒)。

编译器缓存:使用ccache

编译器缓存可以极大加速干净构建和切换分支后的构建。

原理ccache会对预处理后的翻译单元、编译器二进制文件及其编译选项进行哈希计算,并将哈希值与生成的目标文件关联缓存。如果之后遇到相同的哈希,它就直接从缓存中返回目标文件,跳过编译过程。

使用方法

  • ccache伪装成你的编译器(推荐),它会自动转发调用。
  • 支持HTTP和Redis后端,可以实现团队甚至全公司范围内的缓存共享,一人编译,全员受益。

注意事项:在全新的CI环境(缓存为空)中进行干净构建时,使用ccache会有首次填充缓存的开销。因此,需要根据CI的具体使用场景来决定。


识别编译瓶颈:性能分析

上一节我们介绍了一些工具链的优化方法。本节中,我们来看看如何找到代码中真正的编译热点。切记:不要盲目优化,先定位问题。

方法:使用Clang-ftime-trace标志进行构建过程剖析。

操作:在编译命令中添加-ftime-trace(Clang 9及以上版本)。编译器会为每个翻译单元生成一个JSON文件,其中包含了详细的耗时火焰图数据。

工具

  1. 直接分析JSON:可以查看每个翻译单元中时间花费在哪些头文件、模板实例化上。
  2. 使用Clang Build Analyzer:这是一个更上层的工具,它会聚合所有翻译单元的-ftime-trace数据,给出全局视角的分析报告,例如:
    • 编译时间最长的目标(target)
    • 被包含最频繁的头文件
    • 最耗时的模板实例化

通过这个分析,我们就能清晰地看到编译时间主要消耗在哪些头文件、哪些依赖上,从而有针对性地进行优化。


核心优化技术

通过性能分析找到瓶颈后,我们就可以应用以下设计和技术来系统性地减少编译时间。

1. 使用前向声明

前向声明允许你在不包含完整类定义的情况下,声明一个类、结构体或函数。这可以避免在头文件中引入不必要的庞大依赖。

规则:如果只需要使用类型的指针或引用,并且不需要知道该类型的大小或成员,就可以使用前向声明。

代码示例

// 前向声明
class LargeDependency;

class MyClass {
public:
    // 可以:参数/返回值为指针或引用
    void process(LargeDependency* dep);
    LargeDependency& getRef();
    
    // 不可以:成员变量为实例(需要知道大小)
    // LargeDependency instance; // 错误!
    
private:
    // 可以:成员变量为指针或引用
    LargeDependency* ptr_;
    LargeDependency& ref_;
};

实践建议

  • 在头文件中,优先使用前向声明而非#include
  • 考虑为你的库创建专门的前向声明头文件(例如,标准库的<iosfwd>)。
  • 重要原则:只对你拥有的代码进行前向声明。对标准库类型进行前向声明是未定义行为。

2. 移除未使用的#include

头文件中每一个#include都会迫使编译器在预处理时读入并处理该文件。即使你最终没有使用其中的任何内容,这个开销依然存在。

识别方法

  • 手动检查:依赖开发者的细心。
  • 工具辅助:尝试使用include-what-you-use (IWYU) 这类工具,但它们有时会产生误报。
  • 依赖图分析:使用CMake--graphviz选项生成项目依赖图,可视化地发现不合理的依赖关系。

我们的发现:我们有一些通过Protocol Buffers生成的巨大头文件(约25万行),被许多地方包含。通过创建更精细的、只包含必要部分的头文件,我们显著减少了编译负担。

3. 分离接口与实现(Pimpl惯用法)

这是减少编译依赖最强大的技术之一,其核心思想是通过一个额外的间接层,将类的实现细节完全隐藏起来。

场景:假设有一个Translator类,其内部实现依赖一个庞大的TcpClient头文件。这个Translator被500个其他源文件包含。

问题:每个包含Translator的文件都会间接包含庞大的TcpClient,导致编译缓慢。

解决方案

  1. 创建一个抽象的ITranslator接口,只声明纯虚函数。
  2. 将原来的Translator改为实现类TranslatorImpl,它继承ITranslator并包含TcpClient
  3. 在其他代码中,只使用ITranslator的指针或引用。

效果TcpClient这个重型头文件现在只被TranslatorImpl.cpp这一个翻译单元包含。在我们的案例中,这项改动直接移除了25分钟的聚合编译时间。

4. 对象库

CMake的对象库(OBJECT library)允许你将一组源文件编译成一个对象文件集合,而不是分别编译成多个独立的目标文件再链接。

应用场景:我们拥有40个独立的测试可执行文件,每个都链接了相同的测试框架(如Google Test)。每个可执行文件在链接时都重复处理这些框架代码。

优化:使用CMake将多个测试可执行文件合并成一个。在CI流水线上,我们构建并运行这个单一的可执行文件来执行所有测试。这节省了2分钟的链接时间。

灵活性:在本地开发时,我们仍然可以配置为编译独立的测试可执行文件,以便快速迭代。

5. 预编译头文件

预编译头文件(PCH)的原理是,将那些稳定且被广泛包含的头文件(如标准库<vector>, <string>)预先解析并转换成一种编译器能快速加载的格式。

优势:对于包含<vector>的1200个翻译单元,编译器无需重复解析1200次,只需在预编译头文件中解析一次,然后在每个翻译单元中快速加载。

在CMake中使用(3.16+):

target_precompile_headers(my_target PUBLIC
    <vector>
    <string>
    <map>
)

注意事项

  • 少即是多:不要将过多或经常变动的头文件加入PCH,否则会降低收益,并导致任何改动都触发大规模重编译。
  • 可能掩盖错误:如果PCH强制注入了某个头文件(如<vector>),而你的源文件忘记#include <vector>却使用了std::vector,代码仍能编译。这可能导致在禁用PCH时构建失败。建议定期在不开启PCH的情况下进行构建,以确保代码健壮性。

效果:通过合理配置PCH,我们获得了约20% 的编译时间减少。


进阶技术:Unity Build

Unity Build(又称单一编译单元)是将多个源文件通过#include合并到少数几个翻译单元中进行编译的激进方法。

原理:将传统的“一个.cpp文件一个翻译单元”模式,改为“多个.cpp文件合并成一个翻译单元”。这样,这些文件共用的头文件(如标准库)只需要被解析和处理一次,彻底消除了冗余工作。

在CMake中启用

set(CMAKE_UNITY_BUILD ON)
set(CMAKE_UNITY_BUILD_BATCH_SIZE 8) # 默认将每8个文件合并成一个单元

Unity Build 的挑战与解决方案

将现有项目转换为Unity Build可能会遇到一些问题:

  1. 匿名命名空间和静态变量冲突:原来在不同翻译单元中独立的匿名命名空间和静态变量,现在被合并到同一个翻译单元,会导致名称冲突。

    • 解决方案:CMake提供了UNITY_BUILD_UNIQUE_ID目标属性,它会为每个被包含的文件自动生成一个唯一的命名空间包装器。
  2. 模板特化顺序问题:如果文件A中实例化了一个模板,文件B中试图特化同一个模板,而Unity Build以A、B的顺序包含它们,就会违反“特化必须在首次实例化之前”的规则。

    • 解决方案:使用CMakeUNITY_BUILD相关属性,将冲突的文件排除在Unity Build之外,或分到不同的编译组。
  3. 宏污染:不同第三方库可能定义了同名的宏,当它们的头文件被合并到同一个翻译单元时,会产生难以预料的替换和编译错误。

    • 解决方案:将已知不兼容的第三方库对应的源文件分配到不同的Unity Build组中,或者将它们排除在Unity Build之外。
  4. 增量构建可能变慢:合并后,修改任何一个源文件都会导致其所在的整个Unity编译单元需要重新编译。

    • 解决方案:根据项目情况调整CMAKE_UNITY_BUILD_BATCH_SIZE。在CI上使用较大的批次或完全合并以获得最大速度;在本地开发时,可以使用较小的批次或禁用Unity Build以获得更快的增量编译。

Unity Build 的效果

在我们项目中,应用Unity Build带来了最显著的提升:

  • 从1200个独立翻译单元(编译15分钟)到约240个合并单元(9分钟),最终到完全合并(6分钟)。
  • 静态分析(clang-tidy)时间从2.5小时大幅减少到4分钟,这使得日常使用静态分析成为可能。

总结与要点回顾

本节课中,我们一起学习了如何通过系统性的方法减少C++项目的编译时间。以下是核心要点总结:

  1. 分析先行:使用-ftime-traceClang Build Analyzer等工具定位编译热点,避免盲目优化。
  2. 工具链升级:采用NinjaLLD/Mold链接器和ccache编译器缓存,这些是成本低、收益高的第一步。
  3. 代码设计是关键
    • 前向声明:在头文件中尽可能使用,减少不必要的依赖。
    • 移除无用包含:保持头文件简洁。
    • Pimpl惯用法:分离接口与实现,这是减少编译耦合的利器。结合前向声明,这项优化可能贡献60% 以上的时间减少。
  4. 构建系统技巧
    • 对象库:合并相似目标,减少链接重复工作(约10% 收益)。
    • 预编译头文件:对稳定的通用头文件效果显著(约20% 收益)。
  5. 终极加速Unity Build能最大程度消除冗余编译,带来50% 或更多的速度提升,但需要对现有代码进行适配并注意其副作用。

记住,良好的软件设计(如低耦合、接口分离)不仅使代码更易维护,同时也是提升编译性能的最有效途径。从今天介绍的技术中选取适合你项目的部分开始实践,逐步构建一个高效的开发环境。

022:一份(不)完整的指南

概述

在本教程中,我们将学习C++中对象生命周期的核心概念。我们将从最基础的定义开始,逐步深入到存储、类型、值、初始化、销毁以及一些高级话题,如临时对象、placement new、隐式对象创建、指针来源(provenance)和类型双关(type punning)。理解这些概念对于编写正确、高效的C++代码至关重要。

什么是对象和生命周期?

在C++标准中,“对象”是一个基础术语,与面向对象编程无关。程序中的所有操作本质上都是在创建、销毁、引用、访问和操作对象。

一个对象包含三个核心属性:

  1. 存储:对象在内存中占据的一块连续字节区域。
  2. 类型:规定了如何解释存储中的比特序列,从而得到一个有意义的。类型本质上是一个从数据(比特序列)到值的映射函数。
  3. :对象在某个时间点所代表的数学意义上的值。值本身是恒定的(例如数字42),但对象存储的值可以改变(除非对象是常量)。

生命周期是对象的一个时间属性。只有在对象的生命周期内,我们才能安全地访问和操作它。在生命周期开始前或结束后访问对象,会受到严格限制,通常会导致未定义行为。

对象生命周期的宏观流程如下:

  1. 为对象分配存储
  2. 初始化对象,这开始其生命周期。
  3. 在生命周期内使用对象(读取、修改其值)。
  4. 销毁对象,这结束其生命周期。
  5. (可选)在之后的某个时刻释放对象的存储。

需要注意的是,“创建”对象在标准中仅意味着我们有了一个可以讨论的抽象实体,而“销毁”则是一个实际结束生命周期的操作。生命周期是C++标准为了描述程序语义而发明的概念,与实际物理机器的执行没有直接关系。

第0级:变量声明

对象可以通过定义来创建。例如,一个int变量的定义会创建对象、分配存储并开始其生命周期。

要理解存储分配和生命周期开始的精确时机,我们需要查看对象的存储期。存储期定义了包含对象的存储的最小潜在生存时间,由创建对象的构造方式决定。

以下是三种常见的存储期:

  • 自动存储期:适用于在块(或函数)作用域内定义且没有特殊关键字(如static)的变量。

    • 存储分配:当程序执行流遇到变量定义时分配存储。
    • 生命周期开始:对于自动存储期,分配存储的同时也完成了初始化,生命周期随即开始。
    • 生命周期结束/存储释放:当变量所在的块退出时,对象被销毁,存储被释放。
  • 静态存储期:适用于命名空间作用域的变量(全局变量)或使用static关键字声明的变量。

    • 存储分配:在程序启动时分配存储。
    • 生命周期开始:初始化可能发生在不同时间(常量初始化、动态初始化),这很复杂。生命周期在初始化完成时才开始。
    • 生命周期结束/存储释放:在程序终止时释放存储。
  • 线程存储期:适用于使用thread_local关键字声明的变量。

    • 存储分配:当线程启动时为该线程分配存储。
    • 生命周期开始:初始化完成后开始。
    • 生命周期结束/存储释放:在线程终止时释放存储。

对于自动存储期变量,初始化是可选的。如果不进行初始化,对象将拥有一个不确定的值。在赋予它一个确定值之前读取该对象会导致未定义行为(在C++26中将是“错误行为”)。但是,可以为其赋值。

第1级:new 和 delete

对象也可以通过new表达式创建,并通过delete表达式销毁。

new表达式会分配存储、创建对象并开始其生命周期。delete表达式会销毁对象、结束其生命周期并释放存储。与变量声明类似,也可以创建具有不确定值的堆对象。

临时对象

当需要一个对象,但手头只有一个纯右值(prvalue,例如字面量42或返回值的函数调用)时,编译器会创建一个临时对象。这个过程称为临时物化

需要创建临时对象的常见情况包括:

  • 将纯右值绑定到引用(非常量左值引用除外)。
  • 对纯右值进行成员访问(例如getString().size())。
  • 使用auto声明变量(因为auto需要推导类型)。
  • 丢弃一个返回纯右值的函数的结果(为了调用析构函数)。

临时对象的生命周期在其被创建的完整表达式结束时结束。但是有两个重要的例外,称为临时对象生命周期延长

  1. 直接绑定到引用:当一个引用直接绑定到一个临时对象时,该临时对象的生命周期被延长到与引用相同。
  2. 基于范围的for循环:在C++23及以后,在基于范围的for循环的“范围表达式”中创建的临时对象,其生命周期会持续整个循环。

使用临时对象生命周期延长需要非常小心,因为它只在引用直接绑定到临时对象时触发,而不是绑定到临时对象内部的子对象时。

Placement New

placement new是一种特殊的new表达式,它不分配内存,只显式地在给定的内存地址上调用构造函数来创建对象。这允许我们手动控制对象的创建。

要使用placement new,首先需要获得一块内存(例如通过mallocoperator new或字符数组)。然后,使用placement new在该内存上构造对象。要手动销毁对象,需要显式调用析构函数(例如obj.~T();std::destroy_at(&obj))。

placement new允许我们重用对象的内存。销毁旧对象后,可以在同一块内存上构造一个新对象。如果新旧对象满足透明可替换的条件,那么指向旧对象的指针、引用甚至变量名都会自动指向新对象,可以继续使用。

透明可替换的条件是:

  1. 新旧对象占据相同的存储位置。
  2. 新旧对象具有相同的类型(忽略顶层cv限定符)。
  3. 不属于以下例外情况:常量对象(非堆分配)、基类子对象、具有[[no_unique_address]]属性的成员。

如果替换不是透明的(例如,改变了类型,或替换了堆上的常量对象),则必须使用std::launder来“清洗”指针,以更新其来源信息,然后才能安全地解引用。

隐式对象创建

为了兼容C语言代码(如malloc后直接使用),C++20引入了隐式对象创建。对于隐式生命周期类型(其构造函数和析构函数是平凡/无操作的),某些操作(如mallocoperator newmemcpy)会隐式地创建并开始对象的生命周期,以防止未定义行为。

这意味着,像int* p = (int*)malloc(sizeof(int)); *p = 42;这样的代码在C++20后是定义良好的,因为malloc被指定为可以隐式创建int对象。

std::start_lifetime_as

有时我们需要显式地“开始”一个对象的生命周期,但不希望改变底层字节(即不进行初始化)。std::start_lifetime_as函数正是用于此目的。它接受一个指向内存的指针和一个类型,并返回一个指向该类型对象的指针,该对象的生命周期被视为已经开始,其值就是该内存字节的位表示。

这对于处理来自网络、文件映射等的外部数据非常有用,可以安全地将字节序列解释为特定类型的对象。

指针来源

一个指针包含两部分信息:地址来源。来源标识了指针最初指向的是哪个对象或分配区域。

关键点:即使两个指针的地址值相同,如果它们的来源不同,它们也不指向同一个东西,不能互换使用。指针算术不会改变指针的来源。

std::launder的作用就是接受一个指针,返回一个具有相同地址但更新为当前有效来源的新指针。这在非透明对象替换后需要继续使用旧指针时是必要的。

来源是C++标准为了优化(如别名分析)而引入的语义概念,并非硬件特性。

类型双关与严格别名

严格别名规则指出:程序试图通过一个与对象类型不相似(不兼容)的类型的左值来访问对象,是未定义行为。

重要澄清:你可以随意进行reinterpret_cast转换指针类型。未定义行为发生在你通过转换后的指针解引用并访问对象时,而不是在转换指针本身时。

因此,类型双关(用一种类型的视角访问另一种类型对象的内存)必须小心处理。以下是几种可能有效的方法:

  1. 通过std::start_lifetime_as:结束旧对象的生命周期,开始新类型对象的生命周期。之后不能再用旧类型的名称/指针访问。
  2. 通过memcpy:将字节从一个对象复制到另一个对象。这是定义良好的。
  3. 通过std::bit_cast(C++20):一种类型安全的memcpy
  4. 通过联合体:在C++中,更改联合体的活跃成员会销毁旧对象并创建新对象,因此通过旧成员访问是未定义行为。但有一个例外:如果两个标准布局结构体拥有共同的初始序列,则可以通过任一成员访问该共同部分。
  5. 通过指针可互转换:如果两个对象是指针可互转换的(例如,标准布局对象与其第一个非静态数据成员),则可以通过reinterpret_cast在指向它们的指针之间转换并访问。

无效指针和“僵尸”指针

在对象的生命周期结束后、其存储被释放前,指向该对象的指针被称为“僵尸指针”。对僵尸指针的使用受到限制:

  • 允许:复制指针、比较指针、转换为void*、用于delete表达式(如果指针指向堆对象且未被释放)。
  • 不允许(未定义行为):解引用指针读取或修改值、调用非静态成员函数、进行涉及虚基类或dynamic_cast的操作。

当对象的存储被释放后,所有指向该存储内部的指针都变成无效指针值。解引用无效指针是未定义行为,其他操作(如比较)是实现定义的。这允许实现进行一些安全措施(如将delete后的指针设为nullptr)或优化。

在多线程无锁数据结构中,无效指针的语义可能导致微妙的问题,因为标准当前的规定可能使一些常见的模式(如使用比较交换的栈)出现实现定义甚至问题行为。这是标准中一个活跃的研究领域。

总结与实用指南

本节课我们一起深入探讨了C++对象生命周期的复杂世界。我们从对象的基本定义出发,逐步学习了存储期、临时对象、手动内存管理、隐式创建、指针来源和类型双关等高级主题。

作为开发者,以下是一些实用建议:

  • 不要依赖隐式对象创建:语义复杂且不直观。使用placement new来显式创建对象。
  • 关心原有数据时使用std::start_lifetime_as:当你需要将一片内存解释为某个类型的对象而不改变其字节时。
  • 优先使用placement newstart_lifetime_as返回的指针:它们保证具有正确的来源。
  • 必要时使用std::launder:在非透明替换后需要继续使用旧指针时。
  • 避免字符缓冲区,使用联合体:在实现类似std::optional的类时,使用联合体比字符数组更简单,能避免一些标准漏洞。
  • 理解规则但优先使用清晰、明确的代码:C++的这些规则很复杂,但编写清晰、意图明确的代码通常比依赖隐晦的语言机制更安全、更易维护。

023:使用MISRA C++ 2023编写更安全的C++代码

概述

在本教程中,我们将学习如何利用MISRA C++ 2023指南来编写更安全的C++代码。MISRA指南最初源于汽车行业,旨在通过一系列规则和最佳实践,减少软件中的未定义行为、实现定义行为和未指定行为,从而提升关键安全系统(如汽车、医疗设备)的软件质量。我们将探讨其核心理念、常见误区,并通过具体示例理解如何在实际编码中应用这些原则。


课程内容

1. 动机与背景

上一节我们概述了本课程的目标。本节中,我们来看看MISRA指南诞生的背景和核心动机。

MISRA(汽车工业软件可靠性协会)指南的制定,源于对安全关键系统中软件质量日益增长的需求。当软件故障可能导致人身伤害、重大财产损失或环境危害时,就需要更严格的编码标准。

MISRA C++ 2023版主要针对C++17标准,其核心目标是:

  • 消除未定义行为:这是C++中最危险的一类问题,程序行为完全无法预测。
  • 限制实现定义和未指定行为:使代码在不同平台和编译器上的行为更可预测。
  • 充当安全护栏:通过静态分析等工具,在编码阶段及早发现潜在问题。

与早期版本相比,MISRA C++ 2023试图在禁止危险操作和允许现代、良好的C++实践之间取得平衡。


2. 安全编程的核心挑战

理解了MISRA的动机后,我们需要明确C++编程中面临哪些具体的安全挑战。

C++继承了C语言的强大与灵活,但也继承了其复杂性,这带来了几类主要问题:

  1. 未定义行为:标准未规定行为,结果完全不可预测。这是安全性的最大敌人。
    • 示例:有符号整数溢出、解引用空指针、数组越界访问。
  2. 实现定义行为:标准允许编译器或硬件平台自行定义的行为。
    • 核心示例int类型的大小和范围。int的溢出也是未定义行为。
    • 公式:对于有符号整数类型TT_MAX + 1未定义行为
  3. 未指定行为:标准规定了必须达到的效果,但未规定具体实现细节。
  4. 误解与固有观念:程序员对语言特性的误解可能导致错误。
  5. 代码演化问题:代码编写时的假设在未来(如移植到新平台时)可能不再成立。

3. 基础防御措施

面对这些挑战,在深入具体规则前,我们首先应建立一些基础的、有效的防御措施。

以下是每个C++项目都应采用的基础安全实践:

  • 开启并严苛对待编译器警告:这是第一道也是最重要的防线。
    • 代码示例:在编译命令中使用 -Wall -Wextra -Wpedantic -Werror(GCC/Clang)或对应MSVC选项。
  • 采用测试驱动开发:良好的单元测试能提供即时反馈,改善设计,并支持重构。
  • 使用动态分析工具:如AddressSanitizer、UndefinedBehaviorSanitizer等,在运行时检测问题。
  • 利用C++类型系统和标准库:这是与C语言相比的巨大优势。
  • 利用确定性的对象生命周期:这是C++的“杀手级特性”。对象的析构在作用域结束时确定发生,结合RAII(资源获取即初始化)模式,可以安全地管理资源。
    • 核心概念:右花括号 } 标志着对象生命周期的结束和资源的自动释放。

4. 关于MISRA指南的常见误解

在应用指南之前,澄清一些常见的误解至关重要,这有助于我们更正确地使用它。

以下是关于MISRA指南的几个常见误解及其澄清:

  • 误解:项目完成后才处理MISRA违规。
    • 澄清:错误做法。应从项目第一天起就启用静态分析工具,以获得即时反馈并养成良好习惯。
  • 误解:100% MISRA合规等于100%安全。
    • 澄清:错误。合规只能避免语言层面的特定风险,无法保证软件逻辑正确(例如,刹车时代码却加速)。
  • 误解:MISRA禁止编写“好”的代码。
    • 澄清:错误。MISRA指南旨在被违反——但必须是有理由、有记录的违反。这通过“偏差记录”来实现。
  • 误解:标准库违反MISRA,所以我们不能使用。
    • 澄清:错误。标准库实现本身可能需要违反某些规则才能工作。编译器供应商会对其实现进行“鉴定”或“确认”,使其可用于安全关键系统。自己实现类似功能的库反而风险更高。
  • 误解:MISRA规则冗余且令人困惑。
    • 澄清:冗余是有意设计,提供多层防御。有些规则(可判定)工具能精确检查,有些(不可判定或指令)则需要人工确保。

5. 规则体系与偏差处理

既然指南允许偏差,那么了解其规则体系和如何处理偏差就非常重要。

MISRA C++ 2023的规则体系结构如下:

  • 规则:通常可由工具检查的条款。
  • 指令:通常无法由工具自动检查,需要人工确保的条款。
  • 强制程度
    • 必须:强制性要求。
    • :强烈推荐,除非有充分理由。
    • :建议性指导。
  • 可判定性
    • 可判定:工具可理论上完全判断。
    • 不可判定:工具只能尽力而为,无法保证100%覆盖。

偏差处理:当需要违反一条“必须”或“应”的规则时,必须创建偏差记录。记录需包含:

  1. 对规则的引用。
  2. 违反该规则的理由(技术性理由,而非个人偏好)。
  3. 证明风险已得到缓解的论据。
  4. 偏差的范围(应尽可能小,最好封装起来,避免重复偏差)。

6. 整数运算安全

现在,让我们看一个具体且危险的安全领域:整数运算。这是未定义行为的重灾区。

C++内置的整数类型,特别是int,存在诸多问题:

  • int的大小是实现定义的。
  • 有符号整数(包括int)溢出是未定义行为

未定义行为概率示例

  • x / 0:100%(除零)。
  • x / y:接近0%,但需注意 INT_MIN / -1 也是未定义行为(结果无法表示)。
  • x + y(有符号):约25%的随机值组合会导致溢出(未定义行为)。
  • x * y(有符号,32位):对于随机值,溢出概率高达 99.9999999%(约7个9)。这意味着乘法几乎必然溢出!

解决方案:使用安全的整数类型库。

  • 目标:消除隐式提升和未定义溢出。
  • 示例方法:使用枚举类包装整数,并重载运算符。
    // 概念性示例:一个简单的安全整数包装器
    enum class safe_int : int {}; // 基础类型
    // 需重载 +, -, *, / 等运算符,在运算中进行边界检查
    

MISRA相关规则:

  • 规则10.3:不应使用标准整数类型,而应使用明确指定宽度的类型(如int32_t)。
  • 规则10.5:不应使用隐式改变符号的表达式。

7. 类型系统与强类型

整数安全问题的根源之一在于C++类型系统(继承自C)过于“弱”。本节我们探讨如何通过“强类型”来提升安全性。

弱类型系统:允许大量隐式转换,容易掩盖错误。
强类型系统:限制隐式转换,使类型更贴合语义。

强类型的意义42 在程序中代表什么?是年龄、重量、还是页码?使用int无法区分。强类型通过创建具有语义意义的类型来防止误用。

如何创建强类型

struct Age {
    int years;
    explicit Age(int y) : years(y) {}
    // 可添加验证逻辑
};
struct Weight {
    int grams;
    explicit Weight(int g) : grams(g) {}
};
// 现在,Age和Weight不能隐式转换,避免了 `Age a = Weight{70000};` 这样的错误。

MISRA指南鼓励

  • 使用明确的类型,避免隐式转换。
  • 任何你觉得自己需要强制转换的地方,都可能是设计需要改进的信号。

8. 类设计安全

对于C++的核心特性——类,MISRA也提供了一系列安全指导原则。

以下是关于类设计的一些关键规则和理念:

  • 位域:应避免使用C风格的位域,因其行为在很大程度上是实现定义的。如果需要位级操作,应考虑使用专门的库(如作者提供的bitfield库)或编译器扩展(并明确记录偏差)。
  • 联合体:应避免使用C风格的union进行类型双关(例如,用一个int成员写,再用一个struct成员读),这是未定义行为。应使用std::variant(C++17)进行类型安全的联合。
  • 成员访问控制:倾向于使所有成员为public(聚合类)或所有成员为private(封装类)。protected继承被认为风险较高,应谨慎使用。
  • 引用限定成员函数:对于返回对象内部引用或指针的成员函数(如.front().begin()),应使用左值引用限定符 (&),防止在临时对象上调用它们而导致悬空引用。
    class MyContainer {
        int* data_;
    public:
        int& front() & { return data_[0]; } // 只能在左值对象上调用
        // int& front() { return data_[0]; } // 危险:可在临时对象上调用
    };
    

9. 总结与核心建议

本节课中,我们一起学习了MISRA C++ 2023指南的背景、核心挑战、具体规则示例以及如何利用它编写更安全的代码。

作为总结,以下是一些核心建议,无论你是否需要严格遵循MISRA,都能极大提升代码安全性和质量:

  1. 立即启用严格编译警告-Wall -Wextra -Werror 是你的朋友。
  2. 重视测试:建立良好的单元测试文化。
  3. 优先使用值语义:传递和返回对象的值,避免不必要的引用/指针。
  4. 减少“关系类型”的使用:谨慎使用引用、指针、迭代器、视图等,它们容易悬空。尽量缩小其生命周期和传播范围。
  5. 应用强类型:为领域概念创建具体类型,而非滥用内置类型。
  6. 使用管理器类型:利用RAII自动管理资源(内存、文件、锁等)。
  7. 避免原始循环:优先使用算法和范围for循环。
  8. 封装“脏”代码:如果必须使用不安全操作(如特定转换),将其封装在函数或类中,并集中记录偏差。
  9. 持续学习:编程是一项需要持续精进的技能。

MISRA C++ 2023是一套详细的实践指南,虽然主要针对安全关键领域,但其蕴含的谨慎、明确和防御性编程思想,对所有C++开发者都有宝贵的借鉴意义。记住,目标不是盲目合规,而是编写出更健壮、更易维护的代码。

024:是什么在消耗我的内存——C++中的内存利用率分析

概述

在本节课中,我们将跟随演讲者张飞(音译)的亲身经历,学习如何分析和解决C++长运行服务中内存使用率持续升高的问题。我们将从收到内存告警开始,逐步深入到内存分配的基础原理、常见问题(如内存泄漏和内存碎片)的诊断方法,以及相应的解决策略。课程内容旨在为初学者提供一个清晰、实用的内存问题排查框架。


章节 1:问题起源——内存告警

我的故事始于一个告警。告警提示,某台机器的内存使用率达到了90%,需要立即处理。

我首先需要理解这个告警为何如此重要。在Bloomberg,我们有不同级别的告警,而这是最高级别,意味着必须停止服务并进行修复。

我询问了如果不采取行动的后果。答案很明确:首先,系统会进行更多的交换(Swap),操作系统会将不活跃的数据从内存移动到磁盘以腾出空间,这会导致性能下降。其次,操作系统不会允许单个服务占用所有内存,在Linux中,存在一个内存杀手(OOM Killer),它会终止你的服务,导致服务中断。最后,由于我们处于多租户环境,机器上的资源由所有进程共享,因此性能影响不仅限于你的服务,还可能波及到其他更关键、对性能更敏感的服务。这非常糟糕,是我们不希望发生的。

这就是为什么处理这个告警至关重要。


章节 2:定位问题进程

我需要找出是哪个进程导致了这个问题,因为告警是在机器级别(90%内存使用率)。我登录到那台机器并使用 top 命令,它给了我一个内存使用大户的候选列表。

从无告警到有告警,肯定发生了某些变化。我检查了过去几天这些进程的内存使用情况仪表盘,发现了问题。

你可以看到内存使用量从周一的1GB增长到了周五的7GB。这对于一个C++应用程序来说太多了,而且不正常。考虑到我的请求流量是稳定的,在周末并没有异常的请求推高内存使用量,所以问题出在我的代码上,它来自于最近的一次发布。

我的下一个问题是:我的代码是如何导致这个问题的?为什么会发生这种情况?


章节 3:回顾基础——代码如何影响内存使用

我回到基础知识,试图找出原因。本质上,我们需要理解在运行代码和内存使用率达到90%之间发生了什么。这是一个漫长的过程,但许多任务是由两个部分完成的:操作系统和内存分配库(malloc)。

首先,我们不需要直接处理硬件。操作系统会将所有物理内存、磁盘或其他设备映射到一个虚拟地址空间。从进程的角度看,我们使用的是虚拟内存,无需关心数据究竟在磁盘上还是在内存中,操作系统足够智能来移动数据。

其次是 mallocmalloc 是连接C++代码和虚拟内存的桥梁。这里我以Glibc的 malloc 实现为例。它看起来像这样:malloc 会从内核获取一些区域用于数据和文本段,以及一些区域用于堆和栈。

基本上,我的进程的内存使用量应该是这些区域的总和。我知道内核数据段和文本段很小且是静态的,不可能增长到几GB。栈是有限制的,这就是为什么会有栈溢出。唯一可能大量增长的就是堆。我想弄清楚堆为什么在增长。

让我们深入 malloc 看看它是如何工作的。

首先,堆只是一个连续的内存区域,被细分为块(chunk)。块是分配或释放的基本单位。当你调用 malloc 时,你分配一个块;当你 free 时,你释放一个块。

然后是多线程。在多线程环境中,我们需要允许多个活跃的内存区域。为此,我们有了竞技场(arena)。这个竞技场并不是实际的内存区域,而是一个结构体。这个结构体有一些指针指向它将要使用的堆。

例如,我们有一个主线程,主线程会使用这个主竞技场(main arena)。主竞技场告诉线程:“这是你要使用的堆,这是主堆。” 对于其他线程,会有其他竞技场。一个竞技场可以指向一个或多个堆。在这种情况下,这些堆与主堆不同,它们位于虚拟地址空间的其他地方,malloc 会使用系统调用 mmap 来为这些堆分配区域。

我们还有另一个系统调用 brk,这是用于扩展主堆的系统调用。当这两个系统调用被调用时,内存使用量就会上升,因为我们在向操作系统请求更多内存。

基本上,线程访问竞技场,竞技场指向堆,而堆被细分为块。

那么块是什么样子的?这是一个块。一个块可以被分配,成为一个使用中(in-use)的块。在使用中的块里,最重要的部分是有效载荷(payload),它正是 malloc 函数返回的地址。除此之外,还有一些元数据,如大小、前一个块的大小、所属竞技场、是否为 mmap 分配、前一个块是否在使用中等。所有这些元数据都是为了管理这些块。

块也可以被释放。当你释放一个块时,它并不会消失或被销毁,它仍然在那里。唯一的区别是,我们在有效载荷中有了更多的元数据,包括前向(forward)和后向(back)指针,使它看起来像一个链表。一个空闲块还可以与相邻的空闲块合并,形成更大的空闲块。

那么,当我们释放内存时发生了什么?free 只是标记该块为空闲以供重用,并没有销毁任何东西。从操作系统的角度来看,这块内存仍然属于 malloc 和该进程。这就是为什么在大多数C++应用程序的内存使用仪表盘上,你看到的内存使用曲线是上升的,保持平坦,有时会下降,但并不是每次调用 free 都会下降。

所有空闲块都在箱(bin)中管理。箱是另一种结构,它不是额外的内存,我们并没有移动任何东西,它只是一个带有索引和几个链表的结构体。这些空闲块根据它们的大小在不同的箱中管理。你可以看到,前向和后向指针被用于箱中的链表。


章节 4:内存分配与释放的全景图

现在我们把所有部分放在一起,看看当我们进行 mallocfree 时发生了什么。

例如,我有一个堆,有两个使用中的块(黄色)和两个空闲块(蓝色),这两个空闲块在箱中,有一个指针指向它们。

如果我发送一个分配请求,并且合适的箱中有那个块,我就直接使用它,将那个空闲块变为使用中的块,它就从箱中移除了。现在箱里只剩下一个空闲块。

如果我要释放那个块,我们只是把它放回去,将其变回空闲块。如果我要释放这个块,并且相邻的块也是空闲的,我们可以合并它们,变成一个更大的块。

如果我在我的箱里找不到任何合适的块,我需要一个更大的块,例如,在这种情况下,我会先调用 brk 来扩展我的堆,然后在顶部创建一个新的块。当那个块被释放时,它会回到箱中。

有一个例外情况:当你请求的内存太大时,malloc 会很聪明地忽略所有箱,它会直接使用 mmap 在虚拟地址空间的某个地方为你分配一块内存。当你释放那块内存时,我们会使用 munmap 直接将其返还给操作系统。这就是为什么有时你仍然会看到内存使用量下降,背后可能发生了 munmap 操作。

这是一个非常简化的版本,还有很多其他内容,比如线程缓存(tcache),在访问箱之前我们会先查看线程缓存,也许在那里已经找到了一个空闲块。最顶部的块管理方式略有不同,以便为下一次分配做准备。有时,你还可以看到堆会根据某些条件进行收缩。所有这些机制都是为了高效地重用空闲块。

回到我们的地图,可以说 malloc 很聪明,做了很多努力来重用空闲块,操作系统也很聪明,能在内存和磁盘之间移动数据以腾出空间。那么,问题可能出在哪里呢?


章节 5:第一个怀疑对象——内存泄漏

我开始检查。首先,我猜测:我是否有内存泄漏?内存泄漏的定义是:不再需要的内存没有被释放。这看起来完全符合我的情况:内存使用量随着流量持续上升。

以下是几个内存泄漏的例子:

  1. new 了但忘了它:这就是为什么我们更喜欢现代C++并强调RAII(资源获取即初始化),因为有了RAII,就没有对象泄漏,也就没有资源泄漏,因为每个对象背后都是一种资源,比如线程或内存。
  2. 即使使用STL容器:例如,如果你的容器中保留了不再需要的条目。是的,最终当容器销毁时它们会被销毁,但这仍然是一种泄漏,因为你不再需要它们了,却还保留着。
  3. 缺少虚析构函数:这是我们在代码中可能犯的错误。当对象被销毁时,基类中的资源将会泄漏。
  4. 循环引用:想象在一个到处使用 shared_ptr 的世界里,它就变成了一个垃圾收集器。而这个基于引用计数的垃圾收集器的基本问题就是循环引用。没有人放手,你就得到了泄漏。

这些是我想到的四个例子。如果你有其他例子,请告诉我。

现在的问题是:我是否有这些问题?如何发现它们?你可以检查代码中是否有这些情况,但这工作量太大了。所以我们需要工具。我尝试了一些。


章节 6:诊断工具(全)——代码级与库级检测

我首先尝试了测试分配器(test allocator)。

对于那些不熟悉分配器的人:分配器为给定容器处理所有内存分配和释放的请求。在标准讨论的早期,人们就认为分配器是个好东西,因为我们应该让容器独立于内存模型。但受限于语言,我们无法拥有它,这就是为什么所有的STL容器都被重写以接受这些分配器。后来在C++98中,我们有了无状态分配器,这意味着第一次分配和第一千次分配不应该有区别,它是无状态的。再后来,在C++03中,人们移除了这个假设,说分配器实际上可以有状态。这允许了很多自定义分配器的用法。在C++17中甚至更好,我们有了PMR(多态内存资源),它提供了运行时的灵活性。

回到测试分配器,如何使用它?它是什么?如果你查看 vector 的模板,第一个参数是类型,第二个参数是分配器,默认是 std::allocator。你可以在实例化时注入任何你想要的分配器。这个测试分配器就像一个包装器,覆盖了真正的分配器。在我们进行真正的分配工作之前,它会用一个“魔法数字”做一个记录。当我们释放它时,我们尝试匹配那个记录。如果不匹配,就意味着有泄漏或其他内存问题。

可以说,测试分配器的好处是:它速度快,开销小,你可以将它注入到代码的特定部分,因此它也可以是作用域内的。

缺点是:我需要更改我的代码,需要编译和链接。而且我并不知道我的服务中哪部分代码在泄漏,所以这相当困难。

我还能尝试其他方法吗?我找到了地址消毒器(AddressSanitizer)。

它是谷歌提供的一个内存错误检测器。基本上,它是一个编译器插桩模块加上一个运行时库,它会替换掉 malloc。这次我们不需要更改代码,只需要用这个选项编译链接即可。地址消毒器会替换掉 malloc 的实现并做一些事情,我猜它做的事情类似,尝试匹配分配和释放。如果不匹配,就说明有内存泄漏之类的问题。

好处是:这次我不需要更改我的代码,它很快(比什么都不用慢50%到1倍,但相比其他工具仍然很快)。

缺点是:我仍然需要编译和链接,并且有额外的内存成本。我想要一些可以立即使用的东西,不想编译和链接。

于是我找到了Valgrind。Valgrind是一个工具集,包括内存检查器(Memcheck,一个内存错误检测器)、Massif(一个堆分析器)以及其他工具如DHAT等。

这次使用Valgrind,我们不需要更改代码或编译链接,我们只需要在Valgrind命令下运行那个程序。Valgrind会将应用程序运行在沙盒中,在那个沙盒里,Valgrind有权力进行调试和分析工作。

好处是:我的可执行文件没有任何改变,可以直接运行。

缺点是:它很慢,比什么都不用慢10到30倍,并且会占用大量额外内存。


章节 7:诊断工具(二)——堆剖析与问题分类

回到我们的地图,你可以看到这些工具试图在不同的地方捕捉问题。测试分配器是在代码级别,地址消毒器更多是在 malloc 库级别,而Valgrind则说:让我们把所有东西放在沙盒里,在那里捕捉问题。

就结果而言,它们可以分为两类。

第一类是针对前三种工具,如测试分配器、地址消毒器和Memcheck。它们最终能提供的是分析结束时的一个快照。它看起来像这样:你有一个泄漏摘要,包括哪些块在泄漏、大小是多少以及它们的调用栈。你可以根据这些调用栈来调查为什么它们没有被释放。

这是一个快照。我得到了它。但在现实世界中,有一个问题:我们的服务器有很多外部库和遗留代码。我们有静态泄漏。当我说的“静态”是指,在我的服务启动时就泄漏了1MB内存,在运行了成千上万个请求后仍然是1MB。这些问题已经存在,我对它们不感兴趣。但它们对我来说变成了噪音,因为在这个报告中,想象一下你有成千上万个调用栈和内存泄漏,你无法真正分辨出哪一个随着流量在增长,你不知道应该修复哪一个。

这就是Massif可以帮到我们的地方。Massif基本上是一个随时间变化的快照。通过可视化工具,你可以轻松地看出不同调用栈的内存使用量如何随时间变化。你可以轻松分辨出哪些是静态的,哪些是在增长的。对于那些增长的部分,我们很感兴趣。

关于使用这些工具的一些建议:

  1. 是的,静态泄漏可能会掩盖真正的问题,所以你需要足够的流量来触发它,并使它在你的结果中变得显著,以便找出它们。
  2. 它们都是好工具,但适用于不同的用例。如果你知道代码的哪部分可能泄漏,并且你有一个好的单元测试用例,你可以在本地修复它,不需要等待客户端环境。例如,我们希望更早地捕捉问题,一个好的做法是将地址消毒器集成到你的CI(持续集成)中。如果你有好的单元测试集,它会捕捉到大部分问题。
  3. 如果你做不到,你的测试覆盖率不好,仍然有一些客户端流量可能触发问题,最好在这些环境中提前设置好Valgrind。这样当问题出现时,你不需要等待,直接开始分析并找到问题。
  4. 最后一点,也是最重要的:作为开发者,我们应该关心我们分配的对象的生命周期和所有权。当你使用 shared_ptr 时,你只是让它飞走,我们不知道谁会释放它。所以除非必须,否则不要使用它。

后续工作就简单了:你找到一个调用栈,找出哪个对象在泄漏,追踪它,尝试找出为什么它没有被释放,然后修复它。

内存使用情况好多了,但我并不满意,因为它仍然在增长,仍然使用很多内存。那么我还能有什么问题呢?


章节 8:第二个怀疑对象——内存碎片

我猜测也许是内存碎片。碎片化是指你试图分配一个大块,但无法分配,即使你看起来有足够的空闲内存。

第一种情况就像你玩俄罗斯方块。如果那些方块被很好地放置在正确的位置,你仍然有空间给新来的方块。然而,如果你随机放置它们,你就在这些方块之间浪费了空间,你可能没有空间给新的方块了。

第二种情况就像停车。我们遇到过,一辆车占了四个车位。这是另一种浪费空间的方式。

第一种情况我们称之为外部碎片。例如,你有一块空闲空间,你做了一些分配,做了一些释放。现在我需要为4个单元分配空间。我有4个单元,我有足够的空间给4个单元,但我放不下。所以我需要扩展我的堆并创建一个新的块,因为我在这些块之间浪费了空间。

第二种情况就像这样:我只需要2个单元,但我分配了4个。所以我在内部浪费了空间。这是内部碎片。

下一个问题是:我是否有这个问题?如何判断我是否有这个问题?可以说,碎片化是一个程度问题,而不是一个是否问题。所以问题与内存泄漏不同,你可以说我有或没有。但对于碎片化,问题是:我们有多糟糕?我们无法避免它,我们有多糟糕?所以我需要一种方法来评估我的服务有多糟糕。

我找到了这个公式。

对于外部碎片,其思想是:如果你的大部分分配都可以用你的箱来完成,你就不需要向操作系统请求更多内存。那就很好,那就是没有碎片化。

这个公式是:1 - (最大可分配块的大小 / 总空闲空间)。想象一下,你有2GB的空闲空间,但你的最大可分配块只有1KB。那么任何大于1KB的请求,你都需要请求更多内存。这就是严重碎片化,该值接近1。但如果你只有一个空闲空间,该值为0,你就没有碎片化。

对于内部碎片,更直接。你分配了一些东西,你使用了一些东西。所以我们用 1 - (已访问字节数 / 总分配字节数) 来计算。你可以大致了解你使用了多少百分比。如果该值是0.1,意味着巨大的浪费。如果你分配了东西但从未使用它,该值为1。如果你分配了东西并使用了整个块的大小,那就是0,没有碎片化。

下一个问题是:如何获取这些值?对于外部碎片,我找到了 malloc_info。它是一个安全函数,我们可以在代码中调用它来从 malloc 获取一些信息。基本上,它会告诉我:总分配字节数是多少,空闲空间是多少,正在使用的空间是多少。并且有一种方法可以找出最大可分配块的大小。你可以在这个例子中看到,我使用了大约30MB,但我有200MB的空闲空间。这是一种浪费。最大的块只有60KB。所以非常、非常严重的碎片化,你可以说该值非常接近1。

当我看到这个数字时,我在想:我仍然不知道我们有多糟糕,我知道它很糟糕,但到底有多糟糕?所以我拿了另一个服务来比较。服务B的业务逻辑和流量非常相似。我得到的结果是:总的使用中分配量是相似的,每个都是30MB左右。但第二个服务的空闲空间只有2MB,而且最大块是前者的两倍。所以,这个数字仍然接近1,是0.95左右,而之前那个是0.997。但如果你放大到更高层次,你会发现:如果我的服务运行一天,它占用400MB。而第二个服务,我可以让它运行七天,最后它只占用200MB,并且在周末几乎持平,不再增长。所以我会说这是一种进行评估的方法,但我建议你建立自己的基准,因为如果你换到另一个上下文,那个数字真的没有任何意义。

对于内部碎片,我们将使用Valgrind的DHAT工具。我认为它很有用。它会提供一份关于所有分配使用情况的报告。例如,第一个例子:我分配了很多,但从未读取和写入。这是一个零访问块,我们在浪费空间。第二个例子:我有这个调用栈,分配了这个大小,但我只使用了一部分,写入了一部分,读取了一点点。这就是我们识别这些块的方式。你可以进一步调查:我为什么要这样做?为什么我分配了东西却从不使用它们?


章节 9:解决策略——减少内存碎片

问题是如何减少碎片?我们有了碎片,如何减少它?对于内部碎片,正如我所说,你可以检查那些调用栈,那是你的代码,你在做那些事,你应该理解为什么你在浪费空间。但对于外部碎片,它不那么明显。

人们做了很多努力来减少碎片化。Memshrink 是我从另一个会议上听到的想法。其思想是:我将位于物理内存和操作系统之间。我有两个内存页,它们都严重碎片化。你可以看到绿色的数据点和黄色的数据点。我发现,我可以合并它们。当我把第一页上的所有黄色数据移走时,就没有冲突了。所以我可以释放,我可以释放整个页面。从操作系统的角度来看,它仍然是两页,但合并后释放了一页,所以我们节省了空间,我们进行了压缩。

对于操作系统,人们在做伙伴系统(buddy system),基本上每次分配的大小都是2的幂。我看到了这个,但我认为我们把问题从外部碎片转移到了内部碎片,因为这种方式可以避免外部碎片,但你的内部碎片会浪费更多空间。

malloc 级别,malloc 允许你调整一些参数,你可以尝试调整竞技场的数量,你可以尝试调整线程缓存收缩堆的阈值。此外,jemallocmalloc 的另一个实现,它专注于避免碎片化。你可以看到很多想法来更有效地重用那些空闲块。

所以你可以看到我们做了很多努力。但如果我们回到碎片化的来源:如果你进行分配、分配、分配、释放、释放、释放,这与你进行分配、释放、分配、释放、分配、释放是不同的。

我认为根源来自于内存使用模式。所以我认为我们能做的最有效的努力是在代码层面,使内存使用的局部性更好。

局部分配器(local allocator)的想法来自John Lakos在2017年的另一个演讲。在他的演讲中,他专注于使用局部分配器来提高内存使用的局部性,以加速内存访问。但我认为这个想法也可以减少碎片化。以下是我的思考:

以我的例子为例。我有一个长运行的服务,并且是无状态的。所以我处理一个请求,我应该释放与该请求相关的一切,然后处理下一个请求。对于这样的系统,实际上我们可以尝试将它们放入子系统中。例如,我有一些用于日志的代码,一些用于解码请求的代码,一些用于TCP连接的代码,以及一些用于处理该请求的业务逻辑的其他代码。

这些子系统应该有不同的内存使用模式。对于日志和TCP连接,它们会分配一些东西并持有它们,用于缓冲区或转储等。而解码器和处理请求,这些子系统只会在处理请求时分配内存。所以在处理完请求后,它们不会留下任何东西。

你可以看到内存分配的生命周期是不同的。如果你使用全局分配器,意味着我们在同一个内存区域分配所有东西,结果就是:如果你处理第一个请求,不同的子系统会分配一些东西(用不同颜色表示),都在同一个内存范围内。在第一个请求被处理后,所有的短期分配都消失了。所以解码器和处理请求释放了所有内存,剩下的就是其他子系统持有的内存和碎片。

局部分配器的想法与对象池或内存池非常相似。你知道,我的短期子系统将使用一些内存,并且我知道它们会在请求处理完毕后释放它。所以我能做的是:我将它分成两个子系统,一个是短期的,一个是长期的,然后我创建两个局部分配器。这个局部分配器会预先分配一个内存范围,所有对它的分配只会使用那个区域。所以我们将它们分开了。

这次,如果我处理第一个请求,解码器和处理请求(短期系统)会在这里分配一些东西,而长期系统会在这里分配。当第一个请求结束时,分配器1中的所有东西都应该被释放。我们在分配器2中没有碎片。所以我认为,与全局分配器相比,我们可以通过这种方式减少碎片。

在John Lakos的原始演讲中,它是关于局部性的。你可以看到,通过局部分配器,我们可以提高内存访问的局部性,包括物理上的和临时性的。在两个维度上,我们都可以提高局部性。你可以看到蓝色代表快,红色代表慢。我们可以看到,随着局部性在这两个维度上的改善,我们的性能得到了提升。而且,我认为它也可以减少内存碎片。


章节 10:总结与资源

以上就是我故事的全部内容。再次强调,正如我所说,如果你对可能面临哪种内存问题没有头绪,我希望听完这个故事后你能有所了解。如果你是专家,我非常乐意听取任何建议和想法。

如果你有任何问题、想法、评论、建议,或者只是想聊聊,谢谢。

补充资源:大部分内容来自Glibc的 malloc Wiki页面和其他技术讲座,如果你感兴趣的话可以查阅。


本节课总结

在本节课中,我们一起学习了如何系统性地分析和解决C++服务中的内存问题。我们从理解内存告警的重要性开始,学习了如何定位问题进程,并回顾了操作系统和 malloc 在内存管理中的基础角色。我们深入探讨了两种常见的内存问题:内存泄漏内存碎片,并介绍了多种诊断工具,包括测试分配器、地址消毒器、Valgrind(Memcheck, Massif, DHAT)。最后,我们探讨了通过改进内存使用模式(如使用局部分配器)来减少碎片化的策略。希望这些知识能帮助你更好地理解和处理自己项目中的内存挑战。

025:聚焦可持续性,构建更好的计算机系统 🍃

在本节课中,我们将一起探讨如何将可持续性理念融入软件开发过程。我们将了解可持续性的定义、它在软件开发中的具体体现,以及开发者可以采取哪些实际措施来构建更环保、更公平、更经济的软件系统。

欢迎与介绍 👋

欢迎参加本次关于可持续发展的会议。这应该是我在ACCU大会上第三次就此主题进行分享。

首先,我想了解一下,在座有多少人参加过我之前关于此主题的会议?看起来只有少数几位。这意味着对于大多数朋友来说,我需要从基础定义开始讲解。不过,可持续性发展是一个快速发展的领域,我本人也在不断学习,因此本次分享的内容与以往会有所不同。

我的名字是Jutta Eckstein。我的背景是软件开发,但更侧重于“敏捷”实践领域。我与人合著过一本关于“公司级敏捷”的书。我们在书中探讨了一个观点:当一家公司宣称自己全面拥抱敏捷时,它同时也承担起了一份责任。这份责任意味着,公司需要将其所处的经济、生态、社会等整个生态系统也视为“客户”来对待。

这种思考方式引导我开始关注我职业背景下的可持续性问题。如今,我将其总结为:我们应该将地球视为我们开发任何产品时的一个利益相关者

建立联系:可持续性与软件开发 🔗

在深入之前,我想先听听大家的看法:对你而言,可持续性和软件开发之间有什么联系?

  • 能源消耗:尤其是当前人工智能系统消耗的能源量惊人,这个问题日益重要。
  • 硬件与电子垃圾:我们家中可能都有“电子垃圾箱”,里面是废弃的硬件。电池技术、稀有金属的开采和耗竭,以及电子垃圾的处理都是大问题。
  • 产品生命周期与电子垃圾:在消费电子领域,我们需要思考设备的生命周期,能否通过软件更新支持旧设备,否则用户会将其丢弃,增加电子垃圾。
  • 软件对消费的影响:软件在某种程度上影响了社会的消费狂热,引导人们做出购买决策。
  • 数字鸿沟与可访问性:某些国家或群体可能因经济原因无法购买高端产品,导致非故意的排斥,这关乎系统的包容性和可访问性。

大家提到了很多关键点。需要强调的是,软件开发也能助力可持续性,让世界变得更美好。但它也伴随着威胁,主要包括:能源排放、电子垃圾和数字鸿沟

核心定义:什么是可持续性? 📖

为了确保我们在同一层面讨论,首先需要明确“可持续性”的定义。目前存在多种定义:

  1. 布伦特兰报告定义(联合国):核心思想是“满足当代人需求的同时,不损害后代人满足其需求的能力”。这是一个非常宏观且有益的指导原则。

  2. 联合国17个可持续发展目标:这是一个更细化的框架,涵盖了消除贫困、优质教育、经济适用的清洁能源、水下生物保护等具体领域。许多公司被要求依据这些目标报告其可持续性进展。然而,对于软件开发从业者而言,有时很难直接将自己的工作与“零饥饿”等目标联系起来。

  3. 三支柱模型:这个模型将上述17个目标归纳为三个支柱,是我个人更倾向于使用的定义,它更贴近我们的专业领域。

    • 社会支柱:关注公平、健康、福祉。在软件开发中,这可以转化为对多样性、安全、安保、隐私和可访问性的重视。
    • 环境支柱:关注保护地球。这通常意味着减少碳足迹、绿色软件/IT、能源效率
    • 经济支柱:关注改善世界各地每个人的生活和前景。这涉及可持续经济和负责任的产品

这个模型也被称为“三重底线”或“人、地球、繁荣”模型。它为我们提供了一个更易于在软件项目中理解和应用的框架。

现状洞察:不可持续的反面案例 ⚠️

了解了定义后,我们来看看现实中IT领域一些未能践行这些原则的例子。

  • 社会支柱反例:机场的全身扫描仪软件要求用户在“男性”或“女性”之间做二元选择,这给跨性别者或非二元性别者带来了糟糕的体验。从软件角度看,这并非必需,但却造成了真实的社会排斥。
  • 环境支柱反例:IT行业的碳足迹已在去年超过了航空业。我们常被问及是否还在乘坐飞机,却很少被问及是否还在使用电脑或手机。安德烈的研究预测,到2030年,在最坏情况下IT能耗可能占全球总能耗的21%。
  • 经济支柱反例:社交媒体(如Facebook、Instagram)的算法和软件,有时非但没有改善人们的生活和前景,反而可能产生负面影响。这提醒我们,软件具有强大的影响力。

这些例子表明,我们本可以做得更好,这也是我们需要讨论这个主题的原因。

我们能做什么:从理念到实践 🛠️

上一节我们看到了问题,本节中我们来看看作为开发者,可以采取哪些具体行动。

1. 在需求讨论中引入可持续性评估

无论你称之为用户故事、需求还是需求,其核心通常包含:谁需要、需要什么、为什么需要。我们应该在此基础上,增加对可持续性影响的讨论。

就像我们会评估一个需求的成本一样,我们也可以评估它对碳足迹、可访问性或社会公平的影响。这能促成与客户或产品负责人的重要对话。

示例作为设计师,我希望减少页面使用的字体种类,以提升页面性能和对用户的可读性,其影响是减少因数据传输而产生的碳排放。

小知识:字体选择对性能有影响。有研究对比了谷歌字体、系统字体和自托管字体的碳排放差异。虽然单个网站影响微小,但全球有超过10亿个网站,累积效应不容忽视。

2. 审视与优化基础设施

我们应反思对基础设施“总想要最大、最好”的习惯。无论是开发、测试还是生产环境,都应问自己:我们真的需要这么多吗?

  • 按需索取:根据实际需要选择资源规模。
  • 共享基础设施:考虑在不同项目甚至公司间共享资源的可能性。
  • 成本与环保双赢:这样做不仅能减少能源消耗,也能节约成本,从而更容易获得公司的支持。

3. 运用人物角色与人物角色光谱

人物角色帮助我们定义目标用户,避免试图满足所有人却最终谁也满足不好的情况。

  • 思考对立面:定期思考,如果与目标用户截然相反的人尝试使用我们的系统,会发生什么?这有助于发现潜在的排斥问题。
  • 引入“人物角色光谱”:可访问性不仅关乎永久性残疾(如失明),也包括临时性(如耳部感染)和情境性(如在嘈杂酒吧)障碍。考虑到这些,能使系统对所有人都更友好、更包容。让系统更易于访问,受益的是所有人。

4. 支持旧硬件,减少电子垃圾

软件常常迫使用户升级硬件,从而产生大量电子垃圾。数据显示,平均每个英国公民一生会使用160部手机。

我们可以这样做

  • 可持续性功能开关:类似于权限开关,我们可以根据客户端硬件能力动态启用或禁用某些功能,以支持旧设备。
  • 响应式设计:根据客户端能力调整软件行为。

5. 利用能源数据优化运行

我们可以利用现有数据让软件运行更环保。

  • 电力地图:此类工具展示了不同地区、不同时间的能源结构(可再生能源 vs 化石能源比例)。
  • 智能调度:了解何时何地有低碳电力后,可以将可异步运行的、耗能高的任务(如重型算法计算)安排在这些时段运行。这类似于过去在夜间用电低谷时使用洗衣机的做法,但如今的目标是在可再生能源充足时运行
  • 云服务区域选择:谷歌、AWS、Azure等云提供商已开始提供工具,帮助用户根据碳足迹、价格和延迟来选择部署区域。

6. 聚焦价值,消除冗余

  • 构建更少:每一次构建都消耗能源。应聚焦于真正需要的功能,警惕功能蔓延。
  • 监控使用,勇于删除:建立机制监控功能的使用情况,并勇敢地删除那些无人使用的功能。这需要不同的监控视角和测试思路。

7. 重视代码质量与可维护性

技术债不仅拖慢开发速度,其持续的构建、测试和维护也消耗能源。保持代码整洁、可维护,不仅是为了未来的开发者(可能就是我们自己),也是为了系统整体的可持续性。

实践评估:可持续软件开发调查 📊

为了帮助团队了解现状并开启讨论,我与同事共同开发了一份调查问卷。它包含一系列关于社会、环境、经济三个支柱的陈述,供团队评估自身实践。

示例结果分析(基于一次现场调查):

  • 社会支柱得分最高(3.43),特别是在防止恶意访问用户数据、让用户选择共享哪些数据方面做得较好。
  • 可访问性方面得分较低(约2.0),说明这是需要改进的领域。讨论指出,如果开发团队本身更多元化,将有助于构建更具包容性的产品。
  • 环境支柱得分最低(2.32)。在“监控产品硬件利用率”方面尚可,但在“为使用旧设备的用户提供支持”、“告知用户后台任务启动”等方面有很大提升空间。有从业者分享,在消费电子领域,由于硬件快速迭代,公司常会停止对旧设备的软件支持,这迫使用户升级,是一个需要平衡的难题。
  • 经济支柱得分(2.9)。在“及时响应安全漏洞”方面较好,但在“了解功能是否被实际使用”方面较弱。这里也出现了矛盾点:监控功能使用情况可能与用户隐私产生冲突;支持旧硬件会增加代码维护负担,与保持软件轻量化的目标相悖。

这份调查的目的不是提供答案,而是帮助团队提出更好的问题,并找到启动改进的杠杆点。

总结与展望 🌟

本节课中,我们一起学习了可持续软件开发的核心理念与实践。

核心在于,我们需要重新记起那些早已熟知但可能已被遗忘的原则:在网络缓慢、内存有限的年代,我们曾精打细算地构建系统。如今,在资源看似无限的环境下,我们应停止一味追求极限,转而致力于让软件变得更小、更高效。

践行可持续软件开发不仅能保护环境、促进社会公平,也能带来市场优势(开拓新市场)、人才优势(吸引并留住重视可持续性的年轻人才)和成本优势

最后,我推荐一些资源供大家深入探索:

  • 敏捷可持续性宣言
  • 本次使用的调查问卷(包含44个陈述,基于知识共享许可发布)
  • 相关书籍与链接

希望本次分享能为大家开启可持续软件开发之旅提供一些启发和实用的起点。感谢大家的参与!

026:在嵌入式系统中使用C++异常减少固件代码大小 🚀

概述

在本教程中,我们将探讨如何在嵌入式系统中使用C++异常处理机制,并分析其如何影响最终的二进制文件大小。我们将从嵌入式系统的约束开始,逐步深入到异常处理的工作原理、内存开销分析,并与传统的错误返回码机制进行对比,最终理解为何在某些场景下,异常处理反而能生成更小的代码。


嵌入式系统的约束 🧩

在深入探讨异常处理之前,我们首先需要理解嵌入式系统开发所面临的独特约束。这与在操作系统或笔记本电脑上开发软件截然不同。

嵌入式设备通常存储和内存有限。我们的设备通常只有少量的只读存储器(ROM)和随机存取存储器(RAM),例如8KB到32KB的Flash和8KB到5KB的SRAM。

以下是两个常见微控制器的例子:

  • STM32F103:拥有约64KB的Flash(用于存储代码、字符串和常量)和约20KB的SRAM。
  • LPC4078:拥有512KB的Flash和约96KB的SRAM。

嵌入式系统没有操作系统级别的构造。你无法打开文件(设备上甚至没有“文件”的概念),也没有时间或网络服务。这些功能需要通过可用的硬件自行创建或寻找支持特定目标架构的库来实现。

由于内存如此之少,如何防止内存耗尽或内存碎片化?答案是:避免动态内存分配。这意味着在整个代码库中省略 newdeletemallocfreerealloc 等操作。

正因为这些操作不存在,我们也无法使用 std::stringstd::vectorstd::unique_ptrstd::unordered_mapstd::shared_ptr 等标准库容器。如果你确实需要类似功能,可以使用嵌入式模板库(ETL),它提供了许多常用容器,但要求你静态地指定其大小。

所有这些因素共同解释了为什么嵌入式开发者通常远离C++异常。以下是常见的反对理由:

  1. 需要动态内存分配:这是硬性要求,无法避免。
  2. 需要额外的ROM:会引入整个标准库。
  3. 需要运行时类型信息(RTTI):系统需要能够识别抛出类型与捕获类型。
  4. 异常表占用大量内存
  5. 异常处理算法占用大量内存
  6. 传播时间不确定且速度慢:即使你不知道具体时间,也知道它会很糟糕。

从错误返回码到异常的思考历程 🔄

上一节我们介绍了嵌入式系统为何排斥异常。本节中,我们通过一个具体的教学案例,来看看在实际开发中,错误处理机制是如何演变的。

在为学生的嵌入式系统课程开发SDK时,我设计了一组接口供学生构建。其中一个接口是 OutputPin(输出引脚)。微控制器上有许多引脚,你可以将这些引脚的电压从低电平设置为高电平。OutputPin 接口有一个 Set 函数,用于设置状态。

最初我考虑 Set 函数返回 void,但后来我开始思考其他使用场景。例如,在I²C通信协议中,控制器通过两根线(SCL时钟线和SDA数据线)与多个设备(通常是传感器)通信。通信过程中可能发生错误,例如地址无应答(设备不存在)或IO错误(硬件故障)。

OutputPin 抽象不仅可用于微控制器引脚,还可用于任何能代表输出引脚的事物,例如通过I²C控制的输出引脚(用于LED、按钮等)。这时我意识到,除非使用异常,否则无法传递这些I²C错误,或者我必须改变返回类型。

考虑到学生代码的未知用途(可能用于不严重的LED,也可能用于紧急切断继电器),我需要提供错误信息。因此,我决定改变设计。

我使用了 tl::expected(后来改用 std::expected),创建了 ResultStatus 类型。Status 基本上是将 T 设为 voidResult。当我需要返回值时,我添加了另一个名为 GetLevel 的API来读取。

从用户角度看代码,使用异常是最简单的例子。例如,让LED闪烁:打开LED,等待,关闭LED,再等待。

如果使用 std::expected,我们必须检查所有返回值:获取状态,检查是否有错误,如果有错误则返回 std::unexpected

如果我们有错误传播操作符(P2561提案),整个讨论可能就不存在了,因为语法会简单得多。但由于没有,我使用了错误传播宏。GCC支持语句表达式,允许宏返回值(MSVC不支持)。

现在,我需要考虑哪些API需要返回错误。问题是,几乎所有的API都需要。只有少数基于中断的API永远不会返回错误,其他所有API基本上都是可能失败的。例如,通过I²C控制的通用电机接口、通过I²C控制的显示器等。我无法逃避,几乎所有东西底层都可能用I²C实现,从而传播那两种错误。

因此,我将所有东西都迁移到了 std::expected。在2.5年的教学实践中,我从学生那里得到了反馈:

  • 起初使用 if-else 语句(没有给他们宏时),他们讨厌它,不使用它,而是用 void 强制转换来绕过错误检查。
  • 一旦我给了他们宏,他们更愿意使用,但觉得它嘈杂且复杂。
  • 学生们对基于API的错误处理感到恼火,因为它是“病毒式”的,几乎所有的返回类型都是 ResultStatus
  • 当他们查看复杂代码时,必须在大脑中删除所有宏才能理解正常的代码路径。

每个学期都有学生问我:“为什么不直接使用C++异常?它们是语言内置的。” 我会给出那些常见的反对理由,并展示文章链接。因为他们信任我,通常会接受。但随着时间的推移,我开始注意到一些问题:

  1. 我开始讨厌自己的代码,讨厌在复杂的代码中透过宏看逻辑。
  2. 我意识到我其实并不真正了解异常的内部机制,我只是引用了别人的文章。

这促使我开始研究如何在裸机ARM芯片上实际让异常工作,至少为了有一个演示来展示为什么不应该使用它。


在裸机ARM系统上启用异常 🛠️

上一节我们看到了教学中的困境。本节中,我们来看看为了让C++异常在嵌入式系统上运行,需要克服哪些实际障碍。

首先,对于几乎所有嵌入式系统设计和工具链,异常默认是禁用的。当你尝试抛出异常时,代码会直接终止。我花了大约三周时间才发现这不是我的错,而是编译器本身的问题。

唯一合理的做法是重新编译工具链。你需要搜索、下载、解压,遵循PDF指南(ARM的指南非常直接好用),安装先决条件,然后将构建脚本中的 -fno-exceptions 改为 -fexceptions,然后编译。

由于异常使用动态内存,你需要提供一个内存分配函数。有一个函数叫做 __cxa_allocate_exception,你需要实现它(或重写其弱符号定义)来提供内存。在我的实现中,我使用了静态存储,这样就不会干扰那些想使用动态内存分配的学生。

完成这些后,异常可以工作了,但遇到了另一个障碍:巨大的二进制文件大小。基线代码(无异常)是2KB,启用异常后变成了150KB,增长了56倍,占用了30%的Flash空间,仅仅为了抛出和捕获一个异常。

为了找出膨胀的来源,我反汇编了二进制文件。我发现了一些奇怪的东西:std::stringio streamlocale 等。回溯代码,最终都指向了 terminate 处理程序。

解决方案是:省略终止处理程序terminate 是一个弱符号,你可以实现自己的版本来覆盖编译器提供的版本。这样做之后,代码大小减少了91%,降到13KB。这可以接受,只比之前多了11KB。

那么,为什么 terminate 如此庞大?当你在电脑上抛出未捕获的异常时,通常会得到核心转储和一条错误信息。terminate 会打印信息,这涉及到IO流。即使你只是包含了相关头文件而没使用,由于静态初始化的方式,也会自动招致约120KB的空间开销。消除这个,就消除了异常带来的大部分膨胀。

还有一个问题是RTTI。目前我的代码很小,类型很少。但随着程序增长,创建更多用户定义类型和抽象,RTTI会成为问题并开始增长。我想禁用它。

那么,禁用RTTI (-fno-rtti) 后,编译器会怎么做?

  1. 告诉你不能在使用异常时禁用RTTI。
  2. 剥离所有与异常处理无关的RTTI信息。
  3. 静默移除RTTI信息,使用C++抛出任何东西都变成未定义行为。

正确答案是 选项2。GCC会为你清理,只保留代码中实际抛出的类型的RTTI信息,其他一切都可以被剥离。

至此,我意识到我可以控制异常的内存分配,可以静态分配代码中需要使用的异常对象数量。而标准库的 terminate 问题实际上与异常无关。RTTI方面,我愿意为少数抛出的类型支付代价。剩下的问题是异常表和处理算法。

在将代码库迁移到异常处理的过程中,我的学生们很喜欢它,他们喜欢不必担心宏和其他东西的体验。

同时,我也在并行地将代码迁移到结果类型,因为我不确定哪个会胜出。直到发生了一件事:我决定对于足够大的项目,过渡到异常处理而不是返回类型,因为异常在代码大小上更小,并且在异常未被抛出时更快。

我重构并测试了我的代码。像闪烁LED和控制舵机这样的小演示,代码大小如预期般增长了。但当我测试一个更复杂的项目(包含SD卡驱动、显示驱动等)时,我发现代码大小比使用结果类型之前更小。我从未期望异常能使代码更小,这促使我必须找出原因。


异常处理的工作原理(从抛出到捕获) ⚙️

上一节我们看到了异常可能带来代码大小优势的迹象。为了理解这一点,本节我们将深入探讨C++异常从抛出到捕获的完整工作原理。我们将专注于基于表的异常处理,并介绍几个关键概念。

首先,根据异常处理的需求,我们可以将函数分为三类:

  1. 叶子函数:不调用任何其他函数,自身也不抛出。它们没有异常元数据。
  2. 平凡函数:调用其他函数,但不需要任何清理(例如没有局部对象需要析构),也没有 try-catch 块。展开器知道遇到这类函数时,只需要无条件展开。
  3. 动态函数:包含需要清理的内容(有析构函数),和/或有 try-catch 块。

对于ARM架构,我们需要了解其调用约定和寄存器:

  • R0-R3:用于传递参数和返回值。
  • R4-R11:被调用者保存寄存器。
  • R13 (SP):栈指针。
  • R14 (LR):链接寄存器(用于优化叶子函数返回)。
  • R15 (PC):程序计数器。

抛出异常时,代码会转换为对 __cxa_allocate_exception 的调用,分配内存并构造异常对象。然后调用 __cxa_throw,传入异常对象指针、类型信息指针和析构函数。

__cxa_allocate_exception 不仅分配异常对象本身,还在其后附加了一个异常头。这样设计是为了ABI兼容性和灵活性。异常头包含了展开过程所需的状态信息(如虚拟CPU寄存器、类型信息、析构函数、缓存等)。运行时可以通过从传递的指针向后偏移来访问异常头中的字段。

异常索引和表:每个非叶子函数在异常表中都有一个条目。这些条目相对于函数在代码段(.text)中的位置进行排序。每个条目包含一个32位偏移量,指向该函数的异常数据所在位置。

展开指令:这些指令模拟了函数的尾声(epilogue),但省略了返回值部分。它们只有三个目的:从栈中减去分配的空间、弹出寄存器、处理任何特殊的协处理器状态。展开指令是8位编码的,非常紧凑,因为大多数函数只需要很少的指令来展开。

描述符:这些结构指定了当程序计数器在某个范围内时应该执行的操作。例如:

  • 清理描述符:当需要调用局部对象的析构函数时,跳转到“着陆垫”代码。
  • 捕获描述符:包含捕获着陆垫和类型信息指针,用于匹配抛出的异常类型。

语言特定数据区(LSDA):GCC使用LSDA来压缩异常数据。它使用可变长度编码(ULEB128)来表示整数,以节省空间。LSDA包含:

  • 头部信息(格式、编码方式、偏移量)。
  • 调用站点表:描述函数内可能抛出或需要清理的代码范围。
  • 动作表:描述 catch 语句和清理区域的顺序。
  • 类型表:列出函数中捕获的所有唯一类型。

当异常抛出时,运行时库(如 libunwind)会执行以下流程:

  1. 根据当前程序计数器,在异常索引中进行二分查找,找到对应的函数条目。
  2. 检查是否需要拒绝展开(如 noexcept 函数)。
  3. 检查是否有LSDA。
  4. 评估个性例程(personality routine),判断程序计数器是否在某个清理或捕获范围内。
  5. 如果在清理范围,则调用析构函数并继续展开。
  6. 如果在捕获范围,则检查类型是否匹配。如果匹配,则跳转到捕获块代码,并将异常状态标记为“已处理”。
  7. 对于平凡可展开函数,直接解码并应用展开指令。
  8. 重复此过程,直到异常被捕获,或者到达顶层调用 terminate

捕获块在编译后通常会变成一个 switch-case 语句。着陆垫代码会比较过滤器编号(来自动作表)以决定跳转到哪个 catch 块。然后会调用 __cxa_begin_catch__cxa_end_catch 来管理异常对象的生命周期。


异常处理的数据结构大小分析 📊

上一节我们详细了解了异常处理的工作流程。本节中,我们将量化分析异常处理机制中各个数据结构的内存开销,并将其与基于返回值的错误处理进行对比。

我们基于以下假设进行分析:

  • 代码以最小大小为目标进行编译。
  • 所有函数长度小于16KB(基于对Firefox等大型项目的分析,绝大多数函数满足此条件)。
  • 没有函数拥有超过63个不同的“析构路径”或 catch 块组合。
  • 使用ULEB128编码。

以下是关键数据结构的大小估算:

异常索引条目:8字节(由ABI定义)。
短个性数据(SU16):4字节。
长个性数据(LU16/32):最坏情况12字节。
清理描述符:8字节。
捕获块代码:约12字节(包含比较、分支、begin_catchend_catch 调用)。
动作表条目:每个条目约2字节(使用ULEB128编码,范围受限)。
类型表条目:每个唯一类型4字节(指针大小)。
调用站点表条目:每个范围约4-7字节(使用ULEB128编码偏移和长度)。
LSDA头部:5到7字节。

举例说明

  • 平凡可展开函数:只需要异常索引条目(8字节)。
  • 带一个互斥锁(需要析构)的函数:需要索引条目(8字节)、SU16个性数据(4字节)、清理描述符(8字节)、描述符结束标记(4字节),总计24字节。如果栈空间更大或增加更多对象,大小会增加。
  • 带一个 try-catch 的函数:大小约45-47字节。每增加一个 catch 块,大约增加18字节(捕获块代码12字节 + 动作表2字节 + 类型表4字节)。增加新的 try-catch 范围会增加调用站点表大小。

现在,让我们与使用返回类型的错误处理进行对比。

考虑一个调用三个可能失败函数并求和的情景。使用 std::expected 时,每个函数调用后都需要检查返回值。汇编代码显示,每次检查的成本约为6字节(加载、比较、条件分支)。使用 bool 返回类型时,成本可能降至4字节。Rust的 Result 类型检查成本也约为4-6字节。

核心观点:基于返回值的错误处理,其检查代码的成本与函数调用次数线性相关。而异常处理的元数据(异常表、LSDA等)的成本与函数数量相关,并且很多函数是“平凡可展开”的,成本很低。


集中式 vs. 分布式错误处理 🏗️

上一节我们对比了两种机制的数据开销。本节中,我们引入两个概念来总结它们的本质区别,并解释为何异常处理在某些情况下更具优势。

  • 分布式错误处理:强制代码在整个代码库中进行错误处理。每个函数都负责接收错误并将其传递给上层。每当调用一个可能发出错误的函数时,它要么处理错误,要么传播它。使用返回值、输出参数等都属于此类。
  • 集中式错误处理:拥有一个专门的机制(如邮政系统)来传递错误信息。你只为这个机制支付一次成本,它负责在整个代码中传播错误。C++异常处理就是一种集中式错误处理机制。

我的主张是:集中式错误处理方案在代码大小方面比分布式处理更有优势。

原因如下:

  1. 分支并非免费:每次错误检查都需要比较和分支指令,这会产生代码大小开销。对于可能失败的函数调用序列,这种开销是累积的。而对于异常,平凡函数只需要固定的元数据开销(如8字节),无论它内部调用了多少其他函数。
  2. 大多数函数是平凡可展开的:考虑一下你编写的函数类型。在嵌入式领域,或者即使是非嵌入式领域,有多少函数真正需要处理错误而不是仅仅传播它们?有多少函数拥有非平凡的析构函数(需要 std::string 等)?在许多代码库中,大部分函数只是传递错误。

让我们做一个思想实验:假设一个代码库有1000个函数。

  • 5% 的函数实际捕获错误(远高于Audacity的实测比例)。
  • 25% 的函数分配对象且非平凡。
  • 超过一半的函数是平凡可展开的。
  • 10% 是叶子函数。
  • C代码函数总是叶子或平凡的。

估算异常处理的总开销:try-catch 函数开销 + 清理函数开销 + 平凡函数开销 ≈ 15KB。这包括了 libunwind 的实现(约8KB)。如果使用我优化的实现(约2.5KB),开销更低。

对于分布式错误处理(基于返回值),每个函数允许的检查次数有限制。在上述假设下,每个函数平均只能进行约4到6次检查调用,超过这个次数,异常处理在代码大小上就会更优。

结论:为什么C++异常能产生更小的二进制文件?

  1. 减少调用可能失败函数时的代码生成:无需在每个调用点插入检查代码。
  2. 展开信息随函数数量缩放,而非函数调用次数
  3. 消除了额外的返回路径代码:错误返回路径的代码被集中化的展开机制替代。
  4. 展开指令是正常代码的压缩表示:它们只做三件事(减栈、弹寄存器),非常紧凑。
  5. 传播代码只需支付一次libunwind 等运行时库不随应用程序规模扩大而线性增长。

优化潜力与未来展望 🚀

上一节我们得出了异常处理可能更优的结论。本节我们来看看当前的异常实现还有哪些巨大的优化空间,以及未来的可能性。

异常处理机制还有很大的优化潜力,我们尚未对其进行充分的优化。例如:

  • __cxa_begin/end_catch:为什么每个 catch 块都需要单独的函数调用?可以合并并在末尾分支。
  • libunwind:可以针对纯C++环境进行精简,移除对Java等其他语言的支持。
  • 个性数据格式:可以设计更紧凑的编码格式,移除向后兼容的冗余信息。
  • 动作表:或许可以重新设计,用更少的字节描述动作序列。

性能方面:虽然本教程聚焦代码大小,但异常在“悲伤路径”(异常抛出时)的性能也很重要。初步测试显示,自定义的展开实现可以将展开时间显著降低(例如从15000个周期降到3660个周期),目标是降到接近简单返回传播的水平(约1000个周期)。

扩展性:由于完全控制了异常实现,我们可以添加新功能:

  • 异常堆栈跟踪:在异常头中记录展开过程中经过的函数,提供堆栈跟踪。
  • 更好的异常控制:实现单阶段展开以提升速度。
  • 异常工具化:静态分析或运行时工具,用于报告哪些异常类型可能导致程序终止。

总结 📝

在本教程中,我们一起学习了:

  1. 嵌入式系统的约束:资源有限,传统上避免使用异常。
  2. 错误处理机制的演进:从返回值到异常的实践与思考。
  3. 启用异常的技术挑战:重新编译工具链、处理 terminate、管理内存分配和RTTI。
  4. C++异常的工作原理:从抛出、分配、展开到捕获的完整流程,涉及异常头、索引表、展开指令、LSDA等关键概念。
  5. 内存开销分析:量化了异常处理数据结构的大小,并与返回值检查的成本进行对比。
  6. 集中式 vs. 分布式错误处理:从代码大小角度分析了异常处理的优势,指出其成本与函数数量相关,而非调用次数。
  7. 优化前景:认识到当前异常实现并非最优,存在巨大的代码大小和性能优化空间。

最终结论是:对于具有一定复杂度的嵌入式项目,尤其是在错误传播路径较长、大多数函数无需资源清理的场景下,使用C++异常处理机制有可能生成比传统基于返回值的错误处理更小的二进制文件,同时提供更清晰的错误处理逻辑。这打破了“异常必然导致代码膨胀”的固有观念,为嵌入式C++开发提供了新的思路。当然,是否采用需要根据具体项目约束(如实时性要求、工具链支持、团队习惯等)进行权衡。

027:脚本化与自动化——本可以成为脚本的手动任务

在本教程中,我们将探讨一个核心问题:何时值得投入时间去自动化一项任务,何时我们只是在偏离正轨。我们将通过一个生动的个人故事引入,并系统性地分析自动化的收益与成本,最终提供一些实用的决策框架和策略。

我叫 Fred Tangro。目前我在一家从事静态代码分析的公司工作。我最初的专业是生物信息学。

我曾在一个15人的班级里。其中14人对生物学充满热情。

他们听说编程可能是一个有趣的工具。而我则对编程充满热情。

我很享受编程。我认为生物学可能是一个有趣的应用领域。有些时候,我和同事之间的这种差异很明显。

例如,我记得有一次,老师给了我们一份电子表格,并指导我们进行一些实验。

在某个时刻,老师说,好的,你们有1100条数据。在这份电子表格里,数一数有多少条数据的“无人机年龄”值高于80。

我周围的14个人指着屏幕。开始一行一行地手动计数。我当时想,不应该这样做。你应该写一个公式。

然后自动得到结果。当时,我觉得我知道正确的方法,而他们是错的。

但回想起来,我不认为他们完全错了。因为我总是会写公式来计数。

但如果不知道如何写公式,而老师正等着你回答问题,并且你只有100条数据,这并不算多,那么手动计数确实有道理。

所以这可能是我第一次遇到是否自动化,这取决于你的知识和具体情境。如今,我在一家做静态分析的公司工作。

我们在CI和IDE中尝试检测不良模式并发出警告。我们尝试制定C++中不应做的规则,并尝试用模式匹配来检测它们。

C++非常复杂。我们有一个元规则。我们尝试用大量C++代码来测试它。

这些代码可能以不同方式犯同样的错误。C++非常复杂,我们有海量的C++测试代码。

我们最终得到这样的测试:你有一大堆C++代码,以及我们期望在其中找到的内容。这很难阅读。

所以我想要在我的IDE中创建一种插件,它能显示更改将在哪里发生。我称之为“装饰器”。

我和一个同事一起开发了它。它运行得很好,好到我们开始添加更多功能。

因为我们有很多功能,我们开始需要测试。为了测试装饰器,我们添加了测试。

因为我们有了测试,我们在上面建立了CI。因为我们有了CI和测试,我写了一个GitHub Action。

我有很多改进这个Action的想法。但我停下来了,因为我们在开发的GitHub Action自动化了CI,CI又自动运行测试,测试自动测试那个我将放入IDE以装饰测试的插件,而这个插件又是为了让你能把它放到你自己代码的CI测试中。

这是层层嵌套的自动化。无论你看向哪里,你下面都有自动化,而你上面又有新的自动化想法。

有些自动化创造了真正的价值。通过自动化,你改善了每个人的生活。

但有时,我只是在偏离正轨。有时自动化某件事很有趣,但它没有带来任何价值。

这里的“价值”取决于你对价值的定义。这就是我今天的问题:何时值得花时间去自动化某事,何时我们只是在偏离正轨,做了不该做的事。

技术演示的好处是,总有一张HTC图表来回答问题或支持你的观点。

请看HTC 1,2,0,5。你看不清。我快速描述一下。一个轴是:你执行同一任务的频率。另一个轴是:通过自动化,你能从这项任务中节省多少时间。

每个单元格里是,在接下来的五年里(我认为这是一个合理的考虑范围),你在自动化上可以花费多少时间,才不会超过你将要节省的时间。

这张图实际上只是在说:当且仅当我们节省的时间多于我们编写自动化所花费的时间时,自动化才是值得的。

这是一个简洁、简单的表述方式。这也是一个我完全不同意的公式。

如果我不同意一张HTC图表,那没关系,但我们行业中的许多人似乎都这么想。比如,如果你节省的时间没有超过自动化所花的时间,那就不值得。

我认为真正的公式是:当你的收益高于成本时,就值得去做。

感谢来到我的演讲。好的,问题是,通过回答这个具体问题,我们并没有真正解答什么。

我想听听你们是否有关于收益和成本的想法,而不仅仅是花费的时间和节省的时间,这些当然是重要的收益和成本。你们有其他想法吗?不要忘记一些事情。是的,请说。复杂性。

你的意思是自动化比手动操作更容易,还是相反?因为太复杂而变得棘手。复杂。所以你会提高这个门槛,也许。不,如果你有很多不同的事情……好的,所以如果……是的,好的。所以如果太复杂而无法自动化,而你开始自动化一切,那就……请说。嗯。

精神压力,是的。是的,所以必须计算手动错误。我喜欢这个,因为我的整个演讲将要围绕它展开,是的。自动化是可扩展的。好的,是的,所以你可以将相同的自动化复用于其他事情,如果……是的,这是一次性的场景吗?

抱歉镜头问题。可能不理想。下次需要我给你麦克风吗?好的。

我重复了它们。无论如何,现在我要展示我的观点,我认为你们提到的所有点都在幻灯片上。我很高兴。

我从收益开始,因为我是一个乐观的人。是的,我认为房间里的大象之一是:通过自动化,你避免了手动错误。

我故事开头的同学们在屏幕上数了100行。我确信他们中有人漏数了一行或重复数了同一行。如果你自动化,这不会发生。

我在CI/CD领域工作。我知道复制粘贴就足够了,但如果你写足够多的代码,拼写错误就会发生。

顺便说一句,如果你不同意某张幻灯片,或者想补充什么,请随时举手。我打算让这次演讲具有互动性。我知道有些观点可能有点……有争议。所以,没有手动错误。

另外,你可以免费重新运行。我们之前提到过,无论它是运行一次还是测试需要多次进行。

即使测试是带引号的“一次性”。过去我遇到过很多次这样的情况,我以为我在做一次性的更改,把A改成B。

我做了。然后第二天,我意识到实际上我不应该把所有A都改成B,因为有些A应该改成C。

如果我手动操作,这意味着我需要回滚一切,并手动重新执行任务,因为我对要做什么略有误解。有了自动化,我只需回滚。我们运行脚本,稍微修改,将部分A的用法改为C。

对于这次“一次性”更改的再次尝试,这几乎是免费的。所以我认为即使是“一次性”任务,我们仍然获得了能够重新运行一次的好处。

也可能发生这种情况:你将每个场景都更改到主分支。而某个用户正在他们那边工作,还没有这些更改,并且他们到处都在使用旧方式。我们也可以给他们脚本,他们也能在他们那边进行更改。手动操作的话,他们需要做和你一样的工作。

关于分支,这引出了我的下一个观点:追赶移动的目标。

我有一个大型代码库。我想把所有A的用法改为B的用法。所以我创建了一个分支。我工作了一整周。将A的用法改为B的用法。

周末时,我尝试将分支变基到项目的主分支上,而在我工作期间,每个人都引入了新的A用法,因为世界在继续运转。

现在我有了需要解决的冲突。如果我运气好,最终会收敛。在某个时刻,我解决的A到B的更改会多于人们新引入的A用法。

所以如果我去度假,希望最终能收敛。如果团队非常大,并且引入了大量更改,我们可能会陷入实际发散的情况,他们引入的问题比我一次性能解决的还多。

有了自动化,这完全不存在。你花任意长的时间编写自动化。一旦完成,你按下一个按钮。它几乎是瞬间完成的,你为整个世界进行了更改。所以你没有那种试图用小勺子舀空一艘正在下沉的船的感觉。

理解用户流程。如果我的任务很复杂,需要多天执行多个步骤。

通过自动化,我被迫获得对整个流程的高级视野。我被迫看到全局,也迫使自己明确列出每一个例外情况。

这个过程除了给你自动化本身,还给了你一些知识,这是极其宝贵的。这些极其宝贵的知识,你还可以与同事分享。

开头的HTC图表只基于你一个人执行任务的假设,这可能不是事实。如果你的团队有新成员,他们需要学习如何执行任务。手动操作,对他们来说可能非常漫长和复杂。

有了自动化,你给他们可以立即运行的脚本。它完美运行。如果他们需要理解背后的原理,这个自动化很可能是一些他们可以查看并理解的代码。

代码本身就是很好的文档,你不应该只写文档而不写代码。这是一个了解事物的有趣起点。

这个之前提到过,关于可修改性。你考虑编写非常干净的代码,这很好。我的做法略有不同。我有一个大项目,我们在里面放了很多文件和文本文件。

我们试图保持整洁,但后来我在上面添加了一些东西。在某个时刻,我们想要更改格式。所以我写了一个非常粗糙的Python脚本来自动完成。

也许我写这个脚本花的时间比手动操作还多。但我想这么做。我的Python很糟糕。所以这是一个学习的机会,等等。它成功了。

两周后,我需要再次更改格式。我找到这个本应是一次性的Python脚本,修改了五行。重新运行,它完成了我需要做的新格式更改。

所以,通过拥有一个我为一次性任务编写的自动化,我实际上拥有了一个可以复用的东西,而不是一个库。这大大减少了我在这项任务上必须花费的时间。

我也许对Python变得稍微不那么生疏了,但不多。

我认为这个是最重要的收益,在我看来,如果你有一项不是超级重要、不是超级紧急的任务。

它可能是学习新技术的最佳沙盒。你尝试做一个只使用一次的微小脚本。脚本非常小。

所以它是学习新技术的好机会。风险非常有限。如果没成功,你仍然可以手动执行任务。你仍然可以回退到你知道的方法来再次自动化它。

我认为承诺有限。我的意思是,即使你没有遵循最佳实践,因为你在学习这项新技术,没关系。最坏的情况,你扔掉代码,下次用另一种方式重写。最好的情况,它成功了,你做得很好。所以这真的是一个非常好的学习沙盒,用于这种小型的一次性更改链。

学习新技术的另一种方式是玩具项目,但我认为自动化是更好的沙盒。首先,很难随时进行。告诉你的老板,是的,我今天下午没做任何有用的事,但我在学习新技术,这可能不太受欢迎。

通常当我做一个玩具项目但没成功时,比如这项技术没有真正实现我想要的,我总是留下疑问:是因为技术不好,还是因为我想做的事情不是真实场景,反正没人会这么做?我不知道。

而对于玩具项目,你最终,如我所说,只是一堆没有产出的代码。而对于自动化,你最终得到了一些有用的东西,同时解决了实际问题。

所以我真的很喜欢将自动化作为学习新技术的一种方式。

另一个我喜欢的是改变行为,这在某种程度上是光谱的另一端。它不是完全无用的东西。

但是,当古腾堡发明印刷机时,他并没有从一个僧侣的工作时间中节省出一年。它彻底改变了社会。是的,可能我的自动化不会改变社会,但我见过,例如,一些团队。

我见过一个团队。他们有非常非常复杂的发布流程。每个季度发布产品需要两天时间。这非常痛苦。所以每次他们都会换人来做,因为大家都讨厌做。

在某个时刻,David受够了这个问题,说,好吧,我们要自动化。整个团队致力于自动化。

可能花了相当长的时间,直到他们最终得到一行命令。这并没有为他们每个季度节省两天的工作,因为有了这一行命令,他们把它放入了CI,并开始了持续交付。

现在他们几乎能立即获得用户对他们所做的任何场景的反馈。所以通过自动化一项不常做的事情,他们彻底改变了整个团队的行为方式和工作方式,变得更好。

这在你自动化时不会每天都发生,但我认为这是我们偶尔会中一次的彩票大奖。这真的值得在自动化上投入。

所有这些对你的团队和公司都非常有趣。但有时你也必须自私一点,手动执行大量重复性工作可能极其无聊。如果你总是反复按相同的键,你甚至可能患上重复性劳损。

就我个人而言,即使自动化比手动操作多花一点时间,并且最终可能导致受伤,我也会选择自动化。这是一个有趣的问题需要解决,你有一项即将学习的新技术。

有时你在日常工作中做着相当困难的事情,你需要改变节奏,切换到像这样的事情上。这很有趣。你做两个小时,然后回到你的任务上,你会感觉精神焕发一些。所以,它在这方面也很有用。

我是一名C++开发者。当我开发一个小功能时,需要三天。有了自动化,我可以在早上产生想法。到午餐时间,我就完成了自动化。我可以与同事分享,他们很高兴,因为我为他们解决了问题。所以他们很感激。

我的同事很高兴,我很高兴,因为我用很短的时间完成了一些事情。大家都高兴,我们都喜欢自动化。你们看到我遗漏的其他好处了吗?现在说坏的部分,成本。

显然,有了自动化,你不再做任何手动操作了。恭喜。现在你可以犯错误了。而且你可以大规模地犯错误。有两种方式。你可以创建100万个文件,每个文件有100万个错误,这很糟糕。或者你可以创建100万个文件,其中一个文件有一个错误。这更糟糕。

这是自动化的真正陷阱。在100万个文件中有一个错误,因为你的审阅者必须抽样审查这100万个文件。而如果你不……我不会选择那个有错误的文件。顺便说一句,我列出所有自动化成本,但不提供任何解决方案。

那个由你负责。更高的不确定性。一项经常重复的手动任务,在花费时间上是非常可预测的。而编写自动化,特别是如果你不了解技术,则不然。

我们都知道,我们应该将我们认为会发生的时间乘以二,这样我们仍然会错一半。是的。这次演示不是一张HTC图表,而是两张。

所以HTC 1,3,1,9展示了自动化过程,如你预期会发生和实际发生的情况。你期望更改你的任务,你花一点精力自动化它,然后你拥有所有这些空闲时间,因为你不再需要手动操作。而在现实中,你开始写一些代码,当你开始运行时,你发现有一个错误,所以你调试它。

然后你发现这个错误是因为你没有考虑周全,所以你重新思考并实现这个新解决方案,调试新解决方案,等等,最终你根本没有时间实际执行手动任务。当然,这是一个图表。当然,我们不这样做。

谁了解沉没成本谬误?这是我们大脑的一个广泛问题,它让你觉得,如果你不承认某事失败,它就没有真正失败。如果你不承认失败,就没有失败。所以对公司来说,就像,是的,我们在这个项目上投资了数十亿,还没完成。我们现在不会放弃,那将意味着数十亿损失了。我们要投入更多资金。这次,可能就会成功。对你来说,这意味着说,哦,我已经投入了一周时间来节省我两个小时的时间。如果我现在停止,一切都白费了。如果我继续,也许它会成功。不,它不会。

所以在某个时刻,你必须放手,说,好吧。我在这项自动化上花了太多时间。它超级令人兴奋。我没有得到结果,但我现在要停止了。另外,为什么我们保留HTC图表。

自动化非常擅长一遍又一遍地做完全相同的事情。这意味着你在引入刚性到你正在自动化的事情中。

你在任务本身中引入刚性。你也在整个阶段增加了一些刚性。例如,如果你想从Linux迁移到macOS,也许你必须重写所有自动执行操作的脚本。

我们手动做的事情完美地工作。你还在那些偶尔发生一次的例外情况中增加了刚性。

如果你花很多时间自动化你的A流程。你可能会想,好吧,每两周,我们对我们花费的冲刺等进行回顾。可能很好,但也许在十二月,当你的团队四分之三的人休假时,你想做一个为期三周的冲刺。现在你的自动化使得在每个人都不在的情况下进行三周冲刺变得超级困难。

所以因为你自动化了,你让其他一切都变得有点更痛苦。工具策略。嗯。

这与复杂性和一切有关,我想。或者至少我是这样理解的。如果你开始拥有多个自动化工具,并且给你周围的每个人所有这些做这做那的自动化工具,在某个时刻,就太多了。人们有太多工具。他们忘记了一半。他们不太清楚如何使用他们知道的那个。

你只是在浪费每个人的时间。我说多个工具,但我也曾在公司里,你有一个工具,有20个标签页,每个标签页有四个按钮,做完全不同的事情。这完全一样。人们不知道所有功能。他们不使用它们。

所以当你想自动化时,你必须自动化你认为对每个人真正有用的东西。其余的你可以为自己自动化,但可能不要分享它。机会成本。

现在花一些时间来节省五年后的一些时间,就像HTC图表里说的那样,这很好。但也许两周后你有一件非常重要的事情发生。对你的初创公司来说是一个非常大的演示。一个产品即将发布。在这种情况下,投资一些精力来节省五年后的时间,如果你甚至不知道初创公司是否还会存在,这不是对你时间的良好利用。

所以你应该始终考虑当时是否可以做更有用的事情。在我看来,我们应该始终考虑是否可以做更有用的事情。这是否意味着我们应该总是做更有用的事情?花时间考虑它。然后如果你决定仍然想要自动化,好的,因为例如,在初创公司,也许你总是处于某种紧急状态。

你仍然需要在某个时刻拥有一些自动化。所以你将不得不支付这个机会成本。只是在你支付时要意识到它。维护被提到过,这是一个真实的问题。

对我来说,就像,恭喜。你现在处于最糟糕的境地。你的项目非常成功。如果你超级成功,整个公司现在都依赖于你的工具,因为你自动化得非常好。你将不得不支付这个新的机会成本,每次项目、产品不工作时,每次自动化不工作时,因为存在安全问题,因为无论你使用什么服务宕机了,等等,你将不得不停止你正在做的事情来修复自动化。

有时团队会接手这个新自动化的所有权。但我见过这样的情况,是的,你最终成为负责那个人。我提到了刚性。

刚性是坏的,但它可能产生更坏的影响,那就是人们说,好吧,它是刚性的。所以我不会改进这个任务。因为那会很痛苦。我需要去找Fred,请他更改自动化,或者更糟,我需要学习这门语言来更改脚本。所以我就不改进它了。

我说它有益,因为有时是多人都有小的改进想法,这些想法单独不值得完全改变自动化。但如果你意识到所有这些改进想法,你可能会决定,实际上,改造自动化并编写一个包含所有改进的新自动化是极其有价值的。

再次强调,我没有解决方案,但把它记在心里是好的。

我不喜欢这种节省时间与花费时间的想法的一点是,并非所有时间都是平等的。昨天有一个关于神经多样性的演讲,提到有些人患有ADHD,当你患有ADHD时,有些日子,你会高度专注于某事并超级高效。有些日子你完全无法集中注意力。你尝试做你能做的任何事情。一项手动重复性任务不需要任何专注力。如果你累了,你可以做。如果你大脑状态不佳,你可以做。如果是周五,你即将离开,你只有20分钟。你可以做20分钟的手动测试。编写自动化需要你高度集中注意力,持续相同的时间。所以我们节省的是“低专注力时间”,而使用的是“高专注力时间”。这可能并不总是最好的交换。

这有道理吗?再次强调,如果你有任何不同意我说的,请随时举手。稍后会看到很多这些每月出现的幻灯片。

如果你意志足够坚强,能够避免沉没成本谬误并接受你失败了。现在,你必须处理失败。这可能对你的士气不利,就像快速完成某事对你的士气非常有利一样。失败对你的士气不利。如果你花了太多时间追逐自动化,你现在可能没有备用计划。你试图自动化项目的发布。发布必须在周五。现在是周五早上。你没有成功。你通常不按时完成。这很糟糕。所以你应该总是,如果你开始进行内部自动化,并且不确定是否能成功,你必须考虑备用计划。同样的方法。如果你情绪非常低落,无法承受失败,就不要做。这个可能有争议,取决于你。

但我总是说,你不应该在你不想成为你工作的领域表现得太出色。我是为我自己说的。在我的简历中不再出现的公司里,是的,这正是发生的情况。比如,我在一些我不想做的事情上非常出色。因为我是最擅长的人,我负责它们。是的,如果你开始自动化你的工作,人们会来找你说,嘿,Fred,我有一个自动化的想法。你能做吗?如果你喜欢,那很好。这是一个好处。如果你不喜欢,这是一个你应该考虑的成本。这就是我的成本列表。我遗漏了什么吗?

如果人们开始依赖自动化,也可能导致他们失去最初如何做那件事的知识,这可能很重要。是的,确实,它可能在使行为僵化方面起了一点作用,比如我不理解背后发生了什么,所以他们不再有改进的想法。但是的,这是一个很好的点。

所以我提出了很多自动化的理由,很多不自动化的理由。我们仍然有这个问题:这次我应该为这个问题自动化吗?嗯。

我能告诉你的就是,是的,如果你自动化,你将不再做任何手动操作,但你会犯错误。是的,你可以免费多次重新运行相同的脚本,但你会引入关于是否能完成的不确定性。你可能能追赶移动目标,但你必须小心沉没成本。结果可以分享,但如果你分享太多,你将不得不维护它。你会更好地理解流程,但在这个过程中你会让它更僵化。它可以是未来自动化的良好基础,但你现在在为未来的空闲时间付费。你正在学习新技术,但也给自己增加了维护负担。你正在改变行为,但也许你也在使行为僵化。你将减少重复和大脑测试,但会有一些精神成本。它会很有趣,但可能会失败。它是一个很好的士气提升器,但你成为了房间里的自动化专家。那么现在我们该怎么做?起初,我想写下一个非常复杂的公式,大致包含我在这里放的所有内容。

但我们没有时间做那件事。但是,是的,所以公式超级复杂。我认为我们仍然可以提取一些见解。这是我期望你们在某些点上不同意我的地方。但我认为我们仍然可以提取一些我们应该考虑的参数。

第一个是:我们试图自动化的任务有多稳定?所以我做了这个小图表,不要只看机器人。一边是完全不稳定,一直在变化。另一边是完美稳定。这里写着不要自动化。这里写着是的,自动化。那么你期望什么?像这样。所以第一个真的很容易。

在我看来,自动化的唯一理由是如果它完全稳定。但有一个转折点:什么是稳定或不稳定,实际上取决于你如何看待问题。

如果你每天需要将你的所有值和年龄与不同的数字进行比较。一方面,它不稳定,每天都在变化。另一方面,如果你放一个参数代表你要比较的数字,你现在就拥有了一个完全稳定的自动化,带有一个参数。

所以有时你有一个不稳定的问题,但你可以提取一些稍微更小的问题,这些问题完全稳定。那是我们必须追逐并自动化掉的问题。同样,如果你的流程有10个步骤,其中8个很容易自动化,2个非常困难,你只需自动化那8个可以自动化的步骤。技巧是。

你是否试图解决一个紧急问题?我把紧急视为一次性的,比如,周末有一个演示。不是每周都有演示,我们总是处于紧急状态。嗯。所以,一边是,我们很无聊,完全没有紧急情况。另一边是我的CEO站在我身后大喊。

那么你会怎么做?你看你。在我看来,是后者。如果完全没有紧急情况,你自动化。越紧急,我越不想自动化,因为我担心我不确定需要多长时间。所以它可能会失败。我将不得不寻找备用方案。而且因为它紧急,我没有那么多时间备用。所以这是我的第一反应,至少。但后来同事建议更改。像这样。在某个时刻,事情如此紧急,以至于实际上,他们绝望了。

你意识到你无法手动按时完成。那是你想要尝试自动化的时候,因为如果它失败,情况就和之前完全一样。如果成功,你就得救了。所以。在某个时刻,图表可能有点反弹。你同意吗?或者你,你没有,好的。

我想也许如果完全不紧急,可能甚至不值得自动化。所以也许左边也很低。是的,这些图表很多是根据我认为有趣的事情绘制的。所以当我不负责时,我想,好吧,我不妨自动化。那会很有趣。如果你不觉得有趣。左边也确实很低。现在相反,例如,我正在发布产品。我想绝对确定每次发布时,我们都通过测试,当然。并且我们上传了经过正确剥离的正确版本,已经通过了防病毒软件等。

所以如果你想确保某事是可重复的,我认为对每个人来说都有道理,如果它非常重要,我们想要自动化它。因为手动操作会出错,你重复任务的次数越多,你犯手动错误的机会就越多,可能会发布一些你不应该发布的东西,这对公司来说可能是灾难性的。嗯。

我也曾在一家公司工作,在那里,如果你为你的CI请求10台机器,它们本应完全相同。手动一台一台配置,请求10台机器所需的时间是请求一台的10倍。这意味着你请求了10台机器,其中8台严格相同,一台根本启动不了,还有一台只是路径中有一个拼写错误,以至于每五年,测试才会在这台机器上开始失败。你必须意识到,哦,这不是不稳定的测试,这是池中不稳定的机器。同样的事情,在这种情况下,想要每次都完全相同的东西却不自动化,是没有道理的。

似乎没有人不同意。是的,我说我的图表在开始时有一个小反弹。同样,自动化很有趣。如果没人在乎结果,就自动化。啊。好的,所以如果你需要经常重复它,但它完全不重要,人们会忘记怎么做。所以拥有你的自动化也会……如果你不重复它,好的,但是……是的。这个之前是……好的,因为这个是当你重复时。但确实,如果你不重复它,它是……不要破坏下一个图表。在那之前,熟悉度。

我只放了一个词,所以它可能是对你试图自动化的任务的熟悉度,或者是对你将用于自动化的技术的熟悉度。一边是我对它一无所知,另一边我是专家。嗯。我会画两个完全不同的草图。一个是,如果有高压,出于某种原因需要做得非常好,我只在我确切知道我在做什么时才自动化。另一方面,如果压力非常低。那是我想要自动化以学习新技术的时候。学习流程并拥有这个自动化。现在我们知道了怎么做,因为在我学习的过程中,我写下了这些确切描述正在发生的事情的脚本。实际上,我认为我画的所有图表都应该包含所有参数,因为它们都相互作用。但对于这个,我认为它真的完全改变了含义。

所以这就是你刚才说的。我们是在谈论一项很少发生的大型任务,还是很多经常发生的小任务?那么你会在那里画什么?食物?我个人知道像这样的东西。手绘的是。最左边正是这个人所说的,就像,如果你不经常做它。你想要自动化它,因为如果你不经常做,你想要自动化它,因为这是一种了解它如何工作、获得全局视野、保存全局视野的方式。因为三个月后,当其他人需要做它时,我不需要重新学习关于这项任务的一切。这也是你可以改变行为的地方。也许这项庞大的任务很少做,因为它非常漫长和庞大。也许如果它现在是一行命令,我每天都会使用它。

光谱的另一端是超级重复的小任务,这是你患上重复性劳损的地方。这是你因为重复某事太多而犯手动错误的地方。而且,这样做会很有趣。在中间,你有这些中等任务,你重复中等次数,可能自动化的价值较低。

有多少人在重复这项相同的任务?只是你自己吗?是一个披萨团队(小团队)还是一个4到8人的团队,我想,取决于你对披萨有多生气。在最后,是整个公司,可能是谷歌或某个巨头。我会在这里画两个草图。如果你热爱自动化。你选择绿色的。因为最终你会得到一些可以与越来越多人分享的东西。这很有价值。你改善了每个人的生活。每个人都感激你。如果你不想成为自动化专家,如果你不想最终负责维护和了解这项完全被诅咒但只有你知道的测试,因为你写了自动化,你选择红色的。我认为在团队中总是有用的。因为中立男性,等等。之后,你把自己置于一个你可能不喜欢的新位置。同样取决于。也许在你的公司,你可以之后把所有权交给别人然后继续前进。

这些是几个坐标轴。但我尝试在考虑是否有用时将它们纳入考虑。但有很多事情无法真正预测,比如。我们将看到。当然,会不会有任何错误?不,我不写错误,但是。我需要重新运行我认为是一次性的东西吗?同样,当然不,因为我不犯错误,但是。它确实发生在最好的人身上。

经理们有这些统计工具。可以说用来表示,好吧,在公司层面,在所有员工之上,每个人都会以这个百分比犯错,等等,所以他们可以预测。在大数字上,将会发生什么。就像我知道如果我抛一枚硬币,一半结果在一面,一半在另一面。如果我即将抛一枚硬币,这仍然没有给我任何信息。这里也一样,面对自动化,某事失败的概率等等,并不能真正帮助你做决定。

还有一些我没有提到的参数。一个是办公室政治,我没有提到,因为它取决于你的办公室。但在某些地方,为每个人自动化是个坏主意,因为没人关心。你花了很多精力,没人喝彩。在其他一些地方,情况相反,人们对你自动化任何事情都如此高兴,以至于你会开始毫无理由地自动化任何事情。希望你不是在任何一个这样的地方,你不必考虑它。

还有一个是道德。我没有把它放在收益或成本中,我认为道德压倒一切。如果你试图自动化掉某人的工作,可能不要做。不在乎你节省了多少时间。如果你试图自动化,以便残疾人能够做他们原本无法做的测试,那就做。我不在乎它是否比手动操作花更多时间。所以我认为如果涉及道德,在公式中它应该压倒所有其他因素,但这不常发生。

现在我们知道我们无法真正知道它是否有价值,我将提出一些方法。你无法教授。一个是,如果你有这个流程,你训练工具自动化,这只是一系列要点。你应该从完美地记录用户流程开始。我们在每一步做什么,等等。有哪些例外?这份文档已经是一种算法。当你有时间自动化一个要点时,你可以开始自动化。然后你继续观察。当你有空闲时间时,你开始处理另一个要点。当你自动化了两个连续的要点时,你可以将两个脚本合并为一个。现在你自动化了前两个。随着时间的推移,你最终会得到一行命令。希望它不会。你总是在有时间的时候做。所以你有空闲时间,不会损害业务或任何东西。所以你最终会得到好东西,经过很长时间。

我将采取的另一种方法是,例如,我试图自动化发布流程。我们发布需要两天艰苦工作。我们需要在周五发布。今天是周一。我不能只是说,好吧。我要花三天时间尝试自动化。三天后,我们将看到进展。如果我们自动化了每一个,我们很好。我们完成了。如果我们没有。也许我们有一个自动化了部分工作的小脚本。也许我们除了对问题是什么有了新的认识之外什么都没有,我们可以写下来。但无论发生什么,我们都有足够的时间回退到手动方法并按时发布。所以与其思考,我需要多少时间来自动化,不如思考,在不成为问题的情况下,我能花多少时间?然后你尝试,看看是否足够自动化掉一个流程。是的,我的几点只是你和你自己问自己,我想成为自动化专家吗?我想做这个看起来有趣的自动化吗?等等。我认为你应该考虑这一点。衣服。

如果是一项你考虑自己自动化的任务,当然,如果你的经理要求你自动化它,那是另一回事,但如果你自己在思考这个问题。你想或不想,跟随你的心。大多数时候,这可能是最好的解决方案。或者至少倾听你的心。如果你想自动化,而人们,比如你的经理,想微观管理你,不希望你自动化,也许你可以给他们看这个演示来说服他们,是的,它会多花一点时间,但这是值得的。这就是我的列表。


总结

在本节课中,我们一起学习了关于自动化决策的全面思考。我们从个人故事出发,探讨了自动化的众多收益(如避免手动错误、可重复性、理解流程、学习新技术、改变行为、提升士气等)和成本(如引入错误、不确定性、沉没成本、刚性、维护负担、机会成本等)。我们认识到,简单的“节省时间 > 花费时间”公式过于片面,真正的决策需要考虑任务稳定性、紧急性、重要性、熟悉度、影响范围以及个人意愿等多维因素。最后,我们介绍了一些实用策略,如渐进式自动化、设定安全的时间预算进行尝试,以及最重要的——倾听你内心的声音。自动化是一把强大的双刃剑,明智地使用它,可以极大地提升效率和工作幸福感;盲目地追求,则可能陷入徒劳和负担。希望本教程能帮助你在未来面对“是否要自动化”这个问题时,做出更平衡、更明智的选择。

028:链接器基础与编译流程

在本节课中,我们将要学习C++程序从源代码到可执行文件的构建过程,特别是链接器的作用,以及单一定义规则(ODR)的重要性。我们将以Windows和Linux平台为例进行说明。

概述

程序构建通常涉及编译和链接两个主要阶段。大多数人对于编译器的作用有基本了解,但对于链接器的作用可能知之甚少。链接器负责将多个编译后的目标文件(.obj.o)以及库文件组合成一个单一的可执行文件或库。在这个过程中,它需要解析不同模块之间的符号引用。

最简单的程序

理论上,我们可以不经过编译和链接,直接编写机器码。例如,在早期的DOS系统中,.COM文件格式就是直接将字节序列加载到内存并从头开始执行。这种程序不涉及任何外部依赖,因此不需要链接器。

代码示例:一个简单的.COM程序

; 将“Hello World”字符串地址存入DX寄存器
MOV DX, OFFSET HelloString
; 调用DOS中断21h的功能09h(显示字符串)
MOV AH, 09h
INT 21h
; 调用DOS中断21h的功能4Ch(程序退出)
MOV AH, 4Ch
INT 21h

HelloString DB ‘Hello, World!$‘

然而,这种方法无法扩展,代码是线性的,不支持函数调用,只适用于非常简单的单文件程序。

现代编译与链接流程

现代C++程序通常涉及多个源文件。构建过程分为两步:首先将每个源文件编译成目标文件,然后使用链接器将所有目标文件以及所需的库(如C++标准库)链接成最终的可执行文件。

核心流程公式

源代码 (.cpp) -> 编译器 -> 目标文件 (.obj/.o) -> 链接器 + 库文件 -> 可执行文件 (.exe/.out)

有时这个步骤对用户是透明的,编译器可能会在内部完成并丢弃中间的目标文件,但底层原理不变。目标文件中不仅包含机器码,还包含供链接器使用的元数据,例如哪些符号是定义的,哪些是未定义的(需要从其他地方找到)。

例如,一个简单的“Hello World”程序,其printf调用在目标文件中是一个未解析的符号puts(编译器可能将printf优化为puts)。链接阶段会从C运行时库中找到puts函数的实现地址,并将其“修补”到调用指令中。

多文件项目与符号解析

当项目有多个源文件时,链接器的作用变得至关重要。假设我们有两个文件:

main.cpp

void helper(); // 声明外部函数
int main() {
    helper();
    return 0;
}

helper.cpp

#include <stdio.h>
void helper() {
    printf("Hello from helper!\n");
}

单独编译main.cpp会生成一个目标文件,其中包含一个对helper函数的未定义引用。如果试图仅链接main.o,链接器会报错,提示“未定义的引用”。

链接错误示例(GCC)

undefined reference to `helper()‘

链接错误示例(MSVC)

unresolved external symbol "void __cdecl helper(void)" (?helper@@YAXXZ)

MSVC的错误信息中包含了“名字修饰”(Name Mangling)后的符号。C++编译器会对函数名进行修饰,将参数类型、返回类型等信息编码进去,以支持函数重载并确保类型安全。GCC和MSVC使用不同的修饰方案。

为了解析这些未定义的符号,我们需要提供helper.cpp编译后的目标文件。链接器会将main.ohelper.o合并,计算helper函数相对于main中调用点的地址,并用正确的地址替换掉占位符。

目标文件与可执行文件的结构

目标文件和可执行文件并非简单的字节流,它们具有复杂的结构,包含多个“段”(Section)。

  • .text段: 存放程序的执行代码。
  • .data段: 存放已初始化的全局变量和静态变量。
  • .rodata段: 存放只读数据,如字符串常量。
  • 还有其他段用于存储调试信息、重定位信息等。

可以使用工具查看这些结构:

  • Windows (MSVC): 使用 dumpbin /headers executable.exe
  • Linux (GCC): 使用 readelf -l executable.out

链接器的工作就是将这些来自不同目标文件的段合并起来,并修正代码中对其他段地址的引用。

地址绑定:绝对地址与相对地址

程序运行时需要加载到内存中。在现代操作系统中,出于安全考虑(如地址空间布局随机化,ASLR),程序每次加载的起始地址可能不同。

这意味着链接器不能在代码中直接写入函数的绝对内存地址。解决方案是使用相对地址。许多处理器指令支持相对于当前指令指针(IP)的跳转或调用。

例如,在x86-64架构上:

  • call指令可以使用32位的相对偏移量,允许跳转到当前指令前后约2GB的范围内。
  • 对于大多数程序,函数调用都在同一个可执行文件或动态库内,这个范围是足够的。
  • 如果跳转目标超过这个范围(例如,调用另一个动态库中的函数),则需要更复杂的机制(我们将在后面讨论)。

使用相对地址的好处是,代码段(.text)本身在加载后不需要修改,可以被多个进程共享,节省内存。


上一节我们介绍了链接器如何合并目标文件并使用相对地址。本节中我们来看看如何管理越来越多的目标文件,并介绍静态库和动态库。

C++链接器与单一定义规则:2:静态库与动态库

随着项目规模增长,手动指定每一个需要链接的目标文件变得非常繁琐。库文件应运而生,它可以将多个目标文件打包成一个单一文件,方便管理和使用。

静态库

静态库(在Linux上是.a文件,在Windows上是.lib文件)本质上是一个目标文件的归档集合,并附带一个索引,列出每个符号(函数、变量)定义在哪个目标文件中。

创建静态库的命令

# Linux (GCC)
ar rcs libhelper.a helper.o
# Windows (MSVC)
lib /out:helper.lib helper.obj

使用静态库链接时,链接器只从库中提取那些被程序实际引用的目标文件,而不是整个库。这有助于减少最终可执行文件的大小。

例如,如果libhelper.a包含了helper.oextra.o,但程序只调用了helper(),那么链接后的可执行文件中将只包含helper.o的代码,extra.o不会被包含进来。

动态库(共享库)

动态库(在Linux上是.so文件,在Windows上是.dll文件)在程序运行时才被加载。它的核心优势在于代码共享:多个程序可以共享同一份动态库的物理内存副本。

使用动态库时,链接过程分为两步:

  1. 链接时: 链接器记录程序需要哪些动态库,并确保符号存在,但不会将库代码复制到可执行文件中。
  2. 运行时: 操作系统的加载器(Loader)找到所需的动态库,将其映射到进程的内存空间,并解析(绑定)程序中对库中函数的调用地址。

创建动态库的命令

# Linux (GCC)
gcc -shared -o libhelper.so helper.o
# Windows (MSVC) (需在代码中使用 __declspec(dllexport) 标记导出函数)
cl /LD helper.cpp

动态库的查找路径

程序运行时,加载器需要知道在哪里找到动态库。如果找不到,程序将启动失败。

以下是设置查找路径的常见方法:

Linux

  • 将库文件安装到标准目录(如/usr/lib)。
  • 使用 LD_LIBRARY_PATH 环境变量指定额外搜索路径。
  • 在编译时通过链接器选项 -rpath 将路径嵌入可执行文件。

Windows

  • 将库文件放在可执行文件所在目录。
  • 将库文件所在目录添加到 PATH 环境变量中。
  • 使用 SetDllDirectory API 等更复杂的方法。

动态库的符号解析与性能考量

在Linux上,默认情况下,动态库中的所有全局符号都可能被其他模块看到。这可能导致意外的符号冲突,并且所有函数调用(即使是库内部的调用)都可能通过一个全局偏移表(GOT)和过程链接表(PLT)进行间接跳转,带来性能开销。

为了优化,GCC提供了可见性(Visibility)属性。建议将动态库中需要对外公开的接口标记为default可见性,而将内部实现细节标记为hidden可见性。这可以:

  1. 减少导出的符号,避免冲突。
  2. 让编译器优化库内部的函数调用,使用更高效的相对调用指令。
  3. 加快动态链接速度,因为需要处理的符号更少。

代码示例:跨平台的导出/导入声明

// mylib_export.h
#ifdef _WIN32
    #ifdef MYLIB_BUILDING_DLL
        #define MYLIB_API __declspec(dllexport)
    #else
        #define MYLIB_API __declspec(dllimport)
    #endif
#elif defined(__GNUC__)
    #ifdef MYLIB_BUILDING_DLL
        #define MYLIB_API __attribute__ ((visibility ("default")))
    #else
        #define MYLIB_API
    #endif
#else
    #define MYLIB_API
#endif

// helper.h
#include "mylib_export.h"
MYLIB_API void helper();

// helper.cpp (编译动态库时定义 MYLIB_BUILDING_DLL)
#include "helper.h"
MYLIB_API void helper() { /* ... */ }

在Windows上,符号与它所在的DLL是明确关联的,这有助于在出错时定位问题。


上一节我们了解了库的机制。本节中我们来看看一个与链接器密切相关的核心C++规则:单一定义规则,以及违反它可能导致的棘手问题。

C++链接器与单一定义规则:3:单一定义规则(ODR)及其隐患

单一定义规则是C++的一项基本规则,它要求任何可定义的项目(如非内联函数、非模板变量、类等)在整个程序中必须有且只有一个定义。

ODR基本规则

规则简述: 如果一个可定义项目D在多个翻译单元(即源文件)中有定义,并且D是一个非内联、非模板的函数或变量,或者这些定义不满足某些要求(如完全相同的标记序列),那么程序是“病式的”(ill-formed)。

关键问题: 标准指出,对于这种违规,编译器不一定需要提供诊断信息。这意味着你的程序可能通过了编译和链接,但行为是未定义的,这为调试带来了巨大困难。

简单的ODR违规示例

考虑以下两个文件:

main.cpp

void f() { std::cout << "f in main\n"; }
int main() {
    f();
    helper();
}

helper.cpp

void f() { std::cout << "f in helper\n"; } // 违反ODR!
void helper() { f(); }

当我们尝试将这两个目标文件静态链接成一个可执行文件时,链接器会检测到两个同名的全局函数f,并报错“多重定义”。这是一个好情况,因为问题在链接时就被发现了。

ODR与动态库的复杂情况

如果将helper.cpp编译成一个动态库,情况就不同了。

  1. 默认情况(无可见性控制): 程序启动时,加载器先加载主程序,将f加入全局符号表。然后加载动态库,发现它也有一个f。此时,加载器可能直接忽略动态库中的f,而让动态库内的helper()调用主程序中的f。这可能导致出乎意料的行为。
  2. 使用隐藏可见性: 如果动态库中的f被标记为hidden,那么动态库内的调用会找到自己的f。但这样,程序中就存在两个不同的f定义,构成了ODR违规,程序行为仍然是未定义的。

ODR与函数模板

函数模板通常定义在头文件中,在多个源文件中实例化。如果所有实例化都源于同一个模板定义,这是合法的。链接器会从多个相同的实例化体中任意选择一个。

危险在于: 如果由于头文件包含错误、条件编译等原因,导致同一个模板在不同翻译单元中用不同的方式实例化(即ODR违规),链接器通常不会报错,它只会默默地选择其中一个实例化体。程序的行为将取决于链接顺序,这可能导致难以重现的bug。

代码示例:不同的模板实例化

// 文件1:基于某个头文件,T被推导为int
template<typename T>
void process(T val) { /* 实现A */ }

// 文件2:基于另一个版本的头文件,T同样被推导为int,但实现不同
template<typename T>
void process(T val) { /* 实现B */ }
// 链接器可能选择实现A或B,行为不确定。

内联优化带来的迷惑

编译器优化(如内联)会使ODR问题更隐蔽。考虑通过函数指针调用一个函数模板的情况。在没有优化时,调用会通过指针进行。开启优化后,编译器可能直接内联它看到的那个模板实例化体,导致实际调用的函数与通过指针地址判断的结果不一致,给调试增加障碍。

安全警告:符号劫持

动态库在运行时解析符号的特性,虽然提供了灵活性(如运行时插件、调试包装),但也带来了安全风险。

  • 替换库文件: 攻击者可以用恶意库替换正常的动态库。
  • 环境变量劫持: 通过设置LD_LIBRARY_PATH(Linux)或PATH(Windows),可以优先加载攻击者放置的库。
  • LD_PRELOAD(Linux): 这个环境变量指定的库会在其他库之前加载,并可以“劫持”任何函数调用。它可以用于性能分析、调试,但也可能被恶意利用。

示例:一个简单的劫持库

// hijack.cpp
#include <stdio.h>
#include <dlfcn.h> // 用于dlsym

// 原始helper函数的签名
typedef void (*helper_func)();

void helper() {
    printf("Hijacked!\n");
    // 可选:获取并调用原始的helper函数
    static helper_func orig_helper = (helper_func)dlsym(RTLD_NEXT, "helper");
    if (orig_helper) orig_helper();
}

使用命令编译并劫持:LD_PRELOAD=./libhijack.so ./myprogram


总结

本节课中我们一起学习了C++程序构建中链接器的核心作用。

  1. 基础流程: 编译器将源代码翻译成目标文件,链接器则负责合并多个目标文件和库,解析符号引用,生成可执行文件。现代系统使用相对地址,支持地址随机化。
  2. 库管理: 静态库在链接时将其代码复制到最终程序中;动态库在运行时加载,实现代码共享。使用动态库时需要注意符号可见性和库文件查找路径。
  3. 单一定义规则(ODR): 这是C++的重要规则,违反ODR可能导致未定义行为。在静态链接中,链接器通常能发现多重定义错误。但在使用动态库或模板时,ODR违规可能悄无声息地通过编译和链接,导致运行时出现难以调试的诡异问题。
  4. 安全与实践: 了解动态链接机制有助于编写更安全的代码(如限制符号可见性)和进行高级调试(如使用包装函数)。同时,也要意识到运行时符号解析可能带来的安全风险。

理解链接器和ODR,对于构建大型、健壮的C++项目至关重要。

029:Alistair Fisher 演讲内容整理

在本教程中,我们将学习C++中不可变数据结构的概念。我们将探讨值语义与引用语义的区别,分析可变性带来的问题,并了解来自函数式编程世界的解决方案——持久化向量。本教程旨在让初学者理解这些核心概念及其在现代C++开发中的潜在应用。

值语义与引用语义

上一节我们概述了本教程的主题。本节中,我们来看看编程中两个核心概念:值语义和引用语义。

根据Dave Abrahams在CppCon 2022演讲中的定义,值语义基于两个属性:

  1. 写入一个变量不会影响其他变量。
  2. 一个变量不会被其他变量的写入所影响。

简单来说,如果一个变量像整数(int)一样工作——独立且不受外界影响——那么它就具有值语义。

以下是两种语义的代码示例对比:

// 值语义示例:按值传递
int increment(int i) {
    return ++i; // 操作局部副本
}

// 引用语义示例:按引用传递
int increment(int& i) {
    return ++i; // 操作原始数据,可能产生远程影响
}

值语义的代码更易于理解和推理,因为其影响是局部的。引用语义则可能导致“远程作用”,即修改一处代码可能意外影响程序的其他部分,增加了理解和调试的复杂度。

引用语义还会引入其他问题:

  • 生命周期问题:函数可能接收到无效的(如空悬的)引用。
  • 别名问题:多个引用指向同一数据,修改时可能产生意想不到的交互。
  • 竞态条件:在多线程环境中,共享的可变数据极易导致数据竞争。

以下是一个由引用语义引发的实际生产问题的简化示例:

// 一个看似无害的函数
int count_evens(const std::vector<int>& vec) {
    int count = 0;
    for (auto it = vec.begin(); it != vec.end(); ++it) { // 迭代器可能失效
        if (*it % 2 == 0) ++count;
    }
    return count;
}

// 另一个线程可能同时执行
void another_thread(std::vector<int>& shared_vec) {
    shared_vec.push_back(42); // 导致 vec 重新分配,使迭代器失效
}

count_evensanother_thread并发操作同一个向量时,就会发生迭代器失效,导致程序崩溃。这种竞态条件在单元测试中难以发现。

一个直观的解决方案是使用值语义,即按值传递向量:

int count_evens(std::vector<int> vec) { // 按值传递,获得私有副本
    int count = 0;
    for (int value : vec) { // 安全,操作的是本地副本
        if (value % 2 == 0) ++count;
    }
    return count;
}

这解决了竞态条件和别名问题,因为每个函数都拥有自己独立的数据副本。然而,对于大型对象(如巨大的std::vector),频繁复制会带来巨大的性能开销,这违背了使用C++追求效率的初衷。

因此,我们面临一个困境:引用语义易出错,而纯粹的值语义又可能性能低下。我们需要寻找一种兼具安全性和效率的解决方案。

不可变性与常量

上一节我们看到了值语义与引用语义的权衡。本节中,我们从不可变性的角度来重新审视这个问题。

在C++中,我们通常使用const来定义常量。一个const对象在其生命周期内不能被修改。如果我们创建一个const std::vector,并只通过const引用来传递它,那么从效果上看,这些引用就具有了值语义:它们既不影响其他变量,也不受其他变量影响。

我们可以更进一步,定义一个删除了所有非const操作的const_vector类:

class const_vector {
    // 删除所有非const成员函数,如 push_back, operator[], (非const版本)等
    // 只保留 const 成员函数,如 size(), begin() const, end() const
public:
    // 无法修改内容
    // 只能读取
};

这个类本质上是不可变的。然而,一个真正有用的数据结构需要支持“更新”操作,只是方式不同。在函数式编程中,“更新”一个不可变结构意味着创建一个包含新元素的新结构,而原结构保持不变。

例如,一个不可变向量的push_back操作:

const_vector new_vec = old_vec.push_back(new_value); // old_vec 保持不变

这种操作的朴素实现需要复制整个原向量,时间复杂度为O(N),对于大型数据集来说代价太高。

函数式语言的启示:结构共享

上一节我们遇到了不可变数据结构性能瓶颈的问题。本节中,我们来看看函数式语言(如OCaml、Haskell)是如何高效处理这个问题的。

在这些语言中,所有数据默认都是不可变且具有值语义的。它们采用一种称为结构共享的优化技术。当从一个现有列表创建新列表(例如在头部添加元素)时,新列表可以直接复用原列表的尾部,而不是复制它。

原列表 A: [1, 2, 3]
新列表 B: 0 :: A (在头部添加0)
内存布局:
B -> [0] -> 指向 A 的头部
A -> [1] -> [2] -> [3]

这样,push_front(头部添加)操作的时间复杂度是O(1),空间开销也很小。然而,标准的单向链表有其固有缺点:元素访问是O(N)的,并且缓存局部性差,在性能上无法与C++的std::vector竞争。

因此,我们需要一种既能利用结构共享进行高效“更新”,又能提供接近数组性能的不可变数据结构。

持久化向量:一种折衷方案

上一节我们看到链表结构无法满足性能需求。本节中,我们介绍一种更先进的数据结构——持久化向量,它在函数式社区(如Clojure、Scala)中广泛应用,试图在不可变性和性能间取得平衡。

持久化向量本质上是一个前缀树。假设分支因子为4,一个存储7个元素的持久化向量可能看起来像这样:

        [根数组]
     /     |     \
 [子数组] [子数组] ...
  / | \ \
 0  1  2  3 ... (叶节点数组,存储实际元素)

查找元素:要找到第i个元素,将索引i转换为基数为分支因子(例如4)的数,然后使用每一位数字作为路径,逐层向下遍历树。查找时间复杂度为O(log₄ N)。由于分支因子较大(通常为32),这个对数很小,对于极大数量级(如万亿)的元素,深度也只有6-8层,因此在实际中常被视为近似O(1)

更新元素(添加):当需要添加新元素时,我们并不复制整棵树。相反,我们只复制从根节点到新元素插入路径上的节点,而共享所有其他未受影响的节点。

更新前(蓝色)和更新后(红色)共享未改变的节点(绿色)。
只有路径上的节点被复制和修改。

这种“路径复制”策略使得push_back操作的时间复杂度也是O(log₃₂ N),同样可以视为近似O(1),并且空间开销与树高成正比,远小于完整复制。

虽然持久化向量在绝对性能上可能仍无法击败高度优化的、可变的std::vector(后者具有完美的缓存局部性和真正的O(1)摊销访问),但它为不可变数据结构提供了巨大的性能提升。它使得在需要值语义(避免竞态条件、简化推理)的场景下,使用功能丰富、支持高效“更新”的向量结构成为可能。

总结与展望

本节课中,我们一起学习了C++中不可变数据结构的相关知识。

我们首先分析了在多线程时代,传统的引用语义带来的竞态条件、别名和生命周期等问题。值语义是解决这些问题的理想方案,但对于重型对象,直接复制会导致性能不可接受。

接着,我们探讨了通过const实现不可变性的局限,以及朴素不可变数据结构(如复制整个向量)的性能缺陷。

然后,我们借鉴函数式编程的经验,了解了结构共享这一关键优化技术。以链表为例,我们看到其更新效率高但访问效率低。

最后,我们介绍了持久化向量这一折衷方案。它利用树形结构和路径复制,在保持不可变性和值语义的同时,提供了对数时间复杂度的查找和更新操作。通过选择较大的分支因子(如32),这些操作在实际应用中接近常数时间,从而在安全性和性能之间取得了有价值的平衡。

核心要点总结:

  • 安全与性能的权衡:在并发编程中,值语义更安全,但需警惕复制开销。
  • 不可变性的价值:不可变数据天然避免了一类并发错误,并简化了程序推理。
  • 借鉴他山之石:函数式语言在不可变数据结构的性能优化上已有成熟方案(如持久化向量)。
  • 实际考量:虽然持久化向量等结构性能可观,但在极致性能要求的场景,可变的std::vector可能仍是首选。开发者需要根据具体需求(安全性、并发性、性能)来选择合适的数据模型。

未来的C++可能会从函数式编程中吸收更多思想,进一步发展原生支持高效值语义和不可变数据结构的工具和库,以更好地应对现代并发编程的挑战。

030:代码红色——穿越安全编程雷区

概述

在本教程中,我们将跟随Gregg的演讲,一起探讨安全编程中的常见陷阱和最佳实践。我们将了解安全漏洞的历史演变、现代威胁的复杂性,以及如何在软件开发中建立“安全第一”的思维模式。课程将涵盖从基础概念到高级策略的多个方面,旨在帮助初学者理解安全的重要性并掌握基本的防护方法。

章节 1:引言与背景

大家好。在演讲过程中,我会向你们提问,所以请准备好举手回答问题。

请举起手。这应该是每个人。很好,我看不到后面的人。反正后面人也不多。顺便说一句,这个灯很刺眼。所以如果我看向别处,不是因为我想避开你们,只是因为我如果看灯太久就什么都看不见了。

我的名字是Greg。我的姓氏你们可以随意发音。这取决于你们来自哪里。我妻子可以教你们如何正确发音,我也可以。

演讲的题目显然是“代码红色”,即穿越安全编程错误的雷区。这次演讲中不会有任何代码示例或演示。我特意保持了它的通用性。我总是有点恼火,这些会议上对安全不够重视。我被特别要求创建一个演讲,引导大家了解安全。

想象一下,你醒来,或者没醒,除非你喝醉了什么的,离盛大的派对还有半小时。你接到老板的电话,说发生了安全漏洞,需要修复。

这显然是一个极端的例子。但这种电话总是在错误的时间打来。你在半夜接到电话,或者更糟的是,它出现在电视上,你必须向一个随机记者解释为什么会出现大量的投诉,或者公司的秘密数据或客户数据被泄露了。这很有趣。我个人没有经历过这些,但我见过太多次了。我想通过展示当这种情况发生时会发生什么来开始这次演讲。

当客户数据丢失时,相信我,人们会发疯的。

让我们先倒带。我将给你们做一个介绍,关于我为什么在这里以及我将要谈论什么,还有一点关于我自己的信息。

如果你因为这次演讲的启发而打算研究安全,请先获得许可。我必须这么说。不要破坏你无权访问的随机东西。这不好玩。

我特意加上了另一条。观点是我个人的。我为安全公司、安全供应商工作。但我绝不以任何方式将这次演讲或任何内容与他们联系起来。只是为了法律原因必须澄清。

我今年大约45、46岁。我从8岁开始接触计算机编程,始于一种叫做Atari 65 XE的机器。你们有些人可能知道这是什么。有些人可能觉得它像博物馆里的东西。这是80年代末90年代初的8位计算机,当你打开它时,你可以直接编程,并将程序保存到磁带录音机或磁盘上,看看会发生什么。可以说,它对黑客很友好。作为一个有电子学经验的人,这对我非常有吸引力。所以当我得到一台电脑作为生日礼物时,我很快就对BASIC感到厌倦,并开始研究汇编语言。6502是一个非常友好的处理器,我稍后会提到它。谁用过6502?这里有人用过吗?我不想引发争论。

我本应该让这些内容逐一出现。抱歉。

那么,我为什么要做这个演讲?在某个时候,我放弃了Atari,给自己买了一台PC。我有朋友在所谓的“演示场景”里。我不知道你们是否知道演示场景是什么,但基本上,至少在我年轻时的90年代,那是一群人聚在一起在计算机上搞创作,通常是图形方面的,只是为了将硬件推向极限。现在仍然有人这么做。我自己从未写过演示程序。我写过3D引擎和486汇编代码。那是另一种类型的演讲。但这需要你非常了解底层硬件和汇编语言才能优化。

作为一种乐趣和游戏,我们过去常常为软件编写注册机。同样,这发生在我以前居住的国家。在那个年代,这实际上是非法的。我来自一个贫穷的家庭,我们每个月会得到一本PC杂志,里面会有一张CD,上面有很多共享软件,我因为没钱买而不能用。但我有很多时间,也有很多技能,而且妈妈会给我做饭。所以一切都好。我那时大概15、16岁。

有一天,演示场景的一个人给我看了一个叫做SoftICE的软件。我不知道人们是否知道SoftICE是什么。它代表“在线仿真器”。基本上,当你在测试硬件时,你想在非常低的级别调试你的处理器,你取出你的CPU或其他东西,然后插入一个由另一台计算机支持的设备,这允许你仿真或逐步仿真CPU或其他硬件。如今,这要花哨得多,因为仿真器实际上内置在微控制器里了。这没关系。有一个软件,或者曾经有一个,叫做SoftICE。它基本上允许你在任何点停止Windows并进行调试。这对于设备驱动程序非常有用。如果你想调试你的网卡或其他设备驱动程序,并且想逐步调试它,或者想在操作系统完全启动前加载的驱动程序,比如显卡驱动程序,你想能够逐步调试其中的问题,SoftICE是一个允许你这样做的软件。我与他们无关。我当时使用的是正确版本。那是35年前,或者30年前的事了。

我们需要在计算机里插另一张卡,叫做Hercules卡。我不知道是否有人记得Hercules卡是什么。它是一种不同的显示器,是黑白的图形卡。你会在那个显示器上看到调试控制台,有点像之前演讲中的GDB,但更简化。你可以逐步执行所有操作。所以即使软件试图规避调试,你实际上只是停止了整个系统。整个操作系统都会停止,这允许你做有趣的事情,破解那些本来不容易破解的软件。

我为什么要告诉你们这些?因为我对安全的迷恋可以追溯到我15岁的时候。我至今仍然对安全非常热衷。

如今,显然我们很容易做到。我们可以直接拿一个软件,使用像Ghidra这样的软件进行反汇编,这些软件允许非常容易的反编译和内省,所以你可以实际看到发生了什么。

我这里有个笔记写着“不要过多谈论自己”。但我刚刚就说了。抱歉。

我为什么要做这个演讲?就像我说的,这是我的历史,但我开始作为一名软件工程师工作,我的背景重点始终是安全。当我开发功能或其他东西时,人们不认真对待安全问题,这总是让我感到痛苦。因为在安全领域,有一种说法是,你可以失误很多次,但黑客或入侵者只需要幸运一次。所以你必须时刻保持警惕,不能只是说“好吧,我们引入了这个bug,但我们会在下一个版本中修复它。谁在乎1.5亿用户数据被窃取或泄露了?”这显然对任何人都不好。

我不会给出任何代码示例,就像我说的,因为你们已经看过很多了。我想讲得更宽泛一些。但如果你们在整个过程中有任何问题,如果我说了什么,你们感兴趣,就喊出来,我会让你们知道答案。我可能会说“去谷歌一下”,因为所有这些事情,就像我刚才和Tim说的,顺便说一下,Tim就在前排。我总是觉得安全像是一个神秘的东西,这很烦人。其实不是。安全领域有很多本身很有趣的小东西。但我认为,如果你阅读并思考它,这个领域实际上相当容易接近。就像你在开发软件时一样。

我会给出一些我将要谈论的内容的链接。之后我会把幻灯片发到Discord上。所以你们不必截图或做其他事情。所有这些都可以在谷歌上搜索到。就像我刚才说的,我谷歌了所有这些,因为我不记得全部。

章节 2:安全漏洞的历史与演变

那么历史,对吧?这又要回到80年代。像病毒或漏洞这样的东西,在过去你运行DOS的PC上,人们……这对我来说很有趣。我不知道其他人怎么想,但病毒过去常常是人们作为挑战而做的事情。在DOS或Unix或OS/2或其他系统上,你找到了一种方法,当你的朋友放入磁盘时捉弄他们。它会加载第一个扇区,实际上会将一个程序或软件加载到内存中。屏幕上会出现一条消息,或者屏幕上的一切都会反转。这一切都是为了乐趣、兴奋,典型的工程挑战。

我实际上不得不谷歌这个。也许有更早的版本,但我一直记得的一个例子是有一个叫做“Brain”的病毒。我不知道是否有人记得那是什么。但显然它起源于1986年。它是一个IBM PC DOS病毒,会用病毒副本替换软盘上的引导扇区。整个目的只是为了展示可能性。这是我们可以捉弄人的方式,我们可以玩得开心。实际上,它是由巴基斯坦的两兄弟创建的。屏幕上的消息,我想,只会说“这只是一个演示。哈哈。如果你想让我们删除这个或帮助你,请告诉我们。”我不知道怎么操作,因为那是1986年。人们没有电子邮件,或者有些人有,但我不知道,我不知道它是怎么运作的。

所以,你知道,它曾经很有趣。它曾经很令人兴奋。它曾经只是工程师们的恶作剧。或者,你知道,这一切都很友好。

章节 3:现代安全威胁的严峻现实

随着我们进入现在,这就没那么有趣了。这是一个严肃的、价值数十亿美元的产业。它是可怕的。这里有一个家伙。他的名字在边上,如果你们想读的话。Maxim Victorovich Yakubets,我不知道他目前的下落。但他显然被FBI通缉。这是他在莫斯科某处跑来跑去,只是傻傻的,你知道,只是……警察在这个国家会用什么词来形容这种行为?反社会行为,对吧。当然。我确信以这家伙拥有的钱,他能做到这一点。但这只是众多例子中的一个。当然,你知道,俄罗斯等国的腐败如此猖獗,以至于他就这样逃脱了。

这段视频在国家犯罪局的YouTube频道上有一个更长的版本,如果你们想看的话。但为什么要看呢?不过我想给他们点赞,因为我几年前看过那个视频,我记得。我需要提一下。但重点是,如今,如果你有钱并且想作恶,你实际上可以付钱让别人去攻击某人,而你甚至不需要任何技能。这家伙实际上是一个出色的软件工程师,但结果证明他脑子里有点古怪,决定用它来做坏事,而不是做好事。他实际上出生在乌克兰,但显然是俄罗斯居民。同样,我不知道他目前的下落。这些人犯罪后能逍遥法外很长时间,因为他们生活在无法被绳之以法的国家,或者他们与政府关系密切。我不想深入讨论这个,但你知道,如今这是一个非常严重的事情,可悲的是,这就是为什么我们必须关心这个,因为再次强调,那些将目标对准你的系统或你雇主系统的人,大多数时候不会为了好玩而做这些。

章节 4:加密货币与安全

只是为了进一步说明,我不太确定这在房间里或YouTube上的人看来会怎样,但我必须说出来,否则我会感觉不好,因为这就像房间里的大象——加密货币,对吧?这是一项严肃的业务,因为人们可以通过加密货币支付和转移资金来逃脱惩罚。本质上就是这样。就像交易市场一样,公司价值根据现实生活中的各种情况增值或贬值。可悲的是,在我看来,加密货币在很大程度上是由许多 shady 的东西支撑起来的。黑客攻击就是其中之一。如果你被黑客攻击,有人试图勒索你,他们会要求你用比特币或其他加密货币支付,而不会要求支票或代金券,对吧?

有什么问题吗?我的意思是,我不是想让大家扫兴。只是必须提一下。

章节 5:案例分析:Equifax 数据泄露

我将给你们两个例子,实际上我放在了演讲描述中。每个人都应该知道它们,如果你在软件行业,除非你与世隔绝,否则不可能不知道。它们将作为稍后的例子。

在讲了这些可怕的事情之后,我只想说,安全仍然很有趣。作为一名工程师,你可以通过玩安全、测试东西获得很多乐趣。再次强调,我不会带来任何代码示例,因为我认为它们很容易谷歌到。实际上,我笔记里没有这个。但如果你去……我试图为这次演讲准备很多东西。没有一种简单的方法可以谷歌一个CVE然后说“好吧,给我一个会导致这个的代码示例”,因为太多了。如果你试图查看开源产品中安全漏洞的开源示例,显然你可以查看Git日志,因为现在通常在GitHub上。那将是一段相当复杂的代码,最终归结为几行,但它们会分散在各处。这不是一个简单的演示。这是我的观点。所以我想饶过你们。但这仍然很有趣。无论如何,给自己一个挑战,查找一个CVE和一个开源产品,看看发生了什么,因为你会惊讶于它是多么简单,却能造成多大的破坏。

谁知道Equifax?有人听说过或记得吗?我无法凭记忆说出具体日期,所以我做了一些笔记并写在这里。希望它们没错。同样,有数百万个数据来源。但这个很有趣,因为这个事件发生在2017年3月。有一个Apache Struts Java Web框架的漏洞,我稍后会详细讨论,同样没有代码示例。

这是一家美国的大型银行。他们忽略了CERT的建议,没有修补它,不管出于什么原因。也许他们修补了。也许IT安全人员不在,也许他们没有相应的程序。谁知道呢,我相信某个地方有报告详细说明了情况。再次强调,如果你经营一家大企业或为大企业工作,我敦促你查看这些事情,并把这些事情提给你的老板,看看是否能改善你的安全状况,因为这非常重要。这是一个很好的例子。

那么,发生了什么?基本上,在Apache Struts中发现了漏洞。三天后,有人决定扫描整个网络,看看谁在运行Apache Struts,以及他们的服务上是否有那个漏洞。这些人显然有。所以有人发现了。有人设法利用了它。如果你不及时修补,这就是你可能成为目标的方式之一。黑客设法获得了三台服务器的登录凭证。技术细节会有,如果我想深入探讨,这本身就是一个单独的演讲。他们获得了一个立足点,可以这么说。他们能够浏览内部网络。这一切都是通过一个门户服务完成的,如果你想投诉,基本上就是向你的银行投诉,显然他们有这个服务。我不知道,我从未为银行工作过。所以我不知道,我不知道正常的方式是什么。但这里关键的学习点是,仅仅……实际上,我稍后会再谈另一点。

第二个问题。内部流量是加密的,但用于验证加密证书有效性的证书过期了。所以对它们的验证基本上被禁用了。我不知道具体发生了什么。但重点是,他们能够对自己进行中间人攻击,解密更多流量或拦截更多流量,发现更多内部登录信息,或者侵入更多服务器。结果,他们从中获取了大量数据。

实际上,我没有在前一张幻灯片上写。是的,我把这些幻灯片分得有点乱。他们能够获取1.4亿用户数据、用户账户信息。这显然让他们付出了沉重的代价,每人高达2万美元,所以你可以想象。这是一次昂贵的经历。可能被某些保险公司低估了。所以,你知道,他们不必付钱。银行通常能逃脱这些事。或者也许美国纳税人为之买单了。谁知道呢。但重点是,这不仅仅是一次入侵。他们忽略且未修补的初始漏洞只是进入整个组织的一个小立足点。他们还有其他巨大的问题,安全程序上的失误。谁知道呢。再次强调,银行很复杂。数据丢失了,你知道,我确信造成了大量的金钱和时间损失。这是一个例子,说明人们常常忘记,即使你在外部有防火墙或其他东西,我稍后会再谈这一点。如果你允许内部事物不受任何限制地流动,仅仅因为有一个裂缝,你可能仍然会有大问题。再次回到之前的说法,他们只需要幸运一次。而他们这次就做到了。

章节 6:案例分析:SolarWinds 供应链攻击

我发现这很有趣,以一种技术性的最佳方式来说,因为最初的漏洞实际上并不在验证输入的代码中。他们正确地完成了所有的输入验证和清理。但如果出了问题,如果他们发现某些东西不符合要求,捕获异常的代码(因为这是Java)是有漏洞的。这恰恰表明,你必须非常认真地对待如何处理异常,而不仅仅是检查和清理输入,你会惊讶于有多少网络服务和其他软件不这样做。在这种情况下,这是一个相当复杂的用于大文件上传的HTTP标准,我自己也不完全理解。它相当复杂。基本上有很多事情在进行。有很多机制允许你分块发送文件。

重点是,在处理数据时要小心,如果数据不匹配该怎么办,对吧?通常的安全指南是,除非服务器处于调试模式(如开发模式),否则就禁用它。要么不返回任何东西,让TCP连接超时,要么就说……我记不清HTTP返回代码了,大概是403安全或未授权之类的,4xx系列中的一个,不是404,因为那是未找到,但你可以……这是另一件事。当出现问题时,你不必说实话,有时最好只是说“不行”,就这样。

另一个例子,可以说是在不同光谱上的,是SolarWinds攻击。我不知道是否有人记得。这发生得更近,在2019年末9月。一个为SolarWinds工作的第三方供应商向他们提供了一些……具体是什么不重要,因为这是闭源软件。所以,你知道,我们不会找到代码示例中实际出了什么问题。但这是所谓的供应链攻击。有人植入了一段恶意代码。SolarWinds可能改进了。我可以想象,这些是为政府机构工作的大型组织,他们在可追溯性和代码批准方面有标准,一切都必须经过多个流程,但不知何故它溜进去了。

实际上,我有点跳过了。在这之前,一家叫做CrowdStrike的公司,他们服务器上运行着所谓的Falcon传感器,不知何故他们设法发现了一个奇怪的活动,并将其标记为一个问题。这实际上相当聪明,你知道。因为这显然不是……不,这穿过了这么多层网。然而,软件能够发现一种异常行为。所以我认为这非常出色。

那么,基本上发生了什么?这个Orion平台上有一个后门,是由黑客创建的,能够访问并冒充受害组织内的用户,基本上可以随意窥探。我不会深入讨论例子。这本身就是一个演讲,关于如何在网络上检测异常行为,以及恶意行为者通常如何通过命令和控制中心等做到这一点。可以说,软件能够发现它,我认为这非常出色。

所以,他们显然……我确信他们能够追溯。但你如何改进呢?我认为几乎不可能保护自己免受这种攻击。

主要攻击目的显然是获得系统的多个入口点,并能够改变行为或窥探,或者只是在网络中的某个地方获得root shell。据信又是俄罗斯干的。我不知道为什么我总是挑俄罗斯的毛病,这次不是故意的。在安全领域,人们喜欢给敌对机构或敌对国家起有趣的名字。这次叫做Noble Bear,AKA APT29,在我的祖国人们会这么叫。我不知道他们是怎么发现的,但他们设法发现了。目标显然是在内部有眼线。

如果有人两年后看这个视频,他们会想“什么?”。但如今,每个人都必须谈论X。我不知道人们是否听说过最近发生了什么。但当我准备这次演讲时,显然这是在X发生之前。当我编辑这个并为今天做准备时,我想我必须提到,不仅仅是开源软件容易受到供应链攻击,闭源软件也一样。我没有任何例子,因为我无法证明或给你可靠的URL,所以这可能有点道听途说。但业内都知道,我该怎么说呢?如果你开发一个可能供应给大型行业(如银行业)的软件,或者你将控制供水或政府,可能会有一些感兴趣的团体为上述供应商工作,来自美国人喜欢称之为“三字母机构”或敌对的外国机构。所以,俄罗斯对外情报局(SVR)可能会在软件中添加东西。你可能永远发现不了,因为他们显然知道如何规避程序,就像X是加密库(不是压缩库)的一个好例子,说明如何规避并悄悄进行。一旦事情平息下来,我很乐意就此做一个演讲,因为这本身也很吸引人。

到目前为止没有问题。没有人无聊,没有人睡觉。很好。

章节 7:常见安全漏洞类型

所以,再次强调,这个列表本身就需要90分钟才能详细讲完,如果我要详细说明并给你们演示如何做这些事或展示代码示例的话。但凭记忆,这确实是前5或6件事。

  • 内存问题:是的,它们在C++中是可能的。正如你在之前的演讲和今天及昨天的许多其他演讲中看到的,C++在内存问题上并非无懈可击。很抱歉。坦率地说,没有语言是,因为只需要人为错误。
  • 缓冲区溢出:同样,在C++等语言中非常可能。
  • 资源泄漏:无论这意味着什么,这是一个广泛的统称。你基本上只是打印出你不应该的信息,或者记录你不应该的信息,或者你忘了放空指针检查,然后它逃逸了,不管是什么。这是一个统称。
  • 释放后使用:不幸的是,这是一个闭源应用程序或软件。这就是我所说的安全对人们来说有点晦涩难懂的意思,比如CVE-2019-0001。所以显然是2019年报告的第一个。这是一个……我要读一下这里实际写的内容,因为我记不清了,因为又是一个性感的名字。“在具有动态VLAN配置的MX服务设备上的畸形数据包可能触发不受控制的递归循环。”显然是由释放后使用引起的,代码可能是用C++写的。或者delete会是,但你知道,在CVE中他们不会说delete,只会说free。
  • 整数下溢和溢出:所以再次,我回到今天和其他C++演讲中看到的例子。但有时很容易只是将一个整数传递给一个期望字符的函数,由于某种原因,你关闭了编译器警告,这在现实生活中会发生。或者你基本上有一个从JSON或XML等获取的字符串,你没有检查整数类型,然后将其从字符串转换为整数的函数有bug,这一切就发生了。
  • 不安全的内存使用后:这实际上发生在一个开源项目中,在Ghostscript PDF解析中,信不信由你。这是现实世界中的一个例子。PDF是一种相当复杂的文件格式。或者说PostScript,技术上是一种编程语言,也许可以这么说,但我不确定,我不是专家。但PDF,你知道,另一个例子可能是解析HTML、Unicode字符串等,如今这些都是复杂的代码片段。在这种情况下,是试图引用一个错误的点。我认为Ghostscript是用C写的。所以,也许是用C++写的,谁知道呢,我不知道。
  • 格式化字符串漏洞:最后一个就像回到安全的历史早期,你知道,在过去,代码大多是用C写的,有人用了像sprintf这样的代码,或者他们试图打印你作为值传递给他们的字符串,他们期望的是不同长度的字符串,或者他们有一个256字节的静态数组,而你给了512字节,很容易覆盖你不应该覆盖的内存,通常是栈或另一个变量,导致奇怪的事情发生。如今,要使问题可被利用,通常必须在代码中找到多个问题,但人们确实会找。那些有很多时间、动机和资金支持的人会这么做,而且常常住在像俄罗斯这样的地方,他们显然没有别的事可做。再次强调,不是特别针对俄罗斯。你知道我的意思。

章节 8:如何避免安全漏洞

那么如何避免这些呢?再次,我会来回讨论这些事。但首先,SA是所谓的软件成分分析。我不知道人们是否知道那是什么。但那基本上意味着,你查看你的代码所依赖的东西。所以你使用什么库,使用什么第三方代码,使用什么系统API。仅仅知道这些事,有时把它们写下来实际上非常有帮助,尤其是当老板后来打电话告诉你发生了入侵,或者你必须分析你的代码,因为生产中出现了导致重大损失的问题时。

这实际上很有趣。我没有这个……我偏离了我要说的。但只是我想到的一个随机想法,人们常常在代码中发现漏洞,却不知道那是漏洞。基本上,软件随机为客户崩溃。从技术上讲,这是一个漏洞。有人可能利用了它,但客户只是误用了它,对吧?但这种事确实会发生。老实说,我见过不止一次,当人们说“哦,这只是用户错误”时,我笑了。我说“哦,老兄,你的代码里有漏洞,你只是不知道而已。”

另一个我多次提到的是渗透测试。我不知道人们是否知道渗透测试是什么,但用外行的话说,你找公司里的几个人或一个人(我过去做过很多次),找一个从未见过你的代码、不了解你的文化、不了解你的首席开发人员及其方式或怪癖的人,以全新的眼光看待代码,并提出所有问题,告诉你“好吧,你可以在这方面改进。这些事本可以做得更好,这太糟糕了”,不管怎样,最后给你一份报告。这些事通常需要几周时间,因为这个人毕竟是新手,他们需要被引导了解代码。但这非常非常有帮助。像我,我为客户做过几次渗透测试。但有些人以此为生。他们通常在安全方面比我聪明得多,见过更多事情。所以我强烈建议每个企业,如果你认真对待你的代码,并且你希望你的代码更安全,能够承受任何潜在的问题,无论是客户中断还是漏洞,就给自己找一个渗透测试员。这可能会在未来为你节省大量资金,而且如果人们愿意,也能从中学到很多东西,改善你的组织。

威胁建模是另一个。如果你供应给受监管的更严肃的企业,或者在你的特定市场或ISO标准或你必须遵守的任何规定下,可能需要你进行渗透测试和威胁建模。它基本上就是,你要坐下来,说“我认为如果发生这个,可能会造成破坏,这个可能会让我半夜被叫醒,我的老板可能会出现在电视上,最坏的情况,对吧?”为什么这有帮助?显然,你可以由此向下推导,说“好吧,嗯,你为此做了些什么。”渗透测试常常会提出你可能没有想到的威胁。

单元测试。Phil(可能指演讲者认识的人)如果我不提TDD会失望的。但在安全方面,实际上以自己的方式编写代码,并进行大量测试,非常非常关键。我不会说出我过去为之工作的人的名字,但我曾经受雇于一家公司,开发一个当时世界上几乎每个财富100强公司都使用的代码,用于他们的董事会会议,记录未来的讨论或其他什么,相当重要。如果数据泄露,你可以预测公司未来几年的价值。这家公司已经不存在了,所以我可以随意谈论他们。安全层加密简直糟透了。它是一个单独的文件,但我记得大约有20000行代码,用Objective C写的。全是静态函数。这是一团糟。我的工作是解开这个,把它变成许多类并结构化,并进行大量测试。这是一个例子,如果没有TDD我无法做到这一点,因为我必须将测试引入这团混乱中,然后解开混乱,并确保测试仍然通过。在这个过程中我发现了许多问题。这很有趣。

模糊测试是另一个我稍后会再谈到的。谁知道模糊测试是什么?有人不知道模糊测试是什么吗?好吧,朋友们,模糊测试简单来说就是模糊你的输入。所以你有一个开放的监听端口,比如SSH,你只是向它发送随机数据。这些数据看起来应该没问题,但你改变了内容。所以你在某个地方放1,其他地方放2,不管怎样,然后你看它是否崩溃。因为计算机很便宜,你可以在周末运行服务。你可以以随机方式循环地向它扔东西,看看什么有效,什么无效,或者什么会破坏它。你知道我的意思。静态代码分析是另一个。我对人们误用、不使用或忽略静态代码分析的方式有很大的意见。可悲的是,因为这是计算机在分析你的代码。它可能会遗漏一些东西。静态代码分析在计算上相当昂贵。如果你想想它必须生成的所有依赖项和可能性的树,这是一个它必须生成的庞大结构。显然,计算机随着时间的推移变得更好。但它可能会产生很多误报。这实际上导致人们忽略它的输出。我认为你不应该。我有很多轶事。我将在接下来的几张幻灯片中回到它们,但我有一些关于公司的轶事,在那里我被告知忽略静态分析,然后我会接到电话,或者只是遇到过去一起工作过的人,他们会说“哦,是的,你留下的评论,你知道,像这样的GJ。将来看看这个。是的,我们因为那导致问题而中断了一周。”我会说“耶”。但有时,有时只是业务程序如此。所以你必须进行成本分析,看看是否值得重写一段代码。

运行时内存和线程分析。有人用过Valgrind吗?或者Valgrind,不管正确的发音是什么。有人不知道它是什么吗?基本上,如果你使用Linux,或者我不知道它是否在Windows上工作。它是一个代码片段,我认为它本质上模拟你的CPU,并在API调用中放入许多钩子,所以一切运行得更慢。但它能够发现你的代码中的许多问题,可能是像内存问题这样的,你的编译器、静态分析工具等可能会遗漏的问题。它是无价的。我不知道现在是否还这样,但在我还是个小伙子的时候,当我最初使用它时,计算机更慢,像缓存未命中这样的事情非常重要。所以它会告诉你,实际上,你使用这种数据结构的方式很慢。将会有很多L3缓存未命中。再次,我不会深入讨论,我将在几张幻灯片后有一张关于CPU的幻灯片,因为我也有话要说,这很有趣。但Valgrind,如果有人没用过,我强烈建议你试试。运行Valgrind会显示你代码中的许多问题,相信我。根据我的经验,这从不出错。

回到我工作的公司,我留下了很多评论,静态分析和Valgrind将是这些工具,实际上指出了许多潜在问题。突然,由于各种原因,我不被允许修复所有问题。我能做的只是为未来的员工和管理层留下一些代码注释,说“嘿,是我。我曾在这里工作过。”

红队演习。这是针对大企业的。有人知道红队蓝队是什么吗?基本上,对于那些不知道的人,这通常不会发生在小企业。但如果你是一家高风险企业,即使你是一个小团队,也值得让某人尝试侵入你的代码或做一些有趣的事,当然是在老板知情的情况下,这样你知道存在一些风险,是别人造成的。通常当你作为技术人员在办公桌前工作时,他们会看着你出汗,而你试图找出问题所在。这很有趣。如果你经营一个大型组织,我强烈建议你研究一下红队演习。感谢为此付出的人,因为再次,你可能会对你的业务了解很多。

章节 9:模糊测试实践

那么模糊测试。就是我提到的那家我留下代码注释的组织。再次,我不会提名字。但当我加入时,他们实际上……这家公司正在研究DVB编码。所以基本上是处理最终出现在机顶盒上的视频信号。机顶盒,我不知道。人们现在不像以前那样多用机顶盒了,因为有流媒体等。所以这可能又是一个老话题。但机顶盒是运行在你客厅或电视下面(现在通常在电视里面)的计算机。就安全威胁分析建模而言,你的家是一个敌对环境。为什么?因为你可以打开它并摆弄它。所以,你知道,像“敌对环境”这样的术语实际上很有趣,因为它听起来很可怕,但只是意味着有人可以搞乱东西。在后端方面,这很有趣。它的架构相当出色。做得很好。

他们有软件运行,在Linux服务器上运行的小软件,因为它可以扩展。中间是一个通用的……那个软件叫什么来着?我们姑且称之为路由器。它是一个应该与所有组件通信的软件。如果其中一个死了,或者我们需要生成另一个,它会做这些事。为了与所有组件通信,它使用二进制协议,因为有很多数据交换,很多密钥等。基本上更高效。它有一个端口开放。端口号,再次,不重要。但重点是那个端口是开放的,你可以摆弄它。这是因为它可以有一个网络接口服务器,不管怎样。这没关系。

当我加入时,我最初在做一些其他事情。实际上,我当时是作为SQL专家被引进的。我过去做过很多PostgreSQL合同。但因为我的简历上有安全背景,他们觉得“Gregs,对吧?”我在摆弄东西。有一天我决定做这个。有人不知道这是什么吗?nc(netcat)基本上就像……它接收标准输入管道上的输入,在Unix系统上,在那个IP上打开端口,并将该数据作为二进制数据发送到那个特定端口。你可以拦截它的输出。这是一个相当有趣的工具。当然,如果你输入垃圾,它就会把垃圾发送到网络。如果你循环这样做,有趣的事情就会发生。这是当人们问我模糊测试是什么时,我总是给出的例子,因为这是最基本的模糊测试。这实际上发现了许多解析结构时的问题。代码写得很好。有很多检查等。你总会遗漏一些东西,就是这样。几分钟的模糊测试,我们就发现了许多可能导致问题的漏洞。为什么这很重要?因为管理层可能会问我“我们为什么要关心这个?这是一个服务器机架。它将放在客户的数据中心里。没有人会摆弄这个。是的,他们会有网络服务,不管怎样,但如果有人把错误的以太网电缆插到错误的插座,另一个软件使用端口,比如说66000,并向我们的服务发送一些随机数据,导致它宕机,然后你就接到了第一张幻灯片中的电话,对吧?”这又回到了原点,你知道,这整个安全事物就像一个快乐的大家庭。所以我想提一下,如果你想找点乐子,并且有许可,如果你的组织因为某些原因仍在使用二进制协议,就用nc玩玩吧。如今,实际上有模糊测试框架,因为显然模糊测试不仅适用于二进制协议。这只是一个粗糙的例子。但你可以拿一些XML、HTML等,然后对模糊测试框架说“在这些字段上找点乐子”,你可以有一些正则表达式等。有很多人写了非常酷的模糊测试框架。事实上,如果有人看过,有很多HTML渲染问题是通过模糊测试发现的。所以,模糊测试。我强烈推荐。它很有趣。

章节 10:现代威胁与攻击面

那么,现代威胁。我不知道为什么我看着那里。我能在这里看到这个。不管怎样,灯光。再次,这是那些如果我不提就会被指责的幻灯片之一。我将提到我之前提到的公司CrowdStrike。我在博客上找到了这个。我认为这看起来可信。

他们基本上说,大约55%的已识别内部威胁事件涉及未经授权使用或试图使用特权升级漏洞。所以有人带来了他们的iPod或Android手机或iPhone,不管是什么,插入了工作计算机,然后一段软件被执行了,对吧?45%的内部威胁事件是由无意中引入风险的人造成的。我不知道这里有什么区别。哦,抱歉,第一个是55%的人实际上主动尝试了。所以他们知道自己在做什么。45%的事件是那些插入iPod等的人。这,你知道,不好玩,但这表明,在一个大型组织中,我们常常是最薄弱的环节。我打算点名,但你知道,这就是人们发现你为谁工作、你使用什么、他们如何可能利用你的组织或获得立足点,然后在内部发现其他问题,突然之间,这就成了现实。我接受过很多钓鱼培训。当他们试图钓我时,我总是暗笑,因为你知道,当你知道你将被骗一次时,你就会非常认真。但我们必须注意这一点,尤其是如果你有非技术人员,他们不容易发现。你知道,从本性上讲,我们都想乐于助人。尤其是当你做生意时,你想帮助你的客户。但有时这些事情最终会出错,你必须在内部建立层次来防止这种情况,对吧?我们必须有另一层保护,比如银行里的Bob不能仅仅因为有人打电话告诉他们丢了手机,急需帮助,就从某人的账户里取钱。或者,我不知道我是否该谈政治,但NHS(英国国家医疗服务体系)在凌晨3点打电话给你,告诉你有人威胁他们,需要5000英镑。你必须注意这些。

开源情报。我喜欢这个,因为它基本上就是谷歌搜索,只是在互联网上找东西。你知道,如今我们能找到多少信息是相当可怕的。你可以在社交媒体平台上对商业账户进行社会工程学攻击。你可以谷歌搜索。你可以在谷歌地图上找到东西,等等。是的,抱歉,威胁。有趣的事情。不涉及技术,但我认为记住这一点很重要。

章节 11:高级攻击技术

那么,让我们看看另一个有趣的,再次,这让我发笑,你知道,我内心的6502男孩。如果我告诉你,如今有人基本上可以查看你的RAM内容?我稍后会再次提到这个,但可以通过测量时间、定时攻击来发现关于安全、加密、密码等的信息。再次,像所有这些事情,我可能可以做一个演讲。虽然我不是其中任何一项的专家,但如果我想再花一个小时谈论这个,我可以给你们带来例子并展示一些例子,因为定时攻击。我不知道人们是否知道定时攻击是什么,但你基本上测量时间。所以你运行一个例程,测量时间,调用一个API,不管怎样,你改变输入,然后看时间差异,你可以根据内部行为发现一些信息,这显然取决于代码是如何构建的。

在嵌入式系统上,还有功耗分析攻击,这有点类似,但你只是分析某物使用了多少功率。在嵌入式系统上,你还可以通过 starving 或 brownout(电压降低)来玩微控制器,这本身又是另一个演讲,需要很多演示。但你基本上如果微控制器需要5伏,你给它4.5伏,看看它如何应对,基本上,它会做奇怪的事情,尤其是当你想访问硬件中加密的数据,而你没有密钥,或者安全性有点可笑时。所以你想访问的数据实际上没有加密,但受到一些代码层的保护,而你无法真正替换那些代码。

缓存投毒。不知道是否有人知道投毒是什么,但基本上,如今的CPU很复杂。有人听说过Spectre攻击吗?这又是一个话题。我不是专家。我从过去写演示程序时就知道缓存是如何工作的。但如今的计算机CPU更加复杂,你知道,内存访问和缓存等方面有很多层抽象,以及预测分支等。这很复杂。这一切,你知道,都是为了在相同的CPU时钟速度下让它更快,但这很可怕。复杂,显然,这导致了安全问题,令人惊讶。

所以,是的,CPU漏洞。像6502这样的东西,我认为是由一个人设计的。也许他有一个团队和他一起工作。但它是一个……也许是一个小团队,但如今你或许可以下载它的原理图,然后看着它说“哦,是的,我理解这个。我理解这个。”你无法对现代CPU做到这一点。这根本不可能。所以显然,你知道,复杂性,当你增加复杂性时,你就增加了出错的可能性。这是自然法则。

章节 12:内存安全与白盒密码学

所以,是的,这引出了另一个有趣又可怕的幻灯片,再次,这对我来说……当我15岁时,我会说“真的吗,老兄?”但是的。你手中真实的东西并不像看起来那么安全,我想。同样适用于你的交换空间。抱歉,如今交换空间通常是加密的。并且有层次来确保它们的安全,但RAM,就其本质而言,必须对软件可访问。所以它不会被加密。再次,这个的变体。但对于一台运行Windows的普通计算机,你真的不想在RAM中以未混淆的形式存储长期加密密钥或秘密,比如明文密码。这实际上是关于渗透测试的,我想我说过我会再次提到渗透测试。渗透测试报告中经常看到的事情之一,尤其是我过去写了很多iOS应用程序,那曾经是我的合同工作,我们请来的渗透测试员,再次,我自己也会把这一点写进渗透测试报告,经常会指出,当用户尝试登录或输入密码时,我们以明文形式将其保存在内存中,而不是混淆它。但这都与另一个在后台运行的应用程序可能通过各种方式窥探并访问它有关,如果你不在RAM中混淆它的话。同样适用于加密密钥。

最重要的是API密钥。我不是一个网络人员。所以我不做很多网络开发或网络API调用。所以我不确定人们是否仍然……当我过去经常做这些时,人们会给你一个哈希值,你必须将其填入某个字段才能访问API。我实际上为英国一家大型广播公司工作过。当我开始开发他们的iOS应用程序时,我注意到与DRM等相关的API密钥和AES密钥实际上在头文件中。我们显然有不同的暂存、生产等测试环境。所以每个环境会有不同的头文件。我说“我能谈谈这个吗?”所以当我的经理发现我了解一些这方面的事情时,他实际上在一次与CTO的会议上提了出来,第二天CTO说“我能见见这个人吗?”所以我基本上进行了一次三方会议,我向他们解释这不太好。他说“是的,我知道这个,但我对此无能为力。”我说“不,你可以。”那么,有人知道白盒密码学是什么吗?我会很惊讶如果有人知道这个。我会很高兴地惊讶。

白盒基本上意味着你在公开场合做某事,对吧?黑盒,你隐藏它。白盒密码学本质上意味着你展开你正在做的任何加密例程,比如AES。我无法凭记忆解释AES,但有很多位哈希和异或操作,本质上

031:数据抽象演讲 - Kevlin Henney

概述

在本节课中,我们将学习软件工程中的一个核心概念:数据抽象。我们将探讨什么是数据抽象、为什么它如此重要、它如何帮助我们构建更健壮、更易维护的软件,以及如何在实践中应用它。我们将从历史背景出发,理解其基本原理,并通过具体的代码示例来展示其价值。

历史背景与核心概念

上一节我们介绍了本课程的主题。本节中,我们来看看数据抽象这一概念的起源和重要性。

数据抽象并非新概念。早在1970年代,软件工程的先驱们就已经认识到其价值。例如,火星好奇号探测器的软件开发团队就采用了基于清晰关注点分离、数据隐藏、模块化、定义良好的接口的架构原则。这些原则中,除了强故障保护机制是针对特定任务外,其余都是通用且久经考验的软件工程实践。

David Musser 和 Alexander Stepanov 在1987年关于Ada泛型算法的论文中指出:“众所周知,数据抽象对于良好的软件工程实践至关重要。” Barbara Liskov 因其在数据抽象和编程语言设计方面的贡献获得了2008年图灵奖,她的工作如此成功,以至于后来的学生认为这些概念理所当然,而不知道它们被提出之前的样子。

软件本质上是一系列精心构建的幻觉。我们通过代码创造有用的模型和视图。当这些抽象成功时,它们是不可见的;只有当它们失败时(例如,程序崩溃并显示底层错误信息),我们才会注意到其背后的具体实现细节。这种“抽象泄漏”揭示了系统的内部结构。

抽象的本质与方向

上一节我们提到了抽象作为“幻觉”的概念。本节中,我们来深入探讨抽象的本质和我们描述它时的固有偏见。

根据《牛津英语词典》,抽象(abstraction)是“抽离出某物概念的结果,该物没有独立存在,只存在于观念中”。动词“抽象”(to abstract)字面意思是“移除”,源自拉丁语“abstrahere”,意为“抽离”。

在软件开发中,我们习惯于谈论“高层次”和“低层次”抽象。我们通常认为接近机器细节(如汇编代码)是“低层次”,而接近问题领域(如“信用卡号”)是“高层次”。然而,这种观点带有开发者偏见。对于领域专家(用户)而言,用他们熟悉的术语(如“信用卡号”)交谈,他们并不觉得抽象;相反,当我们谈论代码和实现细节时,他们才会觉得“非常抽象”。因此,重要的是认识到我们谈论的是相对于解决方案领域(机器)的抽象,而用户则从问题领域来看待它。

领域建模与“强类型字符串”问题

理解了抽象的方向后,我们来看看一个常见的反面模式:“强类型字符串”(Stringly Typed)系统。

以下是开发中常见的一个问题:我们经常用基本类型(如字符串)来表示领域概念,但这会带来麻烦。

例如,处理信用卡号时,许多开发者会直接使用字符串(string)或尝试用大整数(long)来存储。但这存在几个问题:

  1. 信用卡号不是数字:它不支持算术运算,并且可以有前导零。
  2. 信用卡号也不仅仅是字符串:它有一系列业务规则(如长度8-19位、发卡行标识、校验码等)。

如果我们简单使用 string,这些约束就会散布在代码各处,无法集中表达和维护。这导致了“强类型字符串”系统——即用字符串承载一切,但缺乏对数据含义和约束的明确表达。

解决方案是创建领域值对象。这个概念在 Ward Cunningham 的“整体值”(Whole Value)模式(1990年代)和 Eric Evans 的“领域驱动设计”(2003年)中都有阐述。其核心思想是:创建专门的对象来表示领域中有意义的量,并封装其所有相关行为和约束

对于信用卡号,我们应该定义一个 PaymentCardNumber 类。这个类:

  • 封装内部表示:可以用 stringlong 或任何优化后的结构存储。
  • 暴露清晰的接口:提供验证、获取发卡行信息、格式化输出等方法。
  • 隐藏实现细节:外部代码无法直接操作内部数据,只能通过定义好的接口交互。

这样,我们就把“信用卡号”这个概念提升为代码中的一等公民,提高了代码的表达力和可维护性。

封装、接口与实现分离

上一节我们强调了为领域概念创建专门类型的重要性。本节中,我们来看看支撑这一实践的更基础原则:封装与接口分离。

封装的字面意思是“装入胶囊”,即创建一个有明确边界的东西,内部和外部被隔开。在软件中,封装意味着将数据和对数据的操作捆绑在一起,并隐藏内部实现细节。

接口是模块对外提供的契约,定义了如何使用它。Butler Lampson 在1983年指出:“接口是提供一定数量服务的契约。” 客户端代码依赖于这个契约。

一个关键的设计原则是:针对接口编程,而非针对实现编程(出自《设计模式》)。这意味着客户端代码应该只依赖模块公开的稳定接口,而不应该依赖其易变的内部实现细节。

让我们通过一个栈(Stack)的例子来说明。以下是一个初始的Python实现,它暴露了内部实现:

class Stack:
    def __init__(self):
        self.items = []  # 公共属性!这是一个问题。

    def push(self, item):
        self.items.append(item)

    def pop(self):
        if not self.is_empty():
            return self.items.pop()

    def depth(self):
        return len(self.items)

    def top(self):
        if not self.is_empty():
            return self.items[-1]

    def is_empty(self):
        return len(self.items) == 0

这里的问题是 self.items 是公共的。其他代码可以直接操作这个列表,例如清空它(stack.items = [])或直接修改其内容。这破坏了栈的抽象。更隐蔽的是,如果我们将内部实现从列表(在尾部追加)改为在头部插入,所有依赖 items[-1] 来获取栈顶的客户端代码都会在语义上出错,尽管语法上可能不会报错。

正确的做法是隐藏内部表示:

class Stack:
    def __init__(self):
        self.__items = []  # 使用双下划线进行名称修饰,使其成为“私有”成员。

    def push(self, item):
        self.__items.append(item)
    # ... 其他方法操作 self.__items ...

在C++或C#等语言中,我们使用 private 访问修饰符来达到同样的目的。封装允许我们自由更改实现(例如,将底层容器从 vector 改为 deque 或链表),而不会影响依赖于公共接口的客户端代码。这是模块化的核心好处:减少变更带来的连锁反应。

不变性与契约

上一节我们讨论了通过封装来保护内部状态。本节中,我们来看看如何利用不变性来定义和维持数据抽象的契约

不变性是指某物不发生变化。在软件设计中,它通常指一个对象在创建后其状态就不能再改变(不可变对象),或者指一个在对象生命周期内必须始终为真的条件(类不变式)。

类不变式是数据抽象契约的一部分。例如,对于我们的 Stack 类,一个不变式可能是:“栈中存储的元素不能为 null”。如果我们允许客户端代码直接操作内部数组,这个不变式就无法保证。通过封装,并在 push 等方法中加入验证逻辑,我们就能在公共接口的边界上强制执行这些规则。

考虑一个C#的栈,我们试图维护“无空元素”不变式:

public class Stack<T>
{
    private List<T> _items = new List<T>();

    public void Push(T item)
    {
        if (item == null) throw new ArgumentNullException(nameof(item));
        _items.Add(item);
    }

    // 假设我们提供了一个公共的Items属性用于只读访问
    public IReadOnlyList<T> Items => _items.AsReadOnly();

    // 一个检查不变式的方法(可用于调试或测试)
    private bool InvariantHold()
    {
        return _items.All(item => item != null);
    }
}

通过将 _items 设为私有,并只提供一个只读的视图 (IReadOnlyList<T>),我们防止了外部代码直接插入 null,从而维护了不变式。如果我们错误地将 _items 本身公开为公共属性或公共字段,不变式就会被破坏。

数据抽象的目的不是模糊,而是创建一个新的语义层次,在这个层次上我们可以绝对精确。 不变式就是这个精确语义的核心部分。

模块化与信息隐藏

我们一直在讨论封装和接口,这些概念都源于一个更宏大的思想:模块化。本节中,我们来追溯模块化和信息隐藏的历史。

David Parnas 在1972年的经典论文《将系统分解为模块的准则》中,奠定了现代模块化思想的基础。他提出了信息隐藏原则:每个模块的特征在于它隐藏了一个设计决策,该决策对其他所有模块保密。其接口或定义被选择为尽可能少地透露其内部工作原理。

模块化的预期好处包括:

  • 缩短开发时间:不同小组可以在不同模块上工作,只需少量沟通。
  • 提高产品灵活性:可以对一个模块进行重大更改,而无需更改其他模块。
  • 增强可理解性:可以一次只研究系统的一个模块。

Parnas 还指出,模块之间的连接是它们彼此做出的假设。这些假设不仅仅是显式的导入/包含依赖,还包括隐式的约定,例如线程安全假设、性能特征假设等。良好的模块化通过明确的接口来减少和固化这些假设,使系统更易于推理。

数据抽象与面向对象编程的关系

在讨论了模块化之后,一个常见的问题浮现出来:数据抽象和面向对象编程(OOP)是一回事吗?本节中我们来澄清这一点。

不,它们不是一回事。 数据抽象是一种更基础、更广泛适用的思想。

面向对象编程通常包含三个主要概念:封装、继承和多态。其中,封装(即数据抽象)是基础。然而,许多被称为“面向对象”的代码实际上只包含了“贫血”的数据对象(只有getter和setter)和庞大的“管理器”类,这恰恰是糟糕的数据抽象。

正如 Abadi 和 Cardelli 在《对象理论》中所言:“面向对象编程并不独占我们描述的所有这些优良特性……程序的弹性同样可以通过围绕抽象数据类型组织程序来实现,独立于分类学。” 这里的关键是,抽象数据类型是实现弹性的核心。

Barbara Liskov 在她1987年的著名论文《数据抽象与层次结构》中也指出:“尽管数据抽象是更重要的思想,但层次结构确实在某些情况下扩展了它的有用性。” 她所说的层次结构主要指继承。她的观点是,数据抽象(通过抽象数据类型)比继承更重要、更基础

面向对象编程中的继承(特别是实现继承)只是实现代码复用和建立类型关系的一种机制,而数据抽象是关于如何组织数据和行为的根本方法,它适用于函数式编程、过程式编程等多种范式。

实践总结与核心思想

在本节课中,我们一起学习了数据抽象的核心思想。让我们回顾并总结关键点:

  1. 数据抽象是构建健壮软件的基石:它通过创建定义良好的接口来隐藏实现细节,允许我们构建易于理解、修改和维护的系统。
  2. 创建领域值对象:不要用原始类型(如 string, int)表示领域概念。应创建专门的类型(如 PaymentCardNumber, EmailAddress)来封装数据和相关行为。
  3. 封装与信息隐藏:将数据和操作捆绑在一起,并严格限制对内部状态的访问。使用语言的访问控制机制(如 private, protected, Python的双下划线)。
  4. 针对接口编程:客户端代码应依赖于稳定的抽象接口,而不是易变的具体实现。这降低了耦合度。
  5. 定义并维护不变式:明确你的数据对象必须遵守的规则(契约),并在接口边界上强制执行它们。
  6. 区分公开接口与已发布接口:在团队内部,可以保持接口的灵活性以便重构。只有对那些你承诺长期稳定支持的、对外部团队或系统暴露的接口,才将其视为“已发布”接口并谨慎对待。
  7. 数据抽象独立于范式:它是比面向对象更基础的概念,在函数式、过程式等编程风格中同样重要。

最后,引用艺术家 Hans Hoffman 的话:“简化的能力意味着消除不必要的东西,以便必要的东西可以发声。” 数据抽象正是我们用来分离关注点、消除不必要的实现细节、让领域逻辑清晰发声的核心工具。它不是银弹,但它是我们应对软件复杂性的最强大工具之一。

032:提升C++代码安全性

概述

在本教程中,我们将学习如何使用现代C++物理量和单位库来显著提升代码的安全性。我们将探讨为何类型安全在工程和编程中至关重要,并通过具体的历史案例和代码示例,展示如何利用库的特性来避免常见的单位混淆和计算错误。


第1.1节:安全性的重要性

在当今时代,软件安全性是一个至关重要的方面。特别是当一些观点认为C++并非一种安全的语言时,我们更需要展示C++如何能够实现比其他语言更高的安全性。

上一节我们提到了安全性的宏观背景,本节中我们来看看一个具体的工具——物理量和单位库,如何帮助我们提升软件乃至日常生活的安全性。


第1.2节:现实世界对机器的依赖

自动驾驶汽车曾经是科幻电影中遥远未来的事物。然而,这个“遥远的未来”正在我们的道路上成为现实。当自动驾驶汽车载着我们前往目的地时,我们希望能够在途中休息、工作或睡眠,信任机器能完成它的工作。

不仅仅是自动驾驶汽车,在我们的日常生活中,我们越来越依赖机器。我们拥有手术机器人、飞机、火箭以及许多其他让生活更便利的设备。但如果这些设备编程不当,它们也可能让生活变得不安全。在不久的将来,我们将依赖更多这样的设备。


第1.3节:C++社区的安全关切

C++的安全性近年来一直是C++社区关注的主要问题。我们讨论了潜在的低层基础类型改进,例如安全整数类型,以检测溢出和截断等问题。我们也希望更新一些核心语言规则,使其更安全。

此外,我们还可以通过库来提供更安全的高层结构。本教程讨论的库就是这样一个例子。

C++开发者在这方面需要帮助。这不仅关乎培训,因为我们的行业缺乏大量培训来让工程师成长,而且安全领域是C++编程中的一个特殊领域,拥有相关经验的人并不多,而这种经验很难获得。


第1.4节:受影响的行业

以下是受此类库影响的众多行业:

  • 自动驾驶汽车
  • 航空航天
  • 嵌入式系统
  • 制造业
  • 海事
  • 军事
  • 天文学
  • 图形用户界面设计
  • 音频处理
  • 金融(包括高频交易)

这并非小众领域,实际上非常普遍且需求巨大。


第1.5节:单位问题的历史案例

使用C++在许多情况下确实很困难,但单位和量纲问题本身就是一个固有的复杂问题。即使在日常生活中,不涉及编程时,我们也经常在单位沟通上遇到问题。

在人类历史上,我们曾遇到过许多与单位使用和沟通相关的问题。

以下是历史上一些著名的单位错误案例:

  • 克里斯托弗·哥伦布(1492年):在前往日本和印度的航行中意外发现了美洲。他幸存下来是因为他犯了几个错误。他使用了不同的“英里”长度进行计算,因此认为世界要小得多。当他到达巴哈马时,他以为已经到了日本或印度。
  • 瓦萨号战舰(1628年):在处女航中航行不到一英里就沉没了,导致30名水手不幸身亡。原因是船体一侧比另一侧厚。据称,这是因为两个不同的团队在两侧工作,一个团队使用以瑞典英尺(12英寸)校准的尺子,另一个团队使用较短的阿姆斯特丹英尺。
  • 加拿大航空143号航班(1983年):在12,000米高空耗尽燃油。原因是燃油是以磅为单位计算的,而不是公斤,导致燃油不足以抵达机场。幸运的是,飞机滑翔到最近的机场并成功迫降,无人死亡。
  • 英国摇滚乐队黑色安息日(1983年):在“生而疯狂”巡演中,订购了巨石阵的复制品作为舞台道具。不幸的是,经理在提交订单时,将尺寸单位从英尺误写为米。结果,他们建造了巨大的复制品,成本高昂,且几乎无法放入任何演出场馆。
  • 大韩航空货运6316号航班(1999年):因副机长和机长之间的沟通失误而坠毁。副机长将1500米误读为1500英尺,并命令下降。机长服从了命令。机上三名机组人员和地面上五名无辜者全部遇难。
  • 火星气候轨道器(1999年):可能是工程史上最著名的事故。它未能进入火星轨道,在进入大气层时坠毁。原因是美国国家航空航天局使用国际单位制,而洛克希德·马丁公司使用美制单位,他们在太空探测器的不同模块接口之间没有良好沟通。
  • 乌龟克拉伦斯(2001年):从一个动物园转移到另一个动物园。月亮公园学院动物园的工作人员根据从来源地通过电话获得的信息为它建造了围栏,信息称乌龟重“250”。结果发现是250公斤,而不是磅。因此,克拉伦斯在第一个晚上就破坏了整个围栏,第二天早上被发现正在花园里吃蔬菜。
  • 迪士尼乐园太空山过山车(2003年):因车轴断裂而延迟。断裂是由于将轴承规格从英制单位转换为公制单位时产生混淆造成的。
  • 瑞士与德国之间的莱茵河大桥:由两个不同的团队从两岸开始建造,预计在河中央汇合。不幸的是,瑞士和德国使用不同的海拔基准面:德国使用北海海平面,而瑞士使用地中海海平面,两者相差27厘米。但实际上,由于其中一个团队在计算时犯了符号错误(本应减去该值却加了该值),导致差异完全不同。
  • 一家美国公司向日本客户销售野生大米:报价为每磅39美分,而客户认为是每公斤39美分,导致交易过程中需要解决问题。
  • 《卫报》文章(2023年10月17日):称马拉维遭遇了“比平均气温高出近68华氏度”的极端高温。实际的温升仅为52华氏度。原因是他们在转换数据时,误用了差异和点值,在将原本为欧洲市场准备的标题转换到美国市场时使用了不正确的计算。

此外,还有许多有记录的医疗剂量错误,因为医疗剂量和单位非常奇怪且难以沟通。

以上所有问题的根源都不是C++。C++只是增加了另一层,使这个问题更加复杂。


第1.6节:典型的生产代码问题

让我们看一些在软件中可能发现的典型生产问题。

这是一段我曾贡献过的开源软件的代码。其Windows接口非常令人担忧,这实际上促使我开始研究物理量库。

如你所见,double 类型被到处使用。很难记住参数的数量,也很难通过改变参数顺序或在中途添加新参数来重构这个函数。基本上,维护这样的软件极其困难。

另一个函数充满了魔数。我不敢碰它,没人知道它是做什么的。

这类软件使用大量宏来进行单位转换。当然,并非所有转换都定义了,因此你必须分步进行,这是软件的另一个低效之处。但事实证明,即使采用这种方法,仍然可能出错。当我在同一个代码库中查找相同的宏时,我发现了至少四种不同的结果。

这是一个头文件,这些函数彼此相邻。我们有 distanceSphLatLondistanceLatLon。为了保持一致,或者“一致地不一致”,同样的情况也适用于这里的 distanceAndBearing

如果接口是这样,如何使其正确工作?如何操作软件?如何重构?你可能会认为这只是糟糕的开源软件,没有任何标准和规则。

我从事这项工作七年了,当我开始谈论这个问题时,很多人联系我说:“我不能分享我的源代码,但我们生产环境中的源代码比你演示的还要糟糕得多。” 我猜你在这里是因为你已经对此有所了解。这就是为什么我们想要改进。


第1.7节:MP Units 库的目标

大约七年前,我开始编写MP Units库,并且它已被提议进行标准化。当然,标准化并非该库的最终目标,但如果可能,将其纳入标准会很好。

首先,我们希望通过正确处理物理量、单位和数值,在编译时提供安全性,并且性能必须与使用基本类型(如 double)一样快甚至更快。不应该有运行时开销和空间大小开销。

C++是一门很棒的语言,可能是唯一允许以我们这种方式实现该库所有功能的语言。

通常,我们的用户不是C++专家工程师,他们只是将其作为完成工作的工具。他们不理解复杂的模板错误信息。我们希望提供尽可能易于阅读和使用的东西,同时也易于扩展,因为用户需要根据自己的需求进行扩展。

我们应该能够提供任何单位量级,无论是巨大的还是微小的浮点数。我们应该对量制、单位制、仿射空间抽象进行建模。当然,我们还应该提供文本输出以进行调试和打印。

我们希望支持标量、矢量和张量。有些用户希望将其与自然单位一起使用,如果你了解这个领域,就知道这相当棘手。当然,它应该易于扩展。

目前,MP Units库在两大主流编译器(GCC和Clang)上兼容C++20。Apple Clang也以某种方式支持,因为它总是比Clang晚一个版本。不幸的是,MSVC完全不支持编译。我尝试修复,但MSVC编译器在C++20支持方面存在太多问题。希望MSVC能尽快赶上,但目前我不得不处理GCC和Clang。

除了C++20,我们尝试从语言的最新扩展中获益,以提供未来可能标准化所需的最佳API。目前,我们考虑的是C++29。因此,它不应局限于C++20,因为到那时C++20已经过时了。我们应该尽可能利用最新特性,为用户提供最佳体验。这就是为什么我们也受益于C++20的 std::formatstd::chrono,以及C++23的附加抽象,如果可能,还会使用C++26的抽象来改进库。C++26有一些非常好的特性可以改进这个库。但我们始终尝试与C++20兼容。所有新特性都带有通过编译预处理宏提供的工作区,以便能够与C++20兼容工作。所以不用担心,C++20将在较长时间内足够支持该库。只有当你拥有更好的、更新的编译器时,你才能从简化的接口或可能更快的编译时间中受益。

该库可在GitHub和Conan上找到,你也可以在Compiler Explorer上找到它。

关于标准化,我大约在四五年前(至少在五年前)提交了这份论文。当时我分析了市场现状,并询问委员会我们是否想走这条路。答案是肯定的,于是我开始了标准化工作。不幸的是,COVID-19在中期发生,一切都放缓了。但还有更多的准备工作在进行中。在C++29周期,我提供了物理量的动机、范围和计划。在C++30/31周期,我详细描述了设计和所有方面。

还有一些相关的提案,是该库的依赖项。我们也希望拥有数字概念库,这将允许我们为接口提供更好的约束。我实现了基本的固定字符串,并提交了一份论文,这将使我们拥有一种标准化的方式将文本传递给模板的NTTP参数。此外,我们还可以从编译时质数中受益。似乎ISO委员会和C++社区都对将其标准化有很高的兴趣。

标准化的重要方面首先是安全性,其次,标准化很重要,因为这关乎所谓的“词汇类型”。词汇类型是用于在不同供应商接口之间进行通信的类型。这正是我们这里讨论的情况。以火星气候轨道器为例,美国国家航空航天局和洛克希德·马丁公司应该有一种共同的语言来沟通他们的接口。否则,我们将一直犯这样的错误。


第1.8节:领域基础概念介绍

在深入C++代码接口之前,让我们先谈谈这个领域本身。我们来做一些简单的介绍。

我们有一个量制。量制是几个单位制的基础。这是该库与市场上所有其他库略有不同的地方,因为大多数库只关注一个或多个单位制,但没有一个库尝试正确地将量作为正交维度进行建模。但官方定义就是如此。

一个量制是一组量以及一组将这些量联系在一起的方程。我们有一个由ISO提供的大型量制,它基于七个基本量,你可能已经知道,我将在下一屏展示。

一个单位制是一组基本单位和导出单位以及词头,基于给定的量制。因此,单位制总是基于量制,而由国际计量大会提供的国际单位制基于国际量制,并在其文档中正式声明。

这些是该领域涉及的主要实体。

量纲可以是基本量纲或导出量的量纲。在国际量制中有七个基本量:长度、质量、时间、电流、热力学温度、物质的量和发光强度。每个都有唯一的符号。我们也可以讨论导出量的量纲,例如,力表示为 L M T^-2。但请注意,力本身没有关联的符号。它没有独立的“力的量纲”。力具有基于基本量纲乘积的导出量纲。但它本身不是导出量纲。大多数市场开发者错误地定义了诸如“速度量纲”之类的东西,但实际上在理论和领域中并没有这样的东西。因为速度或力的量纲只是基本量纲的乘积,而不是独立的事物。

事实证明,仅靠量纲不足以描述一个量。存在不同量纲的量,如长度、时间、速度、功率。但也存在量纲相同但种类不同的量。例如功与力矩,频率与放射性活度,甚至面积与燃油消耗量。燃油消耗量是升每公里。升是体积。如果将体积除以长度,得到面积。但我不希望将足球场的面积与我的汽车燃油消耗量相加,这很荒谬。至少在某些领域中不允许这样的计算,因为也许你有一个领域,这并不重要。

同样,可能存在量纲和种类都相同但仍截然不同的量。半径和高度是不同的量,势能、动能和热力学能也是如此。有时你不想在接口中混合这些。

国际标准文档指出,存在称为“量参考”的东西,量参考可以是一个单位。这是一个奇怪的术语,尤其是在C++中,它已经相当重载。所以我不知道这个术语是否会保留,但我们目前至少尝试与官方规范兼容,并说在这种情况下,单位基本上是由官方指定的名称和符号。

国际单位制为所有国际量制量纲提供了基本单位。我们也有一些导出单位,它们可能在不同量之间共享,例如热容和熵都使用焦耳每开尔文。但事实证明,有些量即使意义完全相同,也有其专用的单位。例如,每秒是一个单位,但在许多领域中,如果涉及频率,则应使用赫兹;如果涉及放射性核素的活度,则应使用贝克勒尔。尽管两者在技术上都是“每秒”。你不应该说活度以赫兹为单位,或频率以贝克勒尔为单位,因为这是不正确的。

此外,量差是一种可测量的过程或参考材料,但这不在C++的覆盖范围内。根据定义,量就是数字和参考。这些数字可以是标量、矢量或张量。这在涉及线性库使用时也很重要。你希望也能处理这些用例,而目前市场上没有C++库能做到。在C++行业中,据我所知,唯一能做到的是Python中的Pint库,因为它基于NumPy。

我们在库中进行的基本操作当然是简单的算术运算,如除以或乘以标量。我们当然可以在转换过程中转换一些单位并找到一些共同单位,这是 std::chrono 所做的。但该库带来的新功能是将不同的量相除或相乘,从而得到新的量,例如速度乘以时间得到长度,等等。我们也可以将相同的量相乘得到另一个量,如面积;可以将两个量相除得到一个无量纲量;或者可以对一个量求逆得到另一个量,例如在这种情况下得到频率。


第1.9节:代码示例对比

那么,让我们看看在实践中使用物理量库的代码是什么样子。

这是没有使用任何库的遗留代码。这里重要的是,每个标识符都跟踪了所使用的单位。如果你现在没有使用这个,可能很难理解或维护你的代码。我们有以米为单位的距离,以英里每小时为单位的速度。然后你提供了一些比率来进行转换。实际上,我认为这里是以米每小时为单位,而那里是英里每小时。甚至很难弄清楚这些后缀是什么意思。为了得到结果,你可能需要提供魔数。但我们不喜欢魔数,这就是为什么在库中,我们通常提供一些转换宏,就像你已经看到的那样。如果有人认为宏是错误的,那么人们会提供漂亮的转换因子。是的,我很好,我使用概念。

在许多情况下,使用单位库时,你不必在参数或变量名称中拼写任何单位,因为这已嵌入到类型中。在这种情况下,我们使用CTAD(类模板参数推导)来避免拼写,因为它总是根据右侧初始化器推导出正确的类型。我只对这里的抽象感兴趣,并说我在这里处理的是量。这总是比使用 auto 更好。使用 quantity,你知道你在处理什么;有时你可能会感到困惑,但 quantity 足以表达,你不必拼写整个类型。你在这里使用米、英里每小时,并且可以轻松地取值(以秒为单位)并得到结果,而不需要像这里那样的中间值。在两种情况下你得到完全相同的结果。不需要跟踪单位。库本身知道所有转换因子,你不需要提供任何魔数、宏、常量或比率。用户不需要这些,因为库会在需要时为你完成所有这些步骤。

这样一个库的主要目标是生成错误。如果你的软件不会犯任何错误,就不需要这个库。如果你完美无瑕,为什么要费心使用强类型而不仅仅是 double 呢?所以,如果我在计算中犯了错误,比如在两种情况下都做乘法,这段代码将愉快地提供结果,无论它意味着什么。如果你使用安全的库,你会得到一个编译错误,提示“没有名为 to 的魔术成员函数”或“约束不满足,因为秒的单位不是长度的平方除以时间的单位”。

你也可以使用这些接口提供函数。如果你没有库,你必须使用相同的后缀语法指定参数,而且函数也应该有后缀来说明其结果是什么。所以这是以秒为单位的运行时间,然后你实现函数的逻辑。使用强类型,你确切地知道类型中的单位是什么,因此不必在变量名中编码这些信息。使用C++20,我们可以有这种很棒的语法,使用实体占位符。这真的很容易阅读和理解。这在C++20之前是不可能的,这就是为什么该库至少需要C++20才能工作。在早期版本中,提供这些接口非常非常困难。

如果你想调用这个函数,你必须再次提供所有带后缀的变量,并得到一些结果。在这种情况下,你不必使用后缀。我只是提供了它们,因为我特意想在这里有两个不同的对象,我稍后会展示原因。这就是我创建实体的方式,10公里,公里每小时。然后调用它,在两种情况下得到完全相同的结果。

如果我在这里使用公里,这段代码将愉快地编译并提供一些结果。当然,这是不正确的,因为这不是米。转换没有应用。在这种情况下,当然,如果它是非截断转换,那么库将自动进行转换,你仍然会得到400秒,和之前一样,因为这是相同的值,只是转换到了不同的单位。

如果你以不同的顺序提供参数,在两种情况下你都会得到另一个不正确的结果。在遗留代码中,你会得到另一个错误,提示“无法将速度从公里每小时转换为米”。如果你在实现中犯了其他错误,比如乘法,这段代码会编译,而这里我们会得到另一个编译错误,提示“无法将公里每小时的结果转换为秒”。

那么性能如何呢?你有左边的代码和右边的代码。左边代码的汇编,右边代码的汇编。完全相同的指令,在这种情况下它们被重新排序了,因为我以另一种顺序进行计算,但那些是完全相同的指令。但这通常要求我们将输入和输出转换到某个特定单位,然后从某个特定单位转换到你正在使用的单位。

使用强类型,我们可以提供更好的抽象,我们可以提供通用接口。我们可以说我们在这里使用长度量、速度量和时间量。但我们不具体说明使用了什么单位,使用了什么表示类型。如果不需要,我们不必为这些转换付出代价。如果没有概念,这就不可能实现。如果接口只是基于 double,就没有办法使其类型安全。如果没有概念,就很难做到正确。在旧的语言版本中,你也许可以在叶子上约束参数,但约束返回类型相当棘手。

所以让我们看看,我可以提供英尺和英里每小时作为参数并得到结果,将其转换为秒,并找出在十字路口右转之前需要多少时间。我可以使用不同的单位。假设这是从我家到花园湖的转弯,我预测的速度,并找出到达目的地的时间(以小时为单位)。我们付出的唯一代价是除法的成本。你不必为输入和输出的任何中间转换付费,因为通常如果用户提供公里和小时,我们也希望结果以小时为单位。因此,强迫用户将其转换为米每秒、米,然后再转换回来,只是为不必要的计算付出代价,并且可能导致截断错误和一些精度错误。

如果我想跑半程马拉松距离,我可以提供距离(公里)、配速(秒每公里),并将其提供给同一个函数。我会得到一个错误,因为配速不是速度。但我们经常忘记这一点,至少非专家在使用手表时会忘记。配速不是速度。我总是要向我的妈妈或女朋友解释,拥有这个值的配速意味着什么。你会得到信息:“秒每公里的量不是速度。”如果你做对了,提供了配速的倒数,那么当然,你可以找出比赛的预计时间是1小时38分27秒。顺便说一下,请注意该库与 std::chrono 的协作是多么容易。它只是隐式转换为 chrono 类型,你可以用 chrono 辅助工具打印它。

此外,如果你将仿射空间抽象与点量一起使用,你可以有两个点,云底高度、滑翔高度。热气流强度为4.5米每秒,这是你在云下上升的速度。然后找出,通过减去这两个点,找出在云将你吸入之前还剩多少时间,所以你必须在之前退出。

这些只是展示使用此类API工作方式的一些例子。


第1.10节:库的安全特性

但让我们专注于该库提供的安全特性。

首先,我们有安全的单位转换。我们提供某个量,可以将其转换为不同单位的量,并得到结果。你可以使用成员函数或转换构造函数进行转换。源和目标单位的量级在编译时已知,并由库自动应用,因为你不需要担心转换因子。库使用该信息自动为你计算结果。

我们不能使用 std::ratio。我们继续使用 std::chrono::duration 中使用的比率,因为它有一些限制。对于通用的物理单位库来说,它不够用。std::ratio 是用 std::intmax_t 实现的,它很大,但不够大。不可能定义具有巨大或微小量级的单位,如电子伏特、道尔顿等。实际上,std::ratio 无法覆盖标准中指定的六个SI词头。因为没有库、没有编译器会支持它们,因为 intmax_t 实际上不是你平台上最大整数的大小,它是64位,永远不会改变。你知道为什么吗?答案是ABI。所以如果 long long 类型变长,intmax_t 不会改变。这很糟糕。所以 std::ratio 将永远无法覆盖标准中指定的那些词头(目前有六个)。

除了具有巨大和微小的比率外,有时我们还需要两个不同单位之间的比率为无理数,例如弧度和度。你根本无法用 ratio 来表达这一点。那么我们如何定义单位呢?我们提供了自己的抽象,如 mag_power 来指定词头,它是10的幂。所以我们提供 kilo 作为类模板,然后是变量模板,用于动态构建事物。我们提供米,我们提供秒。你在指定的命名空间中提供唯一的符号。为短符号设置了独立的命名空间,并且它们是选择加入的,因为这些短符号很容易与你库中现有的符号冲突。我们不想用所有这些短名称污染你的命名空间或作用域,所以你必须选择加入才能获得它们。你可以使用 using 指令或 using 声明,以适合你的方式为准。

此外,并非所有单位都只是另一个单位加词头。有时你必须使用不同于词头的转换因子,所以你用这种语法指定分钟为60秒,或小时为60分钟。或者有时你必须使用比率作为因子,比如码是米的某个比率,然后还有英里。

该库的另一个特性是防止数据截断。你可能会笑,但你知道我在这样的库中已经遇到过多少次结果为0的情况吗?因为我只是在整数类型上进行除法。在高速公路上看到这样的标志,所有汽车都停下来,会非常令人惊讶。所以,如果我们使用整数类型,就有可能发生截断。如果你有米,并想将其转换为公里,当然,截断的结果会是0,所有汽车都必须停下来。这就是为什么你会得到编译时错误,无论是使用这个成员函数还是使用转换构造函数。

浮点类型目前至少被认为是值保持的。我们在这里遵循 std::chrono::duration 的提议,因为我们没有更好的方法来声明什么是值保持的,什么不是。我们遵循 std::chrono::duration 的抽象。希望我们能够改进。有一些类型特性提案正在进行中,可能会使这变得更好。但目前,我们假设浮点数是值保持的。我们知道事实并非如此,但我们假设如此。在这种情况下,我们可以用函数或转换构造函数将米转换为公里,并且它能编译。

我们也可以在这里使用整数类型,并在转换前显式地将其强制转换为特定类型,然后它就能工作。这里也很好,它也能工作,因为默认使用 double。这里我们使用 int,默认情况下量中使用 double,所以它也能转换。

如果你的代码中有整数,你也可以强制进行显式转换。我知道 force_in 读起来很长,而且在这里读起来很奇怪,因为在量的上下文中,“force in kilometers”似乎不对。如果你有更好的名字建议,请告诉我们。我们争论过几次如何表达强制转换。目前,我们使用 force_in,并且正在寻找更好的名字。或者你可以在这里使用 value_cast

此外,从浮点表示转换为整数表示被认为是截断的。所以你也可以使用 value_cast,但这次不是带单位,而是带类型,表示我想强制从 float 转换为 int,然后它就能编译。


第1.11节:实践案例:哥伦布的错误

所有这些对你来说可能相当明显,因为你可能已经知道 std::chrono::duration。所以这并不奇怪,但让我们看看这些特性在实践中是如何工作的。让我们找出为什么哥伦布认为他到达了印度。

他从欧洲出发,前往加那利群岛,然后从加那利群岛到达巴哈马。他是如何认为自己到了印度,并且实际上没有死在海洋上的呢?当时,有一位著名的制图师叫阿布·阿巴斯·艾哈迈德·伊本·穆罕默德·伊本·卡提尔·阿尔-法加尼·阿尔-法里斯。这位制图师来自波斯。哥伦布从他那里学到了估计的纬度跨度:56.67英里。所以他以为,好的,赤道上的经度应该大致相当。这就是在地球上旅行一度所需的距离:56.67英里。

当时欧洲使用的土地系统是罗马系统。罗马系统基于罗马尺。现在官方测量的脚的长度是根据科苏图斯雕像上这只脚的长度来测量的。如果你看不到,它是229.6毫米。这是罗马尺或罗马英尺的官方来源。罗马步是罗马军团士兵每隔一步的长度,等于5罗马英尺。罗马英里是罗马军团士兵左脚着地1000次的总距离。易于实施的系统,对吧?基于那个时代的实际现实。

所以我们有一个估计的度数:56.67罗马英里。我们有罗马英尺:296毫米。我们有罗马步,是英尺的5倍。我们有罗马英里,是1000罗马步。再次注意,实体占位符使这个库易于定义事物。同样,在C++20之前是不可能的。在C++20之前,使用解释参数非常棘手,现在使用起来相当容易。

然而,事实证明,波斯地理学家在交流他的测量结果时,使用的不是欧洲罗马系统,而是阿拉伯英里。阿拉伯英里的长度与罗马英里不同。我还提供了目前通过卫星和所有现代技术测量的赤道英里长度。

我们可以在这里创建哥伦布度、阿尔-法加尼度和赤道度的长度。我们可以定义一个测量误差的函数,它接受近似值和精确值,我们只是约束它,看看是否能相减。我们抽象化,取绝对值,除以,得到误差。我可以将罗马英里、阿拉伯英里都放入误差中。用米表示它们,我们会发现哥伦布的假设和阿尔-法加尼的陈述之间的差异已经相当显著:计算过程中已有31%的差异。

让我们进一步看。将这个度数乘以360度,找出赤道长度。根据哥伦布、阿尔-法加尼和实际情况。赤道长度。如果你再次打印出来,这次以海里为单位,我们会发现哥伦布的赤道长度是16,000海里,阿尔-法加尼声称是23,000,而实际是21,000。所以真相介于两者之间。实际上,这使得哥伦布的错误稍小一些。所以对他来说很幸运。但这是他旅行准备过程中的一个错误。

第二个错误是基于一本启示录书籍,出现在圣经的一些英文版本中。这本书《以斯拉二书》说,六部分是可居住的,第七部分被水覆盖。他为一生一次的旅行冒险做准备。为什么不相信圣经呢,对吧?如果陆地是地球的六部分,而水只是七分之一,那么让我们出发吧。所以哥伦布估计从加那利群岛到日本的纬度距离是68度。如果我们了解当前的地理,这是非常乐观的。因为,嗯,根据圣经,地球上能有多少水呢?不多。巴哈马的距离接近6,000公里,而到日本的真实距离是10,000海里。

如果我将它们全部以相同的单位(海里)打印出来,找出误差,我们会发现哥伦布计划旅行的距离是3,000海里。到目标的实际距离是三倍多。他在估算中犯了一个巨大的错误。现在他准备好了所有的储备、食物、水,然后出发去旅行。他非常幸运,在途中发现了陆地,否则我们永远不会再听说哥伦布了。因为特内里费岛和巴哈马之间的实际距离只有3,200海里。所以他们只是在没有食物的情况下多忍受了200海里。当然,他可能还多准备了一些作为备用,所以他们可能还好,但了解这个故事后,我知道他为什么到达那里并幸存下来时那么高兴了。

好的。请注意,我提供给你的代码在构造时就是正确的,这要归功于物理量库的使用。我们可以专注于应用程序的业务逻辑。可能你们中没有人需要跟踪或进行代码审查,看我是否正确处理了量。我们使用了非常不同的奇怪量,如罗马英里、英尺、阿拉伯英里、海里、米、公里等等。而这只是正常工作。这只是自解释的、易于使用的代码。我们不必关注这些,我们只需要关注程序本身的逻辑。

想象一下,你必须用转换宏来实现这个。在这些幻灯片中,这不会那么有趣和容易理解。


第1.12节:实现物理量库的挑战

但这些特性对你来说可能很明显。然而,在一个新库中实现物理量比最初看起来要困难得多。很多人尝试过,因为“能有多难呢?”很多人中途放弃了,因为它通常比看起来要困难得多。

我们甚至只谈论安全性。我还没有谈到正确性或其他一些边界情况。还有其他不那么明显的方面,我想在接下来的幻灯片中解释它们。

例如,让我们谈谈标准中已有十多年的 duration。我们这里有一个结构体 X,它有一个 std::chrono::seconds 的向量。我们用 emplace_back(42) 初始化它。你能看出一些问题吗?如果明天我重构这段代码,使用微秒,代码会继续正常编译,但内部的值大错特错。这不安全,对吧?这是一个我们当时没有想到的缺陷。我们认为 duration 是一个非常好的类型。它确实是,在标准化时是革命性的。当时没有人想到这样的问题。但正如我们所知,它在今天本质上是不安全的,并且已经发生了许多因此导致的问题。

对于使用物理量库的用户来说,这些问题不是问题,因为这永远不会编译。在MP Units库中,不可能仅从一个值创建一个量。你总是必须指定一个单位。如果单位不匹配,它要么进行转换,要么在编译时因截断而失败,就像你之前看到的那样。你可以使用乘法语法,也可以使用双参数构造函数,取决于你喜欢什么,但你总是必须提供两个实体,而不是一个。

另一个问题,可能你也意识到了,但也是问题的根源,那就是如果你想使用某个接受秒的遗留函数。对于 std::chrono::duration,有一个名为 count() 的函数。我有 emplace_back 42秒。函数接受秒。所以我取 count() 没问题,对吧?单位匹配,对吧?秒,秒的计数。但告诉我你存储的类型。是微秒。所以对微秒调用 count(),返回的不是秒,即使你放入了秒。结果将是错误的,差异巨大。当然,标准中有一个叫做 duration_cast 的工具来强制转换,但你必须每次都应用它并记住它。事实证明,用户不记得,也不想关心,因为今天它工作正常,类型匹配。但它们明天可能就不匹配了。所以这不是库设计所强制要求的。

对于我们的需求,我们知道它需要更多变量,但我们希望站在安全的一边。你总是必须拼写数值并提供单位,即使你使用的是相同的单位,你也必须拼写它。如果转换是非截断的,它将始终为你提供正确的值;否则,再次出现编译时错误。所以在这种情况下,你输入秒,它被转换为微秒,然后再转换回秒。一切正常。

所以数值获取器视图总是需要一个单位和参数,以提供类型安全。


第1.13节:量种类与量规格

你还记得克拉伦斯那个太小的围栏吗?让我们尝试为克拉伦斯建造一个围栏。你想实现一个盒子。这是在没有单位库的情况下如何做的:长、宽、高,都是 double。你有面积。长乘以宽,在 double 上运算。有效。

使用市场上的库。这是你如何实现长、宽、高、面积的。更安全吗?可能不,对吧?但这就是目前市场上所有库的现状。市场上几乎没有一个库尝试解决这个问题。正如诺曼会告诉你的,不同长度之间的区别很重要,对吧?你想为这个宠物提供一个垂直的笼子,而不是水平的。这就是为什么我们引入了一个新的抽象,叫做量规格,因为量纲不足以指定量的所有属性。

对于相同的量纲,可能有多个量。我们可能有不同种类的量,如频率、调制率、放射性活度,仍然具有相同的量纲。或者甚至是相同种类的量,如长度、宽度、高度、距离、半径、位置矢量、波长等等。量可以有不同的特征:标量、矢量、张量。其中一些还被定义为非负的。你希望表达所有这些,并从这些抽象带来的安全性中受益。

国际量制将所有这些都是为长度量。它们形成了一棵树,一个层次结构。有很多这样的量,你希望从这样的抽象中受益。用户也可以轻松地用自己的类型扩展这些树。

所以让我们添加一些像“水平长度”的东西。我们声明它是长度量的一个叶子。你有“水平面积”,构建为面积的一个叶子,其方程为水平长乘以宽。所以它是水平面积。我们可以检查水平长度是长度,但并非每个长度都是水平长度。每个水平面积都是面积,但反之则不然。然后我们也可以将长度乘以长度转换为面积。但我们不能将长度乘以长度转换为水平面积,因为并非每个长度乘以长度都是水平面积。但水平长度乘以宽度是水平面积。所以所有逻辑都有效,并提供额外的类型安全。

这就是你如何使用MP Units实现这个:指定水平长度的量、宽度的量、高度的量,提供构造函数,并提供地板作为水平面积。由用户指定他们希望如何初始化它。用户可能完全忽略我们正在使用强类型量这一事实,而只是提供这些东西,这再次不安全,但更容易。用户可能决定他们不想关心。但如果用户关心,那么他们可以提供这种语法或那种语法。这将创建适当类型的强类型量。在底层实现方式上有一个小差异,但结果完全相同。所以由用户决定他们是否希望在所有这些方面都安全。但作为供应商,你可以提供始终类型安全的库。用户可以根据具体情况决定是选择安全性,还是在不关心的情况下使用最简单的做法。

如果你重新排列参数,提供一个错误,你会得到一个很好的错误信息,提示“无法从高度的量转换为水平长度的量”。这正是我们在这里的意图。然后诺曼会很高兴。


第1.14节:量种类的重要性

另一个可能的问题是,这个计算的结果是什么:1赫兹 + 1贝克勒尔 + 1波特?赫兹是频率的单位,贝克勒尔是放射性活度的单位,波特是调制率的单位。它们都是量纲为 T^-1 的量。

我问了市场上几个不同的库。这个库没有波特,所以我只用了赫兹和贝克勒尔。它声称结果是2赫兹。我想,好吧。它总是取左边的单位,对吧?让我们试试另一个。结果是什么?2每秒。我问了市场上另一个最受欢迎的库,也是一个非常好的库。答案是2秒^-1,稍微好一点,因为它是共同单位。但仍然允许了加法。我问了Pint。Pint有波特。3赫兹。我再次尝试了音频反转版本。3贝克勒尔。我问了Java。他们怎么做。别笑。这就是Java中人们必须做算术的方式。他们得到一个错误。

你认为这些答案中哪个是正确的?Java是正确的。这不应该编译。实际上,这个编译错误非常好。真的非常好。整个错误信息。没有像C++那样长达10页。这是运行时错误,但有些问题在编译时也能检测到。我认为,我认为它可以在编译时被捕获。我现在不确定,但我在其他演讲中已经证明了这一点。Java有些东西在编译时发现,有些在运行时发现。Pint总是在运行时发现,因为在Python中没有编译时。但Java有些东西在编译时发现,结果真的非常好。我真的很高兴看到这些错误信息。我希望在某个时候,C++能够有类似的东西,但我们会看到,有一份C++26的论文提案,允许我们提供更好的文本,更好的错误信息追踪。希望它将能够提供类似的东西。

为什么Java在这里是正确的?因为ISO 80000说有一种叫做“量种类”的东西。它说量可以分组到相互可比较的量类别中。相互可比较的量称为同种量。两个或更多的量不能相加或相减,除非它们属于同一类相互可比较的量。给定量制中同种的量具有相同的量纲,但具有相同量纲的量不一定是同种的。这些是ISO 80000的官方定义。而Java是这里唯一正确的。

如果你查看国际量制定义,有很多量纲为 T^-1 的量定义:角速度、频率、旋转频率、角频率、放射性活度、传输速率等等。为了表达这里指定的种类,我们引入了种类修饰符。它表示属于同一种类、同一量树的量族,就像你之前看到的长度量一样。

033:思考并行

大家好,我是 Bryce Adelstein Lelbach。

我是 NVIDIA 的首席架构师,负责并行编程模型、编译器和 C++ 库的战略规划。我在多个编程语言和标准组织中担任领导职务,包括 C++ 委员会,我曾担任库演化组主席。

今天,我很荣幸我的母亲也来到了现场。多年来,她是我唯一的照顾者和榜样,我所有的成功都归功于她的爱与指导。让我们为她鼓掌,她就在这里。

我于 12 年前开始编程,从一开始,并行性就是我的热情和关注点。我很高兴今天有机会与大家分享这份热情。在我职业生涯初期,我有幸师从 Hart Kaiser,他教会了我如何以并行方式思考。在编程生涯初期就培养这种思维方式,多年来对我而言是无价的,因为这是我解决问题时的默认思维方式。

我认为并行性的挑战在于,我们默认是顺序思考的。并行性通常被视为具有挑战性和复杂性,是一种需要谨慎使用、仅由专家使用的工具。但问题是,今天我们生活在一个并行的世界中,几乎每个硬件平台都是并行的,从最小的嵌入式设备到世界上最大的超级计算机。因此,我们必须改变思维方式。今天任何编写代码的人都必须并行思考,并行性必须成为我们默认的思维方式,而不是只在需要时才偶尔使用的奇特工具。

今天,我们将聚焦于一个基础的并行原语,许多算法都可以基于它构建,那就是扫描算法。通过对扫描算法的探索,我们将涵盖我认为每个人都必须学会培养的并行思维模式的一些关键方面。

扫描算法接收一个范围和一个二元操作符,并为每个元素生成该元素之前所有元素的和。

扫描算法有时被称为部分和或前缀和。在 C++ 中,我们有几种不同形式的扫描:partial_sum,以及较新的 inclusive_scanexclusive_scan。今天我们将只讨论相当于 C++ 中 inclusive_scan 的算法。

扫描的输出是一个与输入长度相同的范围。

你可以使用任何二元操作符进行扫描,但该操作符需要满足一些要求。今天我们将使用加法来代表这个操作符。

扫描会保留输入的顺序,从而保留求和顺序,因此该操作不需要满足交换律。像字符串连接这样的操作是支持的,这一点非常重要,因为许多有趣的扫描操作符都是非交换的。

为了并行化扫描,我们必须引入临时聚合,这意味着需要满足结合律和左单位元值的要求。这表示我们需要能够用一个默认值初始化一个临时变量,然后将其与序列中的某个元素相加,得到的结果与直接将该元素相加相同。这允许我们任意分割输入,在不同的线程中处理不同的块,然后再将它们组合起来。

以下是一个简单的串行扫描实现,它在原地操作。对于第一个元素之后的每个元素,我们通过将前一个元素加到当前元素来计算和。

让我们通过一个简单的扫描示例来逐步计算。我们的输入将是一个包含九个单字符字符串的范围,操作符是字符串连接。

结果的第一个元素就是输入的第一个元素。

第二个元素将是前两个元素的连接。

第三个元素将是前三个元素的连接。

这是完整的扫描序列的样子。注意,结果的最后一个元素是整个序列的和,即连接整个字符串数组的结果。这是一个我们稍后将依赖的重要属性。

现在,我们如何开始并行化扫描?起初,这可能看起来很有挑战性,因为每个元素的结果都依赖于其之前所有元素的结果。如果你只看我们这里写的简单版本,似乎存在线性依赖,无法并行化,但实际上是可以的。

当我处理并行问题时,我倾向于将其分解为三个不同的组成部分。

首先是分布:我将如何划分和分配工作?今天,我们将把工作划分为瓦片,瓦片只是输入序列的连续子集。

其次是计算:需要在每个瓦片上执行哪些计算?

第三是通信:瓦片之间需要进行哪些通信?

在这三者中,通信是最需要识别和理解的部分,但通信通常决定了你如何分配工作,以及何时可以计算什么。

为了并行化扫描,我们首先应该考虑它如何进行通信。事实上,我认为扫描不是一个算法,而是一个通信原语。

扫描的每个元素都将信息发送给所有后续元素。扫描操作符将看到每一个元素。顺序被保留,因此我们可以推理相邻元素之间的关系。

扫描可以执行全局的、顺序感知的推理,这使其非常强大。

如果每个元素都必须直接与并行中的其他每个元素通信,我们将会有很大的开销。

但正如我们之前讨论的,我们要求提供的扫描操作符满足结合律。

结合律可以限制我们对通信的需求,因为我们可以将问题划分为瓦片,对每个瓦片进行局部的串行扫描,然后用从其他瓦片通信过来的信息更新局部扫描,以获得最终正确的结果。

让我们看看如何做到这一点。我们的算法将接收两个输入:一个范围(将原地操作,因此它既是输入也是输出)和要划分范围的瓦片数量。

我们将使用 C++17 的并行算法,在瓦片的索引空间上启动一个 parallel_for_each

这个 parallel_for_each 的主体将为每个瓦片调用一次,并处理一个唯一的瓦片。瓦片的数量可能超过系统用于并行化的线程数量。

在瓦片主体内部,我们首先要做的是计算该瓦片负责的输入子范围。我们将使用这个 range_for_tile 辅助函数来完成,它只是尽可能均匀地在瓦片之间划分输入,然后处理余数等问题。

然后,我们获取每个瓦片中的元素,并对它们执行局部串行扫描,产生结果。我们在代码中直接完成这一步。

接下来,我们需要在瓦片之间传播信息。每个瓦片的总和需要加到所有前驱瓦片的元素上。

我们需要某个地方来存储这些信息,每个瓦片一个槽位。因此,在 parallel_for_each 循环之前,我们创建一个向量。

每个瓦片的总和是包含性扫描的最后一个元素,也就是我们刚刚执行的局部串行包含性扫描的结果。因此,我们可以将其赋值给 locals 向量中相应的槽位。

来自不同瓦片的赋值可能同时发生,但这没关系,因为每个瓦片使用 locals 中不同且唯一的槽位,而且目前还没有人读取 locals,所以这里没有数据竞争。

然后,在所有瓦片完成其局部包含性扫描并写入 locals 之后,我们需要一个线程对 locals 执行串行包含性扫描。

locals 进行扫描的结果将看起来像这样。如你所见,每个瓦片需要添加到其元素中的信息,就在其直接前驱瓦片的 locals 槽位中。

接下来,我们需要再次进入并行状态,用那个校正值更新每个瓦片中的元素。

我们跳过第一个瓦片,因为它不需要任何更新,它前面没有瓦片,所以它的元素已经有了最终结果。

我们使用一个串行 for_each 执行更新,将 locals 中适当的值加到每个元素上。注意,我们将 locals 值加在左侧以保持顺序。你会注意到,在整个演讲中,我们进行求和操作的顺序都是经过仔细考虑的,因为我们必须保持全局顺序。

经过那次加法之后,我们将得到每个元素的正确结果。这是一个经典的、简单且工作高效的并行扫描实现策略,通常被称为“向上扫描-向下扫描”。

然而,它需要额外的输入、临时存储,并且需要两轮扫描,因此有两次全局同步。这需要大量的临时存储,因为我们的输入规模可能非常大,尤其是在并行计算时。

我们在第一部分(向上扫描)开始并行,执行局部扫描并将其存储到 locals 向量中。

但随后我们有一个全局同步,所有瓦片必须等待所有其他瓦片完成。只有在那之后,我们才能对 locals 执行扫描,也就是中间那个红色的小部分。

最后,我们再次进入并行状态,这是向下扫描阶段,我们执行更新。但正如我们在这里看到的,中间那个对 locals 的串行包含性扫描显然是一个瓶颈。

这种串行同步带来了巨大的成本,因为它减少了可以并行执行的代码比例,并增加了我们必须等待且没有其他工作可做的时间。这将严重损害可扩展性,阻碍我们从单线程扩展到 CPU 上的数百个线程以及 GPU 上的数十万个线程时的性能。

我在并行编程中的两个指导原则是:本地化同步和隐藏延迟。当我们必须同步时,我们必须将其最小化,只与我们真正需要通信的执行者同步。我们必须识别真正的依赖关系,而不仅仅是依赖全局同步的便利性。

如果我们确实需要等待,我们需要找到在等待期间可以完成的其他有用工作。

将这些原则应用于扫描,我们必须思考每个瓦片实际上依赖于什么。每个瓦片都需要等待所有瓦片完成才能继续吗?不,我们只需要等待前驱瓦片完成其局部扫描并写入 locals 向量。

现在,让我们开始第二次实现扫描,这次使用一种称为“解耦回望”的可扩展策略,我们将只等待我们实际依赖的瓦片。

在这个版本中,我们将不再使用临时向量,而是使用一个 scan_tile_state 对象来促进瓦片之间的通信。我们稍后将详细讨论其工作原理,现在请暂时相信这一点。

这次我们将只有一个并行阶段,一个遍历所有瓦片的 parallel_for_each

在那个 for_each 的主体中,我们将像之前一样计算每个瓦片的子范围。

我们将像之前一样执行局部串行扫描。

但是,我们不是将瓦片的总和存储在 locals 向量中,而是通过调用 set_local_prefix 将其传递给 scan_tile_state。同样,我们稍后将研究其工作原理。

然后,如果我们不是第一个瓦片,我们需要与其他瓦片通信,以获取必须前置到我们瓦片所有元素的前缀。

我们将通过调用 wait_for_predecessor_prefix 来实现,它将返回我们需要添加到所有元素的前缀。

然后,我们将通过将该前缀加到左侧来更新瓦片中的每个元素。

现在,让我们分解一下这里发生了什么。每个瓦片执行其局部串行扫描,这大致上是一个固定量的工作。然后调用 set_local_prefix,发布后续瓦片所需的信息。这应该大致在同一时间发生。可能不会同时发生的是其他线程观察到该内存。

然后,每个瓦片必须等待其前驱前缀变得可用。当这些瓦片在等待时,因为它们是在这个并行 for 循环中本地等待,其他瓦片可以开始执行它们的局部串行扫描并发布结果,将有用的工作与必要的等待重叠起来。

最终,每个瓦片的前驱前缀变得可用,该瓦片完成,另一个瓦片开始执行。通过在这里利用过订阅,即拥有比物理线程多得多的瓦片,我们可以有效地隐藏等待的延迟。

但为了使这一切正确工作,我们需要保证瓦片是按单调递增顺序执行的。也就是说,只有当所有前驱瓦片 X 要么正在执行,要么已经完成时,瓦片 X 才能执行。我称之为单调进展。

如果我们没有这个保证,在具有较弱进展保证的系统上,例如有界线程池或 GPU,我们可能会陷入所有物理线程都在执行那些正在等待尚未启动的瓦片的瓦片,而那些瓦片永远不会启动,因为所有执行资源都被阻塞在等待它们执行上。这是并行前进保证系统中典型的死锁情况。

为了确保单调进展,我们必须改变分配瓦片 ID 的方式。到目前为止,我们一直使用 iota 创建一个整数范围,并将其传递给 parallel_for_each

相反,我们将创建一个原子整数,并让这个 parallel_for 循环的每个主体执行一个宽松的 fetch_add 来为自己分配一个唯一且单调递增的瓦片 ID。

现在,让我们看看 scan_tile_state 如何在瓦片之间执行实际的通信。

scan_tile_state 包含一个描述符向量,每个瓦片一个。

描述符有两个前缀:local,它只存储瓦片元素的总和;complete,它存储从范围开始到该瓦片结束的所有元素的总和。

描述符还有一个 state,它可以是 unavailablelocal(表示本地前缀可用)或 complete(表示完整前缀可用)。这个状态是原子更新的,这就是我们在线程之间进行通信和同步的方式。

当你构造一个 scan_tile_state 时,每个瓦片的 localcomplete 前缀被默认构造为我们之前讨论的左单位元状态,并且 state 被初始化为 unavailable。状态变为 unavailable 的唯一方式就是初始化时。

瓦片通过调用 set_local_prefix 来发布其元素的总和,我们在扫描算法主体中见过它。set_local_prefix 接收一个瓦片 ID 和你正在设置的本地前缀的值。

第一个瓦片是一个特例。它没有前驱,所以它的本地前缀也是它的完整前缀。因此,我们将传入的值同时赋给描述符的两个字段。

然后,我们执行一个 store release 将状态设置为 complete,表示前缀可用。这将与 wait_for_predecessor_prefix 中的 wait acquire 同步,我们稍后会看到。

对于所有其他瓦片,我们只设置本地前缀。然后,我们通过将状态设置为 local 来发出其可用的信号。最后,我们执行 notify_all 来唤醒任何正在等待这个原子的瓦片。

wait_for_predecessor_prefix 由瓦片调用,以检索瓦片之前所有元素的总和,该总和必须添加到它们的所有元素中。

wait_for_predecessor_prefix 搜索前驱描述符,从其前驱的 localcomplete 前缀中重建一个前驱前缀。

这里我讨论了三种类型的前缀:local 前缀,它是给定瓦片中元素的总和,仅此而已,只是那些元素的本地总和。

前驱前缀是给定瓦片之前所有元素的总和,不包括瓦片本身的元素。即从 0 到瓦片开始的所有元素。

前驱前缀由 wait_for_predecessor_prefix 返回,但它们实际上并不存储在描述符中,它们只是即时构建的,因为我们不需要实际存储它们。

完整前缀是从范围开始到瓦片结束的所有元素的总和。因此,完整前缀是前驱前缀(瓦片之前的所有内容)和本地前缀(瓦片内的内容)的总和。

这里有三种类型的前缀。让我们通过一个例子来理解。我们将使用字符串连接来扫描一个字符串向量。我们将输入划分为四个瓦片,每个瓦片三个元素。

我们从每个瓦片的局部串行扫描开始,这将产生这个结果。

每个瓦片的本地前缀是其所有元素的总和,正如我们讨论的,这是局部扫描的最后一个元素。

每个瓦片的前驱前缀是其之前所有元素的总和。对于第一个瓦片,什么都没有。对于第二个瓦片,是第一个瓦片的总和 ABC。对于第三个瓦片,是前两个瓦片的总和 ABCDEF。对于第四个瓦片,是前三个瓦片的总和 ABCDEFGHI

每个瓦片的完整前缀是前驱前缀加上本地前缀。

现在我们已经理解了如何计算这些前缀,我们准备看看 wait_for_predecessor_prefix 本身。为了构建前驱前缀,每个瓦片将搜索其前驱瓦片的描述符,从紧邻其之前的瓦片开始,向后移动到瓦片 0。

对于每个前驱瓦片,如果其状态是 unavailable,那么我们等待它。如果其状态是 local,那么我们将它的本地前缀加到前驱前缀上,并继续搜索。如果其状态是 complete,那么我们将它的完整前缀加到前驱前缀上。如果我们遇到一个完整前缀,那么我们可以停止,因为一个完整前缀包含了从范围开始到当前位置的所有信息。

这就是为什么我们称这种技术为解耦回望。每个瓦片从其位置向后搜索,每个瓦片独立于其他瓦片进行搜索,尽管它们可能使用彼此发布的一些信息。独立搜索在这里很重要,因为由于内存系统的工作方式,一些线程可能在其他线程之前看到前缀的发布。

因此,如果一个线程在另一个线程之前看到一个更新,它允许它取得进一步的进展。也可能出现两个瓦片同时向后看的情况,可能计算一些相同的东西,这完全没问题,因为一点点冗余工作是可以接受的,在许多情况下比等待要好。

现在,让我们看看 wait_for_predecessor_prefix 的实现。

我们首先默认构造一个 T,我们将把前驱前缀求和到其中。

接下来,我们从 i-1 循环到 0,即从紧邻我们之前的瓦片,一直回到第一个瓦片。

对于每个前驱瓦片,我们执行一个 wait acquire,直到其状态不再是 unavailable。这建立了与 set_local_prefixstore release 的“与...同步”关系。因此,一旦状态不再是 unavailable,这个调用就会返回。我们知道它已经处于 unavailable 状态,因为它一开始就是那样。如果它已经被更新为不是 unavailable,那么这将立即返回。

等待之后,我们再次加载状态,因为我们需要知道它是什么。要么是 local,要么是 complete。在这一点上,我们知道它不再会是 unavailable,因为状态只在初始化期间被设置为 unavailable,而那是在临界区之前。因此,此时状态必须是 localcomplete

如果状态是 local,那么我们将其加到我们正在构建的前驱前缀上,加上该瓦片的本地前缀。记住,我们必须从左到右求和以保持顺序,这就是为什么我们不使用 +=,因为它会以错误的顺序进行,会把它加到右边。

否则,如果状态是 complete,那么我们将其求和到前驱前缀中,并停止回望,因为我们遇到了一个覆盖从零到当前位置所有元素的完整前缀,因此我们完成了。

一旦我们完成了前驱前缀的构建,我们必须计算并发布我们自己的完整前缀。我们通过将前驱前缀与我们的本地前缀求和来计算它。

然后,我们通过一个 store acquirecomplete 存储到我们的状态,并执行 notify_all 来唤醒任何等待我们状态的线程,以此来发出其可用的信号。这将与此函数中较早的 waitload 同步,确保如果一个线程观察到状态是 localcomplete,那么它们将看到 localcomplete 前缀的正确写入。

最后,我们返回我们构建的那个前驱前缀。

让我们通过另一个例子来更好地理解这是如何工作的。这是一个有八个瓦片的扫描执行快照。

让我们为这里的瓦片 3 执行 wait_for_predecessor_prefix。我们首先查看瓦片 2,其状态是 local,因此我们将其本地前缀求和到我们正在构建的前驱前缀中。

接下来,我们查看瓦片 1,其状态是 local。注意,瓦片 1 实际上已经写入了它的完整前缀,但我们还没有观察到改变状态的 store release,所以我们不能使用它,因为我们还没有内存的一致视图。在另一个线程中,它可能已经发生了,但这并不意味着我们已经观察到它发生了,所以我们不能使用那个信息。

因此,我们只是将其本地前缀求和到我们的前驱前缀中,然后继续,而不是必须等待。

最后,我们查看瓦片 0,其状态是 complete,因此我们将这个完整前缀加到我们的前驱前缀中,然后完成。无论如何我们都会在这里完成,因为我们在瓦片 0,它前面没有瓦片。瓦片 0 也总是 complete,或者它总是 unavailablecomplete,永远不会变成 local

接下来,我们通过将前驱前缀和我们的本地前缀求和来计算我们的完整前缀。然后我们将其写入我们的描述符,并将此瓦片 3 的状态原子地设置为 complete

好的,接下来让我们处理瓦片 5。我们从瓦片 4 开始,其状态是 local,我们将其本地前缀求和到我们的前驱前缀中。然后我们查看瓦片 3,其状态是 complete。我们将其完整前缀求和到我们的前驱前缀中,然后我们就完成了。我们在这里可以提前完成,因为我们找到了一个完整前缀。其他人已经完成了向后搜索并汇总了我们所有前驱的本地前缀的工作,我们找到了它,所以我们就完成了,不需要继续搜索。

最后,我们计算我们的完整前缀,将其存储到我们的描述符中,并原子地将我们的状态更新为 complete

这就是 scan_tile_state 的全部内容。现在我们应该理解了在我们之前编写的解耦回望扫描中如何进行通信。在我们执行串行包含性扫描后,我们调用 set_local_prefix,它发布我们的本地前缀。然后,对于不是第一个瓦片的瓦片,我们在 wait_for_predecessor_prefix 调用中进行这种回望搜索。一旦我们得到结果前驱前缀,我们就将其加到我们瓦片的所有元素中,然后我们就完成了。

我们的解耦回望实现只需要 O(瓦片数量) 的存储,而我们最初编写的两遍实现(向上扫描-向下扫描版本)需要 O(输入大小) 的存储。在我的基准测试中,我使用了 40 亿个元素和 1000 个瓦片。因此,你肯定希望处于只需要瓦片数量级存储,而不是输入大小数量级存储的范围内。我将在最后回答问题。有幻灯片编号,如果你对特定幻灯片有问题,可以记下来。

我们的解耦回望实现是单遍的,只需要一次全局同步,而我们的两遍方法需要两次全局同步,有两次并行启动和两次并行汇合。就原始性能而言,我在一个 64 核 CPU 系统上,针对 4GB 的问题规模,观察到解耦回望扫描比两遍扫描快大约三倍。在 GPU 和分布式规模上,解耦回望将比两遍扫描快得更多,可能是数量级的差异。这大致就是我们在 NVIDIA GPU 上实现扫描的方式。

扫描本身值得研究,但其重要性在于我们可以用它构建什么。许多问题具有求和、计数、过滤、搜索或邻居感知的方面,这些通常可以用扫描来解决。今天我们将看两个可以用扫描解决的问题的例子。

第一个是 copy_ifcopy_if 接收一个输入范围和一个一元过滤操作。

例如,这里我们有一个字符范围和一个返回字符是否为字母的函数。

它产生一个范围,其中只包含输入中过滤操作返回 true 的元素。

copy_if 不能孤立地评估每个元素,因为要确定输出中写入元素的正确位置,我们必须知道它之前有多少元素通过了过滤。

弄清楚它之前有多少元素会通过过滤,那个“多少”部分是一个计数任务。扫描擅长计数任务。

这听起来像是扫描可以解决的问题。我们将从一个基于扫描的简单 copy_if 实现开始。

我们的 copy_if 函数将接收一个输入范围、一个输出迭代器和一个过滤操作。

首先,我们将为每个输入元素创建一个带有标志的向量。

然后并行地,我们将为每个输入元素评估过滤操作,将结果存储在标志向量中。

接下来,我们将对这个标志数组进行扫描,操作是整数加法,输出是整数。这被称为“加扫描”。这种评估谓词或某种比较函数,然后扫描结果的技术,对于过滤、组合、分组等这类算法非常常见。

我们将为加扫描的输出创建存储。我们需要每个元素一个整数,加上一个额外的整数用于最终计数我们复制了多少个元素。暂时相信我这一点,稍后会清楚。这是我在这里使用的一个小技巧。

现在,我们对标志进行并行扫描,输出到 indices,从 indices 的第二个元素开始输出。indices 的第一个元素被默认初始化为零,这总是第一个元素的正确输出索引。

我们将偏移一个位置写入 indices。现在让我们看一个例子,用图形方式解释为什么这样做。

让我们为这个问题构建 indices 向量。根据规定,我们说第一个输出索引总是零。

我们将输出偏移一个位置,因为当我们遇到一个为 true 的元素时,我们在当前索引处输出它,然后为下一个要写入的元素增加索引。

例如,这里 a 是一个字母,所以它将被写入索引 0。当我们看到那个 true 标志时,我们知道 a 之后的下一个字母需要写入索引 1。

b 也是一个字母,它将被写入索引 1。下一个字母将被写入索引 2。

2 不是字母,所以不会被写入,因此索引不递增。同样,3 不是字母,索引不递增。4 也是如此。

E 是一个字母,它将被写入索引 2。然后下一个字母将被写入索引 3。

然后,indices 的其余部分以类似的方式扫描,产生这个结果。我们需要末尾那个额外的元素,因为如果最后一个元素返回 true(这里不是这种情况),那么 indices 的最后一个元素将递增 1,这将给出返回 true 的元素数量的正确计数,否则我们的计数会少一个。

好的,现在我们将执行下一步,遍历标志向量,对于每个 true 标志,我们在其关联的索引处写入该元素。我们称之为“写入阶段”。

a 被写入索引 0。b 被写入索引 1。E 被写入索引 2。G 被写入索引 3。然后,indices 的最后一个元素具有写入了多少个元素的计数。

让我们看看代码中的写入阶段。它是一个 parallel_for_each 循环,迭代输入、标志和 indices,所有这些都压缩在一起。

对于每个元素,如果标志为 true,那么我们就在 indices 指定的索引处写入该元素。

为了计算我们实际复制到输出的子范围的边界,我们使用 indices 的最后一个元素,它给出了写入了多少个元素的计数。

然后,我们返回我们写入的 out 的正确子范围。这样,我们就有了一个并行的 copy_if 实现。

这个实现需要大量的临时存储,数量级是输入大小的两倍,因为我们需要那个标志数组和索引数组。特别是如果输入规模较小,我们最终可能需要比实际数据多得多的临时存储。这也使用了三个并行阶段,意味着有三次全局同步,其中两次我们希望在一个单遍算法中避免。

现在,让我们用解耦回望来改进它。

重构它以使用解耦回望编写,我们现在需要进行分块,这意味着除了其他参数外,我们还需要一个瓦片大小。

我们需要一个用于 indicesscan_tile_state,以及一个用于分配 ID 的原子变量,就像我们之前做的那样。

接下来,我们有我们的瓦片循环,我们的并行瓦片循环。在那个循环的主体中,我们分配瓦片 ID 并计算这个瓦片的子范围,就像在扫描的解耦回望实现中一样。

接下来,我们将为标志创建瓦片本地存储,瓦片中的每个元素一个。我们将过滤操作评估到标志中。这里我们只是在运行时动态分配这个存储,但也可以预分配,这取决于我们有多少物理线程。如果你使用一个有 16 个线程的线程池运行,你可以让每个线程都有自己的缓存,作为计算这些瓦片的暂存空间。

或者它可以是静态大小并在栈上,这将需要静态瓦片大小。这就是我们在 GPU 上实际的做法,这个本地标志的范围实际上就在线程栈上。

然后,我们也将为 indices 创建瓦片本地存储。同样,我们需要瓦片中元素数量加一个额外的元素,以获得这里有多少东西的正确计数。

然后,我们将使用 set_local_prefixwait_for_predecessor_prefix 对标志执行解耦回望扫描,就像我们在扫描中做的那样。

我们将偏移一个位置输出到 indices,就像我们在三遍 copy_if 中做的那样。

最后,我们执行写入阶段,这与我们之前看到的相同,只是在这里本地化和分块了。需要注意的一点是,我们在这里不对 out 进行分块。我们不会为 out 获取本地范围,因为我们将使用构建的全局索引索引到 out 中。如果我们有从元素 0 到瓦片中任意元素的本地索引,我们会写入错误的位置。

最后,我们将返回我们输出的元素的子范围。使用最后一个瓦片的完整前缀,它将具有写入元素的最终计数。这再次与我们在三遍版本中所做的非常相似。

现在我们有了一个更好的 copy_if 实现。它只需要 O(瓦片数量) 的存储,而不是 O(输入大小) 的存储。记住,在我的测试中,我使用了数十亿的输入元素和数千个瓦片,所以这是非常显著的。

它的性能也快得多,在我测试的 64 核 CPU 系统上,比我们之前看到的三遍实现快大约 11 倍。

好的,现在我们将进入本次演讲的高潮,一个我认为真正美丽,虽然有点更复杂的算法:chunk_by

chunk_by 接收一个范围和一个二元比较操作。

它将输入范围分割成子范围,分割点位于每一对相邻元素之间,对于这些元素,比较操作返回 false。因此,它的输出是一个子范围的序列。在所示的示例中,我们有以换行符结尾的文本,以及一个如果左侧不是换行符则返回 true 的操作,因此这将把文本分割成以换行符结尾的单独行。

chunk_by 的棘手之处在于它处理相邻的元素对,这增加了一些复杂性。让我们看另一个例子。我们有一个包含九个字符的范围,我们的比较操作就是相等性。

我们将把连续相等的元素分组在一起。对于前两个相邻元素,操作返回 false,因为它们不相等。然后对于下两个,是 true。然后,false。然后 true,然后 true,然后 false,依此类推。从中我们可以看到有四个分割点(四个 false),我们需要形成五个不同的块。

我们如何并行化这个?这里我们需要三部分信息。

首先,我们需要知道这个块在输出中的索引是什么,即这个块是第几个,它前面有多少个块。这与 copy_if 类似,我们需要计算我们已经写了多少东西的计数,以确保我们将这个块写到正确的位置。

其次,我们需要知道每个块从哪里开始。这听起来也像是某种计数问题。

最后,我们需要知道每个块在哪里结束。同样,这听起来像是一个计数问题,这正是扫描可以做的。

这里的技巧是我们不想做三个不同的扫描来得到这些答案,我们想做一个大的块状扫描,同时完成所有这些事情。这意味着我们需要定义一个巧妙的数据类型,它将为每个元素跟踪所有这些信息。我们将称之为 interval,原因可能显而易见。

interval 中,它将有四个不同的成员。

第一个是 flag,它保存应用比较操作的结果。如果此元素与其左侧邻居之间存在分割,则为 false,否则为 true。这是 interval 中我们不会扫描的唯一成员。它只是我们在评估比较谓词时设置,然后稍后使用的东西。

接下来是 index,它指示当前组应写入输出的位置。这将是标志取反后的加扫描。稍后会明白。

flagindex 的计算方式与我们在 copy_if 中所做的类似。在 copy_if 中,我们对非反转的标志进行了加扫描,但在 copy_if 中,谓词在我们要做某事时返回 true,而在这里,谓词在我们要做某事时返回 false,这就是为什么我们扫描标志的取反。

接下来是 count,它需要跟踪与此元素共享索引的元素数量,即当前块中有多少个元素。因为所有共享索引的元素都在同一个块中。

最后是 end,它只是元素总数的累计计数。这给出了当前元素之后的位置,这是我们构造此块范围所需的信息之一。

好的,接下来我们需要定义如何对 interval 求和。

interval 视为三个求和分量的聚合:indexcountend。因此,它的求和操作必须执行三个独立但相关的求和。我们不求和 flag,我之前提到过,flag 只是在初始化 interval 范围时由比较操作设置的。所以当我们将两个 interval 相加时,我们总是取右边的 flag

index 被初始化为 flag 的取反,然后我们只是对它进行加扫描,所以就是左边的东西加上右边的东西,基本的加法。

count 有点棘手,因为它跟踪当前块中有多少个元素。所以每当当前块结束时,它需要被重置。当索引发生变化时,我们可以判断当前块结束,当右侧的索引非零时,就会发生这种情况。因为否则,如果它是零,则表示它是左侧块的延续。实际上,情况是,它几乎总是零或一。

最后,对于 end,我们只进行加扫描。end 将为每个元素初始化为 1,为某些单位元元素初始化为 0。这意味着序列中每个存在的元素都会有一个 1,因此求和将给出我们有多少个元素的计数,一个累计的运行计数,这正是我们想要的。

现在请暂时相信这一点,因为我们将通过一些例子来演示这一切是如何工作的。

我们的 chunk_by 算法将接收一个输入范围、一个输出范围和一个比较操作,我们将从一个简单的多遍版本开始编写。

我们将需要一个 interval 的向量,每个元素一个,加上一个额外的用于最终有多少组的计数。这与我们在 copy_if 中所做的相同,我们添加一个额外的元素来跟踪大小。

接下来,我们需要初始化 interval,这比我们在 copy_if 中看到的 indices 初始化要复杂一些。

我们将从初始化 interval 的内部点开始,即除了第一个和最后一个元素之外的所有元素。我们将并行进行这个初始化,因为我们希望并行评估我们的比较操作。

对于每个 interval,我们需要查看两个相邻元素,因为我们的比较操作必须应用于两个元素。一个 interval 总是代表操作右侧的元素。

我们还需要左侧的特殊单位元值来处理第一个元素,以及右侧的特殊单位元值来关闭最后一个组并给出我们有多少组的最终计数。

我们将在这里处理这些单位元值,夹在那个初始化代码之间。

现在,对于每个内部点,我们首先评估比较操作,将 flag 设置为该比较的结果,并将 index 设置为其取反。

我们将 countend 都初始化为 1,因为此时,我们正在初始化的这个 interval 代表一个单一元素。当我们为一个瓦片求和 interval 时,它们将开始代表多个点,然后 count 可能会更大。

对于我们的示例问题,内部点的初始状态将看起来像这样。你可以看到我们有 flag,有 index(它是 flag 的取反,但作为整数),然后 countend 都是 1。到目前为止非常简单。

对于第一个元素,即左单位元,我们将 flag 设置为 true,因为它不代表组的结束。我们将 index 设置为 0,因为那是我们将在输出中开始写入的索引。我们总是从索引 0 开始写入。

对于右单位元,即最后一个元素,我们需要它来关闭最后一个组,否则最后一个组永远不会被写出。因此,我们将 flag 默认设置为 false,表示分割。我们将 index 设置为 1,表示此元素不属于与较早元素相同的块。正如我们在定义如何扫描 count 时看到的,它检查索引以查看左侧和右侧的东西是否在同一个块中。

添加了这些特殊的单位元值后,我们就得到了 interval 的完整初始状态,看起来像这样。

好的,接下来我们对这些 interval 进行并行扫描,我们将逐步了解这是如何发生的。

首先,我们将处理 index。我们只是对 index 进行加扫描,它从 flag 的取反开始,所以我们最终得到这个结果。它与 copy_if 中索引扫描的结果非常相似。

接下来,我们将扫描 count。记住,count 在索引发生变化时被重置。

对于第一个元素,输出与输入相同,就是我们从原始计数中得到的结果,只是被复制下来。

对于下一个元素,count 将被重置,因为索引发生了变化。左侧的索引是 0,右侧的索引是 1,所以 count 原本会是 2,但我们将其重置为 1,因为我们检测到它是一个组的结束。

对于下一个元素,count 增加,因为索引相同。

然后对于下一个元素,索引不同,这意味着它是一个块的结束,我们必须再次重置 count,所以我们重新从 1 开始计数。

下一个元素的索引相等,所以 count 递增,其后的元素也是如此。然后我们以这种方式处理其余元素,产生这个结果。

最后,我们需要处理 end 的扫描,这更简单一些。这只是一个没有重置的加扫描。所以它只是产生这个结果,从 1 到我们有多少个东西,再加上右单位元的一个额外计数。

这就是扫描后我们的 interval 向量中将有的内容。我让你看一会儿。

好的,接下来是这里的写入阶段,它将类似于 copy_if 的写入阶段。这是我们将使用我们在 interval 中构建的信息来产生块并将它们写出的地方。这个写入阶段与扫描和 copy_if 的写入阶段之间的区别在于,这个写入阶段是在相邻的 interval 上完成的,这与我们为扫描和 copy_if 所做的有点不同。

如果右侧 intervalflagfalse,那么它代表一个块的结束。但右侧 interval 代表块结束之后的一个位置,所以刚刚结束的块包含在左侧 interval 中。因此,块中的最后一个元素将由我们看到这个 false 标志之前的东西表示。这就是我们将用于右侧的东西。

我们可以在这里看到哪些标志是 false,然后我们将写入哪些索引。

我们写入左侧 interval 指定的那个索引。记住,chunk_by 的输出是一个块的序列,即范围的序列,所以我们需要在这里构造我们将要输出的范围。

我们将使用 subrange 来实现,我们需要使用 intervalendcount 成员来构造开始和结束的索引。C++ 中的范围索引是半开区间,所以结束应该是最后一个元素之后的位置。

intervalcount 有这个块中元素的计数。end 有当前元素之后的位置的索引。

因此,子范围结束之后的位置的索引就是 intervalend 成员。很简单。

区间的开始是结束之后的位置,即 interval.end 减去这个块中元素的数量(我们有多少东西),也就是 interval.count

对于我们的示例

034:使用对抗性方法测试C++模板

在本教程中,我们将学习如何逐步改进C++模板的测试方法。我们将探讨如何以结构化的方式测试模板函数和类模板,并引入一种名为“对抗性测试”的新方法,以增强测试的可靠性。内容涵盖编译器运行时变化、测试数据管理以及如何确保测试本身的质量。

1:引言与背景

上一节我们概述了本课程的目标。本节中,我们将介绍演讲者的背景和本次讨论的上下文。

我同时学习过数学和计算机科学,并在职业生涯中不断切换这两个领域。目前,我在博世公司工作,这是一家技术公司,主要从事汽车雷达和视频传感开发。我们使用一种基于C++模板的库,其中包含特殊的容器和数学函数。

本次演讲将重点讨论单元测试,并使用Google Test框架作为示例。我将分享一些来自实际工作的主题和案例,这些概念可能也适用于其他测试场景。

2:基础模板测试方法及其局限性

上一节我们介绍了讨论的背景。本节中,我们来看看测试模板函数和类模板的基础方法及其存在的问题。

假设我们想测试一个简单的模板函数,例如计算平方的函数 square

template <typename T>
T square(T x) { return x * x; }

使用Google Test,我们可以这样编写测试:

TEST(SquareTest, NonZero) {
    EXPECT_EQ(square(1), 1);
    EXPECT_EQ(square(2), 4);
    EXPECT_EQ(square(10), 100);
}

TEST(SquareTest, Zero) {
    EXPECT_EQ(square(0), 0);
}

编译并运行测试,一切正常。但仔细看,这些测试非常表面。整数通常有32位,仅测试几个案例可能不够。对于模板函数,我们还需要考虑所有可能的数据类型,甚至是用户自定义的类型。

对于类模板,例如一个自定义的 Vector,测试可能如下:

TEST(VectorTest, BasicOperations) {
    Vector<int> v;
    EXPECT_TRUE(v.empty());
    EXPECT_EQ(v.size(), 0);

    v.push_back(1);
    EXPECT_FALSE(v.empty());
    EXPECT_EQ(v.size(), 1);
    EXPECT_EQ(v.front(), 1);
    EXPECT_EQ(v.back(), 1);

    v.pop_front();
    EXPECT_TRUE(v.empty());
    EXPECT_EQ(v.size(), 0);
}

测试通过了,但我们需要思考:我们到底测试了哪些方法?构造函数?emptysizepush_back?此外,我们只测试了 int 类型,其他类型呢?随着方法增多,测试代码会变得冗长,这不是一个好的扩展方式。

另一个关键点是测试的依据。考虑以下函数:

template <typename Iter>
void sort_sequence(Iter begin, Iter end) {
    if (begin == end) return;
    auto val = *begin;
    for (auto it = begin; it != end; ++it) {
        *it = val++;
    }
}

如果我们编写测试:

TEST(SortTest, SortsVector) {
    std::vector<int> v = {5, 3, 1, 4, 2};
    sort_sequence(v.begin(), v.end());
    EXPECT_TRUE(std::is_sorted(v.begin(), v.end()));
}

测试可能会通过,但函数名 sort_sequence 具有误导性。它并没有排序,而是用递增序列填充范围。这里的问题在于,我们测试所依据的需求不明确。函数名隐含了“排序”的需求,但实际代码实现的是“生成序列”。没有明确的规范,我们就无法判断代码是否正确。

核心要点:每个程序都是针对某个规范正确的。如果没有规范,就无法进行有效的测试。

3:改进测试结构:分离编译时与运行时变化

上一节我们看到了基础方法的局限性。本节中,我们探讨如何通过改进测试结构来更好地管理类型(编译时)和数据(运行时)的变化。

Google Test 提供了模板化测试的方法:

template <typename T>
class VectorTest : public ::testing::Test {};
TYPED_TEST_SUITE(VectorTest, MyTypes);

TYPED_TEST(VectorTest, Initialization) {
    using VectorType = Vector<TypeParam>;
    VectorType v(3);
    EXPECT_EQ(v.size(), 3);
}

这种方法允许我们为类型列表中的每种类型实例化测试。但它存在一些问题:宏的使用复杂、难以定制、不支持多个模板参数、难以模块化扩展,并且要求预先知道所有要测试的类型。

我们决定放弃宏,直接使用纯C++类来实现:

namespace my_test {
    template <typename T>
    class VectorTest : public ::testing::Test {
    protected:
        void TestBody() override {
            Vector<T> v(3);
            EXPECT_EQ(v.size(), 3);
        }
    };
}

// 手动实例化
using MyTestTypes = ::testing::Types<int, float, double>;
INSTANTIATE_TYPED_TEST_SUITE_P(MyPrefix, my_test::VectorTest, MyTestTypes);

这种方法给了我们完全的控制权:可以使用命名空间、支持多个模板参数、可以自由分组和分离测试代码,并且便于用户使用自己的类型来运行我们的测试。

接下来,我们处理运行时数据变化。传统方法是使用Fixture(夹具):

template <typename T>
class VectorFixture : public ::testing::Test {
protected:
    std::vector<Vector<T>> test_vectors = {
        Vector<T>(),                     // 空向量
        Vector<T>(1, T{1}),              // 一个元素
        Vector<T>(100, T{1}),            // 100个相同元素
        Vector<T>({T{2}, T{3}, T{5}, T{7}}) // 几个不同的质数
    };
};

夹具在很多情况下有效,但在测试复制构造函数、移动构造函数或二元操作符时,需要测试对象之间的所有组合,使用夹具会变得笨拙,并且测试数据和测试逻辑耦合紧密。

我们的解决方案是引入等价类概念,将数据生成与测试基础设施分离:

template <typename T>
struct VectorEquivalenceClasses {
    std::vector<Vector<T>> classes;

    VectorEquivalenceClasses() {
        classes.emplace_back(); // 空
        classes.emplace_back(1, T{1}); // 一个元素
        classes.emplace_back(100, T{1}); // 100个元素
        classes.emplace_back(std::initializer_list<T>{T{2}, T{3}, T{5}, T{7}});
    }
};

在测试中,我们遍历这些等价类:

TYPED_TEST(VectorTest, CopyAssignment) {
    VectorEquivalenceClasses<TypeParam> eq_classes_src;
    for (const auto& src : eq_classes_src.classes) {
        VectorEquivalenceClasses<TypeParam> eq_classes_dst;
        for (auto& dst : eq_classes_dst.classes) {
            dst = src; // 测试赋值操作符
            EXPECT_EQ(dst.size(), src.size());
            EXPECT_EQ(dst, src);
        }
    }
}

这种方法的优势

  • 清晰分离了测试数据和测试逻辑。
  • 易于扩展,添加新的等价类会自动纳入所有相关测试。
  • 可以轻松实例化多个副本(例如用于测试const方法)。
  • 测试代码更抽象、更通用。

潜在的缺点

  • 增加了一层间接性,代码稍多。
  • 有时需要在测试中进行运行时判断(例如,检查容器是否为空)。
  • 对于不熟悉此模式的人来说,可能不够直观。

4:测试的局限性:穷举测试的不可能

上一节我们建立了更好的测试结构。本节中,我们正视一个根本性限制:我们无法测试所有情况。

考虑测试一个简单的加法函数:

template <typename T>
T add(T a, T b) { return a + b; }

TEST(AddTest, Exhaustive) {
    using T = uint32_t;
    for (T a = 0; a < std::numeric_limits<T>::max(); ++a) {
        for (T b = 0; b < std::numeric_limits<T>::max(); ++b) {
            EXPECT_EQ(add(a, b), a + b);
        }
    }
}

对于32位整数,这有约40亿种组合,测试永远无法完成。即使有无限的计算资源,为如此简单的操作进行穷举测试也是浪费且不环保的。

因此,我们必须接受测试是不完整的。我们无法证明代码绝对正确,只能尽可能提高信心。这就需要我们明智地选择测试数据

如何选择等价类?
对于容器:

  • 空状态
  • 单元素状态
  • 多元素状态(选择一个有代表性的“大”小)
    对于算法参数(如迭代器):
  • begin() 迭代器
  • 中间位置的迭代器
  • end() 迭代器
    我们可以组合容器状态和迭代器状态进行测试。

对于数学函数,特别是浮点数:

  • 需要包含特殊值:正负零、无穷大、NaN(非数字)。
  • 测试不同的数值类型(float, double, long double)。
  • 对于非基本类型的数学对象(如向量、矩阵),如果模板支持,也应测试。

关于对齐类型的测试:在不同平台上,对齐规则可能不同,可能导致问题。因此,测试具有不同对齐要求的类型很重要。

核心挑战:我们拥有强大的方法(类型参数化 x 运行时等价类)来生成大量测试,但必须谨慎使用,避免测试过多无意义的组合。重点应放在测试那些可能出错的复杂逻辑上,而不是编译器和硬件已经保证正确的简单操作上。

5:超越代码覆盖率:对抗性测试

上一节我们认识到测试必然是不完整的,并讨论了如何选择测试数据。本节中,我们提出一个关键问题:如何知道我们的测试本身是有效的?

通常,人们依赖代码覆盖率工具。达到100%的覆盖率常被视为测试充分的标志。但覆盖率存在严重问题:

  1. 对于模板:一行模板代码可能被某些类型实例化覆盖,但未被其他类型覆盖。覆盖率工具难以完美处理这种情况。
  2. 根本性缺陷:覆盖率只表明代码被执行了,但不检查执行结果是否正确
    TEST(SquareTest, CoverageTrick) {
        square(5); // 这行代码被覆盖了
        // 但没有进行任何断言(EXPECT)!
    }
    
    这个测试能达到100%行覆盖,但完全没有验证功能。

因此,覆盖率是必要的,但绝不是充分的

我们需要一种方法来测试我们的测试。我们可以将测试视为一个过滤器:好的输入应该通过(测试成功),坏的输入应该被拒绝(测试失败)。传统上,我们只测试“好的输入”能通过。

对抗性测试的核心思想是:将待测函数本身也作为测试的一个输入参数。然后,我们不仅传入正确的实现(应通过测试),还传入一些我们知道不正确的实现(应使测试失败)。

如何构建“坏的”实现?

  • 空函数(什么都不做)。
  • 返回固定值的函数。
  • 行为与规范相反的函数(例如,对排序函数传入一个“反排序”函数)。
  • 旧版本中已知有缺陷的实现。
  • 移除或增加参数的函数。

我们需要修改测试基础设施来支持这种“期望失败”的模式。在Google Test中,可以创建一个包装器:

template <bool ShouldSucceed>
struct AdversarialGuard {
    int failure_count = 0;
    ~AdversarialGuard() {
        if (!ShouldSucceed) {
            EXPECT_GT(failure_count, 0) << "Adversarial test should have detected a failure!";
        }
    }
};

#define ADVERSARIAL_EXPECT_EQ(guard, a, b) \
    do { \
        if ((guard).ShouldSucceed) { \
            EXPECT_EQ(a, b); \
        } else { \
            if ((a) != (b)) (guard).failure_count++; \
        } \
    } while(0)

现在,我们可以编写一个同时测试正确和错误排序函数的测试:

template <typename SortAlgo, bool IsCorrectAlgo>
void TestSortAlgorithm() {
    AdversarialGuard<IsCorrectAlgo> guard;
    VectorEquivalenceClasses<int> eq_classes;
    for (auto& vec : eq_classes.classes) {
        auto original = vec;
        SortAlgo sorter;
        sorter(vec.begin(), vec.end());
        // 检查1: 序列应已排序
        ADVERSARIAL_EXPECT_EQ(guard, std::is_sorted(vec.begin(), vec.end()), true);
        // 检查2: 新序列应包含原序列的所有元素(对于排序是必须的)
        // ... 实现元素存在性检查 ...
    }
}

// 测试正确的排序算法(如std::sort)
TEST(SortTest, CorrectAlgorithm) {
    TestSortAlgorithm<std::sort, true>();
}

// 测试错误的“算法”(如什么都不做的函数)
struct DoNothingSorter {
    template <typename Iter>
    void operator()(Iter, Iter) {}
};
TEST(SortTest, AdversarialDoNothing) {
    TestSortAlgorithm<DoNothingSorter, false>();
}

通过运行对抗性测试,我们可以发现测试用例本身的缺陷。例如,如果测试数据原本就是有序的,那么一个“什么都不做”的错误排序函数也可能通过“序列已排序”的检查。对抗性测试会失败,从而提醒我们需要加强测试断言(例如,增加“结果包含原所有元素”的检查)。

对抗性测试的优势

  • 提升测试质量:迫使测试必须能区分正确和错误的实现。
  • 使测试更通用:待测算法成为参数,测试逻辑可复用。
  • 提供具体反馈:测试失败直接指出测试用例的不足,避免了“测试是否足够”的哲学争论。
  • 适用于AI生成的代码:可以用简单的对抗性样本来验证AI生成的测试是否有效。

需要避免的对抗性示例

  • 产生随机行为的函数(难以稳定检测)。
  • 故意引发未定义行为的函数。
  • 实际上满足规范但实现不同的函数(例如,用稳定的排序测试不稳定的排序)。

6:总结与展望

在本教程中,我们一起学习了如何系统化地测试C++模板。

我们首先分析了基础测试方法的局限性,特别是对编译时类型变化和运行时数据变化管理的不足。接着,我们提出了改进方案:

  1. 使用纯C++类而非宏来构建类型参数化测试,以获得更好的控制力和模块化。
  2. 引入“等价类”概念,将测试数据生成与测试基础设施清晰分离,使测试更易于扩展和维护。
  3. 认识到穷举测试的不可能性,强调需要明智地选择测试数据和类型组合。

最后,我们引入了核心创新——对抗性测试。这种方法通过将待测函数作为参数,并同时测试已知的正确和错误实现,来验证测试用例本身的有效性。它超越了代码覆盖率的局限,为我们提供了一种切实可行的手段来评估和提升测试质量,并在AI辅助编程的时代,为验证AI生成的测试代码提供了新思路。

最终结论:通过清晰分离测试结构、精心选择测试数据、并运用对抗性方法来验证测试的有效性,我们可以显著提升C++模板代码的测试质量和最终的产品可靠性。

035:用测试驱动思维重塑大脑,提升C++生产力 🧠

在本节课中,我们将要学习如何将心理学、神经科学与测试驱动开发相结合,以更高效、更愉悦的方式编写C++代码。我们将探讨习惯形成、多巴胺触发以及如何通过TDD“外化”你的大脑,从而提升专注度和生产力。

概述

我们常常认为编写测试是额外的工作,会拖慢开发速度,并且枯燥乏味。然而,通过测试驱动开发的思维模式,我们可以将这些障碍转化为优势。本节将引导你理解TDD的核心循环,并揭示其背后如何与大脑的工作机制协同,从而改变你对编码的认知。

TDD基础:两个循环的故事 🔄

上一节我们介绍了课程的目标,本节中我们来看看测试驱动开发的核心过程。首先,我们需要确保我们对TDD是什么有共同的理解。

TDD本身非常简单,其核心是“红-绿-重构”循环。这个循环不仅关乎代码,更关乎一种思维方式。

TDD循环详解

以下是TDD循环的各个阶段:

  1. 红(编写一个失败的测试):你总是从一个失败的测试开始。关键词是“开始”和“失败”。这个“测试”可以是一个正式的单元测试断言,也可以是一个编译器错误(例如,调用了一个尚未存在的函数)。其核心是设定一个明确的、可验证的期望。

  2. 绿(编写恰好使测试通过的代码):只有当测试失败后,你才编写恰好足够的代码使其通过。此阶段的目标不是设计优雅的代码,而是用最简单、最直接的方式满足测试要求。这有点像回归最初的“黑客”编程乐趣。

  3. 重构(改进代码设计):在所有测试通过(绿色)后,你才能进入重构阶段。在此阶段,你可以安全地应用设计原则、清理代码、提高可读性,而无需担心破坏功能,因为测试会保护你。

  4. 完成?(决定下一步):重构完成后,问自己:“我们完成了吗?” 这是一个有意识的决策点,决定是开始下一个功能(写下一个失败测试)还是结束。这个步骤有助于你在不同的思维模式间清晰切换。

一个简单的C++代码示例

为了具体说明,让我们通过C++和Catch2测试框架来实现一个简单的leftPad函数。

#include <catch2/catch_test_macros.hpp>
#include <string>

// 1. 从最简单的失败测试开始
TEST_CASE("Strings can be padded to the left") {
    REQUIRE(leftPad("", 0) == "");
}

此时编译会失败,因为leftPad函数未定义。这是一个“红”状态。

// 2. 编写恰好通过的代码(绿)
std::string leftPad(const std::string& str, int len) {
    return "X"; // 故意返回错误值,以看到测试失败
}

运行测试,会看到期望""但得到"X"的失败信息。然后修正:

std::string leftPad(const std::string& str, int len) {
    return ""; // 使第一个测试通过
}
// 3. 添加更多测试,驱动实现
TEST_CASE("Strings can be padded to the left") {
    REQUIRE(leftPad("", 0) == "");
    REQUIRE(leftPad("hello", 0) == "hello"); // 新测试,会失败
}

为了让新测试通过,需要修改实现:

std::string leftPad(const std::string& str, int len) {
    return str; // 现在两个测试都通过了
}

通过这种小步快跑的方式,逐步增加测试的复杂性(如非零填充长度、填充字符等),最终驱动出完整且正确的实现。每一步都确保有从红到绿的明确反馈。

习惯形成:将TDD变为本能 🏃

理解了TDD的机制后,如何让它成为日常开发中自然而然的习惯呢?这需要了解习惯形成的心理学模型。

习惯由三个部分组成:暗示例行程序奖励。一旦习惯建立,大脑会沿着建立好的神经通路自动执行例行程序,几乎不需要意志力。

对于编程,旧习惯可能是:

  • 暗示:需要实现一个新功能。
  • 例行程序:直接开始编写实现代码,直到“看起来能工作”。
  • 奖励:看到代码运行成功带来的即时满足感。

要将例行程序改为TDD,关键是将奖励与TDD循环绑定。在TDD中,奖励变得更加频繁和即时:

  • 每次看到测试从红变绿,都是一次小的胜利和积极反馈。
  • 完成一个功能后,看到清晰、少Bug的代码,是更大的满足。
  • 项目长期维护性提高、缺陷率降低,是终极奖励。

核心策略:有意识地关注并享受TDD循环中的这些积极时刻。通过反复将“暗示”(要写代码)与新的“例行程序”(TDD)和强化后的“奖励”(频繁的红绿转换、更好的代码)关联,你的大脑会建立新的、强大的神经通路。最终,不写测试反而会让你感到不适应。

多巴胺触发:让编码变得有趣且高效 🎯

我们常认为多巴胺是“快乐化学物质”,但实际上它更关乎“动机显著性”——它促使我们寻找模式、关注重要信息并期待奖励。

在TDD循环中,充满了这种“不可预测的奖励”,从而持续触发多巴胺分泌:

  • 预期的红变绿:大部分时间如预期发生,带来小确幸。
  • 意外的失败:当你认为测试该过却失败了,或该失败却过了时,打破了预期模式。这时多巴胺水平升高,使你进入高度专注和好奇的状态,迫切想找出原因——这正是深度学习和解决问题的理想心态。
  • 重构挑战:安全地改进代码而不破坏测试,本身就像一个游戏。

这种将工作“游戏化”的机制,使得TDD过程变得引人入胜,同时极大地提升了发现问题和学习代码行为的效率。

外化你的大脑:专注的力量 🧩

人类大脑是“开环系统”,善于通过外部工具(如书写、计算机)扩展思维。TDD正是这样一个强大的外部化工具。

TDD循环的三个阶段对应三种截然不同的思维模式,测试充当了思维的“外部存储”:

  1. 写失败测试时(红):你扮演设计师/用户,思考接口、需求和高级行为。你将期望“外化”为可执行的测试代码。

  2. 写实现代码时(绿):你切换为实现者/黑客。目标单一——让测试变绿。无需记挂设计细节,因为它们已保存在测试中。你可以全心投入解决问题的乐趣。

  3. 重构时:你切换为代码工匠。唯一目标是改善设计并保持行为不变。测试网保障了你的安全,让你可以大胆改进。

这种通过外部工具(测试)在不同思维模式间无缝、无负担切换的能力,就是“外化大脑”。它让你一次只专注于一件事,避免了上下文切换的认知损耗,从而大幅提升专注度和生产力。

总结:重塑思维,加速开发 🚀

本节课中我们一起学习了如何用测试驱动思维重塑你的C++开发方式。

我们首先剖析了TDD的“红-绿-重构”核心循环,并通过leftPad示例展示了其小步快跑、反馈即时的特点。接着,我们从心理学角度探讨了如何通过习惯循环将TDD变为本能,通过多巴胺触发机制让编码过程充满乐趣和高效学习。最后,我们了解了TDD如何作为外化大脑的工具,让你在不同角色间清晰切换,保持极致专注。

回顾最初的偏见:

  • 测试不是额外工作,它通过左移缺陷发现时间、避免返工和重构出更易修改的代码,从长期看减少了总工作量
  • 测试不会拖慢速度,就像汽车的刹车是为了让你开得更快一样,测试提供的安全网让你能更有信心地快速前进
  • 测试不再枯燥,它融入了游戏化的挑战、即时的正面反馈和深度的学习时刻。
  • 测试不会让你分心,它通过外化思维,帮助你更专注在当前最重要的任务上

通过实践测试驱动思维,你不仅能写出更健壮的C++代码,还能重塑你的大脑工作方式,成为一名更高效、更快乐的开发者。

036:概述与动机

在本教程中,我们将学习如何为Node.js编写现代C++插件。我们将从一个实际需求出发,逐步构建一个能够生成日历的Electron应用程序,并深入探讨如何将高性能的C++代码集成到JavaScript生态系统中。

动机:从实际问题到技术方案

上一节我们介绍了本教程的目标,本节中我们来看看驱动我们学习这项技术的实际场景。

我并非专业的JavaScript开发者,而是一名C++程序员。在工作中,我们使用一种名为Parquet的Apache标准大数据文件格式。为了查看这种文件,我们需要运行一个特定的应用程序。在开发过程中,我使用VS Code,但每次查看文件时都必须跳出编辑器去运行那个应用,这非常低效。

因此,我编写了一个VS Code扩展来直接调用那个应用程序。然而,该应用后来被弃用,不再支持新版本的Parquet格式。我找到了一个功能类似的JavaScript库,但它也很快被弃用。于是,我查看了该协议参考实现的源代码,发现它是用C++编写的。我想到,为什么不直接使用原始的实现呢?这就需要从Node.js调用C++代码,而这正是本次讨论的主题。

为了更直观地演示,我决定不展示那个VS Code扩展,而是构建一个更可见的示例:一个用Electron编写的日历应用程序。

项目目标:构建一个日历应用

上一节我们了解了项目的由来,本节中我们来看看要构建的具体应用。

这个应用的目标是允许用户输入想要查看的年份,并选择一周是从周日还是周一开始,然后应用会显示该年的日历。

我已经写好了所需的HTML和JavaScript部分。但接下来,如何生成日历本身呢?幸运的是,去年在ACCU大会上,我使用C++20的Ranges库,借鉴了Eric Niebler的想法,实现了一个生成日历的C++库。这次,我决定直接使用那个库。

Node.js插件基础:什么是N-API?

在深入代码之前,我们需要理解Node.js插件的基本概念。

Node.js插件本质上是共享库或动态链接库,你可以从Node.js应用程序中加载它们,并像导入其他Node.js模块一样导入。它们使用一个名为N-API的C语言API。在此之上,还有一个C++包装器,称为Node-API或Node Addon API。

构建工具链:告别node-gyp,拥抱CMake

上一节我们介绍了插件的基本概念,本节中我们来看看如何构建它们。

最初,Node.js插件使用node-gyp作为构建系统,它基于Python。这意味着你需要安装Python才能使用它。作为一个C++程序员,我个人更喜欢在项目中使用CMake。此外,我还想使用C++ Modules,目前只有CMake等少数构建系统能较好地支持,而node-gyp并不支持。

我发现有一个名为cmake-js的JavaScript库可以帮助解决这个问题,它能为Node.js后端调用CMake。既然使用node-gyp也需要Python,我想,那不如也用Python来安装CMake。

我使用pipx作为Python虚拟环境管理器,其主要优势是我不需要激活虚拟环境就能使用安装的工具,只需运行pipx run命令即可。

以下是pyproject.toml文件的内容,用于描述Python依赖:

[project]
name = "calendar-addon-builder"
dependencies = ["cmake-js"]

配置Node.js项目:package.json

每个Node.js包都有一个package.json文件来描述它。

以下是我的package.json文件的关键部分:

{
  "name": "calendar-addon",
  "version": "1.0.0",
  "scripts": {
    "install": "pipx run cmake-js compile",
    "build": "pipx run cmake-js build"
  },
  "dependencies": {
    "node-addon-api": "^5.0.0"
  },
  "devDependencies": {
    "cmake-js": "^7.0.0"
  }
}

我在这里请求了node-addon-api,这是Node.js插件的C++ API包装器。以及cmake-js。当有人安装这个包时,install脚本会请求cmake-js进行编译。

每个Node.js API都有一个版本号,这基本上决定了哪些Node.js版本能够使用这个插件。Node.js文档中有一个很好的表格,例如,如果你使用版本7(就像我正在用的),你将支持所有你想要的Node.js版本。

配置CMake:CMakeLists.txt

要配置cmake-js,你可以通过命令行传递参数,或者将它们放在npm配置中。npm是Node.js的包管理器,它会查看这个配置。

我在npm配置中告诉CMake我的源代码在哪里,我希望构建文件夹在哪里,并且可以设置任何CMake变量。例如,我要求使用C++23标准。

现在来看我的CMakeLists.txt文件。它看起来是相当标准的CMake文件,但我需要做一些额外的工作。

以下是CMakeLists.txt的核心部分:

cmake_minimum_required(VERSION 3.26)
project(calendar_addon LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 查找node-addon-api头文件
find_package(NodeAddonAPI REQUIRED)

# 创建一个共享库
add_library(calendar_addon SHARED)
target_sources(calendar_addon
    PUBLIC FILE_SET HEADERS
    BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}
    FILES src/calendar_module.cpp
)
target_include_directories(calendar_addon PRIVATE ${NODE_ADDON_API_INCLUDE_DIRS})
target_link_libraries(calendar_addon PRIVATE ${NODE_ADDON_API_LIBRARIES})

# 设置插件版本等信息(从package.json获取)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/package.json ${CMAKE_CURRENT_BINARY_DIR}/package.json COPYONLY)

我在这里查找cmake-js插件的头文件位置以便包含它们。同时,我需要告诉CMake以我使用的适当API版本来构建插件。这里我传递了package.json以避免重复自己。然后,我创建一个共享库,告诉CMakecmake-js包含文件和API的位置,传递版本号,并且还需要链接到node库。

插件接口:连接C++与JavaScript

上一节我们配置好了构建系统,本节中我们来看看如何定义插件的接口。

现在,我进入插件的接口部分。我包含了Node-API头文件。每个插件都应该使用NODE_API_MODULE宏在最后一行声明自身,然后传递一个名为init的初始化函数。

init函数的任务是声明你将暴露的接口。这里我们只有一个名为generate_calendar的函数,它基本上会转发给这里定义的generate_calendar函数。

每个函数基本上都有这个签名:你会得到一个上下文信息参数,并且应该返回一个napi_value

以下是src/calendar_module.cpp的接口部分:

#include <napi.h>
#include "calendar.h" // 假设这是你的C++日历生成库头文件

Napi::Value GenerateCalendar(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    // 检查参数数量
    if (info.Length() < 2) {
        Napi::TypeError::New(env, "需要两个参数:年份和起始星期几").ThrowAsJavaScriptException();
        return env.Null();
    }

    // 提取并验证参数
    if (!info[0].IsNumber() || !info[1].IsNumber()) {
        Napi::TypeError::New(env, "参数必须为数字").ThrowAsJavaScriptException();
        return env.Null();
    }

    int year = info[0].As<Napi::Number>().Int32Value();
    int week_start = info[1].As<Napi::Number>().Int32Value();

    // 调用C++库函数生成日历
    // 假设 `generate_calendar` 返回一个 std::vector<std::string>
    auto calendar_lines = generate_calendar(year, week_start);

    // 将结果转换为JavaScript数组
    Napi::Array result = Napi::Array::New(env, calendar_lines.size());
    for (size_t i = 0; i < calendar_lines.size(); ++i) {
        result[i] = Napi::String::New(env, calendar_lines[i]);
    }
    return result;
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set(Napi::String::New(env, "generateCalendar"),
                Napi::Function::New(env, GenerateCalendar));
    return exports;
}

NODE_API_MODULE(calendar_addon, Init)

由于JavaScript是动态类型的,我需要显式检查从JavaScript端获取的参数。我检查是否获得了生成日历所需的年份和一周起始日。否则,我会抛出异常。请注意,即使我抛出了异常,这里仍然有return语句,因为有可能客户端是在禁用异常的情况下编译的,我不希望我的函数继续执行,所以在这里返回一个undefined值。

验证参数后,我可以提取它们,然后使用另一个模块生成日历,该模块将返回一个字符串范围。最后,我创建一个JavaScript数组并将范围复制到其中。由于JavaScript数组没有范围接口,我不能使用std::copy,所以只能使用一个简单的循环。

C++模块:使用现代C++特性

上一节我们定义了插件的JavaScript接口,本节中我们来看看背后真正的C++实现模块。

正如我所说,实际生成日历的实现是在另一个模块中。这是我第一次使用import语句,对我来说非常兴奋。

如果你在模块中使用头文件,你应该先有module;声明,然后可以包含一些头文件,接着用export module calendar;开始你的模块,之后你还可以导入依赖的其他模块。

以下是C++日历模块src/calendar.ixx的示例结构:

module;
#include <vector>
#include <string>
#include <range/v3/view/concat.hpp> // 使用range-v3库

export module calendar;

import <chrono>; // 导入其他模块(假设存在)
import <format>;

export std::vector<std::string> generate_calendar(int year, int week_start_day) {
    // 使用C++20/23 Ranges和 Chrono 库生成日历的逻辑
    // 返回一个字符串向量,每个元素代表日历的一行
    std::vector<std::string> lines;
    // ... 具体的日历生成算法 ...
    return lines;
}

我不会详细介绍如何实际生成日历的所有细节。正如我提到的,我去年在会议上做过。我这个模块只导出两个函数:第一个函数接收一个年份,返回一个日期范围;第二个函数用于格式化日历,同样使用范围适配器。

在这个模块中,有一个函数需要concat_view,它在C++23中还没有,可能要到C++26。在去年的演讲中,我使用std::generator实现了它,但这次我决定使用一个外部库range-v3。我在这里包含了它,然后通过委托给range-v3来实现concat功能。

依赖管理:使用Conan

要安装range-v3,你可以直接下载头文件,或者使用包管理器。就个人而言,我更喜欢使用包管理器,所以我在这里使用Conan。由于我已经在Python环境中,我可以直接用pip安装Conan。

为了将Conan与CMake集成,你可以使用一个支持Conan的CMake模块。这相当新。你只需传递一个CMake变量来包含这个模块,它会在生成项目之前自动包含,这基本上告诉CMake在哪里找到你的依赖项。

然后,我就可以在CMake中声明我的日历模块依赖于range-v3

你还可以看到编译模块的相同语法:你需要用FILE_SET CXX_MODULES声明target_sources,并传递你所有的模块源文件。

部署与分发:跨平台预构建

上一节我们完成了代码开发,本节中我们来看看如何将插件部署给用户。

你如何部署这个东西呢?有几个JavaScript包可以用于此。基本上,除非你希望用户也为他们自己的平台构建C++代码,并强迫他们下载GCC等工具,否则,你通常希望为你想要支持的每个平台预构建插件。

这就是那些包为你做的事情。问题在于把这些预构建的共享库放在哪里。你可以将它们上传到像S3这样的Web服务,从那里下载,或者上传到Git Releases,或者直接放在NPM包内部。这就是prebuild包所做的,也是我所使用的。

要使用prebuild包,你需要再次提供一些描述文件。你需要使用API版本(比如7)。我只是再次包含package.json并在这里传递它和你的包名。

如果你记得我们最初忽略的那一行,基本上,当有人安装你的包时,它会运行这个prebuild-install验证,检查是否有所需的预构建库,否则它将调用构建系统,当然,除非他们有工具,否则会失败。

在CMake中,我添加了一个自定义步骤,将我刚刚构建的内容复制到prebuild能够找到的适当目录。

如果你使用CI/CD管道或GitHub Actions,基本上,正如我所说,你只需运行一个构建阶段来构建所有你想要支持的平台。顺便说一下,GitHub Actions现在免费支持Mac M1处理器,这很好。然后,你可以在特定的操作系统构建期间上传构件,之后下载它们,并将它们合并到一个包中,然后上传到NPM注册表或你使用的任何地方。

总结

在本教程中,我们一起学习了为Node.js编写现代C++插件的完整流程。

我们从解决一个实际开发效率问题出发,明确了通过C++插件提升Node.js应用性能的需求。接着,我们规划了一个日历应用作为示例项目。然后,我们深入了解了Node.js插件(N-API)的基础。在构建环节,我们选择了CMake替代传统的node-gyp,并配置了项目文件。我们定义了清晰的JavaScript-C++接口,并利用现代C++模块和Ranges库实现了核心逻辑。通过Conan管理了C++依赖。最后,我们探讨了如何使用预构建工具实现跨平台部署。

这就是为Node.js编写现代C++插件的基本方法。整个过程结合了JavaScript的灵活性与C++的高性能,为构建复杂应用提供了强大的解决方案。

037:Lexy - 用于C++解析的DSL库

概述

在本教程中,我们将学习Lexy,一个用于C++的领域特定语言(DSL)库。Lexy本质上是对递归下降解析器的语法糖,它允许你编写解析器,同时保持对分支决策和回溯的完全控制。我们将从基本概念开始,逐步深入到其高级特性和内部实现。

语法定义与解析流程

上一节我们介绍了Lexy的基本概念,本节中我们来看看如何使用Lexy进行解析。使用Lexy需要三个步骤:首先定义语法,然后创建输入,最后调用解析函数。

以下是使用Lexy解析CSS颜色值的示例步骤:

  1. 定义语法:语法由产生式(productions)和规则(rules)组成。规则使用DSL定义如何解析输入。

    struct Color {
        static constexpr auto rule = dsl::hash_sign + dsl::times<3>(dsl::hex_digit);
        static constexpr auto value = lexy::construct<Color>;
    };
    
  2. 创建输入:输入可以是字符串字面量、文件内容或命令行参数等。

    auto input = lexy::zstring_input("#FF00FF");
    
  3. 调用解析:调用解析函数,传入语法的入口产生式、输入和错误处理器。

    auto result = lexy::parse<Color>(input, lexy::collect<std::string>);
    if (result.has_value()) {
        // 使用解析结果
    }
    

核心特性:非声明式与回溯控制

上一节我们介绍了基本的解析流程,本节中我们来看看Lexy的一个核心特性:它是非声明式的,并且需要显式控制回溯。

在声明式解析器中,规则 A* A 可以匹配一个或多个A。但在Lexy中,等效的代码可能无法匹配任何内容,因为Lexy是手写递归下降解析器的语法糖。代码 while (a) {} 后接 a 将永远不会匹配,因为在消耗完所有A后,无法再匹配一个A

类似地,对于分支选择,规则 A | A B 可以匹配AA B。但在Lexy中,如果第一个分支A匹配成功,它将永远不会考虑第二个分支A B

这是因为Lexy默认不会回溯,除非你明确告诉它。决策通过分支条件(branch conditions)做出。分支条件可以是单个标记(如数字或字符类),本质上是单字符前瞻(LALR(1) lookahead)。当你需要进行回溯时,必须使用dsl::peek等操作显式指定。

以下是使用分支条件解析函数调用语法的示例:

struct FunctionCall {
    static constexpr auto rule = dsl::identifier + dsl::parenthesized(dsl::list(dsl::integer));
    static constexpr auto value = lexy::construct<FunctionCall>;
};

高级功能与应用场景

上一节我们探讨了Lexy的非声明式特性,本节中我们来看看它提供的一些高级功能和适用场景。

Lexy支持解析文本,包括Unicode,并能编译时访问字符属性数据库。它提供了解析嵌套结构、引号和转义序列等的基本规则,并支持自动空白字符跳过。

你可以使用Lexy来解析编程语言,作者本人已在Lexy之上编写了三个编译器。它支持关键字和标识符解析,允许指定运算符优先级,并能自动进行错误恢复。此外,Lexy还可以解析二进制输入。

实现原理:解析器组合子与值传递

上一节我们了解了Lexy的高级功能,本节中我们将深入探讨其实现原理。Lexy建立在解析器组合子(parser combinators)之上。

一个解析器本质上是一个函数,它接受一个读取器(reader)并返回布尔值,表示解析是否成功。多个解析器可以通过组合子函数组合在一起,例如按顺序解析。

在传统实现中,这通常使用Lambda和高阶函数完成。但在C++中,这会带来开销。因此,在Lexy中,作者使用了空类型(empty types),整个语法规范都在类型系统中组合。例如,解析单个字面字符的规则就是一个空类型,所有规范都体现在类型上。

一个关键问题是:如何产生和传递值?例如,解析整数时,我们消耗数字后需要返回一个整数值。最初的解决方案是让每个解析器返回一个std::optional<T>,但这会导致复杂的类型组合(如元组的变体的元组),难以使用且会产生不必要的拷贝。

Lexy的解决方案借鉴了用于异步编程的发送者-接收者(senders-receivers)模式。在这个模式中,发送者描述要完成的工作,接收者是接收结果的回调。通过连接发送者和接收者,工作被执行,结果传递给回调。

这种将工作描述(发送者)与结果处理(接收者)分离的思想巧妙地解决了值传递问题。回调(continuation)可以很容易地表示void(不传递值)、元组(传递多个参数)和选择(通过重载传递不同类型),而无需创建实际的元组或变体类型。

在Lexy中,规则本身知道如何解析,但它需要与一个续延(continuation)连接。这是通过实例化一个包含下一个解析器的结构体P来完成的。P的解析函数接受一个上下文和所有先前的参数,执行自己的匹配,成功后调用下一个解析器的解析函数,并传递所有先前的参数及当前值。通过这种方式,值被链式传递,最终调用在语法中指定的回调,产生最终结果。

输入处理:读取器模型 vs 迭代器模型

上一节我们探讨了Lexy的值传递机制,本节中我们来看看它为什么选择自定义的读取器(Reader)模型,而不是标准的C++迭代器。

作者希望支持动态请求输入的场景,例如交互式地从控制台读取多行输入。初始时提示用户输入一行,开始解析,当到达该行末尾时,需要请求更多输入(再次提示用户输入下一行)。只有当用户不想再输入任何内容时,才视为文件结束(EOF)。

如果使用标准的输入迭代器,其操作包括operator*(解引用)、operator++(递增)和operator==(相等比较)。我们需要在某个操作中检查并提示下一行。

  • 如果在operator++中提示,那么即使解析成功完成,也可能不必要地提示用户输入下一行。
  • 如果在operator*中提示,那么在某些解析模式(如跳过所有字符)下可能无法正确工作,因为operator*可能不会被调用。
  • 如果在operator==(相等比较)中实现主要逻辑,虽然可行,但显得非常奇怪,不符合迭代器的惯用语义。

因此,标准的迭代器模型并不适合这种动态输入需求。Lexy引入了Reader结构,它包含peek()bump()方法。peek()方法可以安全地请求更多输入,这个模型更清晰地表达了“前瞻可能需要更多数据”的意图。

总结

在本教程中,我们一起学习了Lexy这个用于C++的解析器DSL库。我们了解了它的基本使用流程:定义语法、创建输入和调用解析。我们深入探讨了其非声明式的核心特性,明白了它需要显式控制回溯,并通过分支条件做出决策。我们还列举了Lexy支持的一系列高级功能,如Unicode支持和错误恢复。最后,我们剖析了其基于解析器组合子和发送者-接收者模式的实现原理,理解了它如何高效地组合和传递值,以及为什么它采用自定义的读取器模型来处理动态输入。Lexy为C++开发者提供了一种在保持手写解析器般的控制力和灵活性的同时,又能享受声明式语法便利的解析方案。

038:无锁与无等待算法——定义与理解

在本节课中,我们将要学习并发编程中的两个核心概念:无锁无等待算法。我们将从历史背景出发,探讨为何需要这些概念,理解同步原语及其存在的问题,并给出清晰的定义。最后,我们会简要讨论这些算法的价值与实现思路。


历史背景与需求演变

上一节我们介绍了课程目标,本节中我们来看看这些概念是如何产生的。

计算机硬件的发展经历了从单核到多核的演变。起初,我们通过提高单核速度来提升性能。当物理极限来临时,我们转向了增加处理器核心数量。软件层面也随之发展,从单任务到多任务,再到需要跨多个核心并行执行计算。

将计算任务拆分到多个核心上执行后,必然需要在某个点重新组合结果,这就引入了同步的需求。同步是协调多个执行单元(如线程)在时间上保持一致的过程。

什么是同步原语?

同步原语是用于实现同步的底层工具。它们将不同的事物在时间上汇聚到同一点。

以下是几种常见的同步原语:

  • 互斥锁:确保同一时间只有一个线程能访问共享资源。
  • 屏障:要求一组(N个)线程都到达某一点后,才能继续执行。
  • 信号量:控制同时访问某个资源的线程数量。

同步原语虽然有用,但也带来了问题。我们制造了强大的多核硬件,却又通过锁限制同一时间只能有一个线程执行,这与降低延迟、提高吞吐量的目标背道而驰。

同步的常见问题

除了性能限制,同步还可能引入其他复杂问题,例如死锁和优先级反转。但本节课的核心焦点是缺乏进展的问题:当使用互斥锁时,所有线程被串行化,只有一个能继续执行,系统整体无法充分利用硬件资源向前推进。

这正是无锁无等待算法要解决的问题:它们旨在确保系统始终保有某种程度的进展。

定义无锁与无等待算法

接下来,我们为无锁和无等待算法提出一个工作定义。请注意,业界存在多种定义,这里的定义旨在帮助理解核心思想。

无锁:对于线程的任意子集,其中至少有一个线程能够取得进展。

  • 这意味着即使部分线程被挂起或执行缓慢,系统整体也不会完全停滞。
  • 例如,一个使用“读写锁”的数据结构,当写锁持有时,所有读者线程都会被阻塞。如果只考虑读者线程这个子集,它们无法取得进展,因此该结构不是无锁的。

无等待:所有活跃的线程都能在有限步数内完成其操作。

  • “有限步数”是关键,它可能依赖于线程数量(例如,步数上界是线程数的线性函数),但必须是可确定的。
  • 这提供了最强的进度保证,但实现也最复杂。

其他常见定义对比

为了更全面地理解,这里列出其他常见的定义方式:

  • 最简定义
    • 无锁:不存在锁。
    • 无等待:不存在等待。
    • 问题在于“锁”和“等待”的定义本身可能模糊。
  • Just Software Solutions 博客的定义
    • 无锁:如果任何线程被挂起,其他线程必须仍能完成它们的任务。
  • 《C++ Concurrency in Action》中的定义
    • 无锁:如果多个线程在一个数据结构上操作,在有限步数内,其中一个将完成其操作。
    • 无等待:每个针对该数据结构的操作都在有限步数内完成。

虽然细节有差异,但核心理念一致:无锁保证“某事”总会前进;无等待保证“所有事”都会前进

我们需要无锁/无等待算法吗?

上一节我们比较了各种定义,本节中我们来看看这些算法是否真的必要。

  • 无等待算法的价值

    • 它提供了严格的运行时保证(有限步内完成),适用于对延迟有极端要求的场景(如自动驾驶系统、生命攸关的系统)。如果需要这种保证,无等待至关重要。
    • 缺点是实现极其复杂,且通常较慢。
  • 无锁算法的价值

    • 它保证程序最终会完成,这是一个良好的属性。
    • 然而,其真正价值往往体现在实现无锁的过程中:为了移除锁,我们通常需要设计更低开销、性能更高的代码。最终得到的高性能才是更普遍的目标。
    • 无锁代码比传统加锁代码更复杂,但遵循一些常见模式。

需要注意的是,当我们说一个数据结构“无锁”时,通常指的是它的核心操作路径是无锁的。例如,一个队列的入队操作可能是无锁的,但当队列满时,它仍然可能选择阻塞调用者,这并不矛盾。

实现技术概览

最后,我们简要了解实现无锁/无等待算法的一些常见技术背景。

  • 原子操作与CAS:无锁算法广泛依赖原子操作,尤其是比较并交换(Compare-And-Swap, CAS)。

    • 公式/伪代码CAS(addr, expected, new_value):如果 *addr 的值等于 expected,则将其设置为 new_value 并返回成功;否则返回失败。
    • 这是实现无锁链表、队列等数据结构的基础。线程通过CAS不断重试来更新共享指针,每次失败都意味着另一个线程成功并取得了进展,从而满足无锁要求。
  • 无等待算法技术

    • 常采用工作转移 策略。当一个线程因冲突无法继续时,它会将未完成的操作“转移”给造成冲突的线程去完成。由于步数上界允许依赖于线程数,这种协作方式可以满足无等待的条件。

本节课中我们一起学习了无锁与无等待算法的基本定义、它们产生的背景、核心价值以及实现基础。记住,无锁关乎系统整体进展,而无等待则提供最强的个体进度保证。追求这些特性,尤其是无锁,往往是优化并发性能、降低同步开销的重要旅程。

posted @ 2026-03-28 12:21  布客飞龙V  阅读(0)  评论(0)    收藏  举报