为什么数组不是泛型类型?

75

Array 被声明:

public abstract class Array
    : ICloneable, IList, ICollection, IEnumerable {
我在想为什么不是这样:
public partial class Array<T>
    : ICloneable, IList<T>, ICollection<T>, IEnumerable<T> {
  1. 如果将它声明为泛型类型会有什么问题?

  2. 如果它是一个泛型类型,我们是否仍需要非泛型的类型,或者它可以从 Array<T> 派生出来?例如:

  3. public partial class Array: Array<object> { 
    

一个 Array<T> 能提供什么样的功能,而 List<T> 不能够提供呢? - D Stanley
2
没有问题,但是为什么要创建一个没有任何价值的类呢?你已经有了 List<T>ArrayT[]。(最后一个例子中,我指的是强类型数组,而不是“通用”数组) - D Stanley
它不会引起问题;它是多余和不必要的。没有它会给你带来问题吗?它能给你什么List<T>没有的东西吗?还是只是语义学上的区别? - D Stanley
3
D Stanley的问题与重点无关,他的声明是错误的。幸运的是,Virtlink提供了全面而准确的答案。 - Jim Balter
1
数组在Swift中是一个合适的泛型,我认为它从C#借鉴了很多。所以,C#数组似乎是一种语言因为许多代码基于旧样式而被困在早期实现的情况。 - Mark Patterson
显示剩余3条评论
7个回答

158

历史

如果数组成为泛型类型,会出现什么问题?

在C# 1.0时代,他们主要从Java中复制了数组的概念。当时还没有泛型,但是创建者认为他们很聪明,复制了Java数组具有的破碎的协变数组语义。这意味着你可以像这样做而不会产生编译时错误(而是运行时错误):

Mammoth[] mammoths = new Mammoth[10];
Animal[] animals = mammoths;            // Covariant conversion
animals[1] = new Giraffe();             // Run-time exception

在C#2.0中引入了泛型,但没有协变/逆变的泛型类型。如果将数组作为泛型,则不能将强制转换为,尽管以前可以这样做(即使它是错误的)。因此,使数组成为泛型会破坏大量代码。
只有在C#4.0中引入了接口的协变/逆变泛型类型。这使得最终可以修复损坏的数组协变性。但同样,这将破坏许多现有的代码。
Array<Mammoth> mammoths = new Array<Mammoth>(10);
Array<Animal> animals = mammoths;           // Not allowed.
IEnumerable<Animals> animals = mammoths;    // Covariant conversion

数组实现通用接口

为什么数组不实现通用的 IList<T>ICollection<T>IEnumerable<T> 接口呢?

由于运行时技巧,每个数组 T[] 都会自动实现 IEnumerable<T>ICollection<T>IList<T>1 根据 Array 类文档

单维数组实现了通用接口 IList<T>ICollection<T>IEnumerable<T>IReadOnlyList<T>IReadOnlyCollection<T>。这些实现在运行时提供给数组,因此,在 Array 类的声明语法中不会出现通用接口。


你能使用数组实现的接口中的所有成员吗?

不行。文档继续说明:

当你将一个数组强制转换为这些接口之一时,需要注意的关键是添加、插入或删除元素的成员会抛出NotSupportedException异常。

这是因为(例如)ICollection<T>有一个Add方法,但是你不能向数组中添加任何内容。它会抛出异常。这是.NET Framework中早期设计错误的另一个例子,会在运行时抛出异常:

ICollection<Mammoth> collection = new Mammoth[10];  // Cast to interface type
collection.Add(new Mammoth());                      // Run-time exception

由于 ICollection<T> 不是协变的(显而易见的原因),所以您不能这样做:

ICollection<Mammoth> mammoths = new Array<Mammoth>(10);
ICollection<Animal> animals = mammoths;     // Not allowed

当然,现在还有一个协变的 IReadOnlyCollection<T> 接口 在幕后也被数组实现了1,但它只包含 Count,因此用途有限。

基类 Array

如果数组是泛型的,我们是否仍然需要非泛型的 Array 类?

在早期阶段,我们需要。所有数组都通过它们的基类 Array 实现了非泛型 IListICollectionIEnumerable 接口。这是给所有数组特定方法和接口的唯一合理方式,并且是使用 Array 基类的主要用途。枚举也是相同的选择:它们是值类型,但从 Enum 继承成员;委托也是如此,继承自 MulticastDelegate

现在支持泛型,是否可以删除非泛型的基类 Array

如果存在泛型类Array<T>,则所有数组共享的方法和接口可以定义在该类上。然后您可以编写例如Copy<T>(T[] source, T[] destination)而不是Copy(Array source, Array destination),这还具有一定的类型安全性。

但从面向对象编程的角度来看,拥有一个通用的非泛型基类Array很好,可以用于引用任何类型的数组,而不管其元素的类型如何。就像IEnumerable<T>继承自IEnumerable(某些LINQ方法仍在使用它)。

Array基类是否可以派生自Array<object>

不行,这会创建一个循环依赖关系:Array<T> : Array : Array<object> : Array : ...。此外,这将意味着您可以在数组中存储任何对象(毕竟,所有数组最终都将继承类型Array<object>)。


未来

新的通用数组类型Array<T>能够在不太影响现有代码的情况下添加吗?

不行。虽然语法可以进行调整,但现有的数组协变无法使用。

数组是.NET中的一种特殊类型。它甚至在公共中间语言中拥有自己的指令。如果.NET和C#的设计人员决定沿着这条路走,他们可以将T[]语法作为Array<T>的语法糖(就像T?Nullable<T>的语法糖一样),并且仍然使用分配连续内存的特殊指令和支持。

但是,您将失去将Mammoth[]数组转换为其基本类型之一Animal[]的能力,类似于无法将List<Mammoth>转换为List<Animal>。但是,数组协变已经被破坏了,而且有更好的替代方案。

数组协变的替代方案?

所有的数组都实现了 IList<T> 接口。如果将 IList<T> 接口作为一个逆变接口进行处理,那么你就可以将任何数组 Array<Mammoth> (或者其他任何列表)强制转换为 IList<Animal>。然而,这需要重写 IList<T> 接口以删除所有可能更改底层数组的方法。
interface IList<out T> : ICollection<T>
{
    T this[int index] { get; }
    int IndexOf(object value);
}

interface ICollection<out T> : IEnumerable<T>
{
    int Count { get; }
    bool Contains(object value);
}

请注意,输入参数位置上的类型不能是 T,否则会破坏协变性。但是,object 对于 ContainsIndexOf 来说已经足够了,当传入错误类型的对象时,它们只会返回 false。实现这些接口的集合可以提供自己的泛型 IndexOf(T value)Contains(T value) 方法。

然后你就可以这样做:

Array<Mammoth> mammoths = new Array<Mammoth>(10);
IList<Animals> animals = mammoths;    // Covariant conversion

甚至有小的性能提升,因为运行时不需要检查分配的值是否与数组元素的实际类型兼容,当设置数组元素的值时。


我的尝试

我尝试了一下,如果在C#和.NET中实现Array<T>类型,并结合上述真正的协变IList<T>ICollection<T>接口,它可以很好地工作。我还添加了不变的IMutableList<T>IMutableCollection<T>接口,以提供新的IList<T>ICollection<T>接口缺少的变异方法。

我围绕它构建了一个简单的集合库,您可以从BitBucket下载源代码和编译二进制文件,或安装NuGet包:

M42.Collections - 专门的集合,具有比内置的.NET集合类更多的功能、特性和易用性。



1) 在 .Net 4.5 中,数组 T[] 通过其基类 Array 实现了以下接口:ICloneable, IList, ICollection, IEnumerable, IStructuralComparable, IStructuralEquatable;并且在运行时通过以下接口默默实现:IList<T>, ICollection<T>, IEnumerable<T>, IReadOnlyList<T>, 和 IReadOnlyCollection<T>


1
感谢奥丁,C#设计师没有像Java一样使用泛型擦除(即=擦除)。使用擦除时,在运行时Array与Array<T>相同,但在所有其他方面都是可怕的事情。 - durilka
1
我无法在工作中使用它,因为该软件是专有的(无法分发源代码)。如果它至少是LGPL或双重许可证Apache2/MS-PL/MIT/BSD,那么我就可以使用它。 - Dustin Kingen
2
看起来当我读这些文件时我的眼睛欺骗了我,因为它们确实是LGPL。 - Dustin Kingen
1
@Virtlink:你应得的。 - Ken Kin
@supercat,您的意思是:您可以将从数组中获取的任何对象放回其中。虽然非常正确,但对于任何可变列表都适用。我可能没有理解您的重点... - Daniel A.A. Pelsmaeker
显示剩余7条评论

15

[更新,新的见解,感觉缺少了什么]

关于之前的答案:

  • 数组像其他类型一样是协变的。你可以使用协变实现类似于“object[] foo = new string[5]”这样的操作,因此这不是原因。
  • 兼容性可能是不重新考虑设计的原因,但我认为这也不是正确的答案。

然而,我能想到的另一个原因是,因为数组是内存中线性元素的“基本类型”。我一直在思考使用Array<T>,这也是您可能会想知道为什么T是Object以及为什么这个“Object”甚至存在的地方?在这种情况下,T []只是我认为另一种与Array<T>相似且与Array协变的语法。由于类型实际上不同,我认为这两种情况类似。

请注意,基本Object和基本Array都不是面向对象语言的要求。C ++就是这方面的完美例子。没有这些基本构造的基本类型的警告是无法使用反射处理数组或对象。对于对象,您习惯制作Foo东西,这使得“对象”感觉自然。实际上,没有数组基类同样不可能做到Foo——这不太常用,但对于范例同样重要。

因此,我认为没有Array基类型的C#,但拥有丰富的运行时类型(尤其是反射)是不可能的。

更多细节...

数组在哪里使用以及为什么它们是数组

对于像数组这样基本的东西具有基本类型非常重要,因为它用于许多事情并且有很好的理由:

  • 简单数组

是的,我们已经知道人们使用T[],就像他们使用List<T>一样。两者都实现了一个共同的接口集,确切地说:IList<T>ICollection<T>IEnumerable<T>IListICollectionIEnumerable

如果您知道这些,可以轻松创建一个数组。我们也都知道这是真的,这并不令人兴奋,所以我们继续前进......

  • 创建集合。

如果您深入研究List,最终会得到一个数组 - 确切地说:T[]数组。

那为什么呢?虽然您可以使用指针结构(LinkedList),但它并不一样。列表是连续的内存块,并通过成为连续的内存块而获得其速度。有很多原因,但简单地说:处理连续内存是处理内存的最快方式 - 甚至在CPU中都有用于此的指令,使其更快。

一位仔细的读者可能会指出,你不需要一个数组,而是需要一个连续的元素块,类型为'T',这样IL才能理解并处理。换句话说,只要确保有另一种类型可以被IL用于同样的目的,就可以在此处摆脱Array类型。
请注意,有值类型和类类型。为了保持最佳性能,您需要按原样将它们存储在块中...但对于编组,这只是一个要求。
  • 编组。
编组使用所有语言都同意的基本类型进行通信。这些基本类型是像byte、int、float、pointer和array这样的东西。最显著的是C/C++中数组的使用方式,如下所示:
for (Foo *foo = beginArray; foo != endArray; ++foo) 
{
    // use *foo -> which is the element in the array of Foo
}

基本上,这将在数组的开头设置一个指针,并递增指针(使用sizeof(Foo)字节),直到它达到数组的末尾。元素在* foo处检索 - 这会获取指针“foo”指向的元素。
请注意,有值类型和引用类型。你真的不想要一个仅将所有内容存储为对象框的MyArray。实现MyArray变得更加棘手了。
一些细心的读者可能指出这里的事实,即你不真正需要一个数组,这是正确的。你需要具有类型Foo的连续元素块 - 如果它是值类型,则必须将其作为(字节表示的)值类型存储在块中。
多维数组
那么更多...多维度呢?显然规则并不是那么黑白分明,因为突然间我们不再拥有所有的基类了:
int[,] foo2 = new int[2, 3];
foreach (var type in foo2.GetType().GetInterfaces())
{
    Console.WriteLine("{0}", type.ToString());
}

强类型定义被放弃了,现在你只能使用集合类型 IList, ICollection, 和 IEnumerable。那我们怎么获取它们的大小呢?如果使用数组基类,我们可以这样做:

Array array = foo2;
Console.WriteLine("Length = {0},{1}", array.GetLength(0), array.GetLength(1));

但如果我们看看其他替代品,比如IList,就没有同等的替代品。我们该怎么解决呢?在这里应该引入一个IList<int, int>吗?当然这是错误的,因为基本类型只是int。那么IMultiDimentionalList<int>呢?我们可以这样做,并填充它与当前在Array中的方法。

  • 数组具有固定大小

你是否注意到了为重新分配数组而进行的特殊调用?这与内存管理有关:数组如此低级,以至于它们不理解增长或缩小是什么意思。在C中,您会使用'malloc'和'realloc'来进行此操作,并且您确实应该实现自己的'malloc'和'realloc'以了解为什么对于您直接分配的所有内容都具有固定大小的重要性。

如果您仔细看看,只有少数几件事情以“固定”大小分配:数组、所有基本值类型、指针和类。显然,我们以不同的方式处理数组,就像我们以不同的方式处理基本类型一样。

关于类型安全的附注

那么为什么需要这些“访问点”接口呢?

在所有情况下,最佳做法是为用户提供类型安全的访问点。可以通过比较以下代码来说明:

array.GetType().GetMethod("GetLength").Invoke(array, 0); // don't...

编写如下代码:

((Array)someArray).GetLength(0); // do!

类型安全使您在编程时可以更加随意。如果使用正确,编译器会在您出错时发现错误,而不是在运行时才发现。我无法强调这一点有多重要 - 毕竟,在测试用例中可能根本不会调用您的代码,而编译器将始终对其进行评估!

将所有内容组合在一起

那么...让我们把所有内容都组合在一起。我们需要:

  • 一个强类型数据块
  • 它的数据被连续存储
  • IL支持,以确保我们可以使用酷炫的CPU指令,使其运行速度非常快
  • 一个公共接口,暴露所有功能
  • 类型安全
  • 多维度
  • 我们希望值类型被存储为值类型
  • 与任何其他语言的相同的封送结构
  • 固定大小,因为这样可以更容易地进行内存分配

对于任何集合来说,这是相当多的低级要求...它需要以某种方式组织内存,并将其转换为IL/CPU...我想说它被认为是基本类型有很好的理由。


1
我添加了更多的内容到我的回答中...直到现在感觉好像少了点什么。我问了自己一个问题:“一个拥有Array<T>的编程语言会有什么限制”,然后得出了这个结论。总之,如果我从头开始重新设计这种语言,我肯定会添加一个Array。因此,我强烈感觉虽然其他回答可能看起来还不错,但它们都是错误的。 - atlaste
1
当然,给你;-) 我想我写了一本关于它的书,而不是一个简单的例子... :-) - atlaste
1
@StefandeBruijn 在我的帖子中,您所说的“基本数组类型错误”是什么意思?另外,关于您的编辑:要创建一个连续的“T”元素块而不使用数组,您需要创建一个具有“T”的_n_字段的类。在C#或CLR中无法在运行时执行此操作。此外,可以使用嵌套向量数组创建多维数组,并且在当前的C#中,T[][]T[,]更快。 - Daniel A.A. Pelsmaeker
1
我同意需要一个连续的元素块(数组),但它需要特殊的运行时支持和使用它的特殊指令。请注意,多维数组没有特殊指令,因此多维数组将比交错数组慢。然而,在C# 5中,没有必要为数组提供特殊语法。对象实例化是明确定义的,但只有对于数组来说是不同的。这是历史遗留问题,当泛型不存在时,但现在它污染了C#语言。顺便说一下,T[]数组不是类型安全的。 - Daniel A.A. Pelsmaeker
2
是的,我们都同意这一点。我指的是T[] == Array<T> : Array,而不是T[] : Array<T> : Array。是的,使用Array<T>而非T[]语法是我的_偏好_,因为在C#中其他所有内容也都是这样工作的。而且现在你用数组的机会比较少了,反而更多地使用IEnumerable<T>List<T>Array<T>可以很好地适应这种情况。当我提到类型安全时,我是指Animal[] animals = new Elephant[10]; animals[0] = new Giraffe();这行代码表明,数组不能像其他语言结构(包括协变泛型)那样提供编译时类型安全性。运行时异常就要出现了! - Daniel A.A. Pelsmaeker
显示剩余9条评论

12

兼容性。Array是一个历史类型,它可以追溯到没有泛型的时代。

如今有意义的做法是有Array,接着是Array<T>,最后是具体的类 ;)


我想借此机会问一下,http://msdn.microsoft.com/en-us/library/system.array.aspx 断言“从而成为公共语言运行时中所有数组的基类”。文档中所说的“作为基类”,是指它“是基类”吗? - e_ne
你的意思是除了历史原因之外,这不会成为一个问题吗? - Ken Kin
1
不,这就是它不具备通用性的原因。它实际上已经通过子类化了 - 只是没有通过正常的通用机制。这是古老的.NET 1.0版本。现在很难更改。 - TomTom
1
是的。这只是历史。很多旧的代码库遗憾地忽略了泛型。 - TomTom
1
是的和不是的。我会预计更多棘手的代码会出现问题。在反射方面,人们会对层次结构做出假设。 - TomTom
1
@Eve:框架包含一些“特殊”类型,它们的后代被认为具有某些特征,而这些特殊类型本身则缺乏这些特征。例如,从System.Enum派生的每种类型都是值类型,其基本表示形式为某种整数类型,但System.Enum本身是类类型。同样,除了System.Enum之外的System.ValueType的派生类型都是值类型,但System.ValueType本身是类类型。System.Array也存在类似的情况。每个合法的派生类型都有一个索引器,但System.Array没有。 - supercat

5

因此,我想知道为什么不是这样:

原因是C#的第一个版本中没有泛型。

但我自己无法想出问题所在。

问题在于它会破坏大量使用 Array 类的代码。C# 不支持多重继承,因此像下面这样的语句

Array ary = Array.Copy(.....);
int[] values = (int[])ary;

如果微软从头开始重新制作C#和.NET,那么将Array变成一个泛型类可能就不会有问题了,但现实并非如此。

否则,Array的所有现有代码都将被打破。


1
我不确定我是否真正理解他的答案。如果反射无法很好地处理通用类,那么我认为使反射更加灵活应该是解决方案,而不是将数组归入非泛型类别中。但是,如果您认为他的答案对您的问题更好,请自行决定接受它。 - JLRishe

3
除了其他人提到的问题,尝试添加一个通用的 Array<T> 会带来一些其他的困难:
  • 即使今天的协变特性从泛型引入之时就存在,它们对于数组来说也是不足够的。设计用于排序 Car[] 的程序将能够对 Buick[] 进行排序,即使它必须将数组元素复制到类型为 Car 的元素中,然后再将它们复制回来。从类型 Car 复制元素回 Buick[] 并不是真正的类型安全,但它很有用。可以定义一个协变单维数组接口,使排序成为可能 [例如通过包括一个 `Swap(int firstIndex, int secondIndex)` 方法],但要做出像数组一样灵活的东西是很困难的。

  • 虽然 Array<T> 类型对于 T[] 可能效果很好,但在泛型类型系统中没有一种方法来定义一个家族,该家族将包括 T[]T[,]T[,,]T[,,,] 等任意数量的下标。

  • .NET 没有一种方法来表达两种类型应被视为相同的概念,这样类型为 T1 的变量就可以复制到类型为 T2

  • 有可能通过捣乱类型系统来允许一个类型 Array<T> 表现出它应该有的行为,但这种类型在很多方面的行为都与其他泛型类型完全不同,而且既然已经有一个实现所需行为的类型 (即 T[]),那么定义另一个类型会带来什么好处并不清楚。


@KenKin:在 .net 类型系统中,没有办法定义真正的数组数组;尝试这样做将会定义一个指向数组的引用数组。如果通用类型可以使用整数以及Type参数来定义,并且如果有一个数组值类型ValueArray<size,T>,那么一个Int[3,5]可能可以在类型系统中表示为大小为3的Array<ValueArray<5,Int>>,但是框架中不存在这样的设施。C#可以在“裸金属”上实现固定数组,但框架不理解它们,也无法验证它们的使用。 - supercat
1
@KenKin:C#中的Foo[][]等同于C中的*Foo[],而C#中的Bar[,]等同于C中的Bar[][] - supercat

2
众所周知,原始的Array是非泛型的,因为在v1时不存在泛型。以下是推测:

如果要使“Array”成为泛型(现在这样做很有意义),则可以采取以下两种方法:

  1. 保留现有的Array并添加泛型版本。这很好,但大多数使用“Array”的情况涉及随时间增长而扩展它,并且更好的实现相同概念的List<T>已经被实现。此时,添加“元素顺序列表,不会增长”的泛型版本看起来并不是很吸引人。

  2. 删除非泛型的Array,并用具有相同接口的泛型Array<T>实现替换。现在,您必须使旧版本的编译代码与新类型一起工作,而不是使用现有的Array类型。虽然框架代码支持这种迁移是可能的(也很可能很难),但总有很多其他人编写的代码。

    由于Array是非常基本的类型,几乎每个现有的代码片段(包括使用反射和封送到本地代码和COM的自定义代码)都使用它。因此,即使是版本之间的微小不兼容性(1.x->2.x的.Net Framework)的代价也非常高。

因此,Array类型将永远存在。我们现在有List<T>作为泛型等效物来使用。


你似乎认为 T[]Array 是等价的,但实际上它们并不相同。而且 List<T> 不是数组 T[] 或任何数组 Array 的泛型等价物。 - Daniel A.A. Pelsmaeker
@Virtlink,对于列表和数组的区别,为什么您认为List<T>不属于“数组概念”?从我的角度来看,T[](通过Array实现)和List<T>都提供了有序的元素序列,并保证了O(1)的索引。列表更加灵活。是的,列表不能在所有可以使用T[]的情况下使用(如PInvoke),但这并不是我关心的事情。 - Alexei Levenkov
@Virtlink 对于 ArrayT[] 的看法是:Array 本质上是 T[] 的通用实现(在“使用与泛型相同的概念”方面),因此在某种程度上我认为它们是等效的。我对原始问题的理解是“为什么在非泛型类的基础上有语法糖(T[]),而不是使用真正的泛型 Array<T> 来实现更直接的映射”,这可能是错误的/无关的... - Alexei Levenkov
T[]是一个具有协变类型参数T的通用数组(尽管与今天在C#中使用的泛型不同),但Array在这里并不相关。如果所有数组T[]直接从Object继承,它也可以正常工作。它们并不等价。而数组是一个基本概念,它为您提供了一块连续的内存区域。事实上,这导致O(1)索引,而列表也恰好具有O(1)索引,并不意味着它们是等价的,当然也不会使列表等价于固定大小的数组。 - Daniel A.A. Pelsmaeker
@Virtlink 很好的观点 - 我们的定义非常不同,你可能应该写下自己的答案,这可能会更有趣/有用。我理解 Array 的唯一目的是实现 T[],我看到它与你的 "Array 恰好被用于实现 T[]" 有所不同。而对于 "数组作为基本概念",它与 "可索引元素序列" 相比,与 "固定大小的连续内存块" 不同... - Alexei Levenkov
谢谢,我会在这里写下自己的答案。 - Daniel A.A. Pelsmaeker

1
也许我漏掉了什么,但是除非将数组实例强制转换或用作ICollection、IEnumerable等,否则使用T类型的数组没有任何优势。
数组快速且已经具备类型安全性,并且不会产生任何装箱/拆箱开销。

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