Post

Scala基础教程 第6节 集合类

Scala基础教程 第6节 集合类

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

如果你从Java转向Scala,最好忘记Java集合类并开始使用Scala集合类。

6.1 主要集合类

常用的主要Scala集合类如下表所示:

描述
Seq序列基类
ArrayBuffer有索引的可变序列
List链表,不可变序列
Vector有索引的不可变序列
Map映射(键值对)基类
Set集基类

SeqMapSet都有可变和不可变两种版本。

本节将介绍这些类的基本用法。

注意:不可变集合类可以安全地用于函数式编程(FP)风格。这些类的方法不会修改集合,而是返回一个新集合。

更详细的Scala集合文档:

6.1.1 ArrayBuffer类

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

ArrayBuffer[T]类是可变的序列,因此可以使用它的方法来修改其内容。ArrayBuffer类似于Java的ArrayList

为了使用ArrayBuffer,必须先导入它:

1
import scala.collection.mutable.ArrayBuffer

像这样创建空的ArrayBuffer

1
2
val nums = ArrayBuffer[Int]()
val names = ArrayBuffer.empty[String]

也可以像这样指定初始元素:

1
val nums = ArrayBuffer(1, 2, 3)

使用()访问具有给定索引的元素:

1
2
val x = nums(1)  // 2
nums(2) = 333

lengthsize方法返回元素个数:

1
val n = nums.size  // 3, same as nums.length

isEmptynonEmpty方法分别返回集合是否为空、是否非空。

使用+=添加元素(等价于addOne()方法):

1
2
// add one element
nums += 4  // ArrayBuffer(1, 2, 3, 4)

+=运算符返回当前对象,因此可以像这样添加多个元素:

1
2
// add multiple elements
nums += 5 += 6  // ArrayBuffer(1, 2, 3, 4, 5, 6)

使用++=添加另一个集合的所有元素(等价于addAll()方法):

1
2
// add multiple elements from another collection
nums ++= List(7, 8, 9)  // ArrayBuffer(1, 2, 3, 4, 5, 6, 7, 8, 9)

使用-=删除元素的第一次出现,如果不存在则不变(等价于subtractOne()方法):

1
2
3
4
5
// remove one element
nums -= 9  // ArrayBuffer(1, 2, 3, 4, 5, 6, 7, 8)

// remove multiple elements
nums -= 7 -= 8  // ArrayBuffer(1, 2, 3, 4, 5, 6)

使用--=删除另一个集合的所有元素(等价于subtractAll()方法):

1
2
// remove multiple elements using another collection
nums --= Array(5, 6)  // ArrayBuffer(1, 2, 3, 4)

下面展示了ArrayBuffer的更多方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val a = ArrayBuffer(1, 2, 3)         // ArrayBuffer(1, 2, 3)
a.append(4)                          // ArrayBuffer(1, 2, 3, 4)
a.appendAll(Seq(5, 6))               // ArrayBuffer(1, 2, 3, 4, 5, 6)
a.clear()                            // ArrayBuffer()

val a = ArrayBuffer(9, 10)           // ArrayBuffer(9, 10)
a.insert(0, 8)                       // ArrayBuffer(8, 9, 10)
a.insertAll(0, Vector(4, 5, 6, 7))   // ArrayBuffer(4, 5, 6, 7, 8, 9, 10)
a.prepend(3)                         // ArrayBuffer(3, 4, 5, 6, 7, 8, 9, 10)
a.prependAll(Array(0, 1, 2))         // ArrayBuffer(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

val a = ArrayBuffer.range('a', 'h')  // ArrayBuffer(a, b, c, d, e, f, g)
a.remove(0)                          // ArrayBuffer(b, c, d, e, f, g)
a.remove(2, 3)                       // ArrayBuffer(b, c, g)

val a = ArrayBuffer.range('a', 'h')  // ArrayBuffer(a, b, c, d, e, f, g)
a.dropInPlace(2)                     // ArrayBuffer(c, d, e, f, g)
a.dropRightInPlace(2)                // ArrayBuffer(c, d, e)

ArrayBuffer类有数百个方法,这里仅仅展示了冰山一角。完整列表参见API文档。

6.1.2 List类

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

List[T]类是不可变的链表(linked list)。如果想要添加或删除元素,需要从现有的List创建一个新的List

创建列表

像这样创建列表:

1
2
val nums = List(1, 2, 3)
val names = List("Joel", "Chris", "Ed")

注:这实际上调用了List类的伴生对象的apply()方法。也可以使用抽象基类来创建序列,这会使用其默认实现。例如:

1
2
3
4
5
scala> Seq(1, 2, 3)
val res0: Seq[Int] = List(1, 2, 3)

scala> Iterable(1, 2, 3)
val res1: Iterable[Int] = List(1, 2, 3)

添加元素

List是不可变的,因此不能直接向其中添加新元素,而是通过在现有列表的开头或末尾追加元素来创建新列表。例如,给定列表

1
val a = List(1, 2, 3)

使用+:在开头追加元素,x +: a等价于a.prepended(x)(以:结尾的方法是右结合的)。

1
val b = 0 +: a  //  List(0, 1, 2, 3)

使用++:在开头追加另一个集合的所有元素,b ++: a等价于a.prependedAll(b)

1
val b = List(-1, 0) ++: a  // List(-1, 0, 1, 2, 3)

还可以使用a :+ x(等价于a.appended(x))和a :++ b(等价于a.appendedAll(b))向列表末尾追加元素。但由于List是单向链表,应该只在开头追加元素,在末尾追加元素是相对较慢的操作(尤其是当列表很大时)。

提示:如果想在不可变序列的开头和末尾追加元素,应使用Vector

由于List是链表,不应该试图通过索引访问元素(例如,myList(999999)需要很长时间)。如果想按索引访问,应使用VectorArrayBuffer

方法名助记

+: vs :+ 冒号(COLon)位于集合(COLlection)侧

1
2
1 +: List(2, 3)  //  List(1, 2, 3)
List(1, 2) :+ 3  //  List(1, 2, 3)

++: vs :++ 冒号位于新集合类型侧

1
2
3
Array(1, 2) ++: List(3, 4)  // List(1, 2, 3, 4)
Array(1, 2) :++ List(3, 4)  // Array(1, 2, 3, 4)
Array(1, 2) ++ List(3, 4)   // Array(1, 2, 3, 4)

这些方法名对于其他不可变序列类(如SeqVector)同样适用。

遍历列表

2.2节已经介绍过,可以使用for循环或foreach()方法来遍历列表。

1
2
3
val names = List("Joel", "Chris", "Ed")
for (name <- names) println(name) // prnums: Joel Chris Ed
names.foreach(println)  // prnums: Joel Chris Ed

这种方式适用于所有序列类。

::和Nil

Scala的List类似于Lisp语言的List类。还可以像这样创建列表:

1
val list = 1 :: 2 :: 3 :: Nil  // same as List(1, 2, 3)

实际上,List类有两个子类:

  • case class ::(head, next)表示非空列表。
  • case object Nil表示空列表。

另外,List类有两个冒号方法:

  • x :: a在开头追加元素,返回new ::(x, this),等价于+:/prepended()
  • b ::: a在开头追加另一个列表的所有元素,等价于++:/prependedAll()

6.1.3 Vector类

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

Vector[T]类是有索引的不可变序列。“有索引的”(indexed)意味着支持快速随机访问(例如listOfPeople(999999))。除此之外,Vector的用法与List相同。

创建Vector

1
2
val nums = Vector.range(1, 6)  // Vector(1, 2, 3, 4, 5)
val names = Vector("Joel", "Chris", "Ed")

在末尾追加元素:

1
2
3
val a = Vector(1, 2, 3)
val b = a :+ 4  // Vector(1, 2, 3, 4)
val c = a :++ Vector(4, 5)  // Vector(1, 2, 3, 4, 5)

注:在这里:++/appendedAll()等价于++/concat()

在开头追加元素:

1
2
val b = 0 +: a  // Vector(0, 1, 2, 3)
val c = Vector(-1, 0) ++: a  // Vector(-1, 0, 1, 2, 3)

遍历方式同List

1
2
for (name <- names) println(name)
names.foreach(println)

6.1.4 Map类

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

Map[K, V]类描述了映射(map)——由键值对构成的集合。

创建映射

像这样通过指定的键值对创建映射:

1
2
3
4
5
val states = Map(
  "AK" -> "Alaska",
  "IL" -> "Illinois",
  "KY" -> "Kentucky"
) // scala.collection.immutable.Map[String, String]

注:->是隐式类scala.Predef.ArrowAssoc中定义的方法。对于任何对象,x -> y返回二元组(x, y)。因此上面的映射等价于Map(("AK", "Alaska"), ...)

Map默认是不可变的。要创建可变映射,需要使用scala.collection.mutable.Map

1
2
import scala.collection.mutable
val states = mutable.Map("AK" -> "Alaska")

访问元素

使用()访问与给定的键关联的值(等价于apply()方法),如果不存在则抛出NoSuchElementException

1
2
val ak = states("AK")  // "Alaska"
val ca = states("CA")  // NoSuchElementException: key not found

get()方法类似于(),但返回一个Option[V],当键不存在时返回None

1
2
val ak = states.get("AK")  // Some(Alaska)
val ca = states.get("CA")  // None

添加元素

对于可变映射,使用+=添加单个元素(键值对):

1
2
states += ("AL" -> "Alabama")
states += ("AR" -> "Arkansas", "AZ" -> "Arizona")

使用++=添加另一个映射的所有元素:

1
states ++= Map("CA" -> "California", "CO" -> "Colorado")

删除元素

使用-=--=删除指定的键,如果不存在则不变:

1
2
3
states -= "AR"
states -= ("AL", "AZ")
states --= List("AL", "AZ")

更新元素

通过将键关联到新值来更新映射元素。如果键已存在则覆盖旧值,否则插入新键值对。

1
states("AK") = "Alaska, A Really Big State"

m(k) = v等价于m.update(k, v)以及m += (k -> v)

遍历映射

2.2.3节已经介绍过遍历映射元素的几种方式。对于映射

1
2
3
4
5
val ratings = Map(
  "Lady in the Water" -> 3.0, 
  "Snakes on a Plane" -> 4.0,
  "You, Me and Dupree" -> 3.5
)

可以使用for循环遍历键值对:

1
2
for ((name, rating) <- ratings)
  println(s"Movie: $name, Rating: $rating")

也可以使用foreach()方法:

1
2
3
ratings.foreach { case (name, rating) =>
  println(s"Movie: $name, Rating: $rating")
}

注:特质Map的默认实现类是HashMap,键是无序的。子特质SortedMap及其实现类TreeMap是有序的。

更多用法参见Map类和API文档。

6.1.5 Set类

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

Set[T]类是没有重复元素的集合。

创建集

Map类似,Set类也有可变和不可变版本。

创建不可变集:

1
val s = Set(1, 2, 3)  // scala.collection.immutable.Set[Int]

创建可变集:

1
2
import scala.collection.mutable
val s = mutable.Set(1, 2, 3)

成员检查

使用()contains()方法检查一个元素是否属于集:

1
2
s(3)  // true
s.contains(8)  // false

添加元素

对于可变集,使用+=++=添加元素:

1
2
3
4
val s = mutable.Set.empty[Int]
s += 1  // Set(1)
s += 2 += 3  // Set(1, 2, 3)
s ++= Vector(4, 5)  // Set(1, 2, 3, 4, 5)

注意,尝试添加已存在的元素将会被忽略。

Set还有一个add()方法,如果确实添加了元素则返回true,否则返回false

1
2
s.add(6)  // true
s.add(5)  // false

删除元素

使用-=--=删除元素,不存在的元素将被忽略:

1
2
3
val s = mutable.Set(1, 2, 3, 4, 5)
s -= 1  // Set(2, 3, 4, 5)
s --= Array(2, 3)  // Set(4, 5)

remove()方法删除元素并返回布尔值,clear()方法清空集。

1
2
3
4
val s = mutable.Set(1, 2, 3, 4, 5)
s.remove(2)  // true, s = Set(1, 3, 4, 5)
s.remove(40)  // false, s = Set(1, 3, 4, 5)
s.clear()  // Set()

集合操作

  • 交集:a & b(等价于intersect()
  • 并集:a | b(等价于union()
  • 差集:a &~ b(等价于diff()

Set的默认实现类是HashSet,另外还有TreeSetLinkedHashSet等。

更多用法参见Set类和API文档。

6.1.6 数组

https://docs.scala-lang.org/overviews/collections-2.13/arrays.html

数组与序列兼容

在Scala中,数组Array[T]是一种特殊的集合。一方面,Scala数组与Java数组一一对应。例如,Scala的Array[Int]表示为Java的int[]Array[String]表示为Java的String[]。但同时,Scala数组所提供的功能远比Java更多。首先,Scala数组是泛型的。也就是说,可以创建Array[T],其中T是类型参数(在Java中不能创建T[])。其次,Scala数组与序列兼容,可以将Array[T]赋值给Seq[T]变量。最后,Scala数组还支持所有序列操作。下面是一个示例:

1
2
3
4
val a1 = Array(1, 2, 3)
val a2 = a1.map(_ * 3)  // Array(3, 6, 9)
val a3 = a2.filter(_ % 2 != 0)  // Array(3, 9)
val a4 = a3.reverse  // Array(9, 3)

Array并不是Seq的子类型,而是有一个从Arrayscala.collection.mutable.ArraySeq的隐式转换(参见3.3.11节),而后者是Seq的子类。可以使用toArray将序列转换回数组。例如:

1
2
3
val s: collection.Seq[Int] = a1  // ArraySeq(1, 2, 3)
val a5: Array[Int] = s.toArray  // Array(1, 2, 3)
a1 eq a5  // false

最后一行说明将数组包装为序列再转换回数组会生成原始数组的副本。

还有一个从Arrayscala.collection.ArrayOps的隐式转换。该转换只将序列方法“添加”到数组中,但不会将数组转换为序列(ArrayOps也不是Seq的子类)。例如:

1
2
3
4
5
val seq: collection.Seq[Int] = a1  // ArraySeq(1, 2, 3)
val a2 = seq.reverse  // ArraySeq(3, 2, 1)

val ops: collection.ArrayOps[Int] = a1
val a3 = ops.reverse  // Array(3, 2, 1)

ArraySeq上调用reverse会得到一个ArraySeq,而在ArrayOps上调用reverse会得到一个Array

通常,你永远不会定义ArrayOps类型的值,而是直接在数组上调用序列方法:

1
val a2 = a1.reverse  // Array(3, 2, 1)

这会自动插入隐式转换intArrayOps(a1),创建一个ArrayOps[Int]对象。

这两种隐式转换都能将数组转换为支持reverse方法的类型,那么在上面这行代码中,编译器为什么选择转换为ArrayOps而不是ArraySeq呢?这是因为ArrayOps转换的优先级高于ArraySeq转换。前者定义在Predef中,而后者定义在LowPriorityImplicitsPredef的超类)中。子类中的隐式转换优先于超类。 因此,如果两种转换都适用,则选择Predef中的转换。字符串也有类似的机制。

1
2
3
4
5
6
7
8
9
object Predef extends LowPriorityImplicits {
  // ...
  @inline implicit def intArrayOps(xs: Array[Int]): ArrayOps[Int] = new ArrayOps(xs)
}

abstract class LowPriorityImplicits {
  // ...
  implicit def wrapIntArray(xs: Array[Int]): ArraySeq.ofInt = if (xs ne null) new ArraySeq.ofInt(xs) else null
}

泛型数组

在Java中不能创建泛型数组T[],其中T是类型参数(参见《Java核心技术》笔记 卷I 第8章 8.6.6节)。那么Scala的Array[T]是如何表示的呢?事实上,泛型数组Array[T]在运行时可能是Java的基本类型数组(如int[]),也可能是对象数组(如String[])。例如,考虑下面的泛型方法count()

1
2
3
4
5
6
7
8
9
10
def count[T](arr: Array[T], value: T): Int = {
  var n = 0
  for (x <- arr) {
    if (x == value) n += 1
  }
  n
}

println(count(Array(1, 2, 1, 4, 3, 2, 1), 1))  // T = Int
println(count(Array("a", "c", "a", "d", "c", "b"), "c"))  // T = String

int[]String[]的唯一公共类型是java.lang.Object,因此Scala编译器会将参数arr的类型映射为Object。另外,在访问泛型数组的元素时,有一系列类型测试来确定实际的数组类型,这在一定程度上会降低效率。Scala编译器会将泛型方法count()翻译为与如下Java代码等价的字节码:

1
2
3
4
5
6
7
8
9
public <T> int count(Object arr, T value) {
    IntRef n = IntRef.create(0);
    genericArrayOps(arr).foreach(x -> {
        if (BoxesRunTime.equals(x, value)) {
            ++n.elem;
        }
    });
    return n.elem;
}

可以看到,Array[T]被使用genericArrayOps()包装为ArrayOps,而x == value被翻译为BoxesRunTime.equals()(其中包含大量的instanceof检查)。

作为对比,下面的countInt()方法使用具体数组类型Array[Int]

1
2
3
4
5
6
7
def countInt(arr: Array[Int], value: Int): Int = {
  var n = 0
  for (x <- arr) {
    if (x == value) n += 1
  }
  n
}

等价的Java代码如下:

1
2
3
4
5
6
7
8
9
public int countInt(int[] arr, int value) {
    IntRef n = IntRef.create(0);
    intArrayOps(arr).foreach(x -> {
        if (x == value) {
            ++n.elem;
        }
    });
    return n.elem;
}

与使用泛型数组的方法相比,参数arr的类型被直接映射为int[],而x == value被翻译为Java的==运算符,没有任何额外开销。

访问泛型数组比具体类型数组慢3~4倍。因此,如果需要最佳性能,应该优先使用具体类型数组而不是泛型数组。

创建泛型数组是一个更难的问题。考虑下面的例子:

1
2
3
4
5
6
7
// this is wrong!
def evenElems[T](xs: Vector[T]): Array[T] = {
  val arr = new Array[T]((xs.length + 1) / 2)
  for (i <- 0 until xs.length by 2)
    arr(i / 2) = xs(i)
  arr
}

evenElems()方法返回向量xs位于偶数位置的元素构成的新数组。由于类型参数T对应的实际类型在运行时被擦除,编译上面的代码会得到以下错误消息:

1
2
3
error: cannot find class tag for element type T
    val arr = new Array[T]((xs.length + 1) / 2)
              ^

你需要提供一些提示来帮助编译器了解T的实际类型是什么。这个提示是scala.reflect.ClassTag类型的对象,称为类清单(class manifest)。类清单是一种类型描述符对象,它描述了类型的顶级类是什么。

需要给evenElems()方法添加一个类清单作为隐式参数:

1
def evenElems[T](xs: Vector[T])(implicit m: ClassTag[T]): Array[T] = ...

也可以使用较短的上下文边界(context bound)语法T: ClassTag,如下所示:

1
def evenElems[T: ClassTag](xs: Vector[T]): Array[T] = ...

这两种形式的含义完全相同,意味着要求存在ClassTag[T]类型的隐式值。如果找到了,则使用它的newArray()方法构造正确类型的数组;否则报告上面的错误消息。

ClassTag的伴生对象中定义了所有基本类型的类清单,一般的对象类型则使用GenericClassTag。例如:

1
2
3
4
5
scala> evenElems(Vector(1, 2, 3, 4, 5))  // scala.reflect.ManifestFactory$IntManifest
val res0: Array[Int] = Array(1, 3, 5)

scala> evenElems(Vector("this", "is", "a", "test", "run"))  // scala.reflect.ClassTag$GenericClassTag
val res1: Array[String] = Array(this, a, run)

但是,如果类型参数本身是另一个没有类清单的类型参数,编译器仍然会报错:

1
2
def wrap[U](xs: Vector[U]) = evenElems(xs)
// error: No ClassTag available for U

解决方法是要求存在U的类清单:

1
def wrap[U: ClassTag](xs: Vector[U]) = evenElems(xs)

总之,创建泛型数组需要类清单。每当需要创建类型参数T的数组时,还需要提供T的隐式类清单。

6.1.7 字符串

https://docs.scala-lang.org/overviews/collections-2.13/strings.html

与数组一样,字符串本身不是序列,但可以被隐式转换为序列,并且支持所有序列操作。例如:

1
2
3
4
5
6
val str = "hello"
str.reverse  // "olleh"
str.map(_.toUpper)  // "HELLO"
str.drop(3)  // "lo"
str.slice(1, 4)  // "ell"
val s: Seq[Char] = str  // WrappedString

有两个隐式转换:

  • 一个将String转换为StringOps,将不可变序列的所有方法添加到字符串中,优先级较高。上面的前四个方法调用中自动插入了该转换。
  • 另一个将String转换为WrappedStringIndexedSeq[Char]的子类),优先级较低。上面最后一行应用了该转换。

6.2 可变和不可变集合

https://docs.scala-lang.org/overviews/collections-2.13/overview.html

Scala系统地区分了可变和不可变集合。可变(mutable)集合可以添加、删除或更新元素。不可变(immutable)集合在创建后永远不会改变,其添加、删除或更新操作会返回一个新的集合,旧集合保持不变。

大多数集合类都有三种变体:

  • scala.collection.immutable包中的集合是不可变的。
  • scala.collection.mutable包中的集合是可变的。
  • scala.collection包中的集合可能是可变的或不可变的,是另外两个的超类,称为根集合。

例如,scala.collection.Seq[T]scala.collection.immutable.Seq[T]scala.collection.mutable.Seq[T]的超类。

一般来说,scala.collection包中的根集合提供转换操作,scala.collection.immutable包中的不可变集合通常增加用于添加或删除单个值的操作,scala.collection.mutable包中的可变集合通常增加有副作用的修改操作。

根集合和不可变集合的另一个区别是:不可变集合一定不会被修改,而根集合的运行时类型仍然可能是可变集合,可能在其他地方被修改。例如:

1
2
3
val s1: scala.collection.immutable.Seq[Int] = ...  // guaranteed to be immutable
val s2: scala.collection.mutable.Seq[Int] = ...
val s: scala.collection.Seq[Int] = s2  // can be mutated through s2

默认情况下,Scala总是选择不可变集合。例如,如果直接使用没有任何前缀、也没有从其他地方导入的Set,将会得到不可变集。因为这是scala.Predef类中定义的别名:

1
2
type Set[A] = scala.collection.immutable.Set[A]
val Set = scala.collection.immutable.Set

要获得可变集,需要显式地写collection.mutable.Setscala包是默认导入的)。

如果想同时使用集合的可变和不可变版本,一种约定是只导入collection.mutable包。这样没有前缀的Set仍然是不可变集,mutable.Set是可变集:

1
2
3
4
import scala.collection.mutable

val s1 = Set(1, 2, 3)  // immutable
val s2 = mutable.Set(1, 2, 3) // mutable

为了方便,一些重要类型在scala包中定义了别名,因此可以直接使用而无需导入。例如,List类型可以通过以下三种方式访问:

  • scala.collection.immutable.List
  • scala.List
  • List

注:这些别名定义在scala/package.scala中:

1
2
type List[+A] = scala.collection.immutable.List[A]
val List = scala.collection.immutable.List

其他类型别名包括Iterable, Iterator, Seq, IndexedSeq, LazyList, Vector, StringBuilderRange

6.2.1 集合类层次结构

下图显示了scala.collection包中的所有集合。这些都是抽象类或特质,通常有可变和不可变的实现。

集合类图

下图显示了scala.collection.immutable包中的所有集合。

不可变集合类图

下图显示了scala.collection.mutable包中的所有集合。

可变集合类图

图例:

图例

集合类层次结构中的大多数类都有三种变体:根、可变和不可变。唯一的例外是Buffer,它只有可变集合。

所有集合都可以通过统一的语法创建——类名后跟其元素。特质和具体实现类都适用。

1
2
3
4
5
6
7
8
9
10
11
Iterable("x", "y", "z")
Seq(1, 2, 3)
List(4, 5, 6)
Vector(7, 8, 9)
Buffer(x, y, z)
IndexedSeq(1.0, 2.0)
LinearSeq(a, b, c)
Set(Color.red, Color.green, Color.blue)
SortedSet("hello", "world")
Map("a" -> 1, "b" -> 2, "c" -> 3)
HashMap("x" -> 24, "y" -> 25, "z" -> 26)

所有集合都实现了toString()方法,按照与上面相同的方式显示。

所有集合都支持Iterable提供的API,但返回类型可能更具体。例如,Iterable.map()方法返回Iterable,但List.map()返回ListSet.map()返回Set,等等。这种行为称为统一返回类型原则(uniform return type principle)。

1
2
3
4
5
scala> List(1, 2, 3).map(_ + 1)
val res0: List[Int] = List(2, 3, 4)

scala> Set(1, 2, 3).map(_ * 2)
val res1: scala.collection.immutable.Set[Int] = Set(2, 4, 6)

6.3 匿名函数

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

本节将介绍函数式编程的一个特性,称为匿名函数(anonymous functions)(类似于Java的Lambda表达式)。

6.3.1 单参数函数

给定一个列表:

1
val nums = List(1, 2, 3)

List[A]map()方法接受一个A => B类型的函数f,将每个元素x映射为f(x),返回一个新列表List[B]。简化的声明如下:

1
def map[B](f: A => B): List[B]

可以通过map()方法将nums中的每个元素加倍来创建一个新列表,如下所示:

1
val doubledNums = nums.map(_ * 2)  // List(2, 4, 6)

在这个示例中,_ * 2是一个匿名函数(类型为Int => Int),表示“将每个元素乘以2”。

如果愿意,也可以用=>语法编写匿名函数。上面的代码等价于

1
2
val doubledNums = nums.map(i => i * 2)
val doubledNums = nums.map((i: Int) => i * 2)

这三种写法的含义相同。

这个map()示例等价于以下Java代码:

1
2
3
4
5
6
List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3));

// the `map` process
List<Integer> doubledNums = nums.stream()
    .map(i -> i * 2)
    .collect(Collectors.toList());

还等价于Scala的for表达式:

1
val doubledNums = for (i <- nums) yield i * 2

使用匿名函数的另一个例子是List类的filter()方法。该方法接受一个A => Boolean类型的谓词p,返回一个新列表List[A],其中仅包含使p(x)true的元素x。声明如下:

1
def filter(p: A => Boolean): List[A]

给定以下列表:

1
val nums = List.range(1, 10)  // List(1, 2, 3, 4, 5, 6, 7, 8, 9)

可以像这样创建一个包含nums中所有大于5的整数的新列表:

1
val a = nums.filter(_ > 5)  // List(6, 7, 8, 9)

这行代码等价于

1
2
def lessThanFive(i: Int): Boolean = i < 5
val a = nums.filter(lessThanFive)

像这样创建只包含偶数的列表:

1
val b = nums.filter(_ % 2 == 0)  // List(2, 4, 6, 8)

6.3.2 多参数函数

List[A]reduce()方法接受一个(A, A) => A类型的二元运算符op,将op依次应用于每个元素,返回最终结果op(...op(op(a1, a2), a3)..., an)。简化的声明如下:

1
def reduce(op: (A, A) => A): A

例如,可以像这样计算列表中所有元素的和:

1
2
val a = List.range(1, 101)  // List(1, 2, ..., 100)
val s = a.reduce(_ + _)  // 5050

其中,匿名函数_ + _等价于(x, y) => x + y

另外也可以直接调用sum方法:

1
val s = a.sum

6.3.3 高阶函数

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

高阶函数(higher order function)是接受函数作为参数或者返回一个函数的函数或方法。

最常见的例子之一是Scala集合的map()方法。

1
2
3
val salaries = Seq(20000, 70000, 40000)
val doubleSalary = (x: Int) => x * 2
val newSalaries = salaries.map(doubleSalary) // List(40000, 140000, 80000)

也可以将方法作为参数传递给高阶函数。

1
2
3
4
5
case class WeeklyWeatherForecast(temperatures: Seq[Double]) {
  private def convertCtoF(temp: Double) = temp * 1.8 + 32

  def forecastInFahrenheit: Seq[Double] = temperatures.map(convertCtoF)
}

其中,方法convertCtoF被传递给高阶函数map()。这是因为编译器会将方法convertCtoF转换为函数x => convertCtoF(x)

接受函数参数的函数

使用高阶函数的一个原因是减少冗余代码。例如:

1
2
3
4
5
6
7
8
9
10
object SalaryRaiser {
  def smallPromotion(salaries: List[Double]): List[Double] =
    salaries.map(salary => salary * 1.1)

  def greatPromotion(salaries: List[Double]): List[Double] =
    salaries.map(salary => salary * math.log(salary))

  def hugePromotion(salaries: List[Double]): List[Double] =
    salaries.map(salary => salary * salary)
}

这三个方法的唯一区别是乘法系数。为了简化,可以将重复代码提取为高阶函数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
object SalaryRaiser {
  private def promotion(salaries: List[Double], promotionFunction: Double => Double): List[Double] =
    salaries.map(promotionFunction)

  def smallPromotion(salaries: List[Double]): List[Double] =
    promotion(salaries, salary => salary * 1.1)

  def greatPromotion(salaries: List[Double]): List[Double] =
    promotion(salaries, salary => salary * math.log(salary))

  def hugePromotion(salaries: List[Double]): List[Double] =
    promotion(salaries, salary => salary * salary)
}

新方法promotion()接受一个列表和一个Double => Double类型的函数。

返回函数的函数

在某些情况下,需要生成一个函数。例如:

1
2
3
4
5
6
7
8
9
10
def urlBuilder(ssl: Boolean, domainName: String): (String, String) => String = {
  val schema = if (ssl) "https://" else "http://"
  (endpoint, query) => s"$schema$domainName/$endpoint?$query"
}

val domainName = "www.example.com"
def getURL = urlBuilder(true, domainName)
val endpoint = "users"
val query = "id=1"
val url = getURL(endpoint, query) // "https://www.example.com/users?id=1"

urlBuilder()的返回类型是(String, String) => String。在上面的例子中,返回的匿名函数是(endpoint: String, query: String) => s"https://www.example.com/$endpoint?$query"

6.3.4 Java函数式接口

从Scala 2.12起,匿名函数可以直接充当Java函数式接口,即单一抽象方法类型(single abstract method, SAM)。这使得在Scala中使用Java库变得更容易。例如:

1
2
3
4
5
scala> val r: Runnable = () => println("Run!")
val r: Runnable = $Lambda$1292/0x000001f44c5b6a08@248227db

scala> r.run()
Run!

注意,只有匿名函数可以转换为SAM类型的实例,而FunctionN类型的任意表达式不可以:

1
2
3
4
5
6
7
8
scala> val f = () => println("Faster!")
val f: () => Unit = $Lambda$1294/0x000001f44c588800@73032867

scala> val fasterRunnable: Runnable = f
                                      ^
       error: type mismatch;
        found   : () => Unit
        required: Runnable

参见

6.4 常用集合方法

Scala集合类的一大优势是它内置了大量有用的方法。本节展示一些最常用的序列方法和映射方法。

6.4.1 常用序列方法

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

本节将展示一些最常用的序列方法。这些方法适用于所有序列类,包括ArrayBufferListVectorArray等。另外,这些方法都不会修改当前集合,而是返回一个新集合。

以下面的列表为例:

1
val a = List(1, 2, 3, 4, 5)
方法描述示例
map(f)将每个元素x映射为f(x)a.map(_ * 2)返回List(2, 4, 6, 8, 10)
filter(p)只保留满足p的元素a.filter(_ > 3)返回List(4, 5)
foreach(f)f应用于每个元素a.foreach(println)打印所有元素
head, tail第一个元素、剩余的元素a.head返回1
a.tail返回List(2, 3, 4, 5)
init, last之前的元素、最后一个元素a.init返回List(1, 2, 3, 4)
a.last返回5
take(n)选择前n个元素a.take(3)返回List(1, 2, 3)
drop(n)丢弃前n个元素a.drop(3)返回List(4, 5)
takeWhile(p)选择满足p的最长前缀a.takeWhile(_ < 3)返回List(1, 2)
dropWhile(p)丢弃满足p的最长前缀a.dropWhile(_ < 3)返回List(3, 4, 5)
reduce(op),
reduceLeft(op),
reduceRight(op)
op依次应用于每个元素
从左到右:op(...op(op(a1, a2), a3)..., an)
从右到左:op(a1, op(a2, ...op(an-1, an)...))
a.reduce(_ + _)返回15
fold(z)(op),
foldLeft(z)(op),
foldRight(z)(op)
类似于reduce,但从零元素z开始
从左到右:op(...op(op(z, a1), a2)..., an)
从右到左:op(a1, op(a2, ...op(an, z)...))
a.fold(0)(_ + _)返回15
scan(z)(op),
scanLeft(z)(op),
scanRight(z)(op)
返回前缀序列
从左到右:z, op(z, a1), op(op(z, a1), a2), ...
从右到左:..., op(an-1, op(an, z)), op(an, z), z
a.scanLeft(0)(_ + _)返回List(0, 1, 3, 6, 10, 15)
a.scanRight(0)(_ + _)返回List(15, 14, 12, 9, 5, 0)

完整列表参见Seq类API文档

6.4.2 常用映射方法

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

本节将展示一些最常用的映射方法。

以下面的映射为例:

1
val m = Map(1 -> "a", 2 -> "b", 3 -> "c", 4 -> "d")
方法描述示例
keys键集合m.keys返回Iterable(1, 2, 3, 4)
values值集合m.values返回Iterable(a, b, c, d)
contains(k)测试映射是否包含键km.contains(3)返回true
transform(f)将每个键值对(k, v)的值映射为f(k, v)m.transform((k, v) => v.toUpperCase)返回
Map(1 -> A, 2 -> B, 3 -> C, 4 -> D)

完整列表参见Map类API文档

6.4.3 工厂方法

https://docs.scala-lang.org/overviews/collections-2.13/creating-collections-from-scratch.html

下表总结了集合伴生对象提供的工厂方法:

方法描述
C.empty空集合
C(x, y, ...)由元素x, y, ...构成的集合
C.concat(xs, ys, ...)拼接xs, ys, ...的所有元素得到的集合
C.fill(n)(e)长度为n、元素全部为e的集合(e是传名参数)
C.fill(m, n)(e)大小为m × n、元素全部为e的集合
C.tabulate(n)(f)长度为n、索引i处的元素为f(i)的集合
C.tabulate(m, n)(f)大小为m × n、索引(i, j)处的元素为f(i, j)的集合
C.range(start, end)整数集合start, start+1, ..., end-1
C.range(start, end, step)startend(不包含)、步长为step的整数集合
C.iterate(x, n)(f)长度为n的集合,元素为x, f(x), f(f(x)), ...

完整列表参见API文档

6.5 与Java集合相互转换

https://docs.scala-lang.org/overviews/collections-2.13/conversions-between-java-and-scala-collections.html

与Scala一样,Java也有丰富的集合库。两者有很多相似之处,有时可能需要相互转换。例如,对现有的Java集合调用Scala集合方法,或者将Scala集合传递给接受Java集合的Java方法。这很容易做到,JavaConverters对象提供了主要集合类型之间的相互转换。

注:从Scala 2.13起应该使用scala.jdk.CollectionConverters

通过asScalaasJava方法支持以下双向转换:

1
2
3
4
5
6
scala.collection.Iterable       <=> java.lang.Iterable
scala.collection.Iterator       <=> java.util.Iterator
scala.collection.mutable.Buffer <=> java.util.List
scala.collection.mutable.Set    <=> java.util.Set
scala.collection.mutable.Map    <=> java.util.Map
scala.collection.concurrent.Map <=> java.util.concurrent.ConcurrentMap

通过asJava方法支持以下单向转换:

1
2
3
4
scala.collection.Seq         => java.util.List
scala.collection.mutable.Seq => java.util.List
scala.collection.Set         => java.util.Set
scala.collection.Map         => java.util.Map

要使用这些转换,只需从JavaConverters对象导入即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
scala> import scala.collection.JavaConverters._
import scala.collection.JavaConverters._

scala> import scala.collection.mutable
import scala.collection.mutable

scala> val jul: java.util.List[Int] = mutable.ArrayBuffer(1, 2, 3).asJava
val jul: java.util.List[Int] = [1, 2, 3]

scala> val buf: mutable.Seq[Int] = jul.asScala
val buf: scala.collection.mutable.Seq[Int] = ArrayBuffer(1, 2, 3)

scala> val jum: java.util.Map[String, Int] = mutable.HashMap("abc" -> 1, "hello" -> 2).asJava
val jum: java.util.Map[String,Int] = {abc=1, hello=2}

在内部,这些转换会创建一个“包装器”对象,将所有操作转发给底层集合对象。因此在转换时,集合不会被拷贝。如果转换后再转换回原始类型,最终会得到与开始时同一个集合对象。

由于Java在类型上不区分可变和不可变集合,scala.collection.immutable.List会转换为一个java.util.List,但其中所有修改操作都抛出UnsupportedOperationException。例如:

1
2
val jul = List(1, 2, 3).asJava
jul.add(7)  // throws UnsupportedOperationException

6.6 视图

https://docs.scala-lang.org/overviews/collections-2.13/views.html

集合有很多构造新集合的方法,例如map()filter()++。这些方法称为变换(transformer)。

实现变换操作主要有两种方式。一种是严格的(strict),即立刻构造一个新集合作为结果;另一种是惰性的(lazy),即构造结果集合的一个代理,其元素仅在需要时计算。除了LazyList,Scala集合的所有转换操作默认都是严格的。

考虑下面的例子,假设对一个整数向量连续做两次映射:

1
2
val v = Vector(1, 2, 3, 4, 5)
val w = v.map(_ + 1).map(_ * 2)  // Vector(4, 6, 8, 10, 12)

表达式v.map(_ + 1)构造了一个新向量,然后通过调用map(_ * 2)将其转换为第三个向量。在许多情况下,构造中间结果是浪费的。在上面的例子中,使用两个函数的组合(_ + 1) * 2进行单次映射会更快。但通常,连续的变换是在程序的不同模块中完成的,组合这些变换会破坏模块化。

避免中间结果的一种通用方式是:首先将集合转换为视图,然后对视图应用所有变换,最后将视图转换回集合。视图(view)是一种特殊的集合,是表示底层集合的轻量级对象,所有变换操作都是惰性的。

要从集合得到其视图,调用view方法(如c.view)。要从视图转换回集合,调用toXxxto(Xxx)方法(如v.toListv.to(List))。

前面的例子可以像这样使用视图:

1
val w = v.view.map(_ + 1).map(_ * 2).to(Vector)  // or .toVector

下面逐步查看每个操作:

1
2
3
4
5
6
7
8
9
10
11
scala> val vv = v.view
val vv: scala.collection.IndexedSeqView[Int] = IndexedSeqView(<not computed>)

scala> vv.map(_ + 1)
val res0: scala.collection.IndexedSeqView[Int] = IndexedSeqView(<not computed>)

scala> res0.map(_ * 2)
val res1: scala.collection.IndexedSeqView[Int] = IndexedSeqView(<not computed>)

scala> res1.to(Vector)
val res2: scala.collection.immutable.Vector[Int] = Vector(4, 6, 8, 10, 12)
  • v.view生成一个视图IndexedSeqView[Int],即惰性求值的IndexedSeq[Int]。视图的toString()方法不会计算元素,因此其内容显示为<not computed>
  • vv.map(_ + 1)的结果是另一个视图。这本质上是一个包装器,记录了需要对向量v应用map(_ + 1)操作,但不会真正计算。
  • res0.map(_ * 2)同上,仅记录操作。
  • 最后调用to(Vector)强制计算结果,这样就不需要中间结果。

下面是另一个例子。假设有一个很长的单词序列,你想从前100万个单词中找到第一个回文。

1
2
3
4
5
def isPalindrome(x: String) = x == x.reverse
def findPalindrome(s: Seq[String]): Option[String] = s.find(isPalindrome)

val firstPalindrome = findPalindrome(words.take(1000000))  // bad
val firstPalindrome = findPalindrome(words.view.take(1000000))  // good

words.take(1000000)总是会构造包含100万个单词的中间序列,而words.view.take(1000000)只会构造一个轻量级的视图对象。

一般来说,视图具有以下属性:

  1. 转换操作的复杂度为O(1)
  2. 元素访问操作的复杂度与底层数据结构相同(例如,IndexedSeqView的索引访问是O(1)的)。

不过也有一些例外。例如,sorted操作无法同时满足这两个属性。实际上,视图的sorted操作不是惰性的,而是始终强制计算,因此违反了属性1,但仍满足属性2。

既然视图这么好用,为什么还要有严格的集合?一个原因是惰性集合的性能并不总是更好。对于较小的集合,在视图中构造和应用闭包的额外开销通常大于避免中间结果的收益。

另一个可能更重要的原因是,如果操作有副作用,惰性求值可能会令人困惑。例如,在Scala 2.8以前,Range类型是惰性的。用户期望下面的代码会创建10个actor:

1
val actors = for (i <- 1 to 10) yield actor { ... }

然而实际上没有创建任何actor。这是因为在早期版本的Scala中,1 to 10产生的对象行为类似于视图。上面的for表达式等价于(1 to 10).map(i => actor { ... }),其结果也是一个视图,因此不会计算任何元素。为了避免此类问题,当前的Scala集合库要求除LazyList和视图外,所有集合都是严格的。

总之,视图是调和效率和模块化的强大工具。不过,应该将视图限制在纯函数式代码中。最好避免将视图与创建新集合同时有副作用的操作混合。

6.7 并行集合

https://docs.scala-lang.org/overviews/parallel-collections/overview.html

Scala标准库包含了并行集合(parallel collection),旨在提供可靠并行编程的高层抽象,同时使用户免受底层细节的困扰。

考虑下面的例子,在大型集合上执行map()操作:

1
2
val a = (1 to 1000000).toList
val b = a.map(_ + 42)

要并行执行相同的操作,只需在串行集合上调用par方法,之后就可以像串行集合一样使用并行集合。例如:

1
val b = a.par.map(_ + 42)

Scala并行集合库为许多重要的集合类提供了对应的并行版本(在scala.collection.parallel包中),包括ParArray, ParSeq, ParVector, ParMap, ParSet, ParRange, ParTrieMap

注意:如果使用Scala 2.13+,使用并行集合必须依赖一个单独的模块scala-parallel-collectionsAPI文档):

1
2
3
4
5
<dependency>
    <groupId>org.scala-lang.modules</groupId>
    <artifactId>scala-parallel-collections_2.13</artifactId>
    <version>1.2.0</version>
</dependency>

并在代码中添加导入:

1
import scala.collection.parallel.CollectionConverters._

6.7.1 创建并行集合

有两种创建并行集合的方式。第一种是使用new关键字:

1
2
import scala.collection.parallel.immutable.ParVector
val pv = new ParVector[Int]

第二种是使用串行集合的par方法:

1
val pv = Vector(1, 2, 3, 4, 5).par

也可以通过调用并行集合的seq方法转换为串行集合。

6.7.2 并行操作示例

下面是一些简单的用法示例。注意:这些示例都是对小集合进行操作,仅作为演示。只有当集合很大时,速度提升才会显著。

1
2
3
4
5
6
val lastNames = List("Smith", "Jones", "Frankenstein", "Bach", "Jackson", "Rodin").par
lastNames.map(_.toUpperCase)  // ParVector(SMITH, JONES, FRANKENSTEIN, BACH, JACKSON, RODIN)
lastNames.filter(_.head >= 'J')  // ParVector(Smith, Jones, Jackson, Rodin)

val parArray = (1 to 10000).toArray.par
parArray.fold(0)(_ + _)  // 50005000

6.7.3 语义

虽然并行集合的用法看起来与串行集合非常相似,但要注意它的语义不同,特别是对于副作用和非结合性操作。

从概念上讲,Scala并行集合通过递归地“分割”(split)给定的集合,并行地对每个分区应用操作,最后重新“组合”(combine)所有结果来完成并行化操作。

并行集合的并发和“无序”(out-of-order)语义意味着:

  • 副作用操作可能导致不确定性。
  • 非结合性操作会导致不确定性。

(1)副作用操作

由于并行集合的并发执行语义,为了保持确定性,应该避免在集合上执行会产生副作用的操作。一个简单的例子是在foreach()方法中修改外部声明的变量。

1
2
3
4
5
6
7
8
9
10
11
12
val list = (1 to 1000).toList.par
var sum = 0
list.foreach(sum += _)
println(sum)  // 467766

sum = 0
list.foreach(sum += _)
println(sum)  // 457073

sum = 0
list.foreach(sum += _)
println(sum)  // 468520

多次执行会得到不同的sum值。这种不确定性的来源是数据竞争——对同一变量的并发读写。

(2)非结合性操作

由于并行集合的“无序”语义,只能执行结合性(associative)操作(即(a op b) op c) == a op (b op c)),以避免不确定性。例如,减法是一种非结合性操作:

1
2
3
4
val list = (1 to 1000).toList.par
list.reduce(_ - _)  // -228888
list.reduce(_ - _)  // -61000
list.reduce(_ - _)  // -331818

注意:非交换性操作并不会导致不确定性。一个简单的例子是字符串拼接——一种结合性但非交换性操作:

1
2
val strings = List("abc", "def", "ghi", "jk", "lmnop", "qrs", "tuv", "wx", "yz").par
val alphabet = strings.reduce(_ ++ _)  // abcdefghijklmnopqrstuvwxyz

并行集合的“无序”语义只意味着操作的执行(在时间意义上)无序,并不意味着结果的组合(在空间意义上)无序。结果会按划分分区的顺序重新组合。

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