Scala(二):进阶篇
12 模式匹配
前一章提到过,Scala的内建控制结构里有一个match表达式,用于模式匹配或偏函数。
模式匹配是Scala中一个强大的高级功能,模式匹配在Chisel中被用于硬件的参数化配置,可以快速地裁剪、配置不同规模的硬件电路。所以,尽管模式匹配不是很容易就能掌握并熟练运用,但是学会它将会对软、硬件编程都大有帮助。
本章介绍了功能强大的模式匹配,尽管概念比较容易理解,但是要熟练运用则比较难。
偏函数,在Chisel里也会用到。
在实际编写硬件时,模式匹配是用不上的。
12.1 样例类与样例对象
(1)样例类:
定义类时,若在最前面加上关键字“case”,那么这个类就被称为样例类。基本形式:
1 |
|
Scala的编译器会自动对样例类添加一些语法便利:
添加与类同名的工厂方法,使得可以通过
类名(参数)
来构造对象,而不需要new 类名(参数)
,使得代码看起来更加自然参数列表的每个参数都隐式地获得了一个val前缀。也就是说,类内部会自动添加与参数同名的公有字段。
自动实现toString、hashCode和equals方法,会自动以“自然”的方式实现toString、hashCode和equals方法
添加一个好用的copy方法,用于构造与旧对象只有某些字段不同的新对象,只需通过传入具名参数和缺省参数实现。比如objectA.copy(arg0 = 10)会创建一个只有arg0为10、其余成员与objectA完全一样的新对象。
(2)样例类定义示例:
1 |
|
(3)样例类的好处
样例类最大的好处是支持模式匹配。相关内容会在本章接下来的内容中介绍。
(4)样例类与样例对象的其他特性
样例对象与样例类很像,也是定义单例对象时在最前面加上关键字case
。
样例对象和普通的单例对象一样,没有参数和构造方法,也是一个具体的实例,但是样例对象的实际形式更接近样例类
前面说的样例类的特性,样例对象也具备,例如可用于模式匹配。从编译后的结果来比较,样例对象与一个无参、无构造方法的样例类是一样的。
12.2 模式匹配
形式:
1 |
|
- 选择器是待匹配的对象,
- 花括号里是一系列以关键字
case
开头的“可选分支”- 每个可选分支都包括一个模式以及一个或多个表达式
- 如果模式匹配成功,就执行相应的表达式,最后返回结果。可选分支定义如下:
- 每个可选分支都包括一个模式以及一个或多个表达式
1 |
|
match表达式/匹配模式与Java的switch语法的区别:
- match是一个表达式,它可以返回一个值
- 可选分支存在优先级,其匹配顺序也就是代码编写时的顺序,并且只有第一个匹配成功的模式会被选中,然后对它的表达式求值并返回。如果表达式有多个,则按顺序执行直到下个case语句为止,并不会贯穿执行到末尾的case语句,所以多个表达式也可以不用花括号包起来
- 要确保至少有一个模式匹配成功,否则会抛出MatchError异常
12.3 模式种类
多样的模式种类,是模式匹配强大的原因
主要种类有以下七种:
- 通配模式
- 常量模式
- 变量模式
- 构造方法模式
- 序列模式
- 元组模式
- 带类型的模式
- 变量绑定
(1)通配模式
定义:
- 用下划线
_
表示,匹配任何对象
使用位置:
- 末尾用于缺省、捕获所有可选路径,相当于switch的default。
- 如果某个模式需要忽略局部特性,也可以用下划线代替。例如:
1 |
|
上述例子中,第一个case就是用下划线忽略了模式的局部特性:表明只有含有三个元素,且前两个为1和2、第三个元素任意的列表才能匹配该模式。不符合第一个case的对象,都会被通配模式捕获。
特别注意:
越具体的模式,可匹配的范围就越小;反之,越模糊的模式,覆盖的范围越大。具体的模式,应该定义在模糊的模式前面,否则如果具体模式的作用范围是模糊模式的子集,那写在后面的具体模式就永远不会被执行。像通配模式这种全覆盖的模式,一定要写在最后。
(2)常量模式
定义:
- 用一个常量、字面量作为模式,使得只能匹配自身
任何字面量、任何val类型的变量或单例对象(样例对象也是一样的)可以作为常量模式,(Nil
这个单例对象能且仅能匹配空列表):
1 |
|
(3)变量模式
定义:
- 变量模式一方面与通配模式一样,一个变量名,它可以匹配任何对象
- 变量模式还会把该变量名与匹配成功的输入对象绑定,在表达式中可以通过这个变量名来进一步操作输入对象。变量模式还可以放在最后面代替通配模式。(可以理解为能当变量调用的通配符)例如:
1 |
|
与通配模式一样,变量模式的后面不能添加别的模式,否则编译器会警告无法到达变量模式后面的代码。例如:
1 |
|
区分常量和变量的规则:
常量模式有时候看上去也是一个变量名,比如“Nil”就是引用空列表这个常量模式。
Scala有一个简单的词法区分规则:
小写字母开头的简单名称会被当做变量模式,其他引用都是常量模式。即使以小写字母开头的简单名称是某个常量的别名,也会被当成变量模式。
如果想绕开这个规则,有两种方法:
如果常量是某个对象的字段,可以加上限定词如this.a或object.a等来表示这是一个常量。
用反引号把名称包起来,编译器就会把它解读成常量,这也是绕开关键字与自定义标识符冲突的方法。例如:
1 |
|
(4)构造方法模式(样例类)
定义:
- 把样例类的构造方法作为模式,其形式为
名称(模式)
,即需要小括号
特性:
- (假设这里的“名称”指定的是一个样例类的名字)那么该模式将首先检查待匹配的对象是不是以这个名称命名的样例类的实例,然后再检查待匹配的对象的构造方法参数是不是匹配括号里的“模式”
- Scala的模式支持深度匹配,也就是说,括号里的模式可以是任何一种模式,包括构造方法模式。嵌套的构造方法模式会进一步展开匹配。例如:
1 |
|
“abc”是常量模式,只能匹配字符串“abc”
e是变量模式,绑定B的第二个构造参数,然后在表达式里加1并返回
A(10)是构造方法模式,B的第三个参数必须是以10为参数构造的A的对象
上述构造方法模式的实际匹配:
1 |
|
(5) 序列模式(List或Array)
定义:
- 序列类型也可以用于模式匹配,比如List或Array。
- 下划线
_
或变量模式可以指出不关心的元素 - 把
_*
放在最后可以匹配任意元素个数。例如:
- 下划线
1 |
|
(6)元组模式(a,b,c,…)
定义:
- 元组也可以用于模式匹配
- 在圆括号里可以包含任意模式,即通过元组匹配多个其他模式
- 形如(a, b, c)的模式可以匹配任意的三元组,注意里面是三个变量模式,不是三个字母常量。
1 |
|
(7)带类型的模式
定义:模式定义时,也可以声明具体的数据类型。
用途:用带类型的模式可以代替类型测试和类型转换。例如:
1 |
|
带类型的变量模式
s: String
将匹配每个非空的String实例m: Map[_, _]
将匹配任意映射实例。
调用变量时候:入参x的类型是Any,而s的类型是String,所以表达式里可以写s.length而不能写x.length,因为Any类没有叫length的成员。m.size同理。
无法进行深度指明:
在带类型的模式中,虽然可以像上个例子那样指明对象类型为笼统的映射“Map[, ]”,但是无法更进一步指明映射的键-值分别是什么类型。前面曾说过,这是因为Scala采用了擦除式的泛型,即运行时并不会保留类型参数的信息,所以程序在运行时无法判断某个映射的键-值具体是哪两种类型。唯一例外的是数组,因为数组的元素类型跟数组保存在一起。
(8)变量绑定(除变量模式外的添加变量手段)
用途:除了变量模式可以使用变量外,还可以对任何其他模式添加变量,构成变量绑定模式。
定义:
- 其形式为
变量名 @ 模式
。
特性:
- 变量绑定模式执行模式匹配的规则与原本模式一样,但是在匹配成功后会把输入对象的相应部分与添加的变量进行绑定,通过该变量就能在表达式中进行额外的操作。例如下面为一个常量模式绑定了变量e:
1 |
|
12.4 守卫模式
模式守卫出现在模式之后,是一条用if开头的语句。模式守卫可以是任意的布尔表达式,通常会引用到模式中的变量。如果存在模式守卫,那么必须模式守卫返回true,模式匹配才算成功。
Scala要求模式都是线性的,即一个模式内的两个变量不能同名。如果想指定模式的两个部分要相同,不是定义两个同名的变量,而是通过模式守卫来解决。
形式:
- case后面加个If
- 通常引用到模式中的变量
- 返回true才算成功
通俗解释:
用于匹配成功后的进一步限定
1 |
|
12.5 密封类Sealed
形式:
sealed class
- 如果在“class”前面加上关键字“sealed”,那么这个类就称为密封类。
特性:
- 作用区域:密封类只能在同一个文件中定义子类,不能在文件之外被别的类继承
- 这有助于编译器检查模式匹配的完整性,因为这样确保了不会有新的模式随意出现,而只需要关心本文件内已有的样例类。所以,要使用模式匹配,最好把最顶层的基类做成密封类。
- 继承自密封类的样例类做匹配:对继承自密封类的样例类做匹配,编译器会用警告信息标示出缺失的组合。如果确实不需要覆盖所有组合,又不想用通配模式来避免编译器发出警告,可以在选择器后面添加
@unchecked
注解,这样编译器对后续模式分支的覆盖完整性检查就会被压制。例如:
1 |
|
有关注解的内容,本教程不会讲解。需要深入了解的读者,请自行查阅资料。Chisel源码使用了注解。
12.6 可选值Option[T]
(1)解决的问题:
从上面很多例子中,我们发现两个问题:
- 一是每条case分支可能返回不同类型的值,导致函数的返回值或变量的类型不好确定,该如何把它们统一起来?
- 二是通配模式下,常常不需要返回一个值,但什么都不写又不太好
要解决这两个问题,Scala提供了一个新的语法——可选值Option[T]
(2)定义
定义:可选值就是类型为Option[T]的一个值
- Option:是标准库里的一个密封抽象类
- T:可以是任意的类型,例如标准类型或自定义的类(并且T是协变的,简单来说,就是如果类型T是类型U的超类,那么Option[T]也是Option[U]的超类)
(3)Some类和None对象
Option类有一个子类:Some类
Some(x)
可以构造一个Some的对象参数x:是一个具体的值,根据x的类型,可选值的类型会发生改变。例如,Some(10)的类型是Option[Int],Some(“10”)的类型是Option[String]
作用:由于Some对象需要一个具体的参数值,所以这部分可选值用于表示“有值”。Some(x)常作为case语句的返回值。
Option类还有一个子对象:None
- 它的类型是
Option[Nothing]
,是所有Option[T]类型的子类,代表“无值”。None常作为通配模式的返回值。
- 它的类型是
也就是说,Option类型代表要么是一个具体的值,要么无值。Some(x)常作为case语句的返回值,而None常作为通配模式的返回值。需要注意的是,Option[T]和T是两个完全没有关系的类型,赋值时不要混淆。
None对象的意义:如果没有可选值语法,要表示“无值”可能会选用null,这就必须对变量进行判空操作。在Java里,判空是一个运行时的动作,如果忘记判空,编译时并不会报错,但是在运行时可能会抛出空指针异常,进而引发严重的错误。有了可选值之后,首先从字面上提醒读者这是一个可选值,存在无值和有值两种情况;其次,最重要的是,由于Option[T]类型与T类型不一样,赋值时就可能需要先做相应的类型转换。类型转换最常见的方式就是模式匹配,在这期间可以把无值None过滤掉。如果不进行类型转换,编译器就会抛出类型错误,这样在编译期就进行判空处理进而防止运行时出现更严重的问题。
(4)判断Some和None的方法
Option可选值提供了一个方法isDefined
如果调用对象是None,则返回false
Some对象都会返回true
还有一个方法get,
- 用于把Some(x)中的x返回
- 如果调用对象是None则报错
12.7 另类用法——定义变量时的模式匹配
原文:对于提取器(这里不用关心提取器是什么),可以通过“val/var 对象名(模式) = 值”的方式来使用模式匹配,常常用于定义变量。这里的“对象名”是指提取器,即某个单例对象,列表、数组、映射、元组等常用集合的伴生对象都是提取器。
定义:提取器通过val/var来进行模式匹配,形式如下,常用于定义变量
1 |
|
- “对象名”是指提取器,某个单例对象,列表、数组、映射、元组等常用集合的伴生对象都是提取器
实例代码:
1 |
|
12.8 偏函数
前面说过,在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语句又可以有自己的参数列表和函数体。例如:
1 |
|
注意:
apply方法可以隐式调用
x.isInstanceOf[T]:判断x是不是T类型(及其超类)的对象,是的话就返回true
- x.asInstanceOf[T]:则把x转换成T类型的对象,如果不能转换则会报错
- 偏函数PartialFunction[Any, Any]:是Function1[Any, Any]的子特质,因为case语句只有一个参数。[Any, Any]中的第一个Any是输入参数的类型,第二个Any是返回结果的类型。如果确实需要输入多个参数,则可以用元组、列表或数组等把多个参数变成一个集合。
在用case语句定义偏函数时,前述的各种模式类型、模式守卫都可以使用。最后的通配模式可有可无,但是没有时,要保证运行不会出错
上述代码运行如下:
1 |
|
13 类型参数化(泛型)
推荐!!!扩展阅读,泛型父类:https://zhuanlan.zhihu.com/p/388138614
在面向对象的编程里,提高代码复用率的一个重要方法就是泛型。泛型是一种重要的多态,称为“全类型多态”或“参数多态”。在某些容器类里,通常需要存储其它类型的对象,但是具体是什么类型,事先并不知道。倘若对每种可能包含的类型都编写一个新类,那么这完全不现实。一是工作量巨大,二是自定义类型是什么完全无法预知。例如,列表的元素可以是基本类型,也可以是自定义的类型,不可能在编写列表类时把自定义类型也考虑进去。更重要的是,这些容器类仅仅需要知道一个具体的类型,其它成员完全是一样的。既然这样,那完全可以编写一个泛型的类,它独立于成员的类型存在,然后把类型也作为一个参数,实例化生成不同的类对象。
既然与定义类型相关,那么可以泛型的自然是类和特质。
在前面讲解集合时,就已经初步了解了这样的类和特质。例如,Array[T]、List[T]、Map[T, U]等等。本章将深入讲解Scala有关类型参数化的内容。
本章的内容也是比较抽象、难理解。其应用在于阅读Chisel的源码,理解语言的工作机制,读懂API。如果是实际编写硬件电路用不到这些语法
13.1 深入解析,类内部的变量
对于可重新赋值的字段,可执行两个基本操作:获取字段值或者设置为一个新值。在JavaBeans库里,这两个操作分别由名为“getter”和“setter”的方法来完成。Scala遵循了Java的惯例,只不过实现两个基本操作的方法的名字不一样:
Scala如果在类中定义了一个var类型的字段:
- 编译器会隐式地把这个变量限制成private[this]的访问权限
- 隐式地定义一个名为“变量名”的getter方法,默认返回变量的值
- 隐式地定义一个名为“变量名_=”的setter方法,默认接收外部传入的参数来直接赋给变量。例如:
1 |
|
注意:
- 字段必须被初始化
- 这里的“= _”,它将字段初始化为零值(具体零值是什么取决于字段的类型,数值类型的零值是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”来调用。例如:
1 |
|
1 |
|
13.2 泛型初步:类型构造器
定义:使用方括号进行限定
1 |
|
- A类型构造器、一个泛型的类
“A”是一个类,但它不是一个类型,因为它接收一个类型参数。A也被称为“类型构造器”,因为它可以接收一个类型参数来构造一个类型,就像普通类的构造方法接收值参数构造实例对象一样。
A[Int]是一种类型,A[String]是另一种类型,等等。也可以说A是一个泛型的类。在指明类型时,不能像普通类那样只写一个类名,而必须在方括号里给出具体的类型参数。例如:
1 |
|
方括号和参数类型添加位置:
泛型的类和特质需要在名字后面加上方括号和类型参数,
成员方法的具有泛型参数需要在方法名后面也必须加上方括号和类型参数
- 字段则不需要,只要直接用类型参数指明类型即可
13.3 型变注解
定义:
像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]是两种没有任何关系的不同类型。
13.4 检查型变注解
标注了型变注解的类型参数不能随意使用,类型系统设计要满足“里氏替换原则”:在任何需要类型为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处在不变点。
以如下例子为例进行解释:
1 |
|
右上角的正号表示协变点,负号表示逆变点。首先,Cat类声明了类型参数,所以它是顶层。方法meow的返回值属于顶层的点,所以返回类型的最右边是正号,表示协变点。因为方法的返回类型也是类型构造器Cat,并且第一个类型参数是逆变的,所以这里相对协变翻转成了逆变,而第二个类型参数是协变的,所以保持协变属性不变。继续往里归类,返回类型嵌套的Cat处在逆变点,所以第一个类型参数的位置相对逆变翻转成协变,第二个类型参数的位置保持逆变属性不变。两个值参数volume和listener都相对协变翻转成了逆变点,并且listener的类型是Cat,所以和返回类型嵌套的Cat一样。方法的类型参数W,也相对协变翻转成了逆变点。
虽然型变注解的检查很麻烦,但这些工作都被编译器自动完成了。编译器的检查方法也很直接,就是查看顶层声明的类型参数是否出现在正确的位置。比如,上例中,T都出现在逆变点,U都出现在协变点,所以可以通过检查。至于W是什么,则不关心。
13.5 类型构造器的继承关系
因为类型构造器需要根据类型参数来确定最终的类型,所以在判断多个类型构造器之间的继承关系时,也必须依赖类型参数。对于只含单个类型参数的类型构造器而言,继承关系很好判断,只需要看型变注解是协变、逆变还是不变。当类型参数不止一个时,该如何判断呢?尤其是函数的参数是一个函数时,更需要确定一个函数的子类型是什么样的函数。
以常用的单参数函数为例,其特质Function1的部分定义如下:
1 |
|
类型参数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]的子类型。
因此,对于含有多个类型参数的类型构造器,要构造子类型,就是把逆变类型参数由子类替换成超类、把协变类型参数由超类替换成子类
13.6 上界和下界
对于类型构造器A[+T],倘若没有别的手段,很显然它的方法的参数不能泛化,因为协变的类型参数不能用作函数的入参类型。如果要泛化参数,必须借助额外的类型参数,那么这个类型参数该怎么定义呢?因为可能存在“val x: A[超类] = new A[子类]”这样的定义,导致方法的入参类型会是T的超类,所以,额外的类型参数必须是T的超类。Scala提供了一个语法——下界,其形式为“U >: T”,表示U必须是T的超类,或者是T本身(一个类型既是它自身的超类,也是它自身的子类)。
通过使用下界标定一个新的类型参数,就可以在A[+T]这样的类型构造器里泛化方法的入参类型。例如:
1 |
|
现在,编译器不会报错,因为下界的存在,导致编译器预期参数x的类型是T的超类。实际运行时,会根据传入的实际入参确定U是什么。返回类型定义成了U,当然也可以是T,但是动态地根据U来调整类型显得更自然。
与下界对应的是上界,其形式为“U <: T”,表示U必须是T的子类或本身。通过上界,就能在A[-T]这样的类型构造器里泛化方法的返回类型。例如:
1 |
|
注意,编写上、下界时,不能写错类型的位置和开口符号
13.7 方法的类型参数
除了类和特质能一开始声明类型参数外,方法也可以带有类型参数。如果方法仅仅使用了包含它的类或特质已声明的类型参数,那么方法自己就没必要写出类型参数。如果出现了包含它的类或特质未声明的类型参数,则必须写在方法的类型参数里。注意,方法的类型参数不能有型变注解。例如:
1 |
|
方法的类型参数不能与包含它的类和特质已声明的类型参数一样,否则会把它们覆盖掉。例如:
1 |
|
13.8 对象私有化
var类型的字段,其类型参数不能是协变的,因为隐式的setter方法需要一个入参,这就把协变类型参数用作入参。其类型参数也不能是逆变的,因为隐式的getter方法的返回类型就是字段的类型。例如:
1 |
|
但是也有例外,如果var字段是对象私有的,即用private[this]修饰,那么它只能在定义该类或特质时被访问。由于外部无法直接访问,也就不可能在运行时违背里氏替换原则。因此隐式的getter和setter方法可以忽略对型变注解的检查。如果想在内部自定义getter或setter方法来产生一个错误,假设当前类型参数T是协变的,尽管可以通过下界来避免setter方法的型变注解错误,但是赋值操作又会发生类型匹配错误。连类型检查都无法通过,更不可能在运行时发生错误。同样,逆变类型参数也是如此。例如:
1 |
|
所以,Scala的编译器会忽略对private[this] var类型的字段的检查。
14 抽象成员
对于本章内容不感兴趣或理解不深的读者,完全可以跳过
因为这些内容也仅仅是帮助理解Chisel标准库的工作机制。实际的电路不可能会有这样的抽象成员。
14.1 抽象成员
类可以用“abstract”修饰变成抽象的,特质天生就是抽象的,所以抽象类和特质里可以包含抽象成员,也就是没有完整定义的成员。Scala有四种抽象成员:抽象val字段、抽象var字段、抽象方法和抽象类型,它们的声明形式如下:
1 |
|
因为定义不充分,存在不可初始化的字段和类型,或者没有函数体的方法,所以抽象类和特质不能直接用new构造实例。抽象成员的本意,就是让更具体的子类或子对象来实现它们。例如:
1 |
|
抽象类型指的是用type关键字声明的一种类型——它是某个类或特质的成员但并未给出定义。虽然类和特质都定义了一种类型,并且它们可以是抽象的,但这不意味着抽象类或特质就叫抽象类型,抽象类型永远都是类和特质的成员。在使用抽象类型进行定义的地方,最后都要被解读成抽象类型的具体定义。而使用抽象类型的原因,一是给名字冗长或含义不明的类型起一个别名,二是声明子类必须实现的抽象类型。
在不知道某个字段正确的值,但是明确地知道在当前类的每个实例中,该字段都会有一个不可变更的值时,就可以使用抽象val字段。抽象val字段与抽象无参方法类似,而且访问方式完全一样。但是,抽象val字段保证每次使用时都返回一个相同的值,而抽象方法的具体实现可能每次都返回不同的值。另外,抽象val字段只能实现成具体的val字段,不能改成var字段或无参方法;而抽象无参方法可以实现成具体的无参方法,也可以是val字段。
抽象var字段与抽象val字段类似,但是是一个可被重新赋值的字段。与前一章讲解的具体var字段类似,抽象var字段会被编译器隐式地展开成抽象setter和抽象getter方法,但是不会在当前抽象类或特质中生成一个“private[this] var”字段。这个字段会在定义了其具体实现的子类或子对象当中生成。例如:
1 |
|
14.2 初始化抽象val字段
抽象val字段有时会承担超类参数的职能:它们允许程序员在子类中提供那些在超类中缺失的细节。这对特质尤其重要,因为特质没有构造方法,参数化通常都是通过子类实现抽象val字段来完成。例如:
1 |
|
要在具体的类中混入这个特质,就必须实现它的两个抽象val字段。例如:
1 |
|
注意,前面说过,这不是直接实例化特质,而是隐式地用一个匿名类混入了该特质,并且花括号里的内容属于隐式的匿名类。
构造子类的实例对象时,首先构造超类/超特质的组件,然后才轮到子类的剩余组件。因为花括号里的内容不属于超类/超特质,所以在构造超类/超特质的组件时,花括号里的内容其实是无用的。并且在这个过程中,如果需要访问超类/超特质的抽象val字段,会交出相应类型的默认值(比如Int类型的默认值是0),而不是花括号里的定义。只有轮到构造子类的剩余组件时,花括号里的子类定义才会派上用场。所以,在构造超类/超特质的组件时,尤其是特质还不能接收子类的参数,如果默认值不满足某些要求,构造就会出错。例如:
1 |
|
在这个例子中,require函数会在参数为false时报错。该特质是用默认值0去初始化两个抽象字段的,花括号里的定义只有等超特质构造完成才有用,所以require函数无法通过。为此,Scala提供了两种方法解决这种问题。
(1)预初始化字段
如果能让花括号里的代码在最开始执行,那么就能避免该问题,这个方法被称作“预初始化字段”。其形式为:
1 |
|
例如:
1 |
|
除了匿名类可以这样使用,单例对象或具名子类也可以,其形式是把花括号里的代码与单例对象名或类名用extends隔开,最后用with连接想要继承的类或混入的特质。例如:
1 |
|
这个语法有一个瑕疵,就是由于预初始化字段发生得比构造超类/超特质更早,导致预初始化字段时实例对象其实还未被构造,所以花括号里的代码不能通过“this”来引用正在构造的对象本身。如果代码里出现了this,那么这个引用将指向包含当前被构造的类或对象的对象,而不是被构造的对象本身。例如:
1 |
|
这个代码无法通过编译,因为this指向了包含用new构造的对象的那个对象,在本例中是名为“$iw”的合成对象,该合成对象是Scala的编译器用于存放用户输入的代码的地方。由于$iw没有叫numerArg的成员,所以编译器产生了错误。
(2)惰性的val字段
预初始化字段是人为地调整初始化顺序,而把val字段定义成惰性的,则可以让程序自己确定初始化顺序。如果在val字段前面加上关键字“lazy”,那么该字段只有首次被使用时才会进行初始化。如果是用表达式进行初始化,那就对表达式求值并保存,后续使用字段时都是复用保存的结果而不是每次都求值表达式。例如:
1 |
|
首先仍然是先构造超特质的组件,但是需要初始化的非抽象字段都被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不用再次求值。至此,对象构造完成。
14.3 抽象类型
假设要编写一个Food类,用各种子类来表示各种食物。要编写一个抽象的Animal类,有一个eat方法,接收Food类型的参数。那么可能会写成如下形式:
1 |
|
如果用不同的Animal子类来代表不同的动物,并且食物类型也会根据动物的习性发生改变。比如定义一头吃草的牛,那么可能定义如下:
1 |
|
奇怪的是,编译器并不允许这么做。问题出在“override def eat(food: Grass) = {}”这句代码并不会被编译。实现超类的抽象方法其实相当于重写,但是重写要保证参数列表完全一致,否则就是函数重载。在这里,超类的方法eat的参数类型是Food,但是子类的版本改成了Grass。Scala的编译器执行严格的类型检查,尽管Grass是Food的子类,但是出现在函数的参数类型上,并不能简单地套用子类型多态,就认为Grass等效于Food。所以,错误信息显示Cow类一是没有实现Animal类的抽象eat方法,二是Cow类的eat方法并未重写任何东西。
如果有读者认为这种规则过于严厉,应该放松,那么就会出现如下不符合常识的情况:
1 |
|
假设编译器放开对eat方法的参数类型的限制,使得任何Food类型都能通过编译,那么Fish类作为Food的子类,也就能被Cow类的eat方法所接受。但是,给一头牛喂鱼,而不是吃草,显然与事实不符。
要达到上述目的,就需要更精确的编程模型。一种办法就是借助抽象类型及上界,例如:
1 |
|
在这里,引入了一个抽象类型。由于方法eat的参数设定为抽象类型,在编译时会被解读成具体的SuitableFood实现,所以不同的Animal子类可以通过更改具体的SuitableFood来达到改变食物类型的目的,并且这符合严格的规则检查。其次,上界保证了在子类实现SuitableFood时,必须是Food的某个子类,即不会喂给动物吃非食物类的东西。此时的Cow类如下所示:
1 |
|
如果现在给吃草的牛喂一条鱼,那么就会发生类型错误:
1 |
|
14.4 路径依赖类型
在前面给牛喂鱼的例子中,可以发现错误信息里有一个有趣的现象:方法eat要求的参数类型是bessy.SuitableFood。关于类型“bessy.SuitableFood”,比普通的类型描述多了一个对象。这说明类型可以是对象的成员,bessy.SuitableFood表示SuitableFood是由bessy引用的对象的成员,或者说bessy引用对象的专属食物。像这样的类型称为路径依赖类型,尽管最后的类型是相同的,但若是前面的路径不同,那就是不同的类型。“路径”就是指对象的引用,它可以是单名,也可以是更长的路径。
比如,狗吃狗粮,一条狗能吃另一条狗的狗粮,但牛怎么都不能吃狗粮:
1 |
|
因为bessy.SuitableFood和lassie.SuitableFood的路径不同,所以它们是不同的类型。而lassie.SuitableFood和bootsie.SuitableFood尽管有不同的路径,似乎是不同的类型,但其实这两个都是实际类型DogFood的别名,所以实质上是同一个类型。
Scala的“路径依赖类型”很像Java的“内部类类型”,但是两者有重要区别:路径依赖类型的路径表明了外部类的对象,而内部类类型仅表明了外部类。
Scala也可以表示Java的内部类,但是语法稍有不同。Scala定义一个内部类只需这样写:
1 |
|
内部类Inner可以通过“Outer#Inner”来寻址,而不是Java的“Outer.Inner”,因为Scala把句点符号作为对象访问成员的专属符号,而类访问成员则是通过井号。比如有如下两个对象:
1 |
|
那么,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”来实例化。例如:
1 |
|
14.5 细化类型
当一个类继承自另一个类时,就称前者是后者的名义子类型。Scala还有一个结构子类型,表示两个类型只是有某些兼容的成员,而不是常规的那样继承来的关系。结构子类型通过细化类型来表示。
比如,要做一个食草动物的集合。一种方法是定义一个食草的特质,让所有的食草动物类都混入该特质。但是这样会让食草动物与最基本的动物的关系不那么紧密。如果按前面定义食草牛那样继承自Animal类,那么食草动物集合的元素类型就可以表示为Animal类型,但这样又可能把食肉动物或杂食动物也包含进集合。此时,就可以使用结构子类型,其形式如下:
1 |
|
最前面是基类Animal的声明,花括号里是想要兼容的成员。这个成员声明得比基类Animal更具体、更精细,表示食物类型必须是草。当然,并不一定要更加具体。那么,用这样一个类型指明集合元素得类型,就可以只包含食草动物了:
1 |
|
14.6 Scala的枚举
Scala没有特定的语法表示枚举,而是在标准类库中提供一个枚举类——scala.Enumeration。通过创建一个继承自这个类的子对象可以创建枚举。例如:
1 |
|
对象Color和普通的单例对象一样,可以通过“Color.Red”这样的方式来访问成员,或者先用“import Color._”导入。
Enumeration类定义了一个名为Value的内部类,以及同名的无参方法。该方法每次都返回内部类Value的全新实例,也就是说,枚举对象Color的三个枚举值都分别引用了一个Value类型的实例对象。并且,因为Value是内部类,所以它的对象的具体类型还与外部类的实例对象有关。在这里,外部类的对象就是自定义的Color,所以三个枚举值引用的对象的真正类型应该是Color.Value。
假如还有别的枚举对象,例如:
1 |
|
根据路径依赖类型的规则,Color.Value和Direction.Value是两个不同类型,所以两个枚举对象分别创造了两种不同类型的枚举值。
方法Value有一个重载的版本,可以接收一个字符串参数来给枚举值关联特定的名称。例如:
1 |
|
方法values返回枚举值的名称的集合。优先给出特定名称,否则就给字段名称。例如:
1 |
|
枚举值从0开始编号。内部类Value有一个方法id返回相应的编号,也可以通过“对象名(编号)”来返回相应的枚举值的名称。例如:
1 |
|
15 隐式转换与隐式参数
隐式定义是一个很常用的Scala高级语法
尤其是在阅读、理解Chisel这样的DSL语言时,就不得不彻底搞明白自定义的隐式定义是如何工作的。
编写实际的硬件电路,像RocketChip的快速裁剪、配置功能,就是通过模式匹配加上隐式参数实现的(配置机制会在后续章节讲解。对于想掌握Chisel高级功能的读者,本章是学习的重点)
15.1 隐式定义的规则
标记规则:只有用关键字“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”这种嵌套的形式,但是可以让隐式定义包含隐式参数来绕开这个限制。
显式优先原则:
- 如果显式定义能通过类型检查,就不必进行隐式转换。因此,总是可以把隐式定义变成显式的,这样代码变长但是歧义变少。用显式还是隐式,需要取舍
命名规则:隐式转换可以用任意合法的标识符来命名。有了名字后,一是可以显式地把隐式转换函数写出来,二是明确地导入具体的隐式转换而不是导入所有的隐式定义。
隐式定义使用位置:
- 转换到一个预期的类型
- 转换某个选择接收端(即调用方法或字段的对象)
- 隐式参数。
15.2 (位置一)隐式地转换到期望类型——右边的转为能用的
使用方法:
1 |
|
Scala的编译器对于类型检查比较严格,比如把一个浮点数赋值给整数变量,通常情况下人们可能希望通过截断小数部分来完成赋值,但是Scala在默认情况下是不允许这种丢失精度的转换的,这会造成类型匹配错误。例如:
1 |
|
可以通过定义一个隐式转换来完成。例如:
1 |
|
隐式转换也可以显式地调用:
1 |
|
15.2 补充:Scala全局层次中隐式插入的用于类型转换的三个包
第七章讲解类继承时,最后提到了Scala的全局类层次,其中就有七种基本值类的转换,比如Int可以赋值给Double。这其实也是隐式转换在起作用,只是这个隐式转换定义在scala包里的单例对象Predef里。
所有的Scala文件都会被编译器隐式地在开头按顺序插入:
- “import java.lang._”
- “import scala._”
- “import Predef._”
三条语句,所以标准库里的隐式转换会以不被察觉的方式工作
15.3 (位置二)隐式地转换接收端——转换左边的
接收端:指调用方法或字段的那个对象,也就是调用对象在非法的情况下,被隐式转换变成了合法的对象,这是隐式转换最常用的地方。
使用方法:
1 |
|
示例代码:
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(键).->(值)”。因此,严格来讲没有“键 -> 值”这个语法,只不过是用隐式转换模拟出来的罢了。
14.4 (位置二)隐式类
隐式类定义:是一个以关键字“implicit”开头的类
作用:
- 用于简化富包装类的编写
特性:
- 不能是样例类,并且主构造方法有且仅有一个参数
- 隐式类只能位于某个单例对象、类或特质里,不能单独出现在顶层
- 隐式类需要单参数主构造方法的原因很简单,因为用于转换的调用对象只有一个,并且自动生成的隐式转换不会去调用辅助构造方法。隐式类不能出现在顶层是因为自动生成的隐式转换与隐式类在同一级,如果不用导入就能直接使用,那么顶层大量的隐式类就会使得代码变得复杂且容易出错。
- 隐式类的特点就是让编译器在相同层次下自动生成一个与类名相同的隐式转换,该转换接收一个与隐式类的主构造方法相同的参数,并用这个参数构造一个隐式类的实例对象来返回。
例如:
1 |
|
将该文件编译后,就可以:
- 在解释器里用“import Rec._”或“import Rec.RectangleMaker”来引入这个隐式转换,
- 用“1 x 10”这样的语句来构造一个长方形。
隐式定义后的效果:
实际上,Int类并不存在方法“x”,但是隐式转换把Int对象转换成一个RectangleMaker类的对象,转换后的对象有一个构造Rectangle的方法“x”。例如:
1 |
|
15.5 (位置三)隐式参数
隐式参数定义:
- 隐式参数:函数最后一个参数列表,用关键字“implicit”声明为隐式的
- 让编译器隐式插入参数:就必须事先定义好符合预期类型的隐式变量(val和var可以混用,关键在于类型)、隐式单例对象或隐式函数(别忘了函数也能作为函数的参数进行传递),这些隐式定义也必须用“implicit”修饰。隐式变量、单例对象、函数在当前作用域的引用也必须满足“单标识符”原则,即不同层次之间需要用“import”来解决。
- 隐式参数的类型:应该是“稀有”或“特定”的,类型名称最好能表明该参数的作用。如果直接使用Int、Boolean、String等常用类型,容易引发混乱。
特性:
- 最后一个参数列表的内部所有参数都是隐式参数
- 注意,是整个参数列表,即使括号里有多个参数,也只需要开头写一个“implicit”。而且每个参数都是隐式的,不存在部分隐式部分显式。
- 当调用函数时,可缺省隐式参数列表,编译器会尝试插入相应的隐式定义
- 可以显式给出参数,但是要么全部缺省,要么全部显式给出,不能只写一部分
示例代码:
1 |
|
上述代码运行结果:
1 |
|
15.6 含有隐式参数的主构造方法
类的主构造方法可以包含隐式参数,辅助构造方法是不允许出现隐式参数。
注意:
A是一个只有一个隐式参数列表的类,A的实际定义形式是“A()(implicit 参数)”,比字面上的代码多了一个空括号,用new实例化类A、被其它类继承中
- 只有一个参数列表时,调用主构造方法时显式给出隐式参数,就必须写出这个空括号。
- 若隐式参数由编译器自动插入,则空括号可有可无。如参数列表所示:
- 如果类A有多个参数列表,且最后一个是隐式的参数列表,则主构造方法没有额外的空括号。
1 |
|
15.7 排序Ordering[T]
排序是一个常用的操作,Scala提供了一个特质Ordering[T],方便用户定义特定的排序行为
特质Ordering[T]内部包括:
- 抽象方法compare,接收两个T类型的参数,然后返回一个Int类型的结果
- 如果第一个参数“大于”第二个参数,返回正数,反之负数,相等返0
- 这里的“大于”、“小于”和“等于”可自定义,取决于compare的具体定义
- 抽象方法gt、gteq、lt和lteq,用于表示大于、大于等于、小于和小于等于,分别根据compare的结果来返回相应的布尔值
Ordering[T]的好处:
如果一个对象里混入了Ordering[T]特质,并实现了自己需要的compare方法,就能省略定义很多其它相关的方法
15.8 上下文界定
(1)一个寻找最大列表元素,且ordering为隐式参数的方法:
一个方法寻找“最大”的列表元素,具体行为根据某个隐式Ordering[T]对象发生改变,那么可能定义如下:
1 |
|
在这里,读者只需关心两行带注释的代码。
- maxList的第二个参数列表是隐式的,在缺省时自动在当前作用域下寻找一个Ordering[T]类型的对象。
- 第一行注释处,函数内部进行了自我调用,并且第二个参数仅仅只是传递了ordering,此时就可以利用隐式参数的特性,不必显式给出第二个参数的传递。
(2)使用implicitly[T]进行优化
第一行注释处,第二个参数仅仅只是传递了ordering,此时就可以利用隐式参数的特性,不必显式给出第二个参数的传递
implicitly[T]是什么:
隐式导入的Predef对象里定义了下面这样一个函数:
1 |
|
想要使用这个函数,可以只写成“implicitly[T]”的形式。只需要指明T是什么具体类型,在缺省参数的情况下,编译器会在当前作用域下自动寻找一个T类型的隐式对象传递给参数t,然后把这个对象返回。例如,implicitly[ORZ]就会把当前作用域下的隐式ORZ对象返回
一个更精简的maxList:
既然函数maxList的第二个参数是编译器隐式插入的,那么第二行注释处也就没必要显式写出ordering,而可以改成“implicitly[Ordering[T]]”
如下所示:
1 |
|
现在,函数maxList的定义里已经完全不需要显式写出隐式参数的名字了
(3)由(2)得到完全省略隐式参数名字的方法
现在,函数maxList的定义里已经完全不需要显式写出隐式参数的名字了,所以隐式参数可以改成任意名字,而函数体仍然保持不变。
上下文界定[T : Ordering]:
由于这个模式很常用,所以Scala允许省掉这个参数列表并改用上下文界定。
上下文界定定义:
- 形如“[T : Ordering]”的函数的类型参数,T为显示类型参数,Ordering[T]为隐式参数
上下文界定含义:
- ①和正常情况一样,先在函数中引入一个类型参数T
- ②为函数添加一个类型为Ordering[T]的隐式参数。例如:
1 |
|
- 上下文界定与上下界:上下文界定与前面讲的上界和下界很像,但
- [T <: Ordering[T]]表明T是Ordering[T]的子类型并且不会引入隐式参数
- [T : Ordering]则并没有标定类型T的范围,而是说类型T与某种形式的排序相关,并且会引入隐式参数。
上下文界定是一种很灵活的语法,配合像Ordering[T]这样的特质以及隐式参数,可以实现各种功能而不需要改变定义的T类型。
15.8 多个匹配的隐式定义
多个隐式定义都符合条件时,编译器会发出定义模棱两可错误。但是如果其中一个比别的更加具体,那么编译器会自动选择定义更具体的隐式定义,且不会发出错误。
“具体”是指满足两个条件之一便可:
- 更具体的定义,其类型是更模糊的定义的子类型。如果是隐式转换,比较的是参数类型,不是返回结果的类型。
- 子类中的隐式定义比超类中的隐式定义更具体。
定义模棱两可:
1 |
|
条件①:
1 |
|
条件②:
1 |
|
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!