本书的这一部分涵盖了在 C# 2(随 Visual Studio 2005 提供)和 C# 5(随 Visual Studio 2012 提供)之间引入的所有功能。 这与本书第三版的全部功能相同。 现在很多感觉就像古老的历史。 例如,我们理所当然地认为 C# 包含泛型。
对于 C# 来说,这是一个非常富有成效的时期。 我将在这部分介绍的一些特性是泛型、可为空的值类型、匿名方法、方法组转换、迭代器、部分类型、静态类、自动实现的属性、隐式类型的局部变量、隐式类型的数组、对象初始化器 、集合初始化器、匿名类型、lambda 表达式、扩展方法、查询表达式、动态类型、可选参数、命名参数、COM 改进、通用协变和逆变、异步/等待和调用者信息属性。 呸!
我希望你们中的大多数人至少对其中的大部分功能都有些熟悉,所以我在这部分的速度很快。 同样,为了合理简洁,我没有像在第三版中那样详细介绍。 目的是满足读者的各种需求:
- 介绍您可能在此过程中错过的功能
- 提醒您曾经知道但忘记的功能
- 对这些功能背后原因的解释:为什么要引入它们以及为什么要按照它们的方式设计它们
- 一个快速参考,以防你知道你想做什么但忘记了十个语法
如果您想了解更多细节,请参阅第三版。 提醒一下,购买第四版即可获得第三版的电子书副本。
这个简短的覆盖规则有一个例外:我完全重写了 async/await 的覆盖范围,这是 C# 5 中最大的特性。第 5 章介绍了使用 async/await 需要了解的内容,第 6 章介绍了它是如何使用的 在幕后实施。 如果你是 async/await 的新手,你几乎肯定会想等到你用过一点后再阅读第 6 章,即便如此,你也不应该期望它只是简单的阅读。 我试图尽可能通俗地解释事情,但这个话题从根本上讲是复杂的。 不过,我确实鼓励您尝试; 深入了解 async/await 有助于增强您在使用该功能时的信心,即使您永远不需要深入了解编译器为您自己的代码生成的 IL。 好消息是,在第 6 章之后,您会在第 7 章的形式中找到一点解脱。这是本书中最短的一章,也是在探索 C# 6 之前恢复的机会。
完成所有介绍后,准备好迎接功能的猛烈冲击。
本章涵盖
- 为灵活、安全的代码使用泛型类型和方法
- 用可为空的值类型表达信息的缺失
- 相对容易地构建 delegates
- 在不编写样板代码的情况下实现迭代器
如果您使用 C# 的经验可以追溯到很久以前,那么本章将提醒您我们已经走了多远,并提示您感谢一个敬业且聪明的语言设计团队。 如果您从未在没有泛型的情况下编写过 C#,您最终可能会想知道 C# 是如何在没有这些特性的情况下取得成功的。无论哪种方式,您仍然可能会发现您不知道的特性或您从未考虑过的细节。
自 C# 2 发布(使用 Visual Studio 2005)以来已经 10 多年了,因此很难对后视镜中的功能感到兴奋。 你不应该低估它的发布在当时的重要性。 这也很痛苦:从 C# 1 和 .NET 1.x 升级到 C# 2 和 .NET 2.0 需要很长时间才能通过行业尝试。 随后的演变要快得多。 C# 2 的第一个特性是几乎所有开发人员都认为最重要的特性:泛型。
2.1 泛型
泛型允许您编写在编译时类型安全的通用代码,在多个地方使用相同的类型,而无需事先知道该类型是什么。 首次引入泛型时,它们的主要用途是集合,但在现代 C# 代码中,它们随处可见。 它们可能最常用于以下方面:
- 集合(它们在集合中和以往一样有用)
- Delegates,特别是在 LINQ 中
- 异步代码,其中 Task 是对类型 T 的未来值的承诺
- 可空值类型,我将在 2.2 节中详细讨论
这绝不是它们有用性的限制,但即使是这四条也意味着 C# 程序员每天都在使用泛型。 集合提供了解释泛型优势的最简单方法,因为您可以查看 .NET 1 中的集合并将它们与 .NET 2 中的泛型集合进行比较。
2.1.1 通过实例介绍:泛型之前的集合
.NET 1有三种广泛的集合:
-
Arrays —— 这些具有直接的语言和运行时支持。 大小在初始化时是固定的。
-
Object-based collections —— 值(和相关的键)在 API 中使用 System.Object 进行描述。 这些没有特定于集合的语言或运行时支持,尽管可以与它们一起使用诸如索引器和 foreach 语句之类的语言功能。 ArrayList 和 Hashtable 是最常用的示例。
-
Specialized collections —— 在 API 中以特定类型描述值,并且集合只能用于该类型。 StringCollection 是字符串的集合,例如; 它的 API 看起来像 ArrayList,但使用 String 而不是 Object 来引用任何值。
数组和专用集合是静态类型的,我的意思是 API 可以防止您将错误类型的值放入集合中,并且当您从集合中获取值时,您不需要将结果转换回 到您期望的类型。
注意 引用类型数组只有在存储数值时才是最安全的,因为数组协变性。我认为数组协变是一个早期的设计错误,已经超出了本书的范围。Eric Lippert在http://mng.bz/ gYPv上写了这个问题,作为他关于协变和忌变的系列博文的一部分。
让我们具体说明一下:假设您想在一个方法(GenerateNames)中创建一个字符串集合,并在另一个方法(PrintNames)中打印出这些字符串。 您将看到三个选项来保留名称的集合——数组、ArrayList 和 StringCollection——并权衡每个选项的优缺点。 代码在每种情况下看起来都相似(特别是对于 PrintNames),但请耐心等待。 我们将从数组开始。
清单 2.1 使用数组生成和打印名称
static string[] 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);
}
}
我在这里没有使用数组初始化器,因为我想模拟一次只发现一个名称的情况,例如从文件中读取它们时。 请注意,您需要将数组分配为开始时的正确大小。 如果你真的是从一个文件中读取,你要么需要在开始之前找出有多少个名字,要么你需要编写更复杂的代码。 例如,您可以分配一个数组作为开始,如果第一个数组已满,则将内容复制到更大的数组中,依此类推。 然后,如果您最终得到的数组大于名称的确切数量,则需要考虑创建一个大小合适的最终数组。
到目前为止,用于跟踪我们的集合大小、重新分配数组等的代码是重复的,并且可以封装在一个类型中。 碰巧,这正是 ArrayList 所做的。
清单 2.2 使用 ArrayList 生成和打印名称
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);
}
}
就我们的 GenerateNames 方法而言,这更简洁:在开始添加到集合之前,您无需知道有多少名称。 但同样,没有什么可以阻止您向集合中添加非字符串; Array List.Add 参数的类型只是 Object
此外,尽管 PrintNames 方法在类型方面看起来很安全,但事实并非如此。 该集合可以包含任何类型的对象引用。 如果您将完全不同的类型(WebRequest,作为一个奇怪的示例)添加到集合中,然后尝试打印它,您会期望发生什么? 由于 name 变量的类型,foreach 循环隐藏了从对象到字符串的隐式转换。 该转换可能会以正常方式失败,并出现 InvalidCastException。 因此,您已经解决了一个问题,但却导致了另一个问题。 有什么可以解决这两个问题的吗?
清单 2.3 使用 StringCollection 生成和打印名称
static StringCollection GenerateNames()
{
StringCollection names = new StringCollection();
names.Add("Gamma");
names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}
static void PrintNames(StringCollection names)
{
foreach (string name in names)
{
Console.WriteLine(name);
}
}
清单 2.3 与清单 2.2 相同,只是在所有地方都将 ArrayList 替换为 String Collection。 这就是 StringCollection 的全部意义:它应该感觉像是一个令人愉快的通用集合,但专门用于处理字符串。 StringCollection.Add 的参数类型是 String,所以你不能通过我们代码中的一些奇怪的错误向它添加 WebRequest。 结果是,当您打印名称时,您可以确信 foreach 循环不会遇到任何非字符串引用。 (诚然,您仍然可以看到空引用。)
如果你总是只需要字符串,那就太好了。 但是如果你需要一个其他类型的集合,你要么希望框架中已经有合适的集合类型,要么自己写一个。 这是一项非常常见的任务,以至于有一个 System.Collections.CollectionBase 抽象类来减少工作的重复性。 还有一些代码生成器可以避免手动编写。
这解决了之前解决方案中的两个问题,但是拥有所有这些额外类型的成本太高了。 随着代码生成器的变化,使它们保持最新是有维护成本的。 在编译时间、程序集大小、JITting 时间和将代码保存在内存中都会产生效率成本。 最重要的是,跟踪所有可用的集合类需要人力成本。
即使这些成本不是太高,您也会失去编写可以以静态类型方式处理任何集合类型的方法的能力,可能在另一个参数或返回类型中使用集合的元素类型 . 例如,假设您想编写一个方法,将集合的前 N 个元素复制到一个新元素中,然后返回该新元素。 您可以编写一个返回 ArrayList 的方法,但这会失去静态类型的优点。 如果你传入一个字符串集合,你会想要一个字符串集合回来。 字符串方面是方法输入的一部分,然后也需要传播到输出。 使用 C# 1 时,您无法用语言表达这一点。
2.1.2 泛型拯救了一天
让我们直接了解 GenerateNames/PrintNames 代码的解决方案,并使用 List< T > 泛型类型。 List< T > 是一个集合,其中 T 是集合的元素类型——在我们的例子中是字符串。 您可以在任何地方用 List< string > 替换 StringCollection。
清单 2.4 使用 List< T > 生成和打印名称
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 > 解决了我们之前谈到的所有问题:
- 与数组不同,您不需要事先知道集合的大小。
- 公开的 API 在需要引用元素类型的任何地方都使用 T,因此您知道 List 将仅包含字符串引用。 如果您尝试添加任何其他内容,您将收到编译时错误,这与 ArrayList 不同
- 您可以将它与任何元素类型一起使用,而无需担心生成代码和管理结果,这与 StringCollection 和类似类型不同。
泛型还解决了将元素类型表示为方法输入的问题。 要更深入地研究这方面,您需要更多的术语。
入参 和 实参
术语入参和实参早于 C# 中的泛型,并且已在其他语言中使用了数十年。 方法将其输入声明为入参,并通过以实参的形式调用代码来提供它们。 图 2.1 显示了两者之间的关系。
public static void Method(string name, int value) { ... } // 入参
...
string customerName = "Jon";
Method(customerName, 5); // 实参
实参的值用作方法内参数的初始值。 在泛型中,您有类型入参和类型实参,它们的想法相同,但适用于类型。 泛型类型或方法的声明在名称后的尖括号中包括类型参数。 在声明的主体中,代码可以将类型入参用作普通类型(只是它不太了解的一种)。
然后,使用泛型类型或方法的代码也在名称后面的尖括号中指定类型实参。 图 2.2 在 List< T > 的上下文中显示了这种关系。
public class List<T> //入参
{
...
}
...
List<string> list = new List<string>(); //实参
图 2.2 类型入参和类型实参的关系
现在想象一下 List< T > 的完整 API:所有方法签名、属性等。 如果您使用图中所示的列表变量,则任何出现在 API 中的 T 都会变成字符串。 例如, List< T > 中的 Add 方法具有以下签名:
public void Add(T item)
但是,如果您在 Visual Studio 中键入 list.Add(,IntelliSense 会提示您,好像 item 参数已使用字符串类型声明。如果您尝试传入另一种类型的参数,则会导致编译时 错误。
尽管图 2.2 指的是泛型类,但方法也可以是泛型的。 该方法声明了类型入参,并且这些类型入参可以在方法签名的其他部分中使用。 方法类型入参通常用作签名中其他类型的类型参数。 以下清单显示了您之前无法实现的方法的解决方案:创建一个新集合的方法,该集合包含现有集合的前 N 个元素,但以静态类型的方式
清单 2.5 将元素从一个集合复制到另一个集合
//方法声明一个类型入参 T 并在参数和返回类型中使用它。
public static List<T> CopyAtMost<T>(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;
}
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); //使用 int 作为类型参数调用方法
Console.WriteLine(firstTwo.Count);
}
许多泛型方法在签名中只使用一次类型参数,并且它不是任何泛型类型的类型参数。 但是使用类型参数来表达常规参数的类型和返回类型之间的关系的能力是泛型强大功能的重要组成部分。
同样,在声明基类或实现的接口时,泛型类型可以使用它们的类型参数作为类型参数。 例如,List< T > 类型实现了 IEnumerable< T > 接口,所以类声明可以这样写:
public class List<T> : IEnumerable<T>
注意 实际上,List< T > 实现了多个接口; 这是一个简化的形式。
通用类型和方法的数量
泛型类型或方法可以声明多个类型参数,方法是用尖括号内的逗号分隔它们。 例如,.NET 1 Hashtable 类的通用等效项声明如下:
public class Dictionary<TKey, TValue>
声明的通用参数是它具有的类型参数的数量。 老实说,这个术语对作者来说比编写代码时的日常使用更有用,但我认为它仍然值得了解。 您可以将非泛型声明视为具有泛型 arity 0 的声明
声明的泛型实际上是使其独特的一部分。 例如,我已经提到了 .NET 2.0 中引入的 IEnumerable< T > 接口,但它与已经是 .NET 1.0 一部分的非泛型 IEnumerable 接口不同。 同样,您可以编写具有相同名称但泛型参数不同的方法,即使它们的签名在其他方面相同:
public void Method() {} // 非泛型方法(泛型 arity 0)
public void Method<T>() {} // 具有泛型参数 1 的方法
public void Method<T1, T2>() {} // 具有泛型参数 2 的方法
当声明具有不同泛型数量的类型时,类型不必是同一类型,尽管它们通常是。 作为一个极端的例子,考虑这些类型声明,它们都可以共存于一个高度混乱的程序集中:
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 类的实例。
正如多个类型可以具有相同的名称但具有不同的泛型数量一样,泛型方法也可以。 这就像基于参数创建重载,只是这是基于类型参数数量的重载。 请注意,尽管泛型arity 将声明分开,但类型参数名称却没有。 例如,您不能像这样声明两个方法:
public void Method<TFirst>() {} // 编译时错误; 不能仅通过类型参数名称重载
public void Method<TSecond>() {}
虽然我们讨论的是多个类型参数,但你不能在同一个声明中为两个类型参数赋予相同的名称,就像你不能将两个常规参数声明为相同的名称一样。 例如,你不能像这样声明一个方法:
public void Method<T, T>() {} // 编译时错误; 重复类型参数 T
不过,两个类型实参相同也没关系,这通常是您想要的。 例如,要创建字符串到字符串的映射,您可以使用 Dictionary<string, string>。
前面的 IAmConfusing 示例使用枚举作为非泛型类型。 这不是巧合,因为我想用它来证明我的下一个观点。
2.1.3 什么可以泛型?
并非所有类型或类型成员都可以是泛型的。 对于类型,它相当简单,部分原因是可以声明的类型相对较少。 枚举不能是泛型的,但类、结构、接口和委托都可以。
对于类型成员,它稍微有点混乱; 有些成员可能看起来像泛型,因为它们使用其他泛型类型。 请记住,只有引入新类型参数的声明才是泛型的。
方法和嵌套类型可以是泛型的,但以下所有内容都必须是非泛型的:
- Fields
- Properties
- Indexers
- Constructors
- Events
- Finalizers
作为一个示例,您可能会倾向于认为一个字段是泛型的,即使它不是,请考虑这个泛型类:
public class ValidatingList<TItem>
{
private readonly List<TItem> items = new List<TItem>(); // 很多其他成员
}
我将类型参数命名为 TItem 只是为了将它与 List< T > 的 T 类型参数区分开来。 这里,items 字段的类型是 List< TItem >。 它使用类型参数 TItem 作为 List< T > 的类型参数,但这是由类声明而不是字段声明引入的类型参数。
对于其中的大多数,很难想象成员如何是泛型的。 不过,有时我想写一个泛型构造函数或索引器,答案几乎总是写一个泛型方法。
说到泛型方法,我之前在描述泛型方法的调用方式时,只对类型参数进行了简化描述。 在某些情况下,编译器可以确定调用的类型参数,而无需在源代码中提供它们。
2.1.4 方法类型参数的类型推断
让我们回顾一下清单 2.5 的关键部分。 你有一个这样声明的泛型方法:
public static List<T> CopyAtMost<T>(List<T> input, int maxElements)
然后,在 Main 方法中,声明一个 List< int > 类型的变量,然后将其用作方法的实参:
List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost<int>(numbers, 2);
我在这里突出显示了方法调用。 CopyAtMost 调用需要一个类型参数,因为它有一个类型参数。 但是您不必在源代码中指定该类型参数。 您可以按如下方式重写该代码:
List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost(numbers, 2);
就编译器将生成的 IL 而言,这与方法调用完全相同。 但是您不必指定 int 的类型参数; 编译器为您推断出这一点。 它是根据您对方法中第一个参数的参数执行此操作的。 您使用 List< int > 类型的参数作为 List< T > 类型参数的值,因此 T 必须是 int
类型推断只能使用您传递给方法的参数,而不是您对结果所做的事情。 它也必须是完整的; 您要么明确指定所有类型参数,要么都不指定。
尽管类型推断仅适用于方法,但它可以用于更轻松地构造泛型类型的实例。 例如,考虑 .NET 4.0 中引入的 Tuple 系列类型。 这由一个非泛型静态 Tuple 类和多个泛型类组成:Tuple< T1 >、Tuple< T1, T2 >、Tuple< T1, T2, T3 > 等等。 静态类有一组重载的 Create 工厂方法,如下所示:
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)
你可以这样写:
Tuple.Create(10, "x", 20)
这是一项需要注意的强大技术; 它通常很容易实现,并且可以使使用泛型代码更加愉快
我不会详细介绍泛型类型推断的工作原理。 随着语言设计者想办法让它在更多情况下工作,它随着时间的推移发生了很大变化。 重载解析和类型推断紧密相连,它们与各种其他特性(例如 C# 4 中的继承、转换和可选参数)相交。 这是我发现最复杂的规范领域,我在这里无法做到公正。
幸运的是,这是一个理解细节对日常编码没有太大帮助的领域。 在任何特定情况下,都存在三种可能性:
- 类型推断成功并为您提供所需的结果。 万岁
- 类型推断成功,但给你一个你不想要的结果。 只需明确指定类型参数或强制转换一些参数。 例如,如果你想要一个 Tuple<int, object, int> 来自前面的 Tuple.Create 调用,你可以显式指定 Tuple.Create 的类型参数,或者只调用 new Tuple<int, object, int>(… ) 或调用 Tuple.Create(10, (object) “x”, 20)。
- 类型推断在编译时失败。 有时这可以通过转换你的一些论点来解决。 例如,null 文字没有类型,因此 Tuple.Create(null, 50) 的类型推断将失败,但 Tuple.Create((string) null, 50) 的类型推断会成功。 其他时候,您只需要显式指定类型参数。
对于最后两种情况,根据我的经验,您选择的选项很少会对可读性产生太大影响。 了解类型推断的细节可以更容易地预测什么可行,什么不可行,但不太可能回报花在研究规范上的时间。 如果你很好奇,我绝不会主动阻止任何人阅读规范。 只是当你发现它在感觉像一个曲折的小通道迷宫之间交替时不要感到惊讶,所有相似的,和一个曲折的小通道的迷宫,完全不同。
不过,这种关于复杂语言细节的危言耸听的说法不应该减损类型推断的便利性。 C# 因为它的存在而更容易使用。
到目前为止,我们讨论过的所有类型参数都是不受约束的。 他们可以代表任何类型。 但是,这并不总是您想要的。 有时,您只想将某些类型用作特定类型参数的类型参数。 这就是类型约束的用武之地。
2.1.5 类型约束
当一个类型参数由泛型类型或方法声明时,它还可以指定类型约束来限制哪些类型可以作为类型参数提供。 假设您要编写一个方法来格式化项目列表,并确保您以特定的文化而不是线程的默认文化来格式化它们。 IFormattable 接口提供了一个合适的 ToString(string, IFormatProvider) 方法,但是如何确保你有一个合适的列表呢? 您可能期望这样的签名:
static void PrintItems(List<IFormattable> items)
但这几乎没有用。 例如,您不能将 List< decimal > 传递给它,即使 decimal 实现了 IFormattable; List< decimal > 不能转换为 List< IFormattable >。
注意 当我们考虑泛型方差时,我们将在第 4 章更深入地探讨其原因。 目前,只需将此视为约束的简单示例。
你需要表达的是,参数是某个元素类型的列表,其中元素类型实现了IFormattable接口。 “一些元素类型”部分表明您可能希望使方法通用,而“元素类型实现 IFormattable 接口的位置”正是类型约束赋予我们的能力。 在方法声明的末尾添加一个 where 子句,如下所示:
static void PrintItems<T>(List<T> items) where T : IFormattable
您在此处约束 T 的方式不仅改变了可以将哪些值传递给方法; 它还改变了您可以在方法中使用 T 类型的值执行的操作。 编译器知道 T 实现了 IFormattable,因此它允许对任何 T 值调用 IFormattable.ToString(string, IFormatProvider) 方法。
清单 2.6 使用类型约束以不变文化打印项目
static void PrintItems<T>(List<T> items) where T : IFormattable
{
CultureInfo culture = CultureInfo.InvariantCulture;
foreach (T item in items)
{
Console.WriteLine(item.ToString(null, culture));
}
}
如果没有类型约束,该 ToString 调用将无法编译; 对于 T,编译器唯一知道的 ToString 方法是在 System.Object 中声明的方法。
类型约束不限于接口。 可以使用以下类型约束:
- 引用类型约束——其中 T :类。 类型参数必须是引用类型。 (不要被 class 关键字的使用所迷惑;它可以是任何引用类型,包括接口和委托。)
- 值类型约束——其中 T : struct。 类型参数必须是不可为空的值类型(结构或枚举)。 可空值类型(在第 2.2 节中描述)不满足此约束。
- 构造函数约束——其中 T : new()。 类型参数必须有一个公共的无参数构造函数。 这允许在代码主体中使用 new T() 来构造 T 的新实例。
- 转换约束——其中 T : SomeType。 在这里,SomeType 可以是类、接口或其他类型参数,如下所示:
- where T : Control
- where T : IFormattable
- where T1 : T2
适度复杂的规则指示如何组合约束。 通常,当您违反这些规则时,编译器错误消息会清楚地表明出了什么问题。
一种有趣且相当常见的约束形式在约束本身中使用类型参数:
public void Sort(List<T> items) where T : IComparable<T>
约束使用 T 作为通用 IComparable< T > 接口的类型参数。 这允许我们的排序方法使用 IComparable 中的 CompareTo 方法对 items 参数中的元素进行成对比较:
T first = ...;
T second = ...;
int comparison = first.CompareTo(second);
我使用基于接口的类型约束比其他任何类型的约束都多,尽管我怀疑你使用什么很大程度上取决于你正在编写的代码类型。
当泛型声明中存在多个类型参数时,每个类型参数都可以具有一组完全不同的约束,如下例所示:
TResult Method<TArg, TResult>(TArg input) // 具有两个类型参数 TArg 和 TResult 的泛型方法
where TArg : IComparable<TArg> // TArg 必须实现 IComparable<TArg>。
where TResult : class, new() // TResult 必须是具有无参数构造函数的引用类型
我们几乎完成了泛型的旋风之旅,但我还有几个话题要描述。 我将从 C# 2 中可用的两个类型相关运算符开始。
2.1.6 运算符的默认值和类型
C# 1 已经有 typeof() 运算符接受类型名称作为其唯一的操作数。 C# 2 增加了 default() 操作符并略微扩展了 typeof 的使用。
默认运算符很容易描述。 操作数是类型或类型参数的名称,结果是该类型的默认值——如果你声明了一个字段并且没有立即为它赋值,你会得到相同的值。 对于引用类型,这是一个空引用; 对于不可为空的值类型,它是“全零”值(0、0.0、0.0m、false、数值为 0 的 UTF-16 代码单元,等等); 对于可空值类型,它是该类型的空值。
默认运算符可以与类型参数一起使用,也可以与提供适当类型参数的泛型类型一起使用(这些参数也可以是类型参数)。 例如,在声明类型参数 T 的泛型方法中,所有这些都是有效的:
- default(T)
- default(int)
- default(string)
- default(List< T >)
- default(List<List< string >>)
默认运算符的类型是在其中命名的类型。 它最常与泛型类型参数一起使用,因为否则您通常可以以不同的方式指定默认值。 例如,您可能希望将默认值用作局部变量的初始值,该局部变量以后可能会或可能不会被分配不同的值。 为了具体说明,这里有一个您可能熟悉的方法的简单实现:
public T LastOrDefault(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(List< TItem >) 声明了一个名为 TItem 的类型参数。
- 涉及泛型但在操作数中未指定类型入参; 例如,typeof(List<>)
其中第一个很简单,根本没有改变。 所有其他的都需要多加注意,最后一个引入了一种新的语法。 typeof 运算符仍然被定义为返回一个 Type 值,那么在每种情况下它应该返回什么? Type 类被扩充以了解泛型。 有多种情况需要考虑; 以下是几个例子:
- 例如,如果您列出包含 List< T > 的程序集中的类型,您会期望得到没有任何特定 T 类型参数的 List< T >。这是一个泛型类型定义。
- 如果你在 List< int > 对象上调用 GetType(),你会想要得到一个包含关于类型参数信息的类型。
- 如果您要求声明为 class StringDictionary : Dictionary<string, T> 的类的泛型类型定义的基本类型,您最终会得到一个具有一个“具体”类型实参的类型(字符串,用于 TKey Dictionary<TKey, TValue> 的类型入参)和一个仍然是类型入参的类型参数(T,对于 TValue 类型参数)。
坦率地说,这一切都非常令人困惑,但这是问题域所固有的。 例如,Type 中的许多方法和属性使您可以从泛型类型定义转到具有所有类型参数的类型,反之亦然。
让我们回到 typeof 运算符。 最容易理解的例子是 typeof(List< int >)。 这将返回表示 List< T > 的 Type,其类型实参为 int,就像调用 new List< int >().GetType() 一样。
下一种情况 typeof(T) 返回代码中 T 的类型实参。 这将始终是一个封闭的构造类型,这是规范中说它是真正类型的方式,在任何地方都没有涉及类型参数。 尽管在大多数地方我试图彻底解释术语,但围绕泛型(开放、封闭、构造、绑定、未绑定)的术语令人困惑,并且在现实生活中几乎没有用处。 稍后我们将需要讨论封闭的构造类型,但我不会触及其余部分。
最容易说明我对 typeof(T) 的含义,您可以在同一个示例中查看 typeof(List)。 下面的清单声明了一个通用方法,它将 typeof(T) 和 typeof(List) 的结果打印到控制台,然后使用两个不同的类型参数调用该方法。
清单 2.7 打印 typeof 运算符的结果
static void PrintType<T>()
{
Console.WriteLine("typeof(T) = {0}", typeof(T));
Console.WriteLine("typeof(List<T>) = {0}", typeof(List<T>));
}
static void Main()
{
PrintType<string>();
PrintType<int>();
}
清单 2.7 的结果如下所示:
typeof(T) = System.String
typeof(List< T >) = System.Collections.Generic.List1[System.String] typeof(T) = System.Int32 typeof(List<T>) = System.Collections.Generic.List
1[System.Int32]
重要的一点是,当您在 T 的类型参数为字符串的上下文中运行时(在第一次调用期间),typeof(T) 的结果与 typeof(string) 的结果相同。 同样,typeof(List< T >) 的结果与 typeof(List< string >) 的结果相同。 当您再次使用 int 作为类型参数调用该方法时,您将获得与 typeof(int) 和 typeof(List< int >) 相同的结果。 每当代码在泛型类型或方法中执行时,类型入参总是引用一个封闭的构造类型。
此输出的另一个要点是使用反射时泛型类型名称的格式。 List`1 表示这是一个名为 List 的泛型类型,泛型数量为 1(一个类型参数),类型参数随后显示在方括号中。
我们之前列表中的最后一个项目符号是 typeof(List<>)。 这似乎完全缺少类型参数。 此语法仅在 typeof 运算符中有效,并引用泛型类型定义。 具有泛型 arity 1 的类型的语法只是 TypeName<>; 对于每个附加的类型参数,您在尖括号内添加一个逗号。 要获得 Dictionary<TKey, TValue> 的泛型类型定义,可以使用 typeof(Dictionary<,>)。 要获得 Tuple<T1, T2, T3> 的定义,可以使用 typeof(Tuple<,>)。
2.1.7 泛型类型初始化和状态
正如您在使用 typeof 运算符时看到的那样,List< int > 和 List< string > 实际上是从相同的泛型类型定义构造的不同类型。 这不仅适用于您如何使用类型,而且适用于类型的初始化方式和静态字段的处理方式。 每个封闭的构造类型都是单独初始化的,并且有自己独立的静态字段集。 下面的清单用一个简单的(不是线程安全的)通用计数器演示了这一点。
清单 2.8 探索泛型类型中的静态字段
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();
}
}
清单 2.8 的输出如下:
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
在该输出中需要关注两个结果。 首先,Generic Counter< string > 值独立于 GenericCounter< int >。 其次,静态构造函数运行两次:每个封闭的构造类型运行一次。 如果您没有静态构造函数,则对于每种类型何时初始化的时间保证会更少,但本质上您可以将 Generic Counter< string > 和 GenericCounter< int > 视为独立类型。
更复杂的是,泛型类型可以嵌套在其他泛型类型中。 当这种情况发生时,每个类型参数的组合都有一个单独的类型。 例如,考虑这样的类:
class Outer<TOuter>
{
class Inner<TInner>
{
static int value;
}
}
使用 int 和 string 作为类型实参,以下类型是独立的,并且每个都有自己的值字段:
- Outer< string >.Inner< string >
- Outer< string >.Inner< int >
- Outer< int >.Inner< string >
- Outer< int >.Inner< int >
在大多数代码中,这种情况相对很少发生,当您意识到重要的是完全指定的类型(包括叶类型和任何封闭类型的任何类型参数)时,处理起来很简单
泛型就是这样,这是迄今为止 C# 2 中最大的单一特性,也是对 C# 1 的巨大改进。我们的下一个主题是可空值类型,它牢固地基于泛型