如何编写对高亮显示友好的代码?
- 响应能力更好
- CPU 使用率得到优化
- 内存使用更高效
- 系统温度更低
- 运行更安静
- 电池续航时间更长
考虑高亮显示复杂度
假设您编写了这个用朴素递归计算斐波那契数的函数: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 中的相应工单 。其中还列出了一些功能,您可以借助这些功能更轻松地应用重构。如果您认为它们有用,请为这些工单投票,以便我们了解大家的需求。 祝您开发愉快! JetBrains 的 Scala 团队“非商用免费” IDE 家族 WebStorm // Rider RustRover // CLion RubyMine // DataGrip
申请免费学生许可证 👉 戳这里看保姆级申请攻略 👈
⏬ 戳「阅读原文」了解更多!