为什么数组是协变的,而泛型是不变的?

204

来自 Joshua Bloch 的《Effective Java》:

  1. 数组与泛型在两个重要方面有所不同。首先,数组是协变的,而泛型是不变的。
  2. 协变只是意味着如果 X 是 Y 的子类型,则 X[] 也将是 Y[] 的子类型。数组是协变的,因为字符串是 Object 的子类型,所以

    String[] 是 Object[] 的子类型

    不变只是意味着无论 X 是否是 Y 的子类型,

     List<X> will not be subType of List<Y>.
    

    我的问题是为什么在Java中将数组协变?还有其他的SO帖子,比如为什么数组是不变的,但列表是协变的,但它们似乎专注于Scala,我无法理解。


1
这不是因为后来添加了泛型吗? - Sotirios Delimanolis
1
我认为比较数组和集合是不公平的,因为集合在后台使用数组!! - Ahmed Adel Ismail
6
举例来说,不是所有的集合都适用,比如LinkedList - Paul Bellora
@PaulBellora 我知道 Map 和 Collection 的实现方式不同,但我在 SCPJ6 中读到 Collections 通常依赖于数组!! - Ahmed Adel Ismail
因为在集合中插入错误的元素时没有ArrayStoreException,而数组有。因此,集合只能在检索时通过强制转换来发现这个问题。因此,泛型将确保解决此问题。 - Kanagavelu Sugumar
我在某处读到,即使是使用数组的决定也是一个错误的决定,后来感到遗憾。 - akhil_mittal
8个回答

188

通过维基百科

Early versions of Java and C# did not include generics (a.k.a. parametric polymorphism).

In such a setting, making arrays invariant rules out useful polymorphic programs. For example, consider writing a function to shuffle an array, or a function that tests two arrays for equality using the Object.equals method on the elements. The implementation does not depend on the exact type of element stored in the array, so it should be possible to write a single function that works on all types of arrays. It is easy to implement functions of type

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

However, if array types were treated as invariant, it would only be possible to call these functions on an array of exactly the type Object[]. One could not, for example, shuffle an array of strings.

Therefore, both Java and C# treat array types covariantly. For instance, in C# string[] is a subtype of object[], and in Java String[] is a subtype of Object[].

这段文字回答了一个问题:“为什么数组是协变的?”或者更准确地说,“为什么在当时数组被设计成协变的?” 当引入泛型时,出于Jon Skeet在这篇回答中指出的原因,它们故意没有被设计成协变。

No, a List<Dog> is not a List<Animal>. Consider what you can do with a List<Animal> - you can add any animal to it... including a cat. Now, can you logically add a cat to a litter of puppies? Absolutely not.

// Illegal code - because otherwise life would be Bad
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // Awooga awooga
animals.add(new Cat());
Dog dog = dogs.get(0); // This should be safe, right?

Suddenly you have a very confused cat.

维基百科文章中描述数组协变的原始动机并不适用于泛型,因为通配符使得协变(和逆变)的表达成为可能,例如:

boolean equalLists(List<?> l1, List<?> l2);
void shuffleList(List<?> l);

5
是的,数组允许多态行为,但是它会引入运行时异常(与泛型的编译时异常不同)。例如:Object[] num = new Number[4]; num[1]= 5; num[2] = 5.0f; num[3]=43.4; System.out.println(Arrays.toString(num)); num[0]="hello"; - eagertoLearn
27
没问题。数组具有可实例化类型并会在需要时抛出 ArrayStoreException 异常,这是正确的。显然,当时认为这是一个值得妥协的地方。与之形成对比的是,现在许多人认为数组协变是一个错误,在事后看来如此。 - Paul Bellora
5
为什么“许多人”认为这是个错误?它比没有数组协变性要更有用。你看到过多少次 ArrayStoreException 错误?它们非常罕见。在我看来,这里的讽刺是不可原谅的……Java 中最糟糕的错误之一是使用点通配符的使用站点方差。 - Scott
3
为什么许多人认为这是一个错误?按我的理解,这是因为数组协变需要在所有数组赋值操作上进行动态类型检查(尽管编译器优化可能有所帮助),这可能会导致显着的运行时开销。 - Dominique Devriese
2
谢谢,Dominique,但是从我的观察来看,许多人认为这是一个错误的原因更多地是在于模仿其他人所说的话。再次审视数组协变性,它比有害得多。实际上,Java 犯下的真正大错是使用通配符进行使用点泛型方差。我认为这引起了比“许多”人想承认的问题更多的问题。 - Scott
数组是具体化的,泛型是被擦除的(泛型仅在编译时存在)。基本上,对于泛型来说,不变性优于协变性。 - Emad Aghaei

38

原因是每个数组在运行时都知道其元素类型,而由于类型擦除,通用集合则不知道其元素类型。

例如:

String[] strings = new String[2];
Object[] objects = strings;  // valid, String[] is Object[]
objects[0] = 12; // error, would cause java.lang.ArrayStoreException: java.lang.Integer during runtime
如果可以在泛型集合中实现这一点:
List<String> strings = new ArrayList<String>();
List<Object> objects = strings;  // let's say it is valid
objects.add(12);  // invalid, Integer should not be put into List<String> but there is no information during runtime to catch this

但是,当有人尝试访问该列表时,这会导致问题:

String first = strings.get(0); // would cause ClassCastException, trying to assign 12 to String

我认为Paul Bellora的回答更加恰当,因为他评论了数组为什么是协变的原因。如果将数组设为不变,则可以使用类型擦除。类型擦除属性的主要原因是为了向后兼容,对吗? - eagertoLearn
数组知道它们的类型这一事实意味着,虽然协变性允许代码“请求”将某些内容存储到数组中,但它并不意味着这样的存储将被允许发生。因此,由于数组具有协变性而引入的危险程度要比它们不知道自己的类型时要小得多。 - supercat
@supercat,正确,我想指出的是对于已经进行类型擦除的泛型,协变无法通过最小化的运行时检查来实现。 - Katona
2
我个人认为这个答案提供了正确的解释,说明为什么数组可以是协变的,而集合则不行。谢谢! - asgs
1
@mightyWOZ 我认为这个问题是关于为什么数组和泛型在方差方面不同。在我看来,泛型不能协变比较令人惊讶,所以我在我的回答中重点关注了它们。 - Katona
显示剩余2条评论

29

也许这篇文章能帮到您:

泛型不支持协变

Java语言中的数组是支持协变的 -- 这意味着如果Integer是Number的子类(事实如此),那么一个Integer也是一个Number,一个Integer[]也是一个Number[],您可以自由地将一个Integer[]传递或赋值给一个需要Number[]的位置。(更正式地说,如果Number是Integer的超类型,则Number[]Integer[]的超类型。)您可能认为泛型类型也是如此 -- List<Number>List<Integer>的超类型,您可以将List<Integer>传递给需要List<Number>的位置。不幸的是,事实并非如此。

原来泛型不支持协变有一个很好的理由:这样会破坏泛型本应提供的类型安全性。想象一下,如果您可以将一个List<Integer>赋值给一个List<Number>,那么以下代码将允许您将一个非Integer类型的东西放入List<Integer>中:

List<Integer> li = new ArrayList<Integer>();
List<Number> ln = li; // illegal
ln.add(new Float(3.1415));
因为ln是一个List<Number>,所以将Float添加到其中似乎完全合法。但是如果ln与li别名,则会破坏li定义中隐含的类型安全承诺——它是整数列表,这就是为什么泛型类型不可能是协变的原因。

5
对于数组,如果在运行时出现ArrayStoreException异常。 - Sotirios Delimanolis
4
我的问题是为什么数组被协变地制作。正如Sotirios所提到的,如果使用数组,则在运行时会得到ArrayStoreException,如果将数组制作为不变,则我们可以在编译时检测到此错误,对吗? - eagertoLearn
@eagertoLearn:Java 的一个主要语义缺陷是它的类型系统中没有任何东西可以区分“仅包含Animal派生类的数组,不必接受来自其他地方的任何项目”和“必须只包含Animal的数组,并且必须愿意接受外部提供的Animal引用”。需要前者的代码应该接受Cat数组,但需要后者的代码不应该。如果编译器能够区分这两种类型,它可以提供编译时检查。不幸的是,唯一区分它们的... - supercat
...的问题是代码是否真的尝试将任何内容存储到它们中,而在运行时没有办法知道。 - supercat

4

参数化类型的一个重要特性是能够编写多态算法,即操作数据结构而不考虑其参数值的算法,例如Arrays.sort()

使用泛型,可以通过通配符类型来实现:

<E extends Comparable<E>> void sort(E[]);

为了真正有用,通配符类型需要通配符捕获,而这需要类型参数的概念。在 Java 添加数组时没有这些东西可用,使引用类型的数组协变可以允许一种更简单的方式来允许多态算法:
void sort(Comparable[]);

然而,这种简单性在静态类型系统中开了一个漏洞:
String[] strings = {"hello"};
Object[] objects = strings;
objects[0] = 1; // throws ArrayStoreException

需要翻译的内容如下:

需要对引用类型数组的每个写入访问进行运行时检查。

简而言之,泛型所代表的新方法使类型系统更加复杂,但也更加静态类型安全,而旧方法则更简单,但静态类型安全性较低。语言设计者选择了更简单的方法,因为他们有更重要的事情要做,而不是关闭类型系统中很少出现问题的小漏洞。后来,在Java确立并解决了紧迫的需求后,他们有资源为泛型做正确的处理(但更改数组会破坏现有的Java程序)。


3

数组之所以是协变的,至少有两个原因:

  • 对于永远不会更改的信息的集合来说,协变非常有用。要使T的集合协变,其后备存储也必须是协变的。虽然可以设计一个不使用T[]作为后备存储的不可变T集合(例如使用树或链表),但这样的集合可能不如由数组支持的集合性能好。可以认为提供协变不可变集合的更好方法是定义一种“协变不可变数组”类型,它们可以使用作为后备存储,但仅允许数组协变可能更容易。

  • 代码经常会通过不知道将要在其中放置什么类型的内容而对数组进行修改,但不会将任何未从同一数组中读取的内容放入该数组。排序代码是一个典型例子。从概念上讲,数组类型可能包括交换或排列元素的方法(这些方法同样适用于任何数组类型),或定义一个“数组操作器”对象,它保存对数组和已从中读取的一个或多个东西的引用,并且可以包括将先前读取的项目存储到它们来自的数组中的方法。如果数组不是协变的,则用户代码将无法定义此类类型,但运行时可以包含一些专门的方法。

数组是协变的事实可能被视为一个丑陋的hack,但在大多数情况下,它有助于创建可工作的代码。


2
数组是协变的这一事实可能被视为一个丑陋的技巧,但在大多数情况下,它有助于创建有效的代码。-- 说得好 - eagertoLearn

3

我认为他们在最初使数组协变方面做出了错误的决定。正如这里所描述的那样,它破坏了类型安全性,因此他们因为向后兼容而被困扰,并且在此之后尝试避免在泛型中犯同样的错误。 这也是Joshua Bloch在书籍《Effective Java(第二版)》的第25条建议中更喜欢使用列表(lists)而不是数组。


1
Josh Block是Java的集合框架(1.2)的作者,也是Java的泛型(1.5)的作者。因此,建立每个人都抱怨的泛型的那个家伙恰好也是写了一本书说它们是更好的方法的人?这并不是一个巨大的惊喜! - cpurdy

2
泛型是不变的:来自JSL 4.10

......子类型不会扩展到泛型类型:T <: U并不意味着C<T> <: C<U> ...

JLS还解释了几行,
数组是协变的(第一个项目): 4.10.3 数组类型之间的子类型

enter image description here


1
我的看法是:当代码期望一个数组A[],而你给它一个B[],其中B是A的子类时,只有两件事需要担心:读取数组元素时会发生什么以及如果写入它会发生什么。因此,在所有情况下保留类型安全性并不难编写语言规则(主要规则是,如果您尝试将A插入B []中,则可能会抛出ArrayStoreException)。然而,对于泛型而言,当您声明一个类SomeClass 时,在类体中使用T的方式可以有任意数量,我猜想这就太复杂了,无法找出所有可能的组合来编写规则,以确定何时允许使用哪些东西,何时不允许。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接