四、Scala深入面向对象:类、对象与伴生关系

简介: 类是蓝图造对象,成员变量方法随手玩;主辅构造器+权限修饰搞封装,单例与伴生对象瞬间懂;main入口、下划线初始化的坑也亮了,读完就能撸代码。

在前几节中,我们学习了 Scala 的基础语法和流程控制。现在,我们将深入探索 Scala 作为一门纯粹的面向对象语言的核心。在 Scala 中,万物皆对象,没有像 Java 那样的原始类型和静态成员的区分。本节将重点介绍如何定义对象的蓝图,以及如何使用 Scala 独特的单例对象和伴生机制。

思维导图

image.png

image.png

一、类和对象

创建对象模板或蓝图。它 定义一类事物共同的 属性 (成员变量)行为 (成员方法)
对象,也称为 实例,是 根据类这个蓝图 创建出来具体实体

基本语法:
scala class ClassName { // 成员变量 (字段) // 成员方法 } // 使用 new 关键字创建类的实例 (对象) val objectName = new ClassName()

## 二、成员变量与成员方法

1. 定义和访问成员变量

在类中定义的变量或常量,称为成员变量或字段

class Person {
  // 定义一个可变的成员变量 name
  var name: String = "Unknown"
  // 定义一个不可变的成员变量 (常量) age
  val age: Int = 0
}

// 创建 Person 类的对象
val person1 = new Person()

// 访问和修改成员变量
println(person1.name) // 输出: Unknown
person1.name = "Alice"
println(person1.name) // 输出: Alice
// person1.age = 25 // 这行会编译错误,因为 age 是 val (常量)

2. 使用下划线 _ 初始化成员变量

在 Scala 中,var 类型的成员变量必须被初始化。如果你暂时不想给它一个有意义的初始值,可以使用下划线 _ 作为占位符,Scala 会为其赋予该类型的默认零值

类型 默认零值
数值类型 (Int, Double, etc.) 0
Boolean false
Char \u0000
所有引用类型 (AnyRef) null

代码案例:

class Student {
  var name: String = _ // 初始化为 null
  var age: Int = _     // 初始化为 0
  var isMale: Boolean = _ // 初始化为 false
}

val student1 = new Student()
println(s"Name: ${student1.name}, Age: ${student1.age}, Is Male: ${student1.isMale}")
// 输出: Name: null, Age: 0, Is Male: false
注意: 这种 下划线初始化的方式在现代 Scala 编程中 使用得越来越少,因为它 容易引入 NullPointerException。更 推荐的做法是 提供一个有意义的 初始值,或者使用 Option 类型来表示 可能缺失的值。

### 3. 定义和访问成员方法
成员方法定义了 对象的行为
scala class Circle { val radius: Double = 5.0 // 定义一个计算面积的方法 def getArea(): Double = { Math.PI * radius * radius } } val c1 = new Circle() // 调用方法 val area = c1.getArea() println(s"The area of the circle is: $area")

## 三、访问权限修饰符
Scala 通过 访问权限修饰符控制成员的 可见性,以 实现封装

| 修饰符 | 描述 |
| :--- | :--- |
| (无修饰符) | 默认为 public,在 任何地方都可以访问。 |
| private | 私有成员,只能在 定义该成员的类或其 伴生对象内部访问。 |
| protected | 受保护成员,只能在 定义该成员的类及其 子类中访问。 |
| private[this] | 对象私有,比 private 更严格。只能在 当前对象实例中访问, 即使是同一个类的 其他对象不能访问。 |
| private[包名] | 包私有,成员的 可见性限定在指定的 及其 子包中。 |

代码案例:
scala class Animal { private var privateName = "Secret" protected var protectedAge = 2 def printInfo(): Unit = { println(s"This is a private name: $privateName") // 类内部可以访问 private } } class Dog extends Animal { def getAge(): Int = { // println(privateName) // 错误:子类不能访问父类的 private 成员 protectedAge // 正确:子类可以访问父类的 protected 成员 } } val animal = new Animal() // println(animal.privateName) // 错误:外部不能访问 private 成员 // println(animal.protectedAge) // 错误:外部不能访问 protected 成员 animal.printInfo() val dog = new Dog() println(s"Dog's age: ${dog.getAge()}")

## 四、类的构造器
构造器是在 创建对象自动调用特殊方法,用于 初始化对象

### 1. 主构造器
> 主构造器 直接定义类名之后参数列表中。
主构造器会 执行类定义所有语句
如果主构造器的 参数没有用 valvar 声明,它将是 一个私有不可变字段仅在类内部可见。
如果用 valvar 声明,该参数会 成为一个 公共成员变量

代码案例:
scala // name 和 age 是主构造器的参数,并成为公共的不可变/可变成员变量 class Employee(val name: String, var age: Int) { // 这部分代码是主构造器的一部分,在 new Employee(...) 时执行 println(s"New employee created: $name, age $age") // 一个普通的成员方法 def work(): Unit = println(s"$name is working.") } val emp1 = new Employee("Alice", 30) println(emp1.name) // 可以访问 emp1.age = 31 // 可以修改

### 2. 辅助构造器
> 一个类可以有 多个辅助构造器。
辅助构造器的 名称必须是 this
关键规则:每个辅助构造器的 第一行必须 直接或间接地调用 主构造器 (或 另一个已定义的 辅助构造器)。

代码案例:
scala class Car(val brand: String, val year: Int) { var color: String = "White" // 辅助构造器一:提供品牌、年份和颜色 def this(brand: String, year: Int, color: String) { this(brand, year) // 必须先调用主构造器 this.color = color } // 辅助构造器二:只提供品牌 def this(brand: String) { this(brand, 2024) // 调用主构造器,年份默认为 2024 } } val car1 = new Car("Toyota", 2023) val car2 = new Car("BMW", 2024, "Black") val car3 = new Car("Ford") println(s"${car3.brand} color is ${car3.color} and year is ${car3.year}")

## 五、单例对象、main方法与伴生对象

1. 单例对象

在 Scala 中,使用 object 关键字定义的不是类,而是一个单例对象——它是一个全局唯一实例
单例对象不能被 new,它的所有成员都类似于 Java 中的静态成员

代码案例:

object Logger {
  var level: String = "INFO"
  def log(message: String): Unit = {
    println(s"[$level] $message")
  }
}

// 直接通过对象名访问成员
Logger.level = "DEBUG"
Logger.log("This is a debug message.")

2. main 方法

Scala 应用程序的入口点是一个名为 main方法,它必须定义在一个单例对象中。

两种实现方式:

  1. 标准 main 方法:
    object MyApp {
    def main(args: Array[String]): Unit = {
    println("Hello from the main method!")
    }
    }
    
  2. 继承 App 特质 (更简洁):
    object MyApp extends App {
    // 这里的代码会直接作为 main 方法体执行
    println("Hello from the App trait!")
    // 命令行参数可以通过 args 变量访问
    if (args.length > 0) {
    println(s"First argument: ${args(0)}")
    }
    }
    

3. 伴生对象

当一个单例对象与一个具有相同的名称,并且它们定义在同一个源文件中时,这个对象被称为该类的伴生对象,该类被称为该对象的伴生类

核心特性:
伴生类和伴生对象可以互相访问对方的私有 (private) 成员
常见用途

在伴生对象中放置类似于 Java 静态方法工具方法
在伴生对象中定义工厂方法 (特别是名为 apply 的方法),用于创建伴生类实例隐藏 new 关键字。

代码案例:

// 伴生类
class User private (val id: Int, val name: String) { // 主构造器设为 private
  private def secretMethod(): String = s"User $name has a secret."

  def greet(): Unit = {
    // 访问伴生对象的私有成员
    println(User.defaultGreeting + ", " + name)
  }
}

// 伴生对象
object User {
  private val defaultGreeting = "Welcome"

  // 工厂方法,可以访问 User 类的私有构造器
  def apply(name: String): User = {
    val newId = scala.util.Random.nextInt(1000)
    new User(newId, name)
  }

  def printSecret(user: User): Unit = {
    // 访问 User 实例的私有方法
    println(user.secretMethod())
  }
}

// 使用伴生对象的 apply 工厂方法创建实例 (无需 new)
val user1 = User("Bob")
user1.greet()
User.printSecret(user1)

// val user2 = new User(10, "Charlie") // 错误:构造器是私有的

六、综合案例

在 Scala 中,工具类 (包含纯粹的功能方法,不维护状态) 通常被实现单例对象

代码案例:一个简单的字符串工具对象

object StringUtils {
  /**
   * 判断字符串是否为空 (null 或 "")
   * @param s 待检查的字符串
   * @return 如果为空则返回 true,否则返回 false
   */
  def isEmpty(s: String): Boolean = {
    s == null || s.trim.isEmpty
  }

  /**
   * 将字符串首字母大写
   * @param s 待转换的字符串
   * @return 转换后的字符串
   */
  def capitalize(s: String): String = {
    if (isEmpty(s)) s else s.substring(0, 1).toUpperCase + s.substring(1)
  }
}

// 在另一个对象中(例如主程序)使用工具类
object MainApp extends App {
  val str1 = "hello scala"
  val str2 = "  "
  val str3: String = null

  println(s"'${str1}' is empty? ${StringUtils.isEmpty(str1)}")
  println(s"'${str2}' is empty? ${StringUtils.isEmpty(str2)}")
  println(s"Capitalized '${str1}': ${StringUtils.capitalize(str1)}")
}

练习题

题目一:简单类定义
定义一个 Book 类,包含两个不可变的成员变量:title (String) 和 author (String)。

题目二:创建和访问对象
创建 Book 类的一个实例,title 为 "Programming in Scala",author 为 "Martin Odersky"。然后打印出这本书的标题。

题目三:成员方法
Book 类添加一个名为 getInfo 的方法,该方法返回一个格式为 "Title by Author" 的字符串。

题目四:下划线初始化
定义一个 Movie 类,包含一个可变的成员变量 director (String),使用下划线 _ 进行默认初始化。创建实例后打印出 director 的初始值。

题目五:主构造器
定义一个 Laptop 类,其主构造器接收 brand (String, 不可变) 和 ramInGB (Int, 可变) 两个参数,并将它们直接定义为公共成员变量

题目六:辅助构造器
Laptop 类添加一个辅助构造器,该构造器只接收 brand 参数,并默认将 ramInGB 设置为 8。

题目七:访问修饰符
定义一个 BankAccount 类,其中 balance (Double) 是私有的。提供一个公共deposit(amount: Double) 方法和一个公共getBalance() 方法来访问余额。

题目八:单例对象
创建一个名为 MathConstants单例对象,在其中定义两个常量:PI (值为 3.14159) 和 E (值为 2.71828)。

题目九:main 方法
创建一个名为 EntryPoint 的单例对象,并继承 App 特质,在其中打印 "Scala application started!"。

题目十:伴生对象与私有成员
定义一个 Circle 类,其主构造器接收一个私有radius (Double) 参数。然后,为其创建一个伴生对象,该对象有一个 calculateArea(c: Circle) 方法,可以计算并返回给定 Circle 实例的面积 (面积 = PI r r)。

题目十一:apply 工厂方法
Circle 的伴生对象中添加一个 apply 方法,该方法接收一个 radius 参数,并返回一个新的 Circle 实例。这样就可以使用 Circle(5.0) 来创建对象。

题目十二:工具类方法
在之前的 StringUtils 单例对象中,添加一个名为 reverse 的方法,接收一个字符串并返回其反转后的结果。

题目十三:主构造器代码块
修改 Employee 类的定义,在其主构造器代码块中添加一条逻辑:检查传入的 age 是否小于18,如果是,则打印一条警告信息 "Warning: Employee age is below 18."。

题目十四:对象私有成员 private[this]
定义一个 Point 类,包含 xy 两个坐标。再定义一个 isSameAs(other: Point) 方法,比较当前点是否与另一个点相同。然后,修改 xyprivate[this],并观察 isSameAs 方法是否还能正常编译。如果不能,解释原因。

题目十五:综合案例
创建一个 Counter 类,它有一个私有的、可变count 变量,初始值为0。类中提供 increment() 方法 (每次将count加1) 和 current() 方法 (返回当前count值)。为其创建一个伴生对象,提供一个 apply 方法,允许通过 Counter() 创建新实例。

答案与解析

答案一:

class Book(val title: String, val author: String)

解析: 在主构造器参数前使用 val 是将参数直接定义为公共不可变成员变量的简洁语法。

答案二:

val myBook = new Book("Programming in Scala", "Martin Odersky")
println(myBook.title)

解析: 使用 new 关键字和类名来创建对象,通过 . 操作符访问其成员。

答案三:

class Book(val title: String, val author: String) {
  def getInfo(): String = {
    s"$title by $author"
  }
}
val myBook = new Book("A Brief History of Time", "Stephen Hawking")
println(myBook.getInfo())

解析: def 用于在类中定义方法。s"" 字符串插值器用于方便地格式化字符串。

答案四:

class Movie {
  var director: String = _
}
val m = new Movie()
println(m.director) // 输出: null

解析: String 是引用类型 (AnyRef),其默认零值是 null

答案五:

class Laptop(val brand: String, var ramInGB: Int)

解析: val 使 brand 成为不可变成员,var 使 ramInGB 成为可变成员。

答案六:

class Laptop(val brand: String, var ramInGB: Int) {
  // 辅助构造器
  def this(brand: String) {
    this(brand, 8) // 调用主构造器
  }
}
val defaultLaptop = new Laptop("Dell")
println(s"${defaultLaptop.brand} has ${defaultLaptop.ramInGB}GB RAM")

解析: 辅助构造器 def this(...) 必须在其第一行调用另一个构造器。

答案七:

class BankAccount {
  private var balance: Double = 0.0

  def deposit(amount: Double): Unit = {
    if (amount > 0) balance += amount
  }

  def getBalance(): Double = {
    balance
  }
}

解析: private 关键字将 balance 的访问权限限制在类内部,外部只能通过公共的 depositgetBalance 方法进行交互,实现了封装。

答案八:

object MathConstants {
  val PI = 3.14159
  val E = 2.71828
}
println(MathConstants.PI)

解析: object 关键字创建了一个全局唯一的单例对象。

答案九:

object EntryPoint extends App {
  println("Scala application started!")
}

解析: 继承 App 特质是创建可执行应用程序的最简洁方式,对象体内的代码会自动成为 main 方法的内容。

答案十:

class Circle private (val radius: Double)

object Circle {
  def calculateArea(c: Circle): Double = {
    // 可以访问 Circle 的私有成员 radius
    Math.PI * c.radius * c.radius
  }
}

解析: 伴生对象 Circle 可以访问伴生类 Circleprivate 成员 radius

答案十一:

class Circle private (val radius: Double)

object Circle {
  def apply(radius: Double): Circle = {
    new Circle(radius)
  }
  // ... calculateArea 方法 ...
}

val myCircle = Circle(5.0) // 无需 new,直接调用 apply 方法

解析: apply 方法是一个特殊的语法糖,允许你像调用函数一样创建对象。

答案十二:

object StringUtils {
  // ... isEmpty, capitalize 方法 ...
  def reverse(s: String): String = {
    if (s == null) s else s.reverse
  }
}
println(StringUtils.reverse("scala"))
```*   **解析:** `String` 类型自带 `.reverse` 方法,可以直接使用。

**答案十三:**
```scala
class Employee(val name: String, var age: Int) {
  if (age < 18) {
    println(s"Warning: Employee $name's age is below 18.")
  }

  def work(): Unit = println(s"$name is working.")
}
val youngEmployee = new Employee("Tom", 17)

解析: 类定义体中、成员方法之外的代码都属于主构造器的一部分,会在对象创建时执行。

答案十四:

class Point(private[this] val x: Int, private[this] val y: Int) {
  def isSameAs(other: Point): Boolean = {
    // this.x == other.x // 这行代码会编译错误
    false // 仅为使代码完整
  }
}

解析: 代码无法正常编译。因为 private[this]对象私有的,意味着只有当前对象 (this) 才能访问 xy。在 isSameAs 方法中,other.x 尝试访问另一个Point 对象的 x 字段,这是不被允许的。如果使用 private,则可以访问,因为 private 允许同一类的不同实例之间互相访问私有成员。

答案十五:

class Counter private {
  private var count: Int = 0
  def increment(): Unit = {
    count += 1
  }
  def current(): Int = {
    count
  }
}

object Counter {
  def apply(): Counter = new Counter()
}

val c1 = Counter()
c1.increment()
c1.increment()
println(c1.current()) // 输出: 2

解析: 这个例子结合了私有构造器、私有成员、公共方法和伴生对象的 apply 工厂方法,是一个典型的Scala封装模式。将构造器设为私有,强制用户通过伴生对象的工厂方法来创建实例。

目录
相关文章
|
6月前
|
存储 安全 Java
synchronized 锁升级
JDK 6 引入的 synchronized 锁升级机制,通过偏向锁、轻量级锁和重量级锁的动态切换,优化了多线程同步性能。该机制根据竞争情况逐步升级锁状态,减少线程阻塞和系统调用开销,从而提升并发效率。
263 0
|
2月前
|
Web App开发 网络协议 Java
Windows 终端命令详解:PowerShell 初学者指南
Windows 终端是一个命令行工具,允许用户通过文本命令与系统交互,执行文件管理、系统配置和网络诊断等操作。PowerShell 是 Windows 终端的现代版本,相比传统的命令提示符(CMD),它功能更强大,支持脚本编写和复杂任务处理。本文将以 PowerShell 为主,带你从零开始学习。
504 6
|
2月前
|
存储 监控 Shell
四、Portainer图形化管理实战与Docker镜像原理
如果觉得命令行繁琐,可以试试Portainer这个图形化管理工具,让你在网页上点点鼠标就能轻松管理容器和镜像。安装它只需要一条docker run命令,非常方便。 同时,要理解Docker为何如此高效,关键在于它的镜像原理:镜像像洋-葱一样分层,启动容器时只在外面加一层可写的“外皮”。所有改动都发生在这层“外皮”上,这就是容器启动快、占用空间小的秘诀。
390 4
|
2月前
|
关系型数据库 MySQL Shell
三、Docker常用命令
把 Docker 玩转,就像一个建筑师,需要掌握两套核心工具:一套用来管理你的“图纸”(镜像),另一套用来管理你用图纸盖好的“房子”(容器)。
297 2
|
3月前
|
运维 开发者 Docker
一、Docker:一场颠覆应用部署与运维的容器革命
Docker的出现,就是为了解决“在我电脑上能跑”这个老大难问题。它像个魔法集装箱,把你的程序和它需要的所有东西(比如库、配置)都打包好,这样无论在哪运行,环境都一模一样。理解它很简单,就三个核心玩意儿:镜像是程序的“安装包”,容器是跑起来的程序,而仓库就是存放和分享这些“安装包”的地方。
441 6
|
4月前
|
Linux 应用服务中间件 Shell
二、Linux文本处理与文件操作核心命令
熟悉了Linux的基本“行走”后,就该拿起真正的“工具”干活了。用grep这个“放大镜”在文件里搜索内容,用find这个“探测器”在系统中寻找文件,再用tar把东西打包带走。最关键的是要学会使用管道符|,它像一条流水线,能把这些命令串联起来,让简单工具组合出强大的功能,比如 ps -ef | grep 'nginx' 就能快速找出nginx进程。
515 1
二、Linux文本处理与文件操作核心命令
|
2月前
|
存储 关系型数据库 MySQL
五、Docker 核心技术:容器数据持久化之数据卷
别把重要数据直接放进Docker容器里,因为容器就像一辆“临租车”,车一还(容器被删除),落在里面的东西就全没了。正确的做法是使用数据卷 (Volume),它好比一个属于你自己的、可插拔的“移动硬盘”。你可以把这个“硬盘”(具名数据卷)挂载到任何一辆“临租车”(容器)上使用。这样一来,就算车换了,你的数据也安然无恙,完美解决了数据库等应用的数据持久化问题。
333 32
五、Docker 核心技术:容器数据持久化之数据卷
|
7月前
|
安全 开发工具 git
如何使用分支回滚代码?
如何使用分支回滚代码?
671 57
|
存储 缓存 安全
HashMap VS TreeMap:谁才是Java Map界的王者?
HashMap VS TreeMap:谁才是Java Map界的王者?
612 2
|
4月前
|
Shell Linux
九、Linux Shell脚本:运算符与表达式
Shell脚本里的变量就像一个个贴着标签的“箱子”。装东西(赋值)时,=两边千万不能有空格。用单引号''装进去的东西会原封不动,用双引号""则会让里面的$变量先“变身”再装箱。默认箱子只能在当前“房间”(Shell进程)用,想让隔壁房间(子进程)也能看到,就得给箱子盖个export的“出口”戳。此外,Shell还自带了$?(上条命令的成绩单)和$1(别人递进来的第一个包裹)等许多特殊箱子,非常有用。
398 0