【发布时间】:2010-09-07 02:20:42
【问题描述】:
我主要使用 Java,泛型相对较新。我一直在读到 Java 做出了错误的决定,或者 .NET 有更好的实现等等。
那么,C++、C#、Java 在泛型方面的主要区别是什么?各有优缺点?
【问题讨论】:
标签: c# java c++ generics templates
我主要使用 Java,泛型相对较新。我一直在读到 Java 做出了错误的决定,或者 .NET 有更好的实现等等。
那么,C++、C#、Java 在泛型方面的主要区别是什么?各有优缺点?
【问题讨论】:
标签: c# java c++ generics templates
我会在噪音中加入我的声音,并努力让事情变得清晰:
List<Person> foo = new List<Person>();
然后编译器会阻止您将不是Person 的东西放入列表中。
在幕后,C# 编译器只是将List<Person> 放入 .NET dll 文件中,但在运行时 JIT 编译器会构建一组新代码,就好像您编写了一个特殊的列表类只是为了包含人一样 - 类似于ListOfPerson.
这样做的好处是它的速度非常快。没有强制转换或任何其他东西,并且因为 dll 包含这是Person 列表的信息,所以稍后使用反射查看它的其他代码可以告诉它包含Person 对象(所以你得到智能感知和等等)。
这样做的缺点是旧的 C# 1.0 和 1.1 代码(在它们添加泛型之前)不理解这些新的 List<something>,因此您必须手动将内容转换回普通的旧 List 才能与它们进行互操作。这不是什么大问题,因为 C# 2.0 二进制代码不向后兼容。唯一会发生这种情况的情况是,如果您将一些旧的 C# 1.0/1.1 代码升级到 C# 2.0
ArrayList<Person> foo = new ArrayList<Person>();
从表面上看,它看起来是一样的,而且有点像。编译器还会阻止您将不是 Person 的内容放入列表中。
不同之处在于幕后发生的事情。与 C# 不同,Java 不会构建一个特殊的 ListOfPerson - 它只是使用 Java 中一直存在的普通的旧 ArrayList。当你从数组中取出东西时,通常的Person p = (Person)foo.get(1); cast-dance 仍然必须完成。编译器正在为您节省按键,但仍然会像往常一样产生速度命中/投射。
当人们提到“类型擦除”时,这就是他们所说的。编译器会为您插入强制转换,然后“删除”它应该是 Person 的列表,而不仅仅是 Object
这种方法的好处是不了解泛型的旧代码不必关心。它仍在处理与往常一样的旧ArrayList。这在 Java 世界中更为重要,因为他们希望支持使用带有泛型的 Java 5 编译代码,并让它在旧的 1.4 或以前的 JVM 上运行,微软故意决定不打扰。
缺点是我之前提到的速度下降,而且因为没有 ListOfPerson 伪类或类似的东西进入 .class 文件,代码稍后会查看它(通过反射,或者如果你将其从另一个已转换为 Object 等的集合中拉出)无法以任何方式表明它是一个仅包含 Person 而不仅仅是任何其他数组列表的列表。
std::list<Person>* foo = new std::list<Person>();
它看起来像 C# 和 Java 泛型,它会做你认为应该做的事情,但在幕后发生了不同的事情。
它与 C# 泛型的最共同之处在于它构建了特殊的 pseudo-classes,而不是像 java 那样仅仅丢弃类型信息,但它完全不同。
C# 和 Java 都产生专为虚拟机设计的输出。如果您编写的代码中包含Person 类,则在这两种情况下,有关Person 类的一些信息都将进入.dll 或.class 文件,而JVM/CLR 将对此进行处理。
C++ 生成原始 x86 二进制代码。一切都不是一个对象,并且没有需要知道Person 类的底层虚拟机。没有装箱或拆箱,函数不必属于类或任何东西。
因此,C++ 编译器对您可以使用模板执行的操作没有任何限制 - 基本上任何您可以手动编写的代码,您都可以获得模板来为您编写。
最明显的例子是添加东西:
在 C# 和 Java 中,泛型系统需要知道类可以使用哪些方法,并且需要将其传递给虚拟机。告诉它的唯一方法是硬编码实际的类,或者使用接口。例如:
string addNames<T>( T first, T second ) { return first.Name() + second.Name(); }
该代码不会在 C# 或 Java 中编译,因为它不知道 T 类型实际上提供了一个名为 Name() 的方法。你必须告诉它——在 C# 中是这样的:
interface IHasName{ string Name(); };
string addNames<T>( T first, T second ) where T : IHasName { .... }
然后你必须确保你传递给 addNames 的东西实现了 IHasName 接口等等。 java 语法不同(<T extends IHasName>),但也有同样的问题。
这个问题的“经典”案例是尝试编写一个执行此操作的函数
string addNames<T>( T first, T second ) { return first + second; }
您实际上无法编写此代码,因为无法在其中声明带有+ 方法的接口。你失败了。
C++ 没有这些问题。编译器不关心将类型传递给任何 VM - 如果您的两个对象都有 .Name() 函数,它将编译。如果他们不这样做,就不会。很简单。
所以,你有它:-)
【讨论】:
int addNames<T>( T first, T second ) { return first + second; } 的说法。泛型类型可以限制为一个类而不是一个接口,并且有一种方法可以在其中声明一个带有+ 运算符的类。
C++ 很少使用“泛型”术语。相反,使用了“模板”这个词,并且更准确。模板描述了一种实现通用设计的技术。
C++ 模板与 C# 和 Java 实现的模板非常不同,主要有两个原因。第一个原因是 C++ 模板不仅允许编译时类型参数,还允许编译时 const-value 参数:模板可以以整数甚至函数签名的形式给出。这意味着您可以在编译时做一些非常时髦的事情,例如计算:
template <unsigned int N>
struct product {
static unsigned int const VALUE = N * product<N - 1>::VALUE;
};
template <>
struct product<1> {
static unsigned int const VALUE = 1;
};
// Usage:
unsigned int const p5 = product<5>::VALUE;
此代码还使用了 C++ 模板的另一个显着特征,即模板专业化。代码定义了一个类模板product,它有一个值参数。它还为该模板定义了一个特化,只要参数评估为 1,就会使用该模板。这允许我定义对模板定义的递归。我相信这是Andrei Alexandrescu首先发现的。
模板专业化对于 C++ 很重要,因为它允许数据结构中的结构差异。模板作为一个整体是一种跨类型统一接口的方法。然而,尽管这是可取的,但在实现中不能平等对待所有类型。 C++ 模板考虑了这一点。这与 OOP 通过重写虚拟方法在接口和实现之间产生的差异非常相似。
C++ 模板对其算法编程范式至关重要。例如,几乎所有容器的算法都被定义为接受容器类型作为模板类型并统一对待它们的函数。实际上,这并不完全正确:C++ 不适用于容器,而是适用于由两个迭代器定义的 ranges,分别指向容器的开头和结尾。因此,整个内容由迭代器限定:begin
使用迭代器代替容器很有用,因为它允许对容器的一部分而不是整个容器进行操作。
C++ 的另一个显着特征是类模板部分特化 的可能性。这在某种程度上与 Haskell 和其他函数式语言中参数的模式匹配有关。例如,让我们考虑一个存储元素的类:
template <typename T>
class Store { … }; // (1)
这适用于任何元素类型。但是假设我们可以通过应用一些特殊技巧来比其他类型更有效地存储指针。我们可以通过部分专门针对所有指针类型来做到这一点:
template <typename T>
class Store<T*> { … }; // (2)
现在,每当我们为一种类型实例化容器模板时,都会使用适当的定义:
Store<int> x; // Uses (1)
Store<int*> y; // Uses (2)
Store<string**> z; // Uses (2), with T = string*.
【讨论】:
Anders Hejlsberg 本人在此处“Generics in C#, Java, and C++”描述了这些差异。
【讨论】:
关于什么的区别已经有很多很好的答案了,所以让我给出一个稍微不同的观点并添加为什么。
正如已经解释过的,主要区别在于 类型擦除,即 Java 编译器会擦除泛型类型并且它们不会最终出现在生成的字节码中。然而,问题是:为什么会有人这样做?这没有意义!还是这样?
好吧,还有什么选择?如果您没有在该语言中实现泛型,那么您在哪里实现它们??答案是:在虚拟机中。这破坏了向后兼容性。
另一方面,类型擦除允许您将通用客户端与非通用库混合使用。换句话说:在 Java 5 上编译的代码仍然可以部署到 Java 1.4。
然而,微软决定打破对泛型的向后兼容性。 这就是 .NET 泛型比 Java 泛型“更好”的原因。
当然,孙不是白痴或懦夫。他们“出局”的原因是,当他们引入泛型时,Java 比 .NET 更古老且更广泛。 (它们在两个世界中大致同时引入。)打破向后兼容性将是一个巨大的痛苦。
换一种说法:在 Java 中,泛型是 语言 的一部分(这意味着它们仅适用于 Java,而不适用于其他语言),在 .NET 中它们是虚拟机的一部分(这意味着它们适用于所有语言,而不仅仅是 C# 和 Visual Basic.NET)。
将此与 .NET 功能(如 LINQ、lambda 表达式、局部变量类型推断、匿名类型和表达式树)进行比较:这些都是 语言 功能。这就是 VB.NET 和 C# 之间存在细微差别的原因:如果这些功能是 VM 的一部分,那么它们在所有语言中都是相同的。但是 CLR 并没有改变:它在 .NET 3.5 SP1 中和在 .NET 2.0 中仍然是一样的。您可以使用 .NET 3.5 编译器编译使用 LINQ 的 C# 程序,并且仍然在 .NET 2.0 上运行它,前提是您不使用任何 .NET 3.5 库。这不适用于泛型和 .NET 1.1,但它会适用于 Java 和 Java 1.4。
【讨论】:
ArrayList<T> 可以作为具有(隐藏)静态Class<T> 字段的新内部命名类型发出。只要新版本的通用库已经部署了 1.5+ 字节码,它就可以在 1.4- JVM 上运行。
跟进我之前的帖子。
无论使用何种 IDE,模板是 C++ 在智能感知上如此失败的主要原因之一。由于模板专业化,IDE 永远无法真正确定给定成员是否存在。考虑:
template <typename T>
struct X {
void foo() { }
};
template <>
struct X<int> { };
typedef int my_int_type;
X<my_int_type> a;
a.|
现在,光标位于指示的位置,IDE 很难在此时说出a 成员是否以及成员有什么。对于其他语言,解析会很简单,但对于 C++,需要事先进行大量评估。
情况变得更糟。如果my_int_type 也在类模板中定义呢?现在它的类型将取决于另一个类型参数。在这里,甚至编译器也会失败。
template <typename T>
struct Y {
typedef T my_type;
};
X<Y<int>::my_type> b;
经过一番思考,程序员会得出结论,这段代码与上面的代码相同:Y<int>::my_type解析为int,因此b应该与a的类型相同,对吧?
错了。在编译器尝试解析此语句时,它实际上还不知道Y<int>::my_type!因此,它不知道这是一种类型。它可能是别的东西,例如成员函数或字段。这可能会引起歧义(尽管在当前情况下不是),因此编译器会失败。我们必须明确告诉它我们引用了一个类型名称:
X<typename Y<int>::my_type> b;
现在,代码编译。要了解这种情况是如何产生歧义的,请考虑以下代码:
Y<int>::my_type(123);
此代码语句完全有效,它告诉 C++ 执行对Y<int>::my_type 的函数调用。但是,如果my_type 不是函数而是类型,则该语句仍然有效并执行通常是构造函数调用的特殊转换(函数样式转换)。编译器无法分辨出我们的意思,所以我们必须在这里消除歧义。
【讨论】:
Java 和 C# 在它们的第一个语言发布后都引入了泛型。但是,在引入泛型时,核心库的变化方式有所不同。 C# 的泛型不仅仅是编译器的魔法,因此不可能在不破坏向后兼容性的情况下生成现有的库类。
例如,在 Java 中,现有的 Collections Framework 是完全通用的。 Java 没有集合类的泛型和旧版非泛型版本。 在某些方面,这更简洁 - 如果您需要在 C# 中使用集合,那么真的没有什么理由去使用非通用版本,但那些遗留类仍然存在,使环境变得混乱。
另一个显着的区别是 Java 和 C# 中的 Enum 类。Java 的 Enum 有这样看起来有点曲折的定义:
// java.lang.Enum Definition in Java
public abstract class Enum<E extends Enum<E>> implements Comparable<E>, Serializable {
(请参阅 Angelika Langer 的非常清楚的 explanation of exactly why 就是这样。本质上,这意味着 Java 可以提供从字符串到其 Enum 值的类型安全访问:
// Parsing String to Enum in Java
Colour colour = Colour.valueOf("RED");
将此与 C# 的版本进行比较:
// Parsing String to Enum in C#
Colour colour = (Colour)Enum.Parse(typeof(Colour), "RED");
由于在将泛型引入语言之前,C# 中已经存在枚举,因此在不破坏现有代码的情况下无法更改定义。因此,与集合一样,它仍以这种遗留状态保留在核心库中。
【讨论】:
ArrayList 重命名为List<T> 并将其放入新的命名空间中。事实是,如果源代码中有一个类以ArrayList<T> 出现,它将在IL 代码中成为不同的编译器生成的类名,因此不会发生名称冲突。
晚了 11 个月,但我认为这个问题已经为一些 Java 通配符问题做好了准备。
这是 Java 的一个语法特性。假设你有一个方法:
public <T> void Foo(Collection<T> thing)
并且假设您不需要在方法体中引用类型 T。你声明了一个名字 T 然后只使用它一次,那么你为什么要为它想一个名字呢?相反,你可以写:
public void Foo(Collection<?> thing)
问号要求编译器假装您声明了一个正常的命名类型参数,该参数只需要在该位置出现一次。
通配符没有什么可以做的,而命名类型参数也不能做(在 C++ 和 C# 中这些事情总是这样做的)。
【讨论】:
class Foo<T extends List<?>> 并使用 Foo<StringList>,但在 C# 中您必须添加额外的类型参数:class Foo<T, T2> where T : IList<T2> 并使用笨重的 Foo<StringList, String>。
维基百科有很多比较Java/C# generics 和Java generics/C++ 模板的文章。 main article on Generics 看起来有点杂乱,但里面确实有一些很好的信息。
【讨论】:
最大的抱怨是类型擦除。在这种情况下,泛型不会在运行时强制执行。 Here's a link to some Sun docs on the subject.
泛型是按类型实现的 擦除:泛型类型信息是 仅在编译时出现,之后 它被编译器擦除。
【讨论】:
C++ 模板实际上比 C# 和 Java 模板更强大,因为它们在编译时进行评估并支持专业化。这允许模板元编程并使 C++ 编译器等效于图灵机(即在编译过程中,您可以计算任何可以用图灵机计算的东西)。
【讨论】:
在 Java 中,泛型只是编译器级别的,因此您可以:
a = new ArrayList<String>()
a.getClass() => ArrayList
请注意,'a' 的类型是数组列表,而不是字符串列表。所以香蕉列表的类型等于()猴子列表。
可以这么说。
【讨论】:
看起来,在其他非常有趣的建议中,有一个关于改进泛型和打破向后兼容性的建议:
目前,实现了泛型 使用擦除,这意味着 泛型类型信息不是 在运行时可用,这使得一些 那种很难写的代码。泛型 以这种方式实施以支持 向后兼容旧版本 非通用代码。具体化的泛型 会使泛型类型 运行时可用的信息, 这将打破传统的非泛型 代码。然而,尼尔·加夫特 建议使类型仅可具体化 如果指定,以免破坏 向后兼容。
【讨论】:
注意:我没有足够的评论点,所以请随时将此作为评论移至适当的答案。
与普遍认为的相反,我从不明白它的来源,.net 实现了真正的泛型而不破坏向后兼容性,他们为此付出了明确的努力。 您不必为了在 .net 2.0 中使用而将非泛型 .net 1.0 代码更改为泛型。通用和非通用列表在 .Net 框架 2.0 中仍然可用,甚至直到 4.0,这完全是出于向后兼容性的原因。因此,仍然使用非泛型 ArrayList 的旧代码仍然可以工作,并使用与以前相同的 ArrayList 类。 从 1.0 到现在,向后代码兼容性始终保持...因此,即使在 .net 4.0 中,如果您选择使用 1.0 BCL 中的任何非泛型类,您仍然必须选择这样做。
所以我不认为 java 必须打破向后兼容性来支持真正的泛型。
【讨论】:
ArrayList<Foo>,它想要传递给一个旧方法,该方法应该用Foo 的实例填充ArrayList。如果ArrayList<foo> 不是ArrayList,如何让它发挥作用?