问答文章1 问答文章501 问答文章1001 问答文章1501 问答文章2001 问答文章2501 问答文章3001 问答文章3501 问答文章4001 问答文章4501 问答文章5001 问答文章5501 问答文章6001 问答文章6501 问答文章7001 问答文章7501 问答文章8001 问答文章8501 问答文章9001 问答文章9501

Scala泛型中的Liskov哲学

发布网友 发布时间: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)}9

Person是一个简单的样例类。

defcompareVal[T<:Comparable[T]](t1:T,t2:T):T={if(t1.compareTo(t2)>0)t1elset2}0

gtOrEq方法需要接收一个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)是不变的关系。

声明声明:本网页内容为用户发布,旨在传播知识,不代表本网认同其观点,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。E-MAIL:11247931@qq.com
两台手机同在一个抖音号,一台关闭了活跃状态,另一台是不是也显示关闭... 为什么会免费存在推股票的QQ群 谁能推荐几个好的股票类的QQ群? 创作出《大中国》的高枫曾经红极一时,为什么后来变得籍籍无名? 芬达是哪个国家的饮料 走步前做哪些热身动作 2010年有什么大事发生啊???做课件要用的 哪个软件可以查看局域网IP是否通,它是小格子形式的,通的就是绿色的... 我家电脑IP地址写成其他人的怎么办,我家里是小区光钎的,写成别人的没事... 局域网里为什么有的IP是重复的,而且流量很小? 找兼职要交会员费可靠吗? 遇到兼职交会员的。怎样判断是骗子 活检性质待查是什么意思? 现颅压高230,磁共振查得颅内有两个病灶,刚做了腰穿,结果待查中 SOS:我用电驴下了一部电影,因为缺少编码什么的不能看,请问谁能帮助我... 孩子视力下降如何恢复 若在酒宴上因劝酒喝醉和主家 一起摔倒导致脑溢血花费十几万,而主家 什么是24小时到账 英雄联盟手游剑姬和剑圣哪个厉害 转账为什么延迟24小时 剑圣剑姬哪个 剑姬和剑圣哪个厉害 信长之野望哪代最好玩? 白芥子加醋外敷法 郑板桥陵园陵园概述 郑板桥哪个朝代的人 郑板桥是哪个频道 郑板桥的创作风格(郑板桥简介) 郑板桥朝代关于郑板桥朝代介绍 阅读:挑山工 答案 Scala | 教程 | 学习手册 --- 字面量/值/变量和类型 【Gradle多模块系列2】在子项目之间声明依赖关系和共享构建逻辑示例详 ... ...车主负全责,对方车也有全险,我应该起诉车主还是双方都要起诉?谢谢... 准妈妈在孕早期可能会经历哪些情绪波动? 怀孕后身体难受情绪低落该怎么办? 怀孕后情绪波动大爱哭的原因是什么 怀孕后情绪不稳定是什么原因引起的? 准妈咪发脾气很可能是胎儿宫内缺氧的信号,要怎么应对? 为什么怀孕期间老想哭 七夕写给男友最暖的话 七夕写给男朋友的话 我想问一下怎样才能进华硕电脑集团/昌硕科技(上海)有限公司工作 华硕电脑昌硕科技 ( 上海 ) 有限公司还招聘普工吗? 现在的待遇怎么样... 别人用手机卡贷了款然后没用了,然后手机卡被我去手机店里办卡办办到了... 给别人实名制手机号和银行卡他们办了贷款能提出钱来吗 这是什么植物,枝上有刺,硬硬的叶子边缘也有刺,开花像毛毛虫,海南山林里... 有一种石灰石样品,其中含有的杂质为二氧化硅,二氧化硅既不溶于水... 几个杀毒软件都试了,就只有金山手机卫士查得出,却又删不了,各位大 什么是调档线和位次 "位次优先、遵循志愿,一轮投档"是什么意思?救命!!!急!!! 如何获取苹果手机天气情况的天气信息