我今天读了一条推文说:
当Java用户抱怨类型擦除时,这很有趣,这是Java正确的唯一事情,而忽略了所有错误的事情。
因此,我的问题是:
Java的类型擦除是否有好处?除了向后兼容和运行时性能的JVM实现首选项之外,它(可能)还提供了哪些技术或编程风格的好处?
我今天读了一条推文说:
当Java用户抱怨类型擦除时,这很有趣,这是Java正确的唯一事情,而忽略了所有错误的事情。
因此,我的问题是:
Java的类型擦除是否有好处?除了向后兼容和运行时性能的JVM实现首选项之外,它(可能)还提供了哪些技术或编程风格的好处?
Answers:
到目前为止,很多答案都与Twitter用户有关。专注于消息而不是使者很有帮助。到目前为止,即使是摘录中也有一个相当一致的信息:
当Java用户抱怨类型擦除时,这很有趣,这是Java正确的唯一事情,而忽略了所有错误的事情。
我得到了巨大的收益(例如参数性)和零成本(指定成本是想象力的极限)。
new T是一个损坏的程序。与“所有命题都是正确的”的主张是同构的。我对此并不大。
这些推文反映了一个观点,该观点对我们是否可以使机器执行某项操作并不感兴趣,而更多地是我们是否可以推断该机器将执行我们实际想要的操作。充分的推理就是一个证明。可以用正式符号或不那么正式的形式指定证明。不论规范语言如何,它们都必须清晰且严格。非正式规范并非不可能正确构建,但在实际编程中经常存在缺陷。我们最终以自动化和探索性测试之类的补救措施来弥补非正式推理中存在的问题。这并不是说测试本质上是一个坏主意,但引用的Twitter用户暗示这是更好的方法。
因此,我们的目标是要有正确的程序,我们可以根据机器实际执行程序的方式来明确,严格地进行推理。但是,这并不是唯一的目标。我们还希望我们的逻辑具有一定的表现力。例如,我们只能用命题逻辑来表达。能够从一阶逻辑中获得通用(∀)和存在(∃)量化是一件好事。
这些目标可以通过类型系统很好地解决。由于Curry-Howard的对应关系,这一点尤其明显。这种对应关系通常用以下类比表示:类型是程序,定理是证明。
这种对应是有点深刻的。我们可以采用逻辑表达式,并通过对应关系将它们转换为类型。然后,如果我们有一个具有相同类型签名的程序可以进行编译,则可以证明逻辑表达式是普遍正确的(重言式)。这是因为对应是双向的。类型/程序与定理/证明世界之间的转换是机械的,并且在许多情况下可以自动化。
Curry-Howard很好地发挥了我们对程序规范的期望。
即使对Curry-Howard有所了解,一些人还是很容易在类型系统的价值被忽略时
关于第一点,IDE可能使Java的类型系统易于使用(这是非常主观的)。
关于第二点,爪哇恰好几乎对应于第一阶逻辑。泛型使用与通用量化等效的类型系统。不幸的是,通配符只给我们一小部分的存在量化。但是通用量化是一个很好的开始。可以说,List<A>
对于所有可能的列表来说,通用工作的函数是很高兴的,因为A完全不受约束。这导致Twitter用户在谈论“参数”。
经常被引用的关于参量的论文是菲利普·沃德勒的定理是免费的!。本文的有趣之处在于,仅凭类型签名,我们就可以证明一些非常有趣的不变量。如果我们要为这些不变量编写自动化测试,那将浪费我们的时间。例如,对于List<A>
,仅来自类型签名的flatten
<A> List<A> flatten(List<List<A>> nestedLists);
我们可以推断出
flatten(nestedList.map(l -> l.map(any_function)))
≡ flatten(nestList).map(any_function)
这是一个简单的示例,您可能可以非正式地对此进行推理,但是当我们从类型系统中免费获得此类证明并由编译器进行检查时,它甚至更好。
从语言实现的角度来看,Java的泛型(对应于通用类型)在用于获取有关程序功能证明的参数性中发挥了重要作用。这涉及到第三个问题。所有这些证明和正确性的获得都需要实现无缺陷的声音类型系统。Java肯定具有一些语言功能,这些功能可以使我们打破推理。这些包括但不限于:
未擦除的泛型在许多方面与反射有关。无需删除,实现中就会附带运行时信息,可用于设计算法。这意味着静态地,当我们对程序进行推理时,我们没有完整的画面。反思严重威胁我们静态推理的任何证据的正确性。并非巧合,反射也会导致各种棘手的缺陷。
那么,未删除的泛型可能有哪些“有用”的方式?让我们考虑一下推文中提到的用法:
<T> T broken { return new T(); }
如果T没有no-arg构造函数会怎样?在某些语言中,您得到的是null。或者,您可以跳过null值并直接引发异常(无论如何,null似乎都会导致该异常)。因为我们的语言是图灵完备的,所以不可能推断出哪些调用broken
将涉及带有无参数构造函数的“安全”类型,而哪些不会。我们已经失去了程序可以普遍运行的确定性。
因此,如果我们要对程序进行推理,强烈建议不要使用会严重威胁我们推理的语言功能。一旦我们做到了,那为什么不只在运行时删除类型呢?不需要它们。我们可以满意地得到所有效率和简单性,这些要求不会导致强制转换失败或调用时可能缺少方法。
擦除鼓励推理。
类型是用于以允许编译器检查程序正确性的方式编写程序的构造。类型是关于值的命题-编译器验证该命题为真。
在程序执行期间,应该不需要类型信息-编译器已对此进行了验证。编译器应该自由地丢弃此信息,以便对代码进行优化-使它运行更快,生成更小的二进制文件等。删除类型参数有助于实现这一点。
Java通过允许在运行时查询类型信息(反射,instanceof等)来打破静态类型。这允许您构造无法进行静态验证的程序-它们绕过类型系统。它还错过了静态优化的机会。
删除类型参数的事实阻止了构造这些不正确程序的某些实例,但是,如果删除了更多类型信息并且删除了reflecting和instanceof工具,那么将不允许更多不正确的程序。
擦除对于维护数据类型的“参数”属性很重要。说我在组件类型T上有一个参数化的“列表”类型,即List <T>。该类型是一个命题,即List类型对于任何类型T都相同。事实T是抽象的,无边界的类型参数,这意味着我们对该类型一无所知,因此,对于T的特殊情况,不能做任何特殊的事情。
例如说我有一个列表xs = asList(“ 3”)。我添加了一个元素:xs.add(“ q”)。我以[“ 3”,“ q”]结尾。由于这是参数化的,因此我可以假设List xs = asList(7); xs.add(8)以[7,8]结尾,从类型中我知道它对String不起作用,对Int不起作用。
此外,我知道List.add函数不能凭空发明T的值。我知道,如果我的asList(“ 3”)添加了“ 7”,则只能从值“ 3”和“ 7”构造唯一的答案。不可能将“ 2”或“ z”添加到列表中,因为该函数将无法构造它。这些其他值都不适合添加,并且参数可防止构造这些不正确的程序。
基本上,擦除可防止某些违反参数的方法,从而消除了程序错误的可能性,这是静态类型化的目标。
(尽管我已经在这里写了一个答案,两年后重新审视了这个问题,但我意识到还有另一种完全不同的答案方式,因此我保留了先前的答案并添加了这个答案。)
在Java Generics上完成的过程是否值得命名为“类型擦除”,这是极有争议的。由于不会删除通用类型,而是将其替换为原始对应类型,因此更好的选择似乎是“类型残缺”。
从普遍理解的意义上讲,类型擦除的典型特征是通过使运行时“盲目”到其访问的数据结构,从而迫使运行时停留在静态类型系统的边界之内。这为编译器提供了全部功能,并使其能够仅基于静态类型证明定理。它还通过限制代码的自由度来帮助程序员,从而为简单推理提供更多功能。
Java的类型擦除不能实现这一点,它会破坏编译器,例如以下示例:
void doStuff(List<Integer> collection) {
}
void doStuff(List<String> collection) // ERROR: a method cannot have
// overloads which only differ in type parameters
(擦除后,以上两个声明会折叠为相同的方法签名。)
另一方面,运行时仍可以检查对象的类型及其原因,但是由于擦除会破坏对真实类型的了解,因此静态类型违例很难实现且很难防止。
为了使问题更加复杂,原始和已删除的类型签名共存,并在编译期间并行考虑。这是因为整个过程不是要从运行时删除类型信息,而是要把通用类型系统插入旧的原始类型系统以保持向后兼容性。这个宝石是一个典型的例子:
public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)
(extends Object
必须添加冗余以保持擦除签名的向后兼容性。)
现在,考虑到这一点,让我们回顾一下报价:
当Java用户抱怨类型擦除时,这很有趣,这是Java唯一正确的方法
什么确切并得到Java的吧?它是单词本身,无论含义如何?相比之下,请看一下谦虚的int
类型:从未执行甚至根本没有执行过运行时类型检查,并且执行始终是完全类型安全的。正确完成后,这就是类型擦除的样子:您甚至都不知道它的存在。
我在这里根本没有考虑的一件事是,OOP的运行时多态性从根本上取决于运行时类型的确定。当一种语言,其主干由保留的类型固定在位时,对其类型系统进行了重大扩展,并以消除类型为基础,则认知失调是必然的结果。这正是Java社区发生的事情。这就是为什么类型擦除引起如此多争议的原因,并且最终是为什么计划在Java的未来版本中撤消它。在对Java用户的抱怨中找到有趣的东西,要么是对Java精神的诚实误解,要么是有意识地贬低了玩笑。
宣称“清除是Java唯一正确的做法”的主张暗示着“基于针对函数参数的运行时类型的动态分派的所有语言从根本上都是有缺陷的”。尽管可以肯定地说它本身是一项合法的主张,甚至可以被认为是对包括Java在内的所有OOP语言的有效批评,但是它不能成为在Java上下文中评估和批评功能的枢轴点,在Java上下文中,运行时多态性是公理的。
总而言之,尽管人们可以有效地指出“类型擦除是语言设计的一种方式”,但在Java中支持类型擦除的职位却错位了,因为这样做太晚了,甚至在历史的时刻也是如此。当Oak被Sun拥抱并重命名为Java时。
关于静态类型本身是否是编程语言设计的正确方向,这适合于我们认为构成编程活动的更广泛的哲学上下文。一门学派显然是从数学的经典传统中衍生出来的,它把程序看作一个数学概念或其他数学概念(命题,函数等)的实例,但是有完全不同的一类方法,它们将编程视为一种实现方法。与机器对话并解释我们想要的机器。在这种情况下,该程序是一个动态的,有机增长的实体,与静态类型的程序精心构建的大厦截然相反。
将动态语言视为朝着这个方向迈出的一步似乎是很自然的:程序的一致性是从下而上出现的,没有先验约束会以自上而下的方式强加它。这种范例可以看作是对过程建模的一步,通过这种过程,我们人类将通过发展和学习成为我们所要达到的目标。
同一用户在同一对话中的后续帖子:
new T是一个损坏的程序。与“所有命题都是正确的”的主张是同构的。我对此并不大。
(这是对另一位用户的声明的回应,即“在某些情况下'new T'似乎会更好”,这种想法new T()
是由于类型擦除是不可能的。(这值得商—-即使T
在运行时,它可能是抽象类或接口,或者可能是Void
,或者可能缺少no-arg构造函数,或者它的no-arg构造函数可能是私有的(例如,因为它应该是单例类),或者no-arg构造函数可以指定一个通用方法无法捕获或指定的检查异常,但这是前提。无论如何,确实可以不删除就至少可以编写T.class.newInstance()
处理这些问题的方法。))
这种类型与命题同构的观点表明,用户具有形式类型理论的背景。(S)他很可能不喜欢“动态类型”或“运行时类型”,并且更喜欢没有向下转换instanceof
和反射的Java 。(例如,像Standard ML这样的语言,它具有非常丰富的(静态)类型系统,并且其动态语义不依赖于任何类型信息。)
顺便提一下,值得牢记的是用户正在拖钓:尽管他可能真诚地喜欢(静态)键入的语言,但他并没有真诚地试图说服其他人。相反,原始推文的主要目的是嘲笑那些不同意的人,在听到一些不同意见后,用户发布了后续推文,例如“ Java删除类型的原因是Wadler等人知道什么。他们正在做,与Java用户不同。” 不幸的是,这使得很难找出他的实际想法。但幸运的是,这也可能意味着这样做不是很重要。真正有见地的人通常不会诉诸那些完全没有内容的巨魔。
类型擦除是一件好事的原因是它使不可能完成的事情是有害的。防止在运行时检查类型参数,使对程序的理解和推理更加容易。
我发现有些反直觉的发现是,当函数签名更为通用时,它们将变得更易于理解。这是因为减少了可能的实现方式。考虑具有此签名的方法,我们以某种方式知道它没有副作用:
public List<Integer> XXX(final List<Integer> l);
此功能可能实现什么?非常多。您几乎无法知道此功能的作用。可能是反转输入列表。可能是将整数配对在一起,将它们求和并返回一半大小的列表。还有许多其他可以想象的可能性。现在考虑:
public <T> List<T> XXX(final List<T> l);
有多少个此功能的实现?由于实现无法知道元素的类型,因此现在可以排除大量实现:无法将元素组合,添加到列表或过滤掉,等等。我们仅限于以下方面:身份(不更改列表),删除元素或反转列表。仅凭其签名就更容易推断此功能。
除了……在Java中,您总是可以欺骗类型系统。因为该通用方法的实现可以使用诸如instanceof
检查和/或强制转换为任意类型之类的东西,所以基于类型签名的推理很容易变得无用。该函数可以检查元素的类型,并根据结果执行任何数量的操作。如果允许这些运行时破解,则参数化方法签名对我们的用处将大大减少。
如果Java没有类型擦除(即,在运行时对类型参数进行了更改),则这将仅允许更多此类破坏推理的恶作剧。在上面的示例中,如果列表具有至少一个元素,则实现只能违反类型签名所设置的期望;但是T
,如果列表被清空,即使列表为空,它也可以这样做。修饰类型只会增加(已经非常多)阻碍我们对代码理解的可能性。
类型擦除使语言不那么“强大”。但是某些形式的“权力”实际上是有害的。
instanceof
阻碍了我们根据类型推断代码做什么的能力。如果Java要验证类型参数,那只会使这个问题变得更糟。在运行时擦除类型具有使类型系统更有用的作用。
这不是直接答案(OP询问“有什么好处”,我在答复“有什么缺点”)
与C#类型的系统相比,Java类型的擦除对于两个版本来说确实是一个痛苦
在C#中,你可以同时实现IEnumerable<T1>
与IEnumerable<T2>
安全,尤其是如果这两种类型不共享一个共同的祖先(即他们的祖先是 Object
)。
实际示例:在Spring Framework中,您不能ApplicationListener<? extends ApplicationEvent>
多次实现。如果您需要根据不同的行为T
进行测试instanceof
(并且您需要引用Class来做到这一点)
正如其他人所评论的new T()
那样,只能通过反射,仅通过调用的实例来完成等效的操作Class<T>
,并确保构造函数所需的参数。C#new T()
仅允许您约束T
到无参数构造函数。如果T
不遵守该约束,则会引发编译错误。
在Java中,您通常会被迫编写类似于以下内容的方法
public <T> T create(....params, Class<T> classOfT)
{
... whatever you do
... you will end up
T = classOfT.newInstance();
... or more advanced reflection
Constructor<T> parameterizedConstructorThatYouKnowAbout = classOfT.getConstructor(...,...);
}
上面的代码的缺点是:
ReflectiveOperationException
在运行时抛出如果我是C#的作者,我会介绍一种功能,它可以指定一个或多个构造函数约束,这些约束在编译时易于验证(因此,我可能需要例如带string,string
params 的构造函数)。但是最后一个是猜测
另外,似乎没有其他答案可以考虑:如果您确实需要使用运行时类型的泛型,则可以这样实现自己:
public class GenericClass<T>
{
private Class<T> targetClass;
public GenericClass(Class<T> targetClass)
{
this.targetClass = targetClass;
}
然后,如果Java不使用擦除,则此类可以执行默认情况下可以实现的所有操作:它可以分配new T
s(假设T
其构造函数与预期使用的模式匹配)或T
s 数组,它可以在运行时动态测试特定对象是否为,T
并根据其更改行为,依此类推。
例如:
public T newT () {
try {
return targetClass.newInstance();
} catch(/* I forget which exceptions can be thrown here */) { ... }
}
private T value;
/** @throws ClassCastException if object is not a T */
public void setValueFromObject (Object object) {
value = targetClass.cast(object);
}
}
避免类似c ++的代码膨胀,因为同一代码用于多种类型;但是,类型擦除需要虚拟分派,而C ++代码膨胀方法可以执行非虚拟分派的泛型
大多数答案更关注编程原理,而不是实际的技术细节。
尽管这个问题已有5年以上的历史了,但这个问题仍然存在:从技术角度来看,为什么要消除类型擦除?最后,答案很简单(更高级别):https : //en.wikipedia.org/wiki/Type_erasure
C ++模板在运行时不存在。编译器为每次调用发出完全优化的版本,这意味着执行不依赖于类型信息。但是,JIT如何处理同一功能的不同版本?仅具有一项功能会更好吗?不想让JIT对其所有不同的版本进行优化。好吧,那类型安全呢?猜猜那必须走出窗外。
但是请稍等:.NET是如何做到的?反射!这样,他们仅需优化一个功能并获得运行时类型信息即可。这就是.NET泛型曾经较慢的原因(尽管它们变得更好了)。我不是在说那不方便!但是它很昂贵,并且在并非绝对必要时不应该使用(在动态类型语言中,它不被认为是昂贵的,因为编译器/解释器仍然依赖于反射)。
这种带有类型擦除的通用编程的开销几乎为零(仍然需要一些运行时检查/强制转换):https : //docs.oracle.com/javase/tutorial/java/generics/erasure.html