ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Scala案例类与模式匹配:用ADT优雅处理封闭类型建模

2026/9/15 21:59:55 拓冰建站 浏览量
Scala案例类与模式匹配:用ADT优雅处理封闭类型建模 写 Scala 的人大概都经历过一个阶段明明 Java 里 class 用得很顺面对样例类case class和模式匹配却一头雾水。我也不例外。直到有次做一个图形面积计算的小工具需要处理 Circle圆和 Rectangle矩形我习惯性地建了抽象类 Shape再让两个子类各自实现 area()写完后总觉得哪里不对再加一个 Triangle 就要再写一个类再改所有用到 Shape 的地方若是再来个求周长的需求每个类又要加一遍。后来我把建模方式换成样例类case class 模式匹配同一个需求只需要一个 sealed trait、两个 case class 和一个 match 函数问题一下子清爽了。这篇文章就从这段经历出发从环境搭建讲到工程里的最佳实践把 Scala 处理这类封闭类型集合的思路一次说透。先说结论Circle / Rectangle 求面积这种题目看起来是面向对象多态的经典例子但当你面对的是一个“类型集合封闭、操作数量不定”的领域时ADT代数数据类型加模式匹配会比继承多态舒服得多。下面我会先用传统写法和 Scala 写法做对比再逐步拆解 case class 和模式匹配的原理、代码演进、工程避坑最后给出一份可以直接照着用的最佳实践清单。1. 为什么这里不适合用“继承 多态”而更适合 ADT 思路1.1 传统 OOP 写法让你先想到了什么在一个 Java 程序员眼里Circle 和 Rectangle 就是两个具体类抽一个 Shape 抽象类定义一个area()抽象方法子类各自实现再想扩展就继续加子类。这种写法本身没有错它的问题也不在“能不能跑”而在“扩展方向”。// 传统 OOP 风格把行为塞进每个类 class Circle(val radius: Double) { def area: Double math.Pi * radius * radius } class Rectangle(val width: Double, val height: Double) { def area: Double width * height }写两个类的时候还好可一旦操作开始变多事情就没那么优雅了。比如你后面要加周长、加 JSON 序列化、加画布渲染每个类都要跟着长出新方法。这时候你感受到的是“类的膨胀”和“跨类逻辑不好共享”。更麻烦的是很多操作并不是某个形状独有的。比如把面积、周长、序列化结果都打印出来这些逻辑放在哪个类都不合适经常被迫写一堆instanceof判断或者为了复用而硬塞到某个公共父类里。类一多继承关系就开始显得笨重。1.2 换个视角把 Circle / Rectangle 当成数据把操作当成函数Scala 给出的另一种建模方式是用一个sealed trait定义类型边界用case class定义每种具体形状把面积计算收敛到一个独立的match函数里。sealed trait Shape case class Circle(radius: Double) extends Shape case class Rectangle(width: Double, height: Double) extends Shape def area(shape: Shape): Double shape match { case Circle(r) math.Pi * r * r case Rectangle(w, h) w * h }我把这种方式称为“把数据摆出来把操作集中起来”。Shape只是类型标记Circle和Rectangle是纯数据容器而area就是一台“识别形状并计算”的机器。代码读起来非常直白所有可能出现的形状、所有分支逻辑摆在同一张表里一眼看完。这不是说 OOP 错了而是当类型集合封闭、操作需要统一分派时ADT 思路往往更贴手。2. 跑起来再说Scala 3 环境准备2.1 快速起步用 scala-cli一条命令跑单文件理论讲再多不如手上先有一份能跑的代码。现在 Scala 官方推荐的快速上手方式是scala-cli它专门为单文件脚本和小型试验设计不需要先搭一个完整工程。curl -fL https://scala-cli.virtuslab.org/get | sh装完后新建一个shape.scala把下面这段写进去main def run(): Unit val shape: Shape Circle(1.0) println(area(shape))然后在同一个文件里补上Shape、Circle、Rectangle的定义和area函数。运行只需要一条命令scala-cli run shape.scala如果你公司或课程里还在用 Scala 2.13也没关系scala-cli 支持指定版本运行scala-cli run --scala 2.13 shape.scalacase class加模式匹配在 Scala 2.13 和 Scala 3 里都能正常写基本语法几乎完全一致后面我会单独讲两个版本之间的差异。2.2 工程化项目用 sbt但我建议初学者先别折腾如果只是学习面积计算这类小例子完全没必要打开 sbt。sbt 的学习曲线相对陡启动也慢放在入门阶段容易把热情磨灭。当你真正开始写一个多文件、多依赖的项目时再考虑用它初始化工程sbt new scala/scala3.g8进入生成的项目目录后用sbt run就可以运行。这个模板默认就是 Scala 3 项目配置好build.sbt后可以引入第三方库。本文后面的代码都可以在单文件里用 scala-cli 直接跑不涉及工程化依赖重心还是放在语言特性本身。3. 样例类自动送你的四件套apply、unapply、equals/hashCode、copy3.1 apply 与 unapply构造器和提取器的镜像关系case class最直观的好处是不用new就能构造对象。Circle(1.0)实际上是调用了伴生对象里编译器自动生成的apply方法。这个设计不是为了少敲两个字母而是为了让“构造一个值”和“匹配一个值”在语法上完全对称。对称的另一端是unapply。模式匹配里写case Circle(r)时编译器会自动调用Circle伴生对象里的unapply把传入的Circle对象里的radius字段提取出来绑定到变量r。这个机制叫“提取器”。Circle(1.0) match { case Circle(r) sradius is $r // 这里是 Circle.unapply 在工作 }你可以把apply理解为“由参数得到对象”把unapply理解为“由对象还原参数”。正因为case class自动同时生成了这一对方法它和模式匹配才能天然咬合。普通 class 想做到同样效果得自己手写 companion object非常麻烦。3.2 不可变特性、结构相等与 copycase class的所有构造参数默认都是val所以对象创建后字段不可变。这不是限制而是设计信号它鼓励你把形状看成“值”而不是一个随时会被改来改去的对象。面积计算里你根本不需要修改半径这种不可变让代码更安全。等值判断也是自动生成的。Circle(1.0) Circle(1.0)返回true因为比较的是结构而不是引用。这个特性在测试里特别好用assert(Circle(1.0) Circle(1.0)) assert(Rectangle(2.0, 3.0) ! Rectangle(3.0, 2.0))还有copy方法可以基于现有对象生成一个修改部分字段的新对象。比如有一个Rect(2.0, 3.0)想改成宽 5.0 的版本val r Rectangle(2.0, 3.0) val r2 r.copy(width 5.0) // 结果是 Rectangle(5.0, 3.0)这些能力放到普通 class 里全都要手写或者依赖 IDE 生成。Java 程序员看到这里应该能理解为什么 Scala 社区这么推崇 case class因为它省掉了大量样板代码同时把不可变和数据建模这两个关键点焊死在语言层面。3.3 什么时候不该用 case class写面积计算这种场景case class 是完美的。但你得知道它不适合什么。如果一个对象拥有稳定身份标识比如用户、订单这类实体你用 case class 就要小心默认的结构相等可能把“拥有相同字段值的两个对象”误判成同一个对象这在现实业务里往往是错的。此外如果类型层次需要被外部第三方持续扩展case class 作为子类也会让模式匹配的穷尽性优势消失。所以我的建议是纯数据、不可变、类型集合相对封闭的地方优先 case class涉及身份、可变状态、开放扩展的领域回到普通 class 或 trait。4. 模式匹配不是简化版 switch而是“结构拆包器”4.1 从一行 match 看模式匹配的真本事很多人刚接触 Scala 时会把match当成高级版 switch这种理解太浅了。switch 只能做值比较而 Scala 的match可以同时完成“类型判断”和“字段提取”。def area(shape: Shape): Double shape match { case Circle(r) math.Pi * r * r case Rectangle(w, h) w * h }case Circle(r)这一行等价于 Java 里的“判断是不是 Circle如果是就强转成 Circle再从结果里取出 radius”。Scala 把类型检查和字段提取合并成了一个动作代码量从五行变成一行而且不可能出现“判断了类型却忘了强转”的低级错误。4.2 常用模式一览常量、变量、构造器、序列、元组模式匹配的灵活度远超表面看起来的样子。一个模式可以是常量、变量、构造器、序列、元组甚至嵌套组合。def describe(x: Any): String x match { case 0 零 case n: Int s整数 $n case List(1, a, b) s以 1 开头的三元素列表${a}, ${b} case (lat, lng) s坐标 ($lat, $lng) case _ 其他 }常量模式case 0直接比较值。变量模式case n: Int提取并绑定变量。构造器模式case List(1, a, b)不仅匹配一个 List还要求第一个元素是 1同时把第二、第三个元素拆出来。元组模式case (lat, lng)直接把元组拆成两个变量。通配符case _匹配任何值类似 default。这些模式可以嵌套使用比如case Some((lat, lng))就同时匹配Option和元组。对面积计算来说我们用得最多的是构造器模式但理解了全局才能看懂别人的 Scala 代码里那些花式 match。4.3 用守卫给计算加一道安全阀模式匹配里还能加if守卫在模式已经匹配的基础上再增加条件判断。def safeArea(shape: Shape): Option[Double] shape match { case Circle(r) if r 0 Some(math.Pi * r * r) case Rectangle(w, h) if w 0 h 0 Some(w * h) case _ None }这里的if r 0会先匹配到 Circle再判断半径是否非负。如果半径是负数这个分支就不会命中继续走到后面的case _。这种写法的好处是逻辑分布得很清楚模式负责“你是什么形状”守卫负责“你的数据是否合法”。5. Circle / Rectangle 求面积的三种演进从能算到算得安全5.1 第一版最简单、能跑先把最原始的版本写出来。这个版本功能完整但没有参数校验也没有考虑批量处理和扩展。sealed trait Shape case class Circle(radius: Double) extends Shape case class Rectangle(width: Double, height: Double) extends Shape def area(shape: Shape): Double shape match { case Circle(r) math.Pi * r * r case Rectangle(w, h) w * h } main def demo(): Unit { val shapes: List[Shape] List(Circle(1.0), Rectangle(2.0, 3.0)) shapes.foreach(s println(area(s))) }运行结果一个是3.141592653589793一个是6.0。代码逻辑没错但注意如果传入Circle(-1.0)面积会算出一个正数这显然是错误的。负半径在几何上没有意义应该在数据源头就把这种状态拦住。5.2 第二版加上参数校验拒绝非法形状我倾向于在构造时就用require校验参数这样非法对象根本不会被创建出来。sealed trait Shape case class Circle(radius: Double) extends Shape { require(radius 0, s半径不能为负数但收到了 $radius) } case class Rectangle(width: Double, height: Double) extends Shape { require(width 0 height 0, s宽高不能为负数但收到了 $width, $height) } def area(shape: Shape): Double shape match { case Circle(r) math.Pi * r * r case Rectangle(w, h) w * h }这样构造Circle(-1.0)会直接抛出IllegalArgumentException把问题暴露在最早阶段。如果你不想抛异常而是希望返回Option或者Either那就把校验放在面积计算的守卫里像我在 4.3 节写的那样。两种方式各有适用场景内部系统里我更喜欢require因为越早失败越容易定位面向外部输入或解析用户数据时Option/Either更友好能把错误信息优雅地返回给调用方。5.3 第三版批量形状和嵌套组合真实项目里你不会只算一个形状而是会处理一批形状。List[Shape]加map加sum就完成了总和计算。如果你还想表达“组合图形”的概念比如一个 Group 里包含多个子图形也可以把它定义成一种形状sealed trait Shape case class Circle(radius: Double) extends Shape case class Rectangle(width: Double, height: Double) extends Shape case class Group(shapes: List[Shape]) extends Shape def area(shape: Shape): Double shape match { case Circle(r) math.Pi * r * r case Rectangle(w, h) w * h case Group(children) children.map(area).sum }这时area的名场面出现了case Group(children) children.map(area).sum这行代码递归调用了自己通过map把每个子形状的面积算出来再用sum汇总。整个逻辑只有一行却完整表达了“组合图形的面积等于所有子图形面积之和”这个规则。这就是模式匹配搭配 ADT 最舒服的地方递归结构天然好写编译器也能帮你保证分支完整。val scene: Shape Group(List( Circle(1.0), Rectangle(2.0, 3.0), Group(List(Circle(0.5), Rectangle(1.0, 1.0))) )) println(area(scene)) // 3.1415... 6.0 0.7853... 1.06. 真正写工程后容易翻车的三个细节6.1 sealed 不是装饰是穷尽性检查的基石sealed关键字看起来只是限制了子类的定义范围实际上它是模式匹配的安全网。只要把Shape声明为sealed trait并且所有子类都定义在同一个文件里编译器就知道全部可能的类型。这时如果你在match里漏掉某个 case编译期会给出非穷尽警告。比如你把case Rectangle(w, h)删掉只剩 Circle 分支编译器会告诉你match may not be exhaustive。这是非常有价值的检查。反过来如果不加sealed外部模块也能随时新增 Shape 子类编译器根本无法判断是否穷尽你的 match 就只能依赖兜底的case _否则运行时大概率摔一个MatchError。我在团队里习惯把这种警告当成 error 处理Scala 2 用-Xfatal-warningsScala 3 里是-Werror。强制在编译期解决穷尽性而不是留到线上报错。6.2 类型擦除别想用模式匹配判断 List[Circle]泛型的类型擦除是 JVM 系语言的老问题。Scala 里可以写case _: List[Circle]但这里有个陷阱def describe(x: Any): String x match { case _: List[Circle] 这是圆的列表 case _: List[Shape] 这是形状列表 case _ 其他 }这段代码在编译期就会收到 unchecked warning因为运行时List[Circle]和List[Shape]都被擦除成了同一种List。第一个分支实际上会匹配任意 List第二个分支永远执行不到。正确做法是先匹配List[_]再对每个元素做模式匹配def describe(x: Any): String x match { case list: List[?] if list.forall(_.isInstanceOf[Circle]) 都是圆 case list: List[?] s形状列表长度 ${list.size} case _ 其他 }当然更 Scala 的做法是直接对List[Shape]做map/collect而不是检查元素类型。记住一个原则不要试图在运行时区分泛型类型参数元素级判断才是正道。6.3 Scala 2 与 Scala 3 的语法差异目前你会在网上看到大量 Scala 2 的老代码而新项目主流是 Scala 3。还好case classmatch这套写法在两个版本里都能用差别主要在定义 ADT 的语法和代码格式上。能力Scala 2Scala 3密封类型sealed trait Shapesealed trait Shape 或直接 enum Shape枚举式 ADT不支持只能 sealed trait case classenum Shape { case Circle(...); case Rectangle(...) }缩进风格必须写花括号可以不写花括号用缩进块扩展方法implicit classextension (s: Shape) def area ...Scala 3 里最直观的改进是enum。下面这种写法更简洁而且天然 sealedenum Shape: case Circle(radius: Double) case Rectangle(width: Double, height: Double)匹配逻辑完全一样。如果公司项目代码还在 Scala 2.13你用 sealed trait case class 就是最稳的选择几乎没有兼容性风险。7. 从面积计算延伸出去的最佳实践清单7.1 建模前先问类型集合是封闭还是开放用 ADT 的前提是你面对的集合是封闭的Circle、Rectangle、可能还有 Triangle、Square但你知道边界在哪。如果业务是插件化架构第三方要不断注册新类型那 ADT 就不合适应该回到 OOP 的开放继承或者用注册表。判断标准很简单你把类型列表写出来问自己“下一个类型什么时候会出现”。如果长期不出现或者出现频率很低那就放心用 sealed trait case class如果每个迭代都在加新类型请立刻换回多态或策略模式。7.2 方法放哪内部方法还是外部 match 函数我见过不少初学者把面积计算写成 case class 内部方法case class Circle(radius: Double) extends Shape { def area: Double math.Pi * radius * radius }这种写法对单个类型没问题可一旦你有一个List[Shape]想统一求每个形状的面积你还是需要类型分派。更统一的做法是放到外面def area(shape: Shape): Double shape match { case Circle(r) math.Pi * r * r case Rectangle(w, h) w * h }Scala 3 还可以用 extension 让它伪装成方法调用extension (shape: Shape) def area: Double shape match case Circle(r) math.Pi * r * r case Rectangle(w, h) w * h这样你既保留了“外部函数集中管理分支”的好处调用时又能写Circle(1.0).area兼顾可读性。如果某个操作只针对单一类型比如 Circle 求直径放内部也无妨但只要操作需要覆盖所有子类型外部 match 是更好的选择。7.3 测试、维护和代码演进的小经验每次加一个新形状比如 Triangle流程应该是定义Triangle子类然后立刻去编译编译器会指出哪些 match 缺了case Triangle。这个反馈循环非常舒服。我在团队里要求新代码一律开启穷尽性检查这个习惯救过我好几次。测试也只针对面积计算函数做数据驱动就好// 伪代码换成你喜欢的测试框架 val cases List( (Circle(1.0), math.Pi), (Rectangle(2.0, 3.0), 6.0), (Group(List(Circle(1.0), Rectangle(2.0, 3.0))), math.Pi 6.0) ) cases.foreach { (shape, expected) assert(area(shape) expected) }关键是保持面积函数是纯函数同样的输入永远产生同样的输出不碰外部状态不做 I/O这样测试和维护成本都很低。在我实际写项目的这些年里case class 模式匹配的组合几乎成了我处理“封闭类型问题”的默认肌肉记忆。刚开始你可能觉得 match 不过是一种语法糖等你真的用它写出一个递归结构清晰、分支穷尽由编译器保证的系统时就会明白这种设计为什么被人反复推荐。