Scala logo

Scala Plugin

Scala Plugin for IntelliJ IDEA and Android Studio

Scala Scala programming

如何编写对高亮显示友好的代码

Read this post in other languages:

本文将介绍高亮显示复杂度的概念,并提供编写对高亮显示友好的代码的方式,从而更快速、更高效地实现高亮显示。

代码样式并非只关乎美观,还会影响设备运行! 编写对高亮显示友好的代码有以下好处:

  1. 响应能力更好
  2. CPU 使用率得到优化
  3. 内存使用更高效
  4. 系统温度更低
  5. 运行更安静
  6. 电池续航时间更长

虽说单体概念类似墨西哥卷饼,但可不要把笔记本电脑当煎锅用哦!

考虑高亮显示复杂度

假设您编写了这个用朴素递归计算斐波那契数的函数:

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

可以预见它运行很慢,但您不会因此责怪 Scala。这个问题更为根本,并且不特定于编程语言。但这并不意味着该函数的运行速度无法变快。可以通过一种方式调整代码,使其输出完全相同的序列,但效率要高得多。

代码高亮显示也是同样的道理。如果高亮显示速度很慢,并不总是 IDE 的问题。某些代码本身就难以分析。但这并不意味着高亮显示的速度无法快速。微小的代码调整就能让高亮显示效率大幅提升,即使代码本质上几乎没变。

到现在为止都没问题。不过,虽然算法复杂度是计算机科学的基础,开发者却很少考虑高亮显示复杂度。(两者有所不同:代码可能运行速度很慢,但很容易实现高亮显示,也可能运行速度很快,但难以实现高亮显示。) 即使您学过编译器构造,其主要内容也不是关于性能的,而其中关于性能的部分,讨论的是编译器,而非源代码。此外,批量编译代码与编辑代码也不是一回事。

遵循软件工程最佳做法通常能加快高亮显示的速度。这种做法在一般情况下也很有用:保持类和方法小型化并有相应的侧重点,优先保证代码清晰,而不追求炫技等。不过,这些原则主要关乎认知复杂度。与算法复杂度相比,认知复杂度通常与高亮显示复杂度存在相关性。尽管如此,它们并不相同,有时甚至可能有显著差异。

编写代码时,您还应考虑高亮显示复杂度。如果您忽视算法复杂度,代码运行会很慢。如果您忽视认知复杂度,代码会难以理解。如果您忽视高亮显示复杂度,代码编译或高亮显示会花很长时间,并在此过程中会消耗过多资源。

好的代码应该在所有方面都表现出色。幸运的是,编写对高亮显示友好的代码的原则很简单,在实践中也很容易应用。(其中大多数方式并非特定于 Scala,也可用于其他语言。)

将代码拆分成模块

大多数 Scala 程序员会将代码划分为软件包,但很少有人把代码划分为模块。这两者背后的原因相同。

与 C 这类语言不同,Scala 支持软件包,大多数 Scala 项目也自然而然地使用软件包。但模块是 IDE 和构建工具的概念,而非编程语言的概念,因此使用频率较低。即使是 Java 平台模块系统,也主要针对已编译的类和 JAR 文件,而非源代码。

模块限制了绑定的作用域,并引入了显式的依赖关系图 — 否则,任何源文件原则上都可以依赖任何其他源文件。这限制了增量编译和分析的作用域,从而加快编译速度、降低峰值资源消耗,并允许模块并行编译。

同样,模块也能提升高亮显示的性能 — IDE 可以更高效地搜索实体和使缓存失效。此外,这还能提高自动补全和自动导入相关性、减少混乱,从而改善 UX。另一个好处是,在运行其中一个模块中的某个应用程序或单元测试时,可以只编译(或重编译)项目相关的部分(即使其他模块无法正常编译也不影响)。

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

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

将类放入单独的文件中

Scala 编译器不限制您可以在源文件中添加的类数量(也不限制对该文件的命名方式)。这个功能可能很有用,但不应过度使用。

如果您只修改了源文件中的一个类,Scala 编译器无法单独编译该类 — 而是需要编译整个源文件。对于 IDE 来说也大致如此:您在编辑器标签页中打开的是文件而非类,IDE 会分析整个文件。(不过,您可以使用增量高亮显示来克服这一限制。)

此外,如果每个类都有一个具有专用名称的文件,即使不使用 IDE,也更容易查找类和在项目中导航。您应该像将软件包放入相应目录一样,将类放入相应的文件中。

另一个原因是 import 语句。虽然每个类都需要自己的一组 import,但在单个文件中定义多个类会合并这些 import,使其成为公共 import。这可能会拖慢引用的解析速度。(如果存在大量 import,而导入的实体又依赖很多 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 和编译器仍然需要查看。在生成代码时,请将输出划分为更小的部分 — 文件、类和方法;不要将所有代码混到一个 blob 中。

依赖接口而非类

“面向接口编程”总体来说是不错的做法,也有助于高亮显示。

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

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

避免通配符 import

使用具名 import 而非通配符 import,这是公认的最佳做法。这样能提高代码的可读性 — 您可以清晰地看到符号的来源。这种方式还能提高代码的稳健性。(否则,当库添加一个与另一个导入类冲突的类时,代码可能无法编译。) 并且代码会更加整洁 – 自动补全将仅显示真正在使用的相关符号。

此外,具名 import 可以加快代码分析的速度。在解析标识符时,必须对每个通配符 import 进行检查,而 import 表达式又可能依赖于上面的通配符 import。还可能存在来自对象的 import,而这些对象自身又依赖于其他位置的 import。所有这些都不局限于当前正在高亮显示的文件。即使您的代码只依赖其他文件中的签名,由于类型注解中的路径不是绝对的,分析过程仍需要处理那些文件中的 import。

通配符 import 对于隐式元素来说尤为棘手。因为隐式元素本身是隐式的,并且可能需要其他隐式元素,因此搜索它们需要消耗大量计算资源。如果隐式元素是通过通配符导入的,那么其使用和导入都是隐式的。这会让任务变得更加复杂 — 分析过程不仅需要找到某个模糊的实体,还必须在模糊的作用域中进行查找。

因此,请优先使用具体 import,而非通配符 import。将现有的通配符 import 转换为具名 import。在 Scala 2 中,考虑按名称导入隐式元素。虽然 Scala 3 中的 given import 有所改进,但它们本质上仍是通配符 import,因此依赖于良好的库设计。为了保险起见,请优先使用按类型 import,而非普通的 given import。(如果您在设计库,请在单独的软件包或对象中定义隐式元素。)

优先使用 import,而非 mixin

可以使用继承代替 import。我们在 Java 中就能看到:每个 TestCase 同时也是 Assert,因此,您可以直接访问 assertEquals 等方法,无需将其导入。这看起来很方便。不过,这本质上是一种强制通配符 import,具有所有常见的缺点。最好选择性地使用 import Assert.assertEquals(也可以选择 import Assert.*)。

此外,使用子类化或混入 trait 的方式比普通通配符 import 更慢。分析必须考虑继承和线性化,以及重载和重写。而且,如果您修改 trait,还必须重新编译使用它的类。

如果某些定义本质上是静态的,请将它们放在 object 中,而非 trait 中,这样,客户端便会导入这些定义,而不是继承它们。

将类和方法声明为 private

最小化类和方法的可访问性有很多充分的理由:区分 API 与实现、维护源码与二进制兼容性、避免自动补全出现杂乱内容,以及降低认知负担。

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

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

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

每个非局部定义要么应当为 private,要么应当带有类型注解。客户端可访问的定义构成了 API。API 是抽象的边界,因此必须为显式;客户端无需研究右侧的实现即可理解左侧的签名。与实现不同,API 必须是稳定的,且不得依赖右侧的内容。类型注解使 API 既显式又稳定。

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

因此,最好始终明确指定非 private 成员的类型。请注意,即使存在重写,也应该指定类型,因为推断类型可能更具体,至少在 Scala 2 中是这样。(例如,如果超类方法返回 Seq[Int],而子类方法只是 = List(1),后者的类型会是 List[Int],这可能影响直接使用子类的客户端。) 您还应当指定 protected 成员的类型,而不仅仅是 public 成员的类型 — 子类也是客户端。(例外情况:当右侧是字面量等简单且稳定的类型时,您可以省略类型。尽管如此,明确写出类型通常对人和编译器都更有益。)

此外,显式类型甚至对私有和局部定义也有好处。虽然增量编译器会重新编译整个文件,但 IDE 可以更渐进地、在更小的作用域内使缓存失效。因此,如果 private 成员很复杂,请为其添加类型注解 — 这样可以提高代码编辑的效率。另外,请为复杂的局部变量指定类型。(有时,您可能需要先提取方法或引入变量才能指定类型。)

Scala 插件中的 Code Style | Type Annotation(代码样式 | 类型注解)要求为 public 和 protected 成员添加类型注解 — 它们会通过重构和代码生成自动添加,并通过相应的检查进行查验。不过,简单表达式有例外情况,且无论复杂度如何,私有或局部定义都不需要添加类型注解。为了保险起见,您可以将这些设置调整得更严格。

优先使用标准语言功能,而非宏

宏背后的概念看似极具吸引力 — 在编译时而非运行时进行计算。但“编译时”也正是“高亮显示时”,无论您在编辑代码时使用的是编译器还是 IDE,这一点都成立… 除非您总是可以一次性写完所有代码,完全不借助任何辅助功能。因此,宏可能会干扰代码编写和编辑,拖慢反馈速度,并消耗更多资源。请注意,这一点不仅适用于定义宏(需要功能标志),也适用于使用宏(不需要功能标志)。

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

这一点可以推广为:不要只是为了“可以使用”而去使用复杂的语言功能 ,应当只在真正需要时才使用;优先选择能解决问题的最简单的解决方案。有关此主题的更多详细信息,请参阅 Martin Odersky 的 Lean Scala

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

即使您使用 AI 生成全部代码,您依然会阅读这些代码。(对吧?) 因此,生成对高亮显示友好的代码依然同样重要 — 代码虽然是在数据中心生成的,但却是在您的机器上进行高亮显示。这也能改善增量编译,从而降低使用智能体时的系统负载。此外,这还能防止上下文填充(即模型加载不相关信息),从而提高准确性并降低成本。

您可以做的第一件事是通过示例引导 AI,因为模型倾向于沿用已有的约定和代码样式。在新项目中,您可以AGENTS.md 明确添加建议。最后但同样重要的一点是,无论代码是由人工编写还是由 AI 生成,您都可以随时重构代码

总结

话虽如此,您 IDE的性能很重要。我们一直在持续提升 IntelliJ IDEA 和 Scala 插件的性能,并提供一些性能优化技巧供您在实践中应用。但就像再多的编译器优化也无法修正那个朴素递归的例子一样,高亮显示有时也需要您提供一些帮助。

与所有事情一样,高亮显示复杂度并非唯一的考量因素;您需要多方平衡。但通常情况下,它们之间并不矛盾:整洁的代码能改善高亮显示复杂度,而改善高亮显示复杂度又会使代码更加整洁。无论如何,始终考虑高亮显示复杂度并备好这些方式总是有益的。

如需了解详情,请参见 YouTrack 中的相应工单。其中还列出了一些功能,您可以借助这些功能更轻松地应用重构。如果您认为它们有用,请为这些工单投票,以便我们了解大家的需求。

如果您有任何疑问,欢迎在 Discord 上向我们提问。

祝您开发愉快!

JetBrains 的 Scala 团队

本博文英文原作者:

Pavel Fatin

Pavel Fatin