Scala的类和对象系统比Java更简洁、更强大。主构造器、伴生对象、case class、继承与多态——理解这些核心概念,是掌握Scala面向对象编程的关键。
如果你从Java转到Scala,定义第一个类时可能会感到惊喜:Java需要几十行样板代码的POJO,在Scala里可能只需要一行。但惊喜之后往往是困惑——主构造器和辅助构造器有什么区别?object和class是什么关系?case class为什么这么神奇?
Scala的类和对象系统不是Java的简单简化,而是一套重新设计的面向对象体系。它融合了Java的类模型、函数式编程的不可变思想,以及模式匹配的解构能力。Spark的Dataset、Akka的Actor、Cats的类型类——这些框架的核心抽象都建立在Scala的类和对象系统之上。
这篇文章从七个层面拆解Scala的类和对象,帮你建立完整的知识体系。

01 类的定义与构造器
主构造器
Scala最核心的设计是:主构造器的参数直接写在类名后面,不需要单独定义构造方法。
// 主构造器参数直接写在类名后
class Person(val name: String, var age: Int) {
// 类体
def greet(): String = s"Hello, I'm $name, $age years old"
}
val p = new Person("Alice", 25)
p.name // "Alice" — val参数自动生成getter
p.age = 26 // OK — var参数自动生成getter和setter
p.greet() // "Hello, I'm Alice, 26 years old"
主构造器参数的修饰符决定了生成的成员:
| 修饰符 | 生成的成员 | 可见性 |
|---|---|---|
val name: String |
私有字段 + public getter | 只读 |
var age: Int |
私有字段 + public getter/setter | 读写 |
name: String(无修饰符) |
仅构造参数,不生成字段 | 类体内可用,外部不可访问 |
private val name: String |
私有字段 + 私有getter | 仅类内部和伴生对象可访问 |
// 无修饰符的参数:只在构造时使用,不生成字段
class Logger(prefix: String) {
def log(msg: String): Unit = println(s"[$prefix] $msg")
// prefix在类体内可用,但外部无法访问
}
val logger = new Logger("INFO")
logger.log("hello") // "[INFO] hello"
// logger.prefix // 编译错误:value prefix is not a member
构造器执行顺序
主构造器的执行顺序是:先执行父类构造器,再按声明顺序执行类体中的语句。类体中的所有语句都是主构造器的一部分。
class Person(val name: String) {
println("构造Person开始") // 这是主构造器的一部分
val upperName = name.toUpperCase
println("构造Person结束")
}
// 输出:
// 构造Person开始
// 构造Person结束
val p = new Person("alice")
辅助构造器
辅助构造器用this定义,第一行必须调用主构造器或其他辅助构造器:
class Person(val name: String, var age: Int) {
// 辅助构造器1:只传name
def this(name: String) = this(name, 0)
// 辅助构造器2:无参
def this() = this("Unknown")
def info: String = s"$name ($age)"
}
new Person("Alice", 25).info // "Alice (25)"
new Person("Bob").info // "Bob (0)"
new Person().info // "Unknown (0)"
对Java开发者的启示:Java的构造器是独立的方法,可以有任意逻辑。Scala的主构造器与类定义融合,辅助构造器必须委托给主构造器——这种设计确保了构造逻辑的单一入口,减少了初始化顺序的bug。
02 字段、方法与访问修饰符
字段的getter和setter
Scala的字段访问不是Java式的getName()/setName(),而是统一访问原则:字段访问和方法调用语法一致。
class Person(var name: String)
val p = new Person("Alice")
p.name // 调用getter,等价于p.name_$eq
p.name = "Bob" // 调用setter,等价于p.name_$eq("Bob")
// 自定义getter/setter
class Person {
private var _name = ""
def name: String = _name // 自定义getter
def name_=(newName: String): Unit = { // 自定义setter
require(newName.nonEmpty, "name不能为空")
_name = newName
}
}
val p = new Person
p.name = "Alice" // 调用name_=
println(p.name) // 调用name
方法定义
class Calculator {
// 有返回值的方法
def add(a: Int, b: Int): Int = a + b
// 无返回值的过程(Unit)
def printResult(result: Int): Unit = {
println(s"Result: $result")
}
// 无参方法:可以省略括号
def version: String = "1.0"
// 多参数列表
def foldLeft[B](z: B)(op: (B, Int) => B): B = {
var acc = z
// ...
acc
}
// 默认参数
def greet(name: String, prefix: String = "Hello"): String =
s"$prefix, $name!"
}
访问修饰符
Scala的访问修饰符比Java更精细:
class Example {
val publicField = 1 // 默认public
private val privateField = 2 // 仅类内部可访问
protected val protectedField = 3 // 子类可访问
// 包级私有:指定包内可访问
private[scala] val packagePrivate = 4
// 伴生对象可访问private成员
private val secret = "xyz"
}
object Example {
def reveal(e: Example): String = e.secret // OK,伴生对象可访问
}
| 修饰符 | Java | Scala |
|---|---|---|
| public | 默认 | 显式(默认就是public) |
| private | 类内部 | 类内部 + 伴生对象 |
| protected | 同包 + 子类 | 仅子类(更严格) |
| 包级私有 | 无修饰符 | private[包名] |
03 单例对象与伴生对象
object:Scala的单例
Scala没有static关键字,用object定义单例对象:
object Config {
val appName = "MyApp"
val version = "1.0"
def show(): Unit = println(s"$appName v$version")
}
Config.appName // "MyApp"
Config.show() // "MyApp v1.0"
object是延迟初始化的:首次访问时才创建实例,且整个JVM中只有一个实例。
伴生对象
与类同名的object称为伴生对象,类称为伴生类。它们可以互相访问私有成员:
class User(val name: String) {
private val secret = "xyz"
}
object User {
// 工厂方法:apply让你可以不用new
def apply(name: String): User = new User(name)
// 可以访问伴生类的私有成员
def revealSecret(user: User): String = user.secret
}
val u1 = new User("Alice") // 传统方式
val u2 = User("Bob") // 调用apply,不需要new
User.revealSecret(u1) // "xyz"
apply与unapply
伴生对象最常用的两个方法是apply和unapply:
// apply:工厂方法,用于创建对象
object User {
def apply(name: String, age: Int): User = new User(name, age)
}
val u = User("Alice", 25) // 等价于User.apply("Alice", 25)
// unapply:解构方法,用于模式匹配
object User {
def unapply(user: User): Option[(String, Int)] =
Some((user.name, user.age))
}
u match {
case User(name, age) => println(s"$name is $age") // 调用unapply
}
对Java开发者的启示:Java的静态方法和静态字段属于类,但Scala把它们移到了伴生对象中。这种设计的好处是:静态成员也是对象的成员,可以继承、可以混入trait、可以作为参数传递。
04 样例类(case class)深度解析
样例类是Scala最实用的语法特性之一。用case修饰的类,编译器会自动生成大量代码:

case class Point(x: Int, y: Int)
这一行代码,编译器自动生成了:
- 构造参数为
val:所有参数默认是val,不可变 equals和hashCode:基于字段值的结构相等toString:可读的字符串表示copy方法:复制并修改部分字段- 伴生对象的
apply:不需要new - 伴生对象的
unapply:支持模式匹配解构
val p1 = Point(1, 2) // 不需要new,调用apply
val p2 = Point(1, 2)
p1 == p2 // true,结构相等
p1.toString // "Point(1,2)"
val p3 = p1.copy(y = 5) // Point(1, 5),复制并修改y
val p4 = p1.copy() // Point(1, 2),完全复制
// 模式匹配解构
p1 match {
case Point(x, y) => println(s"($x, $y)") // 调用unapply
}
样例类的不可变性
样例类的参数默认是val,这意味着创建后不可修改。不可变性是函数式编程的基石:
case class User(name: String, age: Int)
val user = User("Alice", 25)
// user.age = 26 // 编译错误:reassignment to val
// 正确做法:用copy创建新对象
val olderUser = user.copy(age = 26)
样例类与密封特质(ADT)
样例类配合密封特质(sealed trait)可以定义代数数据类型(ADT):
// 密封特质:所有子类必须在同一文件中定义
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Square(side: Double) extends Shape
// 编译器可以检查匹配是否穷尽
def area(shape: Shape): Double = shape match {
case Circle(r) => Math.PI * r * r
case Rectangle(w, h) => w * h
case Square(s) => s * s
// 如果漏掉某个case,编译器会发出警告
}
样例类的限制
// 样例类不能被其他类继承(Scala 2.11+)
// case class Point3D(x: Int, y: Int, z: Int) extends Point(x, y) // 错误
// 样例类的参数默认是val,不能是var(虽然可以显式声明var,但不推荐)
case class MutablePoint(var x: Int, var y: Int) // 合法但不推荐
对Java开发者的启示:Java 14引入了record,本质上就是Scala case class的借鉴。但Scala的case class早在2004年就存在了,而且与模式匹配、密封特质、for推导式深度集成,能力远超Java record。
05 继承与多态
类继承
Scala用extends关键字继承类,用override重写方法:
class Animal(val name: String) {
def speak(): String = "..."
def describe(): String = s"Animal: $name"
}
class Dog(name: String, val breed: String) extends Animal(name) {
override def speak(): String = "Woof!"
override def describe(): String = s"Dog: $name ($breed)"
}
val dog: Animal = new Dog("Rex", "Labrador")
dog.speak() // "Woof!" — 动态分派
dog.describe() // "Dog: Rex (Labrador)"
override规则
Scala要求重写方法必须显式使用override,这比Java更安全:
class Base {
def greet(): String = "Hello"
}
class Derived extends Base {
// override def greet(): String = "Hi" // 正确
// def greet(): String = "Hi" // 编译错误:需要override
// override def greets(): String = "Hi" // 编译错误:父类没有greets
}
final与抽象类
// final类:不能被继承
final class Utility {
def help(): String = "help"
}
// final方法:不能被重写
class Base {
final def cannotOverride(): String = "fixed"
}
// 抽象类:可以有抽象成员
abstract class Shape {
def area: Double // 抽象方法,无实现
val name: String // 抽象val
}
class Circle(radius: Double) extends Shape {
def area: Double = Math.PI * radius * radius // 实现抽象方法
val name: String = "Circle" // 实现抽象val
}
构造器与继承
继承时的构造器执行顺序:父类构造器 → 子类构造器。这意味着子类构造器中不能依赖子类字段(因为父类构造器执行时子类字段还未初始化):
class Base {
val value = 10
println(s"Base: $value") // Base: 10
}
class Derived extends Base {
override val value = 20
println(s"Derived: $value") // Derived: 20
}
new Derived
// 输出:
// Base: 10 ← 父类构造器执行时,子类的value还未覆盖
// Derived: 20
这是Scala(和Java)中常见的坑:父类构造器中调用可被重写的方法或val,可能得到未初始化的值。
类型检查与转换
val animal: Animal = new Dog("Rex", "Labrador")
// 类型检查
animal.isInstanceOf[Dog] // true
animal.isInstanceOf[Cat] // false
// 类型转换
val dog = animal.asInstanceOf[Dog]
dog.breed // "Labrador"
// 更安全的方式:模式匹配
animal match {
case d: Dog => println(s"Dog: ${d.breed}")
case c: Cat => println(s"Cat: ${c.color}")
case _ => println("Other animal")
}
06 内部类与路径依赖类型
Scala的内部类与Java有一个关键区别:Scala的内部类与外部类的实例绑定,而不是与外部类本身绑定。
class Network {
class Node(val name: String) {
def connect(other: Node): Unit =
println(s"$name connects to ${other.name}")
}
}
val net1 = new Network
val net2 = new Network
val node1 = new net1.Node("A")
val node2 = new net1.Node("B")
val node3 = new net2.Node("C")
node1.connect(node2) // OK,同一个Network实例
// node1.connect(node3) // 编译错误:类型不兼容
// net1.Node 与 net2.Node 是不同的类型!
这就是路径依赖类型(path-dependent types):net1.Node和net2.Node是不同的类型。
如果需要让内部类在所有外部实例间共享,可以用#表示类型投影:
class Network {
class Node(val name: String)
}
// 类型投影:Network#Node表示任意Network实例的Node
def connect(n1: Network#Node, n2: Network#Node): Unit =
println(s"${n1.name} connects to ${n2.name}")
val net1 = new Network
val net2 = new Network
connect(new net1.Node("A"), new net2.Node("B")) // OK
对象内部类
object也可以包含内部类:
object Database {
case class Connection(url: String)
def connect(url: String): Connection = Connection(url)
}
val conn = Database.connect("jdbc:mysql://localhost")
conn.url // "jdbc:mysql://localhost"
07 与Java类和对象的对比
理解Scala和Java的类系统差异,能帮助你避免常见的迁移陷阱。
核心差异对比
| 维度 | Java | Scala |
|---|---|---|
| 构造器 | 独立的构造方法 | 主构造器 + 辅助构造器 |
| 静态成员 | static关键字 |
object单例对象 |
| 字段访问 | getName()/setName() |
统一访问原则 |
| 不可变类 | 手动final + 手动equals | case class一行搞定 |
| 内部类 | 与外部类绑定 | 与外部类实例绑定 |
| 访问修饰符 | public/private/protected | 更精细,支持private[包] |
| 枚举 | enum关键字 |
sealed trait + case object |
| 相等比较 | ==是引用比较 |
==是值比较,eq是引用比较 |
Java POJO vs Scala case class
// Java:需要几十行样板代码
public class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name; }
public int getAge() {
return age; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person person = (Person) o;
return age == person.age &&
Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
}
// Scala:一行搞定
case class Person(name: String, age: Int)
Java静态方法 vs Scala伴生对象
// Java:静态方法属于类
public class User {
private String name;
public User(String name) {
this.name = name; }
public static User create(String name) {
return new User(name);
}
}
User.create("Alice");
// Scala:工厂方法在伴生对象中
class User(val name: String)
object User {
def apply(name: String): User = new User(name)
}
User("Alice") // 调用伴生对象的apply
Scala调用Java类
Scala可以无缝调用Java类,但有一些注意事项:
// Scala调用Java的ArrayList
import java.util.ArrayList
val list = new ArrayList[String]()
list.add("hello")
list.get(0) // "hello"
// Java的getter/setter可以像Scala字段一样访问
// Java类:public String getName() { return name; }
val javaObj = new JavaClass()
javaObj.getName // 可以省略括号
// javaObj.name // 不行,Java没有统一访问原则
// Java的静态方法调用
import java.util.UUID
UUID.randomUUID() // 直接调用
Scala类被Java调用
Scala编译后的类可以被Java调用,但伴生对象会变成两个类:
// Scala源码
class User(val name: String)
object User {
def apply(name: String): User = new User(name)
}
// Java调用
User user = new User("Alice"); // 调用Scala类
String name = user.name(); // getter变成name()方法
// 伴生对象变成User$.MODULE$
User user2 = User$.MODULE$.apply("Bob");
// 或者如果生成了静态转发:
User user3 = User.apply("Charlie"); // 需要@scala.annotation.static
写在最后:类和对象是抽象的基石
Scala的类和对象系统看起来比Java复杂——主构造器、伴生对象、case class、路径依赖类型——但这些复杂性是为了用更少的代码表达更多的意图。
- 主构造器让类定义更简洁,消除了构造器与字段的重复声明
- 伴生对象让静态成员成为一等公民,可以继承、可以混入trait
- case class让不可变数据类成为默认选择,消除了几十行样板代码
- 密封特质+样例类让代数数据类型成为可能,编译器可以检查匹配穷尽性
- 路径依赖类型让内部类与外部实例绑定,表达更精确的类型关系
这些特性不是孤立的,它们共同支撑了Scala的面向对象+函数式混合范式。Spark的Dataset[T]为什么类型安全?因为T是一个case class,编译器可以验证字段和类型。Akka的消息为什么可以模式匹配?因为消息是case class,自动生成了unapply。Cats的类型类为什么可以隐式解析?因为伴生对象是隐式解析的关键位置。
学习建议:不要试图一次记住所有规则。先从主构造器和case class开始,写一些数据类。当你需要工厂方法时,自然会学到伴生对象。当你需要继承时,再学override和抽象类。类和对象是Scala编程的基石,但它们是工具,不是目的——用它们解决实际问题,才是最好的学习方式。
从Java到Scala的类系统迁移,不是学习"另一种类的写法",而是学习一种"更简洁、更安全、更表达力的抽象方式"。当你习惯了case class和伴生对象,再回头看Java的POJO和静态方法,你会发现:原来那些样板代码,真的可以不用写。