我最初从 Java 切到 Scala 时,第一反应是:变量声明居然有两种关键字,val 和 var,好像挺简单。但真正写了两个月后才发现,Scala 变量这套东西远不是“val 不可变、var 可变”一句话能讲完的。它牵扯到类型推断、JVM 字段生成、集合不可变性、Java 互操作、JSON 序列化,甚至连你怎么安装 Scala 工具链,都可能因为环境变量和依赖缓存不对,让你在“变量”还没写之前就先被环境折腾一遍。
这篇文章就围绕 Scala 变量这个主题,把它背后那几层容易忽略的细节都摊开来讲。适合刚从 Java/Kotlin 转到 Scala 的开发者,也适合那些已经写了一阵 Scala、但碰到过变量初始化、序列化字段名、模式匹配遮蔽问题的朋友。我会把每个点的“为什么”尽量讲透,并用实际可跑的代码示例和踩坑记录,让你读完能直接用到项目里。
1. Scala 变量再入门:val 与 var 并不是“常量与变量”
1.1 为什么 Scala 要把变量拆成两种关键字
先看一段最基础的代码:
scala复制val name = "tom"
var age = 20
name = "jerry" // 编译报错:Reassignment to val
age = 21 // 编译通过
从语义上讲,val 绑定的是一个“不可重新赋值的引用”,var 绑定的是“可重新赋值的引用”。但注意,我用的是“引用”这个词,而不是“值”。这一点非常关键,因为很多 Java 背景的人会把 val 理解为 final,又把 final 简单等同于“值不能变”,于是遇到下面这种情况就懵了:
scala复制val list = scala.collection.mutable.ListBuffer(1, 2, 3)
list.append(4) // 编译通过,list 指向的对象内容变了
println(list) // ListBuffer(1, 2, 3, 4)
这里的 val list 只是保证 list 这个变量不能再指向另一个 ListBuffer 对象,但并不保证它指向的那个 ListBuffer 内部不被修改。换句话说,val 管的是“变量指向谁”,var 管的是“这个指向关系能不能改”,而对象内部的状态是否可变,取决于对象类型本身。
这点 Java 开发者一定不陌生,Java 里的 final List<Integer> list = new ArrayList<>(); 同样可以继续往里 add。Scala 只是把这个语义从 Java 的 final 继承下来,并且让它成为默认推荐项。Scala 官方风格几乎一直在引导你优先使用 val,因为引用不可变意味着代码的局部推理成本会低很多:你看到一个 val,就不需要担心它在后续某个分支里被悄悄换掉,这对阅读和调试都有极大帮助。
1.2 类型推断不是动态类型
Scala 变量声明里另一个让新人迷惑的点是,你好像可以不写类型:
scala复制val count = 10
val price = 3.5
val message = "hello"
很多刚从 Python 过来的人会误以为 Scala 是动态语言。其实不是,Scala 的每个变量在编译期都有确切类型,只是编译器通过右侧表达式自动推导出来了。你完全可以显式声明:
scala复制val count: Int = 10
val price: Double = 3.5
val message: String = "hello"
类型推断的好处是减少重复,坏处是当推断出来的类型不是你想要的类型时,可能会出现“编译通过但运行时行为诡异”的情况。我碰到过最典型的一个例子是数字类型推断:
scala复制val num = 1 + 2.5
这段代码推断出的结果是 Double,因为 Int 和 Double 做运算时,Int 会先被提升为 Double。如果你以为 num 是 Int,后面拿去当整数用,编译器会在某些方法调用时直接报错,反而能提醒你。真正需要小心的是除法:
scala复制val result = 5 / 2
println(result) // 2
如果你从 Python 3 转过来,可能期待得到 2.5,但 Scala 的 Int 整除规则来自 Java,结果是 2。如果你写 scala 代码时希望得到小数,需要显式把操作数转成 Double:
scala复制val result = 5.0 / 2
// result: Double = 2.5
这类“类型推断帮忙但不背锅”的例子非常多,所以我的建议是:公共 API、类字段、复杂的泛型场景尽量显式写类型,局部变量可以放心交给推断。这样代码既简洁,又不会在跨文件阅读时让人摸不着头脑。
1.3 先用 val,编译器让你换 var 时你再换
在写 Scala 的过程中,我形成了一个强制习惯:所有变量默认都写成 val,除非编译器或者业务逻辑明确告诉我这个引用需要被重新赋值,才改成 var。这个习惯带来的收益非常明显。以前写 Java 时,一个方法里的变量我随时可能重新赋值,这样很容易在长方法里把某个变量的含义改乱;而 Scala 的 val 让变量更像“给表达式结果起个名字”,每次声明都对应一个明确的、不再变化的值,代码的可读性会高一个层级。
当然,这不意味着 var 是禁区。写循环累加、状态机切换、或者和 Java 库交互做缓存等场景,var 依然不可避免。但当你发现自己经常写 var 的时候,可以停下来想一想:能不能把这段逻辑拆成纯函数,用函数返回值替代状态修改?在函数式编程风格里,“变量”更多是作为局部中间结果的标签,而不是长期可变的状态载体。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 变量声明与初始化:从底层看 Scala 变量的生命周期
2.1 类字段和局部变量初始化顺序的差异
Scala 中变量声明的位置不同,初始化时机也不同。先看局部变量,它和 Java 一样,需要先赋值再使用,否则编译器会直接报错,不存在默认值这一说。但类字段不一样,类字段 val 或 var 会跟着类的初始化流程走。
这里有一个 Scala 特有的坑:字段初始化顺序依赖类体里代码写得靠前还是靠后。比如下面这段代码:
scala复制class User {
val name: String = buildName()
val age: Int = 30
def buildName(): String = {
if (age > 20) "adult" else "minor"
}
}
看起来逻辑没问题,name 调用 buildName,buildName 读取 age,age 在后面定义了 30。你可以猜一下运行结果是什么。很遗憾,这里大概率会抛 NullPointerException 或返回空值,因为 Scala 类体中的初始化语句是按源码顺序执行的,当初始化到 name 时,age 字段还没有被赋值,它的值是 JVM 给引用类型字段默认的 null,然后 buildName 里做 boxing 或比较时就会出问题。
要解决这个问题,最简单的办法是把 age 移到 name 前面,或者把 age 定义成 lazy:
scala复制class User {
val age: Int = 30
val name: String = buildName()
def buildName(): String = {
if (age > 20) "adult" else "minor"
}
}
这个例子告诉我们一件事:在 Scala 类体里,不要把字段之间的依赖写得过于隐晦,尤其是不要依赖后面字段的赋值。如果你确实需要交叉引用,lazy val 会是一个更稳妥的选择。
2.2 lazy val:真正意义上的“用到才初始化”
lazy val 是 Scala 变量体系里一个非常有特色的存在。它既不是普通的 val,也不是 var,而是“惰性求值的 val”。先看例子:
scala复制class HeavyConfig {
lazy val config: String = {
println("loading config...")
"config-content"
}
}
当你创建 HeavyConfig 对象时,println 不会执行,config 字段还没被真正初始化。直到代码第一次访问 config 时,才会执行初始化的表达式,并且只执行一次,后续再访问直接取缓存结果。
这个特性有几个常见使用场景。第一,字段初始化成本高,但并非每次都会用到;第二,多个字段存在循环依赖,用 lazy val 可以打破初始化顺序限制;第三,继承体系中,子类的字段可能要被父类方法用到,父类方法又不能保证子类字段已初始化。
但 lazy val 不是银弹,它的实现原理是在字段访问时加锁并做空值判断,也就是线程安全。这个“线程安全”带来两个代价:一是首次访问会有一点同步开销,二是如果初始化过程中出现异常,下次再访问还会重新尝试初始化。某些业务场景中,这可能是好事,也可能是隐患,取决于你是否希望失败后永久保持失败状态。
2.3 变量声明里的下划线:Scala 的“未初始化”占位
Scala 在变量声明中还允许用下划线做“占位初始值”,常见于抽象字段或依赖注入场景。比如:
scala复制class Service {
var connection: Connection = _
}
这里 = _ 的意思是:先把 connection 初始化为该类型的默认值。引用类型默认 null,Int 默认 0,Boolean 默认 false。这个写法比 Java 里直接写空字段更明确,它让你一眼看出“这个字段我不打算在构造时赋值,稍后通过其他方式注入”。
不过在实际项目中,我一般不建议把这种写法当常规手段用,因为它把空指针风险推迟到了运行时。除非你是写框架代码、依赖注入容器、或者确实需要让测试框架先创建对象再注入 mock,否则优先考虑在构造器里把字段全部初始化。构造器参数直接赋值给 val 字段才是 Scala 最干净的做法:
scala复制class Service(connection: Connection) {
def connect(): Unit = connection.open()
}
构造函数参数如果没加 val 或 var,就只是一个普通参数,不作为类字段保存;加了 val,它就自动成为只读字段。这个语法糖让 Scala 变量的定义和使用天然比 Java 简洁很多。
3. JSON 序列化时的大写字段名坑:从 Java Bean 说到 Scala 属性
3.1 Java Bean 的字段名为什么会被“强制小写”
网络热词里有一条非常典型的问题:Java Bean 大写字母开头的变量,JSON 序列化时就变成小写了。这个坑本质不是 Scala 的问题,而是 Java Bean 规范与 JSON 库命名策略碰撞的产物,但 Scala 开发者同样会遇到,因为 Scala 类编译之后底层就是 JVM 的字段和方法。
Java Bean 规范规定,属性是通过 getter/setter 方法名推导出来的。例如你有一个属性 name,对应的 getter 约定是 getName。如果某个字段叫 Name,IDE 自动生成的 getter 通常会是 getName,方法名去掉 get 后剩下 Name,Jackson 在解析时遵循 Java Beans Introspector 的规则,会把这个属性名做首字母小写处理,于是 JSON 输出就成了 name。
这种情况最容易出现在两种地方:一是数据库字段本身是大写开头,比如 UserId,你希望 JSON 输出 UserId;二是从其他语言迁移来的模型,字段大小写风格不统一。此时 Jackson 默认的行为就会和预期不一致。
3.2 Scala case class 如何处理 JSON 字段名
Scala 的 case class 写起来很清爽:
scala复制case class User(userName: String, age: Int)
如果你用 Jackson 的 scala module 序列化,输出通常就是 {"userName":"tom","age":20},因为 case class 字段本身作为构造参数存在,Jackson 主要是按参数名来映射。这里和 Java Bean 的推导规则不同,问题反而少一些。但如果涉及 Java 库或反射库,仍要注意字段名大小写被 Java Beans 规则改变的可能。
一种常见做法是显式指定 JSON 属性名,不要依赖库的默认推导。以 Jackson 为例:
scala复制case class User(
@JsonPropert("user_name") userName: String,
@JsonProperty("age") age: Int
)
这样你完全控制了序列化输出,不管底层变量名怎么变,JSON 字段始终是注解写的名字。对于必须和现有 Java Client 对接的接口,还可以用:
scala复制@BeanProperty
case class User(userName: String)
@BeanProperty 编译时会生成 getUserName 和 setUserName 方法,让 Scala 类在外观上更接近传统 Java Bean。这种做法在混合 Java/Scala 的项目里很实用,毕竟很多 Java 框架只认 getter/setter,不认 case class 构造参数。
3.3 给 Scala 变量的命名建议
从这些序列化问题往回看,变量命名的规范性真的不只是好看,而是直接关系到互操作是否正确。在 Scala 中,我推荐的规范是:
- 普通局部变量用骆驼式小写开头,比如
userCount、encryptedText。 - case class 字段尽量小写开头,避免和 Java Beans 属性解析规则撞车。
- 如果你必须对外提供大写开头的 JSON 字段,用
@JsonProperty显式指定,不要在变量名上硬撑。 - 布尔类型的字段名避免使用
is开头,尤其是作为 Java Bean 属性时。isEnable会生成getEnable或isEnable两种 getter 风格,不同工具解析结果不一致,容易踩坑。
变量命名的核心目标是减少歧义。一个名字不能在 Java 体系里是一种含义,在 Scala 里又是另一种含义,在 JSON 里又变了样。把命名规则定好,用注解固定边界,能省掉大量联调时“字段对不上”的烦恼。
4. Scala 环境安装中的变量配置:Coursier 下载慢的真正突破口
4.1 安装 Scala 时,问题往往出在依赖下载和缓存
在谈变量之前,你得先有一个能跑 Scala 的环境。很多初学者按官网提示用 coursier 安装,结果卡在下载阶段,界面看起来半天没动。于是第一反应是“网络不行”。其实 Coursier 安装慢的原因大多数时候不是网络带宽,而是它默认要从 Maven Central 拉取大量元数据和 jar 包,依赖链一长,几十 MB 的包要反复解析版本、校验校验和,整体耗时就被放大了。
Coursier 是 Scala 官方推荐的安装和依赖管理工具,它本质上和 Maven/Gradle 类似,负责解析依赖并把 jar 缓存到本地。默认情况下,它在用户目录下生成一个缓存目录,Linux/macOS 通常是 ~/.cache/coursier,Windows 是 %LOCALAPPDATA%\Coursier。这些缓存的变量路径,决定了后续 sbt 或 scala-cli 启动时是否需要重新下载。
4.2 手动加速的几个实用设置
如果你用 coursier 安装 Scala,发现依赖解析特别慢,建议按顺序检查这几项。
首先是确认版本。不要盲目装最新版,先看项目使用的 Scala 版本。比如项目是 Scala 2.13,就装对应版本的 scala 编译器;装个 3.x 版本回来,后面跑老项目还得再装一次,时间全耗在重复下载上。
其次是使用国内 Maven 镜像仓库配置。Coursier 允许你在全局配置里增加 repository 地址,你也可以直接通过环境变量指定:
bash复制export COURSIER_REPOSITORIES="central|https://maven.aliyun.com/repository/public"
这里把 central 写在前面,再把国内镜像放后面,依赖解析会先走本地缓存和自定义镜像,速度通常比默认源快不少。实际执行时,你需要确认已经安装 coursier,可以用:
bash复制cs version
如果还没安装,可以按官方文档下载 cs 命令。这里有一个经验:不要反复 Ctrl+C 后重试下载,因为中断会导致缓存文件不完整,下次还得重新解析。最好设置一个较长的超时时间,让依赖整批拉完。
第三是调整 JVM 内存相关的环境变量。Coursier 本身需要 JVM 运行,如果你机器的 JAVA_OPTS 设置过小,依赖解析时频繁 GC,会让整个安装看起来像“卡死”。可以临时给足内存:
bash复制export JAVA_OPTS="-Xms512m -Xmx2g"
设置完再跑安装命令,速度会有体感上的提升。
4.3 自定义缓存目录对变量和环境的影响
Coursier 的缓存目录会影响后续 sbt 的启动。如果你把系统 temp 目录清理了,已经下载的依赖没了,再次启动 sbt 时会重新拉取。我建议把 Coursier 缓存设置到一个不会被系统自动清理的固定目录。
在 Linux/macOS 下,你可以通过设置 COURSIER_CACHE 这个环境变量来指定:
bash复制export COURSIER_CACHE=/data/coursier-cache
Windows 下可以在系统属性里添加同名的用户环境变量。这样做的另一个好处是,当你需要重新安装系统或切换用户时,依赖缓存不会丢,新环境起来只需要做一次 cs setup,剩下的依赖都在本地读取,安装速度会快非常多。
另外,sbt 自身也用 Coursier 作为默认依赖解析器,如果你之前遇到过 sbt 下载慢,检查 ~/.sbt/repositories 文件内容是否为空,或者里面是否只配置了官方源。合理配置本地仓库和镜像源之后,新项目第一次编译也能省下大量时间。
5. 从内存角度看 Scala 的“可变变量”与“可变集合”
5.1 val 搭配可变集合到底算不算“变量变化”
回到 Scala 变量本身,很多人讨论 val/var 时都会涉及一个经典问题:我声明了一个 val 的 ListBuffer,然后又向里面添加元素,这符合函数式风格吗?
scala复制val buffer = scala.collection.mutable.ListBuffer(1, 2, 3)
buffer += 4
从这个语义看,对象的内容确实变了,但这个变化发生在 ListBuffer 内部,而不是发生在 buffer 这个变量上。用更面向对象的说法,是对象自己修改了自己的状态,不是外部把 buffer 指向了另一个对象。
这类代码在 Scala 标准库里其实不少,比如 scala.collection.mutable.Map,经常被用来做累加计数。我们不必看到所有集合都是 immutable 才觉得“纯函数”,关键在于控制可变状态的边界。也就是说,你可以把一个可变集合封装在一个类内部,对外只暴露只读方法。外部代码拿到的 val 引用无法被篡改,内部逻辑则利用可变集合的方便性完成状态管理,这在性能敏感模块里是一种非常实用的折中。
反过来,如果你用 var 保存不可变集合,每次操作都要重新赋值:
scala复制var acc = Vector.empty[Int]
for (i <- 1 to 10) {
acc = acc :+ i
}
这种写法谈不上错,但每次 :+ 都生成一个新 Vector,如果循环次数少没问题,次数多时性能和内存压力会明显上升。可变性不是罪恶,滥用不可变也可能产生性能问题。关键是在同一份代码里,要让读者清楚哪些变量是“引用可变”,哪些集合是“内容可变”,不要在语义上打哑谜。
5.2 值类型与引用类型的变量赋值区别
Scala 变量赋值的另一个底层细节是值类型和引用类型的区别。Java 里 int、long 这些基础类型直接存值,而对象变量存的是引用;Scala 中看起来一切皆对象,比如 Int 也是包装类型,但在 JVM 字节码层面,编译器通常会把它映射成 Java 基础类型 int,变量之间赋值是值复制。
这个差异在写通用工具类时会有感。比如你有两个变量:
scala复制var a = 1
var b = a
b = 2
println(a) // 1
因为 Int 是值语义,b 改成 2 不影响 a。但如果是引用类型:
scala复制case class Person(name: String)
var p1 = Person("tom")
var p2 = p1
p2 = Person("jerry")
println(p1.name) // tom,因为 p2 重新指向了另一个对象
这里 p2 的重新赋值同样不会影响 p1。真正会互相影响的是没有重新赋值、而是修改对象内部字段的情况:
scala复制val arr1 = Array(1, 2, 3)
val arr2 = arr1
arr2(0) = 99
println(arr1(0)) // 99
数组在 Scala 里是引用类型,arr1 和 arr2 指向同一个底层数组,修改 arr2 的元素等于修改 arr1 看到的数组。如果这是你不想看到的结果,那就需要拷贝:
scala复制val arr3 = arr1.clone()
理解“变量保存的是值还是引用”,有助于判断一段代码修改后到底会影响谁,这也是定位线上诡异 bug 的必备基本功。
5.3 匿名函数捕获变量时的闭包陷阱
Scala 变量还能被匿名函数捕获,形成闭包。看这个例子:
scala复制var counter = 0
val increment = () => {
counter += 1
counter
}
这里 increment 捕获了变量 counter。如果后续 counter 被外部重新赋值,闭包内部看到的也是最新值,这和 Java lambda 要求捕获变量必须 effectively final 不一样。Scala 允许捕获 var,运行时的实现方式是把 var 包装到一个对象里,闭包持有这个对象的引用,从而实现对变量的“共享修改”。
这个特性在并发场景要格外小心。多个线程共享同一个 var,可能出现竞态条件。如果有多个闭包同时捕获并修改同一个变量,那最好直接用 java.util.concurrent.atomic.AtomicInteger 或者把状态托管给 Actor 模型。
闭包捕获变量也会导致内存泄漏风险。如果闭包对象存活时间很长,而被捕获的变量又指向一个很大的对象,GC 就无法回收这个对象。排查这类问题,关键是要明确哪些变量进入了闭包,哪些闭包被缓存到了长生命周期容器里。Scala 写起来很爽,但脚手架背后的变量生命周期问题,和不写清楚闭包捕获范围有直接关系。
5.4 可变变量与线程安全的个人建议
如果要在多线程项目里使用 var 和 mutable 集合,我的建议是:能局部化就局部化,变量不要跨线程共享;必须跨线程共享时,优先选择不可变对象加消息传递,其次才考虑加锁或原子类型。这样会让变量从源头减少“被多线程同时改”的可能。
很多写 Java 并发代码的人习惯用一个可变的 HashMap 再外面套 ConcurrentHashMap;Scala 里你也可以这么做,但更函数式的替代方案是用不可变 Map,每次更新生成了新版本,让版本对象去线程间传递。由于旧版本不可变,其他线程即使持有着,也不会看到数据被篡改,从而省掉大量锁竞争。
这个思路落到具体变量上,就是尽量少用全局 var,不把状态暴露成公共对象。一旦一个变量离开方法范围,就要问自己:谁还能改它?改的方式和时机是什么?如果这些问题很难回答,那就说明代码结构需要优化了。
6. 模式匹配中的变量遮蔽:一个容易忽视的 Scala 变量作用域问题
6.1 case 分支里的绑定变量
Scala 的模式匹配允许你在 case 分支中定义一些局部“变量”来绑定被匹配的值,例如:
scala复制user match {
case User(name, age) => println(s"name=$name, age=$age")
case _ => println("unknown")
}
这里的 name 和 age 看起来是变量,但它们的生命周期只在当前 case 分支内,而且不能被重新赋值,本质上是编译器生成的 val 绑定。这个特性容易让新手产生困惑在于:case 后面的变量名不是“重新声明一个新变量”那么简单,它是在做解构和绑定;如果名字取得和外部变量一样,就会发生变量遮蔽。
6.2 变量遮蔽的典型场景和应对方法
变量遮蔽指内部作用域里定义了一个和外部同名的新变量,在内部作用域中,后续代码访问这个名字时,访问到的是内部变量,外部变量被“遮蔽”了。看一个实际例子:
scala复制val default = "unknown"
user match {
case User(name, default) => println(default) // 这里打印的是匹配到的 age,不是外部 default
case _ => println(default)
}
如果同名变量进入 case 分支,你在分支里 println(default),读到的并不是外层那个常量,而是当前年龄字段。一旦有人误读,排查起来相当痛苦。所以我的建议是:case 分支绑定变量时,尽量使用专属的名字,不要在作用域上和外部变量重复;如果 Scala 3 中你希望显式控制绑定语义,也要使用反引号或类型标注把意图写清楚。
6.3 通配符下划线与变量占位
模式匹配中常常见到下划线:
scala复制case _ => ...
它表示“不绑定任何变量”。有时候我们需要“绑定但忽略一部分结构”,又不想为不需要的值起名字,比如:
scala复制user match {
case User(name, _) => println(name)
}
这里的下划线起到了“未使用变量占位”的作用。相比写一个 age 然后不读它,下划线能明确告诉读者:这个位置有值,但我有意忽略。这也是一种变量命名上的纪律:不用写一个无意义的变量名来污染周边作用域。
7. Scala 变量使用的实践心得与避坑清单
7.1 我在代码评审中最常提的三条变量建议
在团队做了挺久 Scala 代码评审之后,我发现变量相关的问题非常集中。第一个问题是局部变量大量使用 var,但循环体内完全可以用 map/filter/foreach 替代。不是不让用 var,而是写法上要先考虑集合操作;确实需要命令式循环时,再退到 var,这样代码的核心逻辑会被函数式表达式清晰地表达出来,var 只出现在局部累加等最小区域。
第二个问题是不给非局部变量加上显式类型。类字段、公共方法返回值这些跨作用域存在的信息,一旦全部依赖类型推断,后来者阅读时还得反复回看你的初始化逻辑。显式写类型不是啰嗦,而是文档。比如 private val orderService: OrderService = ...,从类型名就能看出它负责什么,比编译器的隐形推断更有利于阅读。
第三个问题是变量命名过于宽泛。data、list、map、result 这类名字出现在长方法里的频率极高。本地临时变量就算叫 data,危害还是可控;可一旦它被闭包捕获,逻辑流转几层之后,data 到底指哪份数据就变得模糊。更好的做法是在变量名里带上业务语义,比如 orderList、userByIdMap、validateResult。
7.2 从 Java 迁移到 Scala 后最值得改掉的变量习惯
从 Java 迁移到 Scala,大多数人最难改掉的习惯是:定义一个字段前,先想它未来要不要变,然后顺手就加 var。如果你正在从 Java 切到 Scala,我建议你反过来:先一律声明为 val,写不下去时再改 var。这不是道德说教,而是一个相当实用的观察——Java 里类的字段经常是 setter 注入、状态可以被任意修改;Scala 里如果你把成员都设计成 val,类的线程安全性会天然提升很大一截。并且,val 字段在模式匹配、hashCode、equals、case class 解析这些场景下都有更好的语义契合。
我也遇到过很多“用 val 做常量但是被反射改掉”的场景,比如某些序列化框架要求空构造加 setter 注入,case class 不满足它的要求时就会抛异常。此时抱怨 val 不好并没用,正确做法是给互操作边界加专门的 DTO,不要为了框架需求把领域对象的 val 全部改成 var。维护一条“对外可变、对内不可变”的边界,比让整个模型完全暴露给框架要安全得多。
7.3 一个小习惯:变量“能局部就不要全局,能 val 就不要 var,能不可变就不要可变”
写了这些年代码,最后沉淀下来的也就是这三句话。Scala 变量虽然是入门级话题,但它在真实项目里牵涉的坑并不比高级抽象少。无论是安装环境时一堆变量目录配置、JSON 序列化时大小写被改、还是闭包捕获 var 带来的多线程隐患,最终都要回到同一个原则:让变量的作用域尽可能小,让变量的变化点尽可能明确,让变量的命名尽可能经得起其他人阅读。
你如果正在学 Scala,不妨从今天开始,把所有新写的变量都按照这个原则审视一遍。习惯成自然以后,你会发现代码的 bug 数量会明显下降,review 也会轻松很多。
