C++ 在过去的十年中进步很大,以至于有些人把它看作是一种完全不同的语言,而不是“老旧的遗留 C++”。尽管现代 C++ 依然保留了与原来的准则和基本语法,但这些更新和进步对 C++ 语言和标准库意义重大。
不过,也不是每个人都在使用最新版本的你。诚然,根据 Jetbrains 2021生态系统调查,大多数项目和公司已经颇有进步,并不断向更新的语言规范迈进。不过,尽管 C++ 20 已经发布,但调查中仍有 12% 的开发者使用 C++98/03,40% 的开发者“卡在”C++11上,而绝大多数(42%)的开发者使用 C++17。
图片来源 : Jetbrains 2021生态系统调查
调查数据清楚地展现了,在大多数情况下,开发者和公司并不会迅速升级到最新语言,就算升级,也不会立马更新到最新的 C++ 20,而是选择 C++ 14 或 C++ 17。这样说来,目前使用 C++ 17 的群体还算是个例外,因为他们迫切地想要升级到 C++ 20。
图片来源 : Jetbrains 2021生态系统调查
为什么需要选择更新的版本呢?
使用现代C++版本的理由很多:
- 语法更新,代码库功能更全,编码的可能性更丰富,也意味着代码可能更优质,性能更强大。
- 使用成为标准库中部分的标准和功能,而不是使用外部库,让代码维护更简单,开发者之间进行代码转换更方便。
- 跟上最新趋势,开发者喜欢不断进步。你当然也不希望团队落后,不然他们会寻找其他项目。
- 尽管没有定论,但一般来说,老旧的语言版本,意味着语言无法跟上操作系统和编译器版本发展。因此,这反过来增加额外的兼容性和安全性风险。
为什么企业中的代码语言发展滞后?
公司仍然使用旧版本语言(甚至还有 C++ 98/03 或 C++ 11)的主要原因,是编译器还支持这些语言。如果你的产品依赖于某一确切的操作系统,可能这个操作系统是你安装的一部分。但这个系统缺乏一个现代的 C++ 编译器,那么问题就很容易出现。
另一种情况是,升级代码库很麻烦,且不值得,因为产品是一个老旧的遗留产品。有时候,公司也没有人力能够去开展这类升级,而且,升级也有风险。
但不论原因为何,语言版本滞后迟早都是个问题。因为,你会慢慢积累技术债务,代码也变得过时。假设你要开发一个新的特性,或者修复一个缺陷,你会因为旧的遗留 C++ 版本代码“卡”住,进退两难。这些旧的版本让你无法使用许多现代 C++ 代码用例,你也无法使用现代 C++ 语法库和实用程序。
既然我们了解了升级到现代 C++ 的重要性,现在,我们来讨论迁移的路径。
迁移策略:从传统 C++ 升级为现代 C++,保持系统完好无损
第一个障碍可能是操作系统。旧版本的操作系统可能带有不支持现代 C++ 的C++ 编译器。例如,旧版 VxWorks 仅支持旧版 WindRiver Diab 编译器,这个编译器支持 C++03,但不支持 C++11。在当前的 VxWorks 版本中,Diab 编译器是以 LLVM 为基础的,支持现代 C++ 版本。此外,VxWorks 的较新版本还支持 GNU 和 ICC 编译器。要使用这两个编译器的任何一个,你都要先升级操作系统,如果你使用的是旧版的 VxWorks,这是项目的自身需求。这只是一个例子,其他遗留环境也会出现类似的操作系统障碍。
有时你也需要支持旧版的操作系统,可能客户有需求,或者因为你需要用旧的硬件设备。管理客户需求,宣布软件产品的终结,这些不在本文内容范围之内,但我想提醒的是,这也是一个必经的过程。
如果在迁移到新环境后,还必须支持旧操作系统和旧编译器,那么你可以将新旧环境共享的代码进行隔离,让这些代码保持原样,同时迁移与新环境相关的部分。
如果你正在转换操作系统或编译器,你的第一步是在新的操作系统(如果需要的话)上用新的编译器编译代码,而不改变 C++ 版本。在用更新的 C++ 版本进行编译之前,编译器的简单更改可能会引发一些需要后续进行修复的问题。这些问题通常是因为不同编译器在各方面的要求不同。一些编译器可能对某些方面更宽松,但另一些编译器可能更严格,这些差异会引发编译错误,需要后续修复。你可以使用错误标志(例如删除,-pedantic 或 -Wall)来避免这类错误,同时在项目中添加一个注释,说明这些错误标志将在后面重新添加。
旁注:如果你的项目使用的是静态或动态代码库,无论是内部还是外部代码库,如果这些库是使用旧版编译器编译的(尤其是使用另一个编译器),那么就可能会出现兼容性问题。ABI(应用程序二进制接口)可能会中断,这可能需要你使用新的编译器重新编译相应的代码库。
如果你使用的是同一操作系统和编译器,但只需升级 C++ 版本,接下来要做的是是设置适当的标志,让构建使用更高级的C++版本(例如,-std=C++17)。因为 C++ 版本大多是向后兼容的(在少数情况下,语言本身可以进行错误修复,要求更改不是向后兼容),所以代码只能按原样编译。但是,如果编译器在某些方面要求更加严格,如有些代码之前不算错误,但现在却算作错误,那么可能会引发编译问题。同样,你可以删除一些错误标志来避免这些问题,这通常属于迁移项目管理的内容,帮你将所需的修复推迟到后面的阶段。
一旦你的项目编译并运行新的 C++ 版本,你就可以长舒一口气了。不过,也不能高兴得太早。接下来是全面认证的步骤,要确保所有产品都能正常工作。在另一个环境中重建代码,导致行为改变,其中可能的原因多种多样。所有这些都是源自隐藏在代码中的缺陷,而这些缺陷现在慢慢显现出来。可能与未定义行为代码有关,因为不能代码行为无法确定最终导致错误;或者因为一些未定义行为代码,导致编译器选择了特定的行为,但在后续更改编译器时,编译行为发生改变。如果你的应用程序是多线程的或基于计时的,则在重建后,一般需要更改计时。环境中的任何微小变动,都会引起新的竞争条件和数据竞争,这些竞争关系可能以前只是隐藏在代码中。
完整的认证之后,一切才真正开始:你的代码可以以更新的C++ 版本进行编译、运行和工作。
你可能已经享受到了升级的快乐。例如,即使代码没有任何更改,从 C++ 98/03 升级到 C++ 11 或更高版本,使用 Rvalue 和移动语义的标准库容器可能会更高效!为了充分利用升级的优势,你也希望使用高级 C++ 特性。不过,问题在于从哪里入手。
再小的增量改变,都需要一个理由
如果它能正常运行,那么没有理由去改变它,尽量别碰它。
- 在将所有回路更换为以范围为基础的回路之前,请按兵不动。
- 不要急于用 auto 定义所有变量声明。
- 先将新算法添加到标准库,替换现有循环。
- 没必要将所有旧分配转移到智能指针。
我的建议是,在添加新特性或修复缺陷时,在代码中首先找到最有利的更改,然后非常小心地更改其他地方。例如,如果是从 C++ 98/03 升级到 C++ 的任何更新版本, 其中最有利的更改,以及最需要搜索查看的地方包括:
- 不在零规则下的类(如,它们具有用户定义的复制构造函数、赋值运算符或析构函数),并且可以通过移动构造函数和移动赋值运算符获得性能增益。
- 在不使用 std::move 的情况下,移动数据结构的位置(当然,添加 std::move 时应非常小心,并且仅在相关的情况下)。
- 将对象传递给数据结构,并将其放入数据结构中的位置。
- 尽量将外部库替换为内部标准库,降低对外部库的依赖性。
不论什么时候,更改都需要代码审查。即使开发者觉得并没有做什么重大更改,但所有更改都有一定风险,需要进行审查。
慢慢地向现代 C++ 的新特性转移,在很长一段时间内,代码会处于一种很奇怪的状态,有时在完全相同的文件中,部分是新的代码,部分则看起来过时了。但没关系,罗马也不是一天建成的。
总结
只要你有一个活跃的 C++ 项目,那么向现代 C++ 版本迁移就是必经之路。落后的版本将不断产生技术债务,你不能使用新的 C++ 语法,也无法利用现代版本的外部代码和库。另外,你也会发现,你的开发者流失率会很高,因为开发者总是希望开展新的项目,使用更新的语法和工具。
我们讨论了解决迁移项目的策略。你应该记住,这是一个需要身体力行的项目,不是一个副业,也不是一个下午就完成的事情。你需要周密的计划和明确的目标。如果做得好,则所有的担心都不会实现。
需要记住的一点是,仅仅迁移到一个更新的语言版本是不够的。如果团队不知道如何正确使用现代 C++ 的特性和能力,那么语言的错误使用也将引发更多问题,或者因各种琐碎的使用细节导致工作效率低下。
在迁移项目期间,需要进行很多构建。你需要开展大量编译,大部分情况下,我们需要先修复再重新编译。然而,所有这些构建和编译都需要时间,因此项目进展可能会有所延迟。关键时刻,就需要 Incredibuild 发挥功效。使用Incredibuild 构建加速服务,你可以更快、更好地完成这样的版本迁移。当然,Incredibuild 还有更多优秀的功能,如果你还没有使用过,那它肯定能为你提供一个好的用例。
最后,如果你决定迁移到现代 C++,那就勇敢点。直接跳转到 C++20,即使你的编译器仅部分支持 C++ 20,也是值得的。所有新的 C++ 版本都有很多新的惊喜,C++ 20 带来了四个重大的改变:概念、模块、范围和协程。
点击了解 Incredibuild 的 C++构建加速方案,并获取试用 License!