为什么通用方法和通用类型具有不同的类型引入语法?

44
当我学习泛型时,我注意到泛型方法泛型类型(类或接口)之间的类型引入语法有所不同,这让我感到困惑。
泛型方法的语法为:
<T> void doStuff(T t) {
    // Do stuff with T
}

文档中提到:

泛型方法的语法包括类型参数,放在尖括号内,在方法返回类型之前声明

泛型类型的语法为:

class Stuff<T> {
    // Do stuff with T
    T t;
}

文档说

类型参数部分由尖括号(<>)包围,跟在类名后面。它指定了类型参数。

但是文档没有说明为什么它必须在之前或之后。


为了保持一致,我期望方法语法为 void doStuff<T>(T t) {} 或类的类型语法为 class <T>Stuff {},但显然情况并非如此。为什么一个必须在前面引入,而另一个则在后面?我主要使用泛型的形式为 List<String>,并认为 <String>List 看起来很奇怪,但这是一个主观的论点,对于方法也是如此。你可以像这样调用 doStuffthis.<String>doStuff("a string");。在寻找技术解释时,我想或许必须在指定返回类型之前引入 <T> 到方法中,因为 T 可能是返回类型,编译器可能无法预见到这一点,但这听起来很奇怪,因为编译器很聪明。我想有一个超越“语言设计者只是这样做”的解释,但我找不到。

1
C++模板比Java泛型更早吗?语法非常相似,也许这个问题应该问C++设计师而不是Java的。 - Pablo Lozano
7个回答

19

答案确实在已经链接的GJ规范中,引用自文档的p.14:

参数在方法名之前传递的约定是由于解析约束而必要的:对于更常见的“类型参数在方法名之后”的约定,表达式f(a<b,c>(d))将有两种可能的解析方式。

评论中的实现:

f(a<b,c>(d))可以被解析为f(a < b, c > d) (从比较运算符得到两个布尔值传递给f) 或者f(a<B, C>(d)) (使用类型参数B和C以及值参数d调用a,然后将结果传递给f)。我认为这也可能是为什么Scala选择使用[]而不是<>来表示泛型的原因。


8
不,只有歧义。f(a<b,c>(d))可以解析为f(a < b, c > d)(将两个布尔值从比较中传递给 f)或者f(a<B, C>(d))(使用类型参数 BC 调用 a 并将值参数 d 传递给 f)。我认为这也是为什么Scala选择使用[]而不是<>作为泛型的原因。 - HTNW
@HTNW 这是一个非常有趣的观点...目前,我认为这是正确的答案,而您的评论也证实了这一点。谢谢。 - Eugene

10
据我所知,Java中的泛型是基于GJ的泛型概念(一种支持泛型类型的Java编程语言扩展)。因此,其语法取自GJ,详见GJ Specification
这是对您问题的正式回答,但并不是针对GJ语境下的回答。但很明显,它与C++语法无关,因为在C++中,参数部分位于class关键字和方法返回类型之前。

8

我强烈的假设是,这是因为像你所说的一样,对于一个方法,泛型参数也可以是函数的返回类型:

public <RETURN_TYPE> RETURN_TYPE getResult();

当编译器到达函数的返回类型时,它已经遇到了其类型(也就是说,它知道它是一个泛型类型)。
如果你有以下语法:
public RETURN_TYPE getResult<RETURN_TYPE>();

需要进行第二次扫描以进行解析。

对于类而言,这不是一个问题,因为所有对泛型类型的引用都出现在类定义块中,即在泛型类型声明之后。


1
那也是我提出的原因之一,正如你在问题中所读到的。我正在寻找更多的东西来支持它,而不仅仅是假设。 - Tim
还有我不确定这一点:它需要进行第二次扫描以进行解析。编译器不能记住它找到了一个未知类型吗?只有在它完成对方法的解析之前没有找到匹配的类型介绍时,才将其报告为错误? - Tim
@TimCastelijns 我认为编译过程是一致的,它没有像特殊情况和“if语句”那样的东西,例如当遇到未知类型时,编译器会说:“让我们等一下,可能有一个通用类型声明……”一旦遇到未知类型,编译器就会报告错误,没有回头再检查,这就是为什么泛型在使用之前要先声明,当编译器遇到<..>时,我认为它会将其临时附加到已知类型列表中,并继续工作,没有特殊情况,也不需要等待错误,这是我的看法。 - Yazan

5

并没有什么深刻的理论原因 - 这似乎只是“语言设计者就是这样做了”的情况。例如,C#确实使用您想知道Java为什么不实现的确切语法。以下代码:

private T Test<T>(T abc)
{
    throw new NotImplementedException();
}

代码可以编译。C#与Java相似,这意味着理论上Java也可以实现同样的功能,特别是因为这两种语言在开发早期都实现了泛型。

现在Java语法的优势在于使用当前语法实现LL(1)方法解析器略微容易一些。


据我所知 这可能是 C# 做了这个和那个。我对此无法做出太多解释,这恰恰是我不想要的答案类型。也许我误解了你的意思 - 如果是这样,你能重新表述一下吗? - Tim

4
由于编译期间通用类型和参数化类型的处理方式不同,因此它们在擦除过程中分别被视为“省略类型参数”和“省略类型参数”。Java在2004年的官方版本J2SE 5.0中添加了泛型。在Oracle文档《在J2SE 5.0中使用和编写泛型》中指出:“幕后操作,编译器将泛型实现为称为擦除的前端转换,这是将使用泛型的代码转换或重写为非泛型代码的过程(即,将新语法映射到当前JVM规范)。换句话说,此转换会删除所有泛型类型信息;所有尖括号之间的信息都被删除。例如,LinkedList将变成LinkedList。其他类型变量的用法将被替换为类型变量的上限(例如Object),当生成的代码不正确时,将插入到适当类型的转换。”关键在于“类型擦除”的过程。没有JVM更改来支持泛型,因此Java不会记住编译后的泛型类型。由新奥尔良大学发表的一篇名为“擦除成本”的文章对我们进行了分解:执行类型擦除的步骤包括:省略类型参数:当编译器找到通用类型或方法的定义时,它会删除所有出现的每个类型参数,将其替换为其最左边的限制或Object(如果未指定限制)。省略类型参数:当编译器找到参数化类型时,即通用类型的实例化,它会删除类型参数。例如,类型List被翻译为List。
对于通用方法,编译器会寻找最左边的泛型类型定义。而“最左边”实际上意味着最靠左边,这就是为什么有界类型参数出现在方法的返回类型之前。对于泛型类或接口,编译器会寻找参数化类型,它不像泛型类型那样位于类定义的最左边,而是跟随类名。然后编译器会移除类型参数,以便 JVM 可以理解它。
如果您查看《擦除成本》论文的附录部分,它很好地演示了编译器如何处理泛型接口和方法。
引用一下论文中的内容:
桥接方法
当编译一个继承参数化类或实现参数化接口的类或接口时,编译器可能需要创建一个合成方法,称为桥接方法,作为类型擦除过程的一部分。通常情况下,您不需要担心桥接方法,但如果堆栈跟踪中出现了一个桥接方法,则可能会感到困惑。
另外,编译器有时可能需要插入合成桥接方法。桥接方法是类型擦除过程的一部分。桥接方法负责确保类型擦除后方法签名匹配。请在《类型擦除和桥接方法的影响》中了解更多信息。
编辑:正如 OP 指出的那样,“最左边”实际上意味着最靠左边并不够严谨。(OP 在问题中指出他对“我认为”类型的答案不感兴趣)因此,我进行了一些调查,并找到了这个《泛型 FAQ》。从示例中可以看出,类型参数的顺序确实很重要。即 <T extends Cloneable & Comparable<T>> 的类型擦除后变成了 Cloneable 而不是 Comparable
下面是来自 Oracle 泛型类型擦除的另一个示例:
在以下示例中,通用 Node 类使用有界类型参数:
public class Node<T extends Comparable<T>> {
   ...
}

Java编译器将有限制的类型参数 T 替换为第一个边界类 Comparable
我认为更加技术上正确的说法是类型擦除将边界类型替换为第一个边界类(如果 T 没有限制则替换为 Object),只是因为 Java 语法中第一个边界类在最左侧,所以看起来像是将其替换为第一个边界类。

信息性答案。它字面意思是最左边,这就是为什么有界类型参数出现在方法的返回类型之前 我不认为你可以得出这个结论。我不认为擦除策略会影响参数的顺序。最左边的限制适用于泛型的顺序,而不是参数/关键字在方法定义中出现的顺序。 - Tim
我从这个答案 https://dev59.com/jW_Xa4cB1Zd3GeqP0mUK 得出结论。我同意其中一个评论的观点:“选择最左边的边界的另一个原因是使选择明确定义,以便用户可以控制擦除类中使用的类型。你希望这样做是为了能够为旧代码创建向后兼容的类。也就是说,如果你想让“原始”擦除类扩展特定类型或者让擦除的泛型方法具有特定的参数类型,你可以通过选择边界来控制它。” - OLIVER.KOO
@TimCasteligins 谢谢您指出 :) 我进行了一些搜索,找到了一些例子来支持我的结论。对我来说,这意味着方法定义中参数出现的顺序很重要。 - OLIVER.KOO
我理解你所说的最左边界,但它与方法定义中类型参数的位置有什么关系呢?我不明白这个结论。 - Tim

2

我认为之所以可以将其声明为返回类型,是因为:

 <T> T doStuff(T t) {
     // Do stuff with T
    return t;
}

在声明返回类型之前,您需要先声明类型,因为您不能使用尚未定义的内容。例如,在声明某个变量之前,您不能使用变量x。
我喜欢(任何)语言都遵循一些逻辑规则,这样使用起来更容易,并且在了解它的某个时候,您就知道可以从中期望什么。这是Java的情况,它有一些奇怪的地方,但总体上它遵循一些规则。而您不能在声明之前使用某些内容的规则在Java中非常强烈,对我来说非常好,因为在试图理解Java代码时会产生较少的困惑,这就是我认为背后的原因。但我不知道谁负责这个决定,引用自维基百科:
1998年,Gilad Bracha、Martin Odersky、David Stoutamire和Philip Wadler创建了通用Java,这是Java语言的扩展,支持通用类型。通过添加通配符,通用Java被纳入Java(2004年,Java 5)中。
我认为我们应该询问上面提到的某个人以获得明确的答案,为什么它是现在这个样子。
我不相信这与向后兼容以前版本的Java有任何关系。

你不能使用尚未定义的东西。一般来说,这并不是真的。即使我在方法下面声明变量,我仍然可以在方法中使用它。也许在声明类型时会有所不同,但我不知道为什么会这样。 - Tim
这是不正确的 - C# 允许你完全做到 OP 所询问的事情。 - EJoshuaS - Stand with Ukraine
1
@EJoshuaS,有更多使用doStuff<T>()语法的编程语言,但我们现在讨论的是Java。你不能因为C#可以做到某事就说Java也可以。 - Tim
@TimCastelijns 是的,我确实认同 - 实际上我正在赞同你的评论。我只是给出了一个反例来回应答案 - 我只是认为“你不能使用尚未定义的东西”这个说法是不正确的,因为如果它是正确的,C#也无法做到,所以这里的论点证明了太多。 - EJoshuaS - Stand with Ukraine
@EJoshuaS 那个陈述的正确性完全取决于它所呈现的上下文。有些语言允许,而其他语言则不允许。 - Tim
显示剩余2条评论

-2

Java泛型是在Java 1.5中引入的。新语言特性的想法是永远不要破坏之前的版本。我们必须记住,泛型是语言/开发人员的类型安全功能。

为此,引入了两种新类型参数化类型类型变量

JLS 4.3 引用类型和值提出了以下TypeArgumentTypeVariable的语法。

ReferenceType: ClassOrInterfaceType TypeVariable ArrayType

ClassOrInterfaceType: ClassType InterfaceType

ClassType: TypeDeclSpecifier TypeArgumentsopt

InterfaceType: TypeDeclSpecifier TypeArgumentsopt

TypeDeclSpecifier: TypeName
ClassOrInterfaceType . Identifier

TypeName: Identifier TypeName . Identifier

TypeVariable: Identifier

ArrayType: Type [ ]

例如:

Vector<String>
Seq<Seq<A>>
Seq<String>.Zipper<Integer>
Collection<Integer>
Pair<String,String>

对于参数化类型

Vector<String> x = new Vector<String>();
Vector<Integer> y = new Vector<Integer>();
return x.getClass() == y.getClass();

每当没有给定边界时,它就会将其视为java.lang.Object,并通过类型擦除使其成为例如Vector<Object>,因此它与之前的Java版本向后兼容。

当类本身不是泛型时,泛型方法的语法如下。

来自JLs 8.4 Method Declarations

MethodDeclaration: MethodHeader MethodBody

MethodHeader: MethodModifiersopt TypeParametersopt Result MethodDeclarator Throwsopt

MethodDeclarator: Identifier ( FormalParameterListopt )

一个示例看起来像这样

public class GenericMethod {
    public static <T> T aMethod(T anObject) {
        return anObject;
    }
    public static void main(String[] args) {
        String greeting = "Hi";
        String reply = aMethod(greeting);
    }
}

这将导致类型擦除的结果为

public class GenericMethod {
    public static Object aMethod(Object anObject) {
        return anObject;
    }
    public static void main(String[] args) {
        String greeting = "Hi";
        String reply = (String) aMethod(greeting);
    }
}

并且它还与之前的Java版本具有向下兼容性。详情请参阅两份提案文件以获得更深入的理解。

将泛型添加到Java编程语言:参与者草案规范

Java泛型类型的特化


关于技术部分。创建Java程序的步骤是编译.java文件。可以使用javac命令生成类文件。 JavacParser解析上述规范的整个文件并生成字节码。请参见此处获取JavacParser源代码。
让我们看一下以下Test.java文件。
class Things{}

class Stuff<T>{
    T t;

    public <U extends Things> U doStuff(T t, U u){
        return u;
    };
    public <T> T doStuff(T t){
        return t;
    };
}

为了保持向后兼容,JVM没有改变类文件的先前属性。他们添加了一个新属性并将其命名为Signature。从提案论文中可以看到:

当用作方法或字段的属性时,签名给出该方法或字段的完整(可能是通用)类型。 当用作类属性时,签名指示类的类型参数,其超类型以及所有接口。 签名中的类型语法扩展到参数化类型和类型变量。还有一种新的形式类型参数的签名语法。 签名字符串的语法扩展如下:

JVM Spec 4.3.4定义了以下语法:

MethodTypeSignature: FormalTypeParametersopt (TypeSignature*) ReturnType ThrowsSignature*

ReturnType: TypeSignature VoidDescriptor

ThrowsSignature: ^ ClassTypeSignature ^ TypeVariableSignature

通过使用javap -v命令对Test.class文件进行反汇编,我们得到以下内容:
class Stuff<T extends java.lang.Object> extends java.lang.Object
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#20         // java/lang/Object."<init>":()V
   #2 = Class              #21            // Stuff
   #3 = Class              #22            // java/lang/Object
   #4 = Utf8               t
   #5 = Utf8               Ljava/lang/Object;
   #6 = Utf8               Signature
   #7 = Utf8               TT;
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               doStuff
  #13 = Utf8               (Ljava/lang/Object;LThings;)LThings;
  #14 = Utf8               <U:LThings;>(TT;TU;)TU;
  #15 = Utf8               (Ljava/lang/Object;)Ljava/lang/Object;
  #16 = Utf8               <T:Ljava/lang/Object;>(TT;)TT;
  #17 = Utf8               <T:Ljava/lang/Object;>Ljava/lang/Object;
  #18 = Utf8               SourceFile
  #19 = Utf8               Test.java
  #20 = NameAndType        #8:#9          // "<init>":()V
  #21 = Utf8               Stuff
  #22 = Utf8               java/lang/Object
{
  T t;
    descriptor: Ljava/lang/Object;
    flags:
    Signature: #7                           // TT;

  Stuff();
    descriptor: ()V
    flags:
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 4: 0

  public <U extends Things> U doStuff(T, U);
    descriptor: (Ljava/lang/Object;LThings;)LThings;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=3, args_size=3
         0: aload_2
         1: areturn
      LineNumberTable:
        line 8: 0
    Signature: #14                          // <U:LThings;>(TT;TU;)TU;

  public <T extends java.lang.Object> T doStuff(T);
    descriptor: (Ljava/lang/Object;)Ljava/lang/Object;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: aload_1
         1: areturn
      LineNumberTable:
        line 11: 0
    Signature: #16                          // <T:Ljava/lang/Object;>(TT;)TT;
}
Signature: #17                          // <T:Ljava/lang/Object;>Ljava/lang/Object;
SourceFile: "Test.java"

这个方法

public <U extends Things> U doStuff(T t, U u){
        return u;
    };

将其翻译成Signature,以表示它是一种通用方法。

   Signature: #14                          // <U:LThings;>(TT;TU;)TU;

如果我们在之前的Java 1.5版本中使用非泛型类,例如:

public String doObjectStuff(Object t, String u){
        return u;
    }

会被翻译成

 public java.lang.String doObjectStuff(java.lang.Object, java.lang.String);
    descriptor: (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=3, args_size=3
         0: aload_2
         1: areturn
      LineNumberTable:
        line 12: 0

两者之间唯一的区别在于一个具有“Signature”属性字段,表明它确实是一个通用方法,而另一个早期的Java 1.5版本则没有。但两者都具有相同的“descriptor”属性。
Non-Generic method
 descriptor: (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;
Generic method 
 descriptor: (Ljava/lang/Object;LThings;)LThings;

这使它具有向后兼容性。因此答案就像你建议的那样,“语言设计者只是让它成为那个样子”,再加上“为了使其具有向后兼容性而不添加太多代码。”
HTML格式化:

这使它具有向后兼容性。因此答案就像你建议的那样,“语言设计者只是让它成为那个样子”,再加上“为了使其具有向后兼容性而不添加太多代码。”


编辑:关于评论中提到应该很容易处理不同的语法,我在Philip Wadler和Maurice Naftalin的《Java Generics and Collections》一书中找到了一段话。

Java中的泛型类似于C++中的模板。关于Java泛型和C++模板之间的关系,只有两件重要的事情需要记住:语法和语义。语法是故意相似的,而语义则是故意不同的。
在语法上,选择了尖括号,因为它们对于C++用户来说很熟悉,并且方括号很难解析。但是,语法上有一个区别。在C++中,嵌套参数需要额外的空格,因此您会看到像这样的东西:List< List > [...]等。

请参见此处


2
我可能误解了重点,但是<>在源代码中的位置与类型擦除和向后兼容有任何关系吗?无论您将<>放在哪里,您得到的源代码都与原始编译器不兼容。那么为什么将其放置在特定位置比C ++或C#中使用的位置更“向后兼容”? - SergGr
@SergGr 我的意思是,通常情况下,通过擦除参数化类型,它将具有与先前 jre 版本相同的方法结构。这意味着 javac 解析器的调整是直截了当的。如果 <> 括号完全错位,开发人员可能不得不添加更多的行到解析器中。 - Murat Karagöz
1
我不是专业的编译器开发人员,但我不确定将声明移动到C#中所需的代码量有多少。至于C++类的语法,它甚至更符合Java中实际所做的建议。那为什么不呢?总的来说,就信噪比而言,这个答案对我来说看起来很糟糕。 - SergGr
@SergGr 他们试图对Java解析器进行最小的更改,以类似于新的泛型语法。在Java泛型书中找到了一个段落。我的答案是位置仍然比较容易解析,并且保持向后兼容性,例如使用泛型语法对非泛型库接口进行接口升级。 C#并不关心最后一点。我的意思是我无法告诉您比白皮书和JSR草案中提供的更多信息。这个问题具有极其特定的性质,我已经尝试深入挖掘它。 - Murat Karagöz

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