第 11 章:泛型的底层实现
jerry北京市2026年4月21日C# 5 次阅读 约 17 分钟

深入理解 CLR 泛型与 Java 类型擦除的区别、值类型和引用类型的类型特化、协变与逆变、泛型约束的本质,以及泛型中的静态字段陷阱。
11.1 CLR 泛型 vs Java 类型擦除
Java 的类型擦除
Java 泛型在编译后会擦除类型信息:
// Java 编译前
List<String> list = new ArrayList<String>();
list.add("hello");
String s = list.get(0);
// Java 编译后(类型擦除)
List list = new ArrayList(); // 泛型信息消失
list.add("hello");
String s = (String) list.get(0); // 插入强制转换
CLR 的真泛型(Reification)
.NET 泛型在运行时保留完整的类型信息:
var list = new List<int>();
Console.WriteLine(list.GetType().Name); // List`1
Console.WriteLine(list.GetType().GetGenericArguments()[0]); // System.Int32
// 运行时可以区分不同的泛型实例
Console.WriteLine(typeof(List<int>) == typeof(List<string>)); // False
// 可以在运行时构造泛型类型
Type openType = typeof(List<>);
Type closedType = openType.MakeGenericType(typeof(double));
object instance = Activator.CreateInstance(closedType)!;
// instance 是 List<double>
| 特性 | CLR 泛型 | Java 泛型 |
|---|---|---|
| 运行时类型信息 | 保留 | 擦除 |
| 值类型支持 | 原生支持(无装箱) | 不支持(必须用包装类) |
| 运行时反射 | 完整泛型信息 | 只能获取原始类型 |
| 代码生成 | JIT 为不同类型生成专用代码 | 共享同一份字节码 |
11.2 类型特化(Type Specialization)
CLR 对值类型和引用类型采用不同的策略:
值类型:每种类型生成独立的 JIT 代码
// 这三个会生成三份不同的机器码
List<int> intList = new(); // 专用的 int 版本
List<double> doubleList = new(); // 专用的 double 版本
List<bool> boolList = new(); // 专用的 bool 版本
JIT 编译结果:
┌─────────────────────┐
│ List<int> 的机器码 │ ← 直接操作 4 字节整数,无装箱
├─────────────────────┤
│ List<double> 的机器码 │ ← 直接操作 8 字节浮点数
├─────────────────────┤
│ List<bool> 的机器码 │ ← 直接操作 1 字节布尔值
└─────────────────────┘
为什么值类型需要独立代码?
- 不同值类型大小不同(int 4字节,double 8字节)
- 内存布局不同,需要不同的 CPU 指令
- 避免装箱,直接操作原始值
引用类型:共享同一份 JIT 代码
// 这三个共享同一份机器码
List<string> stringList = new();
List<User> userList = new();
List<object> objectList = new();
JIT 编译结果:
┌──────────────────────────┐
│ List<引用类型> 的共享机器码 │ ← 所有引用类型共用
│ (操作的都是指针,大小相同) │
└──────────────────────────┘
为什么引用类型可以共享?
- 所有引用类型的变量都是指针(8字节,64位系统)
- 内存布局相同,可以用相同的 CPU 指令操作
11.3 协变(Covariance)与逆变(Contravariance)
协变(out):子类型 → 父类型
// IEnumerable<T> 声明为 IEnumerable<out T>
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings; // 协变:string → object ✅
// 协变只允许 T 出现在输出位置
interface IProducer<out T>
{
T Produce(); // ✅ T 在返回值(输出位置)
// void Consume(T item); // ❌ 编译错误:T 不能在参数(输入位置)
}
逆变(in):父类型 → 子类型
// Action<T> 声明为 Action<in T>
Action<object> objectAction = obj => Console.WriteLine(obj);
Action<string> stringAction = objectAction; // 逆变:object → string ✅
stringAction("hello"); // 可以工作,因为 string 是 object
// 逆变只允许 T 出现在输入位置
interface IConsumer<in T>
{
void Consume(T item); // ✅ T 在参数(输入位置)
// T Produce(); // ❌ 编译错误:T 不能在返回值(输出位置)
}
为什么需要协变和逆变?
// 没有协变,这段代码无法编译
void PrintAll(IEnumerable<object> items)
{
foreach (var item in items)
Console.WriteLine(item);
}
List<string> names = ["张三", "李四"];
PrintAll(names); // 因为 IEnumerable<out T> 是协变的,所以可以
// 没有逆变,这段代码无法编译
void SortUsers(IComparer<User> comparer) { /* ... */ }
class EntityComparer : IComparer<object>
{
public int Compare(object? x, object? y) => /* ... */;
}
SortUsers(new EntityComparer()); // 因为 IComparer<in T> 是逆变的,所以可以
常见的协变/逆变接口
| 接口 | 变体 | 说明 |
|---|---|---|
IEnumerable<out T> |
协变 | 只读序列 |
IReadOnlyList<out T> |
协变 | 只读列表 |
IComparable<in T> |
逆变 | 比较 |
IComparer<in T> |
逆变 | 比较器 |
Action<in T> |
逆变 | 无返回值委托 |
Func<out TResult> |
协变 | 有返回值委托 |
Func<in T, out TResult> |
混合 | 输入逆变,输出协变 |
11.4 泛型约束
约束类型
// 值类型约束
void Process<T>(T value) where T : struct { }
// 引用类型约束
void Process<T>(T value) where T : class { }
// 非空约束(C# 9+)
void Process<T>(T value) where T : notnull { }
// 构造函数约束
T Create<T>() where T : new() => new T();
// 基类约束
void Save<T>(T entity) where T : BaseEntity { }
// 接口约束
void Sort<T>(List<T> list) where T : IComparable<T> { }
// 多重约束
void Process<T>(T value) where T : class, IDisposable, new() { }
// 类型参数约束
void Copy<TSource, TDest>(TSource src) where TDest : TSource { }
// .NET 7+:静态抽象成员约束
T Add<T>(T a, T b) where T : INumber<T> => a + b;
约束的底层作用
// 没有约束:T 被当作 object,可能需要装箱
void NoConstraint<T>(T a, T b)
{
// a == b; // 编译错误:不能用 == 比较
a.Equals(b); // 如果 T 是值类型,会装箱
}
// 有约束:编译器知道 T 的能力,可以生成更高效的代码
void WithConstraint<T>(T a, T b) where T : IEquatable<T>
{
a.Equals(b); // 调用 IEquatable<T>.Equals,值类型不装箱
}
11.5 泛型中的静态字段
每个封闭泛型类型有自己独立的静态字段:
class Counter<T>
{
public static int Count = 0;
}
Counter<int>.Count = 10;
Counter<string>.Count = 20;
Console.WriteLine(Counter<int>.Count); // 10
Console.WriteLine(Counter<string>.Count); // 20
Console.WriteLine(Counter<double>.Count); // 0(独立的)
内存中的静态字段:
┌──────────────────┐
│ Counter<int> │ → Count = 10
├──────────────────┤
│ Counter<string> │ → Count = 20
├──────────────────┤
│ Counter<double> │ → Count = 0
└──────────────────┘
每个封闭类型都是独立的"类",有自己的静态字段
利用这个特性实现类型缓存
// 高效的类型信息缓存(比 Dictionary<Type, T> 更快)
static class TypeCache<T>
{
// 每个 T 只计算一次,之后直接读取静态字段
public static readonly int Size = Unsafe.SizeOf<T>();
public static readonly bool IsValueType = typeof(T).IsValueType;
public static readonly string TypeName = typeof(T).FullName ?? typeof(T).Name;
}
Console.WriteLine(TypeCache<int>.Size); // 4
Console.WriteLine(TypeCache<int>.IsValueType); // True
Console.WriteLine(TypeCache<long>.Size); // 8
11.6 泛型的限制
// 不能用 new T() 除非有 where T : new() 约束
// 不能用 default 以外的字面量初始化
// 不能对泛型类型使用运算符(除非 .NET 7+ INumber<T>)
// .NET 7 之前的变通方案
T Add<T>(T a, T b)
{
// return a + b; // 编译错误
dynamic da = a!;
dynamic db = b!;
return da + db; // 运行时解析,有性能代价
}
// .NET 7+ 使用泛型数学
T Add<T>(T a, T b) where T : INumber<T> => a + b;
Console.WriteLine(Add(1, 2)); // 3
Console.WriteLine(Add(1.5, 2.5)); // 4.0
11.7 面试要点
-
CLR 泛型和 Java 泛型的区别?
- CLR 是真泛型(运行时保留类型信息),Java 是类型擦除
- CLR 值类型泛型无装箱,Java 必须用包装类
-
值类型和引用类型的泛型代码生成有什么区别?
- 值类型:每种类型生成独立的 JIT 代码(类型特化)
- 引用类型:共享同一份 JIT 代码
-
什么是协变和逆变?
- 协变(out):子类型可以赋值给父类型,T 只能在输出位置
- 逆变(in):父类型可以赋值给子类型,T 只能在输入位置
-
泛型类的静态字段是共享的吗?
- 不是。每个封闭泛型类型有独立的静态字段
-
where T : new() 约束的作用?
- 保证 T 有无参构造函数,允许在泛型方法中使用
new T()
- 保证 T 有无参构造函数,允许在泛型方法中使用
练习
- 实现一个泛型对象池
ObjectPool<T> where T : new() - 利用泛型静态字段实现一个高性能的类型元数据缓存
- 实现一个支持协变的只读集合接口
- 使用 .NET 7 的泛型数学(INumber<T>)实现一个通用的统计函数(求和、平均值、标准差)
评论
登录 后发表评论
暂无评论