什么是变型?

维基百科上变型的定义:

变型是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。

许多程序设计语言类型系统支持子类型。例如,如果CatAnimal的子类型,那么Cat类型的表达式可用于任何出现Animal类型表达式的地方。所谓的变型(variance)是指如何根据组成类型之间的子类型关系,来确定更复杂的类型之间(例如Cat列表之于Animal列表,返回值为Cat的函数之于返回值为Animal的函数…等等)的子类型关系。当我们用类型构造出更复杂的类型,原本类型的子类型性质可能被保持、反转、或忽略───取决于类型构造器的变型性质。例如在C#中:

  • IEnumerable<Cat>IEnumerable<Animal>的子类型,因为类型构造器IEnumerable<T>是协变的(covariant)。注意到复杂类型IEnumerable的子类型关系和其接口中的参数类型是一致的,亦即,参数类型之间的子类型关系被保持住了。

  • Action<Cat>Action<Animal>的超类型,因为类型构造器Action<T>是逆变的(contravariant)。(在此,Action<T>被用来表示一个参数类型为Tsub-T一级函数)。注意到T的子类型关系在复杂类型Action的封装下是反转的,但是当它被视为函数的参数时其子类型关系是被保持的。

  • IList<Cat>IList<Animal>彼此之间没有子类型关系。因为IList<T>类型构造器是不变的(invariant),所以参数类型之间的子类型关系被忽略了。

“复杂型别” 指的是如容器、函数之类的高级数据结构,因此变型描述的是通过继承关联的容器、函数的赋值兼容性。例如,我们能够将一个返回值为cat列表的方法返回值赋值到animal列表吗?我们能够将Audi列表作为接收cars列表方法的参数吗?

四种变型

在一门程序设计语言的类型系统中,一个类型规则或者类型构造器是:

  • 协变(covariant),如果它保持了子类型序关系≦。该序关系是:子类型≦基类型。
  • 逆变(contravariant),如果它逆转了子类型序关系。
  • 不变(invariant),如果上述两种均不适用。

数组

首先考虑数组类型构造器: 从Animal类型,可以得到Animal[](“animal数组”)。 是否可以把它当作

  • 协变:一个Cat[]也是一个Animal[]
  • 逆变:一个Animal[]也是一个Cat[]
  • 以上二者均不是(不变)?

如果要避免类型错误,且数组支持对其元素的读、写操作,那么只有第3个选择是安全的。Animal[]并不是总能当作Cat[],因为当一个客户读取数组并期望得到一个Cat,但Animal[]中包含的可能是个Dog。所以逆变规则是不安全的。

反之,一个Cat[]也不能被当作一个Animal[]。因为总是可以把一个Dog放到Animal[]中。在协变数组,这就不能保证是安全的,因为背后的存储可以实际是Cat[]。因此协变规则也不是安全的—数组构造器应该是不变。注意,这仅是可写(mutable)数组的问题;对于不可写(只读)数组,协变规则是安全的。

这示例了一般现像。只读数据类型是协变的;只写数据类型是逆变的。可读可写类型应是“不变”的。

Java中泛型是不变的,可有时需要实现逆变与协变,怎么办呢?这时,通配符?派上了用场:

<? extends>实现了泛型的协变,比如:

1
List<? extends Number> list = new ArrayList<Integer>();

<? super>实现了泛型的逆变,比如:

1
List<? super Number> list = new ArrayList<Object>();

extends确定了泛型的上界,而super确定了泛型的下界。

Java中的协变

如果 BA 的子类型, 那么 GenericType<B>GenericType<? extends A> 的子类型.

早期版本的Java不包含泛型(generics,即参数化多态)。在这样的设置下,使数组为“不变”将导致许多有用的多态程序被排除。

例如,考虑一个用于重排(shuffle)数组的函数,或者测试两个数组相等的函数,使用Objectequals方法. 函数的实现并不依赖于数组元素的确切类型,因此可以写一个单独的实现而适用于所有的数组:

1
2
boolean equalArrays (Object[] a1, Object[] a2);
void shuffleArray(Object[] a);

然而,如果数组类型被处理为“不变”,那么它仅能用于确切为Object[]类型的数组。对于字符串数组等就不能做重排操作了。

所以,Java把数组类型处理为协变。在Java中,String[]Object[]的子类型。

如前文所述,协变数组在写入数组的操作时会出问题。Java为此把每个数组对像在创建时附标一个类型。 每当向数组存入一个值,编译器插入一段代码来检查该值的运行时类型是否等于数组的运行时类型。如果不匹配,会抛出一个ArrayStoreException

1
2
3
4
5
6
7
8
// a 是单元素的 String 数组
String[] a = new String[1];
// b 是 Object 的数组
Object[] b = a;
// 向 b 中赋一个整数。如果 b 确实是 Object 的数组,这是可能的;然而它其实是个 String 的数组,因此会发生 java.lang.ArrayStoreException
b[0] = 1;

在上例中,可以从b中安全地读。仅在写入数组时可能会遇到麻烦。

这个方法的缺点是留下了运行时错误的可能,而一个更严格的类型系统本可以在编译时识别出该错误。这个方法还有损性能,因为在运行时要运行额外的类型检查。

Java有了泛型后,有了类型安全的编写这种多态函数。数组比较与重排可以给定参数类型

1
2
<T> boolean equalArrays (T[] a1, T[] a2);
<T> void shuffleArray(T[] a);

协变容器

Java允许子类型(协变)泛型类型,但它会严格限制可以“流入和流出“的泛型类型。换句话说,具有类型参数的返回值的方法是可访问的,而具有类型参数的输入参数的方法是不可访问的。

您可以为子类型赋值超类型:

1
2
3
4
// Type hierarchy: Person :> Joe :> JoeJr
List<? extends Joe> joes = new ArrayList<Joe>(); // OK,没问题👌
List<? extends Joe> joes = new ArrayList<JoeJr>(); // OK,没问题👌
List<? extends Joe> joes = new ArrayList<Person>(); // COMPILE ERROR

也可从协变容器中 读出 超类型:

1
2
3
4
5
// Type hierarchy: Person :> Joe :> JoeJr
List<? extends Joe> joes = new ArrayList<>();
Joe j = joes.get(0); // OK,没问题👌
Person p = joes.get(0); // OK,没问题👌
JoeJr jr = joes.get(0); // compile error (you don't know what subtype of Joe is in the list)

但向协变容器 写入 超类型是禁止的。在下例的代码中, 如果其他方法向 List<? extends Person> 参数中加入 JillList<Joe> 的调用房将会 混乱

1
2
3
4
5
6
// Type hierarchy: Person > Joe > JoeJr
List<? extends Joe> joes = new ArrayList<>();
joes.add(new Joe()); // compile error (无法知道joes接受Joe的什么子类型)
joes.add(new JoeJr()); // compile error (ditto)
joes.add(new Person()); // compile error (intuitive)
joes.add(new Object()); // compile error (intuitive)

Java中的不变

如果 AB 的超类型, 那么 GenericType<A> 不是 GenericType<B> 的超类型,以及反之亦然。

也就是说两种类型互不关联,且在任何条件下都不能互换。

不变容器

在Java中,不变量可能是您将遇到的泛型的第一个例子,并且是最直观的。类型参数的方法可以像人们期望的那样使用。可以访问类型参数的所有方法。

他们无法交换:

1
2
3
// Type hierarchy: Person :> Joe :> JoeJr
List<Person> p = new ArrayList<Joe>(); // COMPILE ERROR (a bit counterintuitive, but remember List<Person> is invariant)
List<Joe> j = new ArrayList<Person>(); // COMPILE ERROR

但可以向容器中添加对应对象:

1
2
3
4
5
// Type hierarchy: Person :> Joe :> JoeJr
List<Person> p = new ArrayList<>();
p.add(new Person()); // OK
p.add(new Joe()); // OK
p.add(new JoeJr()); // OK

也可以从容器中读取对象:

1
2
3
4
// Type hierarchy: Person :> Joe :> JoeJr
List<Joe> joes = new ArrayList<>();
Joe j = joes.get(0); // OK
Person p = joes.get(0); // OK

Java中的逆变

如果 AB 的超类型, 那么 GenericType<A>GenericType<? super B> 的超类型。

逆变容器

逆变容器表现同协变容器相反,也就是说,可以具有类型参数的输入参数的方法是可访问的,而具有类型参数的返回值的方法是不可访问的。

可以为超类型赋值子类型:

1
2
3
4
// Type hierarchy: Person > Joe > JoeJr
List<? super Joe> joes = new ArrayList<Joe>(); // OK
List<? super Joe> joes = new ArrayList<Person>(); // OK
List<? super Joe> joes = new ArrayList<JoeJr>(); // COMPILE ERROR

但不能从容器中读出特定类型

1
2
3
4
5
// Type hierarchy: Person > Joe > JoeJr
List<? super Joe> joes = new ArrayList<>();
Joe j = joes.get(0); // compile error (0位置的元素可以是Object或Person)
Person p = joes.get(0); // compile error (ditto)
Object o = joes.get(0); // 能够执行,因为Java中一切皆对象

可以向其添加直接子类型:

1
2
3
// Type hierarchy: Person > Joe > JoeJr
List<? super Joe> joes = new ArrayList<>();
joes.add(new JoeJr()); // allowed

但不能向其添加超类型:

1
2
3
4
// Type hierarchy: Person > Joe > JoeJr
List<? super Joe> joes = new ArrayList<>();
joes.add(new Person()); // compile error (again, could be a list of Object or Person or Joe)
joes.add(new Object()); // compile error (ditto)

具有 N-Type 参数结构的协变

对于如Functions这种更复杂的类型,规则同样适用,只是需要考虑多个参数规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Type hierarchy: Person > Joe > JoeJr
// 不变
Function<Person, Joe> personToJoe = null;
Function<Joe, JoeJr> joeToJoeJr = null;
personToJoe = joeToJoeJr; // COMPILE ERROR (personToJoe 是不变的)
// 协变
Function<? extends Person, ? extends Joe> personToJoe = null; // 协变
Function<Joe, JoeJr> joeToJoeJr = null;
personToJoe = joeToJoeJr; // OK
// 逆变
Function<? super Joe, ? super JoeJr> joeToJoeJr = null; // 逆变
Function<? super Person, ? super Joe> personToJoe = null;
joeToJoeJr = personToJoe; // OK

协变与继承

Java允许使用协变返回类型和异常类型重载方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Person {
Person get();
void fail() throws Exception;
}
interface Joe extends Person {
JoeJr get();
void fail() throws IOException;
}
class JoeImpl implements Joe {
public JoeJr get() {} // 重载
public void fail() throws IOException {} // 重载
}

但是尝试使用协变参数重载方法只会导致重写:

1
2
3
4
5
6
7
8
9
10
11
12
interface Person {
void add(Person p);
}
interface Joe extends Person {
void add(Joe j);
}
class JoeImpl implements Joe {
public void add(Person p) {} // overloaded
public void add(Joe j) {} // overloaded
}

Final Thoughts

协变为J​​ava带来了额外的复杂性。虽然协变的规则很容易理解,但关于类型参数方法的可访问性的规则是违反直觉的。理解它们需要暂停手头的工作,额外思考。

但是,我的日常经验是,细微差别通常不会受到影响:

  • 我不记得一个我必须声明逆变的实例,我很少遇到它们(虽然它们确实存在)。
  • 协变论证似乎更常见,但同时它们也更容易推理。

总结: 协变在日常编程中提供了适度的便利,特别是当需要与子类型的兼容性时(这在OOP中经常出现)。使用协变、逆变时主要参考PECS(Producer Extends Consumer Super)原则:

  • 频繁往外读取内容的,适合用上界Extends(协变)。
  • 经常往里插入的,适合用下界Super(逆变)。

原文链接

java-variance
协变与逆变