【问题标题】:Are Java generics really this clumsy? Why?Java 泛型真的这么笨拙吗?为什么?
【发布时间】:2012-01-05 06:16:00
【问题描述】:

陪我一会儿。我知道这听起来有点主观和争论,但我发誓最后有一个问号,这个问题实际上可以客观地回答......

来自 .NET 和 C# 背景,近年来,我被泛型与扩展方法相结合在许多 .NET 常见问题的解决方案中提供的语法糖宠坏了。使 C# 泛型如此强大的关键特性之一是,如果其他地方有足够的信息,编译器可以推断类型参数,所以我几乎不需要写出它们。在您意识到您节省了多少击键之前,您不必编写很多代码行。例如,我可以写

var someStrings = new List<string>();
// fill the list with a couple of strings...
var asArray = someStrings.ToArray();

而 C# 只会知道我的意思是第一个 varList&lt;string&gt;,第二个是 string[] 并且 .ToArray() 真的是 .ToArray&lt;string&gt;()

那我来Java。

我对 Java 泛型了解得足够多,知道它们在本质上是不同的,除此之外,编译器实际上不会编译为泛型代码——它会剥离类型参数并生成它无论如何,以某种(相当复杂的)方式(我还没有真正理解)工作。但即使我知道 Java 中的泛型根本不同,我还是不明白为什么需要这样的构造:

 ArrayList<String> someStrings = new ArrayList<String>();
 // fill the list with a couple of strings...
 String[] asArray = someStrings.toArray(new String[0]); // <-- HERE!

到底为什么我必须实例化一个新的String[],其中没有任何元素,不会用于任何事情,以便Java编译器知道它是String[]和不是我想要的任何其他类型的数组吗?

我意识到这就是重载的样子,而toArray() 当前返回的是Object[]。但是,为什么在发明 Java 的这一部分时会做出这个决定呢?为什么这种设计比完全跳过返回Object[]overload 的.toArray() 而只使用返回T[]toArray() 更好?这是编译器的限制,还是框架这部分设计者的想象,还是其他什么?

正如你可能从我对最不重要的事情的强烈兴趣中看出的那样,我有一段时间没睡了……

【问题讨论】:

  • stackoverflow.com/questions/520527/… 提供了很好的信息
  • 由于 Java 7 提供了“钻石”运算符,这使得泛型的实例化稍微(实际上只是一点点)更舒服:ArrayList&lt;Integer&gt; l = new ArrayList&lt;&gt;();
  • @home:“钻石运算符”确实让事情变得更容易了。有点... ;) 但我仍然觉得它很...有趣... Java 要求我指定集合的​​类型 4 次,而 C# 只需要指定一次。如果我执行 C# 中不允许的操作(例如,尝试将字符串强制转换为整数),我仍然会遇到编译错误,但 编译器 会为我跟踪。如果我愿意,我也可以,但我不必
  • @TomasLycken:C# 受益于能够从 Java 在此过程中犯下的许多错误中学习。如果 Java 放弃了向后兼容性,那么它也可以做很多 C# 做得好的事情。在 Java 中需要声明的数量非常可笑,但它是一种较旧的语言,其中包含许多新特性。
  • “从我对最不重要的事情的极度兴趣中你可能可以看出......”。这是邀请我们否决您的问题吗? :-)

标签: java generics


【解决方案1】:

不,这些原因大多是错误的。它与“向后兼容性”或类似的东西无关。这不是因为有一个返回类型为Object[] 的方法(许多签名在适当的情况下为泛型更改了)。也不是因为采用数组可以避免重新分配数组。他们没有“错误地忽略它”或做出错误的设计决定。它们没有包含T[] toArray(),因为它不能用数组的工作方式和泛型中类型擦除的工作方式来编写。

声明List&lt;T&gt; 的方法具有签名T[] toArray() 是完全合法的。但是,没有办法正确实现这种方法。 (你为什么不尝试一下作为练习呢?)

请记住:

  • 数组在运行时知道创建它们的组件类型。在运行时检查对数组的插入。并且在运行时检查从更通用的数组类型到更具体的数组类型的强制转换。要创建数组,您必须在运行时知道组件类型(使用new Foo[x] 或使用Array.newInstance())。
  • 泛型(参数化)类型的对象不知道创建它们时使用的类型参数。类型参数被擦除到它们的擦除(下限),并且只有那些在运行时被检查。

因此不能创建类型参数组件类型的数组,即new T[...]

事实上,如果 Lists 有一个方法 T[] toArray(),那么目前无法实现的泛型数组创建 (new T[n]) 将成为可能:

List<T> temp = new ArrayList<T>();
for (int i = 0; i < n; i++)
    temp.add(null);
T[] result = temp.toArray();
// equivalent to: T[] result = new T[n];

泛型只是编译时的语法糖。可以通过更改一些声明并添加强制转换和其他东西来添加或删除泛型,而不会影响代码的实际实现逻辑。让我们比较一下 1.4 API 和 1.5 API:

1.4 API:

Object[] toArray();
Object[] toArray(Object[] a);

在这里,我们只有一个 List 对象。第一个方法有一个声明的返回类型Object[]并且它创建了一个运行时类Object[]的对象。 (请记住,变量的编译时(静态)类型和对象的运行时(动态)类型是不同的。)

在第二种方法中,假设我们创建了一个String[] 对象(即new String[0])并将其传递给它。数组根据其组件类型的子类型有子类型关系,所以String[]Object[]的子类,所以这是find。这里最需要注意的是它返回一个运行时类String[]的对象,即使它声明的返回类型是Object[]。 (同样,String[]Object[] 的子类型,所以这并不罕见。)

但是,如果您尝试将第一个方法的结果转换为类型String[],您将得到一个类转换异常,因为如前所述,它的实际运行时类型是Object[]。如果您将第二种方法的结果(假设您传入了String[])转换为String[],它将成功。

因此,即使您可能没有注意到(这两种方法似乎都返回 Object[]),这两种方法在 pre-Generics 中实际返回的对象已经存在很大的根本差异。

1.5 API:

Object[] toArray();
T[] toArray(T[] a);

完全同样的事情发生在这里。泛型添加了一些不错的东西,比如在编译时检查第二种方法的参数类型。但基本原理还是一样的:第一个方法创建一个真正运行时类型为Object[]的对象;第二种方法创建一个对象,其真正的运行时类型与您传入的数组相同。

事实上,如果你尝试传入一个数组,其类实际上是T[] 的子类型,比如U[],即使我们有List&lt;T&gt;,猜猜它会做什么?它将尝试将所有元素放入U[] 数组中(这可能会成功(如果所有元素的类型恰好是U),或者失败(如果不是))返回一个实际类型为U[] 的对象.

所以回到我之前的观点。为什么不能做一个方法T[] toArray()?因为您不知道要创建的数组类型(使用newArray.newInstance())。

T[] toArray() {
    // what would you put here?
}

您为什么不能只创建一个new Object[n],然后将其转换为T[]?它不会立即崩溃(因为 T 在此方法中被删除),但是当您尝试将其返回到外部时;并假设外部代码请求特定的数组类型,例如String[] strings = myStringList.toArray();,它会抛出一个异常,因为那里有一个来自泛型的隐式转换。

人们可以尝试各种技巧,例如查看列表的第一个元素来尝试确定组件类型,但这不起作用,因为 (1) 元素可以为空,(2) 元素可以为实际组件类型的子类型,并且稍后当您尝试放入其他元素等时创建该类型的数组可能会失败。基本上,没有好的方法解决这个问题。

【讨论】:

    【解决方案2】:

    toArray(String[]) 部分之所以存在,是因为在 Java 1.5 中引入泛型之前存在两个 toArray 方法。那时,没有办法推断类型参数,因为它们根本不存在。

    Java 在向后兼容性方面非常重视,所以这就是 API 的特定部分笨拙的原因。

    整个类型擦除也是为了保持向后兼容性。为 1.4 编译的代码可以愉快地与包含泛型的新代码交互。

    是的,它很笨拙,但至少它没有破坏引入泛型时存在的庞大 Java 代码库。

    编辑:所以,作为参考,1.4 API 是这样的:

    Object[] toArray();
    Object[] toArray(Object[] a);
    

    1.5 API 是这样的:

    Object[] toArray();
    T[] toArray(T[] a);
    

    我不确定为什么可以更改 1-arg 版本而不是 0-arg 版本的签名。这似乎是一个合乎逻辑的变化,但也许我错过了一些复杂性。或者他们只是忘记了。

    EDIT2:在我看来,在这种情况下,Java 应该在可用的情况下使用推断类型参数,在推断类型不可用的情况下使用显式类型。但我怀疑将其实际包含在语言中会很棘手。

    【讨论】:

    • 好点。当 MS 的 .NET 团队第一次决定在 .NET 1.1 和 2.0 之间放弃向后支持时,他们可能很聪明。他们的用户已经习惯了在引入主要版本时破坏更改的风险,因此当从 2.0/3.0/3.5 升级到 4.0(最新的主要升级)时,没有人会抱怨太多,并且 API 可以在版本之间进行改进:P
    • @TomasLycken:是的。 MS 实际上在 C# 方面做得很好。我真的希望 Java 会放弃向后兼容性,或者至少有一个非泛型集合的弃用路径,最终将允许非向后兼容的代码。
    • “我不确定为什么可以更改 1-arg 版本的签名而不是 0-arg 版本的签名。” - 实际上他们没有不要改变任何东西。 1-arg 版本的 已擦除 签名与以前相同。
    • “我真的希望 Java 放弃向后兼容性……”。贵公司的 IT 管理层可能会强烈反对您。向后兼容性是 Java 对于企业人员的最大卖点之一。
    • 这个答案是错误的。 “我不确定为什么可以更改 1-arg 版本的签名而不是 0-arg 版本的签名。”这就是重点。 不可能正确地编写方法T[] toArray()。他们没有“忽略”某些东西,也不是图书馆设计问题。鉴于数组的性质和泛型的类型擦除,这是不可能的
    【解决方案3】:

    其他人已经回答了“为什么”的问题(我更喜欢 Cameron Skinner 的回答),我只想补充一点,您不必每次都实例化一个新数组,也不必空的。如果数组足够大以容纳集合,它将用作返回值。因此:

    String[] asArray = someStrings.toArray(new String[someStrings.size()])

    只会分配一个正确大小的数组并用集合中的元素填充它。

    此外,一些 Java 集合实用程序库包含静态定义的空数组,可以安全地用于此目的。例如,请参阅 Apache Commons ArrayUtils

    编辑:

    在上面的代码中,当 Collection 为空时,实例化的数组实际上是垃圾。由于数组不能在 Java 中调整大小,因此空数组可以是单例。因此,使用库中的常量空数组可能效率更高。

    【讨论】:

      【解决方案4】:

      这是因为类型擦除。参见维基百科关于 Java 泛型的文章中的Problems with type erasure:泛型类型信息仅在编译时可用,它被编译器完全剥离,在运行时不存在。

      所以toArray 需要另一种方法来确定要返回的数组类型。

      该 Wikipedia 文章中提供的示例非常具有说明性:

      ArrayList<Integer> li = new ArrayList<Integer>();
      ArrayList<Float> lf = new ArrayList<Float>();
      if (li.getClass() == lf.getClass())             // evaluates to true <<==
        System.out.println("Equal");
      

      【讨论】:

      • 它不是完全由于擦除。当我编译像List&lt;String&gt; l = new ArrayList&lt;String&gt;(); l.toArray() 这样的代码时,编译器肯定有足够的信息知道返回String[]。当然,如果我从流中反序列化 List,那么我没有任何类型信息,但 OP 似乎在问为什么 Java 在 可用时无法推断类型。
      • 我完全专注于“为什么我必须实例化一个新的 String[]...”这个问题
      • 这很好,但擦除仍然不是问题的全部。你没有错;我只是认为这个问题比擦除更多。
      • 维基百科的文字真的很有启发性。
      • @CameronSkinner 这是擦除和无类型推断的结合。 FWIW,IDE 消除了键入的需要,并且有些 IDE 会通过消失实例化的类名来减少源代码的混乱,例如菱形运算符,但它是源视图的旋转。
      【解决方案5】:

      根据 Neal Gafter 的说法,SUN 根本没有像 MS 那样的足够资源。

      SUN 推出了带有类型擦除功能的 Java 5,因为使泛型类型信息可用于运行时意味着更多的工作。他们不能再拖延了。

      注意:向后兼容不需要类型擦除 - 该部分由原始类型处理,这是与可具体化类型不同的概念。 Java 仍然有机会移除类型擦除,即使所有类型都可具体化;然而,它被认为不够紧迫,无法列入任何可预见的议程。

      【讨论】:

        【解决方案6】:

        Java 泛型实际上是不存在的。它只是一种仅由编译器处理的句法(糖?或者更确切地说是汉堡包?)。实际上,它只是类转换的简写(?),因此,如果您研究字节码,一开始您可能会有点惊讶...类型擦除,如上面的帖子所述。

        在对集合进行操作时,这种类转换的快捷方式似乎是一个好主意。有一次,您至少可以声明要存储的元素类型,并且独立于程序员的机制(编译器)将检查这一点。但是,使用反射你仍然可以将任何你想要的东西放入这样的集合中:)

        但是当您执行通用策略时,它适用于通用 bean,并且它从 bean 和策略都放入通用服务生成(?),为通用 bean 等获取通用侦听器。您将直接进入 普通地狱。我曾经完成了声明中指定的四个(!)泛型类型,当意识到我需要更多时,我决定取消生成整个代码,因为我遇到了泛型类型合规性问题。

        而对于菱形运算符...你可以跳过菱形并获得完全相同的效果,编译器会进行相同的检查并生成相同的代码。我怀疑在 Java 的下一个版本中它会改变,因为需要这种向后兼容性......所以另一件事几乎没有给出任何东西,Java 有更多的问题需要处理,例如使用日期时间类型操作时极度不便......

        【讨论】:

          【解决方案7】:

          我不能谈论 JDK 团队的设计决策,但 Java 泛型的许多笨拙本质来自于泛型是 Java 5(1.5 - 不管)的一部分这一事实。 JDK 中有很多方法试图保持与 1.5 之前的 API 的向后兼容性。

          至于繁琐的List&lt;String&gt; strings = new ArrayList&lt;String&gt;() 语法,我敢打赌这是为了保留语言的迂腐性质。 Java 的行进顺序是声明,而不是推理。

          strings.toArray( new String[0] );?无法推断该类型,因为 Array 本身被视为泛型类(Array&lt;String&gt;,如果它已公开)。

          总而言之,Java 泛型的存在主要是为了防止您在编译时输入错误。你仍然可以在运行时用一个错误的声明来打自己的脚,而且语法很麻烦。经常发生的情况是,使用泛型的最佳实践是您能做的最好的。

          【讨论】:

          • “数组本身被认为是一个泛型类”并非如此。数组在运行时知道它们的组件类型;而泛型类型则没有
          猜你喜欢
          • 2019-05-27
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2014-09-16
          • 2017-09-05
          • 1970-01-01
          • 2023-03-05
          • 1970-01-01
          相关资源
          最近更新 更多