Chisel前篇-Scala基础介绍

参考博文:https://blog.csdn.net/qq_34291505/article/details/86744581

第一章  新型敏捷硬件开发语言——Chisel和Scala

一,最好的宿主——什么是Scala

  Scala是一门基于JVM(Java Virtual Machine)运行的语言,而且它与Java互相兼容,在设计之初就考虑了与Java的无缝衔接,两者能互相调用。但是它的设计者的目的,是想创造一门比Java更好用、更高效、更优秀的语言。从运行机制上讲,Scala会被编译成与Java一样的字节码,交由JVM运行,所以程序速度和Java一样快。从实用性来看,它的形式比Java简洁的多,语法功能更加强大,代码量往往比相同功能的Java少很多。尽管这还是一门小众的语言,但不妨你去试着学习一下。

  Scala是一门面向对象的函数式语言。时至今日,面向对象已经成为编程语言必不可少的属性,其强大之处自不必过多解释。但另一方面,Scala没有选择更多人熟悉的指令式编程风格,而是选用了更为小众的函数式编程理念。对于熟悉C/C++、Java、Python等流行语言的读者来说,可能从未接触过函数式编程。请稍安勿躁,只需要基本的学习,读者便能掌握基本的函数式编程,并会逐步发现函数式编程的妙处。Scala提倡使用者使用函数式编程,但也预留了指令式编程的余地。

  正如它名字取自的“Scalable”一样,这也是一门可以自由伸缩的语言:既能裁剪已有的类库,又能扩展自定义类库;既能用于完成一个简单的脚本,又足以胜任任何复杂、庞大的软件系统。它的语法比Python还简洁,它的抽象能力比C++还高级。正因此Scala的学习曲线并非平滑的,而是阶梯状的。也正因此,如果你能耐心学会Scala,并逐步掌握它提供的高级语法,深入理解其编程理念,那么你就会发现这很可能是一门让你爱不释手、相见恨晚的编程语言。

  Scala最大的优势,可能就是其各种语法便利造就的强大伸缩性,进而成为一种优秀的宿主语言。换句话说,开发者可以方便地利用自定义Scala类库,快速开发出“新”语言,专门用于某一特殊用途。目前,它是公认构建DSL(domain-specific languages)最好的宿主语言(这个有待考证)。

二,敏捷开发——什么是Chisel

  对每个数字电路工程师而言,Verilog再熟悉不过了,也可能早就无力吐槽了。诚然,Verilog还是C语言时代的产物,现如今,开发效率低下的问题越来越明显。软件开发效率早已用其他语言突飞猛进,晶体管密度也随摩尔定律水涨船高,而我们最前端的HDL语言似乎还在原地踏步。早在二三十年前,人们就认为Verilog/VHDL快要过时了,当时的开发者主要分成三大派:一派主张应该改进Verilog,另外两派则主张把语言转移到软件语言(一派主张C++,一派主张Java),这样就能获得开源的综合工具和仿真器。最终,Verilog的改进版获胜了,也就是Verilog的后续标准——SystemVerilog。一方面,支持改进Verilog的人占多数;另一方面,也得到了Intel的支持。

  然而,SystemVerilog并不是非常好用。首先,尽管它引入了面向对象,但是却只能用于验证,因为验证更像是软件,而不是硬件设计。其次,SystemVerilog解决了一些Verilog语法瑕疵,使得硬件设计更为方便,但效果并不明显,治标不治本。再者,EDA厂商对SystemVerilog的支持不够积极,新特性到现在也不是全都实现了,使得工业界仍然偏向采用Verilog-2001。总结起来,从Verilog到SystemVerilog,并没有C到C++那样巨大的飞跃。

  那么主张C++的那一派呢?也就是我们现在熟悉的SystemC。事实上,SystemC也就是用C++定义的一堆类库。SystemC的开发多数还是用在事务级,即编写硬件模型。用SystemC直接开发硬件并不多见,因为目前EDA工具支持的不够好,相比Verilog,开发出来的电路优化得很差。

  那么主张Java的那些人呢?似乎我们现在才看到基于Java平台的HDL语言——Chisel出现,尽管Scala不是Java,但是基于JVM。Chisel(Constructing Hardware In a Scala Embedded Language)是一门以Scala为宿主语言开发的硬件构建语言,它是由加州大学伯克利分校的研究团队发布的一种新型硬件语言。据团队成员之一Krste Asanovic教授介绍,早在30多年前还没有硬件描述语言的时候,他们就已经开始构想这样一种语言了。最开始Chisel是基于Ruby的,但是后来发现Scala更适合构建Chisel。因为Scala有诸多特性适合描述电路,比如它是静态语言,以编译期为主,适合转换成Verilog/VHDL。再比如它的操作符即方法、柯里化、纯粹的面向对象、强大的模式匹配、便捷的泛型编写、特质混入、函数式编程等特性,使得用Scala开发DSL语言很方便。通过firrtl编译器可以把Chisel文件转换成firrtl文件,这是一种标准的中间交换格式,也就是让各种高级语言方便地转换到Verilog/VHDL的媒介,但它其实和verilog/VHDL属于同一层次。在这里,Chisel选择了妥协,没有直接生成电路,而是借助Verilog,主要是因为没有EDA工具支持,因此,它并不等同于HLS(High Level Synthesis)。将来也许会有EDA工具直接支持Chisel。

  Chisel解决了Verilog的一些痛点。首先,也是最重要的,也就是在硬件电路设计中引入了面向对象的特性。其次,减少了很多不必要的语法,改进了有瑕疵的语法。Verilog的初衷本就是用于电路验证,而不是设计,因此存在很多不可综合的语法。在Chisel转换成Verilog的过程中,不会采用这些不可综合的语法,因此编写Chisel时不用担心无法生成电路,对硬件新手而言是极大便利。再比如,Verilog的reg不一定指代寄存器,这常常被新手误解,而Chisel的Reg就是寄存器,没有歧义。最后,利用Scala的模式匹配、特质混入、类继承等特性,能够迅速改变电路结构。对于日益庞大的SoC系统,这是非常重要的。

  设计Chisel的初衷,就是因为伯克利的团队仅有十几名成员。相比常规硬件设计公司几百上千名工程师的配置,这属实“寒碜”。然而,他们借助Chisel,仅花了一年时间和100万美元,就完成了从RISC-V指令集设计到Rocket芯片的成功流片。这不得不让人好奇,Chisel这把“凿子(chisel的中文意思)”究竟有多大的魔力?

第二章  Scala入门——让你的代码跑起来

一、Scala的安装方法

  要使用Scala,首先需要保证已经安装好了Java 8。对于Linux操作系统,Java 8已经默认安装了,而使用Windows操作系统的用户,则需要在Java官网下载安装包进行安装。请在CMD、PowerShell或终端中运行“java -version”命令,如果得到以下信息,则说明Java 8已经安装成功。

C:\Users\Administration>java -version
java version "1.8.0_201"
Java(TM) SE Runtime Environment (build 1.8.0_201-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)

  然后,在Scala的官网里下载Scala的安装包。首先选择对应的版本,推荐使用最新版本。然后根据系统选择相应的安装包格式:Windows系统使用msi格式,Linux系统使用deb或rpm格式。请在CMD、PowerShell或终端中运行“scala -version”命令,如果得到以下信息,则说明Scala已经安装成功。

C:\Users\Administration>scala -version
Scala code runner version 2.12.7 -- Copyright 2002-2018, LAMP/EPFL and Lightbend, Inc.

二、使用Scala解释器
  继上一步安装完成后,只需要在CMD、PowerShell或者终端里运行“scala”就能进入Scala解释器,界面如下。

PS E:\Microsoft VS\Scala> scala
Welcome to Scala 2.12.7 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_201).
Type in expressions for evaluation. Or try :help.

scala>

   此时,光标会自动移动到“scala>”的后面,接下来就可以键入Scala代码,按回车运行。例如:

scala> 1 + 2
res0: Int = 3

   如果代码不足以构成一条语句,那么按下回车后并不会运行,而是自动跳转到下一行,等待其余代码的键入。例如:

scala> println(
         |     "Hello, world!"
         |  )
Hello, world!

  如果连续键入两行空行,则会退出当前命令,重新开始等待新命令的键入。例如:

scala> println(
         |
         |
You typed two blank lines.  Starting a new command.

scala>

  如果要退出解释器,只需要运行“:quit”或“:q”即可。例如:

scala> :q
PS E:\Microsoft VS\Scala> 

三、运行Scala脚本
  Scala文件的后缀是“scala”,因此,创建一个文件后,将其格式更改为“xxx.scala”就会被解读成Scala的代码文件。如果Scala代码文件是以一个可以计算出结果的表达式或者有副作用的函数作为结尾,那么就是一个脚本文件。有副作用的意思是这个函数不返回函数值,例如println这个函数,只是告诉解释器打印输出信息到屏幕,而不会产生可以使用的结果。

  新建一个名为“hello.scala”的文件,输入以下代码并保存:

// hello.scala
println("Hello, world!")
  那么这就完成了一个Scala脚本文件。在这个文件的路径下打开CMD、PowerShell或者终端(不需要进入解释器),并运行“scala hello.scala”,就会得到以下结果:

PS E:\Microsoft VS\Scala> scala hello.scala
Hello, world!

  读者可以尝试编写其他脚本文件,按这种方法运行。

四、编译非脚本文件
  非脚本文件一定是以定义结尾的,例如定义的class、object、package等等。新建一个Scala文件,输入以下代码并保存:

class Hello {
val hw = "Hello, world!"
def display() = println(hw)
}
   这样,就完成了一个非脚本文件的编写。同样,在这个文件的路径下打开CMD、PowerShell或者终端(不需要进入解释器),并运行“scalac 文件名.scala”,就会在当前目录下得到一个名为“Hello.class”的文件。注意“scala”和“scalac”命令的区别,前者用于运行脚本文件,后者则是启动Scala编译器来编译非脚本文件。此外,“fsc 文件名.scala”命令也可以用来编译文件,只不过“scalac”在完成后会自动关闭编译器,而“fsc”会保持编译器在后台运行,下次编译时就无需再次启动。

  接着,要使用刚才编译好的文件,则需要在“Hello.class”的路径(即当前路径)下使用"scala"命令进入解释器。在解释器里,就可以通过关键字“new”来创建Hello类的对象,并执行相应的操作。例如:

scala> val hello = new Hello
hello: Hello = Hello@5460edd3

scala> hello.display()
Hello, world!

scala> hello.hw
res1: String = Hello, world!

五、总结
  通过这一章的讲解,为Scala新手解释了如何安装、使用解释器以及文件使用方法。也许目前读者还只会编写文中的“hello world”,但是稍安勿躁,下一章开始就将正式进入Scala的语法讲解。至于要如何实现后面的代码运行,读者可以自由选择本章讲解的三种方法:直接在解释器里编写代码、使用脚本文件,或者把定义放在文件里,然后编译好放进解释器运行。

第三章 Scala基础——变量定义与基本类型

一、定义一个变量
  Scala在首次定义一个变量时,必须在变量名前面添加关键字“var”或者“val”。用“var”修饰的变量,可以重新赋予新的值,并且把原值抛弃,类似于Java的非final变量。在后续重新赋值时,就不用再写“var”了。而用“val”修饰的变量,则禁止被重新赋值,类似于Java的final变量,换句话说,就是只能读不能写的变量。

变量名可以是任意的字母、数字和下划线的组合,但是不能以数字开头。Scala推荐的命名方法是“驼峰命名法”,即每个单词的首字母大写,并且变量名和函数名以小写字母开头,类、对象和特质则以大写字母开头。例如,“val isOne”,“class MyClass”。在首次定义变量时,就必须赋予具体的值来初始化。不能出现如下形式:

val x

x = 1

  以下代码都是合法的变量定义:

scala> val x = 1
x: Int = 1

scala> var y = 2
y: Int = 2

scala> val msg = "Hello, world!"
msg: String = Hello, world! 

  var类型的变量在重新赋值时,新值必须和旧值是同一个类型,否则会发生类型匹配错误:

scala> var x = 1
x: Int = 1

scala> x = 10
x: Int = 10

scala> x = "abc"
<console>:12: error: type mismatch;
 found   : String("abc")
 required: Int
       x = "abc"
           ^

  val类型的变量则直接禁止重新赋值:

scala> val x = 1
x: Int = 1

scala> x = 10
<console>:12: error: reassignment to val
       x = 10
         ^

  如果要赋给多个变量相同的值,那么没必要逐条定义,而是在一条语句里用逗号间隔的变量名。例如:

scala> val a, b, c = 1
a: Int = 1
b: Int = 1
c: Int = 1

Scala的变量定义具有覆盖性,也就是说,如果出现了同名的变量,则后出现的变量会覆盖前面的变量。例如:

scala> val x = 1
x: Int = 1

scala> val x = 10
x: Int = 10

scala> x
res0: Int = 10

  要注意的是,赋给变量的对象存在可变与不可变之分。要理解到底是变量指向的对象本身发生了改变,还是变量指向了新的对象。即使是val类型的变量,也能被赋予一个可变对象。这个可变对象能够被重新修改,例如,给可变映射添加新的键值对。事实上,这只是旧对象发生了改变,并未产生新的对象。

Scala提倡定义val类型的变量,因为它是函数式编程,而函数式编程的思想之一就是传入函数的参数不应该被改变。所以,在Scala里,所有函数的参数都必须是val类型的。但是,Scala也允许指令式编程,因而预留了var类型的变量,尽管并不推荐使用。对于习惯了指令式编程的读者,例如,喜欢编写“for(i = 0; i < N; i++)”来实现一个循环,很显然更倾向于使用var类型的变量,因为在这个for循环里,变量i被多次重新赋值。Scala推荐读者学会使用val,学会函数式编程。笔者也是学习C/C++出身的,但是现在已经完全习惯了函数式编程。使用val类型的一个好处就是,你不用去计算某个变量在某个时刻是什么值,因为val类型的变量一旦被初始化,就一直不变,直到被重新定义。

二、Scala的基本类型
  Scala是静态语言,在编译期间会检查每个对象的类型。对于类型不匹配的非法操作,在编译时就能被发现。对于动态语言而言,这种非法操作需要等到运行时才能被发现,此时可能造成严重错误。所以,静态语言相比诸如Python这样的动态语言在某些方面是有优势的。对于Chisel而言,我们就需要这种优势。因为Chisel需要编译成Verilog,我们不能产生非法的Verilog语句并且等到模块运行时才去发现它。

  Scala标准库定义了一些基本类型,如下表所示。除了“String”类型是属于java.lang包之外,其余都在Scala的包里。   

  

  事实上,在定义变量时,应该指明变量的类型,只不过Scala的编译器具有自动推断类型的功能,可以根据赋给变量的对象的类型,来自动推断出变量的类型。如果要显式声明变量的类型,或者无法推断时,则只需在变量名后面加上一个冒号“ : ”,然后在等号与冒号之间写出类型名即可。例如:

scala> val x: Int = 123
x: Int = 123

scala> val y: String = "123"
y: String = 123

scala> val z: Double = 1.2
z: Double = 1.2

   Ⅰ、整数字面量
  整数有四种类型,默认情况下推断为Int类型。如果字面量的结尾有l或者L,则推断为Long类型。此外,Byte和Short则需要定义变量时显式声明。注意,赋给的字面值不能超过类型的表示范围。

  整数字面量默认是十进制的,但如果以“0x”或者“0X”开头,则字面量被认为是十六进制。十六进制的字母不区分大小写。例如:

scala> val a = 100
a: Int = 100

scala> val b = 0X123Abc
b: Int = 1194684

scala> val c: Byte = 200
<console>:11: error: type mismatch;
 found   : Int(200)
 required: Byte
       val c: Byte = 200
                     ^

scala> val d = 200L
d: Long = 200

  Ⅱ、浮点数字面量 
  浮点数的字面量都是十进制的,类型默认是Double类型。可以增加一个字母“e”或者“E”,再添加一个整数作为指数,这样就构成10的n次幂。最末尾可以写一个“f”或者“F”,表示Float类型;也可以写一个“d”或者“D”,表示Double类型。注意,Double类型的字面量不能赋给Float类型的变量。虽然Float允许扩展成Double类型,但是会发生精度损失。例如:

scala> val a = 1.2E3
a: Double = 1200.0

scala> val b = -3.2f
b: Float = -3.2

scala> val c: Float = -3.2
<console>:11: error: type mismatch;
 found   : Double(-3.2)
 required: Float
       val c: Float = -3.2
                      ^

scala> val d: Double = -3.2F
d: Double = -3.200000047683716

  Ⅲ、字符与字符串字面量
   字符字面量是以单引号' '包起来的一个字符,采用Unicode编码。也可以用'\u编码号'的方式来构造一个字符,而且Unicode编码可以出现在代码的任何地方,甚至是名称命名。此外,还支持转义字符。例如:

scala> val a = 'A'
a: Char = A

scala> val b = '\u0041'
b: Char = A

scala> val c = '\u0042'
c: Char = B

scala> val \u0041\u0042 = 1
AB: Int = 1

scala> val d = '\\'
d: Char = \

  字符串就是用双引号" "包起来的字符序列,长度任意,允许掺杂转义字符。此外,也可以用前后各三个双引号"""  """包起来,这样字符串里也能出现双引号,而且转义字符不会被解读。例如:

scala> val a = "\\\\\\"
a: String = \\\

scala> val b = """So long \u0041 String \\\'\"!"""
b: String = So long A String \\\'\"!

  Ⅳ、字符串插值
  Scala包括了一个灵活的机制来支持字符串插值,这使得表达式可以被嵌入在字符串字面量中并被求值。第一种形式是s插值器,即在字符串的双引号前加一个s,形如s“…${表达式}…”。s插值器会对内嵌的每个表达式求值,对求值结果调用内置的toString方法,替换掉字面量中的表达式。从美元符号开始到首个非标识符字符(字母、数字、下划线和操作符的组合称为标识符,以及反引号对` `包起来的字符串)的部分会被当作表达式,如果有非标识符字符,就必须放在花括号里,且左花括号要紧跟美元符号。第二种形式是raw插值器,它与s插值器类似,只不过不识别转义字符。第三种形式是f插值器,允许给内嵌的表达式加上printf风格的指令,指令放在表达式之后并以百分号开始。指令语法来自java.util.Formatter。如:

scala> val name = "ABC"
name: String = ABC

scala> println(s"$name DEFG")
ABC DEFG

scala> s"Sum = ${1 + 10}"
res0: String = Sum = 11

scala> s"\\\\"
res1: String = \\

scala> raw"\\\\"
res2: String = \\\\

scala> printf(f"${math.Pi}%.5f")
3.14159

三、总结
  本章介绍了Scala定义变量的方法及基本变量类型,重点在于学会使用val类型的变量。

第四章 Scala基础——函数及其几种形式

一、定义一个函数
  Scala的函数定义以“def”开头,然后是一个自定义的函数名(推荐驼峰命名法),接着是用圆括号“( )”包起来的参数列表。在参数列表里,多个参数用逗号隔开,并且每个参数名后面要紧跟一个冒号以及显式声明的参数类型,因为编译器在编译期间无法推断出入参类型。写完参数列表后,应该紧跟一个冒号,再添加函数返回结果的类型。最后,再写一个等号“=”,等号后面是用花括号“{ }”包起来的函数体。例如:

用“def”开始函数定义
| 函数名
| | 参数及参数类型
| | | 函数返回结果的类型
| | | | 等号
| | | | |
def max(x: Int, y: Int): Int = {
if(x > y)
x
else |
y |
} |
|
花括号里定义函数体
   Ⅰ、分号推断
  在Scala的代码里,语句末尾的分号是可选的,因为编译器会自动推断分号。如果一行只有一条完整的语句,那么分号可写可不写;如果一行有多条语句,则必须用分号隔开。有三种情况句末不会推断出分号:①句末是以非法结尾字符结尾,例如以句点符号“.”或中缀操作符结尾。②下一行的句首是以非法起始字符开始,例如以句点符号“.”开头。③跨行出现的圆括号对“( )”或者方括号对“[ ]”,因为它们里面不能进行分号的自动推断,要么只包含一条完整语句,要么包含用分号显式隔开的多条语句。另外,花括号对“{ }”的里面可以进行分号的自动推断。为了简洁起见,同时不产生无意的错误和歧义,建议一行只写一条完整的语句,句末分号省略,让编译器自动推断。而且内层的语句最好比外一层语句向内缩进两个空格,使得代码层次分明。

   Ⅱ、函数的返回结果
  在Scala里,“return”关键字也是可选的。默认情况下,编译器会自动为函数体里的最后一个表达式加上“return”,将其作为返回结果。建议不要显式声明“return”,这会引发warning,而且使得代码风格看上去像指令式风格。

  返回结果的类型也是可以根据参数类型和返回的表达式来自动推断的,也就是说,上例中的“: Int”通常是可以省略的。

  返回结果有一个特殊的类型——Unit,表示没有值返回。也就是说,这是一个有副作用的函数,并不能提供任何可引用的返回结果。Unit类型同样可以被推断出来,但如果显式声明为Unit类型的函数,则即使函数体最后有一个可以返回具体值的表达式,也不会把表达式的结果返回。例如:

scala> def add(x: Int, y: Int) = { x + y }
add: (x: Int, y: Int)Int

scala> add(1, 2)
res0: Int = 3

scala> def nothing(x: Int, y: Int): Unit = { x + y }
nothing: (x: Int, y: Int)Unit

scala> nothing(1, 2)

scala>

   Ⅲ、等号与函数体
  Scala的函数体是用花括号包起来的,这与C、C++、Java等语言类似。函数体里可以有多条语句,并自动推断分号、返回最后一个表达式。如果只有一条语句,那么花括号也可以省略。

  Scala的函数定义还有一个等号,这使得它看起来类似数学里的函数“f(x) = ...”。当函数的返回类型没有显式声明时,那么这个等号可以省略,但是返回类型一定会被推断成Unit类型,不管有没有值返回,而且函数体必须有花括号。当函数的返回类型显式声明时,则无论如何都不能省略等号。建议写代码时不要省略等号,避免产生不必要的错误,返回类型最好也显式声明。

   Ⅳ、无参函数
  如果一个函数没有参数,那么可以写一个空括号作参数列表,也可以不写。如果有空括号,那么调用时可以写也可以不写空括号;如果没有空括号,那么调用时就一定不能写空括号。原则上,无副作用的无参函数省略括号,有副作用的无参函数添加括号,这提醒使用者需要额外小心。

二、方法
  方法其实就是定义在class、object、trait里面的函数,这种函数叫做“成员函数”或者“方法”,与多数oop(object-oriented programming)语言一样。

三、嵌套函数
  函数体内部还可以定义函数,这种函数的作用域是局部的,只能被定义它的外层函数调用,外部无法访问。局部函数可以直接使用外层函数的参数,也可以直接使用外层函数的内部变量。例如:

scala> def addSub(x: Int, y: Int) = {
         |     def sub(z: Int) = z - 10
         |     if(x > y) sub(x - y) else sub(y - x)
         | }
addSub: (x: Int, y: Int)Int

scala> addSub(100, 20)
res0: Int = 70

 四、函数字面量
  函数式编程有两个主要思想,其中之一就是:函数是一等(first-class)的值。换句话说,一个函数的地位与一个Int值、一个String值等等,是一样的。既然一个Int值可以成为函数的参数、函数的返回值、定义在函数体里、存储在变量里,那么,作为地位相同的函数,也可以这样。你可以把一个函数当参数传递给另一个函数,也可以让一个函数返回一个函数,亦可以把函数赋给一个变量,又或者像定义一个值那样在函数里定义别的函数(即前述的嵌套函数)。就像写一个整数字面量“1”那样,Scala也可以定义函数的字面量。函数字面量是一种匿名函数的形式,它可以存储在变量里、成为函数参数或者当作函数返回值,其定义形式为:

(参数1: 参数1类型, 参数2: 参数2类型, ...) => { 函数体 }

  通常,函数字面量会赋给一个变量,这样就能通过“变量名(参数)”的形式来使用函数字面量。在参数类型可以被推断的情况下,可以省略类型,并且参数只有一个时,圆括号也可以省略。

  函数字面量的形式可以更精简,即只保留函数体,并用下划线“_”作为占位符来代替参数。在参数类型不明确时,需要在下划线后面显式声明其类型。多个占位符代表多个参数,即第一个占位符是第一个参数,第二个占位符是第二个参数……因此不能重复使用某个参数。例如:

scala> val f = (_: Int) + (_: Int)
f: (Int, Int) => Int = $$Lambda$1072/1534177037@fb42c1c

scala> f(1, 2)
res0: Int = 3

   无论是用“def”定义的函数,还是函数字面量,它们的函数体都可以把一个函数字面量作为一个返回结果,这样就成为了返回函数的函数;它们的参数变量的类型也可以是一个函数,这样调用时给的入参就可以是一个函数字面量。类型为函数的变量,其冒号后面的类型写法是“(参数1类型, 参数2类型,...) => 返回结果的类型”。例如:

scala> val add = (x: Int) => { (y: Int) => x + y }
add: Int => (Int => Int) = $$Lambda$1192/1767705308@55456711

scala> add(1)(10)
res0: Int = 11

scala> def aFunc(f: Int => Int) = f(1) + 1
aFunc: (f: Int => Int)Int

scala> aFunc(x => x + 1)
res1: Int = 3

  在第一个例子中,变量add被赋予了一个返回函数的函数字面量。在调用时,第一个括号里的“1”是传递给参数x,第二个括号里的“10”是传递给参数y。如果没有第二个括号,得到的就不是11,而是“(y: Int) => 1 + y”这个函数字面量。

  在第二个例子中,函数aFunc的参数f是一个函数,并且该函数要求是一个入参为Int类型、返回结果也是Int类型的函数。在调用时,给出了函数字面量“x => x + 1”。这里没有显式声明x的类型,因为可以通过f的类型来推断出x必须是一个Int类型。在执行时,首先求值f(1),结合参数“1”和函数字面量,可以算出结果是2。那么,“f(1) + 1”就等于3了。

五、部分应用函数
  上面介绍的函数字面量实现了函数作为一等值的功能,而用“def”定义的函数也具有同样的功能,只不过需要借助部分应用函数的形式来实现。例如,有一个函数定义为“def max(...) ...”,若想要把这个函数存储在某个变量里,不能直接写成“val x = max”的形式,而必须像函数调用那样,给出一部分参数,故而称作部分应用函数(如果参数全给了,就成了函数调用)。部分应用函数的作用,就是把def函数打包到一个函数值里,使它可以赋给变量,或当作函数参数进行传递。例如:

scala> def sum(x: Int, y: Int, z: Int) = x + y + z
sum: (x: Int, y: Int, z: Int)Int

scala> val a = sum(1, 2, 3)
a: Int = 6

scala> val b = sum(1, _: Int, 3)
b: Int => Int = $$Lambda$1204/1037479646@5b0bfe86

scala> b(2)
res0: Int = 6

scala> val c = sum _
c: (Int, Int, Int) => Int = $$Lambda$1208/1853277442@5e4c26a1

scala> c(1, 2, 3)
res1: Int = 6

  变量a其实是获得了函数sum调用的返回结果,变量b则是获得了部分应用函数打包的sum函数,因为只给出了参数x和z的值,参数y没有给出。注意,没给出的参数用下划线代替,而且必须显式声明参数类型。变量c也是部分应用函数,只不过一个参数都没有明确给出。像这样一个参数都不给的部分应用函数,只需要在函数名后面给一个下划线即可,注意函数名和下划线之间必须有空格。

  如果部分应用函数一个参数都没有给出,比如例子中的c,那么在需要该函数作入参的地方,下划线也可以省略。例如:

scala> def needSum(f: (Int, Int, Int) => Int) = f(1, 2, 3)
needSum: (f: (Int, Int, Int) => Int)Int

scala> needSum(sum _)
res2: Int = 6

scala> needSum(sum)
res3: Int = 6

六、闭包
  一个函数除了可以使用它的参数外,还能使用定义在函数以外的其他变量。其中,函数的参数称为绑定变量,因为完全可以根据函数的定义得知参数的信息;而函数以外的变量称为自由变量,因为函数自身无法给出这些变量的定义。这样的函数称为闭包,因为它要在运行期间捕获自由变量,让函数闭合,定义明确。自由变量必须在函数前面定义,否则编译器就找不到,会报错。

  闭包捕获的自由变量是闭包创建时活跃的那个自由变量,后续若新建同名的自由变量来覆盖前面的定义,由于闭包已经闭合完成,所以新自由变量与已创建的闭包无关。如果闭包捕获的自由变量本身是一个可变对象(例如var类型变量),那么闭包会随之改变。例如:

var more = 1

val addMore = (x: Int) => x + more  // addMore = x + 1

more = 2                                           // addMore = x + 2

var more = 10                                   // addMore = x + 2

more = -100                                      // addMore = x + 2

七、函数的特殊调用形式
   Ⅰ、具名参数
  普通函数调用形式是按参数的先后顺序逐个传递的,但如果调用时显式声明参数名并给其赋值,则可以无视参数顺序。按位置传递的参数和按名字传递的参数可以混用,例如:

scala> def max(x: Int, y: Int, z: Int) = {
         |     if(x > y && x > z) println("x is maximum")
         |     else if(y > x && y > z) println("y is maximum")
         |     else println("z is maximum")
         |  }
max: (x: Int, y: Int, z: Int)Unit

scala> max(1, z = 10, y = 100)
y is maximum 

   Ⅱ、默认参数值
  函数定义时,可以给参数一个默认值,如果调用函数时缺省了这个参数,那么就会使用定义时给的默认值。默认参数值通常和具名参数结合使用。例如:

scala> def max(x: Int = 10, y: Int, z: Int) = {
         |     if(x > y && x > z) println("x is maximum")
         |     else if(y > x && y > z) println("y is maximum")
         |     else println("z is maximum")
         |  }
max: (x: Int, y: Int, z: Int)Unit

scala> max(y = 3, z = 5)
x is maximum

   Ⅲ、重复参数
  Scala允许把函数的最后一个参数标记为重复参数,其形式为在最后一个参数的类型后面加上星号“*”。重复参数的意思是可以在运行时传入任意个相同类型的元素,包括零个。类型为“T*”的参数的实际类型是“Array[T]”,即若干个T类型对象构成的数组。尽管是T类型的数组,但要求传入参数的类型仍然是T。如果传入的实参是T类型对象构成的数组,则会报错,除非用“变量名: _*”的形式告诉编译器把数组元素一个一个地传入。例如: 

scala> def addMany(msg: String, num: Int*) = {
         |     var sum = 0
         |     for(x <- num) sum += x
         |     println(msg + sum)
         |  }
addMany: (msg: String, num: Int*)Unit

scala> addMany("sum = ", 1, 2, 3)
sum = 6

scala> addMany("sum = ")
sum = 0

scala> addMany("sum = ", Array(1, 2, 3))
<console>:13: error: type mismatch;
 found   : Array[Int]
 required: Int
       addMany("sum = ", Array(1, 2, 3))
                              ^

scala> addMany("sum = ", Array(1, 2, 3): _*)
sum = 6

八、柯里化
  对大多数编程语言来说,函数只能有一个参数列表,但是列表里可以有若干个用逗号间隔的参数。Scala有一个独特的语法——柯里化,也就是一个函数可以有任意个参数列表。柯里化往往与另一个语法结合使用:当参数列表里只有一个参数时,在调用该函数时允许单个参数不用圆括号包起来,改用花括号也是可行的。这样,在自定义类库时,自定义方法就好像“if(...) {...}”、“while(...) {...}”、“for(...) {...}”等内建控制结构一样,让人看上去以为是内建控制,丝毫看不出是自定义语法。例如:

scala> def add(x: Int, y: Int, z: Int) = x + y + z
add: (x: Int, y: Int, z: Int)Int

scala> add(1, 2, 3)
res0: Int = 6

scala> def addCurry(x: Int)(y: Int)(z: Int) = x + y + z
addCurry: (x: Int)(y: Int)(z: Int)Int

scala> addCurry(1)(2) {3}
res1: Int = 6

九、传名参数
  第四点介绍了函数字面量如何作为函数的参数进行传递,以及如何表示类型为函数时参数的类型。如果某个函数的入参类型是一个无参函数,那么通常的类型表示法是“() => 函数的返回类型”。在调用这个函数时,给出的参数就必须写成形如“() => 函数体”这样的函数字面量。

为了让代码看起来更舒服,也为了让自定义控制结构更像内建结构,Scala又提供了一个特殊语法——传名参数。也就是类型是一个无参函数的函数入参,传名参数的类型表示法是“=> 函数的返回类型”,即相对常规表示法去掉了前面的空括号。在调用该函数时,传递进去的函数字面量则可以只写“函数体”,去掉了“() =>”。例如:

var assertionEnabled = false

// predicate是类型为无参函数的函数入参
def myAssert(predicate: () => Boolean) =
if(assertionEnabled && !predicate())
throw new AssertionError
// 常规版本的调用
myAssert(() => 5 > 3)

// 传名参数的用法,注意因为去掉了空括号,所以调用predicate时不能有括号
def byNameAssert(predicate: => Boolean) =
if(assertionEnabled && !predicate)
throw new AssertionError
// 传名参数版本的调用,看上去更自然
byNameAssert(5 > 3)
   可以看到,传名参数使得代码更加简洁、自然,而常规写法则很别扭。事实上,predicate的类型可以改成Boolean,而不必是一个返回布尔值的函数,这样调用函数时与传名参数是一致的。例如:

// 使用布尔型参数的版本
def boolAssert(predicate: Boolean) =
if(assertionEnabled && !predicate)
throw new AssertionError
// 布尔型参数版本的调用
boolAssert(5 > 3)
   尽管byNameAssert和boolAssert在调用形式上是一样的,但是两者的运行机制却不完全一样。如果给函数的实参是一个表达式,比如“5 > 3”这样的表达式,那么boolAssert在运行之前会先对表达式求值,然后把求得的值传递给函数去运行。而myAssert和byNameAssert则不会一开始就对表达式求值,它们是直接运行函数,直到函数调用入参时才会对表达式求值,也就是例子中的代码运行到“!predicate”时才会求“5 > 3”的值。

  为了说明这一点,可以传入一个产生异常的表达式,例如除数为零的异常。例子中,逻辑与“&&”具有短路机制:如果&&的左侧是false,那么直接跳过右侧语句的运行(事实上,这种短路机制也是通过传名参数实现的)。所以,布尔型参数版本会抛出除零异常,常规版本和传名参数版本则不会发生任何事。例如:

scala> myAssert(() => 5 / 0 == 0)

scala> byNameAssert(5 / 0 == 0)

scala> boolAssert(5 / 0 == 0)
java.lang.ArithmeticException: / by zero
  ... 28 elided

   如果把变量assertionEnabled设置为true,让&&右侧的代码执行,那么三个函数都会抛出除零异常:

scala> assertionEnabled = true
assertionEnabled: Boolean = true

scala> myAssert(() => 5 / 0 == 0)
java.lang.ArithmeticException: / by zero
  at .$anonfun$res30$1(<console>:13)
  at .myAssert(<console>:13)
  ... 28 elided

scala> byNameAssert(5 / 0 == 0)
java.lang.ArithmeticException: / by zero
  at .$anonfun$res31$1(<console>:13)
  at .byNameAssert(<console>:13)
  ... 28 elided

scala> boolAssert(5 / 0 == 0)
java.lang.ArithmeticException: / by zero
  ... 28 elided

十、总结
  本章内容是对Scala的函数的讲解,重点在于理解函数作为一等值的概念,函数字面量的作用以及部分应用函数的作用。在阅读复杂的代码时,常常遇见诸如“def xxx(f: T => U, ...) ...”或 “def xxx(...): T => U”的代码,要理解前者表示需要传入一个函数作为参数,后者表示函数返回的对象是一个函数。在学习初期,理解函数是一等值的概念可能有些费力,通过大量阅读和编写代码才能熟能生巧。同时不要忘记前一章说过,函数的参数都是val类型的,在函数体内不能修改传入的参数。

第五章 Scala基础——类和对象

一、类
  前面两章介绍了Scala的变量和函数,那么就可以开始学习Scala里关于面向对象的内容。

  在Scala里,类是用关键字“class”开头的代码定义。它是对象的蓝图,一旦定义完成,就可以通过“new 类名”的方式来构造一个对象。而这个对象的类型,就是这个类。换句话说,一个类就是一个类型,不同的类就是不同的类型。在后续的章节中,会讲到类的继承关系,以及超类、子类和子类型多态的概念。

  在类里可以定义val或var类型的变量,它们被称为“字段”;还可以定义“def”函数,它们被称为“方法”;字段和方法统称“成员”。字段通常用于保存对象的状态或数据,而方法则用于承担对象的计算任务。字段也叫“实例变量”,因为每个被构造出来的对象都有其自己的字段。在运行时,操作系统会为每个对象分配一定的内存空间,用于保存对象的字段。方法则不同,对所有对象来说,方法都是一样的程序段,因此不需要为某个对象单独保存其方法。而且,方法的代码只有在被调用时才会被执行,如果一个对象在生命周期内都没有调用某些方法,那么完全没必要浪费内存为某个对象去保存这些无用的代码。

  外部想要访问对象的成员时,可以使用句点符号“ . ”,通过“对象.成员”的形式来访问。此外,用new构造出来的对象可以赋给变量,让变量名成为该对象的一个指代名称。需要注意的是,val类型的变量只能与初始化时的对象绑定,不能再被赋予新的对象。一旦对象与变量绑定了,便可以通过“变量名.成员”的方式来多次访问对象的成员。例如:

scala> class Students {
         |    var name = "None"
         |    def register(n: String) = name = n
         |  }
defined class Students

scala> val stu = new Students
stu: Students = Students@1a2e563e

scala> stu.name
res0: String = None

scala> stu.register("Bob")

scala> stu.name
res2: String = Bob

scala> stu = new Students
<console>:13: error: reassignment to val
       stu = new Students
           ^

  与Java和C++等语言不同的是,Scala的类成员默认都是公有的,即可以通过“对象.成员”的方式来访问对象的成员,而且没有“public”这个关键字。如果不想某个成员被外部访问,则可以在前面加上关键字“private”来修饰,这样该成员只能被类内部的其他成员访问,外部只能通过其他公有成员来间接访问。例如:

scala> class Students {
         |    private var name = "None"
         |    def register(n: String) = name = n
         |    def display() = println(name)
         |  }
defined class Students

scala> val stu = new Students
stu: Students = Students@75063bd0

scala> stu.register("Bob")

scala> stu.name
<console>:13: error: variable name in class Students cannot be accessed in Students
       stu.name
           ^

scala> stu.display
Bob

二、类的构造方法
   Ⅰ、主构造方法
  在C++、Java、Python等oop语言里,类通常需要定义一个额外的构造方法。这样,要构造一个类的对象,除了需要关键字new,还需要调用构造方法。事实上,这个过程中有一些代码是完全重复的。Scala则不需要显式定义构造方法 ,而是把类内部非字段、非方法的代码都当作“主构造方法”。而且,类名后面可以定义若干个参数列表,用于接收参数,这些参数将在构造对象时用于初始化字段并传递给主构造方法使用。Scala的这种独特语法减少了一些代码量。例如:

scala> class Students(n: String) {
         |    val name = n
         |    println("A student named " + n + " has been registered.")
         |  }
defined class Students

scala> val stu = new Students("Tom")
A student named Tom has been registered.
stu: Students = Students@5464eb28

  在这个例子中,Students类接收一个String参数n,并用n来初始化字段name。这样做,就无需像之前那样把name定义成var类型,而是使用函数式风格的val类型,而且不再需要一个register方法在构造对象时来更新name的数据。

  函数println既不是字段,也不是方法定义,所以被当成是主构造函数的一部分。在构造对象时,主构造函数被执行,因此在解释器里打印了相关信息。

   Ⅱ、辅助构造方法
  除了主构造方法,还可以定义若干个辅助构造方法。辅助构造方法都是以“def this(......)”来开头的,而且第一步行为必须是调用该类的另一个构造方法,即第一条语句必须是“this(......)”——要么是主构造方法,要么是之前的另一个辅助构造方法。这种规则的结果就是任何构造方法最终都会调用该类的主构造方法,使得主构造方法成为类的单一入口。例如:

scala> class Students(n: String) {
         |    val name = n
         |    def this() = this("None")
         |    println("A student named " + n + " has been registered.")
         |  }
defined class Students

scala> val stu = new Students
A student named None has been registered.
stu: Students = Students@74309cd5

  在这个例子中,定义了一个辅助构造方法,该方法是无参的,其行为也仅是给主构造方法传递一个字符串“None”。在后面创建对象时,缺省了参数,这样与主构造方法的参数列表是不匹配的,但是与辅助构造方法匹配,所以stu指向的对象是用辅助构造方法构造的。

  在Java里,辅助构造方法可以调用超类的构造方法,而Scala加强了限制,只允许主构造方法调用超类的构造方法(详情见后续章节)。这种限制源于Scala为了代码简洁性与简单性做出的折衷处理。

   Ⅲ、析构函数
  因为Scala没有指针,同时使用了Java的垃圾回收器,所以不需要像C++那样定义析构函数。

   Ⅳ、私有主构造方法
  如果在类名与类的参数列表之间加上关键字“private”,那么主构造方法就是私有的,只能被内部定义访问,外部代码构造对象时就不能通过主构造方法进行,而必须使用其他公有的辅助构造方法或工厂方法(专门用于构造对象的方法)。例如:

scala> class Students private (n: String, m: Int) {
         |    val name = n
         |    val score = m
         |    def this(n: String) = this(n, 100)
         |    println(n + "'s score is " + m)
         |  }
defined class Students

scala> val stu = new Students("Bill", 90)
<console>:12: error: too many arguments (2) for constructor Students: (n: String)Students
       val stu = new Students("Bill", 90)
                                      ^

scala> val stu = new Students("Bill")
Bill's score is 100
stu: Students = Students@7509b8e7

三、重写toString方法
  细心的读者会发现,在前面构造一个Students类的对象时,Scala解释器打印了一串晦涩的信息“Students@7509b8e7”。这其实来自于Students类的toString方法,这个方法返回一个字符串,并在构造完一个对象时被自动调用,返回结果交给解释器打印。该方法是所有Scala类隐式继承来的,如果不重写这个方法,就会用默认继承的版本。默认的toString方法来自于java.lang.Object类,其行为只是简单地打印类名、一个“@”符号和一个十六进制数。如果想让解释器输出更多有用的信息,则可以自定义toString方法。不过,这个方法是继承来的,要重写它必须在前面加上关键字“override”(后续章节会讲到override的作用)。例如:

scala> class Students(n: String) {
         |    val name = n
         |    override def toString = "A student named " + n + "."
         |  }
defined class Students

scala> val stu = new Students("Nancy")
stu: Students = A student named Nancy.

四、方法重载
  熟悉oop语言的读者一定对方法重载的概念不陌生。如果在类里定义了多个同名的方法,但是每个方法的参数(主要是参数类型)不一样,那么就称这个方法有多个不同的版本。这就叫方法重载,它是面向对象里多态属性的一种表现。这些方法虽然同名,但是它们是不同的,因为函数真正的特征标是它的参数,而不是函数名或返回类型。注意重载与前面的重写的区别,重载是一个类里有多个不同版本的同名方法,重写是子类覆盖定义了超类的某个方法。

五、类参数
  从前面的例子可以发现,很多时候类的参数仅仅是直接赋给某些字段。Scala为了进一步简化代码,允许在类参数前加上val或var来修饰,这样就会在类的内部会生成一个与参数同名的公有字段。构造对象时,这些参数会直接复制给同名字段。除此之外,还可以加上关键字private、protected或override来表明字段的权限(关于权限修饰见后续章节)。如果参数没有任何关键字,那它就仅仅是“参数”,不是类的成员,只能用来初始化字段或给方法使用。外部不能访问这样的参数,内部也不能修改它。例如:

scala> class Students(val name: String, var score: Int) {
         |    def exam(s: Int) = score = s
         |    override def toString = name + "'s score is " + score + "."
         |  }
defined class Students

scala> val stu = new Students("Tim", 90)
stu: Students = Tim's score is 90.

scala> stu.exam(100)

scala> stu.score
res0: Int = 100

六、单例对象与伴生对象
  在Scala里,除了用new可以构造一个对象,也可以用“object”开头定义一个对象。它类似于类的定义,只不过不能像类那样有参数,也没有构造方法。因此,不能用new来实例化一个object的定义,因为它已经是一个对象了。这种对象和用new实例化出来的对象没有什么区别,只不过new实例的对象是以类为蓝本构建的,并且数量没限制,而object定义的对象只能有这一个,故而得名“单例对象”。

  如果某个单例对象和某个类同名,那么单例对象称为这个类的“伴生对象”,同样,类称为这个单例对象的“伴生类”。伴生类和伴生对象必须在同一个文件里,而且两者可以互访对方所有成员。在C++、Java等oop语言里,类内部可以定义静态变量。这些静态变量不属于任何一个用new实例化的对象,而是它们的公有部分。Scala追求纯粹的面向对象属性,即所有的事物都是类或对象,但是静态变量这种不属于类也不属于对象的事物显然违背了Scala的理念。所以,Scala的做法是把类内所有的静态变量从类里移除,转而集中定义在伴生对象里,让静态变量属于伴生对象这个独一无二的对象。

  既然单例对象和new实例的对象一样,那么类内可以定义的代码,单例对象同样可以拥有。例如,单例对象里面可以定义字段和方法。Scala允许在类里定义别的类和单例对象,所以单例对象也可以包含别的类和单例对象的定义。因此,单例对象除了用作伴生对象,通常也可以用于打包某方面功能的函数系列成为一个工具集,或者包含主函数成为程序的入口。

  “object”后面定义的单例对象名可以认为是这个单例对象的名称标签,因此可以通过句点符号访问单例对象的成员——“单例对象名.成员”,也可以赋给一个变量——“val 变量 = 单例对象名”,就像用new实例的对象那样。例如:

scala> class A { val a = 10 }
defined class A

scala> val x = new A
x: A = A@7e5831c4

scala> x.a
res0: Int = 10

scala> (new A).a
res1: Int = 10

scala> object B { val b = "a singleton object" }
defined object B

scala> B.b
res2: String = a singleton object

scala> val y = B
y: B.type = B$@4489b853

scala> y.b
res3: String = a singleton object

  前面说过,定义一个类,就是定义了一种类型。从抽象层面讲,定义单例对象却并没有定义一种类型。实际上每个单例对象有自己独特的类型,即object.type。可以认为新类型出现了,只不过这个类型并不能用来归类某个对象集合,等同于没有定义新类型。即使是伴生对象也没有定义类型,而是由伴生类定义了同名的类型。后续章节将讲到,单例对象可以继承自超类或混入特质,这样它就能出现在需要超类对象的地方。例如下面的例子中,可以明确看到X.type和Y.type两种新类型出现,并且是不一样的:

scala> object X
defined object X

scala> object Y
defined object Y

scala> var x = X
x: X.type = X$@630bb67

scala> x = Y
<console>:17: error: type mismatch;
 found   : Y.type
 required: X.type
       x = Y
           ^

七、工厂对象与工厂方法
  如果定义一个方法专门用来构造某一个类的对象,那么这种方法就称为“工厂方法”。包含这些工厂方法集合的单例对象,也就叫“工厂对象” 。通常,工厂方法会定义在伴生对象里。尤其是当一系列类存在继承关系时,可以在基类的伴生对象里定义一系列对应的工厂方法。使用工厂方法的好处是可以不用直接使用new来实例化对象,改用方法调用,而且方法名可以是任意的,这样对外隐藏了类的实现细节。例如:

// students.scala
class Students(val name: String, var score: Int) {
def exam(s: Int) = score = s
override def toString = name + "'s score is " + score + "."
}

object Students {
def registerStu(name: String, score: Int) = new Students(name, score)
}
  将文件students.scala编译后,并在解释器里用“import Students._”导入单例对象后,就能这样使用:

scala> import Students._
import Students._

scala> val stu = registerStu("Tim", 100)
stu: Students = Tim's score is 100.

八、apply方法
  有一个特殊的方法名——apply,如果定义了这个方法,那么既可以显式调用——“对象.apply(参数)” ,也可以隐式调用——“对象(参数)”。隐式调用时,编译器会自动插入缺失的“.apply”。如果apply是无参方法,应该写出空括号,否则无法隐式调用。无论是类还是单例对象,都能定义这样的apply方法。

  通常,在伴生对象里定义名为apply的工厂方法,就能通过“伴生对象名(参数)”来构造一个对象。也常常在类里定义一个与类相关的、具有特定行为的apply方法,让使用者可以隐式调用,进而隐藏相应的实现细节。例如:

// students2.scala
class Students2(val name: String, var score: Int) {
def apply(s: Int) = score = s
def display() = println("Current score is " + score + ".")
override def toString = name + "'s score is " + score + "."
}

object Students2 {
def apply(name: String, score: Int) = new Students2(name, score)
}
  将文件students2.scala编译后,就能在解释器里这样使用:

scala> val stu2 = Students2("Jack", 60)
stu2: Students2 = Jack's score is 60.

scala> stu2(80)

scala> stu2.display
Current score is 80.

  其中,“Students2("Jack", 60)”被翻译成“Students2.apply("Jack", 60)” ,也就是调用了伴生对象里的工厂方法,所以构造了一个Students2的对象并赋给变量stu2。“stu2(80)”被翻译成“stu2.apply(80)” ,也就是更新了字段score的数据。

九、主函数
  主函数是Scala程序唯一的入口,即程序是从主函数开始运行的。要提供这样的入口,则必须在某个单例对象里定义一个名为“main”的函数,而且该函数只有一个参数,类型为字符串数组Array[String],函数的返回类型是Unit。任何符合条件的单例对象都能成为程序的入口。例如:

// students2.scala
class Students2(val name: String, var score: Int) {
def apply(s: Int) = score = s
def display() = println("Current score is " + score + ".")
override def toString = name + "'s score is " + score + "."
}

object Students2 {
def apply(name: String, score: Int) = new Students2(name, score)
}

// main.scala
object Start {
def main(args: Array[String]) = {
try {
val score = args(1).toInt
val s = Students2(args(0), score)
println(s.toString)
} catch {
case ex: ArrayIndexOutOfBoundsException => println("Arguments are deficient!")
case ex: NumberFormatException => println("Second argument must be a Int!")
}
}
}
  使用命令“scalac students2.scala main.scala”将两个文件编译后,就能用命令“scala Start 参数1 参数2”来运行程序。命令里的“Start”就是包含主函数的单例对象的名字,后面可以输入若干个用空格间隔的参数。这些参数被打包成字符串数组供主函数使用,也就是代码里的args(0)、args(1)。例如:

PS E:\Microsoft VS\Scala> scala Start Tom
Arguments are deficient!
PS E:\Microsoft VS\Scala> scala Start Tom aaa
Second argument must be a Int!
PS E:\Microsoft VS\Scala> scala Start Tom 100
Tom's score is 100.

  主函数的一种简化写法是让单例对象混入“App”特质(特质在后续章节讲解),这样就只要在单例对象里编写主函数的函数体。例如:

// main2.scala
object Start2 extends App {
try {
var sum = 0
for(arg <- args) {
sum += arg.toInt
}
println("sum = " + sum)
} catch {
case ex: NumberFormatException => println("Arguments must be Int!")
}
}
   将文件编译后,就可以如下使用:

PS E:\Microsoft VS\Scala> scala Start2 10 -8 20 AAA
Arguments must be Int!
PS E:\Microsoft VS\Scala> scala Start2 10 -8 20 8
sum = 30

十、总结
  本章讲解了Scala的类和对象,从中可以初窥Scala在语法精简和便捷上的努力。难点是理解单例对象的概念、类与类型的关系和工厂方法的作用。如果读者有其他oop语言基础,在这里也并不是能一下就接受Scala的语法。最后一个重点就是学会灵活使用apply方法。 

第六章 Scala基础——操作符即方法

一、操作符在Scala里的解释
在诸如C++、Java等oop语言里,定义了像byte、short、int、char、float之类的基本类型,但是这些基本类型不属于面向对象的范畴。就好比C语言也有这些类型,但是C语言根本没有面向对象的概念。 比如只能说“1”是一个int类型的常量,却不能说它是一个int类型的对象。与之对应的,这些语言还定义了与基本类型相关的操作符。例如,有算术操作符加法“+”,它可以连接左、右两个操作数,然后算出相应的总和。

前面提到,Scala追求纯粹的面向对象,像这种不属于面向对象范畴的基本类型及其操作符都是有违宗旨的。那么,Scala如何实现这些基本类型呢?实际在Scala标准库里定义了“class Byte”、“class  Short”、“class  Char”、“class Int”、“class  Long”、“class  Float”、“class  Double”、“class  Boolean”和“class Unit”九种值类,只不过这些类是抽象的、不可继承的,因此不能通过“new Int”这种语句来构造一个Int对象,也不能编写它们的子类,它们的对象都是由字面量来表示。例如,整数字面量“1”就是一个Int的对象。在运行时,前八种值类会被转换成对应的Java基本类型。第九个Unit类对应Java的“void”类型,即表示空值,这样就能理解返回值类型为Unit的、有副作用的函数其实是空函数。Unit类的对象由一个空括号作为字面量来表示。

简而言之,Scala做到了真正的“万物皆对象”。

还有,与基本类型相关的操作符该如何处理呢?严格来讲,Scala并不存在操作符的概念,这些所谓的操作符,例如算术运算的加减乘除,逻辑运算的与或非,比较运算的大于小于等等,其实都是定义在“class Int”、“class Double”等类里的成员方法。也就是说,在Scala里,操作符即方法。例如,Int类定义了一个名为“+”的方法,那么表达式“1 + 2”的真正形式应该是“1.+(2)”。它的释义是:Int对象“1”调用了它的成员方法“+”,并把Int对象“2”当作参数传递给了该方法,最后这个方法会返回一个新的Int对象“3”。

推而广之,“操作符即方法”的概念不仅仅限于九种值类的操作符,Scala里任何类定义的成员方法都是操作符,而且方法调用都能写成操作符的形式:去掉句点符号,并且方法参数只有一个时可以省略圆括号。例如:

scala> class Students3(val name: String, var score: Int) {
         |    def exam(s: Int) = score = s
         |    def friends(n: String, s: Int) = println("My friend " + n + " gets " + s + ".")
         |    override def toString = name + "'s score is " + score + "."
         |  }
defined class Students3

scala> val stu3 = new Students3("Alice", 80)
stu3: Students3 = Alice's score is 80.

scala> stu3 exam 100

scala> stu3.score
res0: Int = 100

scala> stu3 friends ("Bob", 70)
My friend Bob gets 70.

 二、三种操作符
   Ⅰ、前缀操作符
写在操作数前面的操作符称为前缀操作符,并且操作数只有一个。前缀操作符对应一个无参方法,操作数是调用该方法的对象。前缀操作符只有“+”、“-”、“!”和“~”四个,相对应的方法名分别是“unary_+”、“unary_-”、“unary_!”和“unary_~”。如果自定义的方法名是 “unary_”加上这四个操作符之外的操作符,那么就不能写成前缀操作符的形式。假设定义了方法“unary_*”,那么写成“*p”的形式让人误以为这是一个指针,实际Scala并不存在指针,因此只能写成“p.unary_*”或后缀操作符“p unary_*”的形式。例如:

scala> class MyInt(val x: Int) {
         |    def unary_! = -x
         |    def unary_* = x * 2
         |  }
defined class MyInt

scala> val mi = new MyInt(10)
mi: MyInt = MyInt@2aac87ab

scala> !mi
res0: Int = -10

scala> *mi
<console>:12: error: not found: value *
       *mi
       ^
<console>:12: warning: postfix operator mi should be enabled
by making the implicit value scala.language.postfixOps visible.
This can be achieved by adding the import clause 'import scala.language.postfixOps'
or by setting the compiler option -language:postfixOps.
See the Scaladoc for value scala.language.postfixOps for a discussion
why the feature should be explicitly enabled.
       *mi
        ^

scala> mi.unary_*
res2: Int = 20

   Ⅱ、中缀操作符
中缀操作符的左右两边都接收操作数,它对应普通的有参方法。两个操作数中的一个是调用该方法的对象,一个是传入该方法的参数,参数那一边没有数量限制,只是多个参数需要放在圆括号里。Scala规定,以冒号“ : ”结尾的操作符,其右操作数是调用该方法的对象,其余操作符都是把左操作数当调用该方法的对象。 例如:

scala> class MyInt2(val x: Int) {
         |    def +*(y: Int) = (x + y) * y
         |    def +:(y: Int) = x + y
         |  }
defined class MyInt2

scala> val mi2 = new MyInt2(10)
mi2: MyInt2 = MyInt2@216c6825

scala> mi2 +* 10
res7: Int = 200

scala> mi2 +: 10
<console>:13: error: value +: is not a member of Int
       mi2 +: 10
           ^

scala> 10 +: mi2
res9: Int = 20

对于系统打印函数“print”、“printf”和“println”,其实也是中缀操作符,不过左侧的操作数是调用对象——控制台Console,右侧是要打印的内容。例如:

scala> Console println "Hello, world!"
Hello, world! 

   Ⅲ、后缀操作符
 写在操作数后面的操作符称为后缀操作符,并且操作数只有一个,即调用该方法的对象。后缀操作符也对应一个无参方法,但是要注意方法名如果构成前缀操作符的条件,那么既可以写成前缀操作符,也可以把完整的方法名写成后缀操作符。例如:

scala> class MyInt3(val x: Int) {
         |    def display() = println("The value is " + x + ".")
         |  }
defined class MyInt3

scala> val mi3 = new MyInt3(10)
mi3: MyInt3 = MyInt3@2670435

scala> import scala.language.postfixOps
import scala.language.postfixOps

scala> mi3 display
The value is 10.

三、操作符的优先级和结合性
   Ⅰ、优先级
在数学运算中,乘、除法的优先级要高于加、减法,这是算术操作符的优先级。Scala也保留了这种特性,并有一套判断操作符优先级的规则:通过操作符的首个字符来判断。因为操作符都是方法,所以也就是通过方法名的首个字符来比较优先级,注意前缀操作符的方法名要去掉关键字。当然,圆括号内的优先级是最高的,圆括号可以改变操作符的结合顺序。

 

 上图给出了各种字符的优先级顺序。例如,常规算术运算法则在计算表达式“1 + 2 * 3”时,会先算乘法,后算加法。类似地,如果有一个表达式“1 +++ 2 *** 3”,那么结合顺序就是“1 +++ (2 *** 3)”。

这个规则有一个例外:如果操作符以等号结尾,并且不是“>=”、“<=”、“==”或“!=”四个比较操作符之一,那么就认为是赋值操作符,优先级最低。例如,表达式“sum *= 1 + 2”会先算“1 + 2”,再把得出的3和sum相乘并赋给sum。也就是说,“*=”的优先级并不会因为以乘号开头就比加号高,而是被当作了一种赋值操作。

   Ⅱ、结合性
一般情况下,同级的操作符都是从左往右结合的。但是,前面说了,以冒号结尾的中缀操作符的调用对象在右侧,所以这些操作符是从右往左结合的。例如,“a + b + c + d”的结合顺序是“((a + b) + c) + d”,而“a ::: b ::: c ::: d”的结合顺序则是“a ::: (b ::: (c ::: d))”。

一个好的编程习惯是让代码简洁易懂,不造成歧义。所以,在操作符的结合顺序不能一眼就看明白时,最好加上圆括号来表示前后顺序,即使不加圆括号也能得到预期的结果。例如,想要得到“x + y << z”的默认结果,最好写成“(x + y) << z”,以便阅读。

四、预设操作符
Scala预设了常用的算术、逻辑运算的操作符,总结如下:

Scala的操作符
+ 算术加法
- 算术减法
* 算术乘法
/ 算术除法
% 算术取余
> 大于
< 小于
>= 大于等于
<= 小于等于
== 等于
!= 不等于
&&、& 逻辑与,前者短路,后者不短路
||、| 逻辑或,前者短路,后者不短路
! 逻辑非
& 位与
| 位或
^ 位异或
~ 位取反
>> 算术右移
<< 左移
>>> 逻辑右移
五、对象的相等性
在编程时,常常需要比较两个对象的相等性。其实相等性有两种:①自然相等性,也就是常见的相等性。只要字面上的值相等,就认为两个对象相等。②引用相等性。构造的对象常常会赋给一个变量,即让变量引用该对象。引用相等性用于比较两个变量是否引用了同一个对象,即是否指向JVM的堆里的同一个内存空间。如果两个变量引用了两个完全一样的对象,那么它们的自然相等性为true,但是引用相等性为false。

在Java里,这两种相等性都是由操作符“==”和“!=”比较的。Scala为了区分得更细致,也为了符合常规思维,只让“==”和“!=”比较自然相等性。这两个方法是所有类隐式继承来的,但是它们不能被子类重写。自定义类可能需要不同行为的相等性比较,因此可以重写隐式继承来的“equals”方法。实际上,“==”就是调用了equals方法,而“!=”就是对equals的结果取反。为了比较引用相等性,Scala提供了“eq”和“ne”方法,它们也是被所有类隐式继承的,且不可被子类重写。例如:

scala> val a = List(1, 0, -1)
a: List[Int] = List(1, 0, -1)

scala> val b = List(1, 0, -1)
b: List[Int] = List(1, 0, -1)

scala> val c = List(1, 0, 1)
c: List[Int] = List(1, 0, 1)

scala> val d = a
d: List[Int] = List(1, 0, -1)

scala> a == c
res0: Boolean = false

scala> a == b
res1: Boolean = true

scala> a equals b
res2: Boolean = true

scala> a eq b
res3: Boolean = false

scala> a eq d
res4: Boolean = true

六、总结
本章又进一步阐释了Scala追求的纯粹的面向对象,介绍了“操作符即方法”这个重要概念。这一概念对构建良好的DSL语言很重要,因为它使得不仅内建类型可以写成表达式,也让自定义的类在计算时可以写出自然的表达式风格。

关于对象相等性,这是一个较为复杂的概念。在自定义类里,如果要比较对象相等性,则不仅是简单地重写equals方法,还需要其他手段。这里不再赘述,如有必要,后续会继续讨论。

第七章 Scala基础——类继承

一、Scala的类继承
  在面向对象编程里,为了节省代码量,也为了反映实际各种类之间的联系,通常采取两种策略:包含和继承。包含代表了一种has-a的关系,也就是一个类包括了另一个类的实例。例如,午餐的菜单含有水果,那么就可以先编写一个水果类,然后再编写一个午餐类,并在午餐类里包含水果类的对象,但这两者没有必然联系。继承代表了一种is-a的关系,也就是从一个宽泛的类可以派生出更加具体的类。例如,编写的水果类包含了一些常见水果的公有属性,然后要编写一个更具体的苹果类。考虑到现实世界中,苹果就是(is-a,更准确来说应该是is-a-kind-of)一种特殊的水果,那么苹果类完全可以把水果类里定义的属性都继承过来,而且这两者有必然联系。

  本章介绍的内容就是关于类继承的Scala语法,以及一些特性。

  通过在类的参数列表后面加上关键字“extends”和被继承类的类名,就完成了一个继承的过程。被继承的类称为“超类”或者“父类”,而派生出来的类称为“子类”。如果继承层次较深,最顶层的类通常也叫“基类”。继承关系只有“超类”和“子类”的概念,即超类的超类也叫超类,子类的子类还叫子类。例如:

scala> class A {
         |    val a = "Class A"
         |  }
defined class A

scala> class B extends A {
         |    val b = "Class B inherits from A"
         |  }
defined class B

scala> val x = new B
x: B = B@5922cff3

scala> x.a
res0: String = Class A

scala> x.b
res1: String = Class B inherits from A

  以上代码纯粹是为了演示Scala的语法,没有什么实际意义。

 二、调用超类的构造方法
  大多数类都有参数列表,用于接收参数,传递给构造方法并初始化字段。像前面的例子比较特殊,类A没有参数。假如类A有参数,那么类B该怎么处理呢?在构造某个类的对象时,如果这个类继承自另外一个类,那么应该先构造超类对象的组件,再来构造子类的其他组件。也就是说,类B需要调用类A的构造方法。子类调用超类的构造方法的语法是:

  class 子类(子类对外接收的参数) extends 超类(子类给超类的参数)

  像上个例子中,其实是类A的构造方法没有参数,所以“extends A”也就不需要参数。第五章说过,Scala只允许主构造方法调用超类的构造方法,而这种写法就是子类的主构造方法在调用超类的构造方法。例如:

scala> class A(val a: Int)
defined class A

scala> class B(giveA: Int, val b: Int) extends A(giveA)
defined class B

scala> val x = new B(10, 20)
x: B = B@5f81507a

scala> x.a
res0: Int = 10

scala> x.b
res1: Int = 20

三、重写超类的成员
   Ⅰ、不被继承的成员
  通常,超类的成员都会被子类继承,除了两种成员:一是超类中用“private”修饰的私有成员,二是被子类重写的成员。私有成员无需过多解释。重写的意思是,超类中的某个属性,在子类中可能并不一定符合,而是需要一个新的符合子类行为的版本。例如,几乎所有的金属在室温下都是固态,唯独汞是液态,所以金属类的室温状态可以定义为固态,而子类汞则应该把这个属性重写为液态。 重写超类的成员时,应该在定义的开头加上关键字“override”。例如:

scala> class Metal {
         |    val state = "solid"
         |  }
defined class Metal

scala> class Mercury extends Metal {
         |    override val state = "liquid"
         |  }
defined class Mercury

scala> val mer = new Mercury
mer: Mercury = Mercury@64f34c91

scala> mer.state
res0: String = liquid

  重写时,关键字“override”是必须具备的,这是为了防止意外的重写。比如,由于拼写错误使得字段名相同,但是因为少了override而使得编译器报错;或者,写了override来重写某个方法,但是参数列表意外写错,也会使得编译器报错。更重要的是,改善了“脆弱基类”的问题。比方说,因为版本更新而给类库增加新的类或成员时,会增加破坏客户代码的风险。因为客户代码可能已有同名的定义了,但是因为双方缺乏信息交流而出错。这时,因为override的缺失,编译器会找到相关错误,尽管不能彻底解决这个问题。

   Ⅱ、不可重写的成员
  如果超类成员在开头用关键字“final”修饰,那么子类就只能继承,而不能重写。

  “final”也可以用于修饰class,那么这个类就禁止被其他类继承。

   Ⅲ、无参方法与字段
  Scala的无参方法在调用时,可以省略空括号。鉴于此,对用户代码而言,如果看不到类库的具体实现,那么调用无参方法和调用同名的字段则没有什么不同,甚至无法区分其具体实现到底是方法还是字段。如果把类库里的无参方法改成字段,或是把字段改成无参方法,那么客户代码不用更改也能运行。为了方便在这两种定义之间进行切换,Scala允许超类的无参方法被子类重写为字段,但是字段不能反过来被重写为无参方法,而且方法的返回类型必须和字段的类型一致。例如:

scala> class A {
         |    def justA() = "A"
         |  }
defined class A

scala> class B extends A {
         |    override val justA = "B"
         |  }
defined class B

scala> class C extends A {
         |    override val justA = 1
         |  }
<console>:13: error: overriding method justA in class A of type ()String;
 value justA has incompatible type
         override val justA = 1
                      ^

scala> class D {
         |    val d = 10
         |  }
defined class D

scala> class E extends D {
         |    override def d() = 100
         |  }
<console>:13: error: overriding value d in class D of type Int;
 method d needs to be a stable, immutable value
         override def d() = 100
                      ^

   字段与方法的区别在于:字段一旦被初始化之后,就会被保存在内存中,以后每次调用都只需直接读取内存即可;方法不会占用内存空间,但是每次调用都需要执行一遍程序段,速度比字段要慢。因此,到底定义成无参方法还是字段,就是在速度和内存之间折衷。

  字段能重写无参方法的原理是Scala只有两种命名空间:①值——字段、方法、包、单例对象;②类型——类、特质。因为字段和方法同处一个命名空间,所以字段可以重写无参方法。这也告诉我们,同处一个命名空间的定义类型,在同一个作用域内不能以相同的名字同时出现。例如,同一个类里不能同时出现同名的字段、无参方法和单例对象:

scala> class A {
         |    val a = 10
         |    object a
         |  }
<console>:13: error: a is already defined as value a
         object a
                ^

四、子类型多态与动态绑定
  类型为超类的变量可以指向子类的对象,这一现象被称为子类型多态,也是面向对象的多态之一。但是对于方法而言,尽管变量的类型是超类,方法的版本却是“动态绑定”的。也就是说,调用的方法要运行哪个版本,是由变量指向的对象来决定的。例如:

scala> class A {
         |    def display() = "I'm A."
         |  }
defined class A

scala> class B extends A {
         |    override def display() = "I'm B."
         |  }
defined class B

scala> val x: A = new B
x: A = B@6c5abd8f

scala> x.display
res0: String = I'm B.

五、抽象类
  如果类里包含了没有具体定义的成员——没有初始化的字段或没有函数体的方法,那么这个类就是抽象类,必须用关键字“abstract”修饰。相应的成员称为抽象成员,不需要“abstract”的修饰。因为存在抽象成员,所以这个类不可能构造出具体的对象,因为有无法初始化抽象字段或者无法执行抽象方法,所以抽象类不能通过“new”来构造实例对象。

  抽象类缺失的抽象成员的定义,可以由抽象类的子类来补充。也就是说,抽象类“声明”了抽象成员,却没有立即“定义”它。如果子类补齐了抽象成员的相关定义,就称子类“实现”了超类的抽象成员。相对的,我们称超类的成员是“抽象”的,而子类的成员是“具体”的。子类实现超类的抽象成员时,关键字“override”可写可不写。例如:

scala> abstract class A {
         |    val a: Int
         |  }
defined class A

scala> val x = new A
<console>:12: error: class A is abstract; cannot be instantiated
       val x = new A
               ^

scala> class B(val b: Int) extends A {
         |    val a = b * 2
         |  }
defined class B

scala> val y = new B(1)
y: B = B@7fe87c0e

scala> y.a
res0: Int = 2

scala> y.b
res1: Int = 1

  抽象类常用于定义基类,因为基类会派生出很多不同的子类,这些子类往往具有行为不同的同名成员,所以基类只需要声明有哪些公共成员,让子类去实现它们各自期望的版本。 

六、关于多重继承
   Scala没有多重继承,也就是说,在“extends”后面只能有一个类,这与大多数oop语言不同。多重继承其实是一个很让人头疼的问题,使用起来很复杂,也很容易出错。在笔者学习C++的时候,看到了C++为了使用多重继承而不得不做出的大量语法规则修改,和单个继承混在一起时常把人搞晕。Scala舍弃多重继承的做法,对于程序员而言是莫大的帮助,不用在编写代码时考虑冗长的代码设计。尤其是对超类方法的调用,当存在多个超类时,为了避免歧义而不得不仔细设计方法的行为。

  虽然多重继承不好用,但是它实现的功能在某些时候又不可或缺。为此,Scala专门设计了“特质”来实现相同的功能,并且特质的规则更简单、更明了。特质将在后一章介绍。

七、Scala类的层次结构
  Scala所有的类——不管是标准库里已有的类还是自定义的类,都不是毫无关联的,而是存在层次关系。这种关系如下图所示,其中实线箭头表示属于指向的类的子类,虚线箭头表示可以隐式转换成指向的类:    

  

  最顶部的类是抽象类Any,它是所有类的超类。Any类定义了几个成员方法,如下表所示:

  

  也就是说,任何类都有这几个方法。注意,不能出现同名的方法,若确实需要自定义版本,则记得带上“override”。

  再往下一层,Any类有两个子类:AnyVal和AnyRef。也就是说,所有类被分成两大部分:值类和引用类。值类也就是前面讲过的对应Java的九种基本类型,并且其中七个存在一定的隐式转换,例如Byte可以扩展成Short等等。隐式转换是Scala的一个语法,用于对象在两个类之间进行类型转换,后面章节会讲到。除了标准库里已有的隐式转换,也可以自定义隐式转换。

  除了这九个值类,也可以自定义值类,即定义时显式地继承自AnyVal类。如果没有显式地继承自AnyVal类,则都认为是AnyRef类的子类,也就是说一般自定义的类都属于引用类。大部分标准库里的类都是引用类,比如常见的字符串类String,还有后续会讲解的列表类、映射类、集合类等等。Java的类都属于引用类,因为Java的基本类型都在值类里面。

  前面讲过引用相等性,很显然只有引用类才有引用相等性。事实上,比较引用相等性的两个方法——eq和ne,都定义在AnyRef类里。值类AnyVal是没有这两个方法的,也不需要。

  在层次结构的底部有两个底类型——Null类和Nothing类。其中Null类是所有引用类的子类,表示空引用,即指向JVM里的空内存,这与Java的null概念是一样的。但是Null并不兼容值类,所以Scala还有一个类——Nothing,它是所有值类和引用类的子类,甚至还是Null类的子类。因此Nothing不仅表示空引用,还表示空值。Scala里有一个可选值语法,也就是把各种类型打包成一个特殊的可选值。为了表示“空”、“没有”这个特殊的概念,以及兼容各种自定义、非自定义的值和引用类,这个特殊的可选值其实就是把Nothing类进行打包。

  除了自定义的普通类属于引用类,后一章讲解的特质,也是属于引用类的范畴。

八、总结
  本章介绍了类继承的语法,其内容不多,也简单易懂。这一章真正的难点是阅读大型系统软件时,遇到的纷繁复杂的类层次,要梳理这些类的继承关系往往费时费力。还有自己编写代码时,如何设计类的结构,让系统稳定、简单、逻辑清晰,也不是一件容易事。

  在编写Chisel时,类继承主要用于编写接口,因为接口可以扩展,但是实际的硬件电路并没有很强烈的继承关系。

第八章 Scala基础——特质

一、什么是特质
  因为Scala没有多重继承,为了提高代码复用率,故而创造了新的编程概念——特质。

  特质是用关键字“trait”为开头来定义的,它与单例对象很像,两者都不能有入参。但是,单例对象天生就是具体的,特质天生就是抽象的,不过不需要用“abstract”来说明。所以,特质可以包含抽象成员,而单例对象却不行。另外,两者都不能用new来实例化,因为特质是抽象的,而单例对象已经是具体的对象。类、单例对象和特质三者一样,内部可以包含字段和方法,甚至包含其他类、单例对象、特质的定义。

  特质可以被其它类、单例对象和特质“混入”。这里使用术语“混入”而不是“继承”,是因为特质在超类方法调用上采用线性化机制,与多重继承有很大的区别。其它方面,“混入”和“继承”其实是一样的。例如,某个类混入一个特质后,就包含了特质的所有公有成员,而且也可以用“override”来重写特质的成员。

  Scala只允许继承自一个类,但是对特质的混入数量却没有限制,故而可用于替代多重继承语法。要混入一个特质,可以使用关键字“extends”。但如果“extends”已经被占用了,比如已经拿去继承一个类或混入一个特质,那么后续则通过关键字“with”来混入其他特质。例如:

scala> class A {
         |    val a = "Class A"
         |  }
defined class A

scala> trait B {
         |    val b = "Trait B"
         |  }
defined trait B

scala> trait C {
         |    def c = "Trait C"
         |  }
defined trait C

scala> object D extends A with B with C
defined object D

scala> D.a
res0: String = Class A

scala> D.b
res1: String = Trait B

scala> D.c
res2: String = Trait C

  特质也定义了一个类型,而且类型为该特质的变量,可以指向混入该特质的对象。例如:

scala> trait A
defined trait A

scala> class B extends A
defined class B

scala> val x: A = new B
x: A = B@7cc1f72c 

二、特质的层次 
  特质也可以继承自其他类,或混入任意个特质,这样该特质就是关键字“extends”引入的那个类/特质的子特质。如果没有继承和混入,那么这个特质就是AnyRef类的子特质。前面讲过AnyRef类是所有非值类和特质的超类。当某个类、单例对象或特质用关键字“extends”混入一个特质时,会隐式继承自这个特质的超类。也就是说,类/单例对象/特质的超类,都是由“extends”引入的类或特质决定的。

  特质对混入有一个限制条件:那就是要混入该特质的类/单例对象/特质,它的超类必须是待混入特质的超类,或者是待混入特质的超类的子类。因为特质是多重继承的替代品,那就有“继承”的意思。既然是继承,混入特质的类/单例对象/特质的层次,就必须比待混入特质的层次要低。例如:

scala> class A
defined class A

scala> class B extends A
defined class B

scala> class C
defined class C

scala> trait D extends A
defined trait D

scala> trait E extends B
defined trait E

scala> class Test1 extends D
defined class Test1

scala> class Test2 extends A with D
defined class Test2

scala> class Test3 extends B with D
defined class Test3

scala> class Test4 extends C with D
<console>:13: error: illegal inheritance; superclass C
 is not a subclass of the superclass A
 of the mixin trait D
       class Test4 extends C with D
                                  ^

scala> class Test5 extends A with E
<console>:13: error: illegal inheritance; superclass A
 is not a subclass of the superclass B
 of the mixin trait E
       class Test5 extends A with E

  上例中,类Test1直接混入特质D,这样隐式继承自D的超类——类A,所以合法。类Test2和Test3分别继承自类A和A的子类,所以也允许混入特质D。类Test4的超类是C,而C与A没有任何关系,所以非法。类Test5的超类是A,特质E的超类是B,尽管类A是类B的超类,这也仍然是非法的。从提示的错误信息也可以看出,混入特质的类/单例对象/特质,其超类必须是待混入特质的超类或超类的子类。

 三、混入特质的简便方法
  如果想快速构造一个混入某些特质的实例,可以使用如下语法:

new Trait1 with Trait2 ... { definition }

  这其实是定义了一个匿名类,这个匿名类混入了这些特质,并且花括号内是该匿名类的定义。然后使用new构造了这个匿名类的一个对象,其等效的代码就是:

class AnonymousClass extends Trait1 with Trait2 ... { definition }

new AnonymousClass

例如:

scala> trait T {
         |    val tt = "T__T"
         |  }
defined trait T

scala> trait X {
         |    val xx = "X__X"
         |  }
defined trait X

scala> val a = new T with X
a: T with X = $anon$1@4c1fed69

scala> a.tt
res0: String = T__T

scala> a.xx
res1: String = X__X

  除此之外,还可以在最前面加上一个想要继承的超类:

new SuperClass with Trait1 with Trait2 ... { definition }

四、特质的线性化叠加计算
  多重继承一个很明显的问题是,当子类调用超类的方法时,若多个超类都有该方法的不同实现,那么需要附加额外的语法来确定具体调用哪个版本。Scala的特质则是采取一种线性化的规则来调用特质中的方法,这与大多数语言不一样。在特质里,“super”调用是动态绑定的。也就是说,按特质本身的定义,无法确定super调用的具体行为;直到特质混入某个类或别的特质,有了具体的超类方法,才能确定super的行为。这是实现线性化的基础。

  想要通过混入特质来实现某个方法的线性叠加,那么要注意以下要点:①需要在特质里定义同名同参的方法,并加关键字组合“abstract override”,注意这不是重写,而是告诉编译器该方法用于线性叠加。这个关键字组合只能用在特质里,不允许用在其他地方。②这个关键字组合也意味着该特质必须混入某个拥有该方法具体定义的类中,也就是这个类定义了该方法的最终行为。③需要混入特质进行线性化计算的类,在定义时不能立即混入特质。这样做会让编译器认为这个类是在重写末尾那个特质的方法,而且当类的上一层超类是抽象类时还会报错。应该先定义这个类的子类来混入特质,然后构造子类的对象。或者直接用第三点讲的“new SuperClass with Trait1 with Trait2 ...”来快速构造一个子类对象。④特质对该方法的定义必须出现“super.方法名(参数)”。⑤方法的执行顺序遵循线性化计算公式,起点是公式里从左往右数的第一个特质,外部传入的参数也是由起点接收;起点的“super.方法名(参数)”将会调用起点右边第一个特质的同名方法,并把起点的计算结果作为参数传递过去;依此类推,最后结果会回到最左边的类本身。可以理解为特质是按一定顺序对入参进行各种变换,最后把变换后的入参交给类来计算。⑥要回到类本身,说明这个类直接或间接重写或实现了基类的方法。并且定义中如果也出现了“super.方法名(参数)”,那么会调用它的上一层超类的实现版本。或者这个类没有重写,那就一定要有继承自超类的实现。

  线性化计算公式:①最左边是类本身。②在类的右边写下定义时最后混入的那个特质,并接着往右按继承顺序写下该特质的所有超类和超特质。③继续往右写下倒数第二个混入的特质,以及其超类和超特质,直到写完所有特质。④所有重复项只保留最右边那个,并在最右边加上AnyRef和Any。

  为了具体说明,以如下代码为例:

// test.scala
abstract class A {
def m(s: String): String
}
class X extends A {
def m(s: String) = "X -> " + s
}
trait B extends A {
abstract override def m(s: String) = super.m("B -> " + s)
}
trait C extends A {
abstract override def m(s: String) = super.m("C -> " + s)
}
trait D extends A {
abstract override def m(s: String) = super.m("D -> " + s)
}
trait E extends C {
abstract override def m(s: String) = super.m("E -> " + s)
}
trait F extends C {
abstract override def m(s: String) = super.m("F -> " + s)
}
class G extends X {
override def m(s: String) = "G -> " + s
}
val x = new G with D with E with F with B
println(x.m("End"))
  首先,需要混入特质进行线性化计算的类G在定义时没有立即混入特质,即只有“class G extends X”,而是通过“new G with D with E with F with B”来构造G的匿名子类的对象。其次,注意基类A是一个抽象类,类X实现了抽象方法m,类G重写了X的m,其余特质也用“abstract override”重写了m,这保证了m最终会回到类G。最后,基类A的m的返回类型“String”的声明是必须的,因为抽象方法无法推断返回类型,不声明就默认是Unit。

  根据线性化计算公式可得(蓝色表示起点,红色表示重复,类X不参与计算):

① G

② G→B→A

③ G→B→A→F→C→A

④ G→B→A→F→C→A→E→C→A

⑤ G→B→A→F→C→A→E→C→A→D→A

⑥ G→B→F→E→C→D→A

⑦ G→B→F→E→C→D→A→AnyRef→Any

  起点是B,传入参数“End”会得到“B -> End”;然后B的super.m调用F的m,并传入计算得到的“B -> End”,那么F会得到“F -> B -> End”,再继续向右调用;最后A的m是抽象的,无操作可执行,转而回到G的m,所以最后传给G的参数实际是“D -> C -> E -> F -> B -> End”,得到的结果也就是“G -> D -> C -> E -> F -> B -> End”。 

  通过实际运行可得:

PS E:\Microsoft VS\Scala> scala test.scala
G -> D -> C -> E -> F -> B -> End

  如果G的m也有super或没有重写,那么会调用X的m,最后的结果是最左边多个X: 

// test.scala
...
class G extends X {
override def m(s: String) = super.m("G -> " + s)
}
...
PS E:\Microsoft VS\Scala> scala test.scala
X -> G -> D -> C -> E -> F -> B -> End

  如果立即混入特质,则相当于普通的方法重写:

// test.scala
...
class G extends X with D with E with F with B {
override def m(s: String) = "G -> " + s
}
val x = new G
...
PS E:\Microsoft VS\Scala> scala test.scala
G -> End 

  如果上一层超类是抽象类,立即混入会引发错误:

// test.scala
...
class G extends A with D with E with F with B {
override def m(s: String) = "G -> " + s
}
val x = new G
...
PS E:\Microsoft VS\Scala> scala test.scala
E:\Microsoft VS\Scala\Chapter 12\.\traittest.scala:23: error: overriding method m in trait B of type (s: String)String;
 method m needs `abstract override' modifiers
  override def m(s: String) = "G -> " + s
               ^
one error found 

五、总结
  特质用于代码重用,这与抽象基类的作用相似。不过,特质常用于混入在不相关的类中,而抽象基类则用于构成有继承层次的一系列相关类。在Chisel中,特质常用于硬件电路模块的公有属性的提取,在需要这些属性的电路中混入相应的特质,在不需要的时候删去,就能快速地修改电路设计。

第九章 Scala基础——包和导入

一、包
  当代码过于庞大时,为了让整个系统层次分明,各个功能部分划分明显,常常需要把整体划分成若干独立的模块。与Java一样,Scala把代码以“包”的形式划分。

  包是以关键字“package”为开头来定义的。可以用花括号把包的范围包起来,这种风格类似C++和C#的命名空间,而且这种方法使得一个文件可以包含多个不同的包。也可以不用花括号标注范围,但包的声明必须在文件最前面,这样使得整个文件的内容都属于这个包,这种风格类似Java。对于包的命名方式,推荐使用Java的反转域名法,即“com.xxx.xxx”的形式。

  在包里,可以定义class、object和trait,也可以定义别的package。如果编译一个包文件,那么会在当前路径下生成一个与包名相同的文件夹,文件夹里是包内class、object和trait编译后生成的文件,或者是包内层的包生成的更深一层文件夹。如果多个文件的顶层包的包名相同,那么编译后的文件会放在同一个文件夹内。也就是说,一个包的定义可以由多个文件的源代码组成。

二、包的层次和精确代码访问
  因为包里还可以定义包,所以包也有层次结构。包不仅便于人们按模块阅读,同时也告诉编译器这些代码存在某些层次联系。像访问对象的成员一样,包也可以通过句点符号来按路径层次访问。如果包名中就出现了句点,那么编译器也会按层次编译。例如:

package one.two

  等效于:

 package one

     package two

  这两种写法都会先编译出一个名为one的文件夹,然后在里面又编译出一个名为two的文件夹。如果一个包仅仅是包含了其他的包,没有额外的class、object和trait定义,那么建议写出第一种形式,这样内部代码省去了一次缩进。

  Scala的包是嵌套的,而不像Java那样只是分级的。这体现在Java访问包内的内容必须从最顶层的包开始把全部路径写齐,而Scala则可以按照一定的规则书写更简短的形式。例如:

package bobsrockets {
package navigation {
class Navigator {
// 不需要写成bobsrockets.navigation.StarMap
val map = new StarMap
}

class StarMap
}

class Ship {
// 不需要写成bobsrockets.navigation.Navigator
val nav = new navigation.Navigator
}

package fleets {
class Fleet {
// 不需要写成bobsrockets.Ship
def addShip() = { new Ship }
}
}
}
  第一,访问同一个包内的class、object和trait不需要增加路径前缀。因为“new StarMap”和“class StarMap”都位于bobsrockets.navigation包内,所以这条代码能够通过编译。

  第二,访问同一个包内更深一层的包所含的class、object和trait,只需要写出那层更深的包。因为“class Ship”和“package navigation”都位于bobsrockets包内,所以要访问navigation包内的class、object和trait只需要增加“navigation.”,而不是完整的路径。

  第三,当使用花括号显式表明包的作用范围时,包外所有可访问的class、object和trait在包内也可以直接访问。因为“package fleets”位于外层包bobsrockets,所以bobsrockets包内、fleets包外的所有class、object和trait可以直接访问,故而“new Ship”不需要完整路径也能通过编译。

  以上规则在同一个文件内显式嵌套时可以生效。如果把包分散在多个文件内,并通过包名带句点来嵌套,则不会生效。例如下面的代码就不能通过编译:

// bobsrockets.scala
package bobsrockets {
class Ship
}
// fleets.scala
package bobsrockets.fleets {
class Fleet {
// 无法编译,Ship不在作用域内
def addShip() = { new Ship }
}
}
  即使把这两个文件合并,也无法编译。但是当第二个文件把每个包分开声明时,上述规则又能生效。例如下面的代码是合法的:

// bobsrockets.scala
package bobsrockets
class Ship

// fleets.scala
package bobsrockets
package fleets
class Fleet {
// 可以编译
def addShip() = { new Ship }
}
  为了访问不同文件最顶层包的内容,Scala定义了一个隐式的顶层包“_root_”,所有自定义的包其实都包含在这个包里。例如:

// launch.scala
package launch {
class Booster3
}

// bobsrockets.scala
package bobsrockets {
package navigation {
package launch {
class Booster1
}

class MissionControl {
val booster1 = new launch.Booster1
val booster2 = new bobsrockets.launch.Booster2
val booster3 = new _root_.launch.Booster3
}
}

package launch {
class Booster2
}
}
  Booster3必须通过“_root_”才能访问,否则就和Booster1混淆,造成歧义。

三、import导入
  如果每次都按第二点的精确访问方式来编程,则显得过于繁琐和复杂。因此,可以通过关键字“import”来导入相应的内容。

  Scala的import有三点灵活性:①可以出现在代码的任意位置,而不仅仅是开头。②除了导入包内所含的内容,还能导入对象(单例对象和new构造的对象都可以)和包自身,甚至函数的参数都能作为对象来导入。③可以重命名或隐藏某些成员。例如:

package A {
package B {
class M
}

package C {
object N
}
}
  通过语句“import A.B”就能把包B导入。当要访问M时,只需要写“B.M”而不需要完整的路径。通过“import A.B.M”和“import A.C.N”就分别导入了类M和对象N。此时访问它们只需要写M和N即可。

  路径最后的元素可以放在花括号里,这样就能导入一个或多个元素,例如通过“import A.{B, C}”就导入了两个包。花括号内的语句也叫“引入选择器子句”。如果要导入所有的元素,则使用下划线。例如“import A._”或“import A.{_}”就把包B和C都导入了。

  如果写成“import A.{B => packageB}”,就是在导入包B的同时重命名为“packageB”,此时可以用packageB指代包B,也仍能用“A.B”显式访问。如果写成“import A.{B => _, _}”,就是把包B进行隐藏,而导入A的其他元素。注意,指代其余元素的下划线通配符必须放在最后。

  包导入是相对路径,也就是代码里有“import A._”的文件要和包A编译后的文件夹要在同一级目录下。

四、自引用
  Scala有一个关键字“this”,用于指代对象自己。简单的理解就是:如果this用在类的方法里,则指代正在调用方法的那个对象;如果用在类的构造方法里,则指代当前正在构建的对象。

五、访问修饰符
  包、类和对象的成员都可以标上访问修饰符“private”和“protected”。用“private”修饰的成员是私有的,只能被包含它的包、类或对象的内部代码访问;用“protected”修饰的成员是受保护的,除了能被包含它的包、类或对象的内部代码访问,还能被子类访问(只有类才有子类)。例如:

class Diet {
private val time = "0:00"
protected val food = "Nothing"
}

class Breakfast extends Diet {
override val time = "8:00" // error
override val food = "Apple" // OK
}
  对time的重写会出错,因为私有成员只能被类Diet内部的代码访问,子类不会继承,外部也不能通过“(new Diet).time”来访问。对food的重写是允许的,因为子类可以访问受保护的成员,但是外部不能通过“(new Diet).food”来访问。

  除此之外,还可以加上限定词。假设X指代某个包、类或对象,那么private[X]和protected[X]就是在不加限定词的基础上,把访问权限扩大到X的内部。例如:

package A {
package B {
private[A] class JustA
}

class MakeA {
val a = new B.JustA // OK
}
}

package C {
class Error {
val a = new A.B.JustA // error
}
}
  X还能是自引用关键字“this”。private[this]比private更严格,不仅只能由内部代码访问,还必须是调用方法的对象或构造方法正在构造的对象来访问;protected[this]则在private[this]的基础上扩展到定义时的子类。例如:

scala> class MyInt1(x: Int) {
         |    private val mi1 = x
         |    def add(m: MyInt1) = mi1 + m.mi1
         |  }
defined class MyInt1

scala> class MyInt2(x: Int) {
         |    private[this] val mi2 = x
         |    def add(m: MyInt2) = mi2 + m.mi2
         |  }
<console>:13: error: value mi2 is not a member of MyInt2
         def add(m: MyInt2) = mi2 + m.mi2
                                      ^

  MyInt1可以编译成功,但是MyInt2却不行,因为add传入的对象不是调用方法的对象,所以不能访问字段mi2,尽管这还是代码内部。换句话说,用private[this]和protected[this]修饰的成员x,只能通过“this.x”的方式来访问。

  对于类、对象和特质,不建议直接用private和protected修饰,容易造成作用域混乱,应该用带有限定词的访问修饰符来修饰,显式声明它们在包内的作用域。

  前面说过,伴生对象和伴生类共享访问权限,即两者可以互访对方的所有私有成员。在伴生对象里使用“protected”没有意义,因为伴生对象没有子类。特质使用“private”和“protected”修饰成员也没有意义。

六、包对象
  包里可直接包含的元素有类、特质和单例对象,但其实类内可定义的元素都能放在包里,只不过字段和方法不能直接定义在包里。Scala把字段和方法放在一个“包对象”中,每个包都允许有一个包对象。包对象用关键字组合“package object”为开头来定义,其名称与关联的包名相同,有点类似伴生类与伴生对象的关系。

  包对象不是包,也不是对象,它会被编译成名为“package.class”的文件,该文件位于与它关联的包的对应文件夹里。为了保持路径同步,建议定义包对象的文件命名为“package.scala”,并和定义关联包的文件放在同一个目录下。

七、总结
  本章讲解了包的概念,以及Scala独有的一些语法特点。这一章并不是重点,主要是方便读者在阅读别人的代码时能理解层次结构、模块划分,以及根据import的路径来快速寻找相应的定义。

第十章 Scala基础——集合

  不管是用Scala编写软件,还是用Chisel开发硬件电路,集合都是非常有用的数据结构。Scala里常见的集合有:数组、列表、集、映射、序列、元组、数组缓冲和列表缓冲。了解这些集合的概念并熟练掌握基本使用方法,对提高工作效率大有帮助。本章的内容便是逐一讲解这些集合类,所涉内容均为基础,对编写、阅读Chisel代码有用即可。如果想深入了解集合的原理,请读者自行学习。

一、数组
  数组是最基本的集合,实际是计算机内一片地址连续的内存空间,通过指针来访问每一个数组元素。因为数组是结构最简单的集合,所以它在访问速度上要比其它集合要更快。Scala的数组类名为Array,继承自Java。Array是一个具体的类,因此可以通过new来构造一个数组对象。数组元素的类型可以是任意的,而且不同的元素类型会导致每个元素的内存大小不一样,但是所有元素的类型必须一致。Scala编译器的泛型机制是擦除式的,在运行时并不会保留类型参数的信息。但是数组的特点使得它成为唯一的例外,因为数组的元素类型跟数组保存在一起。

  数组对象必须是定长的,也就是在构造时可以选择任意长度的数组,构造完毕后就不能再更改长度了。构造数组对象的语法如下:

new Array[T](n)

  其中,方括号里的T表示元素的类型,它可以显式声明,也可以通过传入给构造方法的对象来自动推断。圆括号里的n代表元素个数,它必须是一个非负整数,如果n等于0则表示空数组。和Java一样,Scala的类型参数也是放在方括号里的。构造对象时,除了可以用值参数来“配置”对象,也可以用类型参数来“配置”。这其实是oop里一种重要的多态,称为全类型多态或参数多态,即通过已有的各种类型创建新的各种类型。

  数组可以用过下标来索引每个元素,和大多数语言一样,Scala的数组下标也是从0开始的。不过,有一点不同的是,其他语言的数组下标都是写在方括号里,而Scala的数组下标却是写在圆括号里。还记得“操作符即方法吗”?Scala并没有什么下标索引操作符,而是在Array类里定义了一个apply方法,该方法接收一个Int类型的参数,返回对应下标的数组元素。所以,Scala的数组下标才要写在圆括号里,这其实是让编译器隐式插入apply方法的调用,当然读者也可以显式调用。

  虽然数组是定长的,但是每个数组元素都是可变的,也就是可以对数组元素重新赋值。例如:

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

scala> intArray(0) = 1

scala> intArray(1) = 2

scala> intArray(2) = 3

scala> intArray
res0: Array[Int] = Array(1, 2, 3)

  除此之外,Array的伴生对象里还定义了一个apply工厂方法,因此也可以按如下方式构造数组对象:

scala> val charArray = Array('a', 'b', 'c')
charArray: Array[Char] = Array(a, b, c) 

二、列表
   列表是一种基于链表的数据结构,这使得列表访问头部元素很快,往头部增加新元素也是消耗定长时间,但是对尾部进行操作则需要线性化的时间,也就是列表越大时间越长。列表类名为List,这是一个抽象类,因此不能用new来构造列表对象。但是伴生对象里有一个apply工厂方法,接收若干个参数,以数组的形式转换成列表(链表)。列表也是定长的,且每个元素的类型相同、不可再重新赋值,有点像不可写入的数组。列表元素也是从下标0开始索引,下标同样写在圆括号里。例如:

scala> val intList = List(1, 1, 10, -5)
intList: List[Int] = List(1, 1, 10, -5)

scala> intList(0)
res0: Int = 1

scala> intList(3)
res1: Int = -5

   因为列表的数据结构特性使得在头部添加元素很快,而尾部很慢,所以列表定义了一个名为“::”的方法,在列表头部添加新元素。注意,这会构造一个新的列表对象,而不是直接修改旧列表,因为列表是不可变的。其写法如下:

x :: xs

  其中左侧的x是一个T类型的元素,右侧的xs是一个List[T]类型的列表。这种写法符合直观表示。还记得前面说过以冒号结尾的中缀操作符,其调用对象在右侧吗?其实正是出自这里。因为x是任意类型的,如果让x成为调用对象,那么就必须在所有类型包括自定义类型里都添加方法“::”,这显然是不现实的。如果让列表xs成为调用对象,那么只需要列表类定义该方法即可。例如:

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

  还有一个名字相近的方法——:::,它用于拼接左、右两个列表,返回新的列表:

scala> List(1, 2) ::: List(2, 1)
res0: List[Int] = List(1, 2, 2, 1) 

  List有一个子对象——Nil,它表示空列表。Nil的类型是List[Nothing],因为List的类型参数是协变的(有关泛型请见后续章节),而Nothing又是所有类的子类,所以List[Nothing]是所有列表的子类,即Nil兼容所有元素。既然Nil是一个空列表对象,那么它同样能调用方法“::”,通过Nil和::就能构造出一个列表,例如:

scala> 1 :: 2 :: 3 :: Nil
res0: List[Int] = List(1, 2, 3)

  用apply工厂方法构造其实是上述方式的等效形式。展开来解释就是:在空列表Nil的头部添加了一个元素3,构成了列表List(3);随后,继续在头部添加元素2,构成列表List(2, 3);最后,在头部添加元素1,得到最终的List(1, 2, 3)。

  读者可以发挥更多想象,数组与列表元素不仅可以是值类型,它们也可以是自定义的类,甚至是数组和列表本身,构成嵌套的数组与列表。此外,如果元素类型是Any,那么数组和列表也就可以包含不同类型的元素。当然,并不推荐这么做。例如:

scala> List(Array(1, 2, 3), Array(10, 100, 100))
res0: List[Array[Int]] = List(Array(1, 2, 3), Array(10, 100, 100))

scala> List(1, '1', "1")
res1: List[Any] = List(1, 1, 1)

三、数组缓冲与列表缓冲
  因为列表往尾部添加元素很慢,所以一种可行方案是先往列表头部添加,再把列表整体翻转。

  另一种方案是使用定义在scala.collection.mutable包里的ArrayBuffer和ListBuffer。这两者并不是真正的数组和列表,而可以认为是暂存在缓冲区的数据。在数组缓冲和列表缓冲的头部、尾部都能添加、删去元素,并且耗时是固定的,只不过数组缓冲要比数组慢一些。数组和列表能使用的成员方法,在它们的缓冲类里也有定义。

  通过“ArrayBuffer/ListBuffer += value”可以往缓冲的尾部添加元素,通过“value +=: ArrayBuffer/ListBuffer”可以往缓冲的头部添加元素,但只能通过“ArrayBuffer/ListBuffer -= value”往缓冲的尾部删去第一个符合的元素。往尾部增加或删除元素时,元素数量可以不只一个。例如:

scala> import scala.collection.mutable.{ArrayBuffer, ListBuffer}
import scala.collection.mutable.{ArrayBuffer, ListBuffer}

scala> val ab = new ArrayBuffer[Int]()
ab: scala.collection.mutable.ArrayBuffer[Int] = ArrayBuffer()

scala> ab += 10
res0: ab.type = ArrayBuffer(10)

scala> -10 +=: ab
res1: ab.type = ArrayBuffer(-10, 10)

scala> ab -= -10
res2: ab.type = ArrayBuffer(10)

scala> val lb = new ListBuffer[String]()
lb: scala.collection.mutable.ListBuffer[String] = ListBuffer()

scala> lb += ("abc", "oops", "good")
res3: lb.type = ListBuffer(abc, oops, good)

scala> lb -= "abc"
res4: lb.type = ListBuffer(oops, good)

scala> "scala" +=: lb
res5: lb.type = ListBuffer(scala, oops, good)

  当缓冲里的元素添加完毕后,就可以通过方法“toArray”或“toList”把缓冲的数据构造成一个数组或列表对象。注意,这是构造一个新的对象,原有缓冲仍然存在。例如:

scala> lb.toArray
res6: Array[String] = Array(scala, oops, good)

scala> lb.toList
res7: List[String] = List(scala, oops, good)

scala> lb
res8: scala.collection.mutable.ListBuffer[String] = ListBuffer(scala, oops, good)

四、元组
  元组也是一种常用的数据结构,它和列表一样也是不可变的。元组的特点是可以包含不同类型的对象。其字面量写法是在圆括号里编写用逗号间隔的元素。例如:

scala> (1, "tuple", Console)
res0: (Int, String, Console.type) = (1,tuple,scala.Console$@5fc59e43) 

上述例子构造了一个三元组,包含了一个Int对象、一个String对象和控制台对象。注意查看打印的元组类型。

  元组最常用的地方是作为函数的返回值。由于函数只有一个返回语句,但如果想返回多个表达式或对象,就可以把它们包在一个元组里返回。

  因为元组含有不同类型的对象,所以不可遍历,也就无法通过下标来索引,只能通过“_1”、“_2”......这样来访问每个元素。注意第一个元素就是“_1”,不是“_0”。例如:

scala> val t = ("God", 'A', 2333)
t: (String, Char, Int) = (God,A,2333)

scala> t._1
res0: String = God

scala> t._2
res1: Char = A

scala> t._3
res2: Int = 2333

  实际上,元组并不是一个类,而是一系列类:Tuple1、Tuple2、Tuple3......Tuple22。这些类都是具体的,因此除了通过字面量的写法构造元组,也可以显式地通过“new TupleX(元组元素)”来构造。其中,每个数字代表元组包含的元素数量,也就是说元组最多只能包含22个元素,除非自定义Tuple23、Tuple24......不过这没有意义,因为元组可以嵌套元组,并不妨碍元组包含任意数量的元素。

  进一步查看元组的API,会发现每个TupleX类里都有名为“_1”、“_2”......“_X”的字段。这正好呼应了前面访问元组元素所用的独特语法。

  一元组没有字面量,只能显式地通过“new Tuple1(元组元素)”来构造一元组,因为此时编译器不会把圆括号解释成元组。

  二元组也叫“对偶”,这在映射里会用到。

  当函数的入参数量只有一个时,那么调用时传递进去的元组字面量也可以省略圆括号。例如:

scala> def getType(x: Any) = x.getClass
getType: (x: Any)Class[_]

scala> getType(1)
res0: Class[_] = class java.lang.Integer

scala> getType(1, 2, 3)
res1: Class[_] = class scala.Tuple3

五、映射
  映射是包含一系列“键-值”对的集合,键和值的类型可以是任意的,但是每个键-值对的类型必须一致。键-值对的写法是“键 -> 值”。

  实际上,映射并不是一个类,而是一个特质。所以无法用new构建映射对象,只能通过伴生对象里的apply工厂方法来构造映射类型的对象。例如:

scala> val map = Map(1 -> "+", 2 -> "-", 3 -> "*", 4 -> "/")
map: scala.collection.immutable.Map[Int,String] = Map(1 -> +, 2 -> -, 3 -> *, 4 -> /)

  映射的apply方法通过接收一个键作为参数,返回对应的值。例如:

scala> map(3)
res0: String = *

scala> map(0)
java.util.NoSuchElementException: key not found: 0
  at scala.collection.immutable.Map$Map4.apply(Map.scala:204)
  ... 28 elided

  表达式“object1 -> object2”实际就是一个对偶(二元组),因此键-值对也可以写成对偶的形式。例如:

scala> val tupleMap = Map(('a', 'A'), ('b', 'B'))
tupleMap: scala.collection.immutable.Map[Char,Char] = Map(a -> A, b -> B)

scala> tupleMap('a')
res0: Char = A

  默认情况下,使用的是scala.collection.immutable包里的不可变映射。当然,也可以导入scala.collection.mutable包里的可变映射,这样就能动态地增加、删除键-值对。可变映射的名字也叫“Map”,因此要注意使用import导入可变映射时,是否把不可变映射覆盖了。

六、集
  集和映射一样,也是一个特质,也只能通过apply工厂方法构建对象。集只能包含字面值不相同的同类型元素。当构建时传入了重复参数,那么会过滤掉多余的,只取一个。集的apply方法是测试是否包含传入的参数,返回true或false,而不是通过下标来索引元素。例如:

scala> val set = Set(1, 1, 10, 10, 233)
set: scala.collection.immutable.Set[Int] = Set(1, 10, 233)

scala> set(100)
res0: Boolean = false

scala> set(233)
res1: Boolean = true

   默认情况下,使用的也是不可变集,scala.collection.mutable包里也有同名的可变集。

七、序列
  序列Seq也是一个特质,数组和列表都混入了这个特质。序列可遍历、可迭代,也就是能用从0开始的下标索引,也可用于循环。序列也是包含一组相同类型的元素,并且不可变。其构造方法也是通过apply工厂方法。

  只是因为Chisel在某些场合会用到Seq,所以介绍这个概念,但是不必深入了解。

八、集合的常用方法
  上述集合类都定义了很多有用的成员方法,在这里介绍一二。如果想查看更多内容,建议前往官网的API网站查询。

   Ⅰ、map
  map方法接收一个无副作用的函数作为入参,对调用该方法的集合的每个元素应用入参函数,并把所得结果全部打包在一个集合里返回。例如:

scala> Array("apple", "orange", "pear").map(_ + "s")
res0: Array[String] = Array(apples, oranges, pears)

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

   Ⅱ、foreach
  foreach方法与map方法类似,不过它的入参是一个有副作用的函数。例如:

scala> var sum = 0
sum: Int = 0

scala> Set(1, -2, 234).foreach(sum += _)

scala> sum
res0: Int = 233

   Ⅲ、zip
  zip方法把两个可迭代的集合一一对应,构成若干个对偶。如果其中一个集合比另一个长,则忽略多余的元素。例如:

scala> List(1, 2, 3) zip Array('1', '2', '3')
res0: List[(Int, Char)] = List((1,1), (2,2), (3,3))

scala> List(1, 2, 3) zip Set("good", "OK")
res1: List[(Int, String)] = List((1,good), (2,OK))

九、总结
  本章介绍了Scala标准库里的常用集合,这些数据结构在Chisel里面也经常用到,读者应该熟悉掌握它们的概念和相关重点。在后一章内建控制结构中,也要用到这些集合。

第十一章 Scala基础——内建控制结构

  对任何编程语言来说,都离不开判断、选择、循环等基本的程序控制结构。自然,Scala也实现了必需的基本控制结构,只不过这些内建控制结构的语法更贴近函数式的风格。本章内容将对这些语法逐一讲解,这些语法在Chisel里编写电路逻辑时也是经常出现的。

一、if表达式
  用于判断的“if......else if......else”语法想必是所有编程语言都具备的。Scala的if表达式与大多数语言是一样的。在if和每个else if后面都将接收一个Boolean类型的表达式作为参数,如果表达式的结果为true,就执行对应的操作,否则跳过。每个分支都可以包含一个表达式作为执行体,如果有多个表达式,则应该放进花括号里。对整个if表达式而言,实际是算作一个表达式。例如:

scala> def whichInt(x: Int) = {
         |    if(x == 0) "Zero"
         |    else if(x > 0) "Positive Number"
         |    else "Negative Number"
         |  }
whichInt: (x: Int)String

scala> whichInt(-1)
res0: String = Negative Number

二、while循环
  Scala的“while”语法与C语言一致,都是当判别式的结果为true时,一直执行花括号里的循环体,直到判别式为false。“do......while”也是一样的,先执行一次循环体,再来进行判别,直到判别式为false。例如要计算两个整数的最大公约数:

def gcdLoop(x: Long, y: Long): Long = {
var a = x
var b = y
while (a != 0) {
val temp = a
a = b % a
b = temp
}
b
}
  从上述代码可以看出,while语法的风格是指令式的。实际上,Scala把“if”叫“表达式”,是因为if表达式能返回有用的值,而“while”叫循环,是因为while循环不会返回有用的值,主要作用是不断重写某些var变量,所以while循环的类型是Unit。在纯函数式的语言里,只有表达式,不会存在像while循环这样的语法。Scala兼容两种风格,并引入了while循环,是因为某些时候用while编写的代码可阅读性更强。但其实所有的while循环都可以通过其它函数式风格的语法来实现,常见做法就是函数的自我递归调用。例如,一个函数式风格的求取最大公约数的函数定义如下:

def gcd(x: Long, y: Long): Long =
if (y == 0) x else gcd(y, x % y)
三、for表达式与for循环
  要实现循环,在Scala里推荐使用for表达式。不过,Scala的for表达式是函数式风格的,没有引入指令式风格的“for(i = 0; i < N; i++)”。一个Scala的for表达式的一般形式如下:

for( seq ) yield expression

  整个for表达式算一个语句。在这里,seq代表一个序列。换句话说,能放进for表达式里的对象,必须是一个可迭代的集合。比如常用的列表(List)、数组(Array)、映射(Map)、区间(Range)、迭代器(Iterator)、流(Stream)和所有的集(Set),它们都混入了特质Iterable。可迭代的集合对象能生成一个迭代器,用该迭代器可以逐个交出集合中的所有元素,进而构成了for表达式所需的序列。关键字“yield”是“产生”的意思,也就是把前面序列里符合条件的元素拿出来,逐个应用到后面的“expression”,得到的所有结果按顺序产生一个新的集合对象。如果把seq展开来,其形式如下:

for {
p <- persons // 一个生成器
n = p.name // 一个定义
if(n startsWith "To") // 一个过滤器
} yield n
  seq是由“生成器”、“定义”和“过滤器”三条语句组成,以分号隔开,或者放在花括号里让编译器自动推断分号。生成器“p <- persons”的右侧就是一个可迭代的集合对象,把它的每个元素逐一拿出来与左侧的模式进行匹配(有关模式匹配请见后续章节)。如果匹配成功,那么模式里的变量就会绑定上该元素对应的部分;如果匹配失败,并不会抛出匹配错误,而是简单地丢弃该元素。在这个例子里,左侧的p是一个无需定义的变量名,它构成了变量模式,也就是简单地指向persons的每个元素。大多数情况下的for表达式的生成器都是这么简单。定义就是一个赋值语句,这里的n也是一个无需定义的变量名。定义并不常用,比如这里的定义就可有可无。过滤器则是一个if语句,只有if后面的表达式为true时,生成器的元素才会继续向后传递,否则就丢弃该元素。这个例子中,是判断persons的元素的name字段是否以“To”为开头。最后,name以“To”为开头的persons元素会应用到yield后面的表达式,在这里仅仅是保持不变,没有任何操作。总之,这个表达式的结果就是遍历集合persons的元素,按顺序找出所有name以“To”为开头的元素,然后把这些元素组成一个新的集合。例如:

// test.scala
class Person(val name: String)

object Alice extends Person("Alice")
object Tom extends Person("Tom")
object Tony extends Person("Tony")
object Bob extends Person("Bob")
object Todd extends Person("Todd")

val persons = List(Alice, Tom, Tony, Bob, Todd)

val To = for {
p <- persons
n = p.name
if(n startsWith "To")
} yield n

println(To)
PS E:\Microsoft VS\Scala> scala test.scala
List(Tom, Tony, Todd)

  每个for表达式都以生成器开始。如果一个for表达式中有多个生成器,那么出现在后面的生成器比出现在前面的生成器变得更频繁,也就是指令式编程里的嵌套的for循环。例如计算乘法口诀表:

 scala> for {
          |    i <- 1 to 9
          |    j <- i to 9
          |  } yield i * j
res0: scala.collection.immutable.IndexedSeq[Int] = Vector(1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 6, 8, 10, 12, 14, 16, 18, 9, 12, 15, 18, 21, 24, 27, 16, 20, 24, 28, 32, 36, 25, 30, 35, 40, 45, 36, 42, 48, 54, 49, 56, 63, 64, 72, 81)

  每当生成器生成一个匹配的元素,后面的定义就会重新求值。这个求值是有必要的,因为定义很可能需要随生成器的值变化而变化。为了不浪费这个操作,定义应尽量用到相关生成器绑定的变量,否则就没必要使用定义。例如: 

for(x <- 1 to 1000; y = expensiveComputationNotInvolvingX) yield x * y

  不如写成:

val y = expensiveComputationNotInvolvingX 

for(x <- 1 to 1000) yield x * y

  如果只想把每个元素应用到一个Unit类型的表达式,那么就是一个“for循环”,而不再是一个“for表达式”。关键字“yield”也可以省略。例如:

scala> var sum = 0
sum: Int = 0

scala> for(x <- 1 to 100) sum += x

scala> sum
res0: Int = 5050

四、用try表达式处理异常
   Ⅰ、抛出一个异常
  如果操作非法,那么JVM会自动抛出异常。当然,也可以手动抛出异常。只需要用new构造一个异常对象,并用关键字“throw”抛出即可,语法与Java一样。例如:

scala> throw new IllegalArgumentException
java.lang.IllegalArgumentException
  ... 28 elided

scala> throw new RuntimeException("RuntimeError")
java.lang.RuntimeException: RuntimeError
  ... 28 elided

   Ⅱ、try-catch
  try后面可以用花括号包含任意条代码,当这些代码产生异常时,JVM并不会立即抛出,而是被catch捕获。catch捕获异常后,按其后面的定义进行相应的处理。处理的方式一般借助偏函数,在详细了解模式匹配前,只需要了解这些语法即可。例如处理除零异常:

scala> def intDivision(x: Int, y: Int) = {
         |     try {
         |       x / y
         |     } catch {
         |       case ex: ArithmeticException => println("The divisor is Zero!")
         |     }
         |  }
intDivision: (x: Int, y: Int)AnyVal

scala> intDivision(10, 0)
The divisor is Zero!
res0: AnyVal = ()

scala> intDivision(10, 2)
res1: AnyVal = 5

   Ⅲ、finally
  try表达式的完整形式是“try-catch-finally”。不管有没有异常产生,finally里的代码一定会执行。通常finally语句块都是执行一些清理工作,比如关闭文件。尽管try表达式可以返回有用值,但是最好不要在finally语句块里这么做。因为Java在显式声明“return”时,会用finally的返回值覆盖前面真正需要的返回值。为了以防万一,最好不要这样做。例如:

scala> def a(): Int = try return 1 finally return 2
a: ()Int

scala> a
res0: Int = 2

scala> def b(): Int = try 1 finally 2
b: ()Int

scala> b
res1: Int = 1

五、match表达式
  match表达式的作用相当于“switch”,也就是把作用对象与定义的模式逐个比较,按匹配的模式执行相应的操作。在详细了解模式匹配之前,先看一个简单的例子粗浅地了解一番:

scala> def something(x: String) = x match {
         |     case "Apple" => println("Fruit!")
         |     case "Tomato" => println("Vegetable!")
         |     case "Cola" => println("Beverage!")
         |     case _ => println("Huh?")
         |  }
something: (x: String)Unit

scala> something("Cola")
Beverage!

scala> something("Toy")
Huh?

六、关于continue和break
  对于指令式编程而言,循环里经常用到关键字“continue”和“break”,例如下面的Java程序:

// Java
int i = 0;
boolean foundIt = false;

while (i < args.length) {
if (args[i].startsWith("-")) {
i = i + 1;
continue;
}
if (args[i].endsWith(".scala")) {
foundIt = true;
break;
}
i = i + 1;
}
  实际上,这两个关键字对循环而言并不是必须的。例如可以改写成如下Scala代码:

// bad Scala
var i = 0
var foundIt = false

while (i < args.length && !foundIt) {
if (!args(i).startsWith("-")) {
if (args(i).endsWith(".scala"))
foundIt = true
}
i = i + 1
}
  又因为这两个关键字过于偏向指令式风格,就像“return”,所以Scala并没有引入它们。而且,Scala并不提倡使用循环,可以通过函数的递归调用达到相同的效果。一个更好的、函数式风格的Scala代码如下:

// good Scala
def searchFrom(i: Int): Int =
if (i >= args.length) -1
else if (args(i).startsWith("-")) searchFrom(i + 1)
else if (args(i).endsWith(".scala")) i
else searchFrom(i + 1)

val i = searchFrom(0)
  如果实在想用,那么Scala的标准库里提供了break方法。通过“import scala.util.control.Breaks._”可以导入Breaks类,该类定义了一个名为“break”的方法。那么,在写下break的地方,就会被编译器标记为可中断。

七、变量的作用域
  在使用控制结构的时候,尤其是有嵌套时,必然要搞清楚变量的作用范围。Scala变量作用范围很明确,边界就是花括号。例如:

def printMultiTable() = {
var i = 1
// 只有i在作用域内
while (i <= 10) {
var j = 1
// i和j都在作用域内
while (j <= 10) {
val prod = (i * j).toString
// i、j和prod都在作用域内
var k = prod.length
// i、j、prod和k都在作用域内
while (k < 4) {
print(" ")
k += 1
}
print(prod)
j += 1
}
// i和j仍在作用域内;prod和k已经超出作用域
println()
i += 1
}
// i仍在作用域内;j、prod和k已经超出作用域
}
  如果内、外作用域有同名的变量,那么内部作用域以内部变量为准,超出内部的范围以外部变量为准。例如:

scala> def f() = {
         |     val a = 1
         |     do {
         |         val a = 10
         |         println(a)
         |     } while(false)
         |     println(a)
         |  }
f: ()Unit

scala> f
10
1

八、总结
  本章介绍了Scala的内建控制结构,尤其是for表达式,在Chisel里面也是经常用到。对于重复逻辑、连线等,使用for表达式就很方便。尽管Verilog也有for语法,但是使用较为麻烦,而且不能像Chisel一样支持泛型。

  除此之外,Chisel也有自定义的控制结构,这些内容会在后续章节讲解。

第十二章 Scala进阶——模式匹配

  前一章提到过,Scala的内建控制结构里有一个match表达式,用于模式匹配或偏函数。模式匹配是Scala中一个强大的高级功能,并且在Chisel中被用于硬件的参数化配置,可以快速地裁剪、配置不同规模的硬件电路。所以,尽管模式匹配不是很容易就能掌握并熟练运用,但是学会它将会对软、硬件编程都大有帮助。

一、样例类与样例对象
  定义类时,若在最前面加上关键字“case”,那么这个类就被称为样例类。Scala的编译器会自动对样例类添加一些语法便利:①添加一个与类同名的工厂方法。也就是说,可以通过“类名(参数)”来构造对象,而不需要“new 类名(参数)”,使得代码看起来更加自然。②参数列表的每个参数都隐式地获得了一个val前缀。也就是说,类内部会自动添加与参数同名的公有字段。③会自动以“自然”的方式实现toString、hashCode和equals方法。④添加一个copy方法,用于构造与旧对象只有某些字段不同的新对象,只需通过传入具名参数和缺省参数实现。比如objectA.copy(arg0 = 10)会创建一个只有arg0为10、其余成员与objectA完全一样的新对象。

  一个样例类定义如下:

scala> case class Students(name: String, score: Int)
defined class Students

scala> val stu1 = Students("Alice", 100)
stu1: Students = Students(Alice,100)

scala> stu1.name
res0: String = Alice

scala> stu1.score
res1: Int = 100

scala> val stu2 = stu1.copy()
stu2: Students = Students(Alice,100)

scala> stu2 == stu1
res2: Boolean = true

scala> val stu3 = stu1.copy(name = "Bob")
stu3: Students = Students(Bob,100)

scala> stu3 == stu1
res3: Boolean = false

  样例类最大的好处是支持模式匹配。相关内容会在本章接下来的内容中介绍。

  样例对象与样例类很像,也是定义单例对象时在最前面加上关键字“case”。尽管样例对象和普通的单例对象一样,没有参数和构造方法,也是一个具体的实例,但是样例对象的实际形式更接近样例类。前面说的样例类的特性,样例对象也具备,例如可用于模式匹配。从编译后的结果来比较,样例对象与一个无参、无构造方法的样例类是一样的。

二、模式匹配
  模式匹配的语法如下:

  选择器 match { 可选分支 }

  其中,选择器就是待匹配的对象,花括号里是一系列以关键字“case”开头的“可选分支”。每个可选分支都包括一个模式以及一个或多个表达式,如果模式匹配成功,就执行相应的表达式,最后返回结果。可选分支定义如下: 

  case 模式 => 表达式

  match表达式与Java的switch语法很像,但模式匹配功能更多。两者有三个主要区别:①match是一个表达式,它可以返回一个值。②可选分支存在优先级,其匹配顺序也就是代码编写时的顺序,并且只有第一个匹配成功的模式会被选中,然后对它的表达式求值并返回。如果表达式有多个,则按顺序执行直到下个case语句为止,并不会贯穿执行到末尾的case语句,所以多个表达式也可以不用花括号包起来。③要确保至少有一个模式匹配成功,否则会抛出MatchError异常。 

三、模式的种类
  模式匹配之所以强大,原因之一就是支持多种多样的模式。

   Ⅰ、通配模式
  通配模式用下划线“_”表示,它会匹配任何对象,通常放在末尾用于缺省、捕获所有可选路径,相当于switch的default。如果某个模式需要忽略局部特性,也可以用下划线代替。例如:

scala> def test(x: Any) = x match {
         |     case List(1, 2, _) => true
         |     case _ => false
         |  }
test: (x: Any)Boolean

scala> test(List(1, 2, 3))
res0: Boolean = true

scala> test(List(1, 2, 10))
res1: Boolean = true

scala> test(List(1, 2))
res2: Boolean = false

  上述例子中,第一个case就是用下划线忽略了模式的局部特性:表明只有含有三个元素,且前两个为1和2、第三个元素任意的列表才能匹配该模式。不符合第一个case的对象,都会被通配模式捕获。

  越具体的模式,可匹配的范围就越小;反之,越模糊的模式,覆盖的范围越大。具体的模式,应该定义在模糊的模式前面,否则如果具体模式的作用范围是模糊模式的子集,那写在后面的具体模式就永远不会被执行。像通配模式这种全覆盖的模式,一定要写在最后。

   Ⅱ、常量模式
  常量模式,顾名思义,就是用一个常量作为模式,使得只能匹配常量自己。任何字面量都可以作为常量模式,任何val类型的变量或单例对象(样例对象也是一样的)也可以作为常量模式。例如,Nil这个单例对象能且仅能匹配空列表:

scala> def test2(x: Any) = x match {
         |     case 5 => "five"
         |     case true => "truth"
         |     case "hello" => "hi!"
         |     case Nil => "the empty list"
         |     case _ => "something else"
         |  }
test2: (x: Any)String

scala> test2(List())
res0: String = the empty list

scala> test2(5)
res1: String = five

scala> test2(true)
res2: String = truth

scala> test2("hello")
res3: String = hi!

scala> test2(233)
res4: String = something else

   Ⅲ、变量模式
  变量模式就是一个变量名,它可以匹配任何对象,这一点与通配模式一样。但是,变量模式还会把该变量名与匹配成功的输入对象绑定,在表达式中可以通过这个变量名来进一步操作输入对象。变量模式还可以放在最后面代替通配模式。例如: 

scala> def test3(x: Any) = x match {
         |     case 0 => "Zero!"
         |     case somethingElse => "Not Zero: " + somethingElse
         |  }
test3: (x: Any)String

scala> test3(0)
res0: String = Zero!

scala> test3(List(0))
res1: String = Not Zero: List(0)

  与通配模式一样,变量模式的后面不能添加别的模式,否则编译器会警告无法到达变量模式后面的代码。例如:

scala> def test3(x: Any) = x match {
         |     case somethingElse => "Not Zero: " + somethingElse
         |     case 0 => "Zero!"
         |  }
<console>:12: warning: patterns after a variable pattern cannot match (SLS 8.1.1)
         case somethingElse => "Not Zero: " + somethingElse
              ^
<console>:13: warning: unreachable code due to variable pattern 'somethingElse' on line 12
         case 0 => "Zero!"
                   ^
<console>:13: warning: unreachable code
         case 0 => "Zero!"
                   ^
test3: (x: Any)String

  有时候,常量模式看上去也是一个变量名,比如“Nil”就是引用空列表这个常量模式。Scala有一个简单的词法区分规则:以小写字母开头的简单名称会被当做变量模式,其他引用都是常量模式。即使以小写字母开头的简单名称是某个常量的别名,也会被当成变量模式。如果想绕开这个规则,有两种方法:①如果常量是某个对象的字段,可以加上限定词如this.a或object.a等来表示这是一个常量。②用反引号` `把名称包起来,编译器就会把它解读成常量,这也是绕开关键字与自定义标识符冲突的方法。例如:

scala> val somethingElse = 1
somethingElse: Int = 1

scala> def test4(x: Any) = x match {
         |     case `somethingElse` => "A constant!"
         |     case 0 => "Zero!"
         |     case _ => "Something else!"
         |  }
test4: (x: Any)String

scala> test4(somethingElse)
res0: String = A constant!

    Ⅳ、构造方法模式
  构造方法模式也就是把样例类的构造方法作为模式,其形式为“名称(模式)”。假设这里的“名称”指定的是一个样例类的名字,那么该模式将首先检查待匹配的对象是不是以这个名称命名的样例类的实例,然后再检查待匹配的对象的构造方法参数是不是匹配括号里的“模式”。Scala的模式支持深度匹配,也就是说,括号里的模式可以是任何一种模式,包括构造方法模式。嵌套的构造方法模式会进一步展开匹配。例如:

scala> case class A(x: Int)
defined class A

scala> case class B(x: String, y: Int, z: A)
defined class B

scala> def test5(x: Any) = x match {
         |     case B("abc", e, A(10)) => e + 1
         |     case _ =>
         |  }
test5: (x: Any)AnyVal

  其中,“abc”是常量模式,只能匹配字符串“abc”;e是变量模式,绑定B的第二个构造参数,然后在表达式里加1并返回;A(10)是构造方法模式,B的第三个参数必须是以10为参数构造的A的对象。例如:

scala> val a = B("abc", 1, A(10))
a: B = B(abc,1,A(10))

scala> val b = B("abc", 1, A(1))
b: B = B(abc,1,A(1))

scala> test5(a)
res0: AnyVal = 2

scala> test5(b)
res1: AnyVal = ()

   Ⅴ、序列模式
  序列类型也可以用于模式匹配,比如List或Array。下划线“_”或变量模式可以指出不关心的元素。把“_*”放在最后可以匹配任意元素个数。例如:

scala> def test6(x: Any) = x match {
         |     case Array(1, _*) => "OK!"
         |     case _ => "Oops!"
         |  }
test6: (x: Any)String

scala> test6(Array(1, 2, 3))
res0: String = OK!

scala> test6(1)
res1: String = Oops!

   Ⅵ、元组模式
  元组也可以用于模式匹配,在圆括号里可以包含任意模式。形如(a, b, c)的模式可以匹配任意的三元组,注意里面是三个变量模式,不是三个字母常量。例如: 

scala> def test7(x: Any) = x match {
         |     case (1, e, "OK") => "OK, e = " + e
         |     case _ => "Oops!"
         |  }
test7: (x: Any)String

scala> test7(1, 10, "OK")
res0: String = OK, e = 10

   Ⅶ、带类型的模式
  模式定义时,也可以声明具体的数据类型。用带类型的模式可以代替类型测试和类型转换。例如:

scala> def test8(x: Any) = x match {
         |     case s: String => s.length
         |     case m: Map[_, _] => m.size
         |     case _ => -1
         |  }
test8: (x: Any)Int

  其中,带类型的变量模式“s: String”将匹配每个非空的String实例,而“m: Map[_, _]”将匹配任意映射实例。注意,入参x的类型是Any,而s的类型是String,所以表达式里可以写s.length而不能写x.length,因为Any类没有叫length的成员。m.size同理。

  在带类型的模式中,虽然可以像上个例子那样指明对象类型为笼统的映射“Map[_, _]”,但是无法更进一步指明映射的键-值分别是什么类型。前面曾说过,这是因为Scala采用了擦除式的泛型,即运行时并不会保留类型参数的信息,所以程序在运行时无法判断某个映射的键-值具体是哪两种类型。唯一例外的是数组,因为数组的元素类型跟数组保存在一起。

   Ⅷ、变量绑定
  除了变量模式可以使用变量外,还可以对任何其他模式添加变量,构成变量绑定模式。其形式为“变量名 @ 模式”。变量绑定模式执行模式匹配的规则与原本模式一样,但是在匹配成功后会把输入对象的相应部分与添加的变量进行绑定,通过该变量就能在表达式中进行额外的操作。例如下面为一个常量模式绑定了变量e:

scala> def test9(x: Any) = x match {
         |     case (1, 2, e @ 3) => e
         |     case _ => 0
         |  }
test9: (x: Any)Int

scala> test9(1, 2, 3)
res0: Int = 3

四、模式守卫
  模式守卫出现在模式之后,是一条用if开头的语句。模式守卫可以是任意的布尔表达式,通常会引用到模式中的变量。如果存在模式守卫,那么必须模式守卫返回true,模式匹配才算成功。

  Scala要求模式都是线性的,即一个模式内的两个变量不能同名。如果想指定模式的两个部分要相同,不是定义两个同名的变量,而是通过模式守卫来解决。例如:

case i: Int if i > 0 => ???                   // 只匹配正整数

case s: String if s(0) == 'a' => ???   // 只匹配以字母'a'开头的字符串

case (x, y) if x == y => ???               // 只匹配两个元素相等的二元组

五、密封类
  如果在“class”前面加上关键字“sealed”,那么这个类就称为密封类。密封类只能在同一个文件中定义子类,不能在文件之外被别的类继承。这有助于编译器检查模式匹配的完整性,因为这样确保了不会有新的模式随意出现,而只需要关心本文件内已有的样例类。所以,要使用模式匹配,最好把最顶层的基类做成密封类。

  对继承自密封类的样例类做匹配,编译器会用警告信息标示出缺失的组合。如果确实不需要覆盖所有组合,又不想用通配模式来避免编译器发出警告,可以在选择器后面添加“@unchecked”注解,这样编译器对后续模式分支的覆盖完整性检查就会被压制。例如:

 scala> sealed abstract class Expr
defined class Expr

scala> case class Var(name: String) extends Expr
defined class Var

scala> case class Number(num: Double) extends Expr
defined class Number

scala> case class UnOp(operator: String, arg: Expr) extends Expr
defined class UnOp

scala> case class BinOp(operator: String, left: Expr, right: Expr) extends Expr
defined class BinOp

scala> def describe(e: Expr): String = e match {
         |     case Number(_) => "a number"
         |     case Var(_) => "a variable"
         |  }
<console>:16: warning: match may not be exhaustive.
It would fail on the following inputs: BinOp(_, _, _), UnOp(_, _)
       def describe(e: Expr): String = e match {
                                       ^
describe: (e: Expr)String

scala> def describe(e: Expr): String = e match {
         |     case Number(_) => "a number"
         |     case Var(_) => "a variable"
         |     case _ => throw new RuntimeException  // Should not happen
         |  }
describe: (e: Expr)String

scala> def describe(e: Expr): String = (e: @unchecked) match {
         |     case Number(_) => "a number"
         |     case Var(_) => "a variable"
         |  }
describe: (e: Expr)String

  有关注解的内容,本教程不会讲解。需要深入了解的读者,请自行查阅资料。Chisel源码使用了注解。

六、可选值
  从上面很多例子中,我们发现两个问题:一是每条case分支可能返回不同类型的值,导致函数的返回值或变量的类型不好确定,该如何把它们统一起来?二是通配模式下,常常不需要返回一个值,但什么都不写又不太好。要解决这两个问题,Scala提供了一个新的语法——可选值。

  可选值就是类型为Option[T]的一个值。其中,Option是标准库里的一个密封抽象类。T可以是任意的类型,例如标准类型或自定义的类。并且T是协变的,简单来说,就是如果类型T是类型U的超类,那么Option[T]也是Option[U]的超类。

  Option类有一个子类:Some类。通过“Some(x)”可以构造一个Some的对象,其中参数x是一个具体的值。根据x的类型,可选值的类型会发生改变。例如,Some(10)的类型是Option[Int],Some("10")的类型是Option[String]。由于Some对象需要一个具体的参数值,所以这部分可选值用于表示“有值”。

  Option类还有一个子对象:None。它的类型是Option[Nothing],是所有Option[T]类型的子类,代表“无值”。也就是说,Option类型代表要么是一个具体的值,要么无值。Some(x)常作为case语句的返回值,而None常作为通配模式的返回值。需要注意的是,Option[T]和T是两个完全没有关系的类型,赋值时不要混淆。

  如果没有可选值语法,要表示“无值”可能会选用null,这就必须对变量进行判空操作。在Java里,判空是一个运行时的动作,如果忘记判空,编译时并不会报错,但是在运行时可能会抛出空指针异常,进而引发严重的错误。有了可选值之后,首先从字面上提醒读者这是一个可选值,存在无值和有值两种情况;其次,最重要的是,由于Option[T]类型与T类型不一样,赋值时就可能需要先做相应的类型转换。类型转换最常见的方式就是模式匹配,在这期间可以把无值None过滤掉。如果不进行类型转换,编译器就会抛出类型错误,这样在编译期就进行判空处理进而防止运行时出现更严重的问题。 

  可选值提供了一个方法isDefined,如果调用对象是None,则返回false,而Some对象都会返回true。还有一个方法get,用于把Some(x)中的x返回,如果调用对象是None则报错。

七、模式匹配的另类用法
  对于提取器(这里不用关心提取器是什么),可以通过“val/var 对象名(模式) = 值”的方式来使用模式匹配,常常用于定义变量。这里的“对象名”是指提取器,即某个单例对象,列表、数组、映射、元组等常用集合的伴生对象都是提取器。例如:

scala> val Array(x, y, _*) = Array(-1, 1, 233)
x: Int = -1
y: Int = 1

scala> val a :: 10 :: _ = List(999, 10)
a: Int = 999

scala> val capitals = Map("China" -> "Beijing", "America" -> "Washington", "Britain" -> "London")
capitals: scala.collection.immutable.Map[String,String] = Map(China -> Beijing, America -> Washington, Britain -> London)

scala> for((country, city) <- capitals)
         |    println("The capital of " + country + " is " + city)
The capital of China is Beijing
The capital of America is Washington
The capital of Britain is London

八、偏函数
  前面说过,在Scala里,万物皆对象。函数是一等值,与整数、浮点数、字符串等等相同,所以函数也是一种对象。既然函数也是一个对象,那么必然属于某一种类型。为了标记函数的类型,Scala提供了一系列特质:Function0、Function1、Function2……Function22来表示参数为0、1、2……22个的函数。与元组很像,因此函数的参数最多只能有22个。当然也可以自定义含有更多参数的FunctionX,但是Scala标准库没有提供,也没有必要。

  除此之外,还有一个特殊的函数特质:偏函数PartialFunction。偏函数的作用在于划分一个输入参数的可行域,在可行域内对入参执行一种操作,在可行域之外对入参执行其他操作。偏函数有两个抽象方法需要实现:apply和isDefinedAt。其中,isDefinedAt用于判断入参是否在可行域内,是的话就返回true,否则返回false;apply是偏函数的函数体,用于对入参执行操作。使用偏函数之前,应该先用isDefinedAt判断入参是否合法,否则可能会出现异常。

  定义偏函数的一种简便方法就是使用case语句组。广义上讲,case语句就是一个偏函数,所以才可以用于模式匹配。一个case语句就是函数的一个入口,多个case语句就有多个入口,每个case语句又可以有自己的参数列表和函数体。例如:

val isInt1: PartialFunction[Any, String] = {
case x: Int => x + " is a Int."
}
// 相当于
val isInt2 = new PartialFunction[Any, String] {
def apply(x: Any) = x.asInstanceOf[Int] + " is a Int."
def isDefinedAt(x: Any) = x.isInstanceOf[Int]
}
  注意apply方法可以隐式调用。x.isInstanceOf[T]判断x是不是T类型(及其超类)的对象,是的话就返回true。x.asInstanceOf[T]则把x转换成T类型的对象,如果不能转换则会报错。

  偏函数PartialFunction[Any, Any]是Function1[Any, Any]的子特质,因为case语句只有一个参数。[Any, Any]中的第一个Any是输入参数的类型,第二个Any是返回结果的类型。如果确实需要输入多个参数,则可以用元组、列表或数组等把多个参数变成一个集合。

  在用case语句定义偏函数时,前述的各种模式类型、模式守卫都可以使用。最后的通配模式可有可无,但是没有时,要保证运行不会出错。 

  上述代码运行如下:

scala> isInt1(1)
res0: String = 1 is a Int.

scala> isInt2(1)
res1: String = 1 is a Int.

scala> isInt1.isDefinedAt('1')
res2: Boolean = false

scala> isInt2.isDefinedAt('1')
res3: Boolean = false

scala> isInt1('1')
scala.MatchError: 1 (of class java.lang.Character)
  at scala.PartialFunction$ $anon$1.apply(PartialFunction.scala:255)
  at scala.PartialFunction$ $anon$1.apply(PartialFunction.scala:253)
  at $anonfun$1.applyOrElse(<console>:12)
  at scala.runtime.AbstractPartialFunction.apply(AbstractPartialFunction.scala:34)
  ... 28 elided

scala> isInt2('1')
java.lang.ClassCastException: java.lang.Character cannot be cast to java.lang.Integer
  at scala.runtime.BoxesRunTime.unboxToInt(BoxesRunTime.java:101)
  at $anon$1.apply(<console>:13)
  at $anon$1.apply(<console>:12)
  ... 28 elided

九、总结
  本章介绍了功能强大的模式匹配,尽管概念比较容易理解,但是要熟练运用则比较难。最后讲解的偏函数,在Chisel里也会用到。在实际编写硬件时,模式匹配是用不上的。

第十三章 Scala进阶——类型参数化

  在面向对象的编程里,提高代码复用率的一个重要方法就是泛型。泛型是一种重要的多态,称为“全类型多态”或“参数多态”。在某些容器类里,通常需要存储其它类型的对象,但是具体是什么类型,事先并不知道。倘若对每种可能包含的类型都编写一个新类,那么这完全不现实。一是工作量巨大,二是自定义类型是什么完全无法预知。例如,列表的元素可以是基本类型,也可以是自定义的类型,不可能在编写列表类时把自定义类型也考虑进去。更重要的是,这些容器类仅仅需要知道一个具体的类型,其它成员完全是一样的。既然这样,那完全可以编写一个泛型的类,它独立于成员的类型存在,然后把类型也作为一个参数,实例化生成不同的类对象。

  既然与定义类型相关,那么可以泛型的自然是类和特质。在前面讲解集合时,就已经初步了解了这样的类和特质。例如,Array[T]、List[T]、Map[T, U]等等。本章将深入讲解Scala有关类型参数化的内容。

一、var类型的字段
  对于可重新赋值的字段,可执行两个基本操作:获取字段值或者设置为一个新值。在JavaBeans库里,这两个操作分别由名为“getter”和“setter”的方法来完成。Scala遵循了Java的惯例,只不过实现两个基本操作的方法的名字不一样:如果在类中定义了一个var类型的字段,那么编译器会隐式地把这个变量限制成private[this]的访问权限,同时隐式地定义一个名为“变量名”的getter方法,和一个名为“变量名_=”的setter方法。默认的getter方法返回变量的值,而默认的setter方法接收外部传入的参数来直接赋给变量。例如:

class A {
var aInt: Int = _
}
// 相当于
class A {
// 这个变量名“a”是随意取的,只要不与两个方法名冲突即可
private[this] var a: Int = _
// getter,方法名与原来的变量名相同
def aInt: Int = a
// setter,注意名字里的“_=”
def aInt_=(x: Int) = a = x
}
  注意,字段必须被初始化,即“=  _”不能省略,它将字段初始化为零值(具体零值是什么取决于字段的类型,数值类型的零值是0,布尔类型是false,引用类型是null),也可以初始化为某个具体值。如果不初始化,就是一个抽象字段。还有前面讲解的private[this],表明该成员只能用“this.a”或“a”来访问,句点前面不能是其它任何对象。

  实际上定义的var类型字段并不是用private[this]修饰的,只不过被编译器隐式转换了,所以外部仍然可以读取和修改该字段,但编译器会自动转换成对getter和setter方法的调用。也就是说,“对象.变量”会调用getter方法,而“对象.变量 = 新值”会调用setter方法。而且,这两个方法的权限与原本定义的var字段的权限相同,如果原本的var字段是公有的,那么这两个方法就是公有的;如果原本的var字段是受保护的,那么这两个方法也是受保护的;依此类推。当然,也可以逆向操作,自定义getter和setter方法,以及一个private[this]修饰的var类型字段,只要注意方法与字段的名字不冲突。

  另一方面,这也说明字段与方法没有必然联系。如果定义了“var a”这样的语句,那么必然有隐式的“a”和“a_=”方法,并且无法显式修改这两个方法(名字冲突);如果自定义了“b”和“b_=”这样的方法,却不一定要相应的var字段与之对应,这两个方法也可以操作类内的其他成员,而且仍然可以通过“object.b”和“object.b = value”来调用。例如:

class A {
private[this] var a: Int = _
// 默认的getter和setter
def originalValue: Int = a
def originalValue_=(x: Int) = a = x
// 自定义的getter和setter,且没有对应的var字段
def tenfoldValue: Int = a * 10
def tenfoldValue_=(x: Int) = a = x / 10
}
scala> val a = new A
a: A = A@19dac2d6

scala> a.originalValue = 1
a.originalValue: Int = 1

scala> a.originalValue
res0: Int = 1

scala> a.tenfoldValue
res1: Int = 10

scala> a.tenfoldValue = 1000
a.tenfoldValue: Int = 1000

scala> a.originalValue
res2: Int = 100

二、类型构造器
scala> abstract class A[T] {
         |     val a: T
         |  }
defined class A 

  像上述例子所示的“A”是一个类,但它不是一个类型,因为它接收一个类型参数。A也被称为“类型构造器”,因为它可以接收一个类型参数来构造一个类型,就像普通类的构造方法接收值参数构造实例对象一样。比如A[Int]是一种类型,A[String]是另一种类型,等等。也可以说A是一个泛型的类。在指明类型时,不能像普通类那样只写一个类名,而必须在方括号里给出具体的类型参数。例如:

scala> def doesNotCompile(x: A) = {}
<console>:12: error: class A takes type parameters
       def doesNotCompile(x: A) = {}
                             ^

scala> def doesCompile(x: A[AnyRef]) = {}
doesCompile: (x: A[AnyRef])Unit

  除了泛型的类和特质需要在名字后面加上方括号和类型参数,如果某个成员方法的参数也是泛型的,那么方法名后面也必须加上方括号和类型参数。字段则不需要,只要直接用类型参数指明类型即可。

三、型变注解
  像A[T]这样的类型构造器,它们的类型参数T可以是协变的、逆变的或者不变的,这被称为类型参数的“型变”。“A[+T]”表示类A在类型参数T上是协变的,“A[-T]”表示类A在类型参数T上是逆变的。其中,类型参数的前缀“+”和“-”被称为型变注解,没有就是不变的。

  如果类型S是类型T的子类型,那么协变表示A[S]也是A[T]的子类型,而逆变表示A[T]反而是A[S]的子类型,不变则表示A[S]和A[T]是两种没有任何关系的不同类型。

四、检查型变注解
  标注了型变注解的类型参数不能随意使用,类型系统设计要满足“里氏替换原则”:在任何需要类型为T的对象的地方,都能用类型为T的子类型的对象替换。里氏替换原则的依据是子类型多态。类型为超类的变量是可以指向类型为子类的对象,因为子类继承了超类所有非私有成员,能在超类中使用的成员,一般在子类中均可用。有关里氏替换原则的详细解释,这里不再展开。

  假设类型T是类型S的超类,如果类型参数是协变的,导致A[T]也是A[S]的超类,那么“val a: A[T] = new A[S]”就合法。此时,如果类A内部的某个方法funcA的入参的类型也是这个协变类型参数,那么方法调用“a.funcA(b: T)”就会出错,因为a实际指向的是一个子类对象,子类对象的方法funcA接收的入参的类型是S,而子类S不能指向超类T,所以传入的b不能被接收。但是a的类型是A[T]又隐式地告诉使用者,可以传入类型是T的参数,这就产生了矛盾。相反,funcA的返回类型是协变类型参数就没有问题,因为子类对象的funcA的返回值的类型虽然是S,但是能被T类型的变量接收,即“val c: T = a.funcA()”合法。a的类型A[T]隐式地告诉使用者应该用T类型的变量接收返回值,虽然实际返回的值是S类型,但是子类型多态允许这样做。也就是说,要保证不出错,生产者产生的值的类型应该是子类,消费者接收的值的类型应该是超类(接收者本来只希望使用超类的成员,但是实际给出的子类统统都具备,接收者也不会去使用多出来的成员,所以子类型多态才正确)。基于此,方法的入参的类型应该是逆变类型参数,逆变使得“val a: A[S] = new A[T]”合法,也就是实际引用的对象的方法想要一个T类型的参数,但传入了子类型S的值,符合里氏替换原则。同理,方法的返回类型应该是协变的。

  既然类型参数的使用有限制,那么就应该有一个规则来判断该使用什么类型参数。Scala的编译器把类或特质中任何出现类型参数的地方都当作一个“点”,点有协变点、逆变点和不变点之分,以声明类型参数的类和特质作为顶层开始,逐步往内层深入,对这些点进行归类。在顶层的点都是协变点,例如顶层的方法的返回类型就在协变点。默认情况下,在更深一层的嵌套的点与在包含嵌套的外一层的点被归为一类。该规则有一些例外:①方法的值参数所在的点会根据方法外的点进行一次翻转,也就是把协变点翻转成逆变点、逆变点翻转成协变点、不变点仍然保持不变。②方法的类型参数(即方法名后面的方括号)也会根据方法外的点进行一次翻转。③如果类型也是一个类型构造器,比如以C[T]为类型,那么,当T有“-”注解时就根据外层进行翻转,有“+”注解时就保持与外层一致,否则就变成不变点。

  协变点只能用“+”注解的类型参数,逆变点只能用“-”注解的类型参数。没有型变注解的类型参数可以用在任何点,也是唯一一种能用在不变点的类型参数。所以对于类型Q[+U, -T, V]而言,U处在协变点,T处在逆变点,而V处在不变点。

  以如下例子为例进行解释:

abstract class Cat[-T, +U] {
    def meow[Wˉ](volume: Tˉ, listener: Cat[U+, Tˉ]ˉ): Cat[Cat[U+, Tˉ]ˉ, U+]+
}

  右上角的正号表示协变点,负号表示逆变点。首先,Cat类声明了类型参数,所以它是顶层。方法meow的返回值属于顶层的点,所以返回类型的最右边是正号,表示协变点。因为方法的返回类型也是类型构造器Cat,并且第一个类型参数是逆变的,所以这里相对协变翻转成了逆变,而第二个类型参数是协变的,所以保持协变属性不变。继续往里归类,返回类型嵌套的Cat处在逆变点,所以第一个类型参数的位置相对逆变翻转成协变,第二个类型参数的位置保持逆变属性不变。两个值参数volume和listener都相对协变翻转成了逆变点,并且listener的类型是Cat,所以和返回类型嵌套的Cat一样。方法的类型参数W,也相对协变翻转成了逆变点。

  虽然型变注解的检查很麻烦,但这些工作都被编译器自动完成了。编译器的检查方法也很直接,就是查看顶层声明的类型参数是否出现在正确的位置。比如,上例中,T都出现在逆变点,U都出现在协变点,所以可以通过检查。至于W是什么,则不关心。

五、类型构造器的继承关系
  因为类型构造器需要根据类型参数来确定最终的类型,所以在判断多个类型构造器之间的继承关系时,也必须依赖类型参数。对于只含单个类型参数的类型构造器而言,继承关系很好判断,只需要看型变注解是协变、逆变还是不变。当类型参数不止一个时,该如何判断呢?尤其是函数的参数是一个函数时,更需要确定一个函数的子类型是什么样的函数。

  以常用的单参数函数为例,其特质Function1的部分定义如下:

trait Function1[-S, +T] {
  def apply(x: S): T
}

  类型参数S代表函数的入参的类型,很显然应该是逆变的。类型参数T代表函数返回值的类型,所以是协变的。

  假设类A是类a的超类,类B是类b的超类,并且定义了一个函数的类型为Function1[a, B]。那么,这个函数的子类型应该是Function1[A, b]。解释如下:假设在需要类型为Function1[a, B]的函数的地方,实际用类型为Function1[A, b]的函数代替了。那么,本来会给函数传入a类型的参数,但实际函数需要A类型的参数,由于类A是类a的超类,这符合里氏替换原则;本来会用类型为B的变量接收函数的返回值,但实际函数返回了b类型的值,由于类B是类b的超类,这也符合里氏替换原则。综上所述,用Function1[A, b]代替Function1[a, B]符合里氏替换原则,所以Function1[A, b]是Function1[a, B]的子类型。

  因此,对于含有多个类型参数的类型构造器,要构造子类型,就是把逆变类型参数由子类替换成超类、把协变类型参数由超类替换成子类。

六、上界和下界
  对于类型构造器A[+T],倘若没有别的手段,很显然它的方法的参数不能泛化,因为协变的类型参数不能用作函数的入参类型。如果要泛化参数,必须借助额外的类型参数,那么这个类型参数该怎么定义呢?因为可能存在“val x: A[超类] = new A[子类]”这样的定义,导致方法的入参类型会是T的超类,所以,额外的类型参数必须是T的超类。Scala提供了一个语法——下界,其形式为“U >: T”,表示U必须是T的超类,或者是T本身(一个类型既是它自身的超类,也是它自身的子类)。

  通过使用下界标定一个新的类型参数,就可以在A[+T]这样的类型构造器里泛化方法的入参类型。例如:

scala> abstract class A[+T] {
         |     def funcA[U >: T](x: U): U
         |  }
defined class A

  现在,编译器不会报错,因为下界的存在,导致编译器预期参数x的类型是T的超类。实际运行时,会根据传入的实际入参确定U是什么。返回类型定义成了U,当然也可以是T,但是动态地根据U来调整类型显得更自然。

  与下界对应的是上界,其形式为“U <: T”,表示U必须是T的子类或本身。通过上界,就能在A[-T]这样的类型构造器里泛化方法的返回类型。例如:

scala> abstract class A[-T] {
         |     def funcA[U <: T](x: U): U
         |  }
defined class A

  注意,编写上、下界时,不能写错类型的位置和开口符号。

七、方法的类型参数
  除了类和特质能一开始声明类型参数外,方法也可以带有类型参数。如果方法仅仅使用了包含它的类或特质已声明的类型参数,那么方法自己就没必要写出类型参数。如果出现了包含它的类或特质未声明的类型参数,则必须写在方法的类型参数里。注意,方法的类型参数不能有型变注解。例如:

scala> abstract class A[-T] {
         |     def funcA(x: T): Unit
         |  }
defined class A

scala> abstract class A[-T] {
         |     def funcA(x: T, y: U): Unit
         |  }
<console>:12: error: not found: type U
         def funcA(x: T, y: U): Unit
                            ^

scala> abstract class A[-T] {
         |     def funcA[U](x: T, y: U): Unit
         |  }
defined class A

  方法的类型参数不能与包含它的类和特质已声明的类型参数一样,否则会把它们覆盖掉。例如:

 scala> class A[-T] {
         |    def funcA[T](x: T) = x.getClass
         |  }
defined class A

scala> val a = new A[Int]
a: A[Int] = A@3217aada

scala> a.funcA("Hello")
res0: Class[_ <: String] = class java.lang.String

八、对象私有数据
  var类型的字段,其类型参数不能是协变的,因为隐式的setter方法需要一个入参,这就把协变类型参数用作入参。其类型参数也不能是逆变的,因为隐式的getter方法的返回类型就是字段的类型。例如:

scala> class A[-T] {
         |    var a: T = _
         |  }
<console>:12: error: contravariant type T occurs in covariant position in type => T of variable a
         var a: T = _
             ^

scala> class A[+T] {
         |    var a: T = _
         |  }
<console>:12: error: covariant type T occurs in contravariant position in type T of value a_=
         var a: T = _
             ^

  但是也有例外,如果var字段是对象私有的,即用private[this]修饰,那么它只能在定义该类或特质时被访问。由于外部无法直接访问,也就不可能在运行时违背里氏替换原则。因此隐式的getter和setter方法可以忽略对型变注解的检查。如果想在内部自定义getter或setter方法来产生一个错误,假设当前类型参数T是协变的,尽管可以通过下界来避免setter方法的型变注解错误,但是赋值操作又会发生类型匹配错误。连类型检查都无法通过,更不可能在运行时发生错误。同样,逆变类型参数也是如此。例如:

scala> class A[+T] {
         |    private[this] var a: T = _
         |  }
defined class A

scala> class A[+T] {
         |    private[this] var a: T = _
         |   def set[U >: T](x: U) = a = x
         |  }
<console>:14: error: type mismatch;
 found    : x.type (with underlying type U)
 required: T
         def set[U >: T](x: U) = a = x
                                      ^

scala> class A[-T] {
         |    private[this] var a: T = _
         |  } 
defined class A

scala> class A[-T] {
         |    private[this] var a: T = _
         |    def get[U <: T](): U = a
         |  }
<console>:14: error: type mismatch;
 found    : T
 required: U
         def get[U <: T](): U = a
                                ^

   所以,Scala的编译器会忽略对private[this] var类型的字段的检查。

九、总结
  本章的内容也是比较抽象、难理解。其应用在于阅读Chisel的源码,理解语言的工作机制,读懂API。如果是实际编写硬件电路,也是用不到这些语法的。

第十四章 Scala进阶——抽象成员

一、抽象成员
  类可以用“abstract”修饰变成抽象的,特质天生就是抽象的,所以抽象类和特质里可以包含抽象成员,也就是没有完整定义的成员。Scala有四种抽象成员:抽象val字段、抽象var字段、抽象方法和抽象类型,它们的声明形式如下:

trait Abstract {
  type T                          // 抽象类型
  def transform(x: T): T  // 抽象方法
  val initial: T                  // 抽象val字段
  var current: T              // 抽象var字段
}

  因为定义不充分,存在不可初始化的字段和类型,或者没有函数体的方法,所以抽象类和特质不能直接用new构造实例。抽象成员的本意,就是让更具体的子类或子对象来实现它们。例如:

class Concrete extends Abstract {
  type T = String
  def transform(x: String) = x + x
  val initial = "hi"
  var current = initial
}

  抽象类型指的是用type关键字声明的一种类型——它是某个类或特质的成员但并未给出定义。虽然类和特质都定义了一种类型,并且它们可以是抽象的,但这不意味着抽象类或特质就叫抽象类型,抽象类型永远都是类和特质的成员。在使用抽象类型进行定义的地方,最后都要被解读成抽象类型的具体定义。而使用抽象类型的原因,一是给名字冗长或含义不明的类型起一个别名,二是声明子类必须实现的抽象类型。

  在不知道某个字段正确的值,但是明确地知道在当前类的每个实例中,该字段都会有一个不可变更的值时,就可以使用抽象val字段。抽象val字段与抽象无参方法类似,而且访问方式完全一样。但是,抽象val字段保证每次使用时都返回一个相同的值,而抽象方法的具体实现可能每次都返回不同的值。另外,抽象val字段只能实现成具体的val字段,不能改成var字段或无参方法;而抽象无参方法可以实现成具体的无参方法,也可以是val字段。

  抽象var字段与抽象val字段类似,但是是一个可被重新赋值的字段。与前一章讲解的具体var字段类似,抽象var字段会被编译器隐式地展开成抽象setter和抽象getter方法,但是不会在当前抽象类或特质中生成一个“private[this] var”字段。这个字段会在定义了其具体实现的子类或子对象当中生成。例如:

trait AbstractTime {
  var hour: Int
  var minute: Int
}
// 相当于
trait AbstractTime {
  def hour: Int             // hour的getter方法
  def hour_=(x: Int)    // hour的setter方法
  def minute: Int         // minute的getter方法
  def minute_=(x: Int) // minute的setter方法
}

二、初始化抽象val字段
  抽象val字段有时会承担超类参数的职能:它们允许程序员在子类中提供那些在超类中缺失的细节。这对特质尤其重要,因为特质没有构造方法,参数化通常都是通过子类实现抽象val字段来完成。例如:

trait RationalTrait {
  val numerArg: Int
  val denomArg: Int
}

  要在具体的类中混入这个特质,就必须实现它的两个抽象val字段。例如:

new RationalTrait {
    val numerArg = 1
    val denomArg = 2
}

  注意,前面说过,这不是直接实例化特质,而是隐式地用一个匿名类混入了该特质,并且花括号里的内容属于隐式的匿名类。

  构造子类的实例对象时,首先构造超类/超特质的组件,然后才轮到子类的剩余组件。因为花括号里的内容不属于超类/超特质,所以在构造超类/超特质的组件时,花括号里的内容其实是无用的。并且在这个过程中,如果需要访问超类/超特质的抽象val字段,会交出相应类型的默认值(比如Int类型的默认值是0),而不是花括号里的定义。只有轮到构造子类的剩余组件时,花括号里的子类定义才会派上用场。所以,在构造超类/超特质的组件时,尤其是特质还不能接收子类的参数,如果默认值不满足某些要求,构造就会出错。例如:

scala> trait RationalTrait {
         |    val numerArg: Int
         |    val denomArg: Int
         |    require(denomArg != 0)
         |  }
defined trait RationalTrait

scala> new RationalTrait {
         |      val numerArg = 1
         |      val denomArg = 2
         |  }
java.lang.IllegalArgumentException: requirement failed
  at scala.Predef$.require(Predef.scala:264)
  at RationalTrait.$init$(<console>:14)
  ... 32 elided

  在这个例子中,require函数会在参数为false时报错。该特质是用默认值0去初始化两个抽象字段的,花括号里的定义只有等超特质构造完成才有用,所以require函数无法通过。为此,Scala提供了两种方法解决这种问题。

   Ⅰ、预初始化字段
  如果能让花括号里的代码在最开始执行,那么就能避免该问题,这个方法被称作“预初始化字段”。其形式为:

  new { 定义 } with 超类/超特质 

  例如:

scala> new {
         |      val numerArg = 1
         |      val denomArg = 2
         |  } with RationalTrait
res0: RationalTrait = $anon$1@1a01ffff 

  除了匿名类可以这样使用,单例对象或具名子类也可以,其形式是把花括号里的代码与单例对象名或类名用extends隔开,最后用with连接想要继承的类或混入的特质。例如: 

scala> class RationalClass(n: Int, d: Int) extends RationalTrait {
         |    val numerArg = n
         |    val denomArg = d
         |  }
defined class RationalClass

scala> new RationalClass(1, 2)
java.lang.IllegalArgumentException: requirement failed
  at scala.Predef$.require(Predef.scala:264)
  at RationalTrait.$init$(<console>:14)
  ... 29 elided

scala> class RationalClass(n: Int, d: Int) extends {
         |    val numerArg = n
         |    val denomArg = d
         |  } with RationalTrait
defined class RationalClass

scala> new RationalClass(1, 2)
res1: RationalClass = RationalClass@6f26e775

  这个语法有一个瑕疵,就是由于预初始化字段发生得比构造超类/超特质更早,导致预初始化字段时实例对象其实还未被构造,所以花括号里的代码不能通过“this”来引用正在构造的对象本身。如果代码里出现了this,那么这个引用将指向包含当前被构造的类或对象的对象,而不是被构造的对象本身。例如:

scala> new {
         |      val numerArg = 1
         |      val denomArg = this.numerArg * 2
         |  } with RationalTrait
<console>:15: error: value numerArg is not a member of object $iw
         val denomArg = this.numerArg * 2
                             ^ 

  这个代码无法通过编译,因为this指向了包含用new构造的对象的那个对象,在本例中是名为“$iw”的合成对象,该合成对象是Scala的编译器用于存放用户输入的代码的地方。由于$iw没有叫numerArg的成员,所以编译器产生了错误。

   Ⅱ、惰性的val字段
  预初始化字段是人为地调整初始化顺序,而把val字段定义成惰性的,则可以让程序自己确定初始化顺序。如果在val字段前面加上关键字“lazy”,那么该字段只有首次被使用时才会进行初始化。如果是用表达式进行初始化,那就对表达式求值并保存,后续使用字段时都是复用保存的结果而不是每次都求值表达式。例如:

scala> trait LazyRationalTrait {
         |    val numerArg: Int
         |    val denomArg: Int
         |    lazy val numer = numerArg / g
         |    lazy val denom = denomArg / g
         |    override def toString = numer + "/" + denom
         |    private lazy val g = {
         |       require(denomArg != 0)
         |       gcd(numerArg, denomArg)
         |    }
         |    private def gcd(a: Int, b: Int): Int =
         |       if (b == 0) a else gcd(b, a % b)
         |  }
defined trait LazyRationalTrait

scala> val x = 2
x: Int = 2

scala> new LazyRationalTrait {
         |      val numerArg = 1 * x
         |      val denomArg = 2 * x
         |  }
res0: LazyRationalTrait = 1/2

  首先仍然是先构造超特质的组件,但是需要初始化的非抽象字段都被lazy修饰,所以没有执行任何操作。并且由于require函数在字段g内部,而g没有初始化,所以不会出错。然后开始构造子类的组件,先对1 * x和2 * x两个表达式进行求值,得到2和4后把两个抽象字段初始化了。最后,解释器需要调用toString方法进行信息输出,该方法要访问numer,此时才对numer右侧的初始化表达式进行求值,且numerArg已经初始化为2;在numer初始化时要访问g,所以才对g进行初始化,但denomArg已满足require的要求,求得g为2并保存;等到toString方法要访问denom时,才初始化denom,并且g不用再次求值。至此,对象构造完成。 

三、抽象类型
  假设要编写一个Food类,用各种子类来表示各种食物。要编写一个抽象的Animal类,有一个eat方法,接收Food类型的参数。那么可能会写成如下形式:

scala> class Food
defined class Food

scala> abstract class Animal {
         |     def eat(food: Food)
         |  }
defined class Animal

   如果用不同的Animal子类来代表不同的动物,并且食物类型也会根据动物的习性发生改变。比如定义一头吃草的牛,那么可能定义如下:

scala> class Grass extends Food
defined class Grass

scala> class Cow extends Animal {
         |    override def eat(food: Grass) = {} 
         |  }
<console>:13: error: class Cow needs to be abstract, since method eat in class Animal of type (food: Food)Unit is not defined
(Note that Food does not match Grass: class Grass is a subclass of class Food, but method parameter types must match exactly.)
       class Cow extends Animal {
             ^
<console>:14: error: method eat overrides nothing.
Note: the super classes of class Cow contain the following, non final members named eat:
def eat(food: Food): Unit
       override def eat(food: Grass) = {}
                    ^

  奇怪的是,编译器并不允许这么做。问题出在“override def eat(food: Grass) = {}”这句代码并不会被编译。实现超类的抽象方法其实相当于重写,但是重写要保证参数列表完全一致,否则就是函数重载。在这里,超类的方法eat的参数类型是Food,但是子类的版本改成了Grass。Scala的编译器执行严格的类型检查,尽管Grass是Food的子类,但是出现在函数的参数类型上,并不能简单地套用子类型多态,就认为Grass等效于Food。所以,错误信息显示Cow类一是没有实现Animal类的抽象eat方法,二是Cow类的eat方法并未重写任何东西。

  如果有读者认为这种规则过于严厉,应该放松,那么就会出现如下不符合常识的情况:

class Fish extends Food

val bessy: Animal = new Cow

bessy eat (new Fish)

  假设编译器放开对eat方法的参数类型的限制,使得任何Food类型都能通过编译,那么Fish类作为Food的子类,也就能被Cow类的eat方法所接受。但是,给一头牛喂鱼,而不是吃草,显然与事实不符。

  要达到上述目的,就需要更精确的编程模型。一种办法就是借助抽象类型及上界,例如:

scala> class Food
defined class Food

scala> abstract class Animal {
         |     type SuitableFood <: Food
         |     def eat(food: SuitableFood)
         |  }
defined class Animal 

  在这里,引入了一个抽象类型。由于方法eat的参数设定为抽象类型,在编译时会被解读成具体的SuitableFood实现,所以不同的Animal子类可以通过更改具体的SuitableFood来达到改变食物类型的目的,并且这符合严格的规则检查。其次,上界保证了在子类实现SuitableFood时,必须是Food的某个子类,即不会喂给动物吃非食物类的东西。此时的Cow类如下所示:

scala> class Grass extends Food
defined class Grass

scala> class Cow extends Animal {
         |    type SuitableFood = Grass
         |    override def eat(food: Grass) = {}
         |  }
defined class Cow

  如果现在给吃草的牛喂一条鱼,那么就会发生类型错误:

scala> class Fish extends Food
defined class Fish

scala> val bessy: Animal = new Cow
bessy: Animal = Cow@2442f36d

scala> bessy eat (new Fish)
<console>:14: error: type mismatch;
 found   : Fish
 required: bessy.SuitableFood
       bessy eat (new Fish)
                  ^ 

四、路径依赖类型
  在前面给牛喂鱼的例子中,可以发现错误信息里有一个有趣的现象:方法eat要求的参数类型是bessy.SuitableFood。关于类型“bessy.SuitableFood”,比普通的类型描述多了一个对象。这说明类型可以是对象的成员,bessy.SuitableFood表示SuitableFood是由bessy引用的对象的成员,或者说bessy引用对象的专属食物。像这样的类型称为路径依赖类型,尽管最后的类型是相同的,但若是前面的路径不同,那就是不同的类型。“路径”就是指对象的引用,它可以是单名,也可以是更长的路径。

  比如,狗吃狗粮,一条狗能吃另一条狗的狗粮,但牛怎么都不能吃狗粮:

scala> class DogFood extends Food
defined class DogFood

scala> class Dog extends Animal {
         |    type SuitableFood = DogFood
         |    override def eat(food: DogFood) = {}
         |  }
defined class Dog

scala> val lassie = new Dog
lassie: Dog = Dog@2655ad3b

scala> val bessy = new Cow
bessy: Cow = Cow@663e2cfd

scala> lassie eat (new bessy.SuitableFood)
<console>:14: error: type mismatch;
 found   : Grass
 required: DogFood
       lassie eat (new bessy.SuitableFood)
                   ^

scala> val bootsie = new Dog
bootsie: Dog = Dog@456454e0

scala> lassie eat (new bootsie.SuitableFood)

scala>

  因为bessy.SuitableFood和lassie.SuitableFood的路径不同,所以它们是不同的类型。而lassie.SuitableFood和bootsie.SuitableFood尽管有不同的路径,似乎是不同的类型,但其实这两个都是实际类型DogFood的别名,所以实质上是同一个类型。

  Scala的“路径依赖类型”很像Java的“内部类类型”,但是两者有重要区别:路径依赖类型的路径表明了外部类的对象,而内部类类型仅表明了外部类。

  Scala也可以表示Java的内部类,但是语法稍有不同。Scala定义一个内部类只需这样写:

class Outer {
  class Inner
}

  内部类Inner可以通过“Outer#Inner”来寻址,而不是Java的“Outer.Inner”,因为Scala把句点符号作为对象访问成员的专属符号,而类访问成员则是通过井号。比如有如下两个对象:

val o1 = new Outer
val o2 = new Outer

  那么,o1.Inner和o2.Inner就是两个路径依赖类型,并且是两个不同的类型。这两个路径依赖类型都是Outer#Inner的子类型,因为Outer#Inner其实是用任意的Outer对象来表示Inner类型。相比之下,o1.Inner是通过一个被o1引用的具体对象来表示的类型。o2.Inner也是如此。

  与Java一样,Scala的内部类的实例持有包含它的外部类的实例的引用,这使得内部类可以访问包含它的外部类的成员。也正因此,在没有给出某个外部类的具体实例时,不能直接实例化内部类,因为光有内部类实例,没有相应的外部类实例,就无法访问外部类实例的成员。有两种途径实例化内部类:一是在外部类的花括号内部通过“this.Inner”来实例化,让this引用正在构造的外部类实例;二是给出具体的外部类实例,比如o1.Inner,就可以通过“new o1.Inner”来实例化。例如:

scala> val i1 = new o1.Inner
i1: o1.Inner = Outer$Inner@5464a18

   Outer#Inner是不能直接实例化的,因为没有具体的外部类实例:

scala> val i2 = new Outer#Inner
<console>:12: error: Outer is not a legal prefix for a constructor
       val i2 = new Outer#Inner
                          ^

五、细化类型
  当一个类继承自另一个类时,就称前者是后者的名义子类型。Scala还有一个结构子类型,表示两个类型只是有某些兼容的成员,而不是常规的那样继承来的关系。结构子类型通过细化类型来表示。

比如,要做一个食草动物的集合。一种方法是定义一个食草的特质,让所有的食草动物类都混入该特质。但是这样会让食草动物与最基本的动物的关系不那么紧密。如果按前面定义食草牛那样继承自Animal类,那么食草动物集合的元素类型就可以表示为Animal类型,但这样又可能把食肉动物或杂食动物也包含进集合。此时,就可以使用结构子类型,其形式如下:

Animal { type SuitableFood = Grass } 

  最前面是基类Animal的声明,花括号里是想要兼容的成员。这个成员声明得比基类Animal更具体、更精细,表示食物类型必须是草。当然,并不一定要更加具体。那么,用这样一个类型指明集合元素得类型,就可以只包含食草动物了:

val animals: List[Animal { type SuitableFood = Grass }] = ??? 

六、Scala的枚举
  Scala没有特定的语法表示枚举,而是在标准类库中提供一个枚举类——scala.Enumeration。通过创建一个继承自这个类的子对象可以创建枚举。例如: 

scala> object Color extends Enumeration {
         |     val Red, Green, Blue = Value
         |  }
defined object Color

  对象Color和普通的单例对象一样,可以通过“Color.Red”这样的方式来访问成员,或者先用“import Color._”导入。

  Enumeration类定义了一个名为Value的内部类,以及同名的无参方法。该方法每次都返回内部类Value的全新实例,也就是说,枚举对象Color的三个枚举值都分别引用了一个Value类型的实例对象。并且,因为Value是内部类,所以它的对象的具体类型还与外部类的实例对象有关。在这里,外部类的对象就是自定义的Color,所以三个枚举值引用的对象的真正类型应该是Color.Value。

  假如还有别的枚举对象,例如:

scala> object Direction extends Enumeration {
         |     val North, East, South, West = Value
         |  }
defined object Direction

  根据路径依赖类型的规则,Color.Value和Direction.Value是两个不同类型,所以两个枚举对象分别创造了两种不同类型的枚举值。

  方法Value有一个重载的版本,可以接收一个字符串参数来给枚举值关联特定的名称。例如:

scala> object Direction extends Enumeration {
         |     val North = Value("N")
         |     val East = Value("E")
         |     val South = Value("S")
         |     val West = Value("W")
         |  }
defined object Direction 

  方法values返回枚举值的名称的集合。优先给出特定名称,否则就给字段名称。例如:

 scala> Color.values
res0: Color.ValueSet = Color.ValueSet(Red, Green, Blue)

scala> Direction.values
res1: Direction.ValueSet = Direction.ValueSet(N, E, S, W)

  枚举值从0开始编号。内部类Value有一个方法id返回相应的编号,也可以通过“对象名(编号)”来返回相应的枚举值的名称。例如:

scala> Color.Red.id
res2: Int = 0

scala> Color(2)
res3: Color.Value = Blue

scala> Color(3)
java.util.NoSuchElementException: key not found: 3
  at scala.collection.MapLike.default(MapLike.scala:231)
  at scala.collection.MapLike.default$(MapLike.scala:230)
  at scala.collection.AbstractMap.default(Map.scala:59)
  at scala.collection.mutable.HashMap.apply(HashMap.scala:65)
  at scala.Enumeration.apply(Enumeration.scala:142)
  ... 28 elided

scala> Direction.North.id
res4: Int = 0

scala> Direction(0)
res5: Direction.Value = N

七、总结
  对于本章内容不感兴趣或理解不深的读者,完全可以跳过,因为这些内容也仅仅是帮助理解Chisel标准库的工作机制。实际的电路不可能会有这样的抽象成员。

第十五章 Scala进阶——隐式转换与隐式参数

  考虑如下场景:假设编写了一个向量类MyVector,并且包含了一些向量的基本操作。因为向量可以与标量做数乘运算,所以需要一个计算数乘的方法“ * ”,它应该接收一个类型为基本值类的参数。在向量对象myVec调用该方法时,可以写成诸如“myVec * 2”的形式。在数学上,反过来写“2 * myVec”也是可行的,但是在程序里却行不通。因为操作符的左边是调用对象,反过来写就表示Int对象“2”是方法的调用者,但是Int类里并没有这种方法。

  为了解决上述问题,所有的oop语言都会有相应的策略,比如笔者熟悉的C++是通过友元的方式来解决。Scala则是采取名为“隐式转换”的策略,也就是把本来属于Int类的对象“2”转换类型,变成MyVector类的对象,这样它就能使用数乘方法。隐式转换属于隐式定义的一种,隐式定义就是那些程序员事先写好的定义,然后允许编译器隐式地插入这些定义来解决类型错误。因为这部分定义通常对使用者不可见,并且由编译器自动调用,故而得名“隐式定义”。

一、隐式定义的规则
  Scala对隐式定义有如下约束规则:

  ①标记规则。只有用关键字“implicit”标记的定义才能被编译器隐式使用,任何函数、变量或单例对象都可以被标记。其中,标记为隐式的变量和单例对象常用作隐式参数,隐式的函数常用于隐式转换。比如,代码“x + y”因为调用对象x的类型错误而不能通过编译,那么编译器会尝试把代码改成“convert(x) + y”,其中convert是某种可用的隐式转换。如果convert能将x改成某种支持“+”方法的对象,那么这段代码就可能通过类型检查。

  ②作用域规则。Scala编译器只会考虑在当前作用域内的隐式定义,否则,所有隐式定义都是全局可见的将会使得程序异常复杂甚至出错。隐式定义在当前作用域必须是“单个标识符”,即编译器不会展开成“A.convert(x) + y”的形式。如果想用A.convert,那么必须先用“import A.convert”导入才行,然后被展开成“convert(x) + y”的形式。单个标识符规则有一个例外,就是编译器会在与隐式转换相关的源类型和目标类型的伴生对象里查找隐式定义。因此,常在伴生对象中定义隐式转换,而不用在需要时显式导入。

  ③每次一个规则。编译器只会插入一个隐式定义,不会出现“convert1(convert2(x)) + y”这种嵌套的形式,但是可以让隐式定义包含隐式参数来绕开这个限制。

  ④显式优先原则。如果显式定义能通过类型检查,就不必进行隐式转换。因此,总是可以把隐式定义变成显式的,这样代码变长但是歧义变少。用显式还是隐式,需要取舍。

  此外,隐式转换可以用任意合法的标识符来命名。有了名字后,一是可以显式地把隐式转换函数写出来,二是明确地导入具体的隐式转换而不是导入所有的隐式定义。

  Scala只会在三个地方使用隐式定义:转换到一个预期的类型,转换某个选择接收端(即调用方法或字段的对象),隐式参数。

二、隐式地转换到期望类型
  Scala的编译器对于类型检查比较严格,比如把一个浮点数赋值给整数变量,通常情况下人们可能希望通过截断小数部分来完成赋值,但是Scala在默认情况下是不允许这种丢失精度的转换的,这会造成类型匹配错误。例如:

scala> val i: Int = 1.5
<console>:11: error: type mismatch;
 found   : Double(1.5)
 required: Int
       val i: Int = 1.5
                    ^

  但是用户可能并不关心精度问题,确实需要这样一种赋值操作,那么就可以通过定义一个隐式转换来完成。例如:

scala> import scala.language.implicitConversions
import scala.language.implicitConversions

scala> implicit def doubleToInt(x: Double) = x.toInt
doubleToInt: (x: Double)Int

scala> val i: Int = 1.5
i: Int = 1 

  此时再进行之前的赋值,就会正确地截断小数部分。当然,隐式转换也可以显式地调用:

scala> val i: Int = doubleToInt(2.33)
i: Int = 2 

  第七章讲解类继承时,最后提到了Scala的全局类层次,其中就有七种基本值类的转换,比如Int可以赋值给Double。这其实也是隐式转换在起作用,只是这个隐式转换定义在scala包里的单例对象Predef里。因为所有的Scala文件都会被编译器隐式地在开头按顺序插入“import java.lang._”、“import scala._”、“import Predef._”三条语句,所以标准库里的隐式转换会以不被察觉的方式工作。

三、隐式地转换接收端
  接收端就是指调用方法或字段的那个对象,也就是调用对象在非法的情况下,被隐式转换变成了合法的对象,这是隐式转换最常用的地方。例如:

scala> class MyInt(val i: Int)
defined class MyInt

scala> 1.i
<console>:12: error: value i is not a member of Int
       1.i
         ^

scala> implicit def intToMy(x: Int) = new MyInt(x)
intToMy: (x: Int)MyInt

scala> 1.i
res0: Int = 1

  在上个例子中,标准值类Int是没有叫“i”的字段的,在定义隐式转换前,“1.i”是非法的。有了隐式转换后,把一个Int对象作为参数构造了一个新的MyInt对象,而MyInt对象就有字段i。所以“1.i”被编译器隐式地展开成了“intToMy(1).i”。这就使得已有类型可以通过“自然”的方式与新类型进行互动。

  此外,隐式转换的这个作用还经常被用于模拟新的语法,尤其是在构建DSL语言时用到。因为DSL语言含有大量的自定义类型,这些自定义类型可能要频繁地与已有类型交互,有了隐式转换之后就能让代码的语法更加自然。比如Chisel就是这样的DSL语言,如果读者仔细研究Chisel的源码,就会发现大量的隐式定义。

  前面说过,映射的键-值对语法“键 -> 值”其实是一个对偶“(键, 值)”。这并不是什么高深的技巧,就是隐式转换在起作用。Scala仍然是在Predef这个单例对象里定义了一个箭头关联类ArrowAssoc,该类有一个方法“->”,接收一个任意类型的参数,把调用对象和参数构成一个二元组来返回。同时,单例对象里还有一个隐式转换any2ArrowAssoc,该转换也接收一个任意类型的参数,用这个参数构造一个ArrowAssoc类的实例对象。所以,“键 -> 值”会被编译器隐式地展开成“any2ArrowAssoc(键).->(值)”。因此,严格来讲没有“键 -> 值”这个语法,只不过是用隐式转换模拟出来的罢了。

四、隐式类
  隐式类是一个以关键字“implicit”开头的类,用于简化富包装类的编写。它不能是样例类,并且主构造方法有且仅有一个参数。此外,隐式类只能位于某个单例对象、类或特质里,不能单独出现在顶层。隐式类的特点就是让编译器在相同层次下自动生成一个与类名相同的隐式转换,该转换接收一个与隐式类的主构造方法相同的参数,并用这个参数构造一个隐式类的实例对象来返回。例如:

// test.scala
case class Rectangle(width: Int, height: Int)

object Rec {
implicit class RectangleMaker(width: Int) {
def x(height: Int) = Rectangle(width, height)
}
// 自动生成的
// implicit def RectangleMaker(width: Int) = new RectangleMaker(width)
}
将该文件编译后,就可以在解释器里用“import Rec._”或“import Rec.RectangleMaker”来引入这个隐式转换,然后用“1 x 10”这样的语句来构造一个长方形。实际上,Int类并不存在方法“x”,但是隐式转换把Int对象转换成一个RectangleMaker类的对象,转换后的对象有一个构造Rectangle的方法“x”。例如:

scala> 1 x 10
<console>:12: error: value x is not a member of Int
       1 x 10
         ^

scala> import Rec.RectangleMaker
import Rec.RectangleMaker

scala> 1 x 10
res0: Rectangle = Rectangle(1,10)

  隐式类需要单参数主构造方法的原因很简单,因为用于转换的调用对象只有一个,并且自动生成的隐式转换不会去调用辅助构造方法。隐式类不能出现在顶层是因为自动生成的隐式转换与隐式类在同一级,如果不用导入就能直接使用,那么顶层大量的隐式类就会使得代码变得复杂且容易出错。

五、隐式参数
  函数最后一个参数列表可以用关键字“implicit”声明为隐式的,这样整个参数列表的参数都是隐式参数。注意,是整个参数列表,即使括号里有多个参数,也只需要开头写一个“implicit”。而且每个参数都是隐式的,不存在部分隐式部分显式。

  当调用函数时,若缺省了隐式参数列表,则编译器会尝试插入相应的隐式定义。当然,也可以显式给出参数,但是要么全部缺省,要么全部显式给出,不能只写一部分。

  要让编译器隐式插入参数,就必须事先定义好符合预期类型的隐式变量(val和var可以混用,关键在于类型)、隐式单例对象或隐式函数(别忘了函数也能作为函数的参数进行传递),这些隐式定义也必须用“implicit”修饰。隐式变量、单例对象、函数在当前作用域的引用也必须满足“单标识符”原则,即不同层次之间需要用“import”来解决。

  隐式参数的类型应该是“稀有”或“特定”的,类型名称最好能表明该参数的作用。如果直接使用Int、Boolean、String等常用类型,容易引发混乱。例如:

// test.scala
class PreferredPrompt(val preference: String)
class PreferredDrink(val preference: String)

object Greeter {
def greet(name: String)(implicit prompt: PreferredPrompt,
drink: PreferredDrink) = {
println("Welcome, " + name + ". The system is ready.")
print("But while you work, ")
println("why not enjoy a cup of " + drink.preference + "?")
println(prompt.preference)
}
}

object JoesPrefs {
implicit val prompt = new PreferredPrompt("Yes, master> ")
implicit val drink = new PreferredDrink("tea")
}
scala> Greeter.greet("Joe")
<console>:12: error: could not find implicit value for parameter prompt: PreferredPrompt
       Greeter.greet("Joe")
                    ^

scala> import JoesPrefs._
import JoesPrefs._

scala> Greeter.greet("Joe")
Welcome, Joe. The system is ready.
But while you work, why not enjoy a cup of tea?
Yes, master>

scala> Greeter.greet("Joe")(prompt, drink)
Welcome, Joe. The system is ready.
But while you work, why not enjoy a cup of tea?
Yes, master>

scala> Greeter.greet("Joe")(prompt)
<console>:15: error: not enough arguments for method greet: (implicit prompt: PreferredPrompt, implicit drink: PreferredDrink)Unit.
Unspecified value parameter drink.
       Greeter.greet("Joe")(prompt)

六、含有隐式参数的主构造方法
  不仅是普通的函数可以有隐式参数,类的主构造方法也可以包含隐式参数,辅助构造方法是不允许出现隐式参数的。但有一个问题需要注意,假设类A仅有一个参数列表,并且该列表是隐式的,那么A的实际定义形式是“A()(implicit 参数)”,也就是比字面上的代码多了一个空括号。不管是用new实例化类A,还是被其它类继承,若调用主构造方法时显式给出隐式参数,就必须写出这个空括号。若隐式参数由编译器自动插入,则空括号可有可无。例如:

scala> class A(implicit val x: Int)
defined class A

scala> val a = new A(1)
<console>:12: error: no arguments allowed for nullary constructor A: ()(implicit x: Int)A
       val a = new A(1)
                     ^

scala> val a = new A()(1)
a: A = A@7cf8f45a

scala> implicit val ORZ = 233
ORZ: Int = 233

scala> val b = new A
b: A = A@7d977a20

scala> b.x
res0: Int = 233

scala> val c = new A()
c: A = A@de0c402

scala> c.x
res1: Int = 233

scala> val d = new A { val y = x }
d: A{val y: Int} = $anon$1@5c5c7cc4

scala> d.x
res2: Int = 233

scala> import scala.language.reflectiveCalls
import scala.language.reflectiveCalls

scala> d.y
res3: Int = 233

  如果类A有多个参数列表,且最后一个是隐式的参数列表,则主构造方法没有额外的空括号。

七、上下文界定
  排序是一个常用的操作,Scala提供了一个特质Ordering[T],方便用户定义特定的排序行为。该特质有一个抽象方法compare,接收两个T类型的参数,然后返回一个Int类型的结果。如果第一个参数“大于”第二个参数,则应该返回正数,反之应该返回负数,相等则返回0。这里的“大于”、“小于”和“等于”是可以自定义的,完全取决于compare的具体定义,并不一定就是常规的逻辑,比如可以和正常逻辑相反。此外,该特质还有方法gt、gteq、lt和lteq,用于表示大于、大于等于、小于和小于等于,分别根据compare的结果来返回相应的布尔值。换句话说,如果一个对象里混入了Ordering[T]特质,并实现了自己需要的compare方法,就能省略定义很多其它相关的方法。

  假设现在需要编写一个方法寻找“最大”的列表元素,并且具体行为会根据某个隐式Ordering[T]对象发生改变,那么可能定义如下:

def maxList[T](elements: List[T])(implicit ordering: Ordering[T]): T =
    elements match {
       case List() => throw new IllegalArgumentException("empty list!")
       case List(x) => x
       case x :: rest =>
           val maxRest = maxList(rest)(ordering)  // 参数ordering被显式传递
           if (ordering.gt(x, maxRest)) x   // 参数ordering被显式使用 
           else maxRest 

  在这里,读者只需关心两行带注释的代码。注意,函数maxList的第二个参数列表是隐式的,这就会让编译器在缺省给出时,自动在当前作用域下寻找一个Ordering[T]类型的对象。在第一行注释处,函数内部进行了自我调用,并且第二个参数仅仅只是传递了ordering,此时就可以利用隐式参数的特性,不必显式给出第二个参数的传递。

  隐式导入的Predef对象里定义了下面这样一个函数:

def implicitly[T](implicit t: T) = t 

  想要使用这个函数,可以只写成“implicitly[T]”的形式。只需要指明T是什么具体类型,在缺省参数的情况下,编译器会在当前作用域下自动寻找一个T类型的隐式对象传递给参数t,然后把这个对象返回。例如,implicitly[ORZ]就会把当前作用域下的隐式ORZ对象返回。既然函数maxList的第二个参数是编译器隐式插入的,那么第二行注释处也就没必要显式写出ordering,而可以改成“implicitly[Ordering[T]]”。

  所以,一个更精简的maxList如下所示:

def maxList[T](elements: List[T])(implicit ordering: Ordering[T]): T =
    elements match {
       case List() => throw new IllegalArgumentException("empty list!")
       case List(x) => x
       case x :: rest =>
           val maxRest = maxList(rest)  
           if (implicitly[Ordering[T]].gt(x, maxRest)) x 
           else maxRest 

   现在,函数maxList的定义里已经完全不需要显式写出隐式参数的名字了,所以隐式参数可以改成任意名字,而函数体仍然保持不变。由于这个模式很常用,所以Scala允许省掉这个参数列表并改用上下文界定。形如“[T : Ordering]”的函数的类型参数就是一个上下文界定,它有两层含义:①和正常情况一样,先在函数中引入一个类型参数T。②为函数添加一个类型为Ordering[T]的隐式参数。例如:

def maxList[T : Ordering](elements: List[T]): T =
    elements match {
       case List() => throw new IllegalArgumentException("empty list!")
       case List(x) => x
       case x :: rest =>
           val maxRest = maxList(rest)  
           if (implicitly[Ordering[T]].gt(x, maxRest)) x 
           else maxRest 

  上下文界定与前面讲的上界和下界很像,但[T <: Ordering[T]]表明T是Ordering[T]的子类型并且不会引入隐式参数,[T : Ordering]则并没有标定类型T的范围,而是说类型T与某种形式的排序相关,并且会引入隐式参数。

  上下文界定是一种很灵活的语法,配合像Ordering[T]这样的特质以及隐式参数,可以实现各种功能而不需要改变定义的T类型。

八、多个匹配的隐式定义
  当多个隐式定义都符合条件时,编译器会发出定义模棱两可错误。但是如果其中一个比别的更加具体,那么编译器会自动选择定义更具体的隐式定义,且不会发出错误。所谓“具体”,只要满足两个条件之一便可:①更具体的定义,其类型是更模糊的定义的子类型。如果是隐式转换,比较的是参数类型,不是返回结果的类型。②子类中的隐式定义比超类中的隐式定义更具体。

  定义模棱两可:

scala> class A(implicit val x: Int)
defined class A

scala> implicit val z = 10
z: Int = 10

scala> implicit val zz = 100
zz: Int = 100

scala> val a = new A()
<console>:14: error: ambiguous implicit values:
 both value z of type => Int
 and value zz of type => Int
 match expected type Int
       val a = new A()
               ^

 条件①:

scala> class A(implicit val x: Int)
defined class A

scala> implicit val z = 10
z: Int = 10

scala> implicit val zz: Any = 100
zz: Any = 100

scala> val a = new A()
a: A = A@fee881

scala> a.x
res0: Int = 10

条件②:

scala> class A(implicit val x: Int)
defined class A

scala> class Sup {
         |    implicit val z = 10
         |  }
defined class Sup

scala> class Sub extends Sup {
         |    implicit val zz = 100
         |  }
defined class Sub

scala> val a = new Sup
a: Sup = Sup@789dd6bf

scala> val b = new Sub
b: Sub = Sub@772cf46b

scala> import a._
import a._

scala> import b._
import b._

scala> val c = new A()
c: A = A@352bea0e

scala> c.x
res0: Int = 100

九、总结  隐式定义是一个很常用的Scala高级语法,尤其是在阅读、理解Chisel这样的DSL语言时,就不得不彻底搞明白自定义的隐式定义是如何工作的。即使是编写实际的硬件电路,像RocketChip的快速裁剪、配置功能,就是通过模式匹配加上隐式参数实现的。配置机制会在后续章节讲解。对于想掌握Chisel高级功能的读者,本章是学习的重点。

附录

scala中函数组合器的用法

map
map方法可以将某个函数应用到集合中的每个元素并产出其结果的集合。举例:

val names = List("a","b","c")
names.map(_.toUpperCase)
得到结果

List("A","B","C")

foreach
foreach和map类似,只不过它没有返回值,foreach只要是为了对参数进行作用。比如:

List(1,2,3,4,5).foreach{x=>
println(x)
}

输出:

1
2
3
4
5

flatten
flatten的作用是可以把嵌套的结构展开,例如

scala> List(List(1,2),List(3,4)).flatten
res0: List[Int] = List(1, 2, 3, 4)

flatmap
flatMap结合了map和flatten的功能。接收一个可以处理嵌套列表的函数,然后把返回结果连接起来。

scala> List(List(1,2),List(3,4)).flatMap(x=>x.map(x=>x*2))
res5: List[Int] = List(2, 4, 6, 8)

filter
滤除掉使函数返回false的元素

scala> def isEven(i: Int): Boolean = i % 2 == 0
isEven: (i: Int)Boolean

scala> List(1,2,3,4).filter(isEven _)
res6: List[Int] = List(2, 4)

zip
zip方法将两个集合结合在一起,例如:

scala> List('a,'b,'c).zip(List(1,2,3))
res32: List[(Symbol, Int)] = List(('a,1), ('b,2), ('c,3))

zipWithIndex
将集合中的元素和对应的下标结合再一起,下标从0开始

scala> List(2,3,4,5).zipWithIndex
res33: List[(Int, Int)] = List((2,0), (3,1), (4,2), (5,3))

groupBy
对集合中的元素进行分组操作,结果是得到一个Map,举例

scala> List((1,2),(1,3),(2,3),(3,3),(3,4)).groupBy(_._1)
res1: scala.collection.immutable.Map[Int,List[(Int, Int)]] = Map(2 -> List((2,3)), 1 -> List((1,2), (1,3)),
3 -> List((3,3), (3,4)))

drop
去掉集合前面的n个元素

scala> List(1,2,3,4,5,6,7).drop(3)
res2: List[Int] = List(4, 5, 6, 7)

dropRight(n) 是去掉后面的n个元素

scala> List(1,2,3,4,5,6,7).dropRight(3)
res3: List[Int] = List(1, 2, 3, 4)

patch
对于可变的数据结构进行批量替换,返回新的copy,原数据不变

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

scala> val b = List(4,5)
b: List[Int] = List(4, 5)

scala> a patch(0,b,2)
res1: List[Int] = List(4, 5, 3)

scala> a patch(1,b,2)
res2: List[Int] = List(1, 4, 5)



 

posted @ 2019-10-23 10:10  大海在倾听  阅读(2900)  评论(0编辑  收藏  举报