C++ 重构技巧

Blog
Author:
Dori ExtermanDori Exterman
Published On:
11月 17, 2021
Estimated reading time:
1 minute

多年前,当 C++ 还不够成熟的时候,全球代码库很小,我们可以轻松地阅读、审核代码,然后将其用于构建项目,产生无限可能。或许这种情况可能从未出现,但我们可以想象一下,如果程序虽然简短简单,但很完整,不需要扩展功能。这种情况下,可能没必要进行代码重构。然而,如果需要检查或更新代码库,无论是扩展代码库,还是基于不同语言改进代码库,亦或是仅为了检查效率而审核代码库,重构代码都很有必要。

重构代码的原理是在不改变代码的前提下改进现有代码。实质上,就是在提高设计和整体可读性的同时,确保功能完好。代码重构提高了效率、可读性、可维护性和可扩展性。让我们从语法和语义两个方面进行简单的补充说明。

重构为何如此重要?

重构的目的在于使代码更具可维护性。通过提高可读性,使故障诊断和调试变得更简单。此外,对于需要完成新增功能、平台迁移或采用编程语言最新和最重要功能的新手人员,重构能够减轻他们的大量推演工作。

切记,随着新功能的添加,内聚性会降低。内聚性指的是模块各组件之间的关联程度,一般而言,功能应具有高度内聚性。添加一个新功能可以提高多功能性,但它不是一个高度集中的函数或类,而是更广泛和不集中。而重构的目的之一就是提高内聚性。

尽管严格来说,代码重构并非用于清除漏洞,但通过重构,确实可以发现漏洞。例如,当几乎相同的代码块仅略有不同时,细微的差异并不总是像复制粘贴错误那样有意为之。代码重构是预防性维护的重要一步,有助于为未来的开发人员明确遗留功能。

重构的另一重要需求是准备新功能的代码库。需要添加新功能时,一种很好的方式就是先对代码进行必要变更,以便引入新功能,而不改变实际运行。因此,我们可以检查回归,确保代码已准备就绪,可引入新功能,但功能仍可正常运行。接下来,就可以引入新功能。这可能需要改变原先的测试,因为系统行为可能随新功能的变化而改变。将重构代码作为第一步,可以有效降低功能扩展所带来的风险。

我们应在何时重构?

理想情况下,重构应在构建现有代码库之前完成。另一方面,也许您想对遗留的 C++ 代码库进行现代化改造。在任何情况下,当您处理用于添加新功能或更新现有功能的代码时,它提供了清理现有功能的绝佳机会。尤其是,对于那些不熟悉项目的人来说,这是了解已有内容的绝佳时机。重构工作不仅会改善项目(至少在可读性方面),而且会使开发人员在需要进行下一组增强时更加容易。

代码异味

术语代码异味是指可以快速发现的东西,通常对应于代码中更深层次的问题。然而,需要切记的重要一点就是,代码异味通常是问题的一个指标,而不是问题本身。此外,它并不总是表示存在问题。一个很好的例子是有一个特别长的函数。它可能看起来可疑,并最终指向一个隐藏着细微缺陷的区域。也就是说,拥有一个长函数本身并不是一个错误。

还有一种观点认为,只要程序员看到有改进的机会,就应该进行重构。因此,当他们察觉到问题时,就应采取行动。事实上,有人建议,这不应该是一项有计划的任务,而应是一项例行的工作,以确保当前的代码库始终处于良好状态。我们的想法是,如果定期完成小的重构任务,那么就没有必要为一个更为有意和明确的重构项目安排时间。

我们在何时不应重构?

尽管出色的代码重构有这么多好处,但是否有放弃该过程的最佳时机? 当然有,在有些情况下,所需的时间和精力会导致截止日期出现问题。代码重构可能会非常耗时,而且在有些情况下,在进入下一步之前,必须先完成大块代码。当截止日期很紧张时,代码重构的回报可能确实在递减,甚至在最坏的情况下,会使您超过时间线的临界点。这是一个代价高昂的错误,可以通过适当的安排来避免。归根结底,重构可能比你想象的要花更长的时间,所以应有相应的计划。

前面的例子中说的就是一种可以置之不理的情况,直到有一段合适的时间可供操作。然而,有时出于相反的原因跳过重构则是明智的做法。我说的是你不能置之不理的情况。具体来说,代码需要显著更新,并且可能需要完全重写。因此,代码重构的效率比重新开始要低。

为什么在 C++ 中重构代码这么难?

因为存在一系列与代码库重构相关的固有问题。例如,如果团队不熟悉系统的当前状态或出于对设计的考虑,他们就需要花时间了解最新状况。开发人员必须对功能有所理解,确保不会干扰正常功能。例如,在没有必要知识储备的情况下尝试对代码库进行现代化升级,很容易导致某种漏洞,若不进行大量测试,可能难以捕捉到这种漏洞。

除了可能影响任何语言的问题之外,C++ 开发人员发现代码重构更具挑战性,部分原因是语言很复杂。这种复杂性与语言扩展功能(如宏和模板)相结合,更不用说语言本身很大,而且语法很难处理。

幸运的是,您可以遵循一些非常好的基本重构步骤,一些 IDE 可以帮助您使用重构工具。

有些 IDE 包含重构工具

一些 IDE 至少在某种程度上支持重构。虽然并非每个 IDE 都能处理该过程中的所有步骤,但该功能无疑将有助于节省开发和测试时间。当比较现代 IDE(比如 Visual Studio Eclipse)时,很明显两者都具有代码重构功能。有些是内置的,而更高级的重构功能则可以通过使用扩展来实现。我们的最佳 C++ IDE 列表中还包括 JetBrains 的 CLion,其中包括多项自动重构功能。

例如,Visual Studio 具有多个可用于 C++ 的内置重构操作,其中许多可从 Quick Action 上下文菜单中获得。如果需要更多功能,那么 Visual Studio Marketplace 有无数的扩展可以提供帮助。Visual Studio 的一个常用扩展是 Visual Assist,由 Whole Tomato Software 提供。这提供了多种功能来帮助程序员,包括更高级的重构技术。

C++ 重构的顶级技术

只要能在正确的时间内完成,重构显然就是一项值得做的工作。此外,有的情况下最好将重构视为一系列微重构,其中每个微重构都是源代码中的一小部分更改。这些微小的更改带来了明显的好处,为了更好地利用它们,我们建议采用重命名、提取、内联和搬移对象等顶级技术。关于该主题的宝贵资源是 Martin Fowler 与 Kent Beck 合著的著作《Refactoring》。

重命名

说到命名,很多人从一开始就搞错了。它可能从一开始就被懒散地或不恰当地命名,或者,功能已经发展到名称无法再准确描述的地步。值得庆幸的是,对类、变量或函数进行这种类型的更改并不像使用出生证明那样困难。

此操作可以像使用标准文本编辑器复制和替换特定名称一样简单,尽管在涉及 C++ 和一般编码时,范围很重要。这些更改是要在全局范围内进行,还是特定于某个函数或类? Visual Studio 提供了 Rename 功能,如下例所示。

图 1:带有通用 “calculate(…)” 函数的简单代码库

右键单击 “calculate” 会显示上下文菜单,其中包含 Rename 选项。选定该选项后,将显示实体重命名选项。

c-refactoring_figure-2_context-menu-in-Visual-Studio.

图 2:可以从 Visual Studio 的上下文菜单进行重命名

c-refactoring_figure-3_Options-for-renaming-a-variable-in-Visual-Studio

图 3:Visual Studio 中的变量重命名选项

一旦 “calculate” 被 “add” 替换,考虑到作用域和其他选项,结果就是一个在整个程序中使用的名称更合适的变量。

c-refactoring_figure-4_Trivial-codebase-post-Rename-operation-768x731-1

图 4:简单代码库,重命名后操作

请注意,代码仅比原来长了几行。这是因为简单的 Rename 方法在第 6 行自行添加了 “int main();” 声明。由于 main 的原型并没有特别的帮助,这是一个很好的例子,说明自动重构可能会产生意想不到的副作用。并非该工具所做的所有自动重构步骤都是最优的,因此,为了简单起见,程序员可能会选择覆盖这些步骤。

提取

可以对变量、函数、类甚至参数进行提取。本质上,它涉及到将代码分解成更小、更离散的块,类似于构建块。

函数提取

在以下示例中,几乎相同的循环用于增加两个不同的值。使用 Visual Studio 的内置函数提取可以采用不同的方法。本例处理两个循环中的第一个,其中创建了一个新函数 “incrementLong(…)”。这个更为通用的函数被调用两次,而不是运行两个不同的 for() 循环。尽管在这个简单的示例中没有任何变化,但更复杂的过程会受益,因为内循环更改只需要进行一次。

c-refactoring_figure-5_Toy-function-with-duplicate-loops.

图 5:具有重复循环的 Toy 函数,用于添加两个长值

c-refactoring_figure-6_Using-Visual-Studio

图 6:使用 Visual Studio 将循环提取到其自己的函数

选择用于函数提取的代码后,开发人员会看到少量选项以及新函数签名的预览。

 c-refactoring_figure-7_Extract-Function-options-in-Visual-Studio

图 7:Visual Studio 中的 Extract Function 选项

c-refactoring_figure-8_Single-function-extraction-in-Visual-Studio.

图 8:Visual Studio 中的单个函数提取

变量提取

如果您了解函数提取用于合并和离散化代码,那么您也了解变量和类会发生什么。变量提取用于从表达式中创建变量以提高代码可读性。考虑以下示例:

 c-refactoring_figure-9_Pre-variable-extraction.

图 9:预变量提取

c-refactoring_figure-10_Post-variable-extraction

图 10:后变量提取

虽然有一个明显的折衷办法是增加变量,但毫无疑问,即使是这个简单的函数也更容易阅读和调试。关于优化和副作用,还有一点需要说明。

具体来说,在预提取的示例中,如果 x 的值在运行时为 0,那么程序在评估之后将立即知道 if() 语句为假。因此,它不会评估 abs(y),更不用说将其与 20 进行比较。在后提取版本中,会对 abs(x) and abs(y) 进行评估。这仅在第二个函数的执行产生任何副作用时才相关。如果是这样,那么在第二个版本中,无论 x 和 y 的值是多少,状态和执行时间都是相似的。

在这种情况下,不仅提高了可读性,而且还提高了可预测性。

类提取

类提取更可能是手动完成的。一旦认识到一组变量(特别是原语)被用于一个共同的目的,就应该创建一个类。

例如,如果会计系统基于美元,则可能会使用一组简单变量和独立函数,例如:

c-refactoring_figure-12_Simple-function-that-can-be-inlined

图 11:会计系统代码片段

随着时间的推移,该系统会不断发展,并且在某一时刻,该功能会支持多种汇率不同的货币,并跟踪特定日期结束时的每种汇率。这样做需要几个更相关的原语和函数,因此是将它们提取到类中的理想位置。

拥有一个 “balance” 类将包含金额、汇率和相关日期的变量,以及用于初始化、检查和报告这些变量的方法。

内联

内联是另一种重构方法,它实际上与提取相反。考虑这样一种情况,函数体非常简单,比函数调用更容易理解。这通常不是有意为之。实际上,创建函数是为了封装更复杂的行为,但随着时间的推移,就像提取越来越复杂的对象一样,那些变得过于简单的对象可以被内联。

图 12:可以内联的简单函数

与其使用 getBalance() 函数充当 wrapper,不如使用内联版本:

在这种情况下,使用较少的函数会使代码更易于阅读。

搬移

搬移对象是一种重构,通常在一个变量或方法被多个类使用时使用。例如,如果 A 类使用 B 类中的方法,且比 B 类使用的多,那么搬移该方法是有意义的。B 类可以使用代码引用新方法,甚至删除它,这取决于重构的级别。

C++ 重构——总结

代码重构是预防维护的重要一步,可以在不更改其功能的情况改进现有代码库。尽管在重构方面有很多好处,但有时您会这样做,有时您不会。尽管 C++ 比 C# 或 Java 之类的语言更难重构,其好处证明这么做是值得的。如果您这样做了,请做出相应计划,因为它可能需要比预期更长的时间才能完成。与任何编码一样,不可避免地会出现隐藏的挑战。

很多重构技术都是可用的,尽管我们在这里只介绍了一些基础知识,但已经足够帮助您入门了。至少,您知道有现代 IDE 和扩展可以在此过程中为您提供帮助。

本着保持简单的精神,我们就讨论到这里。