概述
loom-scala 提供面向 JVM 的惯用法、生产级 Scala 3 知识,假定读者已经会写 Scala。内容聚焦实际开发中容易出问题的地方:import 静默漏掉的 given、IO.pure 提前且只执行一次的副作用、在计算池上的阻塞调用、只给出警告的非穷尽匹配,以及什么都没运行却以 0 退出的 sbt testOnly。
工具链与构建
默认构建工具为 sbt。建议保持一个 sbt shell(或 sbt --client)常开以支持迭代,因为每次批量调用都会先启动 JVM 并加载构建。
常用命令:
- 完整检查:
sbt scalafmtCheckAll scalafmtSbtCheck "scalafixAll --check" test - 编译主代码与测试:
sbt Test/compile - 全部测试:
sbt test - 单个套件:
sbt 'testOnly com.acme.FooSuite' - 按子串选单个 ScalaTest 测试:
sbt 'testOnly com.acme.FooSpec -- -z "rejects symlinks"' - 仅失败与变更测试:
sbt testQuick(~testQuick保存即重跑) - 格式化:
sbt scalafmtAll scalafmtSbt - 依赖树:
sbt dependencyTree(需先在 project/plugins.sbt 中 addDependencyTreePlugin)
示例 build.sbt 使用 Scala 3.3.4(3.3.x 为 LTS),启用 semanticdbEnabled(scalafix 语义规则需要),依赖 cats-effect、munit、munit-cats-effect,并设置 Test / fork := true 让测试在独立 JVM 中运行。根项目通过 .aggregate(core) 聚合子项目。
%% 会追加 Scala 二进制版本,% 用于 Java 库。Scala 3 可通过 .cross(CrossVersion.for3Use2_13) 使用 Scala 2.13 构件,但带 Scala 2 宏的除外。sbt-tpolecat 会按版本设置严格编译标志(含 -Werror);scalafmt 读取 .scalafmt.conf,需设置 version 与 runner.dialect = scala3。Scala CLI(3.5 起的 scala 命令)和 Mill 是另外的构建工具,均不使用 build.sbt。
Scala 3 语法
- 用 enum 与 sealed trait 定义代数数据类型,配合模式匹配;意义缩进取代花括号,then/do 跟随条件,end 标记结束长块。
- 顶层定义取代 package object;
export转发成员以组合代替手写委托。 - 支持联合类型(
String | Int)与交集类型(A & B);derives请求类型类派生。 opaque type提供无运行时开销的独立类型;AnyVal 值类在集合与泛型中会装箱,opaque 类型不会超出其底层类型装箱。- 从 2.13 迁移可用
-source:3.0-migration -rewrite修正语法。
ADT 与模式匹配
sealed trait 和 enum 让编译器检查穷尽性,但检查只警告,因此要开启 -Werror。编译器看不穿守卫条件,需为每个变体保留无守卫分支。类型模式会被擦除(case xs: List[String] 只给 unchecked 警告)。无匹配分支会在运行时抛 MatchError;collect 显式表达偏函数。自定义提取器可让 case Email(user, domain) 作用于普通字符串。Scala 3 弃用 lambda 内的非局部返回,改用 scala.util.boundary 的 boundary/break。
Given、Using 与扩展方法
类型类实例应放在类型类或数据类型的伴生对象中(隐式作用域,无需导入);无关对象中的孤儿实例需显式导入且易引发歧义。import a.* 不会导入 given,需用 import a.given 或 import a.{*, given}。同一类型的两个可用 given 会触发歧义错误,可通过更具体或把后备实例移入低优先级父 trait 解决。using 参数承载上下文(ExecutionContext、事务、配置),普通依赖应改用构造参数。隐式转换需要语言导入,扩展方法可覆盖其合理场景。深类型类树的自动派生编译慢,逐类型半自动 derives 可保持编译时间平稳。
错误处理
用 Either[E, A] 加错误 ADT 表达预期失败,Option 表示缺失,Try 仅用于捕获 Java 或遗留 API 的异常并在边界转为 Either。缺陷(bug)应抛出;在效应类型内部,失败通过效应传递(IO.raiseError、ZIO 的错误通道)。捕获时用 case NonFatal(e) =>。scala.util.Using.resource 和 Using.Manager 在所有路径上关闭 AutoCloseable 资源。
效应系统:Cats Effect 与 ZIO
每个代码库只选一种效应系统。Future 是急切、记忆化且不可取消的,适合简单异步粘合,其中阻塞代码应放入 blocking {}。
Cats Effect 要点:IO 值描述计算,运行时执行前什么都不发生;IO.pure(expr) 在构造时求值一次;副作用放入 IO(...)/IO.delay;阻塞调用用 IO.blocking 或 IO.interruptible;有释放步骤的资源用 Resource;共享状态用 Ref、一次性信号用 Deferred、纤程间交接用 Queue;unsafeRunSync() 只应在程序边界使用。
ZIO 要点:ZIO[R, E, A] 携带环境、类型化错误与结果;预期失败放入 E,缺陷(die)用于 bug;ZIO.succeed 按名传参故延迟执行,可能抛异常的代码用 ZIO.attempt,阻塞代码用 ZIO.attemptBlocking;ZLayer 装配依赖,ZIO.acquireRelease 配合 ZIO.scoped 管理资源,withParallelism(n) 限制并发扇出。
集合
默认不可变:List 头部插入 O(1),索引与 :+ 为 O(n);Vector 索引与追加接近常数。局部构建用 ListBuffer/ArrayBuffer,返回不可变结果。.view 使链式惰性,Iterator 单次遍历,LazyList 会记忆化。Map.mapValues 与 filterKeys 在 2.13/3 返回每次访问重算的惰性视图(已弃用),应写 .view.mapValues(f).toMap。toMap 遇重复键静默保留最后值;Array 相等是引用相等,应使用 sameElements 或 ArraySeq。深 foldRight 可能导致栈溢出。
测试
MUnit(munit.FunSuite)提供 test、带差异输出的 assertEquals、intercept[E]、FunFixture 每次测试的初始化与清理;munit-cats-effect 的 CatsEffectSuite 可运行 IO 测试。ScalaTest 提供多种风格与 Matchers、AsyncFunSuite。ZIO Test 用 ZIOSpecDefault 与 assertTrue,TestClock 控制时间。属性测试可用 ScalaCheck 的 forAll。不 fork 时 sbt 在自己的 JVM 内并行运行套件;Test / fork := true 时在单个独立 JVM 中依次运行(除非 testGrouping 拆分)。共享文件、端口或全局状态的套件需 Test / parallelExecution := false 或隔离。
Loom 测试运行适配器
Scala 使用 sbt:含 build.sbt 的目录类型为 scala,其运行器始终是 sbt;loom project detect 会逐包打印。Mill 与 Scala CLI 项目没有 build.sbt,因此检测不到。单测试命令为 sbt 'testOnly {test}',以存放 build.sbt 的包目录为工作目录;test 字段为完全限定套件名(ZIO Test 为源码中的对象名),该命令选择整个套件,不传单测过滤。名称无匹配时 sbt 打印 “No tests to run for Test / testOnly” 并退出 0,loom 读取运行器摘要而非仅看退出码,未选中任何测试会被归类为 NotSelected 并判定失败。
编写契约测试:测试源码放在拥有该代码的子项目的 src/test/scala/ 下;每个契约一个只含该契约测试的套件并以契约命名;包声明与目录路径保持一致;根项目需 .aggregate(子项目),否则根上的 testOnly 到不了未聚合的子项目;无显式根项目时会得到聚合全部子项目的默认根。契约套件在冻结时若尚不能编译,会被归类为 BuildFailed,因为 sbt 会整体编译子项目的 Test 配置。