前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[深入解析C#] 泛型

[深入解析C#] 泛型

作者头像
科控物联
发布2022-03-29 16:24:06
1.4K0
发布2022-03-29 16:24:06
举报
文章被收录于专栏:科控自动化

2.1 泛型

使用泛型(generic),可以编写在编译时类型安全的通用代码,无须事先知道要使用的具体类型,即可在不同位置表示相同类型。在引入之初,泛型主要用于集合。如今,泛型已经广泛应用于C#的各个领域,其中用得较多的有如下几项:

集合(在集合中泛型一如既往地重要);

委托(尤其是在LINQ中的应用);

异步代码(Task<T>表示该方法将返回一个类型为T的值);

可空值类型(详见2.2节)

当然,泛型的应用场景远不止上述几项。不过,这4项用途足以表明泛型特性已经深入C#开发人员的日常工作中了。以集合为例来展现泛型的诸多优势,可谓再合适不过了。可以通过对比.NET 1中的普通集合和.NET 2中的泛型集合来充分体会。

2.1.1 示例:泛型诞生前的集合

.NET 1有如下3大类集合。数组:语言和运行时直接支持数组。数组的大小在初始化时就已经确定。普通对象集合:API中的值(或者键)由System.Object描述。尽管诸如索引器和foreach语句这些语言特性可应用于普通对象集合,但语言和运行时并未对其提供专门的支持。ArrayList和Hashtable是更常见的两种对象集合。专用类型集合:API中描述的值具有特定类型,集合只能用于该类型。例如StringCollection是保存字符串的集合,虽然其API看起来与ArrayList的类似,但是它只能接收String类型的元素,而不能接收Object类型的。

数组和专用类型集合都属于静态类型,因此API可以阻止将错误类型的值添加到集合中。在从集合中取值时,也无须手动转换类型。说明 由于存在数组协变机制,因此引用类型的数组不能完全确保类型安全。我认为,数组协变机制是C#早期的一处设计失误。有关数组协变的内容超出了本书范畴,暂不讨论。若有兴趣,请参考Eric Lippert的文章“Covariance and Contravariance in C#, Part Two Array Covariance”,这是他关于协变与逆变的系列文章之一。

下面具体看看。假设有一个名为GenerateNames的方法,该方法用于创建一个String类型的集合,此外还有一个名为PrintNames的方法,它可以把该集合的所有元素显示出来。我们分别用上述三种集合(数组、ArrayList以及StringCollection)来实现,然后对比这三者的优劣。采用这三种方式创建集合,代码大同小异(尤其是PrintNames方法),请容我慢慢道来。首先是数组。代码清单2-1 使用数组创建并打印namesstatic string[]

代码语言:javascript
复制
GenerateNames()
{
    string[] names = new string[4]; <------ 在创建数组时就必须获得数组大小
    names[0] = "Gamma";
    names[1] = "Vlissides";
    names[2] = "Johnson";
    names[3] = "Helm";
    return names;
}
static void PrintNames(string[] names)
{
    foreach (string name in names)
    {
        Console.WriteLine(name);
    }
}

代码清单2-1中特意没有使用数组初始化器来创建数组,而是模拟了逐个获取names元素的场景,比如读文件。另外需注意,在创建数组时就应当为其确定合适的大小。像读文件这种情况,就需要事先知道文件中有多少个名字,才能在创建数组时为其分配大小。或者采用更复杂的方式,比如先创建一个初始数组,如果初始数组被填满,就再创建一个更大的数组,把初始数组中的元素全部复制到新数组中,如此循环往复,直到所有元素添加完毕。之后,如果数组依然有剩余空间,可能需要再创建一个大小合适的数组,再把所有元素复制到最终的这个数组中。

诸如追踪当前集合大小、重新分配数组等重复性操作,都可以用一个类型封装起来,使用ArrayList即可实现。代码清单2-2 使用ArrayList创建并打印names

代码语言:javascript
复制
static ArrayList GenerateNames()
{
    ArrayList names = new ArrayList();
    names.Add("Gamma");
    names.Add("Vlissides");
    names.Add("Johnson");
    names.Add("Helm");
    return names;
}


static void PrintNames(ArrayList names)
{
    foreach (string name in names) <------ 如果ArrayList中包含一个非字符串的元素会怎样?
    {
        Console.WriteLine(name);
    }
}

在创建ArrayList时,无须事先知晓names的元素个数,因此GenerateNames方法得以进一步简化。不过,还有一个和数组相同的问

代码语言:javascript
复制
static ArrayList GenerateNames()
{
    ArrayList names = new ArrayList();
    names.Add("Gamma");
    names.Add("Vlissides");
    names.Add("Johnson");
    names.Add("Helm");
    return names;
}


static void PrintNames(ArrayList names)
{
    foreach (string name in names) <------ 如果ArrayList中包含一个非字符串的元素会怎样?
    {
        Console.WriteLine(name);
    }
}

在创建ArrayList时,无须事先知晓names的元素个数,因此GenerateNames方法得以进一步简化。不过,还有一个和数组相同的问

代码语言:javascript
复制
static void PrintNames(StringCollection names)
{
    foreach (string name in names)
    {
        Console.WriteLine(name);
    }
}

除了把ArrayList都替换成了StringCollection,代码清单2-3与代码清单2-2几乎完全一致。这也正是StringCollection的意义所在:用法上与通用型集合并无二致,但只负责处理String类型的元素。StringCollection.Add方法的参数类型是String,因此不能向其添加WebRequest类型的值。这样就保证了在显示names时,foreach循环不会遇到非String类型的值(null引用例外)。

在只需要处理string类型的情况下,StringCollection确实是不二之选。可是如果需要使用其他类型的集合,要么寄希望于.NET Framework已经提供了所需的集合类型,要么就只能自己写一个了。由于类似的需求十分普遍,因此就有了System.Collections.CollectionBase这个抽象类,用于减少上述重复性工作。另外,还可以使用一些现成的代码生成器,来有效规避纯手写代码。

使用专用类型集合可以解决前面提到的两个问题,但是创建如此多额外类型,代价实在太高了,而且当代码生成器发生变化时,同步更新这些类型的维护成本也不容忽视。另外,编译时间、程序集大小、JIT耗时、代码段内存都会产生额外的性能消耗,最关键的还有维护这些集合所需的人力成本。

即便上述成本都可以忽略,也不能忽视代码灵活性的降低:无法以静态方式编写适用于所有集合类型的通用方法,也无法把集合元素的类型用于参数或者返回值类型。假设需要创建一个方法,该方法把一个集合的前N个元素复制到一个新的集合中,之后返回该新集合。如果使用ArrayList,那就等同于舍弃了静态类型的优势。如果传入StringCollection,那么返回值类型也必须是StringCollection。String类型成了该方法输入的要素,于是返回值也被限制到了String类型。C# 1对这个问题束手无策,于是泛型出场了。

2.1.2 泛型降临

解决上述问题的办法就是采用泛型List<T>。List<T>是一个集合,其中T表示集合中元素的类型,在我们的例子中,string就是这个T,因此List<string>就可以替换所有StringCollection2。2还有一种解决办法——通过接口来约束返回值和参数类型,不过这里不做探讨,以免分散读者的精力。代码清单2-4 使用List<T>创建并打印names

代码语言:javascript
复制
static List<string> GenerateNames()
{
    List<string> names = new List<string>();
    names.Add("Gamma");
    names.Add("Vlissides");
    names.Add("Johnson");
    names.Add("Helm");
    return names;
}


static void PrintNames(List<string> names)
{
    foreach (string name in names)
    {
        Console.WriteLine(name);
    }
}

List<T>解决了前文提到的所有问题。

与数组不同,List<T>无须在创建前先获知集合的大小。

与ArrayList不同,在对外提供的API中,一切表示元素类型之处皆用T来代指,这样我们就能知道List<string>的集合只能包含String类型的引用。如果向集合添加了错误类型的元素,在编译时就会报错。

与StringCollection等类型不同,List<T>兼容所有类型,省去了生成代码以及处理返回值等诸多困扰。

使用泛型,还可以解决使用元素类型作为方法的输入类型这一问题。下面将介绍更多术语,以便进一步深入探讨。类型形参与类型实参形参(parameter)和实参(argument)的概念,比C#泛型概念出现得还要早,其他一些语言使用形参和实参已有数十年之久。声明函数时用于描述函数输入数据的参数称为形参,函数调用时实际传递给函数的参数称为实参。图2-1描述了二者的关系。

图2-1 函数形参与实参的关系

实参的值相当于方法形参的初始值,而泛型涉及两个参数概念:类型形参(type parameter)和类型实参(type argument),相当于把普通形参和实参的思想用在了表示类型信息上。在声明泛型类或者泛型方法时,需要把类型形参写在类名或者方法名称之后,并用尖括号<>包围。之后在声明体中,就可以像普通类型一样使用该类型形参了(只不过此时还不知道具体类型)。

之后在使用泛型类或泛型方法的代码中,需要在类型名或方法名后同样用尖括号包围,给出具体的实参类型。图2-2以List<T>为例呈现了二者的关系。

图2-2 类型形参与类型实参之间的关系

设想一下List<T>的完整API,包括全部的方法签名、属性等。当使用图2-2中的list变量时,API中的T都会被string替代。例如List<T>中的Add方法,其方法签名如下:public void Add(T item)

如果在Visual Studio中输入List.Add(,从IntelliSense的智能补全看,仿佛item参数在声明时就是string类型。如果给Add方法传入

非string类型的值,就会引发编译时错误。

图2-2是关于泛型类的示例。泛型也可以用于方法,在方法声明中给出类型形参,之后就可以在方法签名中使用这些类型形参了。而且当方法声明体中包含其他方法的调用语句时,这些类型形参还可以用作调用其他方法的类型实参。代码清单2-5解决了之前那个悬而未决的问题:以静态类型的方式把一个集合的前N个元素复制到另一个新集合中。代码清单2-5 集合间的元素复制public static List<T> CopyAtMost<T>( (本行及以下2行) 方法声明了类型形参T,并将T用于方法形参和返回类型中

代码语言:javascript
复制
    List<T> input, int maxElements)
{
    int actualCount = Math.Min(input.Count, maxElements);
    List<T> ret = new List<T>(actualCount); <------ 在方法体中使用类型形参
    for (int i = 0; i < actualCount; i++)
    {
        ret.Add(input[i]);
    }
    return ret;
}
代码语言:javascript
复制
static void Main()
{
    List<int> numbers = new List<int>();
    numbers.Add(5);
    numbers.Add(10);
    numbers.Add(20);


    List<int> firstTwo = CopyAtMost<int>(numbers, 2); <------ 方法调用使用nt作为类型实参
    Console.WriteLine(firstTwo.Count);
}

很多泛型方法的类型形参只用于方法签名中3,也不用作类型实参。不过,用类型形参来表示普通形参的类型与返回值类型之间的关系,是泛型的一个重要作用。

同样,当声明有基类或者接口时,泛型形参也可以用作基类或者接口的泛型实参,比如声明泛型类List<T>实现自泛型接口IEnumerable<T>:public class List<T> : IEnumerable<T>说明 实践中实现List<T>时不仅仅实现了这一个接口,上面仅是一个简化的示例。泛型类型和泛型方法的度

泛型类型或泛型方法可以声明多个类型形参,只需在尖括号

内用逗号把它们隔开即可,例如.NET中Hashtable类的泛型声明:public class Dictionary<TKey, TValue>

泛型度(arity)是泛型声明中类型形参的数量。坦白说,泛型度这个术语,我主要将其用于描述概念,对平时编写代码用处不是很大。不过了解这个概念还是有用的。可以将非泛型的声明视为泛型度为0。

泛型度是区分同名泛型声明的有效指标。比如前面提到C# 2中的泛型接口IEnumerable<T>,它和.NET 1.0中的非泛型接口IEnumerable就属于不同类型。类似地,可以编写同名但度不同的泛型方法,如下所示:public void Method() {} <------ 非泛型方法(泛型度为0)

public void Method<T>() {} <------ 泛型度为1的方法

public void Method<T1, T2>() {} <------ 泛型度为2的方法

当声明同名但度不同的泛型类型时,这些类型并不一定是同

一类别的,但一般不建议这么做。假如同一程序集中存在如下同名类型声明,使用者必然晕头转向:

代码语言:javascript
复制
public enum IAmConfusing {}
public class IAmConfusing<T> {}
public struct IAmConfusing<T1, T2> {}
public delegate void IAmConfusing<T1, T2, T3> {}
public interface IAmConfusing<T1, T2, T3, T4> {}

我不提倡以上这种写法,不过依然存在一种可以接受的情况:在一个非泛型静态类中,提供一个辅助方法,它会调用其他同名的泛型类型(静态类相关内容请参考2.5.2节)。2.1.4节将介绍Tuple类,该类用于创建各种泛型Tuple类的实例。

类似于泛型类型,泛型方法也可以定义同名但泛型度不同的方法。这种方式类似于以不同参数来定义不同的重载方法,只不过是根据类型形参的数量来定义重载。请注意,泛型度可以用于区分同名方法,但是类型形参的名字不行,例如不

能出现如下所示的方法声明:public void Method<TFirst>() {}

public void Method<TSecond>() {} <------ 编译时错误:不能仅通过类型形参名称重载方法

这两条语句会被视为同一个方法声明,而方法重载规则不允许使用这样的声明。如果想让以上声明合法,可以通过其他方式区分它们(比如不同的普通参数个数),不过鲜有这样的操作。

另外,在一个方法声明中,多个类型形参不能采用相同的名字,这条规则和普通参数不能同名是一样的。例如下面的方法声明是非法的:public void Method<T, T>() {} <------ 编译时错误:重复的类型形参名称

而对于类型实参来说,同名类型实参很常用,比如Dictionary<string, string>。

前面IAmConfusing代码中用枚举类型作为非泛型类的示例并

非巧合,接下来它会派上用场。3假设我定义了类型形参,但是在方法签名中并不使用该类型形参,这种做法虽然完全可行,但毫无意义。

2.1.3 泛型的适用范围

并非所有类型或者类型成员都适用泛型。对于类型,这很好区分,因为可供声明的类型比较有限:枚举型不能声明为泛型,而类、结构体、接口以及委托这些可以声明为泛型类型。

对于类型成员来说,就没那么界限分明了。有些类型成员因为使用了其他泛型类型,看似泛型成员,但实际不是。只需记住一条原则:判断一个声明是否是泛型声明的唯一标准,是看它是否引入了新的类型形参。

方法和类型可以是泛型,但以下类型成员不能是泛型:

  • 字段;
  • 属性;
  • 索引器;
  • 构造器;
  • 事件;
  • 终结器。

下面举一个貌似泛型但实际不然的例子。考虑如下泛型类:public class ValidatingList<TItem>

{

private readonly List<TItem> items = new List<TItem>(); <------ 很多其他成员

}

这里用TItem作为类型形参的名字,是为了把它和List<T>区别开来。items是类型List<TItem>的一个字段,它将TItem用作List<

T>的类型实参。TItem是由ValidatingList类声明引入的类型形参,而不是由items声明本身引入的。

对于这些无法声明为泛型的类型成员,通常很难想象出它们如何才能成为泛型。有时我也有编写泛型构造器或者泛型索引器的需求,可最后往往是用一个泛型方法就实现了同样的功能。

关于泛型方法的调用,前文仅仅给出了关于类型实参的粗略描述。在调用泛型方法时,有时无须在代码中给出类型实参,编译器可以帮我们决定具体采用哪个类型。

2.1.4 方法类型实参的类型推断

请看代码清单2-5中的关键片段,其泛型方法声明如下:

代码语言:javascript
复制
public static List<T> CopyAtMost<T>(List<T> input, int maxElements)

在main方法中,声明一个List<int>类型的变量numbers,并将该变量作为CopyAtMost方法的调用实参:List<int> numbers = new List<int>();

...

List<int> firstTwo = CopyAtMost<int>(numbers, 2);

函数调用的代码已加粗。CopyAtMost函数声明了一个类型形参,因此在调用时需要给它传递类型实参。不过,在调用时,可以省略类型实参,如下所示:

代码语言:javascript
复制
List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost(numbers, 2);

从编译器之后生成的IL代码的角度讲,这两种调用写法完全相同。这里并不需要明确给出类型实参int,因为编译器可以自行推

断。推断的依据是方法调用中参数列表的第1个实参。形参input的类型是List<T>,其对应实参的类型是List<int>,因此编译器推断T的实际类型是int。

编译器只能推断出传递给方法的类型实参,但推断不出返回值的类型实参。对于返回值的类型实参,要么显式地全部给出,要么隐式地全部省略。

尽管类型推断只能用于方法,但它可以简化泛型类型实例的创建,例如.NET 4.0中的元组系列。元组系列包含了一个非泛型的静态类Tuple以及一批泛型类:Tuple<T1>、Tuple<T1, T2>、Tuple<T1, T2, T3>等。静态类包含了一组重载Create工厂方法:

代码语言:javascript
复制
public static Tuple<T1> Create<T1>(T1 item1)
{
    return new Tuple<T1>(item1);
}


public static Tuple<T1, T2> Create<T1, T2>(T1 item1, T2 item2)
{
    return new Tuple<T1, T2>(item1, item2);
}

这种写法乍一看似乎没什么意义。要知道,泛型类型推断并不适用于构造器。这么做旨在在创建元组的同时利用类型推断。直接调用构造器的实现代码比较烦琐:new Tuple<int, string, int>(10, "x", 20)

但是使用静态方法配合类型推断,代码就简单多了4:4前面说过构造器不能为泛型,构造器中的泛型参数实际上是来自它所在类的类型形参。——译者注Tuple.Create(10, "x", 20)

这是一个非常简单实用的技巧,利用它编写泛型代码轻松而愉悦。

关于泛型类型推断的实现原理,这里不做深入探讨。C#语言设计团队一直致力于让类型推断能够应用于更多场景,在此探索过程中,类型推断的实现原理也在不断更新变化。类型推断和重载决议(overload resolution)的实现原理密切相关,而它们又和其他特性有交叉(比如继承、转换、可选形参等)。这是C#语言规范中较为复杂的一部分内容5,因此这里不再赘述。5这绝非一己之见。就在本书编写期间,重载决议这部分的技术标准崩坏了,在C# 5 ECMA标准中的修复尝试也失败了,只能等到下一个版本再做尝试。

况且理解这部分的实现细节对于日常编码帮助不是很大。大体说来,通常只会遇到以下3种情况。

类型推断成功,并得到预期结果。

类型推断成功,但没有得到预期结果。此时,只需显式指定类型实参或者对某些实参转换类型即可。例如上文的Tuple.Create方法,如果目标结果是Tuple<int, object, int>类型的元组,就显式指定类型实参:Tuple.Create<int, object, int>(10, "x", 20);或者直接调用构造器new Tuple<int, object, int>( ... );或者调用Tuple.Create(10, (object) "x", 20)。

类型推断在编译时报错。有时只需要转换参数类型就能解决。例如调用Tuple.Create(null, 50),类型推断会失败,因为null本身不包含任何类型信息,改写成Tuple.Create((string) null, 50)即可。如遇其他情况,只需显式给出类型实参即可。

依据我的个人经验,无论采取哪种策略,对代码的可读性影响都不大。了解类型推断的原理有助于编码者进行失败预判,但是为此花费大量时间去学习技术标准,又似乎有点得不偿失。如果读者

对这部分内容感兴趣,想深入研究,我也不会强加阻拦,但是要做好心理准备,你可能会仿佛置身于一个错综复杂的迷宫之中而迷途难返。

不过这些都不影响类型推断本身的便利性,C#也因它的存在而变得更加简单易用。

前面提到的所有类型形参都是未经约束的,它们可以表示任何类型。有时对于某个类型形参,需要它只限于特定类型,这就有了类型约束的概念。

2.1.5 类型约束

在泛型类型或泛型方法中声明类型形参时,可以使用类型约束来限定哪些类型可以用作类型实参。假设需要一个用于格式化列表元素的方法,该方法可以确保采用特定culture而不是默认culture来格式化。IFormattable接口有一个满足该需求的方法:ToString(string, IFormatProvider),可是该如何确保传入的列表符合要求呢?或许有人打算这么写:

代码语言:javascript
复制
static void PrintItems(List<IFormattable> items)

但是这种写法几乎没什么用,比如List<decimal>类型的值就无法传给该方法。尽管decimal类型实现了IFormattable接口,但是它不能转换为List<IFormattable>类型。说明 关于List<decimal>不能转换为List<IFormattable>类型的原因,第4章介绍泛型型变时会深入探讨,这里暂且只考虑泛型约束的内容。

下面解释一下这个例子中类型约束要表达的信息:PrintItems方法参数需要一个列表,其中保存的是某个类型的元素,这些元素都

要实现IFormattable接口。其中“某个类型”表示这里需要使用泛型来实现,“元素都要实现IFormattable接口”这一点则需要类型约束来保证,做法就是在函数声明的末尾添加where语句,参考如下代码:static void PrintItems<T>(List<T> items) where T : IFormattable

使用泛型约束,不仅可以约束方法实参的值类型,也会约束方法内部如何操作和使用T类型的值。通过约束,编译器就可以知道T实现了IFormattable接口,于是才会允许该T类型的值调用IFormattable.ToString(string, IFormatProvider)方法。代码清单2-6 使用类型约束打印itemsstatic

代码语言:javascript
复制
void PrintItems<T>(List<T> items) where T : IFormattable
{
    CultureInfo culture = CultureInfo.InvariantCulture;
    foreach (T item in items)
    {
        Console.WriteLine(item.ToString(null, culture));
    }
}

如果没有类型约束,那么item.ToString的调用方法将无法通过编译,因为编译器只能查找到System.Object下的ToString方法。

类型约束不仅适用于接口,还可以约束以下类型。引用类型约束——where T : class。类型实参必须是一个引用类型。(class这个关键字容易引起误解,它表示任何引用类型,包括所有接口和委托。)值类型约束——where T : struct。类型实参必须是非可空值类型(结构体类型或枚举类型)。可空值类型(2.2节会讲到)不适用于本约束。

构造器约束——where T : new()。类型实参必须是公共的无参构造器。该约束保证了可以通过new T()创建一个T类型的实例。转换约束——where T : SomeType。这里的SomeType可以是类、接口或者其他类型形参:where T : Controlwhere T : IFormattablewhere T1 : T2

类型约束可以组合使用,而组合规则比较复杂。一般说来,如果违反了相关规则,编译器会给出明确的错误信息。

类型约束有一种有趣且常见的用法,那就是把类型形参用于类型约束本身:public void Sort(List<T> items) where T : IComparable<T>

以上约束把T用作泛型接口IComparable<T>的类型实参,这样Sort方法就可以调用items中元素的CompareTo方法来比较大小了,CompareTo方法正是来自IComparable<T>接口的实现。T first = ...;

T second = ...;

int comparison = first.CompareTo(second);

我个人使用接口的类型约束频度最高。具体使用哪个类型约束,主要取决于编码类型。

当一个声明中存在多个类型形参时,每个类型形参都可以有各自的类型约束,如下所示:TResult Method<TArg, TResult>(TArg input) <------ 具有两个类型形参TArg和TResult的泛型方法

代码语言:javascript
复制
    where TArg : IComparable<TArg> <------ TArg必须实现IComparable<TArg>接
口
    where TResult : class, new() <------ TResult必须是具有无参构造器的引用类型

泛型相关内容已近尾声,还剩两个话题需要探讨,我们从C# 2与类型相关的两个运算符开始。

2.1.6 default运算符和typeof运算符

早在C# 1时代,typeof()运算符就出现了,它接收一个类型名称作为唯一操作数。C# 2加入了default()运算符,并且略微扩展了typeof的用途。default运算符的功能比较简单:它是一元运算符,其操作数是类型名或类型形参,返回值是该类型的默认值。当声明了一个字段,但是没有为该字段立刻赋值时,该字段的值就是默认值。如果是引用类型,默认值是一个null引用;如果是非可空值类型,将返回对应类型的“0值”(0、0.0、0.0m、false、UTF-16编码的单元0等);如果是可空值类型,则返回该类型的null值。default运算符可以用于类型形参以及提供了类型实参(也可以是类型形参)的泛型类型。例如在泛型方法中声明了一个类型形参T,下面几种形式均合法:default(T)default(int)default(string)default(List<T>)default(List<List<string>>)default运算符返回值的类型与操作数的类型一致。default常与泛型类型形参一起使用,因为对于非泛型类型,可以通过其他方式获得default值。例如定义了一个本地变量后,无法确定该变量在以

后的代码逻辑中是否一定会被赋值,于是我们给该变量先赋一个初始默认值。下面举例说明:

代码语言:javascript
复制
public T LastOrDefault<T>(IEnumerable<T> source)
{
    T ret = default(T); <------ 声明了一个局部变量,并将T的默认值赋给该局部变量
    foreach (T item in source)
    {
        ret = item; <------ 使用序列的当前元素替换局部变量值
    }
    return ret; <------ 返回最后一个赋值的元素值
}

typeof运算符的使用相对复杂一些。考虑以下几种常见情形:

不涉及泛型,例如typeof(string);

涉及泛型,但是不涉及类型形参,例如typeof(List<int>);

仅涉及类型形参,例如typeof(T);typeof操作数中有泛型,而且泛型作为类型形参出现,例如typeof(List<TItem>),它出现在声明了TItem类型形参的方法体内部;

涉及泛型,但是操作数中并没有出现类型实参,例如typeof(List<>)。

其中第一个场景最简单,而且用法从未变过。对于其他场景,需要仔细考虑,尤其最后一个还引入了新语法。typeof运算符的返回值是Type类型的值,而且Type类在经过扩展之后可以支持泛型。那么上述几种情况都各自返回什么值呢?需要考虑很多情形,比如下面这几种。

如果在包含List<T>定义的程序集中获取它的类型,那么结果是List<T>,不包含任何具体的类型实参,这被称为泛型类型定义。

如果在List<int>对象上调用GetType()方法,那么得到的结果

将包含int这个类型实参的信息。假设有一个泛型类定义如下:如果要获取它基类的类型,得到的类型将包含一个具体的类型形参(string)和一个类型形参形式的类型实参(T)。class StringDictionary<T> : Dictionary<string, T>

诚然,上面这些逻辑真的很复杂,但这也确实是类型机制的天性使然。使用Type类提供的很多方法和属性,能做到在泛型类型定义和提供了具体类型实参的类型之间转换。

下面继续介绍typeof运算符。要理解typeof运算符,一个简单的例子是typeof(List<int>),其返回值是List<int>的Type值,结果与调用new List<int>().GetType()相同。

接下来讨论typeof(T)。该表达式返回的是调用代码中T类型实参的Type。它的返回值永远是一个封闭的、已构造的类型,技术规范中将其描述为一个真正不包含任何类型形参的类型。尽管我通常会尽可能完全使用术语来解释概念,但是泛型相关术语(比如开放、封闭、具体、绑定、未绑定等)都太过难于理解,而且在实际编码中几乎用不到。后面会阐释“封闭”和“具体”这两个术语,至于另外几个术语,本书将不会涉及。

下面通过具体示例展示typeof(T)以及typeof(List<T>),其中的PrintType泛型方法负责打印typeof(T)和typeof(List<T>)的执行结果,main方法通过两个类型实参调用该方法。代码清单2-7 打印typeof的执行结果

代码语言:javascript
复制
static void PrintType<T>()
{
    Console.WriteLine("typeof(T) = {0}", typeof(T)); <------ 打印typeof(T)和typeof(List<T>)
    Console.WriteLine("typeof(List<T>) = {0}", typeof(List<T>));
}


static void Main()
{
    PrintType<string>(); <------ 使用string作为类型实参调用方法
    PrintType<int>(); <------ 使用int作为类型实参调用方法
}

以上代码的执行结果如下:typeof(T) = System.String

typeof(List<T>) = System.Collections.Generic.List`1[System.String]

typeof(T) = System.Int32

typeof(List<T>) = System.Collections.Generic.List`1[System.Int32]

重点关注:第一次方法调用时,代码运行上下文中T的类型实参为string,因此执行typeof(T)等同于执行typeof(string);同样,执行typeof(List<T>)等同于执行typeof(List<string>)。接下来以int作为类型实参再次调用方法,所得结果也与typeof(int)和typeof(List<int>)相同。泛型类型或泛型方法内部代码执行时,类型形参总是指向一个封闭的、已构造的类型。

这个例子还展示了使用反射时泛型类型的命名格式。List`1表示这是一个名为List的泛型类型,其泛型度为1(只有一个类型形参),后面方括号中的内容是类型实参。

最后讨论typeof(List<>)。该表达式看起来缺少类型实参。这种写法只有在typeof运算符中才有效,而且指向了泛型类型定义。对于度为1的泛型,书写格式为TypeName<>;如果参数多于1个,每增加一个参数就增加一个逗号。比如要获取Dictionary<TKey, TValue>的泛型类型定义,就写成typeof(Dictionary<,>);要获取Tuple<T1, T2, T3>的定义,则是typeof(Tuple<,,>)。

理解泛型类型定义和封闭的、已构造类型之间的区别,对于本

章最后一个话题至关重要:类型的初始化过程以及如何处理类型范围(静态)状态。

2.1.7 泛型类型初始化与状态

前面typeof的调用结果显示:List<int>和List<string>是由同一个泛型类型定义构造出来的两个类型,在使用时会被当作不同类型来对待;而且在初始化和处理静态字段时,也要当作不同类型处理。每个封闭的、已构造类型都会被单独初始化,并且拥有各自的静态域。代码清单2-8是一个非常简单的、非线程安全的泛型计数器。代码清单2-8 探索泛型中的静态字段

代码语言:javascript
复制
class GenericCounter<T>
{
    private static int value; <------ 每个封闭的、已构造类型对应一个字段


    static GenericCounter()
{
        Console.WriteLine("Initializing counter for {0}", typeof(T));
    }


    public static void Increment()
{
        value++;
    }


    public static void Display()
{
        Console.WriteLine("Counter for {0}: {1}", typeof(T), value);
    }
}


class GenericCounterDemo


    static void Main()
{


        GenericCounter<string>.Increment(); <------ 触发GenericCounter<string>的初始化
        GenericCounter<string>.Increment();
        GenericCounter<string>.Display();
        GenericCounter<int>.Display(); <------ 触发GenericCounter<int>的初始化
        GenericCounter<int>.Increment();
        GenericCounter<int>.Display();
    }
}

代码执行结果如下:Initializing counter for System.String

Counter for System.String: 2

Initializing counter for System.Int32

Counter for System.Int32: 0

Counter for System.Int32: 1

以上执行结果中有两点需要关注。首先,GenericCounter<string>和GenericCounter<int>的值是相互独立的;其次,静态构造器被执行了两次:每个封闭的、已构造类型各自执行了一次。如果没有提供静态构造器,就无法保证这两个类型之间初始化的顺序,但是本质上GenericCounter<string>和GenericCounter<int>还是两个独立的类型。

这个问题还可以进一步复杂化:将泛型类型嵌套。像下面这个类定义这样,类型实参的不同组合将得到不同的类型。

代码语言:javascript
复制
class Outer<TOuter>
{
    class Inner<TInner>
    {
   static int value;
    }
}

如果使用int和string作为类型实参,得到的下面几个类型将都是独立的,且各自拥有value字段:

代码语言:javascript
复制
Outer<string>.Inner<string>Outer<string>.Inner<int>Outer<int>.Inner<string>Outer<int>.Inner<int>

上面这种情况是比较少见的。这里只需知道,重要的是那些已经完全确定的类型(包括叶子类型和封闭类型的所有类型实参在内),这个问题就没有那么复杂了。

以上就是关于泛型的全部内容。泛型是C# 2截至目前最庞大的一个特性了,也是对C# 1的一项重大改进。下面介绍可空值类型,此项特性正是基于泛型建立的。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-10-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 科控物联 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档