发布网友 发布时间:2024-09-26 06:44
共1个回答
热心网友 时间:2024-10-09 18:20
Part1.泛型与类型约束我们在学习Java时就已经接触到泛型的相关概念了。当我们不确定某个参数的类型具体是哪个类型,就会使用泛型E来代替。泛型最常用在Collection集合当中,表示“这是一个装载什么元素的”集合。
同时,为了*这个泛型E类型,我们又为泛型制定了上界,下界的概念,又称之为类型约束。在Scala中,类型约束的方式除了泛型上下界之外,还可以通过视图界定,和上下文界定来实现,这两种方式主要和之前所学习的隐式转换和隐式值有关联。
此外,设T1和T2存在继承关系,P1和P2也存在继承关系,设函数f1:T2=>P1,而f2:T1=>P2,我们能否认为函数f2能够替代f1的功能,或者能否认为f2是f1的子函数?首先,这在Scala中,答案是肯定的。笔者会在后文从里氏替换原则的角度介绍这么理解的缘由。
定义第一个泛型类Java使用尖括号<>(有人也称其为钻石符号)来定义泛型(Scala中往往称它是"类型参数",但意思是相同的),而Scala则使用中括号[]表示泛型。我们来尝试第一个例子:定义一个Message类,将它内部包含的信息种类定义为泛型。
classMessage[E](s:E){defget:E=s}无论是定义泛型类还是定义泛型方法,都是将[]写在参数列表之前。
defgetMid[T](list:List[T]):T={list(list.length/2)}有关泛型的基本用法,笔者不在此阐述。在这里着重介绍Scala的类型约束部分。
类型约束类型约束分为上界(UpperBounds)和下界(LowerBounds)。
在Java的泛型机制中,如果限定某个泛型E是A类型的子类型(或者称A是泛型E的上界),其语法如:<TextendsA>。而Scala则使用[E<:A]来表示(<:类似于逻辑上的<=)。另外,Java中的?占位符在Scala中使用_来表示。
现在,试着使用Scala编写一个通用的比较方法greater,它可以对实现了Comparable接口的类实例进行比较。
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}在主函数中调用此函数,传入两个java.lang.Integer类型的实例:
println(compareVal(java.lang.Integer.valueOf(32),java.lang.Integer.valueOf(12)))在这里不使用Scala的Int类型,因为Scala中的AnyVal类型没有实现Comparable接口(这是Java范畴的内容,Scala使用Ordered特质)。不过,Scala提供了从AnyVal到Java包装类的隐式转换函数,而Java包装类是实现了Comparable接口的。因此,我们在调用这个函数时,需要用下面的写法表示在调用此方法之前,将Scala的Int类型入参全部隐式转换为java.lang.Integer类型。
compareVal[java.lang.Integer](31,21)下界与上转型对象在Java的泛型机制中,如果限定某个泛型E是A类型的父类型(或者称A是泛型E的下界),其语法如:<TsuperA>。Scala使用[E>:A]来表示(>:类似于逻辑上的>=)。
使用下界有时会引发直观上难以接受的现象。我们首先声明四个类:动物类(Animal),鸟类(Bird),鹦鹉类(Parrot)。此三者拥有继承关系,另外还有一个无关的车类(Car)。
classAnimal{defsound():Unit=println("thisisananimal")}classBirdextendsAnimal{overridedefsound():Unit=println("thisisabird")deffly():Unit=println("Thisbirdisflying...")}classParrotextendsBird{overridedefsound():Unit=println("thisisaparrot")overridedeffly():Unit=println("Thisparrotisflying...")}classCar在主函数中定义一个泛型函数如下:
defgetBirds[T>:Bird](things:ListBuffer[T]):ListBuffer[T]=things显然,我们为这个泛型T规定了下界,凭借直觉来看,我们装入things内部的元素至少应该是Bird,或者是Animal类型。如果传入的是Parrot类型,由于它突破了下界的"底线",编译器应当报出一个编译错误。比如说下面这样的写法:
//从常理来说,我们传入的Parrot实例不符合下界的约束,这段代码会报错。getBirds(ListBuffer(newParrot,newBird))foreach(_.sound())而实际上,这段代码是正确的。执行结果是:
thisisaparrotthisisabird在上述代码中,ListBuffer[T]中的T究竟是什么类型的呢?打开交互式终端REPL,将上述的所有声明输入进去,并执行下面的代码:
getBirds(ListBuffer(newParrot,newBird))执行这段代码后,会返回:
Scala>getBirds(ListBuffer(newParrot,newBird))res1:scala.collection.mutable.ListBuffer[Bird]=ListBuffer(Parrot@64aad809,Bird@1f03fba0)从res1反映的结果来看,当我们传入Parrot的实例时,它应该是被视作为Bird的上转型对象,因此程序没有报错。
之前的文章中曾经提醒过,操作上转型对象时,由于Java的动态绑定机制,因此程序总是选择执行尽可能最“具体”的方法。所以,控制台的第一行打印的是thisisaparrot,而不是thisisabird。那么下面做第二个实验,如果这个列表中混入了一个最高级的Animal实例。那么下面的代码可以运行吗?
defgetMid[T](list:List[T]):T={list(list.length/2)}1编译器会直接指出错误:无法解析fly方法。REPL显示,这个T类型目前被认为是Animal。
defgetMid[T](list:List[T]):T={list(list.length/2)}2很显然,为了保持这个列表的兼容性,泛型T在Parrot和Animal之间必须选择更抽象的那个类型,且显然不是所有的动物都具备fly方法。如果此时干脆传入一个与之毫不相关的类:Car,此时,T只能是最抽象的Object类型。
defgetMid[T](list:List[T]):T={list(list.length/2)}3在这种情况下,连map(_.sound())都无法调用。
泛型章节小结笔者根据上一小节的现象,给出以下推导:
对类型参数进行推断时,解释器总是选择“最高级”(或者称“最兼容”)的那个类型,直到Object类。
由于父类不一定拥有子类的方法,因此被推断出来的T类型实例不可以调用子类的方法。
和上界的*有所不同,传入元素的实际类型S可以是下界T的子类,而它会被转换成T类型的上转型对象。在调用其方法时,遵守Java的动态绑定机制。
Part2.视图界定与上下文界定视图界定和上下文界定是属于Scala独有的类型约束方式,不仅如此,这里还大量使用了隐式转换的技巧。在本章的例子开始,我们将不再使用Java的Comparator<T>和Comparable<T>接口实现比较功能,而使用Scala的Ordered[T]和Ordering[T]特质取代之。
视图界定视图界定的符号是<%,表示“ViewBounds”。它的使用范围比泛型<:符号更广,如果存在T<%S,则T未必是严格继承于S的子类,因为我们可以间接地提供由T到S的隐式转换函数来建立"T<:S"的关系,此时可称S是T的视界。
和之前的泛型上界下界有所不同,视图界定的理念更趋近于:利用隐式转换函数,将某些不匹配的T类型转换成适配的类型(类似于一种适配器模式)。
下面举一个实例,3美元和20元人民币之间如何比较大小呢?不同的货币之间是无法直接进行比较的。然而不论是何种货币,它们的价值总是和另外一种物品挂钩——那就是黄金。显然,要想对不同价值的货币进行比较,只需要将它们转换为等价的黄金,然后比较哪一方可兑换更多的黄金就可以说明问题了。
下面给出一些模板类的定义:
defgetMid[T](list:List[T]):T={list(list.length/2)}4其中,只有Gold实现了Ordered特质,因此只有Gold之间可以通过weight属性相互比较。额外提醒,Ordered特质的用法和Java的Comparable<T>本质上相同,除此之外,任何实现了Ordered接口的类可以使用<,>,>=...等符号直接进行比较。
下面再定义一个gt方法,它判别前一种货币是否比后者更值钱(或者等值)——通过它们可兑换黄金的多少判断。
defgetMid[T](list:List[T]):T={list(list.length/2)}5其中,T<%Gold表示允许都将其它不具备比价功能的类T,比如本代码块中的Money类转换成等值的Gold再比价。而这要求我们提供对应的隐式转换函数:
defgetMid[T](list:List[T]):T={list(list.length/2)}6在这段代码中,我们使用了模式匹配来完成对不同种货币的黄金兑换功能。
defgetMid[T](list:List[T]):T={list(list.length/2)}7随后就可以通过gt方法实现对不同种货币的比较了。当然,用于比较的T可以是任意其它类型,只要提供对应的隐式转换函数将其转换为Gold,就可以比较出它们的"价值"大小。
更推荐的写法这段代码运行起来不会有任何问题,然而编译器还是会弹出一个warning。原因是它更推崇将视图界定等效替换成隐式参数的写法。我们刚才定义的gt最好是这样定义的:
defgetMid[T](list:List[T]):T={list(list.length/2)}8这样的写法暴露了隐式参数的参数列表,对于代码的调用者而言,他可以主动地自定义T=>Gold函数的细节,而不仅依赖于定义域内提供的隐式转换函数了。
上下文界定上下文界定的符号为:,形式为[T:M]。它虽然和上界<:下界>:符号仅相差了<与>号,但是代表着截然不同的概念:上下文界定[T:M]表示这里依赖一个隐式值(或称上下文)来给定一个M[T],并调用M[T]的一些功能。
先从一个具体的例子开始说起。声明一个函数gtOrEq:它用于比较两个Person对象,并返回岁数较大的那一个(两者同岁返回前者)。
defgetMid[T](list:List[T]):T={list(list.length/2)}9Person是一个简单的样例类。
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}0gtOrEq方法需要接收一个Ordering[Person]比较器,这个由我们自行实现。由于全局只需要一个这样的比较器,因此这里使用object关键字创建了一个单例对象。
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}1现在,gtOrEq方法可以自动地通过PersonComparator实现对Person的比较了。
很显然,这个gtOrEq方法可以进行归纳,因为它不仅限于比较Person类,其实对于任何一个T类型,gtOrEq方法只需要保证能够接收上下文中提供的Ordering[T]作为比较规则,并调用其gteq方法即可正常运作。使用上下文界定能够很容易地表述这个逻辑,写法上也要更加简洁。
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}2这里没有定义隐式参数列表,那么gtOrEq函数内部现在如何调用Ordering[T]隐式值呢?所有的隐式转换,都会在编译期间完成绑定,Scala提供了implictly[E]来主动获取定义域内满足E类型的隐式值。从上述的代码来看,我们正是通过implicitly[Ordering[T]]获取到PersonComparator比较器的。
Part3.协变,逆变,不变设A是B的父类,另有一个泛型类C[T],并给出不变,逆变,协变的定义。
不变*(invariant)*,即C[T]:C[A]和C[B]没有从属关系,C[T]的继承顺序和T的继承顺序无关。
协变*(covariant)*,即C[+T]:C[A]是C[B]的父类,C[T]的继承顺序和T的继承顺序一致。
逆变*(contravariant)*,即C[-T]:C[B]是C[A]的父类。C[T]的继承顺序和T的继承顺序相反。
在这里,+号和-号均指代型变注解。它们只能用于泛型类,而不能用于泛型方法,因为我们说的型变(协变和逆变),不变概念是在描述类型参数和包含它的泛型类的关系。除此之外,型变注解可以和上界<:和下界>:符号搭配使用。
先举个不变的例子。在Java中,其List类是不变的。这意味着List[String]并不是List[Object]的子类,因此,这样的赋值是错误的:
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}3现在,我们上手Scala代码,尝试这三种情况会有哪些区别。首先给出三个非常简单的类声明:
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}4如果这个Box是不变的Box[T]:
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}5如果这个Box是协变的Box[+T]:
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}6如果这个Box是逆变的Box[-T]:
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}7如果一个类包含了多个泛型参数,则可能会同时存在逆变和协变的关系,比如:
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}8它表示,随着Function[T,R]的不断继承,T会变得更加抽象(越趋近于Any),而R会变得更加具体。那么,Scala引入协变,逆变的意义何在?简单来说,Scala想要为函数function提供一个像OOP那样可被"继承和拓展"的特性,以此来实现"更伟大的FP"。
深入里氏替换原则里氏替换原则*(LiskovSubstitutionPrinciple)*:如果在任何需要U类型的地方,都可以使用T类型替换,那么就可以安全地认为T是类型U的子类型。在OOP程序中,里氏替换原则最常用,也比较容易理解。比如下面这样一段Java代码:
defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}9显然,list是一个上转型对象,它使用了功能更强大的子类代替实现了父类(实际上是接口)的功能。显然,List要求实现的功能,LinkedList全都能做。它满足里氏替换原则,因此使用起来不会带来任何问题。
但反过来,在需要LinkedList的场合,却赋予了一个更抽象的List,则就是一段不安全的代码。原因是:程序可能依赖子类提供更精细的功能,但是其父类却未必能提供。除非将更细化的代码放到了父类上,但这样的程序本身违背了OCP开闭原则,也不符合逻辑上的认识。
描述函数间的"继承"关系在FP编程中,里氏替换原则的哲学是这样表述的:如果函数T,它能实现和函数U同样的功能,且仅需要更抽象的参数,就能够提供更具体的值,则我们可以认为函数T是函数U的子类型,或者称函数T比函数U功能更"强大"。没错,在Scala中可不只有上转型对象,还可以有"上转型函数"。
笔者暂且用简略的符号来表述T是U的"子函数":用P1,P2,...代表一个函数的参数列表中可能出现的类型,R1,R2...代表一个函数的返回值中可能出现的类型,设U:(P1,P2)=>(R1,R2),则T:(↑P1,↑P2)=>(↓R1,↓R2)。其中↑代表着对应Pi类型的上级父类,↓代表着对应Ri类型的下级子类。
显然,这里的参数类型呈现出两种现象:
位于参数列表的类型,其继承顺序和函数本身的继承关系是相反的,因为函数越"强大",参数越抽象。
位于返回值的类型,其继承顺序和函数本身的继承顺序是一致的,因为函数越“强大”,返回值越具体。
说得再明确一点:
参数列表和函数是逆变关系。
返回值和函数是协变关系。
如果我们需要定义一个存在上述继承关系的(P1,P2)=>(R1,R2)函数式接口,它应该是这样:
println(compareVal(java.lang.Integer.valueOf(32),java.lang.Integer.valueOf(12)))0在这个类的描述中,所有在函数的参数列表中出现的Pn都是逆变的,因此这里又被称之为逆变点。同样,所有在返回值中出现的Rn都是协变的,因此返回值这里又被称之为协变点。
编译器会对你的程序进行检查:如果在逆变点中出现的参数是协变的,或者说应该在协变点中出现的参数是逆变的,它都会提示你出现了这样的错误:
println(compareVal(java.lang.Integer.valueOf(32),java.lang.Integer.valueOf(12)))1为什么编译器会对此进行拦截呢?因为这两种情况都违背了函数间的里氏替换原则:
功能更"强大"的函数不应该去消费更具体的参数(不能用的更多)。
功能更"强大"的函数不应该生产出更抽象的返回值(不能造的更少)。
这样的原则满足了程序调用者的”贪心策略":当他不太清楚一个函数具体需要接受什么样的参数时,一定会希望仅仅传入最通用的类型,就能够使这个函数正常工作。这样,程序调用者就不必为了得到正确的调用结果而*去追究类型参数的细节内容。
更"贪心"的是,程序调用者又希望这个函数的返回值是细节齐全的。比如,对于某个字符串处理函数而言,程序员当然希望这个函数能够返回具体的String类型,而不是粗略地返回一个Any/Object类型。这样,程序调用者就不必再进行类型检查,或者是进行强制转换等诸多繁琐的工程了。
在下面的类型定义环节中,对于不涉及型变的类型参数T,它所处的位置将成为不变点,或者说这个位置不会再接收逆变或是协变的参数类型(后文有所涉及)。
println(compareVal(java.lang.Integer.valueOf(32),java.lang.Integer.valueOf(12)))2同时具备逆变协变特性的类型在这里,为了尝试可能的情况和错误,这里所设计的协变,逆变颇有“刻意”之嫌。我们暂时不去讨论这样的声明有什么实际用途,仅分析这样做编译器是否会报错。
还有一种特殊的情况,如果说一个类型参数K同时出现在参数列表和返回值中,并且还要满足里氏替换原则的规则,即希望传入更抽象的K,输出的ListBuffer能装载更详细的K类型,此时该怎么办?
println(compareVal(java.lang.Integer.valueOf(32),java.lang.Integer.valueOf(12)))3我们不能这样做,因为会让K具备歧义,但是可以选择等效替代法,即使用另一种符号O替换掉其中一种情况,比如借助符号O替代掉WorkShop和K之间的协变关系:
println(compareVal(java.lang.Integer.valueOf(32),java.lang.Integer.valueOf(12)))4反过来,使用符号O等效替代掉WorkShop和K之间的协变关系似乎也可行:
println(compareVal(java.lang.Integer.valueOf(32),java.lang.Integer.valueOf(12)))5在大部分情况下,等效替代哪一方应该都能表述同样的语义,但在这里不是。在该变换中,K出现在函数的协变点位置无可厚非。但是,K同时又作为ListBuffer的类型参数,这引发了冲突。原因有二:
在WorkShop自身的定义中,它和K是协变的。假定WorkShop[T]是WorkShop[U]的子类,那么根据里氏替换原则,分别调用它们各自的process方法产出的ListBuffer[T]也是ListBuffer[U]的子类。说得更简单些,就是WorkShop[+K]认为ListBuffer和K之间也应该是协变的。
然而在ListBuffer自身的定义中,ListBuffer和对应位置的类型参数(这里的A其实就是K)是不变的关系。