曾经以为Python中的List用法足够灵活,直至我遇到了Scala…

2021-02-03 16:29:22 浏览数 (1)

导读

继续开工Scala系列专题,虽然对自己来说这是一个全新的方向和足够的挑战,阅读数也很是惨淡,但选择了方向就要坚持下去——生活中的获得感不正是源于一个个挑战和抉择之间吗!

言归正传,前期分别完成了Scala中的变量标识符和运算符的分享,本文重点介绍Scala中的常用集合类数据结构(scala.collection),当完整了解这个包的构成以及各数据结构的常用方法后,你会再次认识到Scala语法的强大和奔放,以至于让我一度质疑“Python语法足够简洁”的论断。

开篇引题:程序=数据结构 算法,对于一个良好的编程实现来说二者缺一不可。而对于数据结构,除了特定框架的特有数据结构外(例如Spark框架的RDD、DataFrame,Pandas框架的DataFrame),其实更为通用的其实还是那些经典数据结构,例如数组、链表、集合、字典等等,这也是绝大多数编程语言的通用设计。当然,还有很多其他数据结构,例如栈、队列、树和图等,其底层大多可以基于这些基础的数据结构进行表示和实现。

具体而言,本文主要介绍Scala中的以下5种经典的集合类数据结构:

  • Array
  • List
  • Set
  • Map
  • Tuple

01 Array

Array,原意即为数组,应该是所有编程语言中都有的数据结构,也是很多场景下常用的集合类型,有编程语言的应该都有了解。那么,Scala中的Array有什么特别之处吗?一句话概括Scala中的Array就是:同质、数据可变、长度不可变的集合。显然,这其中包含了3个关键词,也分别描述了Array的3个特点:

  • 同质:意味着Array中的所有元素类型(或者称之为泛型,字面意就是广泛存在的通用类型)都是相同的,例如都是Int整型、或者String字符串型。如果非要容纳不同的数据类型,例如既有Int又有Double还有String,那么这个Array的数据类型只能是这些数据类型的超类——Any型
  • 数据可变:数据可变意味着Array中的每个元素可以发生变化,比如开始为0,后来可以变为1等等。这里之所以强调Array中的数据可变,是因为与之对应的是List数据不可变
  • 长度不可变:这与C和Java中的数组有一定相似之处,即一旦初始指定了Array的数据个数(即Array的length),那么后续则不能再发生改变。那么如果一定想要发生改变怎么办呢,那就只能调用Array的兄弟,ArrayBuffer

了解了Array数据结构的这3大特点,就相当于get到了Array的价值观。那么接下来自然就是方法论层面的问题:即怎么创建和如何使用。

Array的创建有两种方式,一种是直接指定元素完成初始化,另一种是指定数据类型和长度,而不提供初始数据。

代码语言:javascript复制
scala> val arr1 = Array(1, 2, "string")
val arr1: Array[Any] = Array(1, 2, string)

scala> val arr2 = Array[Int](3)
val arr2: Array[Int] = Array(3)

scala> val arr3 = new Array[Int](3)
val arr3: Array[Int] = Array(0, 0, 0)

如上述示例代码所示,arr1是一个直接指定初始元素的数组,由于此时未指定泛型且实际包含的初始数据既有整型也有字符串,所以相当于创建了一个泛型为Any、长度为3、初始元素为1、2、"string"的数组;arr2的初始化过程类似于arr1,但实际指定了泛型类型为Int型,且实际只有1个初始化数据3;arr3与arr2的唯一区别在于Array前多了一个new关键字,这将直接导致创建了一个长度为3、泛型为Int的数组,进一步地由于指定泛型为Int所以默认初始元素均为0。

这里,对比arr2和arr3的创建过程,可以发现当带有new关键字的初始化时采用的原原本本的由类创建对象的方式;而不带new关键字时,实际上是调用了Array类的伴生对象apply方法完成初始化,在这种方法中可以省略new关键字,从而简化由类创建对象的过程。这将在后续介绍类和对象时予以介绍,此处只需了解两种不同初始化方式的具体实现即可。

在创建一个Array数组后,还需了解基本的常用操作。这里,由于Array数组是数据可变长度不可变的集合,所以对该数组涉及的操作无非就是访问和修改值两类操作;但同时,虽然Array本身长度不可变,但却可以添加新的元素或者与其他Array连接构成新的Array,注意这里都是构成了新的Array,而不是改变原有Array,因为其长度不可变。

代码语言:javascript复制
scala> arr1(0)  //  用() 下标访问数组元素
val res0: Any = 1

scala> arr1 :  3  // :  后面添加新元素
val res1: Array[Any] = Array(1, 2, string, 3)

scala> 0  : arr1  //  :前面添加新元素
val res2: Array[Any] = Array(0, 1, 2, string)

scala> arr1    Array(4, 5)  //    连接其他数组
val res3: Array[Any] = Array(1, 2, string, 4, 5)

如上的示例代码中分别执行了Array元素的访问、前后向添加元素构成新的Array以及与其他Array拼接构成新的Array,基本上这几个操作也是最为常用的操作。除了以上访问和追加新的元素,当然Array也提供了很多常用的接口,例如:

代码语言:javascript复制
scala> arr1.length  // 返回数组长度
val res4: Int = 3

scala> arr1.indices  // 返回数组下标列表
val res7: scala.collection.immutable.Range = Range 0 until 3

scala> arr1.foreach(print)  // 调用foreach方法
12string

其中foreach方法应该称之为是所有集合类数据结构的通用方法。另外,除了length、indices等之外,如果是Array泛型为数值型,那么还有其他常用方法,例如max、min等。

最后,再补充关于Array的两个要点:

  • 创建多维数值。实际上,多维数组就是数组的多层嵌套,所以自然可以用前述的数组初始化方式嵌套完成多维数组的创建,当数组是一个整齐的维度例如m×n时,那么可直接调用Array.ofDim(m, n)创建即可;
  • 前面提到,Array是一个长度不可变的数据集合,那么有时为了应用可变长度的数组,此时需要引用ArrayBuffer类来创建,其与Array的最大区别即在于它的长度是可以动态改变。

02 List

前述详细介绍了Array数据结构的特点、创建及常用方法,与Array表现极为相近但又有重要不同的数据结构就是List,即列表。List的特点可概括为:同质、数据不可变且长度不可变的集合。也就是说,相较于Array类型,List的最大区别在于数据不可变,即一旦初始化则其不可更改。更深层次的讲,Array的底层是一块连续申请的内存,而List则更符合链表的实现特性(实际上,在scala2.11之前的版本,确实存在一个LinkedList类)。

既然List与Array除了数据一旦完成初始即不再可变,所以其很多特性都有相通之处,当然也有很多不同。比如,由于数据不可变,所以其创建过程自然就不能仅指定长度而不提供初值,也就是创建时必须提供所有初值。

代码语言:javascript复制
scala> val list = new List[Int](3)
                  ^
       error: class List is abstract; cannot be instantiated

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

除了创建过程中的区别,Array中的数值访问、元素拼接、两个List拼接以及常用方法在List中也都适用。特别地,长度为0的List是一个特殊对象,表示为Nil。

代码语言:javascript复制
scala> list(0)
val res9: Int = 1

scala> list :  4
val res10: List[Int] = List(1, 2, 3, 4)

scala> 0  : list
val res11: List[Int] = List(0, 1, 2, 3)

scala> list    List(5, 6)
val res12: List[Int] = List(1, 2, 3, 5, 6)

scala> list.length
val res13: Int = 3

然而,如果List的常用方法也仅仅如此的话,那么其实好像是被Array完全碾压的节奏,所以实际上List还有更多的语法糖骚操作,即灵活运用::这个操作符,读作cons,即连接的意思。在前面介绍操作符一文时,有提到过在Scala中但凡以:结尾的操作符,那么都将以右操作数来调用,其实这里主要就是指的就是Array和List,而尤以List含有:方法居多。例如,同样拼接元素,下面的方法也可实现添加元素和拼接两个List:

代码语言:javascript复制
scala> 0 :: list  // list调用::方法,在前面添加元素0,构成新List
val res14: List[Int] = List(0, 1, 2, 3)

scala> List(0, 1) ::: list  // list调用:::方法,与另一个List拼接成新List
val res15: List[Int] = List(0, 1, 1, 2, 3)

当然,将::写在两个操作数中间是将其看做是操作符来执行计算,而更为严谨的说其实质是调用的::方法,即上面两句代码其底层执行的是如下逻辑:

代码语言:javascript复制
scala> list.::(0)  // ::是List的方法接口
val res16: List[Int] = List(0, 1, 2, 3)

scala> list.:::(List(0, 1))  // :::也是list的方法接口
val res17: List[Int] = List(0, 1, 1, 2, 3)

怎么样,是不是感觉Scala中List可比Python中List的骚操作更多呢?

与此同时,List由于更贴近与链表的实现特性,所以具有更多的访问首尾方法,即head和tail,其中head为返回第一个元素,而tail则是返回第一个元素以外的所有元素。

代码语言:javascript复制
scala> list.head  // 返回第一个元素,结果是一个值
val res18: Int = 1

scala> list.tail  // 返回第一个以后的所有元素,结果仍然是一个list
val res19: List[Int] = List(2, 3)

另外需要补充的是,与ArrayBuffer和Array的关系类似,List也有其兄弟ListBuffer,用于实现可变长的列表。

03 Set

与大多数语言中均提供了Set数据结构类似,Scala中的Set也扮演了这一角色。Set的最大特点是:同质、数据去重、长度不可变,其中数据去重是所有集合的特性,默认以哈希集实现。注:Scala中的可变Set和不可变Set是同名类,都叫做Set,这与Array和List区分是否带Buffer是完全不同的命名设计。

在了解Array和List的基础上,Set的创建也比较直观,同时也支持添加元素和拼接两个Set构成新的Set方法。另外,Set设计的定位是用于判断指定集合是否包含目标值,所以contains是其常用方法。Set常用操作示例如下:

代码语言:javascript复制
scala> val set =Set(1, 2, 2, 3)  // 创建Set,会自动去重
val set: scala.collection.immutable.Set[Int] = Set(1, 2, 3)

scala> set   4  // 添加新元素返回新的Set
val res20: scala.collection.immutable.Set[Int] = Set(1, 2, 3, 4)

scala> set    Set(3, 4)  // 拼接另一个Set,返回新的Set
val res21: scala.collection.immutable.Set[Int] = Set(1, 2, 3, 4)

scala> set.contains(2)
val res22: Boolean = true

当然,既然是集合,所以也提供了数学意义上的交集、并集和补集操作。另外,如需使用可变长集合,则需引用scala.collection.mutable.Set类,其与不可变集合Set为同名类,按照就近原则引用。

04 Map

与Set类似,Map也是编程语言中的一种常用数据结构,用于表达映射关系,在Python中就是字典数据结构dict,通过提供键值对的访问方式,可以以O(1)的复杂度完成数据的访问和赋值。在Scala中,Map也区分可变和不可变映射,且为同名类,如果需要创建可变Map,则需在适当位置import相应类即可。

在Scala中,Map的元素类型实际上是一个二值的元组类型(Tuple2),两个值分别为key和value,而对于这个特殊的二值元组,实际上则又有两种具体表达形式,示例如下:

代码语言:javascript复制
scala> val map1 = Map((1, 2), (3, 4))  // 创建map方式1:(key, value)
val map1: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 3 -> 4)

scala> val map2 = Map(2->3, 3->5)  // 创建map方式2:key->value
val map2: scala.collection.immutable.Map[Int,Int] = Map(2 -> 3, 3 -> 5)

类似于Python中的dict,Scala中的Map也可通过keys和values获取所有的键和值,且keys实际上就是一个Set,因而不会存在重复值;而values则不受这一限制:

代码语言:javascript复制
scala> map1.keys  // 获取所有键,返回结果是一个Set类型
val res23: Iterable[Int] = Set(1, 3)

scala> map1.values  // 获取所有值,返回结果是一个迭代类型
val res24: Iterable[Int] = Iterable(2, 4)

与Set类似,Map也支持类似的添加新键值对和与其他Map拼接的方法:

代码语言:javascript复制
scala> map1   (4->5)
val res27: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 3 -> 4, 4 -> 5)

scala> map1    map2
val res28: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 3 -> 5, 2 -> 3)

05 Tuple

前面介绍的4种数据结构,实际上都有一个共性要求是所有元素必须是同质的,即使是存在形式上的不同类型(例如一个数组既有整型,又有字符串型),那么其实质上是定义了泛型为Any的数组。而Tuple元组则是一个实实在在的支持不同泛型的集合数据结构,比如可以是第一个元素是整型,第二个元素是字符串型等等。例如,Map的每个键值对实际上都是一个二值元组,而正因为二值元组可以支持两种不同的数据类型,才保证了Map定义的多样性。

Tuple的创建与前面4种类型也略有不同,其可以直接以圆括号进行初始化即可,括号内的数据即为初始化值,且一旦指定也不可改变。特别地,这里Tuple最多可以支持22个元素的初始化,分别对应Tuple1——Tuple22类型。

代码语言:javascript复制
scala> val tuple1 = (1, "str")  // 二值元组创建方式一:()
val tuple1: (Int, String) = (1,str)

scala> val tuple2 = Tuple2("str", 3)  // 二值元组创建方式二:Tuple2实例化
val tuple2: (String, Int) = (str,3)

scala> val tuple3 = "str"->4  // 特殊地,二值元组还可通过->创建,如同Map中定义的那样
val tuple3: (String, Int) = (str,4)

上面给出了三种二值元组的创建方式,其中前两种是所有Tuple类型共有的,且第一种更为简洁通用;第三种属于二值元组所特有的,毕竟二值元组是Map中的对象类型,具有一定的特殊性。

那么,既然Tuple最多仅支持22个元素,那貌似好像挺受局限,毕竟数量上较少,但实则不然。单从多样性的角度讲,由于元组的每个元素类型都可能不一样,例如上面示例中tuple1是一个(Int, String)型二值元组,而tuple2则是一个(String, Int)型二值元组,虽然仅是类型调换了顺序,却是实实在在的不同类型元组。照此计算,以Scala中9种基本的数据类型为例进行计算,那么Tuple中所有的元组类型总数应该是9 9^2 …… 9^22,这其实是一个非常庞大的数字。

另外值得指出的是,得益于元组中支持不同类型的元素,所以函数中需要返回多个不同类型结果时即可以Tuple类型进行交换。


最后给出Scala中所有集合类数据结构的继承关系图,区分可变(mutable)和不可变(immutable)两种类型,仅做延伸了解,不具体展开。

Scala中的不可变集合类数据结构继承关系

Scala中的可变集合类数据结构继承关系

0 人点赞