Post

Scala基础教程 第3节 类和方法

Scala基础教程 第3节 类和方法

https://docs.scala-lang.org/overviews/scala-book/classes.html

https://docs.scala-lang.org/tour/classes.html

为了支持面向对象编程(OOP),Scala提供了(class)。其语法比Java简洁得多,但仍然易于使用和阅读。

3.1 定义类

下面是一个Scala类,其构造器(constructor)有两个参数firstNamelastName

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

这个类定义大致等价于以下Java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Person {
    private String firstName;
    private String lastName;

    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public String getFirstName() {
        return this.firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return this.lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
}

可以像这样创建Person实例:

1
val p = new Person("Bill", "Panner")

定义构造器参数会自动在类中创建字段(field)。在这个例子中,可以像这样访问firstNamelastName字段:

1
println(p.firstName + " " + p.lastName) // Bill Panner

由于这两个字段都定义为var,因此它们是可变的:

1
2
p.firstName = "William"
p.lastName = "Bernheim"

也可以将字段定义为val,使其不可变:

1
class Person(val firstName: String, val lastName: String)

现在如果尝试修改Person实例的字段,会看到一个错误(与1.4节介绍的val变量一样):

1
2
3
scala> p.firstName = "Fred"
                   ^
       error: reassignment to val

带有valvar的构造器参数是公有的,没有valvar的参数是私有的,仅在类内可见。

1
2
3
4
class Point(x: Int, y: Int)

val point = new Point(1, 2)
point.x  // error: value x is not a member of Point

提示:如果用Scala编写面向对象编程(OOP)代码,通常创建var字段,以便对其进行修改。如果用Scala编写函数式编程(FP)代码,通常会使用case类,而不是像这样的类。

3.2 构造器

3.2.1 主构造器

在Scala中,类的主构造器(primary constructor)是以下各项的组合:

  • 构造器参数
  • 在类体中调用的方法
  • 在类体中执行的语句和表达式

Scala类体中声明的字段处理方式类似于Java,在类首次实例化时赋值。例如:

1
2
3
4
5
6
7
8
9
10
class Person(var firstName: String, var lastName: String) {
  println("the constructor begins")

  var age = 0

  def fullName: String = s"$firstName $lastName"

  println(fullName)
  println("the end of the constructor")
}

类体中可以包含方法、字段和类型,统称为成员(member)。上面的Person类有4个成员:字段firstNamelastNameage以及方法fullName

构造Person对象时,将依次执行类体中的println()调用和age字段初始化:

1
2
3
4
5
scala> val p = new Person("Kim", "Carnes")
the constructor begins
Kim Carnes
the end of the constructor
val p: Person = Person@27a90ce5

这大致等价于以下Java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Person {
    private String firstName;
    private String lastName;
    private int age;

    public Person(final String firstName, final String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
        System.out.println("the constructor begins");
        this.age = 0;
        System.out.println(this.fullName());
        System.out.println("the end of the constructor");
    }

    public String fullName() {
        return firstName + " " + lastName();
    }

    // getters and setters
}

3.2.2 辅助构造器

https://docs.scala-lang.org/overviews/scala-book/classes-aux-constructors.html

可以通过定义名为this的方法来定义辅助构造器(auxiliary constructor)。需要遵循以下规则:

  • 每个辅助构造器必须具有不同的签名(参数列表)。
  • 每个构造器都必须调用一个之前定义的构造器。

下面是一个定义了多个构造器的类的示例:

1
2
3
4
5
6
7
8
// the primary constructor
class Person(var firstName: String, var lastName: String) {
  // one-arg auxiliary constructor
  def this(firstName: String) = this(firstName, "")

  // zero-arg auxiliary constructor
  def this() = this("", "")
}

可以通过几种不同的方式创建Person实例:

1
2
3
val p1 = new Person("Kim", "Carnes")
val p2 = new Person("Kim") // same as new Person("Kim", "")
val p3 = new Person        // same as new Person("", "")

注:也可以为构造器参数指定默认值,这样就不必使用辅助构造器了:

1
class Person(var firstName: String = "", var lastName: String = "")

3.3 方法

https://docs.scala-lang.org/overviews/scala-book/methods-first-look.html

3.3.1 定义方法

在Scala中,使用关键字def定义方法(method)。例如:

1
def double(a: Int) = a * 2

定义了一个名为double的方法,接受一个名为a的整数参数,=后是方法体,返回该整数的两倍(不需要return关键字)。

在Scala中,方法是在类内定义的(就像Java一样)。但出于测试目的,也可以在REPL中定义方法。

像这样调用方法:

1
val x = double(2)

前面例子省略了方法的返回类型,编译器会根据类体自动推断。也可以显式声明方法的返回类型:

1
def double(a: Int): Int = a * 2

当方法体有多行时,可以将其放在花括号内,称为(block)。块中最后一个表达式的值作为其结果。 例如:

1
2
3
4
5
def addThenDouble(a: Int, b: Int): Int = {
  val sum = a + b
  val doubled = sum * 2
  doubled
}

注:在Scala中调用方法时可以直接用{}代替(),例如:

1
2
3
4
val x = double {
  val a = 1 + 1
  a * 2
}

接受函数参数的方法也有两种调用方式:

1
2
3
4
5
6
7
8
9
10
11
val a = nums.map(x => (x + 1) * 2)

val b = nums.map(x => {
  val y = x + 1
  y * 2
})

val c = nums.map { x =>
  val y = x + 1
  y * 2
}

3.3.2 默认参数

https://docs.scala-lang.org/tour/default-parameter-values.html

Scala允许为方法参数提供默认值,在调用方法时可以省略这些参数。

1
2
3
4
5
6
def log(message: String, level: String = "INFO") = {
  println(s"$level: $message")
}

log("System starting")  // prints INFO: System starting
log("User not found", "WARNING")  // prints WARNING: User not found

方法log()的参数level具有默认值,因此是可选的。在第二个调用中,参数"WARNING"覆盖了默认值"INFO"。使用默认参数可以达到与Java中的重载方法相同的效果。

也可以为构造器参数提供默认值:

1
class Point(val x: Double = 0, val y: Double = 0)

注意,如果调用者省略了一个参数,则后续参数必须命名。例如:

1
2
val point1 = new Point(y = 1) // same as new Point(0, 1)
val point2 = new Point(1)     // same as new Point(1, 0)

为了避免引起歧义,Scala不允许有两个重载方法都具有默认参数。考虑下面的两个方法:

1
2
def func(x: Int = 34): Unit = println(s"func($x)")
def func(y: String = "abc"): Unit = println(s"func($y)")

如果调用func(),编译器无法知道应该调用哪个方法。即使显式提供参数(如func(12)),仍然会编译失败。

注:下面的两个重载方法是合法的,func(12)会调用第一个方法。

1
2
def func(x: Int): Unit = println(s"func($x)")
def func(x: Int, y: String = "abc"): Unit = println(s"func($x, $y)")

3.3.3 命名参数

https://docs.scala-lang.org/tour/named-arguments.html

在调用方法时,可以指定参数名,如下所示(类似于Python的关键字参数):

1
2
3
4
5
6
7
def printName(first: String, last: String): Unit =
  println(s"$first $last")

printName("John", "Public")  // Prints "John Public"
printName(first = "John", last = "Public")  // Prints "John Public"
printName(last = "Public", first = "John")  // Prints "John Public"
printName("Elton", last = "John")  // Prints "Elton John"

命名参数可以按任何顺序写。但是,从左到右一旦有参数不按形参顺序,其余的参数必须都命名。例如:

1
2
3
4
5
6
7
8
9
10
11
12
def printFullName(first: String, middle: String = "Q.", last: String): Unit =
  println(s"$first $middle $last")

printFullName("John", "Quincy", "Public")  // Prints "John Quincy Public"
printFullName("John", middle = "Quincy", "Public")  // Prints "John Quincy Public"

printFullName(first = "John", last = "Public")  // Prints "John Q. Public"
printFullName("John", last = "Public")  // Prints "John Q. Public"
printFullName("John", "Public")  // error: not enough arguments

printFullName(last = "Public", first = "John")  // Prints "John Q. Public"
printFullName(last = "Public", "John")  // error: positional after named argument

在最后一个调用中,第一个参数是乱序的,因此第二个参数必须命名。

3.3.4 getter/setter语法

默认情况下成员是公有的。使用private访问修饰符将其声明为私有,只能在类内访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Point {
  private var _x = 0
  private var _y = 0
  private val bound = 100

  def x: Int = _x

  def x_=(newValue: Int): Unit = {
    if (newValue < bound)
      _x = newValue
    else
      printWarning()
  }

  def y: Int = _y

  def y_=(newValue: Int): Unit = {
    if (newValue < bound)
      _y = newValue
    else
      printWarning()
  }

  private def printWarning(): Unit =
    println("WARNING: Out of bounds")
}

在这个Point类中,数据存储在私有字段_x_y中,并为其定义了访问方法:

  • getter方法xy返回_x_y的值。
  • setter方法x_=y_=验证并设置_x_y的值。注意setter的特殊语法:方法名在getter后面添加_=
1
2
3
val point1 = new Point
point1.x = 99
point1.y = 101 // prints the warning

3.3.5 运算符

https://docs.scala-lang.org/tour/operators.html

在Scala中,运算符是方法。任何具有单个参数的方法都可以用作中缀运算符(infix operator)。例如,+运算符可以使用.语法调用:10.+(1),但中缀运算符更易读:10 + 1

定义运算符

可以使用任何合法标识符作为运算符,包括名字(如add)和符号(如+)。

1
2
3
4
5
6
7
8
case class Vec(x: Double, y: Double) {
  def +(that: Vec) = Vec(this.x + that.x, this.y + that.y)
}

val vector1 = Vec(1.0, 2.0)
val vector2 = Vec(3.0, 4.0)

val vector3 = vector1 + vector2 // Vec(4.0, 6.0)

Vec有一个方法+,用于将两个向量相加。

使用括号可以构建复杂的表达式。以下是MyBool类的定义,其中包括方法andornegate

1
2
3
4
5
case class MyBool(x: Boolean) {
  def and(that: MyBool): MyBool = if (x) that else this
  def or(that: MyBool): MyBool = if (x) this else that
  def negate: MyBool = MyBool(!x)
}

现在可以使用andor作为中缀运算符:

1
2
def not(x: MyBool) = x.negate
def xor(x: MyBool, y: MyBool) = (x or y) and not(x and y)

这使得xor的定义更容易阅读(使用.语法要写为x.or(y).and(not(x.and(y))))。

注:notxor方法并没有访问MyBool的实例字段,因此适合放在类MyBool的伴生对象中(详见3.5节)。

运算符优先级

当表达式使用多个运算符时,将根据运算符第一个字符的优先级进行求值。下面按优先级递减的顺序列出,同一行的字符具有相同的优先级:

1
2
3
4
5
6
7
8
9
10
(characters not shown below)
* / %
+ -
:
< >
= !
&
^
|
(all letters, $, _)

例如,表达式

1
a + b ^? c ?^ d less a ==> b | c

等价于

1
((a + b) ^? (c ?^ d)) less ((a ==> b) | c)

其中出现的运算符按优先级从高到低依次是?^, +, ==>, ^?, |less

3.3.6 嵌套方法

https://docs.scala-lang.org/tour/nested-functions.html

在Scala中,可以嵌套方法定义。下面的factorial()方法用于计算给定数字的阶乘:

1
2
3
4
5
6
7
8
def factorial(x: Int): Int = {
  def fact(x: Int, accumulator: Int): Int = {
    if (x <= 1) accumulator else fact(x - 1, x * accumulator)
  }  
  fact(x, 1)
}

println("Factorial of 3: " + factorial(3))

3.3.7 多个参数列表

https://docs.scala-lang.org/tour/multiple-parameter-lists.html

方法可以有多个参数列表。例如,Scala集合API中Iterable特质的foldLeft()方法定义如下:

1
2
3
4
trait Iterable[A] {
  ...
  def foldLeft[B](z: B)(op: (B, A) => B): B = ...
}

foldLeft()将双参数函数op应用于初始值z和该集合的所有元素,从左向右(即op(...op(op(z, e1), e2)..., eN))。下面是使用示例:

1
2
3
val numbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val res = numbers.foldLeft(0)((m, n) => m + n)
println(res) // 55

res是0与numbers中所有元素相加的总和。

用例

下面是多参数列表的一些建议用法。

(1)辅助类型参数推断

在Scala 2中,泛型方法的类型推断一次处理一个参数列表。假设有以下方法:

1
def foldLeft1[A, B](as: List[A], z: B, op: (B, A) => B): B = ???

以下调用方式会编译失败:

1
val notPossible = foldLeft1(numbers, 0, _ + _)

必须显式指定类型参数AB,或者显式指定函数op的参数类型,像这样:

1
2
val firstWay = foldLeft1[Int, Int](numbers, 0, _ + _)
val secondWay = foldLeft1(numbers, 0, (a: Int, b: Int) => a + b)

否则编译器无法推断函数_ + _的类型,因为它仍然在推断AB

通过将参数op移动到单独的参数列表中,AB就能在第一个参数列表中被推断出来。然后将其用于第二个参数列表,从而可以推断_ + _的类型为(Int, Int) => Int

1
2
def foldLeft2[A, B](as: List[A], z: B)(op: (B, A) => B): B = ???
def possible = foldLeft2(numbers, 0)(_ + _)

这个定义不需要任何类型提示就可以推断出所有类型参数。

(2)隐式参数

为了将某些参数指定为implicit,必须将它们放在单独的隐式参数列表中(详见3.3.9节)。例如:

1
def execute(arg: Int)(implicit ec: ExecutionContext) = ???

(3)部分应用

当使用比声明少的参数列表调用方法时,将产生一个接受缺少的参数列表作为其参数的函数,这称为部分应用(partial application)。

例如:

1
2
3
4
5
6
7
8
val numbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val numberFunc = numbers.foldLeft(List[Int]()) _ // ((List[Int], Int) => List[Int]) => List[Int]

val squares = numberFunc((xs, x) => xs :+ x*x)
println(squares) // List(1, 4, 9, 16, 25, 36, 49, 64, 81, 100)

val cubes = numberFunc((xs, x) => xs :+ x*x*x)
println(cubes)  // List(1, 8, 27, 64, 125, 216, 343, 512, 729, 1000)

与柯里化的比较

有时会将具有多个参数列表的方法称为柯里化(currying):将接受多个参数的函数转换为一系列接受单个参数的函数。

不建议使用 “curry” 一词指代Scala的多参数列表。原因有两个:

  • Scala的多参数列表是作为语言的一部分直接实现的,而不是从单参数函数中派生出来的。
  • 容易与Scala标准库的currieduncurried方法混淆,它们根本不涉及多参数列表。

但是,多参数列表和柯里化之间有一定的相似之处。尽管定义语法不同,但调用语法看起来一样:

1
2
3
4
5
6
7
8
9
10
11
12
// version with multiple parameter lists
def addMultiple(n1: Int)(n2: Int) = n1 + n2

// two different ways of arriving at a curried version instead
def add(n1: Int, n2: Int) = n1 + n2
val addCurried1 = (add _).curried
val addCurried2 = (n1: Int) => (n2: Int) => n1 + n2

// regardless, all three call sites are identical
addMultiple(3)(4)  // 7
addCurried1(3)(4)  // 7
addCurried2(3)(4)  // 7

3.3.8 重复参数

https://scala-lang.org/files/archive/spec/2.13/04-basic-declarations-and-definitions.html#repeated-parameters

参数列表的最后一个参数可以添加后缀*(例如x: T*),称为重复参数(repeated parameter)。具有重复参数T*的方法可以接受任意数量的T类型参数。在方法内部,重复参数的类型是Seq[T]

具有重复参数的方法不允许有默认参数。

例如,下面的方法计算可变数量的整数参数的和。

1
2
3
4
5
def sum(args: Int*) = {
  var result = 0
  for (arg <- args) result += arg
  result
}
1
2
3
println(sum())  // 0
println(sum(1))  // 1
println(sum(1, 2, 3))  // 6

如果要将一个数组或序列传递给重复参数,需要使用语法: _*

1
2
val xs = List(1, 2, 3)
println(sum(xs: _*))  // 6

3.3.9 隐式参数

https://docs.scala-lang.org/tour/implicit-parameters.html

方法可以有上下文参数(contextual parameter),也称为隐式参数(implicit parameter)。以关键字implicit开头的参数列表表示隐式参数。如果调用者没有显式提供这些参数,编译器将查找正确类型的implicit值。如果能找到合适的值,则自动传递。

考虑下面的例子。我们定义了一个特质Comparator[A],用于比较A类型的值,并提供了IntString两种实现。然后定义一个方法max(),返回两个参数的最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
trait Comparator[A] {
  def compare(x: A, y: A): Int
}

object Comparator {
  implicit object IntComparator extends Comparator[Int] {
    override def compare(x: Int, y: Int): Int = Integer.compare(x, y)
  }

  implicit object StringComparator extends Comparator[String] {
    override def compare(x: String, y: String): Int = x.compareTo(y)
  }
}

def max[A](x: A, y: A)(implicit comparator: Comparator[A]): A =
  if (comparator.compare(x, y) >= 0) x else y

println(max(10, 6))             // 10
println(max("hello", "world"))  // world
println(max(false, true))       // error: could not find implicit value for parameter comparator: Comparator[Boolean]

调用max(10, 6)max("hello", "world")comparator参数会分别自动填充为Comparator.IntComparatorComparator.StringComparator。由于无法找到隐式的Comparator[Boolean],因此调用max(false, true)编译失败。

Scala会在两个地方查找可用的隐式值:

  • 首先在调用位置查找可直接访问(没有前缀)的隐式值(例如max()方法所在类的成员)。
  • 然后在隐式候选类型的伴生对象中查找标记为implicit的成员(例如,候选类型Comparator[Int]的伴生对象Comparator)。

详见FAQ - Where does Scala look for implicits?

3.3.10 隐式转换

https://docs.scala-lang.org/tour/implicit-conversions.html

隐式转换(implicit conversion)是Scala的一个强大特性,有两个常见用法:

  • 允许将一种类型的值传递给另一种类型的参数。
  • 在Scala 2中为类提供额外的成员。

在Scala 2中,从类型S到类型T的隐式转换有3种定义方式:

  • 具有单个S类型参数的隐式类 T(必须定义在另一个class/object/trait内部)。
1
2
3
object Helpers {
  implicit class T(s: S)
}
  • 具有函数类型S => T的隐式值。
1
implicit val s2t: S => T = ???
  • 可转换为该类型值的隐式方法。
1
implicit def s2t(s: S): T = ???

隐式转换应用于两种情况:

  • 如果表达式e的类型为S,而S不符合表达式的期望类型T
  • 在表达式e.m中,e的类型为S,而m不是S的成员。

在第一种情况下,会搜索可用于e且结果类型符合T的转换。一个例子是将一个Int传递给接受Long参数的方法,这种情况下会插入隐式转换Int.int2long(x)

在第二种情况下,会搜索可用于e且结果类型包含名为m的成员的转换。一个例子是比较两个字符串"foo" < "bar"String没有成员<,在这种情况下会插入隐式转换Predef.augmentString("foo"),其结果类型为StringOps,该类定义了<方法。(scala.Predef会自动导入到所有Scala程序中)

另一个例子是可以对数组调用map()等方法,这是因为隐式转换Predef.xxxArrayOps()能够将Array[T]转换为ArrayOps[T],而后者定义了map()方法。

3.3.11 传名参数

https://docs.scala-lang.org/tour/by-name-parameters.html

传名参数(by-name parameter)在每次使用时都会求值,如果没有被使用则不会求值(这类似于将传名参数替换为传递的表达式)。相反,传值参数(by-value parameter)即使没有使用也会求值,但只求值一次。

要使一个参数传名调用,只需在其类型前添加=>。例如:

1
2
3
def byName(x: => Int): Unit = println(s"$x $x")

byName(Random.nextInt(10)) // possible output: 4 7

这个调用等价于println(s"${Random.nextInt(10)} ${Random.nextInt(10)}"),两个x生成了两个不同的随机数(在这里是4和7)。

作为对比,传值参数只会求值一次,两个x对应同一个随机数(在这里是8):

1
2
3
def byValue(x: Int): Unit = println(s"$x $x")

byValue(Random.nextInt(10)) // possible output: 8 8

注:可以将传名参数理解为无参数的匿名函数,只是省略了空括号。上面的byName()方法等价于

1
2
3
def byName(x: () => Int): Unit = println(s"${x()} ${x()}")

byName(() => Random.nextInt(10))

下面的示例使用传名参数实现了while循环:

1
2
3
4
5
6
def whileLoop(condition: => Boolean)(body: => Unit): Unit = {
  if (condition) {
    body
    whileLoop(condition)(body)
  }
}
1
2
3
4
5
6
var i = 5

whileLoop (i > 0) {
  println(i)
  i -= 1
}  // prints 5 4 3 2 1

如果conditionfalse,则body永远不会被求值。如果body是计算密集型或运行时间较长的代码(如获取URL),这有助于提高性能。

3.4 包和导入

https://docs.scala-lang.org/tour/packages-and-imports.html

Scala使用(package)来创建名称空间,使你能够模块化程序。

3.4.1 创建包

包是通过在Scala文件的顶部用关键字package声明一个或多个包名来创建的。

1
2
3
package users

class User

按照惯例,包名与Scala文件所在的目录名相同(但不是强制的)。然而,Scala对文件布局是不可知的。一个sbt项目的目录结构可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
ExampleProject/
  build.sbt
  project/
  src/
    main/
      scala/
        users
          User.scala
          UserProfile.scala
          UserPreferences.scala
    test/
      scala/

users目录中的每个Scala文件都具有相同的包声明。

注:Java要求每个源文件只能有一个公有类,且类名必须与文件名相同,Scala没有这个要求。

声明包的另一种方式是将其嵌套在一起(类似于C++的命名空间):

1
2
3
4
5
6
7
8
package users {
  package administrators {
    class NormalUser
  }
  package normalusers {
    class NormalUser
  }
}

包名应全部小写。如果开发代码的组织有网站,则包名应该遵循以下格式约定:<top-level-domain>.<domain-name>.<project-name>(即网站域名的逆序+项目名称,与Java相同)。例如,如果Google有一个名为SelfDrivingCar的项目,包名可能为像这样:

1
2
3
package com.google.selfdrivingcar.camera

class Lens

3.4.2 导入

import语句用于访问其他包中的成员(类、特质、函数等)。访问同一个包的成员不需要导入。

1
2
3
4
import users._  // import everything from the users package
import users.User  // import the class User
import users.{User, UserPreferences}  // Only imports selected members
import users.{UserPreferences => UPrefs}  // import and rename for convenience

Scala与Java的一个不同之处在于,导入可以在任何地方使用:

1
2
3
4
def sqrtplus1(x: Int) = {
  import scala.math.sqrt
  sqrt(x) + 1.0
}

如果发生命名冲突,你希望从项目的根包导入某些内容,则在包名前加上_root_

1
import _root_.users._

注意:scalajava.lang包以及object Predef都是默认导入的。

3.5 单例对象

https://docs.scala-lang.org/tour/singleton-objects.html

对象(object)是只有一个实例的类。当它被引用时才会惰性创建。

作为顶级值,对象是一个单例类。作为类成员或局部值,其行为与lazy val完全一样。

3.5.1 定义单例对象

对象的定义类似于类,但使用关键字object

1
2
3
4
5
package logging

object Logger {
  def info(message: String): Unit = println(s"INFO: $message")
}

方法info()可以从程序中的任何位置导入。创建像这样的辅助方法是单例对象的一种常见用法。

可以像这样在另一个包中使用info()

1
2
3
4
5
6
7
8
9
import logging.Logger.info

class Project(name: String, daysToComplete: Int)

class Test {
  val project1 = new Project("TPS Reports", 1)
  val project2 = new Project("Website redesign", 5)
  info("Created projects")  // Prints "INFO: Created projects"
}

  • 上面的import语句相当于Java的静态导入。
  • 也可以导入logging.Logger然后调用Logger.info()
  • 如果一个对象不是顶级的,而是嵌套在另一个类中,那么该对象就“依赖于”外层类的实例。例如,假设class A具有成员object B,那么对于类A的两个实例a1a2a1.Ba2.B不是同一个对象。

3.5.2 伴生对象

https://docs.scala-lang.org/overviews/scala-book/companion-objects.html

与类同名的对象称为类的伴生对象(companion object)。反过来,这个类是对象的伴生类(companion class)。

伴生类和对象可以互相访问私有成员。伴生对象用于非特定于类实例的方法和值。

注意:如果一个类或对象有伴生,则它们必须定义在同一个文件中。要在REPL中定义伴生,需要进入:paste模式。

1
2
3
4
5
6
7
8
9
10
11
12
import scala.math.{Pi, pow}

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)
val area = circle1.area

class Circle有一个特定于实例的成员area,单例object Circle有一个可用于所有实例的calculateArea()方法。

伴生对象还可以包含工厂方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Email(val username: String, val domainName: String)

object Email {
  def fromString(emailString: String): Option[Email] = {
    emailString.split('@') match {
      case Array(a, b) => Some(new Email(a, b))
      case _ => None
    }
  }
}

val scalaCenterEmail = Email.fromString("scala.center@epfl.ch")
scalaCenterEmail match {
  case Some(email) => println(
    s"""Registered an email
       |Username: ${email.username}
       |Domain name: ${email.domainName}
    """.stripMargin)
  case None => println("Error: could not parse email")
}

object Email包含工厂方法fromString(),从字符串创建Email实例,返回一个Option[Email]以防解析错误。

Java程序员注释:

  • Java中的静态成员相当于Scala中伴生对象的普通成员。
  • 在Java代码中使用伴生对象时,其成员将被定义在伴生类中,并带有static修饰符(即使你自己没有定义伴生类)。这叫做静态转发(static forwarding)。
  • Scala编译器会为Email生成两个类文件:Email.class和Email$.class,分别对应class Emailobject Email。等价的Java代码如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Email.class
public class Email {
    private final String username;
    private final String domainName;

    public static Option<Email> fromString(final String emailString) {
        return Email$.MODULE$.fromString(emailString);
    }

    public String username() { return this.username; }
    public String domainName() { return this.domainName; }

    public Email(final String username, final String domainName) {
        this.username = username;
        this.domainName = domainName;
    }
}

// Email$.class
public final class Email$ {
    public static final Email$ MODULE$ = new Email$();

    public Option<Email> fromString(final String emailString) { ... }

    private Email$() {}
}

3.6 Case类

https://docs.scala-lang.org/overviews/scala-book/case-classes.html

https://docs.scala-lang.org/tour/case-classes.html

另一个支持函数式编程的Scala特性是case类。通过在class前添加关键字case来定义case类。

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

注:Scala的case类类似于Java 16的record

3.6.1 优点

Case类具有常规类的所有功能,但有更多优点。编译器会自动为case类生成以下代码:

  • 构造函数参数默认是公有val字段,因此会为每个字段生成访问器方法。
  • 在伴生对象中生成apply()方法,因此不需要使用new关键字来创建新实例。
  • 在伴生对象中生成unapply()方法,使你可以在match表达式中使用case类。
  • 生成copy()方法。你可能不会在OOP代码中使用这个特性,但它一直在FP中使用。
  • 生成equals()hashCode()方法,使你可以用==比较对象并将其用作映射的键。
  • 生成默认的toString()方法,有助于调试。

(1)不需要new

定义case类时,不需要使用new关键字来创建新实例:

1
2
3
// "new" not needed before Person
scala> val christina = Person("Christina", "niece")
val christina: Person = Person(Christina,niece)

这是因为在Person的伴生对象中生成了apply()方法,等价的代码如下:

1
2
3
4
5
class Person(val name: String, val relation: String)

object Person {
  def apply(name: String, relation: String): Person = new Person(name, relation)
}

(2)没有修改器方法

Case类的构造函数参数默认是公有val字段,因此会为每个字段生成访问器(accessor)方法:

1
2
scala> christina.name
val res0: String = Christina

但不会生成修改器(mutator)方法:

1
2
3
4
// can't mutate the `name` field
scala> christina.name = "Fred"
                      ^
       error: reassignment to val

这意味着case类的实例是不可变的,因此是并发安全的。

注:也可以在case类中使用var字段,但不建议这样做,因为这违背了case类的初衷。

(3)unapply()方法

2.4.8介绍了如何编写unapply()方法。Case类的一个优点是它会自动生成unapply()方法,因此支持模式匹配并提取字段。这是case类最大的优点。

为了说明这一点,假设有以下特质:

1
2
3
trait Person {
  def name: String
}

然后创建两个扩展该特质的case类:

1
2
case class Student(name: String, year: Int) extends Person
case class Teacher(name: String, specialty: String) extends Person

可以像这样编写match表达式:

1
2
3
4
5
6
def getPrintableString(p: Person): String = p match {
  case Student(name, year) =>
    s"$name is a student in Year $year."
  case Teacher(name, specialty) =>
    s"$name teaches $specialty."
}

为了展示这段代码能够工作,首先创建一个StudentTeacher的实例:

1
2
val alice = Student("Alice", 1)
val bob = Teacher("Bob Donnan", "Mathematics")

接下来在REPL中使用这两个实例调用getPrintableString()

1
2
3
4
5
scala> getPrintableString(alice)
val res0: String = Alice is a student in Year 1.

scala> getPrintableString(bob)
val res1: String = Bob Donnan teaches Mathematics.

模式case Student(name, year) =>能够工作,是因为Student被定义为case类,其unapply()方法的签名符合特定的标准:接受一个Student参数,返回包装在Option元组中的构造器字段。等价的代码如下:

1
2
3
4
5
object Student {
  def unapply(x: Student): Option[(String, Int)] = {
    if (x == null) None else Some((x.name, x.year))
  }
}

这种模式匹配称为构造器模式(constructor pattern)。

(4)copy()方法

Case类具有自动生成的copy()方法,可用于克隆整个对象,或者在克隆过程中更新一个或多个字段。例如:

1
2
3
4
5
scala> val alice = Student("Alice", 1)
val alice: Student = Student(Alice,1)

scala> val alice2 = alice.copy(year = 2)
val alice2: Student = Student(Alice,2)

如上所示,使用copy()方法时只需提供想要在克隆过程中修改的字段的名称,如果不提供参数则拷贝整个对象。这个过程称为“拷贝时更新”(update as you copy)。

注:Student类的copy()方法等价的代码如下:

1
def copy(name: String = this.name, year: Int = this.year) = Student(name, year)

(5)equals()hashCode()方法

Case类自动生成了equals()hashCode()方法,因此可以对实例进行相等比较:

1
2
3
4
5
6
7
8
9
10
11
scala> val bob = Student("Bob", 1)
val bob: Student = Student(Bob,1)

scala> val bob2 = alice.copy(name = "Bob")
val bob2: Student = Student(Bob,1)

scala> bob == bob2
val res0: Boolean = true

scala> bob eq bob2
val res1: Boolean = false

注:Scala的==eq运算符分别等价于Java的equals()==

这些方法使你可以在集和映射中使用case类。

(6)toString()方法

最后,case类有一个默认的toString()方法实现,这在调试代码时很有帮助:

1
2
scala> s"alice is $alice"
val 3: String = alice is Student(Alice,1)

3.6.2 Case对象

https://docs.scala-lang.org/overviews/scala-book/case-objects.html

case object类似于object。但就像case类比常规类具有更多特性一样,case对象也比常规对象具有更多特性:

  • 是可序列化的
  • 具有默认的hashCode()实现
  • 具有改进的toString()实现

Case对象主要用于两个地方:

  • 创建枚举
  • 创建在其他对象之间传递的“消息”容器(例如使用Akka actors库)

(1)用case对象创建枚举

在Scala中可以像这样创建枚举:

1
2
3
4
sealed trait Size
case object Small extends Size
case object Medium extends Size
case object Large extends Size

创建枚举的另一种方式见3.7节。

(2)将case对象用作消息

Case对象的另一种用途是表示“消息”的概念。例如,假设你正在编写一个像Amazon Alexa这样的智能语音助理应用,你希望能够传递“说话”消息,比如“说出指定的文本”、“停止说话”、“暂停”和“继续”。你可以为这些消息创建case对象,如下所示:

1
2
3
4
case class StartSpeakingMessage(text: String)
case object StopSpeakingMessage
case object PauseSpeakingMessage
case object ResumeSpeakingMessage

如果使用Akka库的Actor类,就可以编写这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Speak extends Actor {
  def receive = {
    case StartSpeakingMessage(text) =>
      // code to speak the text
    case StopSpeakingMessage =>
      // code to stop speaking
    case PauseSpeakingMessage =>
      // code to pause speaking
    case ResumeSpeakingMessage =>
      // code to resume speaking
  }
}

3.6.3 函数式错误处理

https://docs.scala-lang.org/overviews/scala-book/functional-error-handling.html

函数式编程就像代数,没有空值或异常。但是,当尝试访问宕机的服务器或缺失的文件时仍然会有异常。本节将介绍Scala的函数式错误处理技术。

Option/Some/None

抽象类Option[T]表示可选的T类型值(类似于java.util.Optional),有两个实现类:

  • Some[T]表示存在的值
  • None表示不存在的值

例如,假设要编写一个字符串转整数的方法toInt(),可以返回一个Option[Int],而不是抛出异常或返回null:

1
2
3
4
5
6
7
def toInt(s: String): Option[Int] = {
  try {
    Some(Integer.parseInt(s.trim))
  } catch {
    case e: Exception => None
  }
}

可以使用match或者for表达式处理toInt()的结果:

1
2
3
4
5
6
7
8
9
10
toInt(s) match {
  case Some(i) => println(i)
  case None => println("That didn't work.")
}

val y = for {
  a <- toInt(stringA)
  b <- toInt(stringB)
  c <- toInt(stringC)
} yield a + b + c

在第二个例子中,y的类型为Option[Int],当三个字符串都解析成功时,y的值为Some(a + b + c),否则为None

注:

  • Option是可迭代的,等价于长度为0或1的序列,因此可以使用isEmptymap()filter()foreach()等序列方法。详见API文档
  • Scala字符串有两个转换为整数的方法toInttoIntOption,前者返回Int或抛出异常,后者返回Option[Int]

Try/Success/Failure

抽象类Try[T]表示可能失败(抛出异常)的计算,有两个实现类:

  • Success[T]包含成功计算的结果
  • Failure[T]包含抛出的异常

下面是使用这些类重写的toInt()方法:

1
2
3
4
5
import scala.util.{Try, Success, Failure}

def toInt(s: String): Try[Int] = Try {
  Integer.parseInt(s.trim)
}

可以看到,这比使用Option的方式更简短。

在REPL中演示该方法如何工作:

1
2
3
4
5
scala> val a = toInt("1")
val a: scala.util.Try[Int] = Success(1)

scala> val b = toInt("boo")
val b: scala.util.Try[Int] = Failure(java.lang.NumberFormatException: For input string: "boo")

有很多方式可以处理Try的结果,包括从失败中“恢复”,但常见的方式仍然是使用matchfor表达式:

1
2
3
4
5
6
7
8
9
10
toInt(s) match {
  case Success(i) => println(i)
  case Failure(e) => println(s"Failed. Reason: ${e.getMessage}")
}

val y = for {
  a <- toInt(stringA)
  b <- toInt(stringB)
  c <- toInt(stringC)
} yield a + b + c

Either/Left/Right

抽象类Either[A, B]表示两种可能类型之一的值,有两个实现类:

  • Left[A, B]表示A类型的值
  • Right[A, B]表示B类型的值

习惯上用Left表示失败,用Right表示成功。

下面的toInt()方法当字符串成功转换为整数时返回包含整数的Right,否则返回包含原始字符串的Left

1
2
3
4
5
6
7
def toInt(s: String): Either[String, Int] = {
  try {
    Right(s.toInt)
  } catch {
    case e: NumberFormatException => Left(s)
  }
}
1
2
3
4
toInt(s) match {
  case Left(x) => println(s"$x is not an integer")
  case Right(x) => println(s"$x is an integer")
}

3.7 枚举

Scala 2没有内置的枚举类型,必须通过其他方式来定义枚举。

(1)case对象

可以通过定义一个特质和多个实现该特质的case对象来创建枚举,这种方式已经在3.6.2节介绍过。

(2)Enumeration

https://www.scala-lang.org/api/2.13.18/scala/Enumeration.html

创建枚举的另一种方式是扩展Enumeration类。例如:

1
2
3
4
object WeekDay extends Enumeration {
  type WeekDay = Value
  val Mon, Tue, Wed, Thu, Fri, Sat, Sun = Value
}

其中,第一行中的ValueEnumeration的表示枚举值的成员类型。在这里将其定义为别名WeekDay,在外部需要通过WeekDay.WeekDay来表示这个枚举类型。

第二行中的ValueEnumeration的无参数方法,返回下一个枚举值。在这里同时为多个变量赋值,等价于以下代码:

1
2
3
4
val Mon = Value
val Tue = Value
...
val Sun = Value

可以像这样使用WeekDay枚举:

1
2
3
4
5
import WeekDay._

def isWorkingDay(d: WeekDay): Boolean = !(d == Sat || d == Sun)

WeekDay.values.filter(isWorkingDay).foreach(println)

注意,isWorkingDay()的参数类型是WeekDay.WeekDay,而最后一行访问的是object WeekDay

可以通过扩展Enumeration.Val类为枚举添加属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object Color extends Enumeration {
  case class Color(rgb: Int) extends super.Val {
    def red: Byte = ((rgb >> 16) & 0xFF).toByte
    def green: Byte = ((rgb >> 8) & 0xFF).toByte
    def blue: Byte = (rgb & 0xFF).toByte
  }

  val Black = Color(0x000000)
  val Red = Color(0xFF0000)
  val Green = Color(0x00FF00)
  val Blue = Color(0x0000FF)
  val Yellow = Color(0xFFFF00)
  val Magenta = Color(0xFF00FF)
  val Cyan = Color(0x00FFFF)
  val White = Color(0xFFFFFF)
}

注:Scala 3支持使用关键字enum创建枚举,详见Scala 3 Book - Enums

3.8 内部类

https://docs.scala-lang.org/tour/inner-classes.html

在Scala中,可以让类作为其他类的成员,称为内部类(inner class)。与Java不同的是,在Scala中内部类是绑定到外部类实例的。

假设类GraphNode分别表示图和节点。如果我们希望编译器在编译时防止将属于不同图的节点连接起来,可以将Node作为Graph的内部类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Graph {
  class Node {
    var connectedNodes: List[Node] = Nil

    def connectTo(node: Node): Unit = {
      if (!connectedNodes.exists(node.equals)) {
        connectedNodes = node :: connectedNodes
      }
    }
  }

  var nodes: List[Node] = Nil

  def newNode: Node = {
    val res = new Node
    nodes = res :: nodes
    res
  }
}

在这里将图表示为节点的列表(List[Node])。每个节点都有一个它连接到的其他节点的列表(connectedNodes)。类Node是一个路径相关的类型(path-dependent type),因为它嵌套在类Graph中。因此,connectedNodes中的所有节点都必须使用同一个Graph实例中的newNode创建。

1
2
3
4
5
6
val graph1: Graph = new Graph
val node1: graph1.Node = graph1.newNode
val node2: graph1.Node = graph1.newNode
val node3: graph1.Node = graph1.newNode
node1.connectTo(node2)
node3.connectTo(node1)

其中node1, node2node3的类型是graph1.Node,即绑定到实例graph1Node类。

如果现在有两个图,Scala的类型系统不允许将一个图的节点连接到另一个图的节点,因为它们具有不同的类型:

1
2
3
4
5
6
7
val graph1: Graph = new Graph
val node1: graph1.Node = graph1.newNode
val node2: graph1.Node = graph1.newNode
node1.connectTo(node2)  // legal
val graph2: Graph = new Graph
val node3: graph2.Node = graph2.newNode
node1.connectTo(node3)  // illegal!

这是因为类型graph1.Node不同于类型graph2.Node

在Java中,前面示例中的最后一行是正确的,因为两个图的节点具有相同的类型Graph.Node。在Scala中,也可以表达这样的类型,写作Graph#Node。如果我们希望能够连接不同图的节点,需要这样修改类的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Graph {
  class Node {
    var connectedNodes: List[Graph#Node] = Nil

    def connectTo(node: Graph#Node): Unit = {
      if (!connectedNodes.exists(node.equals)) {
        connectedNodes = node :: connectedNodes
      }
    }
  }

  var nodes: List[Node] = Nil

  def newNode: Node = {
    val res = new Node
    nodes = res :: nodes
    res
  }
}

3.9 注解

https://docs.scala-lang.org/tour/annotations.html

注解(annotation)用于将元信息与定义相关联。

注解应用于其后的第一个定义或声明。定义和声明之前可以有多个注解,注解的顺序不重要。

@deprecated

方法前的注解@deprecated会导致编译器在调用该方法时打印警告。

1
2
3
4
5
6
object DeprecationDemo extends App {
  @deprecated("deprecation message", "1.2.3")
  def hello = "hola"

  hello
}

编译器会打印警告 “warning: 1 deprecation” ,添加选项-deprecation查看详细信息。

1
2
3
4
5
6
7
8
9
$ scalac DeprecationDemo.scala
warning: 1 deprecation (since 1.2.3); re-run with -deprecation for details
1 warning

$ scalac -deprecation DeprecationDemo.scala
DeprecationDemo.scala:5: warning: method hello in object DeprecationDemo is deprecated (since 1.2.3): deprecation message
  hello
  ^
1 warning

@tailrec

有些注解在不满足条件时会导致编译失败。例如,注解@tailrec确保方法是尾递归(tail-recursive)的。尾递归是递归的一种特殊形式,即一个方法的所有递归调用都出现在方法的末尾。尾递归可以保持常数内存复杂度,这使得编译器可以进行优化。

下面是尾递归的计算阶乘方法:

1
2
3
4
5
6
7
8
9
import scala.annotation.tailrec

def factorial(x: Int): Int = {
  @tailrec
  def factorialHelper(x: Int, accumulator: Int): Int = {
    if (x == 1) accumulator else factorialHelper(x - 1, accumulator * x)
  }
  factorialHelper(x, 1)
}

注解@tailrec可以确保factorialHelper()方法确实是尾递归的。如果将该方法的实现改为下面这样,编译会失败:

1
2
3
4
5
6
7
def factorial(x: Int): Int = {
  @tailrec
  def factorialHelper(x: Int): Int = {
    if (x == 1) 1 else x * factorialHelper(x - 1)
  }
  factorialHelper(x)
}

会看到错误消息

1
error: could not optimize @tailrec annotated method factorialHelper: it contains a recursive call not in tail position

Java注解

可以在Scala代码中使用Java注解。注意:确保使用-target:jvm-1.8选项。

例如,假设有一个用于跟踪类的源代码的注解

1
2
3
4
@interface Source {
    public String url();
    public String mail();
}

在Scala中可以使用命名参数实例化注解:

1
2
@Source(url = "https://coders.com/", mail = "support@coders.com")
class MyScalaClass ...

如果注解只包含一个(没有默认值的)元素,按照惯例,如果将其命名为value,就可以使用类似构造器的语法:

1
2
3
4
@interface SourceURL {
    public String value();
    public String mail() default "";
}
1
2
@SourceURL("https://coders.com/")
class MyScalaClass ...

也可以只命名email参数:

1
2
@SourceURL("https://coders.com/", mail = "support@coders.com")
class MyScalaClass ...

还有一些预定义注解。例如,字段注解@transient@volatile分别等价于Java的transientvolatile修饰符。详见Scala语言规范-Annotations

This post is licensed under CC BY 4.0 by the author.