探讨 Scala 为何在 JVM 上将函数式与面向对象思想结合、它做对了什么,以及团队应注意的权衡取舍。

Java 让 JVM 成功,但也带来了许多团队最终会遇到的约定:大量样板代码、对可变状态的偏重,以及常常需要框架或代码生成才能保持可控的模式。开发者喜欢 JVM 的速度、工具和部署方式——但他们也希望有种能更直接表达想法的语言。
到 2000 年代早期,日常的 JVM 工作涉及冗长的类层次结构、getter/setter 仪式以及可能滑入生产环境的 null 相关错误。编写并发程序是可能的,但共享的可变状态让细微的竞态条件容易产生。即使团队遵循良好的面向对象设计,日常代码仍然承载很多偶然的复杂性。
Scala 的赌注是:通过一门更好的语言可以减少这种摩擦,而不必放弃 JVM:通过编译成字节码保持“足够好的”性能,同时给开发者提供能更清晰建模领域并更容易变更系统的特性。
大多数 JVM 团队并不是在“纯函数式”与“纯面向对象”之间二选一——他们是在截止日期前交付软件。Scala 的目标是让你在合适的场景下使用 OO(封装、模块化 API、服务边界),而在这些模块内部依靠函数式思想(不可变性、表达式导向、可组合的变换)来使程序更安全、更易推理。
这种融合反映了真实系统的构建方式:在模块和服务周围使用面向对象边界,在这些边界内部用函数式技术来减少错误并简化测试。
Scala 的出发点是提供更强的静态类型、更好的组合与重用,以及在语言层面减少样板的工具——同时保持与 JVM 库和运行方式的兼容。
Martin Odersky 在研究 Java 的泛型时设计了 Scala,并受到 ML、Haskell、Smalltalk 等语言的启发。围绕 Scala 形成的社区——学术界、企业 JVM 团队,后来还有数据工程领域——帮助把它塑造成一门尝试在理论与生产需求间取得平衡的语言。
Scala 认真对待“一切皆对象”这句话。你在其他 JVM 语言中可能认为是“原始值”的东西——像 1、true 或 'a'——在 Scala 中像普通对象一样具有方法。这意味着你可以写出 1.toString 或 'a'.isLetter 之类的代码,而无需在“原始操作”和“对象操作”之间切换思维模式。
如果你习惯了 Java 风格的建模,Scala 的面向对象表层会立即让人觉得熟悉:你定义 class,创建 实例,调用 方法,并用像接口一样的类型来组织行为。
你可以以直观的方式建模领域:
class User(val name: String) {
def greet(): String = s"Hi, $name"
}
val u = new User("Sam")
println(u.greet())
这种熟悉感在 JVM 上很重要:团队可以采用 Scala,而不必放弃“对象与方法”的基本思路。
Scala 的对象模型比 Java 更统一、更灵活:
object Config { ... }),常常替代 Java 中的 static 模式。val/var 直接成为字段,减少样板代码。继承仍然存在并且常用,但通常更轻量:
class Admin(name: String) extends User(name) {
override def greet(): String = s"Welcome, $name"
}
在日常工作中,这意味着 Scala 支持人们依赖的相同 OO 基石——类、封装、重写——同时平滑了一些 JVM 时代的不便(例如大量 static 使用和冗长的 getter/setter)。
Scala 的函数式一面并不是一个独立的“模式”——它体现在语言引导你常用的默认做法。两个核心思想驱动着大部分实践:优先 不可变 数据,并把代码当作产生值的 表达式。
在 Scala 中,用 val 声明值,用 var 声明变量。两者都存在,但文化上的默认是 val。
当你使用 val 时,你是在说:“这个引用不会被重新赋值。”这个小小的选择减少了程序中的隐式状态量。更少的状态意味着当代码增长时更少的惊喜,尤其是在多个步骤的数据变换流程中,值不断被转换。
var 仍然有其位置——UI 粘合、计数器或性能关键的部分——但使用它应该是有意为之,而不是习惯性操作。
Scala 鼓励把代码写成返回结果的表达式,而不是主要通过改变状态的语句序列。
这通常表现为从更小的结果构建一个最终结果:
val discounted =
if (isVip) price * 0.9
else price
这里 if 是一个表达式,因此返回一个值。这种风格更容易理解“这个值是什么”,而不必追踪一连串赋值的来龙去脉。
替代修改集合的循环,Scala 代码通常对数据进行变换:
val emails = users
.filter(_.isActive)
.map(_.email)
filter 和 map 是高阶函数:它们接受其他函数作为输入。其好处并非纯理论上的——它带来清晰度:你可以把这个流水线读作一个小故事:保留活跃用户,然后提取邮箱地址。
纯函数仅依赖输入且没有副作用(不进行隐藏写操作、不做 I/O)。当更多代码是纯的时,测试变得直接:给定输入,断言输出。推理也更简单,因为你不需要猜测系统其他地方还会改变什么。
Scala 对“如何在不构建巨大家族树的情况下共享行为”的回答是 trait。trait 有点像接口,但它也可以包含真实实现——方法、字段和小的辅助逻辑。
Traits 让你描述一种能力(“能记录日志”、“能验证”、“能缓存”),然后把这种能力附加到不同的类上。这鼓励把构件做得小而专注,而不是让每个人都继承一个臃肿的基类。
与单继承的类层次不同,traits 被设计成以受控的方式支持多重行为继承。你可以把多个 trait 加到一个类上,Scala 定义了明确的方法线性化(linearization)规则来解析方法调用顺序。
当你“mix in” traits 时,你是在类边界处组合行为,而不是在继承树上往下钻。这通常更易维护:
一个简单示例:
trait Timestamped { def now(): Long = System.currentTimeMillis() }
trait ConsoleLogging { def log(msg: String): Unit = println(msg) }
class Service extends Timestamped with ConsoleLogging {
def handle(): Unit = log(s"Handled at ${now()}")
}
使用 traits 的场景:
使用 抽象类 的场景:
真正的收益在于 Scala 让重用更像是组装零件,而不是继承注定的命运。
Scala 的模式匹配是让这门语言感觉“更函数式”的特性之一,尽管它同时支持经典的面向对象设计。与其把逻辑塞进一堆虚方法里,你可以 检查 一个值并根据其形状选择行为。
最简单的情况,模式匹配就是更强大的 switch:它可以匹配常量、类型、嵌套结构,甚至把值的部分绑定到名字上。因为它是一个表达式,它自然会产生结果——常导致紧凑且可读的代码。
sealed trait Payment
case class Card(last4: String) extends Payment
case object Cash extends Payment
def describe(p: Payment): String = p match {
case Card(last4) => s"Card ending $last4"
case Cash => "Cash"
}
上面的例子也展示了 Scala 风格的 ADT:
sealed trait 定义了一组封闭的可能性。case class 和 case object 定义了具体变体。“sealed” 是关键:编译器知道所有有效的子类型(在同一文件内),这为更安全的模式匹配打开了可能性。
ADT 鼓励你用显式的方式建模领域的真实状态。与使用 null、魔法字符串或可能组合出不可能状态的布尔值相比,你明确列出允许的情况。这使得许多错误在编码阶段就无法表达——因此也就不会滑入生产。
当你在做以下事情时,模式匹配特别出色:
当每个行为都写成巨大的 match 块并散布在代码库各处时,模式匹配就会被滥用。如果匹配块变得庞大或无处不在,通常表示你需要更好的分解(辅助函数)或把部分行为移到数据类型本身附近。
Scala 的类型系统是团队选择它的主要原因之一,同时也是一些团队放弃它的主要原因之一。在最好的时候,它让你写出既简洁又有强校验的代码;在最差的情况下,你可能会觉得像在调试编译器。
类型推断意味着你通常不必在每处都写出类型。编译器常常能从上下文推断出类型。
这转化为更少的样板:你可以把注意力放在值的含义上,而不是不断标注它的类型。当你确实添加类型注解时,通常是为了在边界处(公共 API、复杂泛型)澄清意图,而不是每个局部变量都注解。
泛型让你为多种类型写容器和工具(比如 List[Int] 和 List[String])。方差关系到当类型参数变化时,泛型类型是否可以被替换。
+A)大致意味着“猫的列表可以当做动物的列表使用”。-A)大致意味着“动物处理器可以当做猫处理器使用”。这对库设计很强大,但初学时可能让人困惑。
Scala 推广了一种模式:可以在不修改类型的情况下,传入能力(capability)来“为类型添加行为”。例如,你可以定义如何比较或打印某个类型,并自动选取相应逻辑。
在 Scala 2 中用 implicit 实现;在 Scala 3 中用 given/using 更直接地表达。核心思想相同:以可组合的方式扩展行为。
权衡在于复杂性。类型级的技巧可能产生冗长的错误信息,过度抽象的代码会让新来者难以阅读。许多团队采用经验法则:用类型系统去简化 API 并防止错误,但避免让每个人都需要像编译器那样思考才能修改代码的设计。
Scala 有多条并发写法路径。这很有用——因为并非每个问题都需要相同级别的工具——但也意味着团队应对采用哪个方案保持审慎。
对于许多 JVM 应用,Future 是以最简单的方式并发运行任务并组合结果的工具。你启动工作,然后用 map/flatMap 来构建异步工作流,而不阻塞线程。
一个良好的心理模型:Futures 适用于独立任务(API 调用、数据库查询、后台计算),你希望合并结果并在一个地方处理失败。
Scala 允许你以更线性的风格表达 Future 链(通过 for 推导)。这并没有新增并发原语,但使意图更清晰并减少回调地狱。
权衡在于:仍然容易意外阻塞(例如等待 Future),或在没有区分 CPU 绑定与 IO 绑定工作时过载执行上下文。
对于长期运行的管道——事件、日志、数据处理——流式库(如 Akka/Pekko Streams、FS2 等)关注的是 流量控制。关键特性是背压:当消费者跟不上时,生产者会放慢速度。
这种模型通常优于“多次 spawn Futures”,因为它把吞吐与内存作为一等关注点。
Actor 库(Akka/Pekko)把并发建模为通过消息通信的独立组件。这可以简化对状态的推理,因为每个 actor 一次处理一条消息。
当你需要长期存在、带状态的进程(设备、会话、协调器)时,actor 很合适;而对于简单的请求/响应应用可能显得过重。
不可变数据结构减少了共享可变状态——这是许多竞态条件的根源。即使在使用线程、Futures 或 actors 时,传递不可变值也能让并发错误更少、调试更轻松。
对简单并行工作先用 Futures;当需要可控吞吐时转向流;当状态与协调占主导地位时考虑 actors。
Scala 最大的实际优势是它运行在 JVM 上,可以直接使用 Java 生态。你可以实例化 Java 类,实现 Java 接口,调用 Java 方法而几乎不显山露水——很多时候感觉你只是在用另一个 Scala 库。
大多数“顺利路径”的互操作是直接的:
在运行时,Scala 编译为 JVM 字节码。从操作角度看,它像其他 JVM 语言一样运行:由相同运行时管理,使用相同的 GC,并用熟悉的工具进行性能分析与监控。
摩擦主要出现在 Scala 的默认与 Java 的默认不一致时:
Null。 许多 Java API 返回 null;Scala 代码偏好 Option。你常常需要防御性地封装 Java 结果以避免意外的 NullPointerException。
受检异常。 Scala 不强制你声明或捕获受检异常,但 Java 库可能抛出它们。这可能导致错误处理感受不一致,除非你标准化异常如何被翻译。
可变性。 Java 集合和以 setter 为主的 API 鼓励可变。在 Scala 中混合可变与不可变风格可能导致令人迷惑的代码,尤其在 API 边界处。
把边界当作翻译层:
Option,并仅在边界处再转换回 null。做好这些工作后,互操作可以让 Scala 团队通过重用成熟的 JVM 库更快地推进,同时在服务内部保持更可表达和更安全的 Scala 代码。
Scala 的宣称很吸引人:你可以把优雅的函数式代码写出来,在需要时保留 OO 结构,并留在 JVM 上。实际上,团队并不是简单地“得到 Scala”——他们会感受到一组日常权衡,这些在入职、构建和代码审查中显现出来。
Scala 给了你很多表达能力:多种建模数据的方式、抽象行为的多种途径、多种组织 API 的方式。灵活性一旦形成共享心智模型就很高效——但在早期可能会拖慢团队速度。
新来者的困难往往不在语法,而在选择:“这该是 case class、普通 class 还是 ADT?”“我们使用继承、trait、类型类还是仅仅函数?”难点不在于 Scala 不可学——而是要就“什么是我们认同的 Scala”达成一致。
随着项目增长或依赖宏/复杂库(在 Scala 2 中更常见),Scala 的编译往往比团队预期更重。增量构建能有所帮助,但编译时间仍是反复出现的实际问题:更慢的 CI、更慢的反馈循环,以及更大的压力去保持模块小且依赖干净。
构建工具也带来一层复杂性。无论你用 sbt 还是其他系统,都要关注缓存、并行度以及如何拆分子模块。这些不是学术问题——它们影响开发者幸福感和修复 Bug 的速度。
Scala 的工具支持已有明显改进,但仍值得用你自己的堆栈做一次评估。在标准化之前,团队应该测试:
如果 IDE 表现不佳,语言的表达能力反而会适得其反:代码“正确”但难以探索,会增加维护成本。
因为 Scala 同时支持 FP 与 OOP(以及许多混合写法),代码库可能会变成看起来像几种语言混合的产物。这通常是挫败的根源:问题不是 Scala 本身,而是约定不一。
约定与 linter 很重要,因为它们减少争论。提前决定团队认为什么是“好的 Scala”——如何处理不可变性、错误处理、命名、以及何时使用高级类型级模式。统一风格能让入职更顺畅,并把审查焦点放在行为而非审美上。
Scala 3(在开发期常称为 “Dotty”)并不是对 Scala 身份的改写——而是试图在保留 FP/OOP 混合的同时,抚平团队在 Scala 2 中遇到的尖锐问题。
Scala 3 保留了熟悉的基础,但推动代码更清晰的结构。
你会注意到带有显著缩进的可选大括号,它使日常代码更像现代语言而不是密集 DSL。它还把一些在 Scala 2 中“可能但混乱”的模式标准化——例如通过 extension 添加方法比使用一堆 implicit 技巧更直接。
在理念上,Scala 3 试图让强大特性显得更显式,从而使读者在不必记住大量约定的情况下也能看懂代码发生了什么。
Scala 2 的 implicits 极为灵活:这对类型类和依赖注入很有利,但也导致令人困惑的编译错误和“远程行为”。
Scala 3 用 given/using 替代了大部分 implicit 使用。功能相似,但意图更清晰:given 表示“这是一个被提供的实例”,using 表示“这个方法需要一个实例”。这提高了可读性,使 FP 风格的类型类模式更易于追踪。
枚举也是一项重要改进。许多 Scala 2 团队用 sealed trait + case objects/classes 来建模 ADT。Scala 3 的 enum 提供了专门且整洁的语法——更少样板代码,但保留相同的建模能力。
多数真实项目通过交叉构建(同时发布 Scala 2 和 Scala 3 的制品)并逐模块迁移。
工具有所帮助,但仍需工作:源不兼容(尤其是 implicits 周围)、依赖宏-heavy 的库,以及构建工具链都会拖慢进度。好消息是:典型的业务代码比大量依赖编译器魔法的代码更容易迁移。
在日常代码中,Scala 3 往往让 FP 模式感觉更“第一类”:类型类的连线更清晰、用 enum 写 ADT 更干净、以及在没有过多仪式的情况下使用更强的类型工具(如并/交类型)。
同时它并不抛弃 OO——traits、classes 与 mixin 组合依然很中心。不同之处在于 Scala 3 让“OO 结构”与“FP 抽象”之间的边界更明显,通常有助于团队长期保持代码一致性。
Scala 可以成为 JVM 上的“高功率”语言,但它不是默认的万金油。最大收益出现在问题受益于更强的建模与更安全的组合时,并且团队准备好有意识地使用这门语言。
数据密集型系统与管道。 如果你在做大量的变换、校验和增强(流、ETL、事件处理),Scala 的函数式风格和强类型能让这些变换更显式、更少出错。
复杂领域建模。 当业务规则细致复杂(定价、风控、资格判定、权限)时,Scala 用类型表达约束和构建小而可组合的组件的能力能减少“if-else 爆炸”,并让无效状态更难以表示。
已经投入 JVM 的组织。 如果你的世界已依赖 Java 库、JVM 工具与运维实践,Scala 能在不离开生态的情况下带来 FP 风格的可用性。
Scala 奖励一致性。团队通常在具备以下条件时更容易成功:
没有这些,代码库可能会滑入多种风格并存的混乱状态,新来者难以跟进。
需要快速上手的小团队。 如果你预计频繁交接、许多少年贡献者或快速的人力变动,学习曲线与多样化的写法会拖慢速度。
简单的 CRUD 服务。 对于简单的“接收请求/写入记录”的服务,如果领域复杂度低,Scala 的好处可能不足以抵消构建工具、编译时间与风格决策的成本。
问自己:
如果大多数答案是“是”,Scala 往往是很合适的选择。如果不是,更简单的 JVM 语言可能更快产出结果。
一个实用建议:在评估语言时把原型循环做短。例如,有的团队会使用类似 Koder.ai 之类的快速原型平台,从基于聊天的规范快速生成一个小型参考应用(API + 数据库 + UI),在规划阶段反复迭代,并用快照/回滚来快速探索替代方案。即便你的生产目标是 Scala,拥有一个可导出源码并与其他 JVM 实现比较的快速原型,也能让“是否选择 Scala”这个讨论更加具体——基于工作流、部署和可维护性,而不仅仅是语言特性。
Scala 的设计目标是减少典型 JVM 开发中的痛点——样板代码过多、与 null 有关的错误、以及脆弱的继承驱动设计——同时保留 JVM 的性能、工具链和库访问能力。目标是在不离开 Java 生态的前提下,更直接地表达领域逻辑。
在实际项目中,用 OO 来划定清晰的模块边界(API、封装、服务接口),在这些边界内部使用 FP 技术(不可变性、表达式导向代码、接近纯函数的写法)可以减少隐式状态,使行为更容易测试和修改。
默认应优先使用 val,以避免意外重赋值并减少隐藏状态。当确实需要时(如性能敏感的紧循环或 UI 粘合代码),再有意识地使用 var。尽量把可变性限制在局部和受控范围内,不要把它带入核心业务逻辑。
Trait 是可重用的“能力”组件,可以混入到许多类中,避免构建深而脆弱的继承树。
用 sealed trait 结合 case class/case object 建模一个封闭的状态集合,然后用 match 处理每个分支。
这种方式使得无效状态更难表达,也能让编译器在新增分支时提醒未处理的情况,从而提高安全性。
类型推断减少了重复的类型注解,使代码更简洁,同时仍然有静态类型检查。
通常的做法是在 边界处添加显式类型(公共方法、模块 API、复杂泛型),以便提高可读性并稳定编译错误,而不是给每个局部变量都加注解。
方差描述泛型类型在子类型关系下的替换规则。
+A):容器可以“放宽”,例如 List[Cat] 可被视为 List[Animal]。它们是实现类型类风格设计的机制:在不修改原始类型的情况下“从外部”提供行为。
implicit。given / using。Scala 3 更清晰地表达了“谁提供了实例”和“谁需要实例”的意图,通常能提高可读性并减少“远程作用”的问题。
从简单到复杂逐步选择:
无论哪种方式,传递不可变数据都会显著降低竞态问题的概率。
把 Java/Scala 边界当作翻译层:
null 时,立即转换为 Option,并且只在边界处再把 Option 转回 null。这样可以让互操作性可预测,防止 Java 的默认(null、可变)蔓延到代码库全部位置。
-A):消费者/处理器可以被放宽,例如 Handler[Animal] 可以用在需要 Handler[Cat] 的地方。这些概念在设计接受或返回泛型类型的库/API 时会特别明显。