第7章 面向对象进阶

继承、抽象类、case class 与 sealed:如何让数据建模既安全又好用,是 Scala 工程代码的常见形态。

进阶第10-11周3 讲9 段示例代码

本章学习目标

  • 会写继承、override、super、抽象类与多态
  • 能用 case class 替代样板式 JavaBean,并知道它自动生成了什么
  • 能用 sealed trait + match 写出穷尽检查的数据建模
  • 理解不可变数据模型在并发与测试中的好处

本章导览

核心内容

7.1

继承与抽象类:override、super 与多态

用抽象类定义骨架,用 override、super 实现多态。

能定义抽象类并让多个子类实现抽象成员能正确使用 override 与 super 改写父类行为能通过父类类型调用子类实现,理解运行时多态

知识点

抽象类负责定义“必须有什么”

  • abstract class 不能 new,可以同时包含抽象成员和已经实现的方法。
  • 抽象成员只有签名,没有方法体,子类负责补全。
  • 需要构造参数或共享字段时,抽象类比 trait 更合适。

override 是明确的重写声明

  • 覆盖父类的具体成员必须写 override,漏写会直接编译报错。
  • 实现抽象成员时 override 可写可不写,显式写出来更清楚。
  • 重载(参数列表不同)不是重写,不要为了消除报错乱加 override。

super 与运行时多态

  • super.m() 调用父类版本,m() 或 this.m() 走运行时类型的方法。
  • 父类方法里调用另一个可覆盖方法时,实际执行的是子类版本。
  • 把子类对象交给父类类型的变量或集合,就是多态:同一调用产生不同结果。

代码示例

▶ 抽象类 Shape 与两个子类的面积计算
SCALA
abstract class Shape {
  def area: Double
  def describe: String = "area=" + f"$area%.2f"
}

class Circle(val r: Double) extends Shape {
  override def area: Double = math.Pi * r * r
}

class Square(val side: Double) extends Shape {
  override def area: Double = side * side
}

object Main {
  def main(args: Array[String]): Unit = {
    val shapes = List[Shape](new Circle(1.0), new Square(2.0))
    shapes.foreach(s => println(s.describe))
  }
}
👀 运行结果
area=3.14
area=4.00
▶ super 调用父类模板,动态派发到子类方法
SCALA
class Animal {
  def speak(): String = "..."
  def intro(): String = s"I say ${speak()}"
}

class Dog extends Animal {
  override def speak(): String = "Woof"
  override def intro(): String = "Dog: " + super.intro()
}

object Main {
  def main(args: Array[String]): Unit = {
    val d = new Dog
    println(d.intro())
    println(d.speak())

    val a: Animal = d
    println(a.speak())
  }
}
👀 运行结果
Dog: I say Woof
Woof
Woof
▶ 表达式树:父类类型统一操作不同子类
SCALA
abstract class Expr {
  def eval: Int
}

class Num(val n: Int) extends Expr {
  override def eval: Int = n
}

class Add(val left: Expr, val right: Expr) extends Expr {
  override def eval: Int = left.eval + right.eval
}

object Main {
  def main(args: Array[String]): Unit = {
    val e: Expr = new Add(new Num(1), new Add(new Num(2), new Num(3)))
    println(e.eval)
  }
}
👀 运行结果
6

常见错误

⚠️ 踩坑与解决
  • new Shape 实例化抽象类
    → abstract class 不能直接创建实例,编译报 class Shape is abstract; cannot be instantiated。;new 它的具体子类,例如 new Circle(1.0)。
  • 覆盖父类具体方法时漏写 override
    → 编译器把同名同参数的方法视为非法重定义,报 method ... needs override modifier。;在 def 前加 override;如果其实想新增重载,就改参数列表。
  • 子类忘了给父类构造器传参
    → 父类没有无参构造器时,子类 extends 后必须提供参数,否则编译报参数不足。;写成 class Dog(name: String) extends Animal(name),先满足父类再初始化自己。

💡 依赖抽象类型而不是具体子类;新增子类时不用改调用方,这才是继承带来的好处。

7.2

case class:样例类与自动生成的方法

用 case class 少写样板代码,获得比较、复制与匹配。

能定义 case class 并使用自动生成的 apply、copy、toString能解释结构相等与引用相等的区别能用 case class 做去重、分组和模式匹配

知识点

一行 case class 自动生成什么

  • 伴生对象自动带 apply,可直接写 Point(1, 2),不必 new。
  • 自动实现 equals、hashCode、toString,还能用 copy 复制并替换字段。
  • 自动带 unapply,可直接 case Point(x, y) 解构。

适合做不可变数据载体

  • 参数默认是 val,创建后字段不能重新赋值。
  • “修改”用 copy:p.copy(y = 5) 返回新对象,原对象完全不变。
  • 样例类可以当 Map 的键,因为内容相同的对象 equals 与 hashCode 也相同。

结构相等与引用相等

  • case class 的 == 比较字段内容,两个不同实例只要字段相同就是 true。
  • 普通 class 不重写 equals 时,== 等价于引用比较,两个 new 出来的对象通常不相等。
  • 需要按内容去重、查找、比较时,优先 case class。

代码示例

▶ case class 的打印、取值、copy 与相等判断
SCALA
case class Point(x: Int, y: Int)

object Main {
  def main(args: Array[String]): Unit = {
    val p = Point(1, 2)
    println(p)
    println(p.x)
    println(p.copy(y = 5))
    println(p == Point(1, 2))
    println(p == Point(1, 3))
    println(p.productArity)
  }
}
👀 运行结果
Point(1,2)
1
Point(1,5)
true
false
2
▶ case class 的模式匹配、copy 与 productIterator
SCALA
case class User(name: String, age: Int)

object Main {
  def main(args: Array[String]): Unit = {
    val u = User("Alice", 20)
    u match {
      case User(n, a) => println(s"$n is $a")
      case _ => println("other")
    }

    val older = u.copy(age = 21)
    println(older)
    println(u.productIterator.toList)
  }
}
👀 运行结果
Alice is 20
User(Alice,21)
List(Alice, 20)
▶ 按内容去重、排序和分组
SCALA
case class Book(title: String, price: Double)

object Main {
  def main(args: Array[String]): Unit = {
    val books = List(Book("Scala", 59.0), Book("Python", 45.0), Book("Scala", 59.0))
    println(books.distinct)
    println(books.distinct.size)
    println(books.distinct.sortBy(_.price))
    println(books.groupBy(_.title).keys.toList.sorted)
  }
}
👀 运行结果
List(Book(Scala,59.0), Book(Python,45.0))
2
List(Book(Python,45.0), Book(Scala,59.0))
List(Python, Scala)

常见错误

⚠️ 踩坑与解决
  • 拿普通 class 和 case class 比 ==,结果不同
    → 普通 class 默认继承 AnyRef 的 equals(引用相等),case class 自动生成按字段比较的 equals。;数据类改成 case class;必须用普通 class 时手动重写 equals 和 hashCode。
  • 把 case class 的字段声明成 var,放进 Set 后再修改
    → equals 与 hashCode 基于字段,对象作为 Set 元素或 Map 键后修改字段会破坏哈希查找。;保持字段为 val,改值用 copy;确实需要可变状态就改用普通 class。
  • 写 p.x = 3 想修改 case class 字段
    → case class 的参数默认是 val,重新赋值编译报 reassignment to val。;用 p.copy(x = 3) 得到新对象,原对象保持不变。

💡 只要这个类型主要在装数据,就用 case class;行为复杂、需要封装可变状态的再写普通类。

7.3

sealed + match:用模式匹配做数据建模

用 sealed 限定类型范围,用穷尽 match 拆解数据。

能用 sealed trait 加 case class、case object 建模有限状态能写出覆盖全部分支的 match 表达式能理解非穷尽匹配的警告与运行时 MatchError

知识点

sealed 把子类型锁在同一文件

  • sealed trait 的直接子类必须写在同一个文件,编译器因此知道所有可能分支。
  • 通常用 case class 携带数据,用 case object 表示无参分支。
  • 有限集合不要开放继承,sealed 能把“还有哪些情况”交给编译器检查。

match 是表达式,不是语句

  • 每个 case 返回一个值,整个 match 可以直接赋给变量或作为方法返回值。
  • case class 的 unapply 负责把字段拆出来,case Circle(r) 就是解构。
  • 守卫 if 写在模式后面,用来补充条件而不再嵌套 if。

穷尽性与可扩展性

  • match 没有覆盖所有子类型时,编译器给出 non-exhaustive match 警告。
  • 警告不是错误,运行时碰到漏掉的分支才会抛 MatchError。
  • 新增一个 case class 后,所有未更新的 match 会立刻出现警告,这是 sealed 的价值。

代码示例

▶ sealed trait Shape 加穷尽 match 计算面积
SCALA
sealed trait Shape
case class Circle(r: Double) extends Shape
case class Rect(w: Double, h: Double) extends Shape

object Main {
  def area(s: Shape): Double = s match {
    case Circle(r) => math.Pi * r * r
    case Rect(w, h) => w * h
  }

  def main(args: Array[String]): Unit = {
    println(f"${area(Circle(1.0))}%.2f")
    println(f"${area(Rect(2.0, 3.0))}%.2f")
  }
}
👀 运行结果
3.14
6.00
▶ 递归代数数据类型:用 match 解释表达式
SCALA
sealed trait Expr
case class Num(n: Int) extends Expr
case class Add(l: Expr, r: Expr) extends Expr
case class Mul(l: Expr, r: Expr) extends Expr

object Main {
  def eval(e: Expr): Int = e match {
    case Num(n) => n
    case Add(l, r) => eval(l) + eval(r)
    case Mul(l, r) => eval(l) * eval(r)
  }

  def main(args: Array[String]): Unit = {
    val e = Add(Num(2), Mul(Num(3), Num(4)))
    println(eval(e))
    println(eval(Add(Num(1), Num(2))))
  }
}
👀 运行结果
14
3
▶ case object 无参分支加带守卫的 match
SCALA
sealed trait Payment
case class Cash(amount: Double) extends Payment
case class Card(amount: Double, last4: String) extends Payment
case class Coupon(code: String) extends Payment
case object Free extends Payment

object Main {
  def describe(p: Payment): String = p match {
    case Cash(a) => f"cash $a%.2f"
    case Card(a, last4) => f"card $a%.2f (****$last4)"
    case Coupon(c) if c.nonEmpty => s"coupon $c"
    case Coupon(_) => "invalid coupon"
    case Free => "free"
  }

  def main(args: Array[String]): Unit = {
    println(describe(Cash(12.5)))
    println(describe(Card(88.0, "1234")))
    println(describe(Coupon("SAVE10")))
    println(describe(Coupon("")))
    println(describe(Free))
  }
}
👀 运行结果
cash 12.50
card 88.00 (****1234)
coupon SAVE10
invalid coupon
free

常见错误

⚠️ 踩坑与解决
  • match 少写一个 sealed 子类分支
    → 编译器只给 non-exhaustive match 警告,运行时如果传入漏掉的类型会抛 scala.MatchError。;补齐分支;不要用 case _ 草草吞掉,除非确实是默认语义。
  • 把 sealed trait 的子类写在另一个文件
    → Scala 2 要求 sealed 的直接子类与 trait 同文件,否则编译报 illegal inheritance from sealed trait。;把子类移到同一个文件,或改用普通 trait 并接受无法检查穷尽性。
  • 模式顺序写反,宽泛的 case 挡住具体 case
    → match 从上往下匹配,case _ 或变量模式放在前面会让后面的分支永远不可达。;把具体模式放前面,通配和默认分支放最后。

💡 Scala 3 可以直接用 enum 定义这类有限集合,Scala 2.13 的等价写法就是 sealed trait 加 case class、case object。