Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

如何编写对代码高亮友好的程序

#编程实践#ide性能#代码优化#scala#软件工程

本文介绍了“高亮复杂度”(highlighting complexity)的概念,并提供了一系列编写“高亮友好型”代码的建议,从而实现更快速、更高效的代码高亮。

代码风格不仅仅是为了美观,它还会影响物理世界!高亮友好型代码的益处包括:

  1. 更好的响应速度
  2. 优化的 CPU 使用率
  3. 高效的内存占用
  4. 更低的系统温度
  5. 更安静的运行状态
  6. 更长的电池续航 虽然单子(Monads)就像墨西哥卷饼(burritos),但你肯定不想在笔记本电脑上煎鸡蛋!

考虑高亮复杂度

假设你编写了如下函数,使用朴素递归来计算斐波那契数列:

$ scala
def fib(n: Int): Int =
  if (n <= 1) n
  else fib(n - 1) + fib(n - 2)

它的速度很慢,这是预料之中的,但你不能因此责怪 Scala。这个问题更本质,并不局限于特定的编程语言。然而,这并不意味着该函数无法优化。有一种方法可以调整代码,使其在输出完全相同序列的情况下提高效率。

代码高亮也是如此。如果高亮速度很慢,IDE 并不总是罪魁祸首。有些代码天生就难以分析。 不过,这并不意味着高亮不能变快。微小的代码调整可以使高亮效率显著提升,即便代码逻辑本身保持不变。

到目前为止一切顺利。然而,虽然“算法复杂度”是计算机科学的基础课程,但开发者很少考虑“高亮复杂度”。(两者有所不同:代码可能运行缓慢但易于高亮,也可能运行快速但难以高亮。)即使你学习过编译器构造,其重点通常也不是性能;即便涉及性能,讨论的也是编译器而非源代码。此外,批量编译代码与编辑代码并不相同。

遵循软件工程的最佳实践往往能加快高亮速度。这在一般情况下也很有用:保持类和方法的小巧与专注,追求清晰而非卖弄技巧等。然而,这些原则主要关注的是“认知复杂度”。与算法复杂度不同,认知复杂度通常与高亮复杂度相关。尽管如此,它们并不完全相同,有时甚至会有显著差异。

在编写代码时,你也应该考虑高亮复杂度。 如果忽视算法复杂度,代码性能会很差;如果忽视认知复杂度,代码会难以理解;如果忽视高亮复杂度,代码在编译或高亮时会耗费很长时间,并在此过程中消耗过多的资源。

优秀的代码应该在各方面都很出色。 幸运的是,让代码变得“高亮友好”的原则简单且易于实践。(大多数建议并非 Scala 独有,对其他语言同样有效。)

将代码拆分为模块

大多数 Scala 程序员会将代码划分为包(package),但很少有人将其划分为模块(module)。这两者其实有着相同的理由。

与 C 语言不同,Scala 原生支持包,大多数 Scala 项目也自然地使用了它们。然而,模块是 IDE 和构建工具的概念,而非编程语言本身,因此使用频率较低。即使是 Java 平台模块系统(JPMS),也主要是关于已编译的类和 JAR 包,而非源代码。

模块限制了绑定的作用域并引入了明确的依赖图——否则,任何源文件理论上都可能依赖于其他任何源文件。这限制了增量编译和分析的范围,从而加快了编译速度,降低了峰值资源消耗,并允许模块并行编译。

同样,模块也提高了高亮性能——IDE 可以更高效地搜索实体并使缓存失效。此外,这还能通过使自动补全和自动导入更相关来改善用户体验,减少干扰。另一个好处是,在运行应用程序或单元测试时,你可以只编译(或重新编译)项目的一部分(即使其他模块无法干净地编译)。

包通常是模块的自然边界。如果你的项目中只有一个模块,或者某些模块过大,请考虑将一个或多个包提取到单独的模块中。由于重构不会影响包本身,这应该是向后兼容的。此外,你仍然可以将这些类打包成一个 JAR 文件——重构针对的是源代码,而不一定是字节码。

请注意,你必须使用真正的模块——使用多个目录或多个源根目录(source roots)并不等同于模块。(参考 sbt 的多项目构建)。

将类放入单独的文件

Scala 编译器对源文件中可以添加多少个类(或如何命名文件)没有限制。这很有用,但你不应该过度使用该功能。

如果你只修改了源文件中的一个类,Scala 编译器无法单独编译该类,它必须编译整个源文件。对于 IDE 而言通常也是如此:你在编辑器标签页中打开的是文件而非类,这会分析整个文件。(不过,你可以使用增量高亮来克服这一限制。)

此外,当每个类都有一个专用名称的文件时,即使没有 IDE,也更容易查找类并在项目中导航。你应该像将包放入相应的目录一样,将类放入相应的文件中。

另一个原因是 import 语句。虽然每个类都需要自己的一组导入,但在单个文件中定义多个类会合并这些导入并使它们成为公共的。这可能会减慢引用的解析速度。(如果存在大量导入,而导入的实体又依赖于更多的导入,则可能会引发组合爆炸。)

如果你发现单个文件中包含许多较大的类,请考虑将类提取到单独的源文件中。这很容易做到,且不会影响向后兼容性。(显然,伴生类和密封类层次结构应保留在同一个文件中。)

在包中而不是对象中定义类

在 Scala 中,包和 object 非常相似,甚至还有 package object!这使得将类放入 object 而非 package 成为可能。然而,有充分的理由避免这样做。

首先,由于每个 object 都包含在一个源文件中,在 object 中定义多个类意味着在一个文件中包含多个类,这正如我们所见,并不理想。

其次,这不仅影响源代码,还影响编译后的代码。虽然每个类都被编译为单独的 JVM .class 文件,就像它们在 package 中定义一样,但 object 只有一个概要(outline)——即 pickles 或 TASTy。结果,即使编译器和 IDE 只需要访问其中一个类,它们也必须处理所有类。

因此,通常应该package 中而不是 object 中定义类。将 object 留给方法、变量和 type 定义。(在 Scala 3 中,顶层定义甚至可以直接放在 package 中。)

提倡小巧的类和方法

是的,你已经知道了这一点。但这里有一个转折。当你通常想到“小”时,往往会想到“简单”。例如,如果一个类只包含几个具有描述性名称的方法,这个类看起来很简单,你无需分析这些方法的代码就能理解它们的作用。

然而,这种便利并不适用于编译器或 IDE。如果你打开文件,整个内容都会被分析,如果方法(进而导致类)很大,分析将消耗大量时间和资源。

考虑将大型类和方法拆分为更小的部分,即使它们很简单。对于高亮显示,“代码行数”很重要;如果一个类或方法非常大,即使只有一个也可能造成负担。

这也适用于生成的代码:如果一个源文件是生成的,且其他源代码依赖于它,你不需要查看该代码,但 IDE 和编译器依然会进行处理。生成代码时,请将输出划分为更小的部分——文件、类和方法;不要将所有内容混在一起。

依赖接口而非类

“面向接口编程”通常是好事,这也有助于高亮性能。

假设有一个大型类,其中包含构成其 API 的几个方法。即使你只访问该 API,读取源文件也需要解析整个类,包括所有的实现细节。即使你显式指定了类型,解析相应的引用也需要处理大量的导入。

因此,如果类非常大,请考虑提取接口,而不是直接引用该类

避免通配符导入

使用具名导入而非通配符导入是广为人知的最佳实践。它使代码更具可读性——你可以清楚地看到符号来自哪里。它也使代码更健壮。(否则,在库添加了一个与现有导入冲突的类后,代码可能会停止编译。)此外,它减少了干扰——自动补全只会显示实际使用中的相关符号。

此外,具名导入可以加快代码分析速度。解析标识符时,必须检查每个通配符导入,而导入表达式本身可能又依赖于上方的通配符导入。可能存在来自对象的导入,而这些对象本身又依赖于其他地方的导入。所有这些并不局限于正在高亮的文件。即使你的代码只依赖于其他文件中的签名,由于类型注解中的路径不是绝对的,分析依然需要处理那些文件中的导入。

通配符导入对于隐式转换(implicits)尤其成问题。由于隐式转换是隐式的,并且可能需要其他隐式转换,搜索它们可能会非常耗费计算资源。如果使用通配符导入隐式转换,那么使用和导入都是隐式的。这使任务变得更加复杂——分析不仅需要找到某些模糊的实体,还必须在模糊的作用域中查找。

因此,优先使用特定导入而非通配符导入。将现有的通配符转换为具名导入。在 Scala 2 中,考虑按名称导入隐式转换。虽然 Scala 3 中的 given 导入是一种改进,但它们本质上也是通配符导入,因此依赖于良好的库设计。为保险起见,优先使用按类型导入(by-type imports)而非普通的 given 导入。(如果你正在设计一个库,请将隐式转换定义在单独的包或对象中。)

优先使用导入而非混入(mixins)

可以使用继承来代替导入。我们在 Java 中也能看到这一点:每个 TestCase 也是 Assert,因此你可以访问 assertEquals 等方法而无需导入它们。这看起来很方便。然而,这实际上是一种强制性的通配符导入,具有所有常见的弊端。最好选择性地 import Assert.assertEquals(或者作为选项使用 import Assert.*)。

此外,与常规的通配符导入相比,使用子类化或混入 trait 的方法更慢。分析必须考虑继承和线性化,以及重载和重写。如果你修改了 trait,使用它的类必须重新编译。

如果某些定义本质上是静态的,请将它们放入 object 而非 trait,以便客户端通过导入而非继承来使用它们。

将类和方法声明为 private

最小化类和方法的可见性有很多充分的理由:区分 API 与实现、维护源代码和二进制兼容性、防止自动补全列表冗余、减轻认知负担。

鲜为人知的是,尽可能将类和方法声明为 private 可以提高编译和高亮性能。增量编译器在确定 API 时不包含 private 成员,因此无需存储和比较它们。在解析引用的过程中,IDE 可以更快地跳过不可访问的元素。当你写下 "Foo" 时,你已经知道暗示的是哪一个 Foo。然而,解析引用往往涉及的计算量可能会让你感到惊讶。将不合适的 Foo 声明为不可访问,有助于加快分析速度。

Scala 插件可以通过自动检测可以设置为 private 的声明来提供帮助。

为公共或复杂的定义指定类型

每个非本地定义要么应该是 private,要么具有类型注解。客户端可访问的定义构成了 API。API 是抽象的边界,因此必须是显式的;客户端不应该为了理解签名(左侧)而去研究实现(右侧)。与实现不同,API 必须稳定,且不能依赖于右侧的内容。类型注解使 API 既显式又稳定。

类型注解对增量计算有很大帮助。当签名稳定时,代码修改后需要重新编译的类更少。同样,在 IDE 中编辑代码时,可以重用更多的缓存,从而使高亮速度更快并减少资源消耗。

因此,最好总是显式指定非私有成员的类型。请注意,即使存在重写,也应该指定类型,因为推断出的类型可能更具体,至少在 Scala 2 中是这样。(例如,如果超类方法返回 Seq[Int] 而子类方法只是 = List(1),后者的类型将是 List[Int],这可能会影响直接使用子类的客户端。)你也应该指定受保护(protected)成员的类型,不仅仅是公共成员——子类也是客户端。(作为例外,当右侧既简单又稳定时,例如字面量,可以省略类型。话虽如此,明确写出类型通常更好,无论是对人类还是对编译器而言。)

此外,显式类型甚至可以使私有和本地定义受益。虽然增量编译器会重新编译整个文件,但 IDE 可以更渐进且在更窄的范围内使缓存失效。因此,如果私有成员很复杂,请为其添加类型注解——这可以使代码编辑更高效。此外,指定复杂本地变量的类型。(有时你可能需要先提取一个方法或引入一个变量来指定类型。)

Scala 插件中的 Code Style | Type Annotation 要求为公共和受保护成员提供类型注解——它们由重构和代码生成自动添加,并由相应的检查器进行检查。但是,简单表达式有例外情况,对于私有或本地定义,无论复杂程度如何,都不强制要求。为了保险起见,你可以将这些设置调得更严格。

优先使用标准语言特性而非宏

宏背后的概念看起来很诱人——你在编译时而不是运行时进行计算。然而,“编译时”也是“高亮时”,无论你是在编辑代码时使用编译器还是 IDE,这都是事实……除非你总是没有辅助地一次性写完所有代码。因此,宏可能会干扰代码的编写和编辑,导致反馈变慢并消耗更多资源。请注意,这不仅适用于需要特性标志的定义宏,也适用于使用宏,这不需要特性标志。

宏实际上很少被需要。以 Lisp 为例:语法非常有限,语言是动态的,所以无论如何都不会执行静态分析。然而,Scala 本身就是一种表达力很强的语言,并且是静态类型的。在 Scala 中,标准语言特性足以胜任大多数任务。在这种情况下,宏只会使静态分析以及理解代码变得更加困难。因此,编写代码时,首先尝试使用标准语言特性:类型参数、隐式参数等。宏应该是最后的手段,而不是首选解决方案。

这可以概括为:不要仅仅因为“你可以”就使用复杂的语言特性,只有在真正需要时才使用;优先选择能解决问题的最简单方案。关于此主题的更多详细信息,请参阅 Martin Odersky 的 Lean Scala

将这些原则应用于 AI 生成的代码

即使你使用 AI 生成了 100% 的代码,你依然会阅读这些代码。(对吧?)因此,编写高亮友好的代码依然至关重要——代码是在数据中心生成的,但却是在你的机器上进行高亮显示的。这也改善了增量编译,减少了使用代理工具时的系统负载。此外,它还防止了上下文填充(当模型加载无关信息时),从而提高了准确性并降低了成本。

你能做的第一件事就是以身作则引导 AI,因为模型倾向于传播现有的惯例和编码风格。在一个新项目中,你可以明确地AGENTS.md 中添加建议。最后但同样重要的是,你可以随时重构你的代码,无论它是人类编写的还是 AI 编写的。

总结

话虽如此,IDE 的性能也同样重要。我们一直在致力于提高 IntelliJ IDEA 和 Scala 插件的性能,并且你可以实践一些提高性能的技巧。然而,正如再多的编译器优化也无法修复朴素递归示例一样,高亮显示有时也需要你的协助。

和所有事情一样,高亮复杂度不是唯一的考虑因素;你需要平衡不同的考量。但通常情况下,两者并不矛盾:干净的代码改善了高亮复杂度,而改善高亮复杂度会产生更干净的代码。总之,始终考虑高亮复杂度并掌握这些技巧是非常有用的。

更多详情,请参见 YouTrack 上的相应 工单。它还列出了可以帮助你更轻松地应用重构的功能。如果你觉得它们有用,请为这些工单投票,让我们知道存在这方面的需求。

如果你有任何问题,欢迎随时在 Discord 上向我们提问。

祝开发愉快!

JetBrains Scala 团队