Scala 2022-03-22 · 20 min 阅读

Scala 让你又爱又恨的语法,到底在表达什么

Scala 的语法看起来”花样繁多”,背后其实只有一条主线:让编译器多做一点,让运行时少错一点。这一篇我们从 val 讲起,途经 case class、sealed、@tailrec、7 个语法糖、模式匹配 8 个 Trick,一直到自定义 unapply——把 Scala 入门的”语法全景”梳理一遍。


序:第一次被 Scala 吓到的那个晚上

2018 年某个加班的晚上,我打开同事发过来的一段 Scala 代码,看了三分钟没敢说话。

val pf1: PartialFunction[Any, String] = { case s: String => "yes" }
val pf2: PartialFunction[Any, String] = { case d: Double => "yes" }
val pf = pf1 orElse pf2
List("str", 3.14, 10) foreach { item => print(pf(item).toString + "") }

我心里想的是:这是什么?偏函数?orElse?为什么 List foreach 的花括号能这么写?为什么 print 后面那个 + "" 是多余的但又不报错?

那晚我盯着屏幕一个小时,把每个看似”魔法”的语法反编译成 Java,才第一次真正理解 Scala 设计的哲学:它不是在 Java 上面加糖,它是用类型系统重新表达程序员的意图。

这一篇我想讲清楚:Scala 那些”看起来很怪”的语法,到底想说什么。

一、val 不是一个简单的 final

我们先看最基础的部分:

val array: Array[String] = new Array[String](5)   // val 声明,引用不可变
array(0) = "hello"                                // 但 array[0] 是可变的!

var stockPrice: Double = 100.0                    // var 才是真正"可重新赋值"
stockPrice = 200.0

从 Java 转过来的人第一反应是”哦,val 就是 final“。这话对了一半——val 承诺的是”这个引用不能再指向别处”,但它指向的对象完全可以被修改。

array 是 val,所以你不能写 array = new Array[String](10),但 array(0) = "hello" 完全合法。

这跟 Java 的 final 一样。但 Scala 在”鼓励你用 val“这件事上比 Java 认真得多——社区约定是”能用 val 就别用 var,能用 case class(不可变数据结构)就别用普通 class”。Scala 的并发优势很大程度上来自这个约定:当你看到一个 val,你就知道它的引用在生命周期里是稳定的。

一个小细节:方法名可以是”操作符”

Scala 里那些看着像操作符的 +、-、*、::、++,全都是方法名。这意味着你可以用反引号包任何字符序列当方法名:

class Crocodile extends Animal with Carnivore with Philosopher {
  // 方法名是 ?!,能这样写是因为 Scala 里"操作符"就是方法
  override def ?!(thought: String): Unit = println(s"I was thinking: $thought")
}

val aCroc = new Crocodile
aCroc ?! "What if we could fly?"   // 调用的是 aCroc.?!("What if we could fly?")

这看着搞笑,但 ScalaTest 的 should/must、Akka 的 !(tell)都靠这个特性。Scala 的”操作符重载”不是重载,是操作符就是方法。

二、Case Class:你以为只是个数据类,其实它是 DSL 的地基

case class Point(x: Double = 0.0, y: Double = 0.0) {
  // shift 方法返回一个新 Point,原 Point 不变
  def shift(deltax: Double = 0.0, deltay: Double = 0.0) =
    copy(x + deltax, y + deltay)
}

case class 关键字是 Scala 最被低估的特性。一行 case class,编译器自动给你 7 样东西:

自动生成 作用
构造器 Point(1, 2) 不需要 new
伴生对象 Point 既能当类型又能当工厂方法
unapply 模式匹配的解构器(后面详讲)
equals / hashCode 基于字段值比较,不是引用
toString 输出 Point(1.0, 2.0) 这种可读形式
copy 命名参数做”修改”:point.copy(y = 6.6)
Serializable 自动混入

参数还能给默认值,于是 Point() 是原点,Point(1.0) 是 (1.0, 0.0)。

这套机制的本质:case class 是一种带类型安全的数据结构,但因为自带 unapply,它就成了 Scala 模式匹配、for 推导式、JSON 库、ORM 库的基础。没有 case class,Scala 生态会塌掉一半。

三、sealed:让”忘了写某个 case”从 bug 变成编译错误

sealed abstract class Option[+A] {}   // +A 后面会讲,sealed 现在先记住

这一行非常不起眼,但改变了整个错误处理生态。sealed 翻译过来是”封闭的”——它告诉编译器:所有继承我的子类必须写在同一个源文件里。

这意味着编译器能看到所有可能的子类,所以你写 match 的时候漏掉一个 case,编译器会警告或报错。

对比一下普通抽象类:

abstract class Animal                  // 任何文件、任何包都能继承
sealed abstract class Animal           // 只能在本文件继承

这个特性的真正威力:当你有 5 个 case class 表达”业务状态”,Scala 编译器能告诉你”嘿,你 match 没处理 Cancelled 这个状态”。

我自己的一个教训

我曾经用普通 abstract class 写过一个枚举层级,三个月后加了个新 case,没有任何编译器警告——直到 MatchError 在生产环境爆了 5 分钟。从那之后我给自己立了规矩:有限层级一律 sealed。

四、@tailrec:让”忘了写累积器”从 StackOverflow 变成编译错误

递归是 FP 的灵魂,但 JVM 栈是有限的。Java 递归跑着跑着 StackOverflowError,Scala 给了一个温柔的护栏:

def factorial(intValue: Int): Long = {
  @tailrec   // 强制要求下面的 fact 必须是尾递归,否则编译报错
  def fact(value: Int, accumulator: Int): Long = {
    if (value <= 1) accumulator
    else fact(value - 1, value * accumulator)   // 递归调用之后没有任何其他操作 = 尾递归
  }
  fact(intValue, 1)
}

@tailrec 注解告诉编译器:”这个递归必须是尾递归”。如果你的递归不是(比如忘了带累积器),编译直接失败——而不是运行时栈爆了才被发现。

注意几个细节:

背后是一个更大的设计哲学:Scala 倾向于让”潜在的错误”在编译期显形。@tailrec、sealed、case class、Option 都是这条主线的体现。

五、Dark Sugars:那些怪写法背后都是普通方法调用

讲个真实感受:第一次看到 2 :: List(3, 4) 的时候,我本能地以为这是”自定义的语法”。后来才知道——它就是 List(3, 4).::(2)。

Scala 有一整套”语法糖”让代码读起来像数学公式或自然语言。理解每个糖的反编译真相,你就能看懂 90% 的奇怪 Scala 代码。

糖 1:单参方法的花括号调用

def singleArgMethod(arg: Int): String = s"$arg little ducks..."

val description = singleArgMethod {
  // 花括号里的表达式会被求值后传给 singleArgMethod
  42
}   // 等价于 singleArgMethod(42)

花括号和小括号在调用单参方法时可以互换。这有什么意义?看这个:

List(1, 2, 3).map { x => x + 1 }   // 比 List(1, 2, 3).map(x => x + 1) 看着舒服

很多 DSL 风格 API 都会利用这一点。

糖 2:单抽象方法的 lambda 化

trait Action {
  def act(x: Int): Int
}

val aFunkyInstance: Action = (x: Int) => x + 1   // 编译器知道 Action 只有一个抽象方法

val aSweetThread = new Thread(() => println("sweet, scala!"))
// 替代了 new Thread(new Runnable { override def run(): Unit = ... })

只要一个 trait(或 Java interface)只有一个抽象方法,就可以用 lambda 直接实现——因为编译器知道”这个 lambda 就是要实现那个唯一的抽象方法”。Java 8 之后也支持这个特性(叫 SAM),但 Scala 早了好多年。

糖 3::: 操作符的右结合

val prependedList = 2 :: List(3, 4)
// 不是 2.::(List(3, 4)),而是 List(3, 4).::(2)
// 因为以 : 结尾的方法是右结合的

val chain = 1 :: 2 :: 3 :: List(4, 5)
// 实际执行顺序:List(4, 5).::(3).::(2).::(1)

Scala 明确规定:以 : 结尾的方法调用时是右结合的。这让 cons 操作(向列表头部追加元素)的链式调用 1 :: 2 :: 3 :: List(4, 5) 读起来就像”从右往左”自然地往 list 前加元素。

糖 4:多词方法名

class TeenGirl(name: String) {
  // 反引号包起来的任意字符串都能当方法名
  def `and then said`(gossip: String) = println(s"$name said $gossip")
}

val lilly = new TeenGirl("Lilly")
lilly `and then said` "Scala is so sweet"   // 调用是 lilly.`and then said`("...")

看着搞笑,但 ScalaTest 的 should/must/when、Akka 的 !(tell)都靠这个特性写出”自然语言风格”的测试和 DSL。

糖 5:中缀类型

class Composite[A, B]
val composite: Int Composite String = ???

class -->[A, B]
val towards: Int --> String = ???

类型名也能用符号。这在函数式编程库(如 Cats 的 Either[A, B])里偶尔出现,让某些领域特定的类型读起来更像数学公式。

糖 6:apply 和 update 的括号语法

val anArray = Array(1, 2, 3)
anArray(2)        // 编译为 anArray.apply(2),读取元素
anArray(2) = 7    // 编译为 anArray.update(2, 7),写入元素

apply 是”用括号调用方法”的语法糖,update 是”用括号赋值”的语法糖。Array、MutableMap 等可变集合的整个 API 都靠这两个方法。

糖 7:setter 语法

class Mutable {
  private var internalMember: Int = 0   // 真正的存储

  def member = internalMember           // getter

  def member_=(value: Int): Unit =      // setter,方法名以 _= 结尾
    internalMember = value
}

val a = new Mutable
a.member = 42   // 编译为 a.member_=(42)

只要方法名以 _= 结尾,就可以用等号赋值。这是 Scala getter/setter 模式的基础——也是 Scala 写可变容器时”看上去像属性访问,但其实是方法调用”的核心。

六、模式匹配 8 个 Trick:远超 switch 的解构器

终于到了 Scala 真正的”杀手锏”——模式匹配。很多人把 match 理解为”加强版的 switch”,这低估了它。Scala 的模式匹配本质是一个编译器驱动的解构器:它会根据值的结构(List(_, 2, _)、Person(name, age))做解构 + 提取,所有这些都在一个 match 里完成。

下面这 8 个 Trick 来自实战,每个对应一类真实场景。

Trick 1:列表按位置解构

val aList = List(1, 2, 3)
val listDescription = aList match {
  case List(_, 2, _) => "List containing 2 on its second position"
  case _             => "unknown list"
}

在解析 CSV、协议头、配置文件时,”按位置匹配列表”特别有用。

Trick 2:Haskell 风格的 ::

val numberList = List(1, 2, 3, 42)

// 模式 1 :: tail 既解构(head=1, tail=List(2,3,42)),又判断(列表非空)
val startsWithOne = numberList match {
  case 1 :: tail => s"list starts with one, tail is $tail"
}

def process(aList: List[Int]): String = aList match {
  case Nil              => "List is empty"
  case head :: tail     => s"list starts with $head, tail is $tail"
}

如果你写过 Haskell/Racket,这套语法应该很熟悉。

Trick 3:变长模式 _*

val dontCareAboutTheRest = numberList match {
  case List(_, 2, _*) => "I only care about second number being 2"
  //      ^^^^^^^^
  //      _* 表示"任意多个元素,名字我不关心"
}

判断”列表至少有几个元素 / 某个位置必须是某值”时必备。

Trick 4:中缀模式 + 末尾追加

val mustEndWithTheMeaningOfLife = numberList match {
  // List(1, 2, _) :+ 42 是个中缀表达式
  // 含义:一个 List(1, 2, 任意) 后面追加 42
  case List(1, 2, _) :+ 42 => "that's right, I have a meaning"
}

:+ 是 List 的”末尾追加”操作符。

Trick 5:按类型匹配

def gimmeAValue(): Any = 45

val gimmeTheType: String = gimmeAValue() match {
  case _: String => "I have a string"
  case _: Int    => "I have an int"
  case _         => "Something else"
}

Java 的 instanceof + 强制类型转换要写 5 行,Scala 一行搞定。处理 JSON 反序列化、配置解析、协议包时是杀手锏。

Trick 6:命名绑定 p@

val bobInfo: String = bob match {
  // p@Person(name, age) 把整个对象绑定到 p,同时把字段解构到 name、age
  case p @ Person(name, age) => s"$name's info: ${requestInfo(p)}"
}

当你既需要整个对象(传给其他方法),又需要解构其中的字段时,这个 trick 必不可少。

Trick 7:守卫(guards)

val ordinal2: String = aNumber match {
  case 1 => "first"
  case 2 => "second"
  case 3 => "third"
  case n if n % 10 == 1 => n + "st"   // 守卫:只有这个条件成立才算匹配
  case n if n % 10 == 2 => n + "nd"
  case n if n % 10 == 3 => n + "rd"
  case _ => aNumber + "th"
}

“按区间匹配”或”按某种规则计算”时非常方便。

Trick 8:备选模式 |

val myOptionalList = numberList match {
  case List(1, _*) | List(_, _, 3, _*) => "I like this list"
  //                ^
  //                | 表示"或者",只要左或右任一匹配就算匹配
  case _ => "I hate this list"
}

在”有多个合法结构”的场景下非常实用。

模式匹配不是 switch

回头看这 8 个 Trick,你会发现模式匹配至少做了 4 件事:

  1. 解构(case class、tuple、list)
  2. 类型判断(_ : Type)
  3. 命名绑定(p@)
  4. 条件筛选(guards + |)

每件事单独看都不复杂,但它们能组合——这才是 Scala 模式匹配真正的威力。

七、进阶 unapply:自己写”模式”

case class 的解构能力靠编译器自动生成的 unapply 在背后撑腰。但 Scala 允许你完全自定义 unapply——这让模式匹配的能力扩展到了没有 case class 的类。

class Person(val name: String, val age: Int)

object Person {
  // 让 Person 也能参与模式匹配,并且加入"未成年不能匹配"的业务规则
  def unapply(person: Person): Option[(String, Int)] =
    if (person.age < 21) None        // 返回 None 表示"模式不匹配"
    else Some((person.name, person.age))

  // 重载:让 Person 这个 object 也能匹配 Int 类型的输入
  def unapply(age: Int): Option[String] =
    Some(if (age < 21) "minor" else "major")
}

val bob = new Person("Bob", 25)
val greeting = bob match {
  case Person(n, a) => s"Hi, my name is $n and I am $a year."
}
// greeting == "Hi, my name is Bob and I am 25 year."

val legalStatus = bob.age match {
  case Person(status) => s"my legal status is $status"
  //               ^^^^^^
  //      bob.age 是 Int,匹配的是 unapply(age: Int) 那个重载
}
// legalStatus == "my legal status is major"

注意 Person.unapply 返回 None 而不是抛异常——这就是”模式不匹配”和”运行时错误”的区别。unapply 返回 None 时编译器会继续尝试下一个 case,整个 match 都不匹配才抛 MatchError。

一个超优雅的 trick:unapply 可以返回 Boolean

// even 和 singleDigit 不是值,是返回 Boolean 的 unapply object
object even       { def unapply(arg: Int): Boolean = arg % 2 == 0 }
object singleDigit { def unapply(arg: Int) = arg > -10 && arg < 10 }

val n = 8
val mathProperty = n match {
  case singleDigit() => "single digit"   // 匹配 predicate
  case even()        => "an even number"
  case _             => "no property"
}

even 不是一个值,是一个 unapply 返回 Boolean 的 object。case even() 在 n 是偶数时匹配。

这是 Scala 模式匹配里少有人知道但极其实用的 trick——它让你把”某种谓词判断”包装成”模式”,复用模式匹配的语法。

极致自定义:Wrapper[T]

// unapply 的"标准签名"是 Option[T]
// 但编译器只要求返回类型有 isEmpty: Boolean 和 get: T 两个方法
abstract class Wrapper[T] {
  def isEmpty: Boolean
  def get: T
}

object PersonWrapper {
  def unapply(person: Person): Wrapper[String] = new Wrapper[String] {
    def isEmpty = false
    def get = person.name
  }
}

println(bob match {
  case PersonWrapper(n) => s"This person's name is $n"
  case _               => "An alien"
})

这给了框架作者(JSON 库、ORM 库)极大的自由度——可以构造”懒求值”或”抛错时给出更详细信息”的 unapply。

最后还有 unapplySeq,让你的自定义列表也能用 List(1, 2, _*) 这种 vararg 模式匹配——这是 List、Stream、Vector 都能匹配 vararg 的根本原因。

本篇小结

如果你让我用一句话总结 Scala 语法的设计哲学,我会说:

让编译器多做一点事,让运行时少错一点。

🔜 下篇预告

这一篇我们站在”语法层面”看 Scala——它的所有怪语法,背后都是为了让”潜在的错误”在编译期显形。

但 Scala 真正的灵魂在函数式编程。下一篇我会讲一个我亲身经历的故事:2019 年我写了一个 ETL 任务,Java 8 写法在生产环境 OOM 了,换成 Scala 21 行就稳如老狗——那一刻我才真正理解 “函数是一等公民” 不是噱头,是性能、正确性、可读性的全方位提升。

我们将从 Function1 这个”lambda 的真相”开始,到高阶函数、偏函数、自己手写一个函数式 Set,最后到 Option 和 for 推导式——这是一条”从命令式思维转向函数式思维”的完整路径。

# Scala