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

时间:2023-12-18 20:13:14

[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:能够写出协变、逆变的代码和测试用例
    • 针对类的测试用例
    • 针对函数的测试用例
    • 针对函数变量的测试用例

最后,阿静真美!