[comment]: # 不变(Invariant), 协变(Covarinat), 逆变(Contravariant) : 一个程序猿进化的故事

阿袁工作的第1天: 不变(Invariant), 协变(Covarinat), 逆变(Contravariant)的初次约

阿袁,早!开始工作吧。

阿袁在笔记上写下今天工作清单:

这个似乎是小菜一碟。

虽然不知道如何转换对象,那就定义一个函数参数,让外部把转换逻辑传进来。我真聪明啊!

这样,阿袁实现了第一个函数convert.

class ObjectHelper[TInput, TOutput] {
def convert(x: TInput, f: TInput => TOutput): TOutput = {
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 // 协变在输出中是允许的

应用场景:给一个函数参数(或变量)赋一个函数值。

输入参数类型 - 不变规则:给一个函数参数赋一个函数值时,传入函数的输入参数类型,可以是函数参数对应的泛型参数类型。

输入参数类型 - 逆变规则:给一个函数参数赋一个函数值时,传入函数的输入参数类型,可以是函数参数对应的泛型参数类型的父类。

输入参数类型 - 协变不能规则:给一个函数参数赋一个函数值时,传入函数的输入参数类型,不能是函数参数对应的泛型参数类型的子类。

输出参数类型 - 不变规则:给一个函数参数赋一个函数值时,传入函数的返回值类型,可以是函数参数对应的泛型参数类型。

输出参数类型 - 协变规则:给一个函数参数赋一个函数值时,传入函数的返回值类型,可以是函数参数对应的泛型参数类型的子类。

输出参数类型 - 逆变不能规则:给一个函数参数赋一个函数值时,传入函数的返回值类型,不能是函数参数对应的泛型参数类型的父类。

根据上面的发现,传入函数的输入类型不能是A3,输出类型不能是B1,依次列出下表:

A1B1no
A1B2yes
A1B3yes
A2B1no
A2B2yes
A2B3yes
A3B1no
A3B2no
A3B3no

测试代码:

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)
}
}

增加了测试代码:

    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;

不带关键字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
}

阿袁工作的第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:

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:能够写出协变、逆变的代码和测试用例

    • 针对类的测试用例
    • 针对函数的测试用例
    • 针对函数变量的测试用例

最后,阿静真美!

04-15 13:04
查看更多