对于开发者而言,继承遗留的 C++ 代码库是一项具有挑战性的任务,需要审慎和详尽计划。本文作者从自身经验出发,分享了一系列有效方法。
原文链接:https://gaultier.github.io/blog/you_inherited_a_legacy_cpp_codebase_now_what.html
有时候,也许你刚换了一份新工作,也许刚换了一个团队,也许团队中某个有经验的人刚离开,这时需要你来继承一个旧的 C++ 代码库:这个代码库既庞大又复杂,还很特别,经常以各种有趣的方式崩溃。一句话概括来说:它充满了各种遗留问题。
尽管如此,Bug 仍需要修复,奇怪的功能也还需要添加,你无法完全忽视或直接干脆让它消失——这个代码库很重要,至少对给你发工资的人来说是这样,所以它对你也很重要。
不过你不用担心,因为我在很多地方都有过同样的经历,有一种方法,它不会让你太痛苦,能让你真正修复 Bug、添加功能,甚至有朝一日你还有望能重写它。
因此,接下来请和我一起回忆一下哪些方法对我有用,哪些方法绝对要避免。
我先声明一下,我并不讨厌 C++,它只是碰巧成为了人们滥用的语言之一,并导致了很多可怕的混乱。可怜的 C++ 只是受害者,C++ 委员会将在 C++45 中修复它,不用担心……跑题了,让我们回到眼前的话题。
下面是需要采取的步骤概述:
只对代码和构建系统做最小的改动,最好是不做改动,让它在本地运行。即使你十分手痒,也不要进行大的重构!
拿出“电锯”,“锯掉”一切和公司/开源项目宣传和销售特性无关的一切
通过添加 CI、linters、fuzzing、自动格式化等功能,让项目步入 21 世纪
最后,可以对代码进行小规模的增量修改,不断重复,直到你不再每天晚上都被应用被黑客入侵的噩梦惊醒。
如果可以的话,考虑用一种内存安全语言重写部分代码。
总体目标就是:花费最少的精力,使项目在安全性、开发人员体验、正确性和性能方面达到可接受的状态——记住这一点很重要,整个过程与“干净的代码”、使用新的热门语言特性等都无关。
好了,让我们开始吧!顺便说一下,本文所有内容都适用于纯 C 代码库或 C 和 C++ 混合代码库,如果你是这样的人,请继续阅读!
获得支持
你以为我会上来就比较不同的杀毒程序、编译标志或构建系统吗?不,在我们做任何工作之前,都要与人交谈,对吗?
软件工程必须是一种可持续的实践,而不是几个月或几年后就会倦怠。我们不可能在下班后或各种死亡行程中,独自一人完成这项工作!我们需要说服人们支持这项工作,让他们理解我们在做什么,以及为什么。这包括每个人:你的老板、同事,甚至是非技术人员。这样就算你去度假,回来后就会发现,当你不在办公室时,人们还在继续这项工作。
所有这一切只意味着:用一些简单的事实、合适的解决方案和一个时间表,以外行也能够听懂的方式解释问题。下面我给你简单举几个例子:
嘿,老板,上一个员工花了 3 周时间才编写好代码并做出了第一个贡献。如果我们能花最少的精力,在几分钟内完成,那不是很好吗?
嘿,老板,我快速组装了一个简单的 Fuzzing 设置,结果几秒内就让应用崩溃了 253 次。我想知道,如果有人在生产过程中对我们的应用进行这样的测试,会发生什么情况?
嘿,老板,最近修复几个紧急 Bug 花了几个人和两个星期的时间才部署到生产环境中,因为这个应用只能在一台服务器上构建,而这台服务器使用的是早在 8 年前就停止支持的旧操作系统了。哦,顺便说一下,一旦这台服务器死机,我们就再也无法进行部署了。如果能在便宜的云实例上构建我们的应用,那该多好啊?
嘿,老板,我们在生产中遇到了一个影响用户的隐秘 Bug,花了几周时间才发现并修复,原来是由于未定义的行为(“代码中很难察觉的问题”)破坏了数据。而当我在代码上运行这个行业标准的 linter(”发现代码中问题的程序”)时,它能立即发现这个问题。我建议,我们应该在每次修改代码时都运行这个工具!
嘿,老板,一年一度的审计就要到了,上次审计花了 7 个月才通过,因为审计员对他们所看到的不满意,这次我有办法让审计更顺利。
嘿,老板,刚刚新闻上说有一个安全 Bug,它可以解密加密数据并窃取机密,我认为我们可能会受到影响,但我不确定,因为我们用的加密库是手工制作的(”复制粘贴”),上面有一些未经任何人审查的改动。我们应该清理并设置一些功能,以便在出现影响我们的漏洞时自动发出警报。
相反,以下是你记得一定要避免的说法:
我们没有用最新的 C++ 标准,我认为应该停止所有工作,留两周时间来升级。不过我也不知道会有什么东西被破坏,因为我们没有测试。
我打算在一个单独的分支上修改项目中的很多东西,并为此工作数月。我相信它肯定会在某个时候被合并的!
我们打算从头开始重写这个项目,这需要几周的时间。
我们将改进代码库,但不知道何时能完成,也不知道具体要做什么。
好了,假设现在你已经得到了所有重要人物的支持,我们来回顾一下这个过程:
每一次改变都是小规模、渐进式的。应用程序之前能运行,之后也能运行。测试通过后,测试人员很满意,也没有任何更改被绕过。
如果需要紧急修复 Bug,可以照常进行,没有任何阻碍。
每项变更都是可衡量的改进,可以向非专家解释和演示。
如果整个工作不得不暂停或完全停止(因为优先级转移、预算原因等),与开始工作前相比,总体上仍有净收益(而且这种收益在某种程度上是可衡量的)。
根据我的经验,采用这种方法,你可以让每个人都满意,并能真正完成需要做的改进工作。
好了,让我们言归正传吧!
写下你支持的平台
这一点非常重要,但很少有项目做到这一点。写在 README 中,这只是一个列出你的代码库正式支持的<架构>-<操作系统>对的列表,例如 x86_64-linux 或 aarch64-darwin。这对于在每一个平台上的构建都能正常工作至关重要,而且我们稍后还会看到,它还可以清理掉不支持平台上的不必要文件。
如果你想要更加高级一些,甚至可以写下架构版本,比如 ARMV6 vs ARMv7 等等。
这有助于回答一些重要问题,比如:
我们能否依靠硬件支持浮点数、SIMD 或 SHA256?
我们是否需要支持 32 位?
我们是否会在大二进制平台上运行?(答案很可能是:不会,过去不会,将来也不会)。
一个 char 能否是 7 位?
还有很重要的一点: 这份列表一定要包括开发者的工作站,这也就引出了我下面要说的内容。
在你的机器上构建
你一定会惊讶于有这么多 C++ 代码库,它们是成功产品的核心部分,能赚取数百万美元,却基本上无法编译。当然如果一切顺利,它们是可以编译的。但我说的不是这个,我说的是在你支持的所有平台上可靠、稳定地构建:没有什么“我花了三周时间终于编译成功了”这样的过程,它本身就是能运行。
讲一个小插曲,我以前非常喜欢空手道,每周要进行 3、4 次训练。我清楚地记得我的一位老师曾经对我说:“你还没有掌握这一招。有时你会,有时你不会,所以你就是不会。当你用勺子吃饭时,你会不会五次中有一次没吃到嘴里?”
作为一名软件工程师,我一直带着这个问题。“新功能有效”意味着每次都有效,而不是有 80% 的可能性有效,因此构建工作是一样的。
经验告诉我,快速高效地开发软件的最佳方式是在自己的机器上构建,最好还能在自己的机器上运行。如果你的项目非常庞大,这可能是个问题,因为你的系统可能没有足够的内存来完成构建。备选方案是:在某处租用一台大型服务器,然后再运行构建。虽然这并不理想,但总比没有好。
另一个障碍是需要一些特定平台的 API,例如 Linux 上的 io_uring。在这种情况下,可以在工作站上的虚拟机内实现一个临时接口进行构建——同样,这也并不理想,但有总比没有好。
我过去曾使用过上述所有方法,最终发现:直接在自己的机器上构建仍是最佳选择。
在你的机器上通过测试
首先,如果没有测试的话,要对代码进行任何修改都会非常困难。因此,在对代码进行任何修改之前,先编写一些测试,确保它们通过了再开始修改。最简单的方法是,捕捉程序在现实世界中运行时的输入和输出,并以此为基础编写端到端测试,测试内容越丰富越好。
于是现在,你有了一套测试工具。如果某些测试失败,就先暂时禁用它们。确保代码最终通过测试,即使整个测试套件运行起来需要几个小时。
在 README 中写明如何构建和测试应用程序
理想情况下,这是一个用于构建的命令和一个用于测试的命令。一开始如果涉及的内容较多也没关系,在这种情况下,可以把相应的命令放在一个 build.sh 和 test.sh 中,以便封装这些疯狂的过程。
我们的目标是让非 C++ 专家也能编译代码并运行测试,无需向你询问任何问题。在这一步,有些人可能会建议记录项目布局、架构等,但由于下一步将删除大部分内容,我建议不要现在浪费时间,到最后再做。
找到容易实现的成果来加快构建和测试
我强调“容易实现的”,也就是不需要更改构建系统、不需要付出巨大努力(我在本文中不断重复这一点,这一点非常重要)。
同样,在一个典型的 C++ 项目中,你会惊讶地发现,构建系统根本不需要做多少工作。试试下面这些方法,看看是否有用:
构建并运行依赖项测试。在一个使用 unittest++ 作为测试框架的项目中(作为一个 CMake 子项目构建),我发现默认行为是每次都构建测试框架的测试并运行它们!这太疯狂了,通常有一个 CMake 变量或类似的东西可以选择不这样做。
构建并运行依赖项的示例程序。和上面的情况一样,这次的罪魁祸首是 mbedtls。同样,设置一个 CMake 变量来选择不进行这项操作就解决了问题。
当你的项目作为另一个父项目的子项目被包含时,默认情况下构建并运行项目的测试。刚才在依赖关系中嘲笑的默认行为,原来我们对其他项目也做了同样的事情!我不是 CMake 专家,但似乎没有在构建中排除测试的标准方法。因此,我建议在默认情况下添加一个名为 MYPROJECT_TEST 的构建变量,默认情况下不设置,只有在设置该变量时才构建和运行测试。通常只有直接参与项目的开发人员才会设置它,示例、生成文档等也是如此。
当你只需要其中一小部分、却要编译所有的第三方依赖程序时:mbedtls 是一个很好的例子,因为它公开了许多编译时标志来切换你可能不需要的部分。小心默认设置,只构建你需要的部分!
为目标列出错误的依赖关系,导致在不需要的情况下重建整个世界:大多数构建系统都有办法从它们的角度输出依赖关系图,这确实有助于搞清这些问题。没有什么比等待数分钟或数小时进行重建更糟糕的了,因为你知道它只应该重建几个文件。
尝试一个更快的链接器:mold 是一个可以直接使用并且在没有成本的情况下真正提供帮助的链接器。然而,这具体也取决于有多少库被链接、整体是否是一个瓶颈等等。
如果可以的话,尝试使用不同的编译器:我曾看到过一些项目,其中 clang 的速度是 gcc 的两倍,而另一些项目则毫无差别。
一旦做到了这一点,还可以再尝试以下几种方法,尽管收益通常要小得多,有时甚至是负的:
LTO(链接时优化):关闭/开启/使用 thin
拆分调试信息
使用 Make 还是 Ninja
使用的文件系统类型,并调整其设置
一旦迭代周期感觉 OK,代码就可以放在显微镜下观察了。如果构建时间很长,那么想要修改代码就不太现实了。
删除所有不必要的代码
我曾经看到整个代码库的 30% 甚至更多是完全无用的代码,每次进行编译、重构等,你都要为此付出代价。所以,我们要将它们删除。
下面是一些方法:
编译器有一堆 -Wunused-xxx 警告,比如 -Wunused-function。它们可以捕获一些问题,但并不是所有问题都能被捕获。这些警告的每一个实例都应该得到处理,通常只需删除代码,重新构建并重新运行测试即可。在少数情况下,这可能是调用错误函数的表现,因此,我不太愿意将这一步完全自动化。但如果你对你的测试套件很有信心,那就去做吧。
Linters(代码检查工具)可以找到未使用的函数或类字段,比如 cppcheck。根据我的经验,在继承情况下,这些工具会有很多误报,尤其是关于虚拟函数,但好处是这些工具绝对会找到编译器没有注意到的未使用的内容。因此,如果不是用于持续集成(CI),将 linters 添加到你的工具库中是个很好的选择。
我见过更奇特的技术,其中链接器被指示将每个函数放在自己的部分中,在链接时如果检测到某部分未被使用,会删除该部分并打印出来。但这会导致很多噪音,例如标准库函数未被使用,所以我觉得这并不实用。还有人检查生成的程序集,并将其中函数与源代码进行比较,但这对虚拟函数不起作用。所以,根据你的情况,也许值得一试?
还记得支持的平台列表吗?是的,是时候用它来清理所有不支持平台的代码了。在一个专门运行在 FreeBSD 上的项目中支持旧版本的 Solaris 代码?直接删掉;由于我们运行的平台可能没有随机数生成器(当然事实证明并非如此),从而编写了随机数生成器的代码?直接删掉;我们只在现代 Linux 和 macOS 上运行,但其中有针对 POSIX 2001 不受支持情况下的代码?直接删掉;检查主机 CPU 是否支持大端,如果支持就交换字节?直接删掉(你最后一次为大端 CPU 发布代码是什么时候?);多年前为了一个从未实现的假设功能而引入的代码?直接删掉。
做这些事情的好处,不仅在于你将构建时间加快了 5 倍,且没有任何副作用,更重要的是,如果你的老板稍微懂点技术,他们会很喜欢看到删除数千行代码的 PR,你的同事也是如此。
Linter
在设置 linter 规则时不要过度,添加一些基本规则,将其融入到开发生命周期中,逐步调整规则并修复出现的问题,然后继续前进。不要试图启用所有规则,那只会收益递减。我过去曾使用过 clang-tidy 和 cppcheck,它们很有帮助,但非常慢且噪音很大,所以要小心。但是,没有 linter 也是不可取的。第一次运行 linter,它会捕捉到很多真实的问题,以至于你会想:为什么即使所有警告都开启了,编译器仍然没有检测到任何问题?
代码格式化
等待适当的时机,确保没有分支活动(否则会遇到可怕的合并冲突),随机选择一种代码风格,对整个代码库进行一次性格式化,通常使用 clang-format,提交配置,完成。不要浪费口水去争论实际的代码格式化,它只是为了让差异更小并避免争论,所以不要对此争论!
Sanitizers
与 linters 类似,它也可能是一个“兔子洞”。但不幸的是,Sanitizers 绝对是必需的,以便发现真实的、影响生产的、难以检测的 Bug,并能修复它们。-fsanitize=address,undefined 是一个很好的基准,它们通常不会产生误报,因此如果检测到了什么问题,就去修复它。在运行测试时使用它,也能检测到问题,我甚至听说有人在生产环境中启用了一些 sanitizer。所以如果你的性能预算允许的话,这可能是个好主意。
如果你(必须)用来发布生产代码的编译器不支持 sanitizers,你至少可以在开发和运行测试时使用 clang 或类似的编译器。这时,你在构建系统上所做的工作就派上用场了,使用不同的编译器应该相对容易。
有一点可以肯定的是:即使是世界上最好的代码库,即使它拥有最好的编码实践和开发人员,只要你启用了 sanitizers,你绝对会发现一些多年未被发现的可怕 Bug 和内存泄漏。所以去做吧,不过请注意,修复这些问题可能需要大量的工作和重构。
最后一点:理想情况下,所有第三方依赖项在运行测试时也应该启用 sanitizers 进行编译,以便发现其中的问题。
添加 CI 管道
正如 Bryan Cantrill 曾经说过的,“我相信大多数固件都是从开发人员笔记本电脑的主目录中产生的”。设置 CI 既快速又免费,还能自动执行我们迄今为止已经设置好的所有功能(linters、代码格式化、测试等)。这样,我们就能在每次更改时,都在一个纯净的环境中生成生产二进制文件。如果作为开发人员的你还没有做到这一点,那么我认为你还没有真正进入21世纪。
锦上添花的是:大多数 CI 系统都允许在不同平台的矩阵上运行这些步骤!这样,你就可以明确检查支持的平台列表是否是真实的,而不仅是理论上的。
通常情况下,CI 管道就像是 make all test lint fmt,所以这并不是什么高深的技术。只需确保工具(linters、sanitizers 等)报告的问题确实未通过管道,否则就不会有人注意到并修复它们。
递进式代码改进
这已是众所周知的领域了,我就不多说了。我只想说,很多代码通常都可以大幅简化。
我记得曾经迭代简化过一个复杂的类,它需要手动分配和(有时)释放内存,还要处理通用事务等等。结果发现,这个类所做的一切就是分配一个指针,然后检查指针是否为空,然后…… 就是这样。是的,在我看来,这就是一个布尔值。真/假,没有更多的内容了。
我觉得这一步是最难限制时间的,因为每一轮简化都会开辟新的简化途径。在此,请运用你的最佳判断力,保持保守,并将重点放在安全性、正确性和性能等实际目标上,而不是“代码整洁” 等主观标准上。
根据我的经验,将项目中使用的 C++ 标准升级,有时可帮助简化代码,例如用 for (auto x : items) 循环替换手动增加迭代器的代码。但请记住,这只是达到目的的手段,而不是目的本身。如果你只需要 std::clamp,就自己写吧。
用内存安全语言重写?
我现在就在工作中这样做,这值得写一篇单独的文章。但这个过程中也有很多陷阱,只有在有充分理由的情况下,才能这样做。
结论
好了,至此这就是一个切实可行、循序渐进的计划,可让你摆脱复杂的遗留 C++ 代码库所带来的麻烦情况。我刚刚在工作中完成了一个项目,现在工作起来轻松多了。我看到以前不愿意接近这个代码库的同事,现在都做出了有意义的贡献,我真的感觉很棒。
还有一些重要的话题我想提一下,但最终没有说,比如在本地调试器中运行代码的绝对必要性、模糊测试、依赖项扫描以查找漏洞等等,也许这些会成为下一篇文章的内容!
附录:依赖管理
这一部分非常主观,只是我强烈而偏颇的观点。
迄今为止,我一直小心避免的一个热门话题就是依赖管理。简而言之,在 C++ 中没有依赖管理,大多数人会借助系统包管理器,这一点很容易注意到,因为他们的 README 是这样的:
在 Ubuntu 20.04 上:sudo apt install [100 行软件包]
在 macOS 上:brew install [100 行稍微不同命名的软件包]
其他操作系统:好吧,你可能运气不好了。我猜你得选择一个主流的操作系统并重新安装。
我自己也这么做过,但我认为这是一个糟糕的主意,原因如下:
如上所述,安装说明取决于操作系统和发行版,而更糟糕的是,它们还依赖于发行版的版本。我记得有一个项目花了几个月的时间从 Ubuntu 20.04 迁移到 Ubuntu 22.04,因为它们发布了不同版本的软件包,因此升级发行版也就意味着要同时升级项目的 100 个依赖项。这显然不是个好主意,理想情况下,你应该一次只升级一个依赖项。
总有一些第三方依赖项没有软件包,而你无论如何都得从源代码中构建它。
这些软件包从来都不会按照你想要的标志构建。Fedora 和 Ubuntu 多年来一直在辩论是否启用带有帧指针的软件包(最近才开始启用)。还记得关于 sanitizers 的部分吗?你要如何获取启用了 sanitizer 的依赖项?这是不可能的。还有更多的例子:LTO、-march、调试信息等等。或者它们使用的 C++ 编译器版本与你正在使用的版本不同,从而破坏了两者之间的 C++ ABI。
你希望在审计、开发、调试等过程中,轻松查看当前正在使用的依赖项源代码。
如果遇到 Bug,你希望能轻松地修补依赖项,并且在不必大幅改变构建系统的情况下重新构建。
在不同系统中,你永远不会获得完全相同的软件包版本,例如开发人员 Alice 使用的是 macOS,Bob 使用的是 Ubuntu,而生产系统使用的是 FreeBSD。因此,你会遇到无法重现的奇怪差异,这很烦人。
上述观点的推论:你不知道在不同系统上使用的确切版本,因此很难自动生成物料清单(BOM),而这在某些领域是必需的。
这些软件包有时没有你需要的库的版本(静态或动态)。
所以你可能会想,我知道了,我会使用那些新的 C++ 包管理器,比如 Conan、vcpkg 等等……不要那么急:
它们需要外部依赖项,因此你的 CI 会变得更加复杂和缓慢(例如,找出它们需要的 Python 的确切版本,这肯定会与你项目需要的 Python 版本不同)。
它们没有所有版本的软件包。例如:Conan 和 mbedtls,它从版本 2.16.12 跳到版本 2.23.0。中间的版本呢,它们怎么了?它们是否有缺陷,不应该使用吗?谁知道呢!无论如何,安全漏洞也没有列在可用版本中。
它们可能不支持你关心的某些操作系统或架构(FreeBSD、ARM 等)。
我的意思是,如果你的情况适合使用它们,那很好,它绝对是一个比使用系统软件包更好的改进。只是到目前为止,我从未遇到过可以使用它们的项目——总会有一些障碍。
那么我有什么建议呢?好吧,那就老实用 git 子模块和从源代码编译的方法。这的确很麻烦,但也有以下优点:
简单易懂。
比手动管理依赖更好,因为 git 有历史记录和差异功能。
你可以确切地知道,使用的是哪个版本的依赖项,直到提交为止。
升级单个依赖项的版本很简单,只需运行 git checkout。
适用于所有平台。
你可以选择确切的编译标志、编译器等来构建所有的依赖项,甚至可以为每个依赖项量身定制。
即使没有 C++ 经验,开发人员也能轻松掌握。
获取依赖项是安全的,远程源代码在 git 中,没有人会悄悄改变它。
可以递归工作(即:对依赖项的依赖项进行递归构建)。
每个子模块中的每个依赖项的编译,都可以简单到使用 CMake 的 add_subdirectory,或者手动使用 git submodule foreach make。
如果实在无法使用子模块,另一种方法是仍从源代码编译,但通过一个脚本手动执行,获取每个依赖项并进行构建。在实际中的例子:Neovim。
如果你的依赖关系图在 Graphviz 中看起来像一张 Rorschach 测试,并且必须构建数千个依赖项,那就不太容易做到了,但使用像 Buck2 这样的构建系统还是有可能的,它可以进行本地-远程混合构建,并在不同用户的构建之间重用构建产物。
最后,你还可以看看编译语言(Go、Rust 等)的软件包管理器,我知道的所有包管理器都是从源代码开始编译的。除去 git,再加上自动化,这就是同样的方法。