〇、前言
C# 里面的泛型不仅可以使用泛型函数、泛型接口,也可以使用泛型类、泛型委托等等。在使用泛型的时候,它们会自行检测你传入参数的类型,因此它可以为我们省去大量的时间,不用一个个编写方法的重载。与此同时,使用泛型会提升程序的效率。
本文将围绕泛型的各个方面,详细看下泛型到底怎么用,会给每位开发者带来什么便利。
一、泛型类型参数和运行时中的泛型
1.1 泛型类型参数
在泛型类型或方法定义中,类型参数是在其创建泛型类型的一个实例时,客户端指定的特定类型的占位符。
泛型类(例如泛型介绍中列出的 GenericList
若要使用 GenericList
可创建任意数量、使用不同的类型参数的构造类型实例。如下示例,创建 GenericList
GenericList<float> list1 = new GenericList<float>(); GenericList list2 = new GenericList(); GenericList list3 = new GenericList();
在 GenericList
1.2 泛型类型的命名
当泛型类型允许用任意类代替,且仅有一个泛型类型时,就可以用字符T
作为泛型类型的名称。如下示例:
public int IComparer<T>() { return 0; } public delegate bool Predicate<T>(T item); public struct Nullable where T : struct { /*...*/ }
如果泛型类型存在多个,为了避免混淆,建议给类型参数描述性名称加上字符T
做前缀,加以区分。如下示例:
public interface ISessionChannel<TSession> { /*...*/ } public delegate TOutput Converter<TInput, TOutput>(TInput from); public class List<T> { /*...*/ }
下面是一个简单的示例,泛型 TSession 的一个实现,实际类型为 Test。
public interface ISessionChannel<TSession> // 泛型类型 TSession { TSession Session { get; } } public class Test : ISessionChannel<Test> // 类 Test 作为泛型类型的实际类型 { public int MyProperty { get; set; } public Test Session => new Test() { MyProperty = 1 }; }
https://learn.microsoft.com/zh-cn/dotnet/csharp/programming-guide/generics/generic-type-parameters
1.3 运行时中的泛型
泛型类型或方法编译为 Microsoft 中间语言(MSIL)时,它包含将其标识为具有类型参数的元数据。值类型和引用类型的泛型在 MSIL 编译过程中是有区别的,下面来介绍一下区别在哪里。
当首次构造泛型类型,使用值类型作为参数时:
运行时会为泛型类型创建专用空间,MSIL 执行过程中会在合适的位置,替换传入的一个或多个参数。为每种用作参数的类型,创建专用化泛型类型。
下面例举个示例:
// 首先,声明了一个由整数构造的堆栈 // 运行时生成一个专用版 Stack 类,其中用整数相应地替换其参数 Stack<int>? stack; // 每当程序代码使用整数堆栈时,运行时都重新使用已生成的专用 Stack 类 // 在下面的示例中创建了两个整数堆栈实例 // 由于它们【都是 int 类型,所以共用 Stack 代码的一个实例】 Stack<int> stackOne = new Stack<int>(); Stack<int> stackTwo = new Stack<int>(); // 假定在代码中另一点上再创建一个将不同值类型(例如 long 或用户定义结构)作为参数的 Stack 类 // 则,此时运行时在 MSIL 中,会【生成另一个版本的泛型类型】并在适当位置替换 long Stack<long> stackTwo = new Stack<long>();
当首次构造泛型类型,使用引用类型作为参数时:
运行时创建一个专用化泛型类型,用对象引用替换 MSIL 中的参数。之后,每次使用引用类型作为参数实例化已构造的类型时,无论何种类型,运行时皆重新使用先前创建的专用版泛型类型。原因很简单,因为对实例的引用是类似的,可以存放在同一泛化类型中。
下面也例举个简单的示例:
// 先声明两个类 class Customer { } class Order { } // 再声明一个 Customer 类型的堆栈 // 此时,运行时生成一个专用的 Stack 类,此类中会被填入引用类型值的引用,而不是实际数据 Stack customers; // 下面再创建另一类型 Order 的堆栈 // 虽然不同于 Customer 类型的堆栈,但是 MSIL 也不会再为 Order 类型的堆栈创建新的 Stack 类 // 而是使用之前创建的专用的 Stack 类的实例,将 orders 变量的引用加入新的实例中 Stack orders = new Stack(); // 假定之后遇到一行创建 Customer 类型堆栈的代码 customers = new Stack(); // 此时的处理方式相同,再创建一个 Stack 类的一个实例
由于引用类型的数量因程序不同而有较大差异,因此通过将编译器为引用类型的泛型类,创建的专用类的数量减少至 1,这样泛型的 C# 实现,可极大减少代码量。
使用值类型或引用类型参数,实例化泛型 C# 类时,反射可在运行时对其进行查询,且其实际类型和类型参数皆可被确定。
https://learn.microsoft.com/zh-cn/dotnet/csharp/programming-guide/generics/generics-in-the-run-time
1.4 为什么要约束类型参数?
在没有任何约束的情况下,类型参数可以是任何类型。编译器只能假定 System.Object 的成员,它是任何 .NET 类型的最终基类。如果客户端代码使用不满足约束的类型,编译器将发出错误。
可以通过使用 where 上下文关键字指定约束。
下面列举一下总共 12 种约束类型:
约束 | 描述 |
where T : struct | 类型参数必须是不可为 null 的值类型。由于所有值类型都具有可访问的无参数构造函数,因此 struct 约束表示 new() 约束,并且不能与 new() 约束结合使用。struct 约束也不能与 unmanaged 约束结合使用。 |
where T : class | 类型参数必须是引用类型。此约束还应用于任何类、接口、委托或数组类型。在可为 null 的上下文中,T 必须是不可为 null 的引用类型。 |
where T : class? | 类比上一条,增加了可为 null 的情形。 |
where T : notnull | 类型参数必须是不可为 null 的值类型或引用类型。 |
where T : default | 重写方法或提供显式接口实现时,如果需要指定不受约束的类型参数,此约束可解决歧义。default 约束表示基方法,但不包含 class 或 struct 约束。 |
where T : unmanaged | 类型参数必须是不可为 null 的非托管类型。unmanaged 约束表示 struct 约束,且不能与 struct 约束或 new() 约束结合使用。 |
where T : new() | 类型参数必须具有公共无参数构造函数。 与其他约束一起使用时,new() 约束必须最后指定。 new() 约束不能与 struct 和 unmanaged 约束结合使用。 |
where T : <基类名> | 类型参数必须是指定的基类或派生自指定的基类。在可为 null 的上下文中,T 必须是从指定基类派生的不可为 null 的引用类型。 |
where T : <基类名>? | 类比上一条,增加了基类派生的可为 null 的引用类型。 |
where T : <接口名称> | 类型参数必须是指定的接口或实现指定的接口。可指定多个接口约束。约束接口也可以是泛型。在的可为 null 的上下文中,T 必须是实现指定接口的不可为 null 的类型。 |
where T : <接口名称>? | 类比上一条,增加了实现指定接口的可为 null 的类型。 |
where T : U | 为 T 提供的类型参数必须是为 U 提供的参数或派生自为 U 提供的参数。在可为 null 的上下文中: 如果 U 是不可为 null 的引用类型,T 必须是不可为 null 的引用类型。 如果 U 是可为 null 的引用类型,则 T 可以是可为 null 的引用类型,也可以是不可为 null 的引用类型。 |
那到底为啥要用约束呢?
首先,声明上面表中这些约束,意味着你可以放心的执行所约束类型的操作和方法。就好比你和几个朋友约饭订位置,肯定要提前说好都有谁,不然大概率会出现空座或坐不下的异常情况。
如果泛型类或方法,对泛型成员使用除简单赋值之外的其他操作,或者调用 System.Object 不支持的任何方法,则将对类型参数应用约束,不然易引发异常。
例如,基类约束告诉编译器,仅此类型的对象或派生自此类型的对象可用作类型参数。编译器有了此保证后,就能够允许在泛型类中调用该类型的方法。
下面示例代码,使用基类约束:
public class Employee // 基类声明 { public Employee(string name, int id) => (Name, ID) = (name, id); public string Name { get; set; } // 基类中包含 Name 属性 public int ID { get; set; } } public class GenericList<T> where T : Employee // 约束泛型参数 T 类的基类是 Employee { private class Node // 私有类 Node 中的属性包含对泛型 T 类的操作 { public Node(T t) => (Next, Data) = (null, t); public Node? Next { get; set; } public T Data { get; set; } } private Node? head; public void AddHead(T t) { Node n = new Node(t) { Next = head }; head = n; } public IEnumerator GetEnumerator() { Node? current = head; while (current != null) { yield return current.Data; current = current.Next; } } public T? FindFirstOccurrence(string s) { Node? current = head; T? t = null; while (current != null) { // 此处可以放心的访问基类 Employee 中的 Name 属性 if (current.Data.Name == s) { t = current.Data; break; } else { current = current.Next; } } return t; } }
另外,也可以对同一类型参数应用多个约束,并且约束自身可以是泛型类型。如下代码,多条件用逗号分隔:
class EmployeeList<T> where T : Employee, IEmployee, System.IComparable<T>, new() { // ... }
在应用 where T : class 约束时,必须避免对类型参数使用 == 和 != 运算符,因为这些运算符仅测试引用标识而不测试值相等性。如果必须测试值相等性,建议同时应用 where T : IEquatable
上面说了如何对一个参数应用多个约束,下面看下对多个参数都进行约束怎么写:
class Base { } class Test<T, U> where U : struct where T : new() { }
未绑定类型的参数,就无法进行对比,因为不知道它到底是值类型还是引用类型,但肯定都属于 System.Object。
另外再看几个示例:
// 类型参数可以作为约束,如下 public class List<T> { public void Add<U>(List items) where U : T {/*...*/} // 仅约束添加的 U 对象,对 items 中的 U 无效 } // 类型参数可在泛型类定义中用作约束 public class SampleClass<T, U, V> where T : V { } // 约束 T 不可为空 public class List<T> where T : notnull { // ... } // 使用 unmanaged 约束来指定类型参数必须是不可为 null 的非托管类型 // 通过 unmanaged 约束,用户能编写可重用例程,从而使用可作为内存块操作的类型 unsafe public static byte[] ToByteArray<T>(this T argument) where T : unmanaged { var size = sizeof(T); // 注:sizeof 运算符必须用在已知的内置类型上,此处前提是 where T : unmanaged var result = new Byte[size]; Byte* p = (byte*)&argument; for (var i = 0; i < size; i++) result[i] = *p++; return result; } // 使用 System.Delegate 约束,就能以类型安全的方式编写使用委托的代码 public static TDelegate? TypeSafeCombine(this TDelegate source, TDelegate target) where TDelegate : System.Delegate => Delegate.Combine(source, target) as TDelegate; // 可指定 System.Enum 类型作为枚举类型的基类约束 public static Dictionary<int, string> EnumNamedValues<T>() where T : System.Enum { var result = new Dictionary<int, string>(); // Enum.GetValues、Enum.GetName 使用反射,会对性能产生影响,调用 EnumNamedValues 来生成可缓存和重用的集合来避免使用反射 // var values = Enum.GetValues(typeof(T)); var values = EnumNamedValues(); foreach (int item in values) result.Add(item.Key, item.Value); // result.Add(item, Enum.GetName(typeof(T), item)!); return result; } enum Rainbow { Red, Orange, Yellow, Green, Blue, Indigo, Violet }
二、泛型类
泛型类是 C# 语言中一种强大的特性,它允许在定义类时,使用类型参数来表示其中的某些成员。通过使用泛型类,我们可以编写更通用、可复用的代码,以适应不同类型的数据。
泛型类最常见用法是用于链接列表、哈希表、堆栈、队列和树等集合。无论存储数据的类型如何,添加项和从集合删除项等操作的执行方式基本相同。
class Program { static void Main(string[] args) { // 创建一个整数类型的栈 Stack<int> intStack = new Stack<int>(3); intStack.Push(10); intStack.Push(20); intStack.Push(30); intStack.Push(40); Console.WriteLine($"出栈元素:{intStack.Pop()}"); Console.WriteLine($"出栈元素:{intStack.Pop()}"); Console.WriteLine(); // 创建一个字符串类型的栈 Stack<string> stringStack = new Stack<string>(3); stringStack.Push("Hello"); stringStack.Push("World"); Console.WriteLine($"出栈元素:{stringStack.Pop()}"); Console.WriteLine(); // 创建一个自定义类型的栈 Stack personStack = new Stack(2); Person p1 = new Person("John", 25); Person p2 = new Person("Alice", 30); personStack.Push(p1); personStack.Push(p2); Console.WriteLine($"出栈元素:{personStack.Pop()}"); Console.WriteLine($"出栈元素:{personStack.Pop()}"); Console.WriteLine($"出栈元素:{personStack.Pop()}"); Console.ReadLine(); } } // 自定义一个Person类 public class Person { public string Name { get; set; } public int Age { get; set; } public Person(string name, int age) { Name = name; Age = age; } public override string ToString() { return string.Format("Name: {0}, Age: {1}", Name, Age); } } // 定义一个泛型类 public class Stack<T> { private T[] elements; private int top; public Stack(int size) { elements = new T[size]; top = -1; } public void Push(T item) { if (top >= elements.Length - 1) { Console.WriteLine("栈已满,无法入栈"); return; } elements[++top] = item; } public T Pop() { if (top < 0) { Console.WriteLine("栈为空,无法出栈"); return default(T); } T item = elements[top--]; return item; } }
输出结果:
在上述示例代码中,我们创建了一个泛型类 Stack
在 Main 方法中,我们分别创建了整数类型、字符串类型和自定义类型(Person)的栈,并对其进行了一些入栈和出栈操作。由于使用了泛型类,我们可以在编译时指定栈中存储的元素类型,并在运行时处理相应类型的数据。
这个示例代码也展示了泛型类的诸多好处,例如:
- 可以重复使用同一个泛型类来处理不同类型的数据,提高代码的复用性。
- 在编译时进行类型检查,避免了类型转换错误和运行时异常。
- 提供了更好的代码可读性和维护性,因为我们可以在泛型类中使用具有描述性的类型参数名称。
详情可参考:https://www.cnblogs.com/dotnet261010/p/9034594.html
三、泛型接口
泛型接口是 C# 语言中的另一个强大特性,它允许在定义接口时使用类型参数来表示其中的某些成员。通过使用泛型接口,可以定义通用的接口规范,以适应不同类型的实现。
以下示例代码是对泛型接口的一个简单的应用:
// 测试一下 class Program { static void Main(string[] args) { IRepository userRepository = new UserRepository(3); User user1 = new User(1, "John"); User user2 = new User(2, "Alice"); User user3 = new User(3, "Bob"); userRepository.Add(user1); // 添加用户信息 userRepository.Add(user2); userRepository.Add(user3); User retrievedUser = userRepository.GetById(2); Console.WriteLine("Retrieved user: {0}", retrievedUser.Name); userRepository.Delete(user2); // 删除用户 2 retrievedUser = userRepository.GetById(2); if (retrievedUser != null) // 删除后再去查询就返回 null Console.WriteLine("Retrieved user: {0}", retrievedUser.Name); } } // 定义一个泛型接口 public interface IRepository<T> { void Add(T item); void Delete(T item); T GetById(int id); } // 实现泛型接口的具体类 public class UserRepository : IRepository<User> { private User[] users; private int count; public UserRepository(int size) { users = new User[size]; count = 0; } public void Add(User user) { if (count >= users.Length) { Console.WriteLine("仓库已满,无法添加用户"); return; } users[count++] = user; } public void Delete(User user) { int index = Array.IndexOf(users, user); if (index < 0) { Console.WriteLine("用户不存在"); return; } for (int i = index; i < count - 1; i++) { users[i] = users[i + 1]; } count--; } public User GetById(int id) { foreach (User user in users) { if (user.Id == id) return user; } Console.WriteLine($"找不到 id 为 {id} 的用户"); return null; } } // 自定义 User 类 public class User { public int Id { get; set; } public string Name { get; set; } public User(int id, string name) { Id = id; Name = name; } }
结果输出:
在上述示例代码中,首先定义了一个泛型接口 IRepository
通过使用泛型接口,我们可以在编译时指定接口中的类型参数,使得 IRepository
在 Main 方法中,我们创建了一个 UserRepository 对象,并对其进行了一些添加、删除和查询操作。由于使用了泛型接口,我们可以保证在调用接口方法时传入正确的数据类型,并且在编译时进行类型检查。
由示例代码可以看到,泛型接口也具备许多好处,例如:
- 可以定义通用的接口规范,可以被多个类和方法重用,从而减少代码重复。
- 在编译时进行类型检查,避免了类型转换错误和运行时异常,也减少类型转换的开销。
- 提供了更好的代码可读性和维护性,因为我们可以在泛型接口中使用具有描述性的类型参数名称,还可以帮助开发人员更好地理解代码的用途和行为。
- 可以提高代码的灵活性和可扩展性。通过使用泛型接口,可以在不修改代码的情况下,轻松地添加新的类型或修改现有类型的属性和方法。
四、泛型方法
泛型方法是通过类型参数声明的方法。它允许在方法定义时不指定具体的数据类型,而是在调用方法时根据需要传入实际的类型。如下示例:
// 声明一个泛型方法 static void Swap<T>(ref T lhs, ref T rhs) { T temp; temp = lhs; lhs = rhs; rhs = temp; } // 声明一个泛型方法,将输入的两个泛型实例值对调 public static void TestSwap() { int a = 1; int b = 2; Swap<int>(ref a, ref b); Console.WriteLine(a + " " + b); // 输出:2 1 }
还可省略类型参数,编译器将推断类型参数。 如下 Swap 调用等效于之前的调用:
Swap(ref a, ref b);
类型推理的相同规则适用于静态方法和实例方法。
编译器可基于传入的方法参数推断类型参数;而无法仅根据约束或返回值推断类型参数,因此,类型推理不适用于不具有参数的方法。
如果定义一个具有与当前类相同的类型参数的泛型方法,则编译器会生成警告 CS0693,因为在该方法范围内,向内 T 提供的参数会隐藏向外 T 提供的参数。如果需要使用类型参数(而不是类实例化时提供的参数)调用泛型类方法,可以考虑为此方法的类型参数提供另一标识符,如下示例中 GenericList2
class GenericList<T> { // CS0693. void SampleMethod<T>() { } } class GenericList2<T> { // No warning. void SampleMethod<U>() { } }
使用约束在方法中的类型参数上实现更多专用操作。此版 Swap
void SwapIfGreater<T>(ref T lhs, ref T rhs) where T : IComparable { T temp; if (lhs.CompareTo(rhs) > 0) { temp = lhs; lhs = rhs; rhs = temp; } }
泛型方法可重载在数个泛型参数上。 例如,以下方法可全部位于同一类中:
void DoWork() { } void DoWork<T>() { } void DoWork<T, U>() { }
五、泛型与数组
下限为零的单维数组自动实现 IList
static void Main() { int[] arr = { 0, 1, 2, 3, 4 }; List<int> list = new List<int>(); for (int x = 5; x < 10; x++) { list.Add(x); } ProcessItems<int>(arr); ProcessItems<int>(list); Console.ReadLine(); } static void ProcessItems<T>(IList coll ) { System.Console.WriteLine("IsReadOnly : {0} .",coll.IsReadOnly); //coll.RemoveAt(4); // System.NotSupportedException: 'Collection was of a fixed foreach (T item in coll) { System.Console.Write(item?.ToString() + " "); } System.Console.WriteLine(); }
六、泛型委托
委托可以定义它自己的类型参数。引用泛型委托的代码可以指定类型参数以创建封闭式构造类型,就像实例化泛型类或调用泛型方法一样,如以下示例中所示:
public delegate void Del<T>(T item); public static void Notify(int i) { } Del<int> m1 = new Del<int>(Notify); // C# 2.0 版具有一种称为方法组转换的新功能,适用于具体委托类型和泛型委托类型,因此上一行代码可简化为: Del<int> m2 = Notify;
在泛型类中定义的委托,可以和类方法以相同方式来使用泛型类的类型参数。
class Stack<T> { public delegate void StackDelegate(T[] items); }
引用委托的代码,必须指定所包含类的类型参数,如下所示:
private static void DoWork(float[] items) { Console.WriteLine("执行工作"); } public static void TestStack() { Stack<float> s = new Stack<float>(); // 泛型类型参数 float 来指定栈中存储的元素类型为浮点数 Stack<float>.StackDelegate d = DoWork; // 调用委托引用的方法 float[] array = { 1.0f, 2.0f, 3.0f }; d(array); }
如下示例代码,定义了一个泛型委托 AddDelegate
// 声明一个委托 public delegate T AddDelegate<T>(T a, T b); public class Calculator // 计算加法类 { public static int Add(int a, int b) { return a + b; } public static float Add(float a, float b) { return a + b; } public static double Add(double a, double b) { return a + b; } } public class Program { public static void Main(string[] args) { // 分别以不同的类型将 Add() 方法添加到委托实例的引用 AddDelegate<int> intAddDelegate = Calculator.Add; int sum1 = intAddDelegate(5, 10); Console.WriteLine("Sum of integers: " + sum1); AddDelegate<float> floatAddDelegate = Calculator.Add; float sum2 = floatAddDelegate(3.14f, 2.78f); Console.WriteLine("Sum of floats: " + sum2); AddDelegate<double> doubleAddDelegate = Calculator.Add; double sum3 = doubleAddDelegate(2.5, 3.7); Console.WriteLine("Sum of doubles: " + sum3); Console.ReadLine(); } }
结果输出:
详情可参考:https://learn.microsoft.com/zh-cn/dotnet/csharp/programming-guide/generics/generic-delegates
七、小小的总结
由以上的介绍,可以看到泛型在 C# 语言中是一个非常强大的特性,总体看来它有一下几点好处:
- 首先就是代码的重用。没有泛型的话,我们需要声明一个父类,然后有几个类型再继承出来几个子类,这样就非常麻烦。如果用上泛型那么就可以写一遍搞定,也提高了代码的可维护性和可扩展性。
- 然后就是类型安全。泛型提供了编译时类型检查的好处,这意味着编译器可以在编译时捕获并阻止不匹配类型的错误。这有助于减少运行时错误,并增加代码的健壮性。
- 还有可以性能优化。泛型可以提供更高的性能,因为它们在编译时生成特定类型的代码。相比于使用非泛型的代码,泛型可以避免装箱和拆箱操作,从而提高代码的执行效率。
- 可以将类型参数化。通过使用泛型,可以将类型作为参数传递给类、方法或委托,从而使代码更加灵活和可配置。这样可以实现更高级别的抽象和模块化。
- 最后还可以进行安全约束。使用泛型,可以对泛型类型进行 where 约束,限制其可以接受的类型。这可以帮助我们确保代码只能在特定类型上运行,并提供更严格的类型检查。
泛型在 C# 中提供了更加灵活、安全和高效的编程方式。它可以提高代码的可重用性、可维护性和可扩展性,同时还能够减少错误并提高性能。
因此,在合适的情况下,使用泛型是一个非常好的选择。