从本章开始的后面四章涵盖了Scala 3中领域建模的概念。 领域建模 是指如何使用编程语言对周围的世界进行建模,也就是说,如何对人、汽车、金融交易等概念建模。无论你是用函数式编程还是面向对象的编程风格来写代码,这都意味着你对这些事物的 属性 和 行为 进行建模。
为了灵活地对周围的世界进行建模,Scala 3提供了以下语言结构:
- 类
- 样例类
- 特质
- 枚举
- 对象和样例对象
- 抽象类
- 方法,可以在以上所有的这些结构中定义
这里涵盖了很多知识点,为了帮助大家理解并消化这些貌似复杂的概念,5.1小节分别展示了以FP和OOP编程时如何使用这些结构。然后,本章介绍了类和样例类,第六章介绍了特质和枚举,第七章介绍了对象,第八章的一些小节介绍了方法。因为抽象类的使用频率较低,所以它们只在5.1小节被提及。
虽然Scala和Java有很多相似之处,但与 类 和 构造函数 有关的语法代表了这两种语言之间的一些最大差异。Java往往比较啰嗦,显然Scala则比较简洁,而代码最终会生成其他代码。例如,这个只有一行的Scala类编译成了至少29行的Java代码,其中大部分是模板式的属性的 访问字段/修改字段 方法:
class Employee(var name: String, var age: Int, var role: String)
因为类和构造函数非常重要,所以在本章的最开始的小节中将对它们进行详细讨论。
接下来,因为 equals 的概念是一个非常重要的话题,所以5.9小节花了很多时间来演示如何在Scala中实现 equals 方法。
当在 match 表达式中使用类时,请在这个类的伴生对象中实现 unapply 方法。因为这相当于调用 对象 中的方法,这个主题在7.8小节“使用unapply实现模式匹配”中涉及。
类字段的访问方法的概念很重要,所以5.10小节演示了如何禁止访问字段和修改字段方法的自动生成。之后,5.11小节演示了如何覆盖访问字段和修改字段方法的默认行为。
在Java中,将 访问字段 和 修改字段 方法称为 getter 和 setter 方法似乎是正确的,主要是因为JavaBeans的 get / set 标准。在本章中,我将这些术语互换使用,但要明确的是,Scala并不遵循JavaBeans的访问字段和修改字段方法的命名规则。
接下来,两个小节展示了与参数和字段有关的其他技术。首先,5.12小节展示了如何将一个代码块(的执行结果)赋值给一个类中的 lazy 字段,然后5.13小节展示了如何通过使用 Option 类型来处理未初始化的 var 字段。
最后,正如在前面看到的,OOP风格的Scala Employee 类相当于29行Java代码。相比之下,这个FP风格的样例类相当于远远超过一百行的Java代码:
case class Employee(name: String, age: Int, role: String)
因为样例类生成了很多模板代码,所以它们的用途和好处将在5.14小节中讨论。此外,由于样例类与默认的Scala类不同,样例类的构造函数(实际上是工厂方法)将在5.15小节中讨论。
因为Scala提供了特质、枚举、类、样例类、对象和抽象类,所以在设计代码时,你想要了解如何从这些领域建模选项中选择合适的。
解决方案取决于是使用函数式编程还是面向对象的编程风格。这两种解决方案将在下面的章节中讨论。在讨论中还提供了一些例子,随后简要介绍了何时应该使用抽象类。
在以FP风格编程时,将主要使用这些结构:
- 特质
- 枚举
- 样例类
- 对象
在FP风格中,可以按如下方式使用这些结构:
特质
特质是用来创建小型的、以逻辑分组为单元的行为。它们通常被写成 def 方法,但如果愿意也可以被写成 val 函数。无论哪种方式,它们都被写成纯函数(详见第10章的“纯函数”)。这些特质以后将被组合成具体的对象。
枚举
使用枚举来创建代数数据类型(ADTs,如6.13小节“用枚举建模代数数据类型”所示)以及广义ADTs(GADTs)。
样例类
使用样例类来创建具有不可变字段的对象(在某些语言中被称为 不可变记录 ,如Java 14中的 记录(record) 类型)。样例类是为FP风格创建的,它们有几个专门的方法对使用这种风格很有帮助,包括:默认为 val 字段的参数,当你想模拟修改值时的 copy 方法,用于模式匹配的内置 unapply 方法,良好的默认 equals 和 hashCode 方法等等。
对象
在FP中,通常会使用对象作为使一个或多个特质 “真实化”的方式,在这个过程中,技术上被称为 具像化(reification)。
在FP中,当不需要样例类的所有功能时,也可以使用普通 类 结构(相对于 样例类 结构)。当这样做时,需要将字段定义为 val 字段,然后可以手动实现其他行为,比如为类定义一个 unapply 提取方法,详见7.8小节,“使用unapply实现模式匹配”。
当以OOP风格进行编程时,将主要使用这些结构:
- 特质
- 枚举
- 类
- 对象
在OOP风格中,可以按如下方式使用这些结构:
特质
Traits主要是作为接口使用的。如果你用过Java,可以像Java 8或更高的版本的接口一样使用Scala的特质,既有抽象成员,也有具体成员。以后会用类来实现这些特质。
枚举
主要使用枚举来创建简单的常量集,如显示器的位置(顶部、底部、左侧和右侧)。
类
在OOP中,将主要使用普通的类,而不是样例类。将它们的构造函数参数定义为var字段,这样它们就可以被改变。它们将包含基于这些可变字段的方法。根据需要覆盖默认的访问字段和修改字段方法(getters和setters)。
对象
使用 对象 结构作为创建相当于Java中静态方法的方式,比如一个包含对字符串进行操作的静态方法的 StringUtils 对象(详见7.4小节,“用伴生对象创建静态成员”)。
当想要样例类提供的许多或全部功能时(见5.14小节),也可以用它们来代替普通的类(尽管它们主要是为了FP风格编程所设计的)。
为了讨论这个解决方案,这里将分别展示FP和OOP的例子。但在进入这些单独的例子之前,这里先展示一下这几个枚举,FP和OOP都使用了它们:
enum Topping:
case Cheese, Pepperoni, Sausage, Mushrooms, Onions
enum CrustSize:
case Small, Medium, Large
enum CrustType:
case Regular, Thin, Thick
像这样使用枚举,从技术上讲其实是ADT,详见6.13小节,“用枚举建模代数数据类型”,展示了FP和OOP领域建模之间的一些共同点。
10.10小节 “实际应用中的例子: 函数式领域建模”中的披萨店例子,详细演示了FP领域建模的方法,所以在这里只简单介绍一下。
首先,使用上面的枚举来定义一个 Pizza 类,使用 样例类 结构:
case class Pizza(
crustSize: CrustSize,
crustType: CrustType,
toppings: Seq[Topping]
)
之后再加入这些样例类:
case class Customer(
name: String,
phone: String,
address: Address
)
case class Address(
street1: String,
street2: Option[String],
city: String,
state: String,
postalCode: String
)
case class Order(
pizzas: Seq[Pizza],
customer: Customer
)
在FP中,样例类是首选,因为所有的参数都是不可变的,而且样例类提供了内置的方法,使编写FP风格代码更容易(如5.14小节所示)。另外,注意这些类不包含任何方法;它们只是简单的数据结构。
接下来,把对这些数据结构进行操作的方法写成纯函数,并把这些方法进行分组使之成为小的、有逻辑组织的特质,或者说这个例子中只有一个特质:
trait PizzaServiceInterface:
def addTopping(p: Pizza, t: Topping): Pizza
def removeTopping(p: Pizza, t: Topping): Pizza
def removeAllToppings(p: Pizza): Pizza
def updateCrustSize(p: Pizza, cs: CrustSize): Pizza
def updateCrustType(p: Pizza, ct: CrustType): Pizza
然后在其他特质中实现这些方法:
trait PizzaService extends PizzaServiceInterface:
def addTopping(p: Pizza, t: Topping): Pizza =
// the 'copy' method comes with a case class
val newToppings = p.toppings :+ t
p.copy(toppings = newToppings)
// there are about two lines of code for each of these
// methods, so all of that code is not repeated here:
def removeTopping(p: Pizza, t: Topping): Pizza = ???
def removeAllToppings(p: Pizza): Pizza = ???
def updateCrustSize(p: Pizza, cs: CrustSize): Pizza = ???
def updateCrustType(p: Pizza, ct: CrustType): Pizza = ???
end PizzaService
注意在这个特质中,所有的东西都是不可变的。披萨、配料和饼皮的细节被传递到方法中,它们不会改变这些值。而是根据传入的值返回新的值。
最终,把服务变成了 “真实的”,把它们具像为了对象:
object PizzaService extends PizzaService
在这个例子中,只用了一个特质,但在实际应用中,经常会把多个特质组合到一个对象中,就像这样:
object DogServices extend TailService, RubberyNoseService, PawService ...
如上所示,这就是如何将多个细化的、单一目的的服务组合成一个更大的、完整的服务。
这就是在这里所要展示的披萨店的例子,更多细节请参见10.10小节,“实际应用中的例子: 函数式领域建模”。
接下来,将为这个同样的问题创建一个OOP风格的解决方案。首先,使用类结构和可变参数创建一个OOP风格的披萨类:
class Pizza (
var crustSize: CrustSize,
var crustType: CrustType,
val toppings: ArrayBuffer[Topping]
):
def addTopping(t: Topping): Unit =
toppings += t
def removeTopping(t: Topping): Unit =
toppings -= t
def removeAllToppings(): Unit =
toppings.clear()
前两个构造函数参数被定义为 var 字段,所以它们可以被改变, toppings 被定义为 ArrayBuffer ,所以它的值也可以被改变。
请注意,FP风格的样例类包含属性,但没有行为,而用OOP的方法,pizza类包含两者,包括处理可变参数的方法。这些方法中的每一个都可以定义在一行中,但这里把每个方法的主体放在单独的一行中,以使它们易于阅读。但如果愿意的话它们可以像这样写得更简洁:
def addTopping(t: Topping): Unit = toppings += t
def removeTopping(t: Topping): Unit = toppings -= t
def removeAllToppings(): Unit = toppings.clear()
如果继续沿着这条路走下去,会创建更多的OOP风格的类来封装属性和行为。例如,一个订单类可能完全封装了一系列构成 订单 的概念:
class Order:
private lineItems = ArrayBuffer[Product]()
def addItem(p: Product): Unit = ???
def removeItem(p: Product): Unit = ???
def getItems(): Seq[Product] = ???
def getPrintableReceipt(): String = ???
def getTotalPrice(): Money = ???
end Order
// usage:
val o = Order()
o.addItem(Pizza(Small, Thin, ArrayBuffer(Cheese, Pepperoni)))
o.addItem(Cheesesticks)
这个例子假设有一个看起来像这样层次结构的 Product 类:
// a Product may have methods to determine its cost, sales price,
// and other details
sealed trait Product
// each class may have additional attributes and methods
class Pizza extends Product
class Beverage extends Product
class Cheesesticks extends Product
这里不会对这个例子作进一步说明,因为我认为大多数读者都熟悉用多态方法来封装属性和行为的OOP风格。
因为现在在Scala 3中,特质可以接受参数,而且类只能扩展一个抽象类(同时可以混入多个特质),那么问题来了,“什么时候应该使用抽象类?”
答案就是“几乎不用”,具体来说应该是这样:
- 当在Java使用Scala代码时,扩展一个类比扩展一个特质更容易。
- 当我在Scala Center提出这个问题时,Scala.js的创建者Sébastien Doeraene说道:“在Scala.js中,一个类可以从JavaScript导入或导出”。
- 同时,Scala中心的讲师总监Julien Richard-Foy指出,抽象类可能比特质的编码效率略高,因为作为父类,特质是动态的,而对于抽象类,它是静态已知的。
因此,我的经验是始终使用特质,然后在满足上述条件(也有可能是我没有想到的其他条件)时,再回过头来使用一个抽象类。
除了帮助了解领域建模选项外,这个小节还为其他小节提纲挈领,这些小节提供了关于每个主题的更多细节:
- 类在从5.2小节开始其他许多小节中都有讨论。
- 样例类在5.14小节中详细讨论。
- 使用特质作为接口的概念在6.1小节 “使用特质作为接口”中讨论。
- 在6.3小节 “像抽象类一样使用特质”中讨论了将特质作为抽象类使用的问题。
- 在6.11小节 “使用特质创建模块”和7.7小节 “将特质具像为对象”中涵盖了将特质具像为模块的概念。
- 在10.10小节 “实际应用中的例子:函数式领域建模”中,将更详细地介绍FP式披萨店的例子。
- 第10章中讨论了许多其他的FP概念。
- 如果对的Java代码中使用Scala特质感兴趣,请参阅22.5小节,“在Java中使用Scala特质”。
你想为Scala类创建一个主构造函数,但你发现这种方法与Java(以及其他语言)不同。
Scala类的主构造函数由下面这些组成:
- 构造函数参数
- 类的主体中的字段(变量分配)
- 在类的主体中执行的语句和表达式
下面的类演示了构造函数参数、类字段和类主体中的语句:
class Employee(var firstName: String, var lastName: String):
// a statement
println("the constructor begins ...")
// some class fields (variable assignments)
var age = 0
private var salary = 0d
// a method call
printEmployeeInfo()
// methods defined in the class
override def toString = s"$firstName $lastName is $age years old"
def printEmployeeInfo() = println(this) //uses toString
// any statement or field prior to the end of the class
// definition is part of the class constructor
println("the constructor ends")
// optional 'end' statement
end Employee
构造函数参数、语句和字段都是类的构造函数的一部分。注意,方法也在类的主体中,但它们不是构造函数的一部分。
因为类的主体中的 方法调用 是构造函数的一部分,当 Employee 类的实例被创建时,可以看到类声明开头和结尾的 println 语句的输出,以及对 printEmployeeInfo 方法的调用:
scala> val e = Employee("Kim", "Carnes")
the constructor begins ...
Kim Carnes is 0 years old
the constructor ends
val e: Employee = Kim Carnes is 0 years old
如果熟悉Java的话,可以发现Scala中声明主构造函数的过程与Java相比是非常不同的。在Java中,什么代码在主构造函数中,什么代码不在,是相当明显的,但Scala模糊了这种区别。然而,一旦理解了这种方法,就有助于使类声明更加简洁。
在上面所示的例子中,构造函数的两个参数 firstName 和 lastName 被定义为 var 字段,这意味着它们是 可变的 :它们在最初被设置后可以被改变。因为这些字段是可变的,也因为它们默认是公共访问,所以Scala为它们生成了访问字段和修改字段方法。因此,给定一个 Employee 类型的实例 e ,可以像这样改变其值:
e.firstName = "Xena"
e.lastName = "Princess Warrior"
也可以这样来访问其值:
println(e.firstName) // Xena
println(e.lastName) // Princess Warrior
因为 age 字段像构造函数参数一样被声明为 var ,类成员默认是公开的,所以它也是可见的,可以被改变和访问:
e.age = 30
println(e.age)
相反,salary 字段被声明为 私有的 ,所以它不能从类之外被访问:
scala> e.salary
1 |e.salary
| ^^ ^^
|variable salary cannot be accessed as a member of (e: Employee)
当在类的主体中调用一个方法时 —— 比如在这里是 printEmployeeInfo 方法的调用 —— 这就是一个 语句 ,它也是构造函数的 —— 部分。如果好奇的话可以通过用 scalac 将代码编译成 Employee.class 文件,然后用像JAD这样的反编译器工具将其反编译成Java源代码来验证。Employee 构造函数被反编译为Java代码时就是这个样子:
public Employee(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
super();
Predef$.MODULE$.println("the constructor begins ...");
age = 0;
double salary = 0.0D;
printEmployeeInfo();
Predef$.MODULE$.println("the constructor ends");
}
这清楚地展示了 Employee 构造函数中的两个 println 语句和 printEmployeeInfo 方法调用,以及初始 age 和 salary 的设置。
在Scala中,类的主体中的任何语句、表达式或变量赋值都是类的主构造函数 —— 部分。
作为最后的比较,当用JAD反编译类文件,然后计算Scala和Java文件中的源代码行数 —— 即使每个文件使用相同的格式风格 —— 你会发现Scala的源代码有9行,Java的源代码有38行。有人说,开发人员花在 阅读 代码上的时间是我们 写 代码的10倍,所以这种创建简洁而又可读的代码的能力 —— 我们称之为 表现力 —— 是最初吸引我加入Scala的一个原因。
你想知道如何控制Scala类中作为构造函数参数的字段的可见性。
如下面的例子所示,Scala类中构造函数字段的可见性取决于该字段是否被声明为 val 或 var ,以及该字段是否被 private 修饰。
以下是解决方案的简短版本:
- 如果一个字段被声明为 var ,Scala会为该字段生成getter和setter方法。
- 如果字段是一个 val ,Scala只为它生成一个getter方法。
- 如果一个字段既不是 var 也不是 val ,Scala不会为该字段生成getter或setter方法;它成为类的私有部分。
- 此外, var 和 val 字段可以用 private 关键字进行修饰,这样可以防止产生公开的getter和setter方法。
下面的例子里会阐述更多的细节。
如果一个构造函数参数被声明为 var ,那么该字段的值就可以被改变,所以Scala为该字段生成了getter和setter方法。在下面这个例子中,构造函数参数名被声明为 var ,所以该字段可以被访问和修改:
scala> class Person(var name: String)
scala> val p = Person("Mark Sinclair Vincent")
// getter
scala> p.name
val res0: String = Mark Sinclair Vincent
// setter
scala> p.name = "Vin Diesel"
scala> p.name
val res1: String = Vin Diesel
熟悉Java的读者会发现Scala在生成访问字段和修改字段方法时并不遵循JavaBean getName/setName 的命名规则。相反,只需通过它的名字就可以访问一个字段。
如果一个构造函数字段被声明为 val ,那么该字段的值一旦被设置就不能被改变也就是说它是不可变的,就像Java中的 final 。因此,它有一个访问字段方法,而 没 有一个修改字段方法:
scala> class Person(val name: String)
defined class Person
scala> val p = Person("Jane Doe")
p: Person = Person@3f9f332b
// getter
scala> p.name
res0: String = Jane Doe
// attempt to use a setter
scala> p.name = "Wilma Flintstone"
1 |p.name = "Wilma Flintstone"
| ^^^^^^^^^
|Reassignment to val name
最后一个例子失败了,因为没有为 val 字段生成一个修改字段方法。
当构造函数参数上既没有 val 也没有 var 时,这个字段就是私有的,Scala就不会为字段生成访问方法或修改方法。如下所示,当创建一个这样的类时:
class SuperEncryptor(password: String):
// encrypt increments each Char in a String by 1
private def encrypt(s: String) = s.map(c => (c + 1).toChar)
def getEncryptedPassword = encrypt(password)
然后试图访问 password 字段时,会报错说:该字段没有被声明为 val 或 var :
val e = SuperEncryptor("1234")
e.password // error: value password cannot be accessed
e.getEncryptedPassword // 2345
如上所示,不能直接访问 password 字段,但由于 getEncryptedPassword 方法是一个类成员,它可以访问 password 。如果继续试验这段代码就会发现,在声明 password 时不使用 val 或 var ,就相当于把它变成了一个私有的 val 。
在大多数情况下,我只是偶然使用这种语法 —— 我忘了为字段指定 val 或 var。但如果你想接受一个构造函数参数,然后在类中使用该参数,但又不想让它在类外直接使用,那么这种语法就有意义了。
除了上面三种基本配置外,还可以给 val 或 var 字段添加 private 关键字。这可以防止生成getter和setter方法,因此该字段只能从类的成员中访问,如本例中的 salary 字段所示:
enum Role:
case HumanResources, WorkerBee
import Role.*
class Employee(var name: String, private var salary: Double):
def getSalary(r: Role): Option[Double] = r match
case HumanResources => Some(salary)
case _ => None
在这段代码中, getSalary 可以访问 salary 字段,因为它被定义在类的内部,但是正如下面这个例子所展示的 salary 字段不能从类的外部直接访问:
val e = Employee("Steve Jobs", 1)
// to access the salary field you have to use getSalary
e.name // Steve Jobs
e.getSalary(WorkerBee) // None
e.getSalary(HumanResources) // Some(1.0)
e.salary // error: variable salary in class Employee cannot be accessed
如果这些都让人感到困惑,那么想想编译器在生成代码时的选择就会对理解这些有所帮助。当一个字段被定义为 val 时,根据定义,它的值不能被改变,所以只生成一个getter,而不生成setter是合理的。同样,根据定义, var 字段的值 可以 被改变,所以生成getter和setter是说得过去的。
构造函数参数可以被设置成 private 给了开发人员额外的灵活性。当它被添加到 val 或 var 字段时,getter和setter方法会像以前一样被生成,但会被标记为私有。如果不在构造函数参数上指定 val 或 var ,根本不会生成任何getter或setter方法。
表5-1中总结了根据这些设置会生成的访问字段和修改字段方法。
表5-1 构造函数参数设置的效果
参数的设置 | 访问字段方法 | 修改字段方法 |
---|---|---|
var | 有 | 有 |
val | 有 | 没有 |
默认(既没有var也没有val) | 没有 | 没有 |
var或val上有private | 没有 | 没有 |
样例类构造函数中的参数与这些规则有一个不同之处:样例类构造函数参数默认为 val 。因此,如果定义一个样例类字段而不添加 val 或 var ,像这样:
case class Person(name: String)
仍然可以访问这个字段,就像它被定义为一个 val 一样:
scala> val p = Person("Dale Cooper")
p: Person = Person(Dale Cooper)
scala> p.name
res0: String = Dale Cooper
虽然这与普通的类不同,但它很便利,并且与函数式编程中使用样例类的方式有关,即作为不可变的记录。
- 关于手动添加自己的访问字段和修改字段方法的更多信息,请参阅5.11小节;关于 private 修饰的更多信息,请参阅5.3小节。
- 关于样例类原理的更多信息,请参阅5.14小节。
你想为一个类定义一个或多个辅助构造函数,这样类的使用者就可以有多种方式来创建对象实例。
将辅助构造函数定义为类中的方法,其名称为 this 并有合适的签名。可以定义多个辅助构造函数,但它们必须有不同的签名(参数列表)。另外,每个构造函数必须调用已经定义过的构造函数。
举个例子,假设这里有两个枚举定义,将在后面的 Pizza 类中使用:
enum CrustSize:
case Small, Medium, Large
enum CrustType:
case Thin, Regular, Thick
根据上面的定义,下面有一个 Pizza 类,有一个主构造函数和三个辅助构造函数:
import CrustSize.*, CrustType.*
// primary constructor
class Pizza (var crustSize: CrustSize, var crustType: CrustType):
// one-arg auxiliary constructor
def this(crustSize: CrustSize) =
this(crustSize, Pizza.DefaultCrustType)
// one-arg auxiliary constructor
def this(crustType: CrustType) =
this(Pizza.DefaultCrustSize, crustType)
// zero-arg auxiliary constructor
def this() =
this(Pizza.DefaultCrustSize, Pizza.DefaultCrustType)
override def toString = s"A $crustSize pizza with a $crustType crust"
object Pizza:
val DefaultCrustSize = Medium
val DefaultCrustType = Regular
基于上面这些构造函数,可以通过以下方式创建相同的披萨:
import Pizza.{DefaultCrustSize, DefaultCrustType}
// use the different constructors
val p1 = Pizza(DefaultCrustSize, DefaultCrustType)
val p2 = Pizza(DefaultCrustSize)
val p3 = Pizza(DefaultCrustType)
val p4 = Pizza
所有这些定义的输出结果是相同的:
A Medium pizza with a Regular crust
这个小节有以下几个要点:
- 辅助构造函数是通过创建名为 this 的方法来定义的。
- 每个辅助构造函数必须以调用先前定义过的构造函数为开始。
- 每个构造函数参数列表必须不同。
- 一个构造函数使用方法名 this 调用另一个构造函数,并指定所需参数。
在所示的例子中,所有的辅助构造函数都调用主构造函数,但这不是必须的;辅助构造函数只需要调用先前定义的构造函数之一即可。例如,接受 crustType 参数的辅助构造函数可以被写成调用参数为 CrustSize 的构造函数:
def this(crustType: CrustType) =
this(Pizza.DefaultCrustSize)
this.crustType = Pizza.DefaultCrustType
尽管 “解决方案”中所示的方法是完全有效的,但在创建这样的多个类构造函数之前,请花些时间阅读5.6小节。如该小节所示,使用默认参数值通常可以消除对多个构造函数的需要。例如,这种方法与解决方案中所示的类具有几乎相同的功能:
class Pizza(
var crustSize: CrustSize = Pizza.DefaultCrustSize,
var crustType: CrustType = Pizza.DefaultCrustType
):
override def toString =
s"A $crustSize pizza with a $crustType crust"
你想把一个类的主构造函数变成私有的,比如说为了编写一个单例。
要使主构造函数成为私有的,在类名和构造函数参数之间插入 private 关键字:
// a private one-arg primary constructor
class Person private (var name: String)
如REPL所示,这样就无法创建一个类的实例了:
scala> class Person private(name: String)
defined class Person
scala> val p = Person("Mercedes")
1 |val p = Person("Mercedes")
| ^^
|method apply cannot be accessed as a member of Person.type
当我第一次看到这种语法时,我觉得它有点不自然,但如果在看代码时将其读出来,会读成:“这是一个具有 私有构造函数 的 Person 类......” 我发现这句话中的 “私有构造函数”可以帮助我记住在 private 关键字是放在构造函数之前的。
为了在Scala中实现单例模式,可以将主构造函数设为 private ,然后在类的伴生对象中创建一个 getInstance 方法:
// a private constructor that takes no parameters
class Brain private:
override def toString = "This is the brain."
object Brain:
val brain = Brain()
def getInstance = brain
@main def singletonTest =
// this won’t compile because the constructor is private:
// val brain = Brain()
// this works:
val brain = Brain.getInstance
println(brain)
访问方法的名字不一定非得是 getInstance ;这里只是因为Java的惯例是这么命名的。当然也可以给它起一个你认为最好的名字。
伴生对象 是指与一个 类 定义在同一个文件中,并且与该类有相同名称的 对象。如果在一个名为 Foo.scala 的文件中声明一个名为 Foo 的类,然后在同一个文件中声明一个名为 Foo 的对象,那么 Foo 对象就是 Foo 类的伴生对象。
伴生对象可用于几个目的,其中一个目的是,在伴生对象中声明的任何方法都将做为对象的静态方法。7.4小节,“用伴生对象创建静态成员”,描述了更多关于创建相当于Java静态方法的信息。7.6小节,“用apply实现静态工厂”,描述了如何(以及为什么)在伴生对象中定义apply方法的例子。
根据要完成的任务,可能创建一个私有类的构造函数根本就没有必要。比如,在Java中,可以通过在Java类中定义 静态 方法来创建一个 文件工具类 ,但在Scala中,可以通过将这些方法放在Scala 对象 中来做同样的事情:
object FileUtils:
def readFile(filename: String): String = ???
def writeFile(filename: String, contents: String): Unit = ???
这让代码的使用者可以不需要创建一个 FileUtils 类的实例,而是直接调用这些方法:
val contents = FileUtils.readFile("input.txt")
FileUtils.writeFile("output.txt", content)
在这种情况不需要一个私有的类构造函数;你只要别定义类就可以了。
你想为一个构造函数参数提供一个默认值,这让这个类的使用者可以选择在调用构造函数时可以不指定该参数的值。
在构造函数声明中给参数一个默认值。下面是一个 Socket 类的声明,它有一个 timeout 的构造参数,其默认值为 10_000 :
class Socket(val timeout: Int = 10_000)
因为该参数定义了默认值,所以可以调用构造函数而不指定 timeout 值,在这种情况下,其值就是默认值:
val s = Socket()
s.timeout // Int = 10000
也可以在创建一个新的 Socket 时指定一个所需的 timeout 值:
val s = Socket(5_000)
s.timeout // Int = 5000
这小节展示了一个强大的功能,可以消除对辅助构造函数的需求。如解决方案中所示,下面的单个构造函数相当于两个构造函数:
class Socket(val timeout: Int = 10_000)
val s = Socket()
val s = Socket(5_000)
如果没有这个功能,就需要两个构造函数来获得同样的功能——一个需要单个参数的主构造函数和一个零参数的辅助构造函数:
class Socket(val timeout: Int):
def this() = this(10_000)
当然也可以为多个构造函数参数提供默认值:
class Socket(val timeout: Int = 1_000, val linger: Int = 2_000):
override def toString = s"timeout: $timeout, linger: $linger"
尽管只定义了一个构造函数,但这个类现在看起来有三个构造函数:
println(Socket()) // timeout: 1000, linger: 2000
println(Socket(3_000)) // timeout: 3000, linger: 2000
println(Socket(3_000, 4_000)) // timeout: 3000, linger: 4000
如8.3小节 “调用方法时使用参数名”所示,如果愿意的话,也可以在创建类实例时提供构造函数参数的名称:
Socket(timeout=3_000, linger=4_000)
Socket(linger=4_000, timeout=3_000)
Socket(timeout=3_000)
Socket(linger=4_000)
当你扩展一个有构造函数参数的基类时新子类可能需要额外的构造函数参数。
在解决方案中,将介绍扩展一个有多个 val 构造函数参数的类的情形,处理定义为 var 的构造函数参数的情况更为复杂,将在讨论中处理。
假设基类构造函数只有 val 参数,当定义其子类的构造函数时,不要在两个类共有的字段上声明 val 。只要在子类中定义的新的构造函数参数声明为 val(或 var )字段即可。
为了演示这一情形,首先定义一个 Person 基类,它有一个 val 参数是 name :
class Person(val name: String)
接下来,将 Employee 定义为 Person 的一个子类,它接受构造函数参数 name 和一个 age 的新参数。 name 参数在父类 Person 中也是被使用的,所以不要对该字段进行 val 声明,但 age 是新的,所以要将其声明为一个 val :
class Employee(name: String, val age: Int) extends Person(name):
override def toString = s"$name is $age years old"
现在可以这样创建一个新的 Employee 了:
scala> val joe = Employee("Joe", 33)
val joe: Employee = Joe is 33 years old
和预期的一样,而且由于字段是不可变的,所以没有其他问题。
当基类中的构造函数参数被定义为一个 var 字段时,情况就比较复杂了。有两种解决方案:
- 将子类中对应的字段命名成不同的名字。
- 在伴生对象中定义 apply 方法来实现子类的构造函数。
第一种方法是在子类构造函数中为公共字段使用一个不同的名字。例如,在这个例子中,在 Employee 的构造函数中使用 _name ,而不是使用 name :
class Person(var name: String)
// note the use of '_name' here
class Employee(_name: String, var age: Int) extends Person(_name):
override def toString = s"$name is $age"
原因是 Employee 类中的这个构造函数参数( _name )最终被生成为 Employee 类中的一个字段。如果对 Employee.class 文件进行反编译,就可以看到这一点:
$ javap -private Employee
public class Employee extends Person {
private final java.lang.String _name;
private final int age;
public Employee(java.lang.String, int);
public int age();
public java.lang.String toString();
}
如果把这个字段命名为 name , Employee 类中的这个字段就会覆盖 Person 类中的 name 字段。这会造成一些问题,比如在这个例子的最后看到的不一致的结果:
class Person(var name: String)
// i incorrectly use 'name' here, rather than '_name'
class Employee(name: String, var age: Int) extends Person(name):
override def toString = s"$name is $age years old"
// everything looks OK at first
val e = Employee("Joe", 33)
e // Joe is 33 years old
// but problems show up when i update the 'name' field
e.name = "Fred"
e.age = 34
e // "Joe is 34 years old" <-- error: this should be "Fred"
e.name // "Fred" <-- this is "Fred"
发生这种情况是因为,我在这个例子中的 Employee 中(不正确地)使用了 name 字段 ,而这个名字与 Person 中的 name 冲突了。所以,当扩展一个有 var 构造函数参数的类时,需要在子类中为该字段使用不同的名字。
在这个例子中,所示方法在 Employee 类中创建了一个名为 _name 的私有 val 字段。然而,这个字段在这个类之外不能被访问,所以这是一个相对小的问题。只要不使用这个字段,就不会有什么问题。
因为该方案在 Employee 类中创建了一个私有 val 字段 _name ,一些读者可能更喜欢另一个方案。
另一个方案是这样的:
- 将 Employee 构造函数设为 私有 。
- 在 Employee 的伴生对象中创建一个 apply 方法作为构造函数。
例如,这个 Person 类有一个 var 参数 name :
class Person(var name: String):
override def toString = s"$name"
可以在 Employee 类中创建一个私有构造函数,并在其伴生对象中创建一个 apply 方法,像这样:
class Employee private extends Person(""):
var age = 0
println("Employee constructor called")
override def toString = s"$name is $age"
object Employee:
def apply(_name: String, _age: Int) =
val e = new Employee()
e.name = _name
e.age = _age
e
下面这些代码会如我们所希望的运行:
val e = Employee("Joe", 33)
e // Joe is 33 years old
// update and verify the name and age fields
e.name = "Fred"
e.age = 34
e // "Fred is 34 years old"
e.name // "Fred"
这种方法允许 Employee 类从 Person 类中继承 name 字段,并且不需像前面的解决方案那样要使用 _name 。其代价是,这种方法需要写更多的代码,尽管它是一种更清晰的方法。
总之,如果要扩展一个只有 val 构造函数参数的类,请使用前面解决方案中提到的方法。但是,如果要扩展一个有 var 构造函数参数的类,请使用讨论中所示的两种解决方法中的一个。
你想知道如何在定义子类构造函数时控制父类构造函数的调用。
这是个有点棘手的问题,因为可以控制子类主构造函数中调用的父类构造函数,但不能控制被子类辅助构造函数调用的父类构造函数。
当在Scala中定义一个子类并声明指定extends部分时,可以控制其主构造函数所调用的父类构造函数。例如,在下面的代码中,Dog 类的主构造函数调用了 Pet 类的主构造函数,它是以 name 为参数的单参数构造函数:
class Pet(var name: String)
class Dog(name: String) extends Pet(name)
此外,如果 Pet 类有多个构造函数,Dog 类的主构造函数可以调用这些构造函数中的任何一个。在下一个例子中,Dog 类的主构造函数通过在其 extends 中指定 Pet 类的辅助构造函数来调用该辅助构造函数:
// (1) two-arg primary constructor
class Pet(var name: String, var age: Int):
// (2) one-arg auxiliary constructor
def this(name: String) = this(name, 0)
override def toString = s"$name is $age years old"
// calls the Pet one-arg constructor
class Dog(name: String) extends Pet(name)
或者,它也可以调用宠物类的两个参数的主构造函数:
// call the two-arg constructor
class Dog(name: String) extends Pet(name, 0)
然而,由于辅助构造函数的第一行必须是对当前类的另一个构造函数的调用,所以辅助构造函数没有办法调用父类构造函数。
如以下代码所示,Employee 类的主构造函数可以调用 Person 类中的任何构造函数,但 Employee 类的辅助构造函数必须调用其自身类中先前定义的构造函数,其第一行是 this 方法:
case class Address(city: String, state: String)
case class Role(role: String)
class Person(var name: String, var address: Address):
// no way for Employee auxiliary constructors to call this constructor
def this(name: String) =
this(name, null)
address = null //don’t use null in the real world
class Employee(name: String, role: Role, address: Address)
extends Person(name, address):
def this(name: String) =
this(name, null, null)
def this(name: String, role: Role) =
this(name, role, null)
def this(name: String, address: Address) =
this(name, null, address)
因此,没有办法直接控制子类中的辅助构造函数调用哪个父类的构造函数。事实上,由于每个辅助构造函数必须调用同一个类中先前定义的构造函数,所有的辅助构造函数最终都会调用子类的主构造函数所调用的同一个父类构造函数。
你想为一个类定义一个 equals 方法,这样就可以对对象的实例进行相互比较。
因为了解背景可以让解决方案会更容易理解,所以首先这里将分享需要知道的三件事:
首先是对象实例间的比较是使用==符号:
"foo" == "foo" // true
"foo" == "bar" // false
"foo" == null // false
null == "foo" // false
1 == 1 // true
1 == 2 // false
case class Person(name: String)
Person("Alex") == Person("Alvin") // false
这与Java不同,Java对基本类型值的比较使用==,对对象的比较使用 equals 。
第二件要知道的事是,==定义在 Any 类上,所以(a)它被所有其他类继承,(b)它调用为类定义的 equals 方法。如 1 == 2 与 1. == (2) 是一样的,这里的==方法调用了 1 对象上的equals方法,在这个例子中它是 Int 的一个实例。
要知道的第三件事是,正确编写 equals 方法是一个困难的问题,以至于Programming in Scala用了23页来讨论它,而Effective Java用了17页来介绍对象相等。Effective Java在开始讨论时说:“重写 equals 方法似乎很简单,但有很多方法可以弄错,而且后果可能很严重。” 尽管有这样的复杂性,这里将试图展示一个靠谱的解决方案,同时也将分享更多相关的参考资料。
在进入如何实现 equals 方法之前,值得注意的是,Effective Java 指出,以下情况不要去实现 equals 方法:
- 一个类的每个实例在本质上都是独一无二的。如Thread类的实例。
- 该类没有必要提供一个逻辑上的相等测试。如Java Pattern类;设计者不认为人们会想要或需要这个功能,所以它只是简单地从Java Object 类中继承了它的行为。
- 父类已经覆盖了 equals ,并且它的行为对这个类也是合适的。
- 该类是私有的或在Java中是包私有的,而且可以肯定它的 equals 方法永远不会被调用。
这是在四种情况下,不要为Java类写一个自定义的 equals 方法,这些规则对Scala也有意义。本小节的其余部分主要介绍如何正确地实现 equals 方法。
第四版Programming in Scala推荐了一个为非最终类实现 equals 方法的七步流程:
- 创建一个具有合适签名的 canEqual 方法,接受一个 Any 参数并返回一个 Boolean 。
- 如果传递给它的参数是当前类的一个实例, canEqual 应该返回 true ,否则返回 false 。( 当前 类在继承中尤其重要)。
- 用合适的签名实现 equals 方法,接受一个 Any 参数并返回一个 Boolean 。
- 将 equals 的函数体写成一个单一的match表达式。
- match表达式应该有两种情况。正如在下面的代码中的那样,第一个 case 应该是当前类的类型化模式。
- 在这个第一个 case 的主体中,为这个类中相等判断来实现一系列逻辑 “与”的测试。如果这个类扩展了 AnyRef 以外的任何类,可能需要调用父类equals方法作为这些测试的一部分。其中一个 “与” 的测试必须是对 canEqual 的调用。
- 对于第二个 case ,使用通用匹配并且生成false即可。
作为一个实际问题,任何时候实现了一个 equals 方法,也应该实现一个 hashCode 方法。在下面的例子中将其展示为一个可选的第八步。
这个小节中将展示两个例子,一个在这里,另一个在讨论中。
这里这个例子演示了如何为一个小的Scala类正确编写 equals 方法。在这个例子中,创建了一个有两个 var 字段的 Person 类:
class Person(var name: String, var age: Int)
考虑到这两个构造函数参数,下面是实现了 equals 方法和相应 hashCode 方法的 Person 类的完整代码。注释中展示了代码所指的解决方案中的对应步骤:
class Person(var name: String, var age: Int):
// Step 1 - proper signature for `canEqual`
// Step 2 - compare `a` to the current class
// (isInstanceOf returns true or false)
def canEqual(a: Any) = a.isInstanceOf[Person]
// Step 3 - proper signature for `equals`
// Steps 4 thru 7 - implement a `match` expression
override def equals(that: Any): Boolean =
that match
case that: Person =>
that.canEqual(this) &&
this.name == that.name &&
this.age == that.age
case _ =>
false
// Step 8 (optional) - implement a corresponding hashCode method
override def hashCode: Int =
val prime = 31
var result = 1
result = prime * result + age
result = prime * result + (if name == null then 0 else name.hashCode)
result
end Person
如果将这段代码与之前描述的七个步骤相比较,很容易发现它们与这些定义相匹配。解决方案的关键是第一个 case 语句里面的这段代码:
case that: Person =>
that.canEqual(this) &&
this.name == that.name &&
this.age == that.age
这段代码测试了被比较的对象是否是 Person 的一个实例:
case that: Person =>
如果这个对象不是一个 Person ,就会执行另一个 case 语句。
接下来,这行代码反过来测试当前实例( this )是 that 类的一个实例:
that.canEqual(this) ...
当涉及到继承时,这一点尤其重要,比如 Employee 的实例是 Person 的一个实例,但 Person 的实例不一定是 Employee 的一个实例。
测试之后,也就是 canEqual 之后的其他代码测试 Person 类中各个字段的相等性。
在定义了 equals 方法后,就可以用 == 来比较 Person 的实例是否相等,正如下面的ScalaTest单元测试所展示的那样:
import org.scalatest.funsuite.AnyFunSuite
class PersonTests extends AnyFunSuite:
// these first two instances should be equal
val nimoy = Person("Leonard Nimoy", 82)
val nimoy2 = Person("Leonard Nimoy", 82)
val nimoy83 = Person("Leonard Nimoy", 83)
val shatner = Person("William Shatner", 82)
// [1] a basic test to start with
test("nimoy != null") { assert(nimoy != null) }
// [2] these reflexive and symmetric tests should all be true
// [2a] reflexive
test("nimoy == nimoy") { assert(nimoy == nimoy) }
// [2b] symmetric
test("nimoy == nimoy2") { assert(nimoy == nimoy2) }
test("nimoy2 == nimoy") { assert(nimoy2 == nimoy) }
// [3] these should not be equal
test("nimoy != nimoy83") { assert(nimoy != nimoy83) }
test("nimoy != shatner") { assert(nimoy != shatner) }
test("shatner != nimoy") { assert(shatner != nimoy) }
所有这些测试都如预期那样通过。在讨论中,解释了自反性和对称性,第二个例子展示了当 Employee 类继承 Person 时,这个公式是如何工作的。
在作者写作这本书时,当给定一个带有 name 和 age 字段的 Person 类时,IntelliJ IDEA Version 2021.1.4可以生成一个 equals 方法,该方法几乎与该解决方案中的代码相同。
Scala中 == 的原理是,当它在一个类实例上被调用时,如 nimoy == shatner ,nimoy 上的 equals 方法被调用。简而言之,这段代码:
nimoy == shatner
与下面这段代码相同:
nimoy.==(shatner)
也与下面这段代码相同:
nimoy.equals(shatner)
如图所示,== 方法就像是调用 equals 的语法糖。当然可以写成 nimoy.equals(shatner) ,但没人这么做,因为== 对我们来说更易读。
Any类的equals方法基本上规定了equals方法应该如何实现。它首先指出,“这个方法的任何实现都应该是一个相等关系”。它进一步指出,一个相等关系应该有以下三个属性:
- 自反性:对于任何 Any 类型的实例 x ,x.equals(x) 应该返回 真 ;
- 对称性:对于任何 Any 类型的实例 x 和 y ,当 y.equals(x) 返回 真 时,x.equals(y) 应该也返回 真 ;
- 传递性:对于任意 AnyRef 类型的实例 x 、y 和 z ,如果 x.equals(y) 返回 真 ,y.equals(z)返回 真 ,那么 x.equals(z) 也应该返回 真 。
最后,它指出 “如果覆盖了 equals 方法,应该验证这个对应的实现仍然是一个相等关系”。
Person 的例子符合这一标准。
现在来看看当涉及到继承时如何处理这个问题。
这种方法的一个重要好处是,当在类中使用继承时,可以继续使用它。例如,在下面的代码中,Employee 类扩展了解决方案中所示的 Person 类。它使用了与第一个例子中相同的形式,并附加了一些判断:(a) 判断 Employee 中的新 role 字段,以及(b) 调用 super.equals(that) 来验证 Person 中的 equals 也为真:
class Employee(name: String, age: Int, var role: String)
extends Person(name, age):
override def canEqual(a: Any) = a.isInstanceOf[Employee]
override def equals(that: Any): Boolean =
that match
case that: Employee =>
that.canEqual(this) &&
this.role == that.role &&
super.equals(that)
case _ =>
false
override def hashCode: Int =
val prime = 31
var result = 1
result = prime * result + (if role == null then 0 else role.hashCode)
result + super.hashCode
end Employee
注意:
- canEqual 检查 Employee (而不是 Person )的实例。
- 第一个 case 表达式也测试 Employee(而不是 Person )。
- Employee 的case语句调用 canEqual ,测试其类中的字段,并调用 super.equals(that) 来使用 Person 中的 equals 代码进行相等判断。这确保了 Person 中的字段和 Employee 中的新 role 字段都是相等的。
下面的ScalaTest单元测试验证了 Employee 中的 equals 方法被正确实现:
import org.scalatest.funsuite.AnyFunSuite
class EmployeeTests extends AnyFunSuite:
// these first two instance should be equal
val eNimoy1 = Employee("Leonard Nimoy", 82, "Actor")
val eNimoy2 = Employee("Leonard Nimoy", 82, "Actor")
val pNimoy = Person("Leonard Nimoy", 82)
val eShatner = Employee("William Shatner", 82, "Actor")
// equality tests (reflexive and symmetric)
test("eNimoy1 == eNimoy1") { assert(eNimoy1 == eNimoy1) }
test("eNimoy1 == eNimoy2") { assert(eNimoy1 == eNimoy2) }
test("eNimoy2 == eNimoy1") { assert(eNimoy2 == eNimoy1) }
// non-equality tests
test("eNimoy1 != pNimoy") { assert(eNimoy1 != pNimoy) }
test("pNimoy != eNimoy1") { assert(pNimoy != eNimoy1) }
test("eNimoy1 != eShatner") { assert(eNimoy1 != eShatner) }
test("eShatner != eNimoy1") { assert(eShatner != eNimoy1) }
所有的测试都会通过,包括 eNimoy 和 pNimoy 的比较,它们分别是 Employee 和 Person 类的实例。
警告,虽然这些例子展示了实现 equals 和 hashCode 公式化方法,但Artima的博文 “How to Write an Equality Method in Java”解释道,当 equals 和 hashCode 算法依赖于 可变状态 ,即像 name 、age 和 role 这样的 var 字段时,这对集合中的对象可能会有问题。
一个基本的问题是,如果你的类的使用者把可变字段放到集合中,那么当字段放在集合中之后发生变化会引起下面这个问题。首先,像这样创建一个 Employee 实例:
val eNimoy = Employee("Leonard Nimoy", 81, "Actor")
然后将这个实例加入到 集合 中:
val set = scala.collection.mutable.Set[Employee]()
set += eNimoy
当运行下面这段代码时,会看到它返回 真 ,正如预期的那样:
set.contains(eNimoy) // true
但现在如果修改 eNimoy 实例,然后运行同样的测试,它(很可能)返回 假 :
eNimoy.age = 82
set.contains(eNimoy) // false
关于这个问题的处理,Artima的博文建议,在这种情况下,不应该覆盖 hashCode ,应该把 equals 方法换个名字。这样,类将继承 hashCode 和 equals 的默认实现。
这里不会深入讨论 hashCode 算法,但在 Effective Java 中,Joshua Bloch写道,下面这些语句构成了 hashCode 算法描述(他从 Java Object 文档中对其进行了修改):
- 当 hashCode 在一个应用程序中重复调用一个对象时,它必须始终如一地返回相同的值,前提是 equals 方法比较中的信息没有改变。
- 如果两个对象根据它们的 equals 方法是相等的,它们的 hashCode 值必须相同。
- 如果两个对象根据它们的 equals 方法是不相等的,不要求它们的 hashCode 值是不同的。但是为不相等的对象产生不同的哈希值可能会提高哈希表的性能。
作为对 hashCode 算法的简单调研,这里在 Person 类中使用的算法与 Effective Java 中的建议一致:
// note: the `if name == null then 0` test is required because
// `null.hashCode` throws a NullPointerException
override def hashCode: Int =
val prime = 31
var result = 1
result = prime * result + age
result = prime * result + (if name == null then 0 else name.hashCode)
result
接下来,这是通过将 Person 作为样例类,然后用Scala 3的 scalac 命令编译其代码,再用JAD反编译产生的hashCode 方法:
public int hashCode() {
int i = 0xcafebabe;
i = Statics.mix(i, productPrefix().hashCode());
i = Statics.mix(i, Statics.anyHash(name()));
i = Statics.mix(i, age());
return Statics.finalizeHash(i, 2);
}
IntelliJ IDEA的 生成代码 选项为Scala 2.x版本的 Person 类生成了下面这样的代码:
// scala 2 syntax
override def hashCode(): Int = {
val state = Seq(super.hashCode(), name, age)
state.map(_.hashCode()).foldLeft(0)((a, b) => 31 * a + b)
}
- Programming in Scala, Martin Odersky et al. (Artima Press)。
- Effective Java, Joshua Bloch (Addison-Wesley)。
- Artima博客文章 “How to Write an Equality Method in Java”( https://oreil.ly/yNUFZ )。
- 维基百科对相等关系的定义( https://oreil.ly/RnDdF )。
- 请参阅23.11小节,“控制类如何使用跨界相等性进行比较”,关于跨界相等性的讨论,以及23.12小节,“使用CanEqual类型族限制相等比较”,关于如何用 CanEqual 类型族限制相等比较的讨论。
当你把一个类的字段定义为 var 时,Scala会自动为它生成访问(getter)和修改(setter)方法,而把一个字段定义为 val 会自动生成访问方法,那么如何做到既不生成访问方法也不生成修改方法?
解决方案是:
- 在 val 或 var 声明中添加 private 访问修饰符,这样它只能被当前类的实例访问
- 添加 protected 的访问修饰符,这样它就可以被扩展当前类的类所访问。
这里有一个 private 访问修饰符例子:这个 Animal 类将 _numLegs 声明为一个私有字段。因此,其他非 Animal 实例不能访问 _numLegs ,但请注意到 iHaveMoreLegs 方法可以访问另一个 Animal 实例的 _numLegs 字段(如 that._numLegs ):
class Animal:
private var _numLegs = 2
def numLegs = _numLegs // getter
def numLegs_=(numLegs: Int): Unit = // setter
_numLegs = numLegs
// note that we can access the `_numLegs` field of
// another Animal instance (`that`)
def iHaveMoreLegs(that: Animal): Boolean =
this._numLegs > that._numLegs
对于这段代码,下面的ScalaTest 断言 测试全部通过:
val a = Animal()
assert(a.numLegs == 2) // getter test
a.numLegs = 4
assert(a.numLegs == 4) // setter test
// the default number of legs is 2, so this is true
val b = Animal()
assert(a.iHaveMoreLegs(b))
另外,如果试图从类外访问_numLegs,会发现这样的代码无法通过编译:
//a._numLegs // error, cannot be accessed (others cannot access _numLegs)
如果将 Animal 中的 _numLegs 字段从 private 改为 protected ,就可以创建一个 Animal 的子类,并重写 _numLegs 的值:
class Dog extends Animal:
_numLegs = 4
接下来创建两个 Dog 实例,可以看到下面所有的测试都能通过,就跟之前的测试一样:
val a = Dog()
assert(a.numLegs == 4)
a.numLegs = 3
assert(a.numLegs == 3)
// the default number of legs is 4, so this is true
val b = Dog()
assert(b.iHaveMoreLegs(a))
同样地, _numLegs 仍然不能从类的外部访问,所以这行代码无法通过编译:
a._numLegs // compiler error, cannot be accessed
Scala构造函数参数和字段默认是公开,所以当不希望这些字段有访问方法或修改方法时,需要将字段定义为 private 或 protected ,这样可以获得相应的控制级别。
作为提醒,如果想更多地了解Scala的工作原理,可以创建小的测试例子,然后用 javap 反编译,这样做会对理解Scala有很大帮助。例如,这里有个有两个 val 字段的类,其中 i 是公开的,d 是 private 的:
class Foo(val i: Int, private val d: Double)
当用 scalac 编译该类,然后用 javap 反编译时,可以看到JVM看到了什么:
$ javap -private Foo
Compiled from "Foo.scala"
public class Foo {
public Foo(int, double);
private final int i; // i is private and has
public int i(); // a public accessor method
private final double d; // d is private and has
private double d(); // a private accessor method
}
注意,这里在这段代码中加入了注释来解释它的输出,但其余部分是由 javap 命令生成的。在命令行输入 javap -help ,可以看到其他可用于反编译类文件的选项。
你想要覆盖Scala生成的getter或setter方法。
这是一个有点棘手的问题,如果想坚持使用Scala的命名惯例的话是无法直接覆盖Scala生成的getter和setter方法。例如,有一个 Person 的类,其构造参数为 name ,如果试图按照Scala惯例创建getter和setter方法,下面这段代码将无法编译:
// error: this won’t work
class Person(private var name: String):
def name = name
def name_=(aName: String): Unit =
name = aName
试图编译这段代码会产生下面这样错误:
2 | def name = name
| ^
| Overloaded or recursive method name needs return type
本书将在讨论中更多地探讨这个问题,比较简短的回答是,构造函数参数和getter方法都是 name ,而Scala不允许这样。
为了解决这个问题,需要改变类构造函数中使用的字段的名称,这样它就不会与getter方法的名称相冲突。一种方法是在参数名上加一个前导下划线,所以如果想手动创建一个名为 name 的getter方法,在构造函数中使用参数名 _name ,然后根据Scala惯例声明getter和setter方法即可:
class Person(private var _name: String):
def name = _name // accessor
def name_=(aName: String): Unit = _name = aName // mutator
注意,构造函数参数被声明为 private 和 var ,private 关键字使Scala不会将该字段暴露给其他类,而 var 允许改变 _name 的值。
正如将在讨论中所看到的,创建一个名为 name 的getter方法和一个名为 name_= 的setter方法符合Scala命名字段的惯例,它可以让类的使用者这样使用:
val p = Person("Winston Bishop")
// setter
p.name = "Winnie the Bish"
// getter
println(p.name) // prints "Winnie the Bish"
如果不想遵循这个Scala命名规则来命名getters和setters,可以使用任何想用的其他方法。例如,可以按照JavaBeans的风格来将其命名为 getName 和 setName 。
总结一下,覆盖默认的getter和setter方法的是:
- 创建一个 private 的 var 构造函数参数,其名称是想从类中引用的参数。在这个例子中,这个字段被命名为 _name 。
- 定义希望其他类使用的getter和setter方法名。在这个例子中,getter方法名是 name ,setter方法名是 name_= (结合Scala的语法糖,用户可以写成p.name = "Winnie the Bish")。
- 根据需要修改getter和setter方法的主体。
虽然这些例子使用在类的构造函数中的字段,但同样的原则也适用于在类内部定义的字段。
当将构造参数定义为一个 var 字段并编译时,Scala会使该字段成为类的私有部分,并自动生成可以用来访问该字段的getter和setter方法。例如,给定这个 Storck 类:
class Stock(var symbol: String)
在用 scalac 编译成类文件后,再用JAD这样的工具进行反编译,可以看到这样的Java代码:
public class Stock {
public Stock(String symbol) {
this.symbol = symbol;
super();
}
public String symbol() {
return symbol;
}
public void symbol_$eq(String x$1) {
symbol = x$1;
}
private String symbol;
}
可以看到Scala编译器生成了两个方法:一个名为 symbol 的getter方法和一个名为 symbol_$eq 的setter方法。第二个方法与在Scala代码中命名为 symbol_= 的方法是一样的,但是Scala需要把=符号翻译成 $eq ,以便兼容JVM。
第二个方法的名字有点不寻常,但它遵循了Scala的惯例,当它与一些语法糖混合时,它可以像这样在Stock 实例上设置符号字段:
stock.symbol = "GOOG"
其工作方式是,Scala会将那行代码转换为这行代码:
stock.symbol_$eq("GOOG")
这是一般情况下不必考虑的事情,除非想覆盖修改方法。
你想知道如何用一个代码块来初始化一个类中的字段,或者通过调用一个方法或函数来初始化它。
将所需的代码块或函数赋给类体内的一个字段。如果算法需要长时间的运行,可以选择将该字段定义为 lazy 。
在下面的例子类中,字段 text 被设置为一个代码块——一个 try/catch 块它要么返回(a)一个文件中包含的文本,要么返回(b)一个错误信息,这取决于该文件是否存在并且可以被读取:
import scala.io.Source
class FileReader(filename: String):
// assign this block of code to the 'text' field
val text =
// 'fileContents' will either contain the file contents,
// or the exception message as a string
val fileContents =
try
Source.fromFile(filename).getLines.mkString
catch
case e: Exception => e.getMessage
println(fileContents) // print the contents
fileContents // return the contents from the block
@main def classFieldTest =
val reader = FileReader("/etc/passwd")
因为对 text 字段的赋值是在 FileReader 类的主体中,这段代码是在该类的构造函数中,当创建该类的新实例时将被执行。因此,当编译并运行这个例子时,它将打印出文件的内容或试图读取文件时产生的异常信息。无论哪种方式,代码块都会被执行——包括println语句——结果被赋到 text 字段。
当需要字段在被访问前不被求值时可以将字段定义为 lazy 的。为了证明这一点,可以更新前面的例子,使 text 成为一个 lazy 的 val 字段:
import scala.io.Source
class FileReader(filename: String):
// the only difference from the previous example is that
// this field is defined as 'lazy'
lazy val text =
val fileContents =
try
Source.fromFile(filename).getLines.mkString
catch
case e: Exception => e.getMessage
println(fileContents)
fileContents
@main def classFieldTest =
val reader = FileReader("/etc/passwd")
现在,当这个例子被运行时,什么也没有发生;没有看到输出,因为 text 字段在被访问之前没有被求值。这个代码块直到调用 reader.text 才被执行,这时才会看到 println 语句的输出。
这就是 lazy 字段的工作原理:即使它被定义为类中的一个字段——这意味着它是类构造函数的一部分——该代码也不会被执行,直到显式使用它。
当字段在算法的正常处理过程中可能不会被访问时,或者当运行算法需要很长时间而想把它推迟到以后时,将字段定义为 lazy 是一种有用的方法。
- 这些例子中使用了 try/catch 表达式,但使用 Try 类可以更简洁地编写代码。参阅24.6小节,“使用Scala的错误处理类型(Option、Try和Either)”,以了解如何使用 Try 、Success 和 Failure 来使代码更加简洁的细节。
- 请参阅5.2小节以了解字段在类构造函数中是如何工作的。
你想知道如何为一个类中未初始化的var字段设置类型,假设写下这样的代码:
var x =
那么要如何去完成这个表达式呢。
一般来说,最好的方法是将字段定义为一个 Option 。对于某些类型,如 字符串 和数字字段,可以指定默认的初始值。
例如,设想你正在创建下一个伟大的社交网络,为了鼓励人们注册,在注册过程中只要求提供用户名和密码。因此,在类构造函数中把 username 和 password 定义为字段:
case class Person(var username: String, var password: String) ...
然而,之后有可能想从用户那里获得其他信息,包括他们的年龄、名字、姓氏和地址。设置这前三个 var 字段的默认值很简单:
var age = 0
var firstName = ""
var lastName = ""
但是当到了 address 字段的时候,该怎么办呢?解决办法是将 address 字段定义为一个 Option ,如下所示:
case class Person(var username: String, var password: String):
var age = 0
var firstName = ""
var lastName = ""
var address: Option[Address] = None
case class Address(city: String, state: String, zip: String)
当用户提供了一个地址时,就用 Some 来对其进行赋值,像这样:
val p = Person("alvinalexander", "secret")
p.address = Some(Address("Talkeetna", "AK", "99676"))
当需要访问 address 字段时,有多种方法可以使用,这些方法将在24.6小节 “使用Scala的错误处理类型(Option、Try和Either)”中详细讨论。作为一个例子,可以用 foreach 来打印 address 字段:
p.address.foreach { a =>
println(s"${a.city}, ${a.state}, ${a.zip}")
}
如果 address 字段没有被赋值,address 的值是 None ,对它调用 foreach 不会有任何作用。如果 address 字段被赋值了,它将是一个包含 address 的 Some ,Some 的 foreach 方法从 Some 中提取值并在 foreach 打印数据。
可以把 None 的 foreach 方法的主体看成是这样定义的:
def foreach[A,U](f: A => U): Unit = {}
因为 None 一定是空的——它是一个空的容器——它的 foreach 方法本质上是一个什么都不做的方法。(它的实现方式与此不同,但这是一种便于理解的方式。)
同样,当在 Some 上调用 foreach 时,它知道它包含一个元素,比如一个 Address 的实例,所以它将传入的函数应用于这个元素。
- 需要强调的是,Scala提供了一个非常好的机会不再去使用 空 值。24.5小节,“消除代码中的空值”,展示了消除 null 值常见的方法。
- 在Scala框架中,例如Play Framework,Option 字段是很常用的。参阅24.6小节,“使用Scala的错误处理类型(Option、Try和Either)”,详细讨论如何处理 Option 值。
- 与此相关的是,有时可能需要覆盖一个数值字段的默认类型。对于这些情况,请参阅3.3小节,“覆盖默认数值类型”。
你想了解如何通过 样例类 来生成 match 表达式、Akka actors,或者其他类似情况来生成模板代码,包括访问和修改方法,以及 apply 、 unapply 、 toString 、 equals 和 hashCode 方法等等。
当希望类有许多额外的内置功能时——比如在函数式编程中创建类——可以将类定义为一个 样例类 ,在其构造函数中声明它所需要的参数:
// name and relation are 'val' by default
case class Person(name: String, relation: String)
将一个类定义为样例类,会有很多有用的模板代码生成,其好处在于:
- 访问方法是为构造函数参数生成的,因为样例类构造函数参数默认为 val 。对于声明为 var 的参数,也会生成修改方法。
- 生成了一个好的默认toString方法。
- 生成了unapply方法,使得在 match 表达式中使用样例类变得容易。
- 生成了 equals 和 hashCode 方法,因此实例可以很容易地被比较以及在集合中使用。
- 生成了一个 copy 方法,这使得从现有的实例创建新的实例变得很容易(这是函数式编程中常用的技术)。
下面是这些功能的演示。首先,定义一个样例类和它的实例:
case class Person(name: String, relation: String)
val emily = Person("Emily", "niece") // Person(Emily,niece)
样例类的构造函数参数默认为val,所以为参数生成了访问方法,但没有生成修改方法:
scala> emily.name
res0: String = Emily
// can’t mutate `name`
scala> emily.name = "Miley"
1 |emily.name = "Miley"
|^^^^^^ ^^
|Reassignment to val name
如果写的是非FP风格的代码,可以把构造函数参数声明为 var 字段,然后访问和修改方法都会生成:
scala> case class Company(var name: String)
defined class Company
scala> val c = Company("Mat-Su Valley Programming")
c: Company = Company(Mat-Su Valley Programming)
scala> c.name
res0: String = Mat-Su Valley Programming
scala> c.name = "Valley Programming"
c.name: String = Valley Programming
样例类也有一个很好的默认 toString 实现:
scala> emily
res0: Person = Person(Emily,niece)
因为一个 unapply 方法是为样例类自动创建的,所以当需要提取 match 表达式中的信息时,它可以很好地工作:
scala> emily match { case Person(n, r) => println(s"$n, $r") }
(Emily,niece)
equals 和 hashCode 方法是根据样例类的构造参数生成的,因此实例可以在 map 和 集合中使用,并在 if 表达式中轻松比较:
scala> val hannah = Person("Hannah", "niece")
hannah: Person = Person(Hannah,niece)
scala> emily == hannah
res0: Boolean = false
样例类也会生成一个 copy 方法,当需要复制一个对象并在复制过程中改变一些字段时,这个方法很有帮助:
scala> case class Person(firstName: String, lastName: String)
// defined case class Person
scala> val fred = Person("Fred", "Flintstone")
val fred: Person = Person(Fred,Flintstone)
scala> val wilma = fred.copy(firstName = "Wilma")
val wilma: Person = Person(Wilma,Flintstone)
这种技术在FP中普遍使用,我将其称为 边复制边更新 。
样例类主要是为了在以FP风格编写Scala代码时创建不可变的记录。事实上,纯FP开发者认为样类与ML、Haskell和其他FP语言中的不可变记录相似。因为它们是为FP设计的——在那里所有的东西都是不可变的——样例类的构造函数参数默认为 val 。
如解决方案中所示,当创建一个样类时,Scala会为类生成大量的代码。要想看到生成的代码,首先编译一个简单的样例类,然后用 javap 反编译它。例如,把这些代码放在一个名为 Person.scala 的文件中:
case class Person(var name: String, var age: Int)
然后编译它:
$ scalac Person.scala
这将创建两个类文件: Person.class 和 Person$.class 。Person.class 文件包含 Person 类 的字节码,可以用下面的命令反编译它的代码:
$ javap -public Person
将会有以下输出,这就是 Person 类的公共签名:
Compiled from "Person.scala"
public class Person implements scala.Product,java.io.Serializable {
public static Person apply(java.lang.String, int);
public static Person fromProduct(scala.Product);
public static Person unapply(Person);
public Person(java.lang.String, int);
public scala.collection.Iterator productIterator();
public scala.collection.Iterator productElementNames();
public int hashCode();
public boolean equals(java.lang.Object);
public java.lang.String toString();
public boolean canEqual(java.lang.Object);
public int productArity();
public java.lang.String productPrefix();
public java.lang.Object productElement(int);
public java.lang.String productElementName(int);
public java.lang.String name();
public void name_$eq(java.lang.String);
public int age();
public void age_$eq(int);
public Person copy(java.lang.String, int);
public java.lang.String copy$default$1();
public int copy$default$2();
public java.lang.String _1();
public int _2();
}
接下来,反编译 Person$.class ,它包含伴生对象的字节码:
$ javap -public Person$
Compiled from "Person.scala"
public final class Person$ implements
scala.deriving.Mirror$Product,java.io.Serializable {
public static final Person$ MODULE$;
public static {}; public
Person apply(java.lang.String, int);
public Person unapply(Person);
public java.lang.String toString();
public Person fromProduct(scala.Product);
public java.lang.Object fromProduct(scala.Product);
}
正如上面所示,当把一个类声明为样例类时,Scala会生成 大量 的源代码。
作为比较,如果把该代码中的关键词 case 去掉——使其成为一个普通的类,然后再编译,它只会创建 Person.class 文件。当反编译它时会发现Scala只生成了以下代码:
Compiled from "Person.scala"
public class Person {
public Person(java.lang.String, int);
public java.lang.String name();
public void name_$eq(java.lang.String);
public int age();
public void age_$eq(int);
}
这是个很大的区别。如果需要这些功能,这是件好事,事实上,在FP中,所有这些方法都被派上了用场。然而,如果不需要所有这些额外的功能,不如考虑使用一个普通的 类 声明,然后根据需要添加所需方法。
重要的是虽然样例类非常方便,但其中没有任何东西是不能靠自己编码实现的。
Scala也有 样例对象 ,它与样例类相似,生成了许多类似的附加方法。样例对象在某些情况下很有用,比如为Akka actors 创建不可变的消息时:
sealed trait Message
case class Speak(text: String) extends Message
case object StopSpeaking extends Message
在这个例子中,Speak 需要一个参数,所以它被声明为一个样例类,但是 StopSpeaking 不需要参数,所以它被声明为一个样例对象。
不过要注意的是,在Scala 3中通常可以用枚举来代替样例对象:
enum Message:
case Speak(text: String)
case StopSpeaking
- 在18.7小节 “向 Actors 发送消息” 中讨论了为Akka消息使用样例对象。
- 当想在一个样例类中使用多个构造函数时,请参阅5.15小节。
- 关于如何使用枚举的更多细节,请参阅6.12小节,“如何用枚举创建命名值集合”。
与前面的小节类似,你想知道如何为一个样例类而不是普通类定义一个或多个辅助构造函数。
样例类是一种特殊类型的类,它能生成大量的模板代码。由于它们的工作方式,向样例类添加显式的辅助构造函数与普通类不同。这是因为它们不是真正的构造函数:它们是类的伴生对象中的 apply 方法。
为了证明这一点,假设有一个Person.scala的文件中有这样一个样例类:
// initial case class
case class Person(var name: String, var age: Int)
这样就可以通过下面这段代码创建一个 Person 实例:
val p = Person("John Smith", 30)
虽然这段代码看起来和普通的类一样,但实际上它的实现是不同的。当写下这一行代码时,Scala编译器会在将其转换为这样:
val p = Person.apply("John Smith", 30)
这是对 Person 类的伴生对象中的 apply 方法的调用。写代码时是看不见这一行的,只能看到前面那行,但这是编译器对代码的翻译。因此,如果想给样例类增加新的构造函数,就得写新的 apply 方法。(为了清楚起见, 构造函数 这个词在这里用得很宽泛。写一个 apply 方法更像是写一个工厂方法)。
例如,假设为了创建新的 Person 实例要给其添加两个辅助构造函数,一个不指定任何参数,另一个只指定 name ,那么解决方案就是在 Person.scala 中的 Person 样例类的伴生对象中添加 apply 方法:
// the case class
case class Person(var name: String, var age: Int)
// the companion object
object Person:
def apply() = new Person("<no name>", 0) // zero-args constructor
def apply(name: String) = new Person(name, 0) // one-arg constructor
下面的代码展示了这一做法的有效性:
val a = Person() // Person(<no name>,0)
val b = Person("Sarah Bracknell") // Person(Sarah Bracknell,0)
val c = Person("Sarah Bracknell", 32) // Person(Sarah Bracknell,32)
// verify the setter methods work
a.name = "Sarah Bannerman"
a.age = 38
println(a) // Person(Sarah Bannerman,38)
最后,注意到在伴生对象的 apply 方法中,new 关键字被用来创建一个新的 Person 实例:
object Person:
def apply() = new Person("<no name>", 0)
---
这是需要 new 的罕见情况之一。在这种情况下,它告诉编译器要使用类的构造函数。如果不使用 new ,编译器会认为这里指的是伴生对象中的 apply 方法,这会产生一个循环或递归引用。
- 5.14小节详细说明了样例类的工作原理。
- 关于工厂方法的更多信息,请参阅我的博客:Java factory pattern tutorial( https://oreil.ly/CdHjh )。