[comment]: # 不变(Invariant), 协变(Covarinat), 逆变(Contravariant) : 一个程序猿进化的故事
阿袁工作的第1天: 不变(Invariant), 协变(Covarinat), 逆变(Contravariant)的初次约
阿袁,早!开始工作吧。
阿袁在笔记上写下今天工作清单:
实现一个scala类ObjectHelper,带一个功能:
- 函数1:将一个对象转换成另一种类型的对象。
这个似乎是小菜一碟。
虽然不知道如何转换对象,那就定义一个函数参数,让外部把转换逻辑传进来。我真聪明啊!
这样,阿袁实现了第一个函数convert.
class ObjectHelper[TInput, TOutput] {
def convert(x: TInput, f: TInput => TOutput): TOutput = {
f(x)
}
}
本文是用Scala语言写的示例。(最近开始学Scala)
Scala语言中的 expression-oriented 编程风格中,不写return, 最后一个语句的结果会被当成函数结果返回。
f(x) 等价于 return f(x)。
完成了。
哦,对了!昨天在和阿静交流后,猿进化了 - 知道要写单元测试。
单元测试
阿袁想考虑一下类的继承关系,在调用convert时,对函数参数f的赋值有没有什么限制。
先定义这几个类:
class A1 {}
class A2 extends A1 {}
class A3 extends A2 {}
class B1 {}
class B2 extends B1 {}
class B3 extends B2 {}
A系列的类,将会被用于输入的泛型参数类型。其关系为 A3 继承 A2 继承 A1。
B系列的类,将会被用于输出的泛型参数类型。其关系为 B3 继承 B2 继承 B1。
它们的笛卡尔乘积是9,就是说有9种组合情况。定义一个测试类:
object ObjectHelperTest {
def convertA1ToB1(x: A1) : B1 = {new B1()}
def convertA1ToB2(x: A1) : B2 = {new B2()}
def convertA1ToB3(x: A1) : B3 = {new B3()}
def convertA2ToB1(x: A2) : B1 = {new B1()}
def convertA2ToB2(x: A2) : B2 = {new B2()}
def convertA2ToB3(x: A2) : B3 = {new B3()}
def convertA3ToB1(x: A3) : B1 = {new B1()}
def convertA3ToB2(x: A3) : B2 = {new B2()}
def convertA3ToB3(x: A3) : B3 = {new B3()}
def test () = {
var helper = new ObjectHelper[A2, B2]()
var result : B2 = null
result = helper.convert(, ???)
}
}
- 问题:对于一个ObjectHelper[A2, B2]对象,上面的9个自定义的convertXtoY函数中,哪些可以用到convert的第二个参数上?
// 对于函数参数的输入参数的数据类型TInput,看看是否可以转换成传入函数的输入参数的数据类型?
TInput ---> f(x: TInputSuperType) // 逆变在输入中是允许的
TInput ---> f(x: TInput) // 不变在输入中是允许的
TInput -->X f(x: TInputSubType) // 协变在输入中是不允许的
// 对于传入函数的返回值,看看是否可以转换为调用函数的返回值类型TOutput?
f(): TOutputSuperType -->X TOutput // 逆变在输出中是不允许的
f(): TOutput ---> TOutput // 不变在输出中是允许的
f(): TOutputSubType ---> TOutput // 协变在输出中是允许的
注: 因为不能把一个子类对象转换成父类对象。
逆变(contravariant),可以理解为: 将一个对象转换成它的父类对象。
协变(coavariant),可以理解为: 将一个对象转换成它的子类对象。
应用场景:给一个函数参数(或变量)赋一个函数值。
输入参数类型 - 不变规则:给一个函数参数赋一个函数值时,传入函数的输入参数类型,可以是函数参数对应的泛型参数类型。
输入参数类型 - 逆变规则:给一个函数参数赋一个函数值时,传入函数的输入参数类型,可以是函数参数对应的泛型参数类型的父类。
输入参数类型 - 协变不能规则:给一个函数参数赋一个函数值时,传入函数的输入参数类型,不能是函数参数对应的泛型参数类型的子类。
输出参数类型 - 不变规则:给一个函数参数赋一个函数值时,传入函数的返回值类型,可以是函数参数对应的泛型参数类型。
输出参数类型 - 协变规则:给一个函数参数赋一个函数值时,传入函数的返回值类型,可以是函数参数对应的泛型参数类型的子类。
输出参数类型 - 逆变不能规则:给一个函数参数赋一个函数值时,传入函数的返回值类型,不能是函数参数对应的泛型参数类型的父类。
根据上面的发现,传入函数的输入类型不能是A3,输出类型不能是B1,依次列出下表:
输入类型 | 输出类型 | 是否可用 |
---|---|---|
A1 | B1 | no |
A1 | B2 | yes |
A1 | B3 | yes |
A2 | B1 | no |
A2 | B2 | yes |
A2 | B3 | yes |
A3 | B1 | no |
A3 | B2 | no |
A3 | B3 | no |
测试代码:
class A1 {}
class A2 extends A1 {}
class A3 extends A2 {}
class B1 {}
class B2 extends B1 {}
class B3 extends B2 {}
object ObjectHelperTest {
def convertA1ToB1(x: A1) : B1 = {new B1()}
def convertA1ToB2(x: A1) : B2 = {new B2()}
def convertA1ToB3(x: A1) : B3 = {new B3()}
def convertA2ToB1(x: A2) : B1 = {new B1()}
def convertA2ToB2(x: A2) : B2 = {new B2()}
def convertA2ToB3(x: A2) : B3 = {new B3()}
def convertA3ToB1(x: A3) : B1 = {new B1()}
def convertA3ToB2(x: A3) : B2 = {new B2()}
def convertA3ToB3(x: A3) : B3 = {new B3()}
def testConvert() = {
var helper = new ObjectHelper[A2, B2]()
var result : B2 = null
result = helper.convert(new A2(), convertA1ToB2)
println(result)
result = helper.convert(new A2(), convertA1ToB3)
println(result)
result = helper.convert(new A2(), convertA2ToB2)
println(result)
result = helper.convert(new A2(), convertA2ToB3)
println(result)
}
}
ObjectHelperTest.testConvert()
跑了一遍,都正常输出。在提交了写好的代码之后,阿袁开启了他的美好的学习时间。
阿袁工作的第2天: 协变(Covariant)用途的再次理解
第二天,阿静看到了阿袁的代码,准备在自己的工作中使用一下。
不久,阿袁看到阿静面带一种奇怪的微笑,走了过来,而目的地明显是他。让人兴奋,又有种不妙的感觉。
“阿袁,你写的ObjectHelper有点小问题哦!”
“有什么问题吗?我这次可是写了测试用例的。”
“我看了你的测试用例,我需要可以这样调用convert。”
阿静写出了代码:
helper.convert(new A2(), convertA3ToB2)
阿袁看到一个在阿静面前显摆的机会,立刻,毫不保留地向阿静讲解了自己的规则。
并说明这个用例违反了输入参数类型 - 协变不能规则。
“好吧,这样写code,总该可以吧?”,阿静继续问道。
helper.convert(new A3(), convertA3ToB2)
阿静把代码中的new A2()
改成new A3()
。
阿静继续说:
“调用者传入子类A3的实例,后台程序只要负责把这个实例传给处理函数convertA3ToB2不就行了。”
阿袁也看出了可能性。
“你说的有些道理。调用者可以维护输入参数和输入函数之间的一致性,这样就可以跳过输入参数类型 - 协变不能规则的约束。”
“我们发现了一个新的规则。”
输入参数类型 - 调用者的协变规则:调用者可以维护这样一种一致性:输入值 匹配 输入函数的输入参数类型,这样可以使用协变。
阿袁画出下面的说明草图:
// 对于函数参数的输入参数的数据类型TInput,看看是否可以转换成传入函数的输入参数的数据类型?
TInput -->X f(x: TInputSubType) // 协变在输入中是不允许的
// 然而, 如果调用者输入一个TInputSubType实例,
// 并且使用一个支持TInputSubType的函数f,造成了前后一致。
// 输入中的协变就变得允许了。
TInputSubType ---> convert(x: TInput, f(x: TInputSubType))
“谢谢!我把这个实现一下,我的代码可以进化了。”
阿袁使用了协变语法,代码变成了:
class ObjectHelper[TInput, TOutput] {
def convert[T1 <: TInput](x: T1, f: T1 => TOutput): TOutput = {
f(x)
}
}
使用了[T1 <: TInput],表示T1可以是TInput的子类。
增加了测试代码:
def testConvert() = {
//...
// covariant
result = helper.convert(new A3(), convertA3ToB2)
println(result)
result = helper.convert(new A3(), convertA3ToB3)
println(result)
}
阿袁工作的第3天: 逆变(Contravariant)用途的再次理解
阿袁昨晚并没有睡好,一直在考虑昨天的问题,既然,输入可以允许协变,那么是否有输出需要逆变的例子呢?
早上,找到了阿静,和她商量商量这个问题。
“关于昨天那个问题,你的例子证明了对于输入,有需要协变的情况。你觉得有没有对于输出,需要逆变的例子呢?”
“我想,我们可以从你的草图继续看下去。”
昨天,输出逆变的草图是这样:
// 对于传入函数的返回值,看看是否可以转换为调用函数的返回值类型TOutput?
f(): TOutputSuperType -->X TOutput // 逆变在输出中是不允许的
"怎么能变成这样呢?"
f(): TOutputSuperType ---> TOutput
“我觉得还是需要调用者,来参与。” 阿静说。
阿袁突然间醍醐灌顶的说道,“我明白了。调用者可以只接受父类类型。像这样子。”
// 对于传入函数的返回值,看看是否可以转换为调用函数的返回值类型TOutput?
f(): TOutputSuperType -->X TOutput // 逆变在输出中是不允许的
// 然而, 如果调用者使用一个返回值为TOutputSubType的函数f,
// 并且把调用函数的返回值赋给一个TOutputSubType对象。
// 输出中的逆变就变得允许了。
y: TOutputSubType = convert(x, f(): TOutputSubType): TOutput ---> TOutputSubType
“太好了,阿袁。今天又进化了。”
“好,我去把它改好。”
阿袁回去后,使用了逆变的语法,把ObjectHelper代码改成了:
class ObjectHelper[TInput, TOutput] {
def convert[T1 <: TInput, T2 >: TOutput](x: T1, f: T1 => T2): T2 = {
f(x)
}
}
测试用例也补全了:
def testConvert() = {
var helper = new ObjectHelper[A2, B2]()
var result : B2 = null
result = helper.convert(new A2(), convertA1ToB2)
println(result)
result = helper.convert(new A2(), convertA1ToB3)
println(result)
result = helper.convert(new A2(), convertA2ToB2)
println(result)
result = helper.convert(new A2(), convertA2ToB3)
println(result)
// covariant
result = helper.convert(new A3(), convertA3ToB2)
println(result)
result = helper.convert(new A3(), convertA3ToB3)
println(result)
// contrvariant
var resultB1 : B1 = null
resultB1 = helper.convert(new A2(), convertA1ToB1)
println(resultB1)
resultB1 = helper.convert(new A2(), convertA2ToB1)
println(resultB1)
// covariant & contrvariant
resultB1 = helper.convert(new A3(), convertA3ToB1)
println(resultB1)
}
阿袁工作的第4天:一个更简洁的实现
一个更简洁的实现
今天,阿袁在做了大量尝试后,发现一个简洁的实现方案。
似乎scala编译器,已经很好的考虑了这个问题。不用协变和逆变的语法也能支持想要的功能,
所有的9个函数都可以合理的使用。
def convert[TInput, TOutput](x: TInput, f: TInput => TOutput): TOutput = {
f(x)
}
也发现了C#中等价的实现方式:
public TOutput Convert<TInput, TOutput>(TInput x, Func<TInput, TOutput> f) {
return f(x);
}
对一个函数变量,会怎么样呢?
由于函数变量不能设定协变和逆变约束,因此只有最基本的四种函数可以设置。
def testConvertVariable() = {
var convertFun : A2 => B2 = null;
val convertFunA1ToB2 : A1 => B2 = convertA1ToB2
// set a function value
convertFun = convertFunA1ToB2
println(convertFun)
// set a function
convertFun = convertA1ToB2
println(convertFun)
convertFun = convertA1ToB3
println(convertFun)
convertFun = convertA2ToB2
println(convertFun)
convertFun = convertA2ToB3
println(convertFun)
}
C#中等价的实现方式:
delegate T2 ConvertFunc<in T1, out T2>(T1 x);
public static void TestDelegateGood() {
ConvertFunc<A2, B2> helper = null;
// set a function, ok
helper = ConvertA1ToB2;
// set a function variable, ok
ConvertFunc<A1, B3> helperA1ToB3 = ConvertA1ToB3;
helper = helperA1ToB3;
注意: delege中,使用了in/out。C#的逆变,协变语法。
不带关键字in/out的实现,有个小问题:
delegate T2 BadConvertFunc<T1, T2>(T1 x);
public static void TestDelegateBad() {
BadConvertFunc<A2, B2> helper = null;
// set a function, ok
helper = ConvertA1ToB2;
// set a function variable, error
ConvertFunc<A1, B3> helperA1ToB3 = ConvertA1ToB3;
// helper = helperA1ToB3; // complie error
}
可以看出关键字in/out在赋函数变量赋值的时候,会起到作用。但是不影响直接赋函数。
总觉得这个限制,可以绕过去似的。
阿袁工作的第5天:协变、逆变的一个真正用途。
昨天的简洁方案,让阿袁认识到了自己还没有明白协变、逆变的真正用途。
它们到底有什么用呢?难道只是编译器自己玩的把戏吗?
阿袁设计了这样一个用例:
这是一个新的ObjectHelper,提供了一个比较函数compare,
这个函数可以把比较两个对象,并返回一个比较结果。
class ObjectHelper[TInput, TOutput] (a: TInput) {
def x: TInput = a
def compare(y: TInput, f: (TInput, TInput) => TOutput): TOutput = {
f(x, y)
}
}
测试用例是这样,还是使用了A系列作为输入类型,B系列作为输出类型。
class A1 {}
class A2 extends A1 {}
class A3 extends A2 {}
class B1 {}
class B2 extends B1 {}
class B3 extends B2 {}
测试用例,考虑了这样一个case:
期望可以比较两个A3类型的数据,返回一个B1的比较结果。
可是我们只有一个A1对象的比较器,这个比较器可以返回一个B3的比较结果。
object ObjectHelperTest{
// 一个A1对象的比较器,可以返回一个B3的比较结果
def compareA1ToB3(x: A1, y: A1) : B3 = {new B3()}
def test(): Unit = {
// helper的类型是ObjectHelper[A2, B2]
var helper: ObjectHelper[A2, B2] = null
// 我们期望可以比较A3类型的数据,返回B1的比较结果。
helper = new ObjectHelper[A3, B1](new A3())
// 可是我们只有一个A1对象的比较器,可以返回一个B3的比较结果。
println(helper.compare(new A3(), compareA1ToB3))
}
}
ObjectHelperTest.test()
第一次测试
- 失败:
Line: helper = new ObjectHelper[A3, B1](new A3(), new A3())
error: type mismatch;
found : this.ObjectHelper[this.A3,this.B1]
required: this.ObjectHelper[this.A2,this.B2]
Note: this.A3 <: this.A2, but class ObjectHelper is invariant in type TInput.
You may wish to define TInput as +TInput instead. (SLS 4.5)
Note: this.B1 >: this.B2, but class ObjectHelper is invariant in type TOutput.
You may wish to define TOutput as -TOutput instead. (SLS 4.5)
helper = new ObjectHelper[A3, B1](new A3())
^
- 失败原因
类型匹配不上,错误信息提示要使用+TInput和-TOutput.
第二次测试
- 根据提示,修改代码为:
class ObjectHelper[+TInput, -TOutput] (a: TInput) {
def x: TInput = a
def compare(y: TInput, f: (TInput, TInput) => TOutput): TOutput = {
f(x, y)
}
}
- 再次运行,再次失败:
Line: def compare(y: TInput, f: (TInput, TInput) => TOutput): TOutput = {
error: contravariant type TOutput occurs in covariant position in type (y: TInput, f: (TInput, TInput) => TOutput)TOutput of method compare
def compare(y: TInput, f: (TInput, TInput) => TOutput): TOutput = {
^
error: covariant type TInput occurs in contravariant position in type TInput of value y
def compare(y: TInput, f: (TInput, TInput) => TOutput): TOutput = {
^
- 失败原因:
-TOutput为逆变,却要使用到协变的返回值位置上。+TInput为协变,却要使用到逆变的位置上。
第三次测试
根据提示,修改代码为:
class ObjectHelper[+TInput, -TOutput] (a: TInput) {
def x: TInput = a
def compare[T1 >: TInput, T2 <: TOutput](y: T1, f: (T1, T1) => T2): T2 = {
f(x, y)
}
}
再次运行,成功!
总结:
这个用例的一个特点是:在实际场合下,不能找到一个类型完全匹配的外部帮助函数。
一个糟糕的情况是,外部帮助函数的输入参数类型比较弱(就是说,是父类型),
可以使用逆变的方法,调用这个弱的外部帮助函数。
阿袁的日记
2016年9月X日 星期六
这几天,有了一些协变和逆变的经验。根据认识的高低,分为下面的几个Level。
-
Level 0:知道
- 其实,编译器和类库已经做好了一切,这些概念只是它们的内部把戏。我根本不用考虑它。
-
Level 1:知道
- 协变和逆变发生的场景
- 给一个泛型对象赋值
- 给一个函数变量赋值
- 给一个泛型函数传入一个函数参数
- 协变是将对象从父类型转换成子类型
- 逆变是将对象从子类型转换成父类型
- 协变和逆变发生的场景
-
Level 2:了解协变和逆变的语法
- Scala: +T : class的协变
- Scala: -T :class的逆变
- Scala: T <: S :function的协变
- Scala: T >: S : function的逆变
- C#: out :协变
- C#: in : 逆变
-
Level 3:理解协变和逆变发生的场景和用例
- 调用者对输入参数的协变用例
- 调用者对输出参数的逆变用例
- 调用者只有一个不平配的比较函数用例
// 对于函数参数的输入参数的数据类型TInput,看看是否可以转换成传入函数的输入参数的数据类型?
TInput ---> f(x: TInputSuperType) // 逆变在输入中是允许的
TInput ---> f(x: TInput) // 不变在输入中是允许的
TInput -->X f(x: TInputSubType) // 协变在输入中是不允许的
// 然而, 如果调用者输入一个TInputSubType实例,
// 并且使用一个支持TInputSubType的函数f,造成了前后一致。
// 输入中的协变就变得允许了。
TInputSubType ---> convert(x: TInput, f(x: TInputSubType))
// 对于传入函数的返回值,看看是否可以转换为调用函数的返回值类型TOutput?
f(): TOutputSuperType -->X TOutput // 逆变在输出中是不允许的
f(): TOutput ---> TOutput // 不变在输出中是允许的
f(): TOutputSubType ---> TOutput // 协变在输出中是允许的
// 然而, 如果调用者使用一个返回值为TOutputSubType的函数f,
// 并且把调用函数的返回值赋给一个TOutputSubType对象。
// 输出中的逆变就变得允许了。
y: TOutputSubType = convert(x, f(): TOutputSubType): TOutput ---> TOutputSubType
- Level 4:能够写出协变、逆变的代码和测试用例
- 针对类的测试用例
- 针对函数的测试用例
- 针对函数变量的测试用例
最后,阿静真美!