在将子类型多态性(面向对象)与参数多态性(泛型)相结合的任何编程语言中,都会出现方差问题。 假设我有一个字符串列表,键入List<String>
。 我可以将其传递给接受List<Object>
的函数吗? 让我们从这个定义开始:
interface List<T> {void add(T element);Iterator<T> iterator();...
}
破碎的协方差
凭直觉,我们可能首先认为应该允许这样做。 看起来不错:
void iterate(List<Object> list) {Iterator<Object> it = list.iterator();...
}
iterate(ArrayList<String>());
确实,包括Eiffel和Dart在内的某些语言确实接受此代码。 可悲的是,它是不完善的,如以下示例所示:
//Eiffel/Dart-like language with
//broken covariance:
void put(List<Object> list) {list.add(10);
}
put(ArrayList<String>());
在这里,我们将List<String>
传递给接受List<Object>
的函数,该函数尝试将Integer
添加到列表中。
Java使用数组也会犯同样的错误。 以下代码编译:
//Java:
void put(Object[] list) {list[0]=10;
}
put(new String[1]);
它在运行时失败,并带有ArrayStoreException
。
使用地点差异
Java对于通用类和接口类型采用了不同的方法。 默认情况下,类或接口类型为invariant ,即:
- 当且仅当
U
与V
完全相同类型时,才可将L<V>
分配给L<V>
。
由于在很多时候这非常不方便,因此Java支持一种称为“ 使用站点差异”的方法 ,其中:
-
L<U>
可分配给L<? extends V>
如果U
是V
的子类型,则L<? extends V>
,并且 -
L<U>
可分配给L<? super V>
L<? super V>
如果U
是的超类型V
。
丑陋的语法? extends V
? extends V
或? super V
? super V
称为通配符 。 我们还说:
-
L<? extends V>
L<? extends V>
在V
是协变的,并且 -
L<? super V>
L<? super V>
在V
是反变的。
由于Java的通配符表示法很丑陋,因此在本讨论中我们将不再使用它。 取而代之的是,我们将分别使用关键字in
和out
来表示通变量和协方差。 从而:
-
L<out V>
在V
是协变的,并且 -
L<in V>
是在逆变V
。
给定的V
称为通配符的边界 :
-
out V
是一个上限通配符,V
是其上限,并且 -
in V
是下界通配符,V
是其下界。
从理论上讲,我们可以有一个具有上限和下限的通配符,例如L<out X in Y>
。
我们可以使用交集类型表示多个上限或多个下限,例如L<out U&V>
或L<in U&V>
。
请注意,类型表达式L<out Anything>
和L<in Nothing>
指的是完全相同的类型,并且此类型是L
的所有实例的超类型。 您会经常看到人们将通配符类型称为存在性类型 。 他们的意思是,如果我知道该list
的类型为List<out Object>
:
List<out Object> list;
然后我知道存在一个未知的类型T
,这是Object
的子类型,因此list
的类型为List<T>
。
或者,我们可以从更宽泛的角度出发,说List<out Object>
是所有List<T>
类型的并集,其中T
是Object
的子类型。
在具有使用地点差异的系统中,以下代码无法编译:
void iterate(List<Object> list) {Iterator<Object> it = list.iterator();...
}
iterate(ArrayList<String>()); //error: List<String> not a List<Object>
但是这段代码可以做到:
void iterate(List<out Object> list) {Iterator<out Object> it = list.iterator();...
}
iterate(ArrayList<String>());
正确地,此代码无法编译:
void put(List<out Object> list) {list.add(10); //error: Integer is not a Nothing
}
put(ArrayList<String>());
现在我们在兔子洞的入口。 为了将通配符类型集成到类型系统中,同时拒绝如上例所示的错误代码,我们需要一种更为复杂的类型参数替换算法。
会员输入使用地点差异
也就是说,当我们有一个泛型类型类似List<T>
有一种方法void add(T element)
,而不是仅仅直截了当代Object
的T
,就像我们做普通不变的类型,我们需要考虑的方差类型参数出现的位置。 在这种情况下, T
出现在List
类型的反位置 ,即作为方法参数的类型。 我不会在这里写下的复杂算法告诉我们,在此位置我们应该用Nothing
(底部类型)代替。
现在想象一下我们的List
接口有一个带有以下签名的partition()
方法:
interface List<T> {List<List<T>> partition(Integer length);...
}
List<out Y>
的partition()
的返回类型是什么? 好吧,在不损失精度的情况下,它是:
List<in List<in Y out Nothing> out List<in Nothing out Y>>
哎哟。
由于没有人在他们的头脑中想去考虑这样的类型,因此明智的语言会抛弃其中的某些界限,留下这样的东西:
List<out List<out Y>>
这是可以接受的。 不幸的是,即使在这种非常简单的情况下,我们也已经远远超出了程序员可以轻松跟随类型检查器所做的工作的地步。
因此,这就是我不信任使用地点差异的原因所在:
- Ceylon设计的一个重要原则是,程序员应始终能够重现编译器的推理。 这是原因的一些与使用现场方差出现的复杂类型的非常困难。
- 它具有病毒性作用:一旦这些通配符类型在代码中立足,它们便开始传播,很难回到我的普通不变式类型。
申报地点差异
使用场所方差的一个更合理的选择是声明场所方差 ,在声明时我们指定泛型类型的方差。 这是我们在锡兰使用的系统。 在此系统下,我们需要将List
分为三个接口:
interface List<out T> {Iterator<T> iterator();List<List<T>> partition(Integer length);...
}interface ListMutator<in T> {void add(T element);
}interface MutableList<T>satisfies List<T>&ListMutator<T> {}
List
声明为协变类型, ListMutator
为逆变类型, MutableList
为两者的不变子类型。
似乎对多个接口的需求似乎是声明站点差异的一个很大的缺点,但事实证明,将变异与读取操作分开是很有用的,并且:
- 变异运算通常是不变的,而
- 读取操作通常是协变的。
现在我们可以这样编写函数:
void iterate(List<Object> list) {Iterator<Object> it = list.iterator();...
}
iterate(ArrayList<String>());void put(ListMutator<Integer> list) {list.add(10);
}
put(ArrayList<String>()); //error: List<String> is not a ListMutator<Integer>
您可以在此处阅读有关声明位置差异的更多信息。
为什么我们在锡兰需要使用场所差异
可悲的是,Java没有声明站点差异,并且与Java的干净互操作对我们来说很重要。 我不喜欢纯粹为了与Java互操作而在语言的类型系统中添加主要功能,因此多年来,我一直拒绝向Ceylon添加通配符。 最后,现实和实用性获胜,我的顽固失去了。 因此,锡兰1.1现在具有带有单界通配符的使用站点差异。
我试图尽可能严格地限制此功能,而仅提供体面的Java互操作所需的最低限度。 这意味着,就像在Java中一样:
- 没有形式为
List<in X out Y>
双界通配符,并且 - 通配符类型不能出现在类或接口定义的
extends
或satisfies
子句中。
此外,与Java不同:
- 没有隐含界的通配符,上限必须始终以显式形式编写,并且
- 不支持通配符捕获 。
通配符捕获是Java的一个非常聪明的功能,它利用了通配符类型的“现有”解释。 给定这样的通用函数:
List<T> unmodifiableList<T>(List<T> list) => ... :
Java让我调用unmodifiableList()
,传递一个通配符类型,如List<out Object>
,返回另一个通配符List<out Object>
,理由是存在一些未知的X
,这是Object
的子类型,对其进行调用是正确的。 也就是说,即使无法为任何T
将List<out Object>
类型分配给List<T>
,此代码也被认为是类型正确的代码:
List<out Object> objects = .... ;
List<out Object> unmodifiable = unmodifiableList(objects);
在Java中,涉及通配符捕获的键入错误几乎是无法理解的,因为它们涉及未知且难以理解的类型。 我没有计划向锡兰添加对通配符捕获的支持。
试试看
使用站点差异已经实现,并且已经在Ceylon 1.1中起作用,如果您非常有动力,可以从GitHub获得。
即使此功能的主要动机是强大的Java互操作性,但在通配符很有用的其他场合(可能很少见)。 但是,这并不表示我们的方法有任何重大变化。 除极端情况外,我们将继续在Ceylon SDK中使用声明站点差异。 更新: 我只是意识到我忘了感谢Ross Tate,感谢他为我提供了有关使用站点差异的成员键入算法的详细知识。 罗斯知道这些非常棘手的东西!
翻译自: https://www.javacodegeeks.com/2014/08/why-i-distrust-wildcards-and-why-we-need-them-anyway.html