在C#的集合类库中,你会频繁看到List<T>、Dictionary<TKey, TValue>这样的类型声明,其中的T就是一个类型参数,这就是泛型。泛型的本质是把「类型」本身作为参数传递,让同一段代码逻辑可以工作于不同的数据类型之上。在没有泛型的年代(.NET 1.x时代),开发者只能用ArrayList配合object来存储任意类型,取值时不仅需要强制类型转换,值类型还会经历装箱拆箱,性能和类型安全都大打折扣。泛型的出现正是为了解决这两大问题。本文将系统地讲解C#中泛型类、泛型方法和泛型约束的定义与使用语法。

一、为什么需要泛型:从object的缺陷说起
先看一段没有泛型时的典型代码。假设我们要写一个可以存储任意类型的栈,只能这样实现:
public class ObjectStack
{
private object[] items = new object[10];
private int index = 0;
public void Push(object item)
{
items[index++] = item;
}
public object Pop()
{
return items[--index];
}
}
// 使用时必须强制转换,编译器无法检查类型
ObjectStack stack = new ObjectStack();
stack.Push(123);
stack.Push("hello");
int number = (int)stack.Pop(); // 运行时才能发现错误
这段代码有两个明显的问题。第一是类型不安全:你可以往栈里塞进任何东西,编译器毫无怨言,直到取值转换类型时才在运行时抛出InvalidCastException。第二是性能损耗:值类型(如int、double、struct)传入object参数时会发生装箱,取出时又发生拆箱,每次装箱都会在堆上分配内存,频繁操作时开销相当可观。
泛型通过在编译期确定类型参数,一次性解决了这两个问题。改用泛型后的代码如下:
public class GenericStack<T>
{
private T[] items = new T[10];
private int index = 0;
public void Push(T item)
{
items[index++] = item;
}
public T Pop()
{
return items[--index];
}
}
// 编译期就确定类型,错误在编码阶段即可发现
GenericStack<int> intStack = new GenericStack<int>();
intStack.Push(123);
// intStack.Push("hello"); // 直接编译报错
int number = intStack.Pop(); // 无需转换,无装箱拆箱
值得一提的是,.NET对值类型的泛型是在 JIT 编译时为每个值类型生成独立的机器码,因此完全消除了装箱开销;而引用类型则共享一份代码。这也是List<int>比ArrayList快得多的根本原因。
二、泛型类与泛型接口的定义语法
定义泛型类时,在类名后面用尖括号声明类型参数,参数名习惯上用T开头(T表示Type,多个参数时可以用TKey、TValue这样带语义的名字)。类型参数在整个类内部可以像普通类型一样使用,用于字段、属性、方法参数和返回值。
// 单个类型参数的泛型类
public class Repository<T> where T : class, new()
{
private readonly List<T> _data = new List<T>();
public void Add(T entity)
{
_data.Add(entity);
}
public T Find(Predicate<T> match)
{
return _data.Find(match);
}
public T CreateNew()
{
return new T(); // 依赖 new 约束才能这样写
}
}
// 多个类型参数的泛型类
public class Pair<TKey, TValue>
{
public TKey Key { get; set; }
public TValue Value { get; set; }
}
泛型类支持嵌套和继承。继承泛型类时,派生类要么继续传递类型参数,要么明确指定具体类型:
// 派生类继续开放类型参数
public class NumberRepository<T> : Repository<T> where T : class, new()
{
}
// 派生类封闭为具体类型
public class UserRepository : Repository<User>
{
}
泛型接口的语法与泛型类完全一致,最常见的例子就是.NET内置的IEnumerable<T>和IComparer<T>。实现泛型接口时同样可以保持开放或封闭:
public interface IComparer<T>
{
int Compare(T x, T y);
}
public class IntComparer : IComparer<int>
{
public int Compare(int x, int y) => x.CompareTo(y);
}
// 泛型类实现泛型接口,保持参数开放
public class ReverseComparer<T> : IComparer<T> where T : IComparable<T>
{
public int Compare(T x, T y) => y.CompareTo(x);
}
需要注意一个细节:泛型类型在运行时是不同的类型。GenericStack<int>和GenericStack<string>是完全不同的两个类型,二者之间不存在继承关系,也不能相互赋值,这一点在判断类型和序列化场景中尤其容易踩坑。
三、泛型方法:定义、调用与类型推断
泛型方法允许在普通类或泛型类中声明带有自己类型参数的方法。语法是在返回值之前用尖括号声明类型参数:
public class Utils
{
// 普通类中的泛型方法
public static T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
// 交换两个任意类型的变量
public static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
}
调用泛型方法有两种方式。一种是显式指定类型参数:Utils.Max<int>(3, 5);另一种更常用的是依赖编译器的类型推断,直接写Utils.Max(3, 5),编译器会根据实参自动推断T为int。类型推断能大幅减少代码噪音,LINQ中的Where、Select等方法之所以写起来简洁,正是因为推断机制替你省去了类型参数。
int bigger = Utils.Max(3, 5); // 推断 T 为 int
string longer = Utils.Max("abc", "abcd"); // 推断 T 为 string
int x = 1, y = 2;
Utils.Swap(ref x, ref y); // 调用后 x=2, y=1
使用类型推断时要留意一个陷阱:如果两个实参的类型不一致且不存在隐式转换,推断会失败。例如Utils.Max(3, 5.0)无法通过编译,因为int和double之间需要显式转换,此时必须手动写成Utils.Max<double>(3, 5.0)。另外,泛型方法可以做方法重载,但仅靠类型参数个数或约束不同来区分的重载,在某些调用场景下可能产生二义性,实际项目中应避免这种设计。
四、泛型约束:where关键字的六种用法
单纯的类型参数意味着「任何类型都可以」,这会带来限制——在方法体内你几乎无法对T做任何有意义的操作,连比较和new都不行。泛型约束的作用就是告诉编译器:传入的类型必须满足某些条件,从而换取更强的操作能力。C#中通过where子句声明约束,共有以下几种:
| 约束写法 | 含义 |
|---|---|
| where T : 基类名 | T必须是指定基类或其派生类 |
| where T : 接口名 | T必须实现指定接口 |
| where T : class | T必须是引用类型 |
| where T : struct | T必须是不可为空的值类型 |
| where T : new() | T必须有公共无参构造函数 |
| where T : notnull | T必须是非可空类型(C# 8.0+) |
下面逐一演示这些约束的实际效果。基类约束和接口约束最常用,它们让你可以在方法体内调用基类或接口的成员:
public class Animal
{
public virtual string Speak() => "...";
}
// 基类约束:T 只能是 Animal 或其子类
public static void MakeSound<T>(T animal) where T : Animal
{
Console.WriteLine(animal.Speak()); // 可以直接调用 Animal 的成员
}
// 接口约束:T 必须可比较
public static T GetBigger<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
class和struct约束用于限定引用类型或值类型。典型应用是缓存或对象池场景——值类型分配在栈上,引用类型在堆上,二者的生命周期管理策略完全不同,用约束区分开后可以分别写针对性逻辑。new()约束则解决「如何在泛型里创建T的实例」这个问题:
public static T CreateInstance<T>() where T : new()
{
return new T(); // 没有 new() 约束时这句无法编译
}
// struct 约束配合默认值
public static T GetValueOrDefault<T>(T[] array, int index) where T : struct
{
if (index < 0 || index >= array.Length)
return default; // 值类型一定有默认值,不会返回 null
return array[index];
}
多个约束可以叠加在同 一个类型参数上,多个类型参数也可以各有各的约束,语法上用空格分隔约束,用多个where子句区分参数:
public class Factory<TProduct, TRaw>
where TProduct : class, IComparable<TProduct>, new()
where TRaw : struct
{
public TProduct Build(TRaw material)
{
TProduct product = new T();
// 此时可以对 product 调用 CompareTo,也能 new 出实例
return product;
}
}
约束的顺序有一个不成文但必须遵守的规则:如果同时存在基类约束,它必须排在最前面,接口约束随后,最后才是new()。此外约束会被继承——派生类的类型参数必须满足基类声明的全部约束,这也是泛型类继承时容易报编译错误的常见原因。
五、default关键字与泛型的进阶话题
在泛型代码中经常需要拿到T的默认值,但引用类型的默认值是null,值类型的默认值是全零,不能直接写null。这时要用default关键字:
public static T FirstOrDefault<T>(List<T> list)
{
if (list == null || list.Count == 0)
return default; // 引用类型返回 null,值类型返回 0
return list[0];
}
另一个值得了解的进阶概念是协变与逆变。默认情况下List<string>并不能当作List<object>使用,但接口可以通过out和in修饰类型参数实现型变:out T表示协变(T只出现在输出位置),IEnumerable<string>就可以赋值给IEnumerable<object>;in T表示逆变(T只出现在输入位置),IComparer<object>可以赋值给IComparer<string>。LINQ之所以能优雅地处理类型层次,协变接口功不可没。
最后总结几条实践经验:泛型类型参数的数量不要过多,两到三个基本够用,参数越多可读性越差;为泛型参数和泛型类都写清晰的XML注释;优先使用.NET内置的泛型集合而不是自己造轮子,除非有特殊需求;在性能敏感路径上放心使用泛型,因为值类型泛型是零装箱开销的。掌握好泛型,你就掌握了现代C#最核心的抽象能力之一,后续学习LINQ、依赖注入容器等高级特性时都会轻松许多。