Scala 3 — 書籍

工具

語言

Scala 提供許多不同的建構,讓我們可以模擬我們周遭的世界

  • 類別
  • 物件
  • 伴隨物件
  • 特質
  • 抽象類別
  • 列舉 僅限 Scala 3
  • 案例類別
  • 案例物件

本節簡要介紹這些語言功能。

類別

與其他語言一樣,Scala 中的類別是建立物件實例的範本。以下是類別的一些範例

class Person(var name: String, var vocation: String)
class Book(var title: String, var author: String, var year: Int)
class Movie(var name: String, var director: String, var year: Int)

這些範例顯示 Scala 有非常輕量的方式來宣告類別。

我們範例類別的所有參數都被定義為 var 欄位,這表示它們是可變的:您可以讀取它們,也可以修改它們。如果您希望它們是不可變的(唯讀),請將它們建立為 val 欄位,或使用案例類別。

在 Scala 3 之前,您使用 new 關鍵字來建立類別的新實例

val p = new Person("Robert Allen Zimmerman", "Harmonica Player")
//      ---

然而,使用 通用應用程式方法,在 Scala 3 中不需要這樣做: 僅限 Scala 3

val p = Person("Robert Allen Zimmerman", "Harmonica Player")

取得類別執行個體(例如 p)後,就能存取其欄位,在這個範例中,所有欄位都是建構函式的參數

p.name       // "Robert Allen Zimmerman"
p.vocation   // "Harmonica Player"

如前所述,所有這些參數都建立為 var 欄位,因此您也可以變更它們

p.name = "Bob Dylan"
p.vocation = "Musician"

欄位和方法

類別也可以有方法和額外欄位,這些欄位和方法不屬於建構函式的一部分。它們是在類別主體中定義的。主體會在預設建構函式的過程中初始化

class Person(var firstName: String, var lastName: String) {

  println("initialization begins")
  val fullName = firstName + " " + lastName

  // a class method
  def printFullName: Unit =
    // access the `fullName` field, which is created above
    println(fullName)

  printFullName
  println("initialization ends")
}
class Person(var firstName: String, var lastName: String):

  println("initialization begins")
  val fullName = firstName + " " + lastName

  // a class method
  def printFullName: Unit =
    // access the `fullName` field, which is created above
    println(fullName)

  printFullName
  println("initialization ends")

以下 REPL 會話顯示如何使用這個類別建立新的 Person 執行個體

scala> val john = new Person("John", "Doe")
initialization begins
John Doe
initialization ends
val john: Person = Person@55d8f6bb

scala> john.printFullName
John Doe
scala> val john = Person("John", "Doe")
initialization begins
John Doe
initialization ends
val john: Person = Person@55d8f6bb

scala> john.printFullName
John Doe

類別也可以延伸特質和抽象類別,我們會在以下專門的章節中探討。

預設參數值

快速瀏覽其他幾個功能,類別建構函式參數也可以有預設值

class Socket(val timeout: Int = 5_000, val linger: Int = 5_000) {
  override def toString = s"timeout: $timeout, linger: $linger"
}
class Socket(val timeout: Int = 5_000, val linger: Int = 5_000):
  override def toString = s"timeout: $timeout, linger: $linger"

這個功能的一大優點是,它讓您的程式碼使用者可以透過各種不同的方式建立類別,就像類別有替代建構函式一樣

val s = new Socket()                  // timeout: 5000, linger: 5000
val s = new Socket(2_500)             // timeout: 2500, linger: 5000
val s = new Socket(10_000, 10_000)    // timeout: 10000, linger: 10000
val s = new Socket(timeout = 10_000)  // timeout: 10000, linger: 5000
val s = new Socket(linger = 10_000)   // timeout: 5000, linger: 10000
val s = Socket()                  // timeout: 5000, linger: 5000
val s = Socket(2_500)             // timeout: 2500, linger: 5000
val s = Socket(10_000, 10_000)    // timeout: 10000, linger: 10000
val s = Socket(timeout = 10_000)  // timeout: 10000, linger: 5000
val s = Socket(linger = 10_000)   // timeout: 5000, linger: 10000

建立類別的新執行個體時,您也可以使用命名參數。當許多參數具有相同的類型時,這特別有用,如這個比較所示

// option 1
val s = new Socket(10_000, 10_000)

// option 2
val s = new Socket(
  timeout = 10_000,
  linger = 10_000
)
// option 1
val s = Socket(10_000, 10_000)

// option 2
val s = Socket(
  timeout = 10_000,
  linger = 10_000
)

輔助建構函式

您可以定義一個類別,讓它有多個建構函式,以便您的類別使用者可以透過不同的方式建立類別。例如,假設您需要撰寫一些程式碼來模擬大學入學系統中的學生。在分析需求時,您發現需要能夠以三種方式建立 Student 執行個體

  • 在他們第一次開始入學程序時,使用姓名和政府 ID
  • 在他們提交申請時,使用姓名、政府 ID 和額外的申請日期
  • 在他們被錄取後,使用姓名、政府 ID 和他們的學生 ID

使用 OOP 風格處理這個情況的一種方法是使用這個程式碼

import java.time._

// [1] the primary constructor
class Student(
  var name: String,
  var govtId: String
) {
  private var _applicationDate: Option[LocalDate] = None
  private var _studentId: Int = 0

  // [2] a constructor for when the student has completed
  // their application
  def this(
    name: String,
    govtId: String,
    applicationDate: LocalDate
  ) = {
    this(name, govtId)
    _applicationDate = Some(applicationDate)
  }

  // [3] a constructor for when the student is approved
  // and now has a student id
  def this(
    name: String,
    govtId: String,
    studentId: Int
  ) = {
    this(name, govtId)
    _studentId = studentId
  }
}
import java.time.*

// [1] the primary constructor
class Student(
  var name: String,
  var govtId: String
):
  private var _applicationDate: Option[LocalDate] = None
  private var _studentId: Int = 0

  // [2] a constructor for when the student has completed
  // their application
  def this(
    name: String,
    govtId: String,
    applicationDate: LocalDate
  ) =
    this(name, govtId)
    _applicationDate = Some(applicationDate)

  // [3] a constructor for when the student is approved
  // and now has a student id
  def this(
    name: String,
    govtId: String,
    studentId: Int
  ) =
    this(name, govtId)
    _studentId = studentId

這個類別有三個建構函式,由程式碼中的編號註解提供

  1. 主建構函式,由類別定義中的 namegovtId 提供
  2. 具有參數 namegovtIdapplicationDate 的輔助建構函式
  3. 具有參數 namegovtIdstudentId 的另一個輔助建構函式

這些建構函式可以這樣呼叫

val s1 = new Student("Mary", "123")
val s2 = new Student("Mary", "123", LocalDate.now)
val s3 = new Student("Mary", "123", 456)
val s1 = Student("Mary", "123")
val s2 = Student("Mary", "123", LocalDate.now)
val s3 = Student("Mary", "123", 456)

雖然可以使用此技術,但請記住,建構函數參數也可以有預設值,這使得類別看起來有多個建構函數。這在先前的 Socket 範例中顯示。

物件

物件是只有一個執行個體的類別。當參照其成員時,會以延遲方式初始化,類似於 lazy val。Scala 中的物件允許在一個命名空間下對方法和欄位進行分組,類似於在 Java、Javascript (ES6) 或 Python 中的 @staticmethod 中使用 static 成員的方式。

宣告 object 類似於宣告 class。以下是包含一組用於處理字串的方法的「字串工具程式」物件範例

object StringUtils {
  def truncate(s: String, length: Int): String = s.take(length)
  def containsWhitespace(s: String): Boolean = s.matches(".*\\s.*")
  def isNullOrEmpty(s: String): Boolean = s == null || s.trim.isEmpty
}
object StringUtils:
  def truncate(s: String, length: Int): String = s.take(length)
  def containsWhitespace(s: String): Boolean = s.matches(".*\\s.*")
  def isNullOrEmpty(s: String): Boolean = s == null || s.trim.isEmpty

我們可以使用物件如下所示

StringUtils.truncate("Chuck Bartowski", 5)  // "Chuck"

Scala 中的匯入非常靈活,允許我們匯入物件的所有成員

import StringUtils._
truncate("Chuck Bartowski", 5)       // "Chuck"
containsWhitespace("Sarah Walker")   // true
isNullOrEmpty("John Casey")          // false
import StringUtils.*
truncate("Chuck Bartowski", 5)       // "Chuck"
containsWhitespace("Sarah Walker")   // true
isNullOrEmpty("John Casey")          // false

或僅部分成員

import StringUtils.{truncate, containsWhitespace}
truncate("Charles Carmichael", 7)       // "Charles"
containsWhitespace("Captain Awesome")   // true
isNullOrEmpty("Morgan Grimes")          // Not found: isNullOrEmpty (error)

物件也可以包含欄位,這些欄位也可以像靜態成員一樣存取

object MathConstants {
  val PI = 3.14159
  val E = 2.71828
}

println(MathConstants.PI)   // 3.14159
object MathConstants:
  val PI = 3.14159
  val E = 2.71828

println(MathConstants.PI)   // 3.14159

伴隨物件

與類別同名的 object,並宣告在與類別相同的檔案中,稱為「伴侶物件」。類似地,對應的類別稱為物件的伴侶類別。伴侶類別或物件可以存取其伴侶的私人成員。

伴侶物件用於不特定於伴侶類別執行個體的方法和值。例如,在以下範例中,類別 Circle 有名為 area 的成員,特定於每個執行個體,而其伴侶物件有一個名為 calculateArea 的方法,(a) 不特定於執行個體,且 (b) 可用於每個執行個體

import scala.math._

class Circle(val radius: Double) {
  def area: Double = Circle.calculateArea(radius)
}

object Circle {
  private def calculateArea(radius: Double): Double = Pi * pow(radius, 2.0)
}

val circle1 = new Circle(5.0)
circle1.area
import scala.math.*

class Circle(val radius: Double):
  def area: Double = Circle.calculateArea(radius)

object Circle:
  private def calculateArea(radius: Double): Double = Pi * pow(radius, 2.0)

val circle1 = Circle(5.0)
circle1.area

在此範例中,可供每個執行個體使用的 area 方法使用伴侶物件中定義的 calculateArea 方法。再次,calculateArea 類似於 Java 中的靜態方法。此外,由於 calculateArea 是私有的,因此無法由其他程式碼存取,但如所示,Circle 類別的執行個體可以看到它。

其他用途

伴侶物件可用於多種用途

  • 如所示,它們可用於在一個命名空間下對「靜態」方法進行分組
    • 這些方法可以是公開的或私有的
    • 如果 calculateArea 為公開,則會以 Circle.calculateArea 方式存取
  • 它們可以包含 apply 方法,這些方法得益於一些語法糖,可作為建構新執行個體的工廠方法
  • 它們可以包含 unapply 方法,這些方法用於解構物件,例如使用模式比對

以下快速說明如何將 apply 方法用作工廠方法來建立新物件

class Person {
  var name = ""
  var age = 0
  override def toString = s"$name is $age years old"
}

object Person {
  // a one-arg factory method
  def apply(name: String): Person = {
    var p = new Person
    p.name = name
    p
  }

  // a two-arg factory method
  def apply(name: String, age: Int): Person = {
    var p = new Person
    p.name = name
    p.age = age
    p
  }
}

val joe = Person("Joe")
val fred = Person("Fred", 29)

//val joe: Person = Joe is 0 years old
//val fred: Person = Fred is 29 years old

本文未介紹 unapply 方法,但 語言規格中有介紹。

class Person:
  var name = ""
  var age = 0
  override def toString = s"$name is $age years old"

object Person:

  // a one-arg factory method
  def apply(name: String): Person =
    var p = new Person
    p.name = name
    p

  // a two-arg factory method
  def apply(name: String, age: Int): Person =
    var p = new Person
    p.name = name
    p.age = age
    p

end Person

val joe = Person("Joe")
val fred = Person("Fred", 29)

//val joe: Person = Joe is 0 years old
//val fred: Person = Fred is 29 years old

本文未介紹 unapply 方法,但 參考文件中有介紹。

特質

如果您熟悉 Java,Scala 特質類似於 Java 8+ 中的介面。特質可以包含

  • 抽象方法和欄位
  • 具體方法和欄位

在基本用法中,特質可用作介面,僅定義其他類別將實作的抽象成員

trait Employee {
  def id: Int
  def firstName: String
  def lastName: String
}
trait Employee:
  def id: Int
  def firstName: String
  def lastName: String

不過,特質也可以包含具體成員。例如,下列特質定義兩個抽象成員 (numLegswalk()),並具備 stop() 方法的具體實作

trait HasLegs {
  def numLegs: Int
  def walk(): Unit
  def stop() = println("Stopped walking")
}
trait HasLegs:
  def numLegs: Int
  def walk(): Unit
  def stop() = println("Stopped walking")

以下提供另一個具有抽象成員和兩個具體實作的特質

trait HasTail {
  def tailColor: String
  def wagTail() = println("Tail is wagging")
  def stopTail() = println("Tail is stopped")
}
trait HasTail:
  def tailColor: String
  def wagTail() = println("Tail is wagging")
  def stopTail() = println("Tail is stopped")

請注意,每個特質只處理非常具體的屬性和行為:HasLegs 只處理腿,而 HasTail 只處理與尾巴相關的功能。特質讓您可以建構像這樣的小型模組。

在程式碼的後續部分,類別可以混合多個特質來建構較大的元件

class IrishSetter(name: String) extends HasLegs with HasTail {
  val numLegs = 4
  val tailColor = "Red"
  def walk() = println("I’m walking")
  override def toString = s"$name is a Dog"
}
class IrishSetter(name: String) extends HasLegs, HasTail:
  val numLegs = 4
  val tailColor = "Red"
  def walk() = println("I’m walking")
  override def toString = s"$name is a Dog"

請注意,IrishSetter 類別實作在 HasLegsHasTail 中定義的抽象成員。現在,您可以建立新的 IrishSetter 執行個體

val d = new IrishSetter("Big Red")   // "Big Red is a Dog"
val d = IrishSetter("Big Red")   // "Big Red is a Dog"

這只是使用特質可以完成工作的範例。如需更多詳細資訊,請參閱這些建模課程的其餘部分。

抽象類別

當您想要撰寫類別,但知道它會有抽象成員時,您可以建立特質或抽象類別。在大多數情況下,您會使用特質,但歷史上曾有兩種情況,使用抽象類別比使用特質更好

  • 您想要建立一個採用建構函數參數的基礎類別
  • 這段程式碼會由 Java 程式碼呼叫

採用建構函數參數的基礎類別

在 Scala 3 之前,當基礎類別需要採用建構函數參數時,您會將其宣告為 abstract class

abstract class Pet(name: String) {
  def greeting: String
  def age: Int
  override def toString = s"My name is $name, I say $greeting, and I’m $age"
}

class Dog(name: String, var age: Int) extends Pet(name) {
  val greeting = "Woof"
}

val d = new Dog("Fido", 1)
abstract class Pet(name: String):
  def greeting: String
  def age: Int
  override def toString = s"My name is $name, I say $greeting, and I’m $age"

class Dog(name: String, var age: Int) extends Pet(name):
  val greeting = "Woof"

val d = Dog("Fido", 1)

特質參數 僅限 Scala 3

然而,在 Scala 3 中,特質現在可以有 參數,因此您現在可以在相同情況下使用特質

trait Pet(name: String):
  def greeting: String
  def age: Int
  override def toString = s"My name is $name, I say $greeting, and I’m $age"

class Dog(name: String, var age: Int) extends Pet(name):
  val greeting = "Woof"

val d = Dog("Fido", 1)

特質更靈活,可以組合使用—您可以混合多個特質,但只能延伸一個類別—而且在大部分時間都應該優先於類別和抽象類別。經驗法則是在您想要建立特定類型的執行個體時使用類別,而當您想要分解和重複使用行為時使用特質。

列舉 僅限 Scala 3

列舉可以定義一個由有限命名的值組成的類型(在 FP 建模 部分,我們將會看到列舉比這靈活得多)。基本列舉用於定義常數組,例如一年的月份、一週中的日子、北/南/東/西等方向,以及更多。

舉例來說,這些列舉定義與披薩相關的屬性組

enum CrustSize:
  case Small, Medium, Large

enum CrustType:
  case Thin, Thick, Regular

enum Topping:
  case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions

要在其他程式碼中使用它們,請先匯入它們,然後再使用它們

import CrustSize.*
val currentCrustSize = Small

列舉值可以使用等於 (==) 進行比較,也可以進行匹配

// if/then
if currentCrustSize == Large then
  println("You get a prize!")

// match
currentCrustSize match
  case Small => println("small")
  case Medium => println("medium")
  case Large => println("large")

其他列舉功能

列舉也可以參數化

enum Color(val rgb: Int):
  case Red   extends Color(0xFF0000)
  case Green extends Color(0x00FF00)
  case Blue  extends Color(0x0000FF)

它們也可以有成員(例如欄位和方法)

enum Planet(mass: Double, radius: Double):
  private final val G = 6.67300E-11
  def surfaceGravity = G * mass / (radius * radius)
  def surfaceWeight(otherMass: Double) =
    otherMass * surfaceGravity

  case Mercury extends Planet(3.303e+23, 2.4397e6)
  case Earth   extends Planet(5.976e+24, 6.37814e6)
  // more planets here ...

與 Java 列舉的相容性

如果您想要將 Scala 定義的列舉用作 Java 列舉,您可以透過延伸類別 java.lang.Enum(預設匯入)來執行,如下所示

enum Color extends Enum[Color] { case Red, Green, Blue }

類型參數來自 Java enum 定義,且應該與列舉的類型相同。在延伸 java.lang.Enum 時,不需要提供建構函數參數(如 Java API 文件中定義)—編譯器會自動產生它們。

在像這樣定義 Color 之後,您可以像使用 Java 列舉一樣使用它

scala> Color.Red.compareTo(Color.Green)
val res0: Int = -1

有關 代數資料類型參考文件 的部分會更詳細地介紹列舉。

案例類別

案例類別用於建模不可變資料結構。請看以下範例

case class Person(name: String, relation: String)

由於我們宣告 Person 為案例類別,欄位 namerelation 預設為公開且不可變。我們可以如下建立案例類別的實例

val christina = Person("Christina", "niece")

請注意欄位無法變異

christina.name = "Fred"   // error: reassignment to val

由於案例類別的欄位假設為不可變,Scala 編譯器可以為您產生許多有用的方法

  • 產生一個 unapply 方法,讓您可以在案例類別上執行模式比對(也就是 case Person(n, r) => ...)。
  • 在類別中產生一個 copy 方法,這對於建立實例的修改副本非常有用。
  • 產生使用結構相等性的 equalshashCode 方法,讓您可以在 Map 中使用案例類別的實例。
  • 產生一個預設的 toString 方法,這對於除錯很有幫助。

這些額外的功能在以下範例中示範

// Case classes can be used as patterns
christina match {
  case Person(n, r) => println("name is " + n)
}

// `equals` and `hashCode` methods generated for you
val hannah = Person("Hannah", "niece")
christina == hannah       // false

// `toString` method
println(christina)        // Person(Christina,niece)

// built-in `copy` method
case class BaseballTeam(name: String, lastWorldSeriesWin: Int)
val cubs1908 = BaseballTeam("Chicago Cubs", 1908)
val cubs2016 = cubs1908.copy(lastWorldSeriesWin = 2016)
// result:
// cubs2016: BaseballTeam = BaseballTeam(Chicago Cubs,2016)

// Case classes can be used as patterns
christina match
  case Person(n, r) => println("name is " + n)

// `equals` and `hashCode` methods generated for you
val hannah = Person("Hannah", "niece")
christina == hannah       // false

// `toString` method
println(christina)        // Person(Christina,niece)

// built-in `copy` method
case class BaseballTeam(name: String, lastWorldSeriesWin: Int)
val cubs1908 = BaseballTeam("Chicago Cubs", 1908)
val cubs2016 = cubs1908.copy(lastWorldSeriesWin = 2016)
// result:
// cubs2016: BaseballTeam = BaseballTeam(Chicago Cubs,2016)

支援函式程式設計

如前所述,案例類別支援函式程式設計 (FP)

  • 在 FP 中,您會嘗試避免變異資料結構。因此,建構函式欄位預設為 val 是合理的。由於案例類別的實例無法變更,因此可以輕鬆地共用它們,而不用擔心變異或競爭條件。
  • 您可以使用 copy 方法作為範本,建立新的(可能已變更)實例,而不是變異實例。這個程序可以稱為「複製時更新」。
  • 自動為您產生 unapply 方法,也讓案例類別可以透過模式比對以進階的方式使用。

案例物件

案例物件對物件來說,就像案例類別對類別一樣:它們提供許多自動產生的方法來讓它們更強大。它們特別有用於您需要單例物件,而且需要一些額外功能時,例如在 match 表達式中使用模式比對。

當您需要傳遞不可變訊息時,案例物件很有用。例如,如果您正在進行音樂播放器專案,您會建立一組命令或訊息,如下所示

sealed trait Message
case class PlaySong(name: String) extends Message
case class IncreaseVolume(amount: Int) extends Message
case class DecreaseVolume(amount: Int) extends Message
case object StopPlaying extends Message

然後在程式碼的其他部分,您可以撰寫像這樣的方法,使用模式比對來處理傳入的訊息(假設方法 playSongchangeVolumestopPlayingSong 在其他地方定義)

def handleMessages(message: Message): Unit = message match {
  case PlaySong(name)         => playSong(name)
  case IncreaseVolume(amount) => changeVolume(amount)
  case DecreaseVolume(amount) => changeVolume(-amount)
  case StopPlaying            => stopPlayingSong()
}
def handleMessages(message: Message): Unit = message match
  case PlaySong(name)         => playSong(name)
  case IncreaseVolume(amount) => changeVolume(amount)
  case DecreaseVolume(amount) => changeVolume(-amount)
  case StopPlaying            => stopPlayingSong()

此頁面的貢獻者