>> >> >> Reference << << << <<<<<<Ref>>>>>>
Generic
Modified: 2025-06-01 | Author:ljf12825

简单来说,泛型允许在编写类、接口、方法时,使用“类型参数”来代替具体的类型。这个类型参数在编译时或运行时才被指定

为什么需要

在泛型出现之前,如果想创建一个可以存放任意类型数据的集合(比如一个盒子),通常会使用object类型,因为C#中所有类型都继承自object

// 一个只能存放整数的盒子
public class IntBox
{
    public int Data { get; set; }
}

// 一个只能存放字符串的盒子
public class StringBox
{
    public string Data { get; set; }
}

// 为了存放任意类型,使用object
public class ObjectBox
{
    public object Data { get; set; }
}

使用objectBox时会出现两个问题

  1. 性能损失(拆箱装箱):当存放值类型(如int, struct)时,会发生装箱(Boxing)和拆箱(Unboxing)操作,影响性能
ObjectBox box = new ObjectBox();
box.Data = 42; // 装箱:将值类型int包装为object引用类型
int data = (int)box.Data; // 拆箱:将object转换回值类型int
  1. 类型不安全:编译器无法在编译时保证类型安全,容易在运行时引发InvalidCastException
ObjectBox box = new ObjectBox();
box.Data = "Hello World"; // 存放一个字符串
int data = (int)box.Data; // 运行时错误!无法将字符串转换为整数

泛型如何解决这些问题

用泛型重新写这个“盒子”

// T 是一个整数参数,它只是一个占位符
public class GenericBox<T>
{
    public T Data { get; set; }
}

使用这个泛型类

// 创建一个专门存放int的盒子
GenericBox<int> intBox = new GenericBox<int>();
intBox.Data = 42; // 类型安全,只能是int
int intData = intBox.Data; // 无需类型转换和拆箱,性能高

// 创建一个专门存放string的盒子
GenericBox<string> stringBox = new GenericBox<string>();
stringBox.Data = "Hello"; // 类型安全,只能是string
string stringData = stringBox.Data; // 无需类型转换

优势:

语法

public class Box<T>
{
    public T Value { get; set; }
    public Box(T value) => Value = value;
}

用法

var intBox = new Box<int>(42);
var strBox = new Box<string>("hello");
public class Utility
{
    // Generic Method
    public static void Swap<T>(ref T a, ref T b)
    {
        T temp = a;
        a = b;
        b = temp;
    }
}

// 使用
int x = 1, y = 2;
Utility.Swap<int>(ref x, ref y); // 显式指定类型
// 或者更常见的,让编译器推断
Utility.Swap(ref x, ref y);

string s1 = "foo", s2 = "bar";
Utility.Swap(ref s1, ref s2); // 同样适用于string
// 泛型接口
public interface IRepository<T>
{
    void Add(T entity);
    T GetById(int id);
    IEnumerable<T> GetAll();
}

// 实现泛型接口
public class ProductRepository : IRepository<Product>
{
    public void Add(Product entity) { /* ... */ }
    public Product GetById(int id) { /* ... */ }
    public IEnumeralbe<Product> GetAll() { /* ... */ }
}

public class UserRepository : IRepository<User>
{
    public void Add(User entity) { /* ... */ }
    public User GetById(int id) { /* ... */ }
    public IEnumerable<User> GetAll() { /* ... */ }
}
// 旧的非泛型集合(不推荐)
ArrayList list = new ArrayList();
list.Add(1);
list.Add("string"); // 可以混合类型,危险!
int i = (int)list[0]; // 需要强制转换

// 新的泛型集合(推荐)
List<int> intList = new List<int>();
intList.Add(1);
// inList.Add("string"); // 编译错误!类型安全
int i = intList[0]; // 无需强制转换

Dictionary<string, int> keyValuePairs = new Dictionary<string, int>();
keyValuePairs.Add("Alice", 95);
int score = keyValuePairs["Alice"]; // 类型安全,键是string,值是int

泛型约束(where

有时需要对类型参数T施加一些限制,比如要求T必须是一个类,或者必须实现某个接口。这就是泛型约束

public class GenericClass<T> where T : class, new() // T 必须是引用类型,并且有无参构造函数
{
    public T CreateInstance()
    {
        return new T(); // 因为有了 new() 约束,所以可以 new T()
    }
}

public class Calculator<T> where T : IComparable<T>
{
    public T Max(T a, T b)
    {
        return a.CompareTo(b) > 0 ? a : b; // 因为有了 IComparable约束,所以可以调用 CompareTo方法
    }
}

常见的约束类型

约束可以组合where T : Base, IMyInterface, new()顺序不限,但通常把new()放在最后

方差

方差是C#类型系统中一个非常重要但稍显复杂的概念,它关乎泛型类型参数的继承关系如何影响泛型类型本身的继承关系
核心思想:协变(Convariance)与逆变(Contravariance)
为了更好地理解,首先需要明确一个基础:在面向对象编程中,存在一种“里氏替换原则”。即,如果Dog继承自Animal,那么在任何需要Animal的地方,我们都可以安全地使用Dog。我们把这个关系记作:Dog -> Animal(Dog可以替换Animal)

假设有两个类

class Animal { }
class Dog : Animal { }

显然:

Animal a = new Dog(); // 合法

这就是正常的继承兼容(subtyping)

泛型类型

List<Animal> animals = new List<Dog>(); // 编译错误

因为List<Dog>不是List<Animal>
虽然DogAnimal的子类,但“装着狗的列表”不是“装着动物的列表”

原因很简单:
如果允许这样写,那就可能往animal里加一只Cat,而者实际上塞进了一个List<Dog>,逻辑崩溃

协变

例子:

// 使用 'out' 关键字声明协变接口
interface IReadOnlyCollection<out T>
{
    T GetItem(int index);
    int Count { get; }
}

class Animal { }
class Dog : Animal { }

// 因为Dog -> Animal,并且接口是协变的
// 所以 IReadOnlyCollection<Dog> -> IReadOnlyCollection<Animal>
IReadOnlyCollection<Dog> dogs = GetSomeDogs();
IReadOnlyCollection<Animal> animal = dogs; // 这是合法的!协变允许这样赋值
// IReadOnlyCollection<T>只用作输出,Dog当Animal输出,不写入
为什么这是安全的

因为IReadOnlyCollection<Animal>的消费者只期望从它哪里获取Animal对象。而我们的dogs集合里装的虽然是Dog,但每一个Dog都是一个Animal。所以,把Dog当作Animal返完全是安全的。T只输出,不会写入,编译器知道不会往里加别的动物,保证了不会有“把Cat塞进Dog集合”的风险,所以是安全的

.NET中的实际例子

IEnumerable<T>接口就是协变的,因为它只有一个方法GetEnumerator()返回IEnumerator<T>,而IEnumerator<T>的核心是T Current { get; }属性,T只用于输出

IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // 合法,因为 IEnumerable<out T>

逆变

// 使用 'in' 关键字声明逆变接口
interface IComparer<in T>
{
    int Compare(T x, T y);
}

class Animal { }
class Dog : Animal { }

// 因为 Dog -> Animal,并且接口是可逆的
// 所以 ICompare<Animal> -> IComparer<Dog> (变为反向)
IComparer<Animal> animalComparer = GetAnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // 这是合法的!逆变允许这样赋值
为什么这是安全的

IComparer<Dog>的消费者需要的是一个能比较两个Dog对象的比较器。而我们有一个animalComparer,它声称自己能比较任何Animal(包括Dog)。既然它能比较任何动物,那么它当然也能比较两个特定的Dog。所以,把一个“更通用的”比较器当作一个“更具体的”比较器来使用是安全的。T只出现在输出位置,保证了比较器不会要求返回一个具体的Dog

.NET中的实际例子

IComparer<T>Action<T>委托都是逆变的

Action<Animal> actOnAnimal = (animal) => Console.WriteLine(animal.Name);
Action<Dog> actOnDog = actOnAnimal; // 合法,因为 Action<in T>

// 现在调用 actOnDog(someDog)
// 实际上是执行actOnAnimal(someDog)
// 而 actOnAnimal 可以处理任何 Animal,所以处理一个Dog是安全的

不变

// 没有 'in' 或 'out' 关键字,是不变接口
interface IList<T>
{
    T GetItem(int index); // T 用于输出
    void Add(T item);     // T 用于输入
}

class Animal { }
class Dog : Animal { }

IList<Dog> dogs = new List<Dog>();
IList<Animal> animals = dogs; // 编译错误!不允许!
为什么不安全

假设上面的赋值是合法的

// 假设合法
animals.Add(new Cat()); // 因为 animals 是 IList<Animal>,添加 Cat 从它的角度看是合法的。

// 但 animals 实际上指向的是一个 List<Dog> dogs!
// 现在,我们试图把一只 Cat 放进一个 Dog 的列表里!这会导致类型不安全,运行时可能会崩溃。

因此,对于像IList<T>这样同时进行输入和输出的类型,C#强制其不变,以保障类型安全

重要规则

  1. 仅限接口和委托:方差(in/ out修饰符)只能用于接口和委托的泛型参数,不能用于类和结构体
  2. 类型安全是前提:C#的方差系统是在编译时和CLR层面严格检查的,其根本目的是提供灵活性的同时,100%保证安全

方差本质上就是让泛型类型系统具备类似“继承”的能力,从而解决泛型代码的复用问题

底层实现

编译阶段

当写下

class Box<T> { public T Value; }

编译器不会生成多个类型副本
它只在元数据(metadata)中生成一个定义模板

.class public auto ansi beforefieldinit Box`1<T> // `1表示有一个泛型参数
        extends [System.Runtime]System.Object
{
    .field public !0 Value
}

编译器生成的是“开放泛型类型定义(open generic type definition),这是一种模板。真正的类型要到运行时才“实例化”(instantiation)

CLR类型系统中的泛型机制

.NET的CLR实现了真正的泛型(reified generics),这和Java的类型擦除机制不同。
这意味着:

CLR中两种泛型类型

  1. 开放泛型(open generic):像List<T>Dictionary<Tkey, TValue> 还没绑定具体类型
  2. 封闭泛型(closed generic):比如List<int>List<string> 类型参数已被具体化

运行时首次遇到List<int>时,会“构造”出一个新的类型实例

System.Collections.Generic.List<Int32>

CLR把这当成一个真正的新类型,有独立的元数据和方法表(vtable)

JIT编译

当CLR JIT编译泛型方法或类时,它会根据类型参数决定是“复用”还是“重新生成代码”

类型参数JIT行为原因
引用类型共享代码(code sharing)所有引用类型的IL/JIT实现一样,只操作引用
值类型特化代码(code specialization)值类型大小和布局不同,不能共享
List<int> a = new();
List<string> b = new();

这就是泛型性能强大的原因

元数据与类型句柄(TypeHandle)

每个封闭泛型类型在运行时都有唯一的RuntimeTypeHandle
这意味着CLR知道List<int>List<string>是不同的
这种机制确保了

IL层观察

static void Main()
{
    var list = new List<int>();
    list.Add(42);
}

反编译IL

IL_0000: newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()
IL_0005: ldc.i4.s 42
IL_0007: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)

注意!0————它代表第一个泛型参数T
CLR看到List<int>时,会将!0替换成实际类型int32,在生成的机器码中直接操作32位整数

值类型与装箱对比实验

List<int> list1 = new List<int>();
List<object> list2 = new List<object>();

list1.Add(123); // 无装箱
list2.Add(123); // 发生装箱

原因:

所以泛型的好处:避免频繁的堆分配与GC压力

运行时缓存机制(Type Instantiation Cache)

CLR内部有一张“泛型实例化缓存表”

这部分逻辑在MethodTableBuilderGenerics::CreateTypeInstantiation内部完成(CLR源码可查)

协变与逆变的运行时原理(接口/委托)

协变(out)和逆变(in)只在编译器和运行时的类型检查层有效
CLR在方法签名元数据中标记这些参数的方差属性
运行时验证时允许安全的替换(如IEnumerable<string> -> IEnumerable<object>
但在方法表布局上仍是独立类型,没有动态类型转换

泛型静态成员隔离机制

每个封闭泛型类型都有独立的静态字段副本

class Counter<T> { public static int Count; }

Counter<int>.Count = 1;
Counter<string>.Count = 2;
Console.WriteLine(Counter<int>.Count); // 1

CLR在创建封闭类型时,独立分配静态区块
底层就是每个RuntimeTypeHandle拥有独立静态存储区

Activator.CreateInstance<T>()new()约束

new()约束机制与特点

语法

public class Factory<T> where T : new()
{
    public T Create() => new T();
}

编译层面:

注意:没有new()约束,写new T()会报错: CS0304: Cannot create an instance of the variable type ‘T’ because it does not have the new() constraint

运行时层面:
new T()是内联构造,编译器会在IL中直接生成

newobj instance void !0::.ctor()

JIT时,这会被特化成对T的构造函数直接调用,没有任何反射

性能层面:
new T()和直接new MyClass()一样快,没有额外开销

Activator.CreateInstance<T>()

基本用法:

T obj = Activator.CreateInstance<T>()

机制:

IL表现:

call !!0 [System.Private.CoreLib]System.Activator::CreateInstance<!!0>()

这其实是泛型方法调用,内部最终会调用

Acticator.CreateInstance(Type type)

这个方法执行时走的是反射路径

  1. 查询类型句柄(TypeHandle)
  2. 取构造函数信息
  3. 通过RuntimeMethodHandle.InvokeMethod()调用

性能层面:
因为走了反射,速度大约是new()的20~50倍慢(取决于JIT优化与类型缓存)
不过Activator.CreateInstance<T>()Activator.CreateInstance(Type)稍快一点,因为它缓存了泛型参数信息

使用场景

  1. 业务逻辑工厂(推荐new()
public class ObjectPool<T> where T : new()
{
    private readonly Stack<T> _pool = new();

    public T Get() => _pool.Count > 0 ? _pool.Pop() : new T();
    public void Release(T item) => _pool.Push(item);
}

性能关键场景下,应始终用new()约束

  1. 动态类型加载(必须用Activator) 如果类型在编译时未知,只能运行时从字符串或配置加载
Type type = Type.GetType("MyNamespace.MyClass, MyAssembly");
object instance = Activator.CreateInstance(type);

或者在泛型方法中实例化不满足new()的类型

public T Create<T>()
{
    return (T)Activator.CreateInstance(typeof(T), nonPublic: true);
}

例如T的构造函数是private或带参数时,这就是唯一办法

  1. 结合依赖注入(反射创建 + 缓存) 现代框架(如ASP.NET Core)在底层就是用类似ActivatorUtilities的机制,反射创建实例后缓存构造函数委托,提高性能
var ctor = typeof(T).GetConstructors().First();
var lambda = Expression.Lambda<Func<T>>(Expression.New(ctor)).Compile();
T obj = lambda(); // 比 Activator 快很多

这属于“动态生成构造函数委托”的优化版

内部机制

[编译阶段]
 new T()编译器检查 new() 约束生成 newobj IL直接调用构造函数
 Activator.CreateInstance<T>()调用反射API查找构造函数调用MethodHandle.Invoke

最佳实践

  1. 能用new()就绝不要用Activator
    • 编译期安全
    • 性能极高
    • 可被JIT优化与内联
  2. 仅当类型在编译期未知时才用Activator
    • 如插件系统、反射加载模块、序列化框架
  3. 避免在循环或性能关键路径中使用Activaotr
    • 如果一定要用,创建后缓存委托(Func<T>)复用
  4. 不要再泛型工具类里随意使用Activator代替约束
    • 它会破坏类型安全和调试可预测性

运行时类型信息与反射

运行时类型信息(RTTI, Run-Time Type Infomation)是“类型信息存在于运行时”的机制;反射是“利用RTTI在运行时查询或操作类型”的手段

RTTI

当写下

object obj = new List<int>();

编译器并不知道obj实际上是一个List<int>,但在运行时,CLR会保存每个对象的“类型描述符”信息,也就是RTTI

在C#中,每个对象在内存中其实带着一个指向它的TypeHandle的指针,这个句柄告诉CLR

换句话说:每个对象都知道自己是谁

反射

反射(Reflection)就是CLR提供的一组API,可以在运行时

Type t = typeof(List<int>);
Console.WriteLine(t.FullName); // System.Collections.Generic.List`1[System.Int32]

typeof()GetType()Type.GetMethod()这些都是反射的入口
再比如动态创建对象

object list = Activator.CreateInstance(typeof(List<int>));

这是反射的常见用途之一:动态加载类,这意味着:

反射与泛型的结合

C#的泛型是“真实泛型(reified generics)”,也就是说:

所以可以

Type t1 = typeof(List<int>);
Type t2 = typeof(List<string>);
Console.WriteLine(t1 == t2); // false

泛型和NULL(nullable reference types)

在C#中,null是引用类型(reference type)或可空值类型(nullable value type)的默认值
而泛型类型参数T的行为取决于它被“约束(constraint)”成什么样

void Foo<T>(T value)
{
    if (value == null) // 可能报错
        Console.WriteLine("Null!");
}

上面这段代码编译不通过,因为编译器不知道T是引用类型还是值类型\

编译器无法保证value == null是合法的比较

通过约束告诉编译器T可以为null

C#的约束系统可以明确告诉编译器T是哪一类

  1. where T : class
    • 说明 T 必须是引用类型
    • 此时就可以写if (value == null)
void Foo<T>(T value) where T : class
{
    if (value == null) Console.WriteLine("Null!");
}
  1. where T : struct
    • 说明 T 必须是值类型
    • 此时 T 不可能为null
    • 但可以用Nullable<T>来允许null值
void Foo<T>(T? value) where T : struct
{
    if (value == null) Console.WriteLine("Null");
}
  1. where T : unmanaged
    • 说明 T 必须是非托管类型(纯值类型,没有引用字段)
    • 同样,不能为null
    • 常用于底层内存操作、指针、Span等结合

default<T>

当不知道 T 是值类型还是引用类时,不能直接写null,但可以写

T value = default;

或者

T value = default(T);

这在泛型里是万能解法,编译器会根据类型自动选择默认值
default(T)在泛型中就像一把“万能钥匙”,无论T是什么类型,都能安全获得“空值”或“默认初始状态”

可空值类型

当希望值类型也能用null

int? x = null;
Nullable<int> = default;

Nullable<T>是一个特殊的泛型结构体,定义大致是这样的

public struct Nullable<T> where T : struct
{
    private bool hasValue;
    private T value;
}

这让int?, bool?, float?等拥有了“null状态”,编译器对?., ??, == null等操作都内置了特殊支持

可空引用类型

从C#8.0起,语言引入了一个语义层的“null检查系统”

所以在泛型中,如果启用了可空上下文

void Foo<T>(T? value)

就意味着在声明:value可以是null————不管T是值类型还是引用类型

泛型静态成员

class Generic<T>
{
    public static int Count;
}

Generic<int>.Count = 10;
Generic<string>.Count = 20;

Console.WriteLine(Generic<int>.Count); // 10
Console.WriteLine(Generic<string>.Count); // 20

虽然它们是“同一个泛型类”,但不同的T会生成不同的静态副本
也就是说

对于每个封闭泛型类型(如Generic<int>Generic<string>),CLR都会为它生成独立的静态数据区

C#的泛型是真实泛型,这意味着在CLR层面,每个具体的Generic<T>都会被“实例化”成一个独立的运行时类型描述

这不是语法糖,而是真实的类型区分

typeof(Generic<int>) == typeof(Generic<string>) // false

静态构造函数(static ctor

每个封闭类型在第一次使用时,都会运行自己的静态构造函数

class Generic<T>
{
    static Generic()
    {
        Console.WriteLine($"Static ctor for {typeof(T)}");
    }
}

new Generic<int>();
new Generic<string>();

输出

Static ctor for System.Int32
Static ctor for System.String

可以看到,CLR会为每个封闭类型单独触发一次初始化
这是非常关键的行为,意味着泛型的静态成员可以安全地缓存类型相关的全局信息,而互不干扰

利用这一特性:类型级缓存器

这个特性被许多框架、序列化器、ORM用来做“类型级缓存器”

class TypeCache<T>
{
    public static readonly string TypeName = typeof(T).FullName;

    static TypeCache()
    {
        Console.WriteLine($"Cache built for {TypeName}");
    }
}

// 初始化多个类型
var a = TypeCache<int>.TypeName;
var b = TypeCache<string>.TypeName;

输出

Cache built for System.Int32
Cache built for System.String

这种设计可以让每个类型都有自己的缓存,而不用用字典去手动区分类型,性能极高,而且线程安全
这在诸如:

中都是非常经典的优化模式

泛型静态成员的JIT编译逻辑

当JIT编译器遇到一个泛型类型时,它的行为是这样的

共享JIT代码 != 共享静态数据

陷阱与建议

  1. 不要把泛型当万能替代:类型安全没了约束时仍会出错。合理加where约束
  2. 方差只在接口/委托上:尝试对List<T>使用out/in时不可能的
  3. 注意装箱:如果把泛型值类型赋给非泛型接口/object就会装箱。尽量使用泛型接口
  4. 过度复杂的泛型层次会降低可读性:泛型设计要平衡灵活性与易用性
  5. new()约束:若仅为创建实例就加new(),考虑是否更好地注入工厂以便测试与解耦
  6. 不要用反射做泛型的常态逻辑:反射慢、复杂。只在必要时用