泛型的泛型如何工作?

22

虽然我理解泛型的一些特殊情况,但以下示例中我还是有所遗漏。

我有如下类:

1 public class Test<T> {
2   public static void main(String[] args) {
3     Test<? extends Number> t = new Test<BigDecimal>();
4     List<Test<? extends Number>> l =Collections.singletonList(t);
5   }
6 }

第四行出现了错误。
Type mismatch: cannot convert from List<Test<capture#1-of ? extends Number>> 
to List<Test<? extends Number>>`. 

很明显,编译器认为不同的 ? 不是真正相等的。尽管我的直觉告诉我这是正确的。

有人能提供一个示例,在第4行合法的情况下会产生运行时错误吗?

编辑:

为了避免混淆,我用具体的赋值替换了第3行中的 =null


4
您可以通过编写Collections.<Test<? extends Number>> singletonList(t)使其编译。 - kennytm
2
通过使用 ?,您告诉编译器它们不一定相等,如果您希望它们被视为相等,则可以使用 T 或其他占位符类型。 - Jason Sperske
我对你问题中的这部分感到困惑:“can anyone provide an example where I would get a runtime-error if line 4 was legal?” 你是在问如果没有编译时错误,是否可能抛出运行时错误? - Jason Sperske
1
请查看通配符捕获和辅助方法 - wchargin
1
@Jonathan,我猜想你想传递不同类型的参数,每个参数都扩展了Number(而不是让事情崩溃),最好使用T并且有T extends Number。看起来你正在寻找灵活性以“做正确的事情”,泛型(集合只是对象的集合)被引入以解决这些问题(强制执行某种类型安全性)。 - Jason Sperske
显示剩余5条评论
5个回答

22

正如Kenny在评论中提到的那样,你可以通过以下方式解决这个问题:

List<Test<? extends Number>> l =
    Collections.<Test<? extends Number>>singletonList(t);

这立即告诉我们,该操作并非不安全,而只是受到了有限的推断的影响。如果它是不安全的,上述代码将无法编译。

既然在泛型方法中使用显式类型参数只是作为提示时才需要的,我们可以推测,在此处需要它是推断引擎的技术限制。事实上,Java 8编译器目前计划改进类型推断的许多方面。我不确定你的特定情况是否会得到解决。

那么,实际发生了什么?

好吧,我们得到的编译错误显示Collections.singletonList的类型参数T被推断为capture<Test<? extends Number>>。换句话说,通配符具有与特定上下文相关联的一些元数据。

  • 捕获通配符(capture<? extends Foo>)最好的理解方式是将其视为未命名类型参数,具有相同的边界(即<T extends Foo>,但无法引用T)。
  • 发挥捕获的威力的最佳方法是将其绑定到泛型方法的命名类型参数上。我将在下面的示例中演示这一点。请参见Java教程“Wildcard Capture and Helper Methods”(感谢@WChargin提供的参考)进行深入阅读。

假设我们想要一个可以将列表向后移动并循环的方法。然后假设我们的列表具有未知(通配符)类型。

public static void main(String... args) {
    List<? extends String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
    List<? extends String> cycledTwice = cycle(cycle(list));
}

public static <T> List<T> cycle(List<T> list) {
    list.add(list.remove(0));
    return list;
}

这很好地运作了,因为T被解析为capture<? extends String>,而不是? extends String。如果我们使用这个非泛型的cycle实现,情况将会如何:
public static List<? extends String> cycle(List<? extends String> list) {
    list.add(list.remove(0));
    return list;
}

它将无法编译,因为我们没有通过将其分配给类型参数使捕获可访问。

这就开始解释了为什么singletonList的消费者从类型推断器将T解析为Test<capture<? extends Number>而受益,从而返回一个List<Test<capture<? extends Number>>>而不是List<Test<? extends Number>>

但为什么一个不能赋值给另一个?

为什么我们不能将List<Test<capture<? extends Number>>>简单地分配给List<Test<? extends Number>>呢?

如果我们考虑到capture<? extends Number>相当于具有上限为Number的匿名类型参数,那么我们可以将这个问题转化为“为什么以下代码不能编译?”(它不能!):

public static <T extends Number> List<Test<? extends Number>> assign(List<Test<T>> t) {
    return t;
} 

这个代码没有编译通过是有充分的理由的。如果它能够编译通过,那么以下情况就会成为可能:

//all this would be valid
List<Test<Double>> doubleTests = null;
List<Test<? extends Number>> numberTests = assign(doubleTests);

Test<Integer> integerTest = null;
numberTests.add(integerTest); //type error, now doubleTests contains a Test<Integer>

那么为什么显式的方式有效呢?

让我们回到起点。如果上述内容是不安全的,那么为什么这个是被允许的:

List<Test<? extends Number>> l =
    Collections.<Test<? extends Number>>singletonList(t);

为了使此功能正常工作,需要允许以下内容:
Test<capture<? extends Number>> capturedT;
Test<? extends Number> t = capturedT;

好的,这不是有效的语法,因为我们无法直接引用捕获,所以让我们使用与上面相同的技术来评估它!让我们将捕获绑定到“assign”的另一个变体:

public static <T extends Number> Test<? extends Number> assign(Test<T> t) {
    return t;
} 

这段代码可以成功编译。很容易理解为什么它是安全的,因为它正是类似此用例的应用场景。
List<? extends Number> l = new List<Double>();

8

这里并没有潜在的运行时错误,只是编译器无法静态确定。每当您引起类型推断时,它会自动生成一个新的<? extends Number>捕获,而两个捕获不被视为等价。

因此,如果您通过为singletonList指定<T>来删除调用中的推断:

List<Test<? extends Number>> l = Collections.<Test<? extends Number>>singletonList(t);

它正常工作。生成的代码与您的调用合法时没有任何区别,只是编译器无法自行解决这个问题。

推断创建捕获并且捕获不兼容的规则是阻止此教程示例编译并在运行时崩溃的原因:

public static void swap(List<? extends Number> l1, List<? extends Number> l2) {
    Number num = l1.get(0);
    l1.add(0, l2.get(0));
    l2.add(0, num);
}

是的,语言规范和编译器可能可以更加复杂地区分您的例子和其他情况,但它并没有这样做,而且解决方法也很简单。


0

看一下类型擦除。问题在于“编译时”是Java强制执行这些泛型的唯一机会,因此如果它让这个通过,它将无法判断您是否尝试插入无效的内容。这实际上是一件好事,因为这意味着一旦程序编译完成,泛型在运行时不会产生任何性能损失。

让我们试着用另一种方式来看待你的例子(让我们使用两种扩展Number但行为非常不同的类型)。考虑以下程序:

import java.math.BigDecimal;
import java.util.*;

public class q16449799<T extends Number> {
  public T val;

  public static void main(String ... args) {
    q16449799<BigDecimal> t = new q16449799<>();
    t.val = new BigDecimal(Math.PI);

    List<q16449799<BigDecimal>> l = Collections.singletonList(t);
    for(q16449799<BigDecimal> i : l) {
      System.out.println(i.val);
    }
  }
}

这将输出(正如人们所期望的):

3.141592653589793115997963468544185161590576171875

现在假设您提供的代码没有引起编译器错误:

import java.math.BigDecimal;
import java.util.concurrent.atomic.AtomicLong;

public class q16449799<T extends Number> {
  public T val;

  public static void main(String ... args) {
    q16449799<BigDecimal> t = new q16449799<>();
    t.val = new BigDecimal(Math.PI);

    List<q16449799<AtomicLong>> l = Collections.singletonList(t);
    for(q16449799<AtomicLong> i : l) {
      System.out.println(i.val);
    }
  }
}

你期望输出什么?你不能合理地将BigDecimal转换为AtomicLong(你可以从BigDecimal的值构造一个AtomicLong,但是强制转换和构造是不同的事情,泛型被实现为编译时糖来确保强制转换成功)。至于@KennyTM的评论,在你的初始示例中寻找具体类型,但尝试编译此代码时会出现问题。
import java.math.BigDecimal;
import java.util.*;

public class q16449799<T> {
  public T val;

  public static void main(String ... args) {
    q16449799<? extends Number> t = new q16449799<BigDecimal>();

    t.val = new BigDecimal(Math.PI);

    List<q16449799<? extends Number>> l = Collections.<q16449799<? extends Number>>singletonList(t);
    for(q16449799<? extends Number> i : l) {
      System.out.println(i.val);
    }
  }
}

当你尝试给t.val赋值时,这将会导致错误。


拆箱/装箱的成本如何?此外,在其他语言中使用“真正”的泛型会有什么性能惩罚? - Theodoros Chatzigiannakis
装箱/拆箱只涉及从原始类型到对象类型的转换和相反的转换(适用于数字并具有运行时成本)。Java中的泛型是在Java类型系统的基础上实现的,非常向后兼容(retroweaver通过将一个jar添加到类路径中将它们添加到Java 1.4中(您仍然必须使用Java 1.5来编译它们)。我不能代表其他语言。 - Jason Sperske
正如我之前所写的:我对这段代码有一种不好的直觉,但我想不出具体的例子来说明它会出错。那么,有吗? - Jonathan
@JasonSperske 对,抱歉,用词不当。我本意是说向上转型/向下转型。我认为,使用类型擦除的泛型是通过将输入强制转换为“Object”,然后将任何从“Object”输出的内容进行强制转换(完全是为了保持您所说的兼容性)。这样对吗?在这种情况下,与像C#这样实际JIT编译通用类的不同具体实现并且不会在它们之间放置任何转换相比,这种转换是否有开销? - Theodoros Chatzigiannakis

0

也许这可以解释编译器的问题:

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

这个通用的通配符列表可以容纳任何继承自Number的元素。因此,将一个整数列表分配给它是可以的。然而,现在我可以向myNums中添加一个Double,因为Double也是从Number继承的,这会导致运行时问题。因此,编译器禁止对myNums进行任何写访问,我只能使用读取方法,因为我只知道无论我得到什么都可以转换为Number。

因此,编译器对这样的通配符泛型可以做很多事情表示不满。有时他会对你可以确保安全和OK的事情感到生气。

但幸运的是,有一个技巧可以避免这个错误,这样你就可以测试自己可能会破坏它的东西:

public static void main(String[] args) {

    List<? extends Number> list1 = new ArrayList<BigDecimal>();
    List<List<? extends Number>> list2 = copyHelper(list1);


}

private static <T> List<List<T>> copyHelper(List<T> list) {
    return Collections.singletonList(list);

}

我知道那段代码,它在Oracle教程中的某个地方,不幸的是,它不能与泛型的泛型一起使用。我尝试了我的示例和你的代码,在两种情况下都出现了“无法转换”的错误。 - Jonathan
是的,你说得对。我的IDE不再抱怨它了,但当我做整个项目时,我也会遇到错误(使用Java 1.7)。也许他们用一些Java更新修复了这个问题。然而问题存在于“?”符号,不能用它来创建对象(这就是singeltonList试图做的事情)。 - mszalbach

0

原因是编译器不知道通配符类型是相同的。

它也不知道你的实例是null。虽然null是所有类型的成员,但编译器只考虑声明的类型,而不是变量可能包含的,在类型检查时。

如果代码执行,它不会引起异常,但这只是因为值为null。仍然存在潜在的类型不匹配,这就是编译器的工作-禁止类型不匹配。


正如我已经写过的那样,我确实理解,这可能是危险的。问题是,如果该行合法,是否有运行时错误的示例? - Jonathan

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