泛型用类型参数保留输入、输出和存储之间的类型关系,同一份声明可以由多个具体类型构造使用。
约束只开放编译期能力,不会验证运行时业务数据;泛型类默认不变,out和in也只对符合条件的接口与委托生效。
用最小约束表达算法真正需要的操作,以EqualityComparer<T>.Default等泛型协议处理值,并在API边界单独验证null、空集合与业务不变量。
是什么,为什么存在
泛型(generic) 声明把一个或多个类型留作参数。类、结构、接口、委托和方法都可以声明 类型参数(type parameter) ,通常写作T、TKey或TResult。使用方提供类型实参后,List<string>或Dictionary<string, int>这样的构造类型才确定下来。
泛型解决的是类型关系复用,而不只是少写几份代码。T Find<T>(IEnumerable<T> source)表明返回值与序列元素是同一种类型;若改成object Find(IEnumerable source),这种关系会消失,调用方只能强制转换并承担运行时失败。
值类型通过object传递时通常发生 装箱(boxing) ,取回时还要拆箱。List<int>直接按int存取元素,既保留编译期检查,也不需要为了集合的每个元素先转换成object。这不表示所有泛型代码都零分配,只说明它避免了这条特定转换路径。
你会在集合、LINQ、委托、异步API、依赖注入和序列化库中反复遇到泛型。读取IEnumerable<Order>时,尖括号里的类型不是文档装饰,而是编译器和运行时都能使用的契约组成部分。
泛型适合多种类型共享同一算法,而且算法只依赖一组明确能力的场景。如果不同类型需要完全不同的业务流程,堆叠类型检查和typeof(T)分支通常是在隐藏多个实现;接口、多态或独立服务会更清楚。
工作原理
声明与构造是两个阶段
Cache<TKey, TValue>声明两个类型参数。Cache<string, Product>提供两个类型实参,形成封闭构造类型;参数个数、顺序和约束都必须匹配。Cache<,>表示未绑定泛型类型,只能用于typeof和反射等元数据操作,不能直接创建实例。
同一个类型参数在多个位置出现时,编译器会保持它们相等。下面的签名要求item、fallback和返回值都使用同一个T,调用方不能把互不相关的类型混在一次调用中。
static T Choose<T>(bool useItem, T item, T fallback) =>
useItem ? item : fallback;泛型方法的类型参数属于方法,不要求包含它的类型也是泛型。调用Choose(true, 10, 20)时,编译器从实参推断T为int。方法类型推断不靠返回值猜测T,推断失败或结果不符合约束时,调用方必须调整实参或明确写出类型实参。
约束开放可用能力
没有约束的T只能使用所有类型都具备的操作,例如赋值、object成员和EqualityComparer<T>.Default。 泛型约束(generic constraint) 通过where子句缩小可接受类型,同时让泛型实现可以调用约束保证的成员。
| 约束 | 调用方可提供的类型 | 泛型实现获得的能力 |
|---|---|---|
where T : class | 非可空引用类型 | 按非可空引用类型分析T |
where T : class? | 可空或非可空引用类型 | 按可空引用类型分析T |
where T : struct | 非可空值类型 | 使用值类型规则;隐含可用无参构造 |
where T : unmanaged | 不含托管引用的非可空值类型 | 在允许不安全代码时取得大小或指针 |
where T : notnull | 非可空值类型或非可空引用类型 | 对可空类型实参产生诊断 |
where T : BaseType | 指定基类的派生类型 | 使用基类公开成员 |
where T : IContract | 实现指定接口的类型 | 使用接口成员,包括静态抽象成员 |
where T : new() | 具有公开无参构造函数的非抽象类型 | 执行new T() |
约束是编译期规则,不是业务验证器。where T : class不会阻止旧代码、反射或关闭可空分析的调用方传入null;where T : new()也不能证明新对象已满足领域不变量。公开边界仍要检查实际值。
多个约束的顺序受语法限制:引用类型、值类型或unmanaged等主约束放在前面,基类或接口约束随后,new()靠后。allows ref struct是反约束,表示实现遵守ref struct的安全规则;它必须位于其他约束之后,不能把类型实参当作可以装箱或跨await保存的普通值。
相等、比较与运算符需要协议
对未知T使用==并不总能编译,也未必表达领域需要的相等性。普通相等查找应使用EqualityComparer<T>.Default,排序可以接收IComparer<T>,需要调用CompareTo时才添加IComparable<T>约束。这样,值类型、引用类型和调用方提供的比较策略走同一条明确路径。
C#的接口可以声明静态抽象成员。INumber<TSelf>等泛型数学接口因此能让受约束代码使用T.Zero和+,而不必改用dynamic。这是一个真正的能力约束:调用方只能提供实现所需静态协议的类型。
不要为了方便随手添加new()、class或某个宽泛接口。约束会成为公开API的兼容性边界;算法没有使用的约束只会排除原本有效的类型,并让以后移除约束成为设计修正。
变体控制引用转换方向
变体(variance) 描述已有引用类型转换怎样传播到泛型接口或委托。IEnumerable<out T>是协变的,所以IEnumerable<string>可以赋给IEnumerable<object>;它只从接口输出T,调用方无法通过该接口写入一个任意object。
IComparer<in T>是逆变的。能比较任意Animal的比较器当然也能比较两个Dog,所以IComparer<Animal>可以用于需要IComparer<Dog>的位置。方向看起来相反,是因为T只作为输入进入接口。
只有接口和委托的类型参数可以声明out或in,泛型类本身始终不变。即使Dog派生自Animal,List<Dog>也不能赋给List<Animal>;否则接收方就能加入一只不是Dog的Animal。变体转换也只适用于引用类型实参,IEnumerable<int>不会因此转换成IEnumerable<object>。
示例
四个示例依次展示类型关系、约束提供的静态能力、变体转换和运行时类型身份。当前环境没有 .NET SDK或C#编译器,因此代码块按仓库约定标明未执行,输出块不伪造结果。
用类型参数保留查找结果
这个查找方法把元素、目标值和比较器绑定到同一个T。调用方从string?[]实参得到string?推断,不需要把元素转换成object。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
string?[] states = ["queued", null, "sent", "sent"];
Console.WriteLine(FindIndex(states, null));
Console.WriteLine(FindIndex(states, "sent"));
Console.WriteLine(FindIndex(states, "missing"));
static int FindIndex<T>(IReadOnlyList<T> items, T target)
{
EqualityComparer<T> comparer = EqualityComparer<T>.Default;
for (int index = 0; index < items.Count; index++)
{
if (comparer.Equals(items[index], target))
return index;
}
return -1;
}# not executed here: the .NET SDK and C# compilers are unavailableEqualityComparer<T>.Default能处理示例中的两个null操作数,也会采用具体类型的默认相等规则。方法返回索引而不是default(T),所以“没有找到”不会和元素类型的合法默认值混在一起。
真实API还要决定items本身能否为null。可空注解可以表达预期并产生警告,但跨越反射、反序列化或未启用可空分析的边界时,若契约要求拒绝null,仍应执行运行时检查。
用静态抽象成员实现泛型求和
INumber<T>保证T.Zero和加法可用。实现既没有按具体数值类型分支,也没有把运算推迟给dynamic。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
using System.Numerics;
int[] units = [2, 3, 5];
decimal[] prices = [1.25m, 2.50m, 4.00m];
Console.WriteLine(Sum(units));
Console.WriteLine(Sum(prices));
static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (T value in values)
total += value;
return total;
}# not executed here: the .NET SDK and C# compilers are unavailable空序列会返回T.Zero,这是该方法明确选择的数学单位元。这个结果不一定适合平均值、最大值或业务金额校验;那些操作需要各自定义空输入契约,而不是照搬求和行为。
若只需要加法和零值,可以约束更窄的操作符接口,而不是整个INumber<T>。约束应跟随实现真正调用的成员,这能让自定义数值类型更容易满足API。
分开读取方与写入方
生产者只返回T,因此声明out T;接收器只接收T,因此声明in T。赋值方向由这两个接口位置决定,并没有改变具体对象的运行时类型。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
IProducer<Dog> dogSource = new DogSource();
IProducer<Animal> animalSource = dogSource;
IConsumer<Animal> animalSink = new AnimalSink();
IConsumer<Dog> dogSink = animalSink;
Animal animal = animalSource.Create();
dogSink.Save(new Dog("Rex"));
Console.WriteLine(animal.Name);
public interface IProducer<out T>
{
T Create();
}
public interface IConsumer<in T>
{
void Save(T value);
}
public sealed class DogSource : IProducer<Dog>
{
public Dog Create() => new("Milo");
}
public sealed class AnimalSink : IConsumer<Animal>
{
public void Save(Animal value) =>
Console.WriteLine($"saved: {value.Name}");
}
public record Animal(string Name);
public sealed record Dog(string Name) : Animal(Name);# not executed here: the .NET SDK and C# compilers are unavailableanimalSource的静态类型只承诺产生Animal,但实际对象仍是DogSource,产生的实例仍是Dog。dogSink只能被调用来保存Dog,而底层AnimalSink本来就能处理这种输入。
若一个接口既接收又返回同一个T,通常不能把该参数标为协变或逆变。把读取和写入能力拆成两个窄接口,有时能得到安全的变体转换;拆分是否合适仍取决于领域契约。
观察构造类型的运行时身份
运行时保留泛型类型实参。示例还展示了泛型静态类的字段按封闭构造类型分别存在。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
Type integers = typeof(List<int>);
Type strings = typeof(List<string>);
Console.WriteLine(integers == strings);
Console.WriteLine(integers.GetGenericTypeDefinition() == typeof(List<>));
Console.WriteLine(string.Join(", ", integers.GetGenericArguments()));
TypeSlot<int>.Label = "quantities";
TypeSlot<string>.Label = "names";
Console.WriteLine(TypeSlot<int>.Label);
Console.WriteLine(TypeSlot<string>.Label);
public static class TypeSlot<T>
{
public static string Label { get; set; } = typeof(T).Name;
}# not executed here: the .NET SDK and C# compilers are unavailableList<int>与List<string>是不同的构造类型,但两者的泛型类型定义都是List<>。反射代码需要先分清拿到的是定义还是封闭类型,再决定调用GetGenericArguments()、MakeGenericType()或创建实例。
TypeSlot<int>.Label和TypeSlot<string>.Label不共享字段。这种行为可用于按类型缓存元数据,但缓存仍需要容量与生命周期设计;类型数量来自不受控动态输入时,泛型静态字段不是自动有界的缓存。
陷阱
用object或dynamic绕过设计问题
修复方法: 先写出算法需要的最小协议。相等使用IEqualityComparer<T>,排序使用IComparer<T>,静态运算使用带静态抽象成员的接口;不同业务行为则用多态或独立实现,不要用dynamic掩盖。
把default(T)当作未找到
修复方法: 返回索引、布尔值加out T、可辨识的结果类型,或在契约适合时返回可空值。选择必须同时覆盖值类型、引用类型和实际业务含义。
用new()代替对象创建契约
修复方法: 让调用方传入Func<T>或领域工厂,或者约束到真正描述创建协议的接口。只有空对象本来就有效,而且泛型实现确实负责创建时,才使用new()。
把所有泛型集合都当作协变
修复方法: 查看实际接口声明中的out或in,并检查类型实参是否是引用类型。只读消费方可接收协变接口;需要修改时,保留准确元素类型或显式复制到新的目标集合。
认为可空约束已经检查了值
修复方法: 用约束表达类型级意图,用ArgumentNullException.ThrowIfNull等运行时检查保护真实边界。测试来自反射、反序列化、旧程序集和禁用可空上下文的输入。
添加算法没有使用的约束
修复方法: 为约束中的每项能力找到实现里的实际使用位置。找不到就删除;业务数据规则应放进参数验证或领域类型,而不是借类型约束表达。
约束的边界与可空性
class、class?、notnull和struct表达不同集合。启用可空分析时,class要求非可空引用类型,class?接受可空引用类型;notnull同时接受非可空引用类型与非可空值类型;struct接受非可空值类型,但不接受Nullable<T>。把它们统称为非空约束会丢失真实差异。
可空注解的警告级别还取决于调用方的可空上下文。违反notnull通常产生编译器警告而不是运行时异常,因此库实现不能把它当作信任边界。泛型参数的T?含义也受约束影响:编写公开API时,应配合注解属性与运行时验证表达完整契约。
default约束只用于重写方法或显式接口实现,以说明未带class或struct主约束的类型参数。它不是一个可以随处添加的零值约束。allows ref struct则放宽可接受类型集合,同时要求泛型实现遵守栈限制;它与普通约束缩小集合的方向相反,所以文档称它为反约束。
约束之间还可能互斥。struct已经保证可用无参构造,所以不能再组合new();基类约束与某些主约束也不能任意并列。不要凭记忆排列复杂约束,编译最小声明,并让公开签名只保留确有用途的部分。
运行时的构造泛型
.NET运行时保留封闭泛型类型的类型实参,因此typeof(List<int>)能报告int,反射也能区分List<int>与List<string>。这与把所有类型实参擦成同一个运行时类型的模型不同。序列化器、依赖注入容器和反射工厂正是依赖这些元数据工作。
保留类型身份不表示每个构造类型都复制全部机器码。运行时通常能为多个引用类型实参共享泛型实现代码,而值类型构造通常需要针对其布局生成代码。这是运行时策略,不应被改写成没有测量依据的速度结论。
泛型类型的静态字段属于每个封闭构造类型。Registry<Order>和Registry<Customer>各有自己的静态状态,这能实现按类型存储,也可能悄悄形成多个生命周期很长的缓存。审查时要按实际构造类型计数,并明确清理、容量与并发规则。
反射中的typeof(Dictionary<,>)是泛型类型定义,包含未绑定参数;typeof(Dictionary<string, int>)是封闭构造类型。MakeGenericType()会在运行时验证参数个数和约束,不合格时抛出异常。外部输入不应直接决定要构造的类型定义或类型实参,应先映射到允许列表。
变体的精确边界
变体标注写在接口或委托的声明处,不写在使用处。协变参数主要出现在输出位置,逆变参数主要出现在输入位置;编译器还会检查属性、方法参数、返回值和嵌套委托位置,拒绝可能造成不安全写入或读取的声明。
变体只传播已有的隐式引用转换。string到object有引用转换,所以IEnumerable<string>到IEnumerable<object>成立;int到object需要装箱,不属于同一种转换,因此对应泛型接口之间不发生协变。用户定义转换也不会自动变成泛型变体转换。
泛型类可以实现变体接口,但类本身仍然不变。List<string>可被当作IEnumerable<object>使用,是先把列表看作IEnumerable<string>,再应用接口的协变;这不产生List<object>,也没有给原列表增加写入object的能力。
数组协变是另一套更早的运行时规则。string[]可以赋给object[],但写入不兼容对象会在运行时抛出ArrayTypeMismatchException。不要拿数组行为推导泛型集合;泛型变体的目标正是让不安全方向在编译期被拒绝。
泛型API的演进
公开类型参数名称会出现在文档和反射中,TKey、TValue、TSource比无差别的T1、T2更能说明关系。名称不改变类型身份,但它影响调用方理解约束、诊断消息与生成文档的质量。
增加约束通常会让已有调用代码无法重新编译,因为原先允许的类型实参可能被排除。移除约束能扩大调用集合,却可能迫使实现放弃原先使用的成员。修改in、out或参数位置也会改变可赋值关系,因此这些都属于API设计变更。
返回宽泛接口并不自动改善设计。IEnumerable<T>可能延迟执行、重复查询或只允许单次枚举;IReadOnlyList<T>才承诺计数和索引,但仍不保证底层数据不会变化。泛型参数表达元素类型,集合接口表达访问能力,两层契约都要写准确。
测试泛型库时,不要只挑一个引用类型。至少选一个值类型来暴露装箱与默认值假设,选一个可空场景验证边界,再选一个具有自定义相等或比较规则的类型。涉及变体时,分别写出应当编译和应当拒绝的赋值,并把编译诊断作为测试目标的一部分。
框架中的开放泛型
反射和依赖注入框架常接收开放泛型定义。例如,一个注册关系可以表达每个IRepository<T>都由Repository<T>实现,容器在请求具体服务时再用同一个类型实参封闭两边。这个机制减少重复注册,却没有取消构造函数、生命周期和约束检查。
开放定义的参数个数与位置必须兼容。二元定义不能直接匹配一元服务,带约束的实现也不能为不满足约束的实参创建封闭类型。框架何时报告错误取决于它是在注册、构建容器还是第一次解析时验证,因此启动测试应主动解析关键封闭类型。
| 表达式 | 表示的类型 | 能否直接创建实例 |
|---|---|---|
typeof(Box<>) | 带一个未绑定参数的泛型定义 | 否 |
typeof(Box<int>) | 类型实参为int的封闭构造类型 | 是 |
typeof(Box<>).MakeGenericType(typeof(string)) | 运行时创建的封闭构造类型 | 是 |
开放泛型注册不会证明每个封闭组合都有业务意义。Repository<AuditLog>可能满足语言约束,却违反应用对聚合根、事务或授权的要求。需要这种规则时,应通过明确标记、注册允许列表或领域接口限制,而不是等到第一次请求才发现。
不要把客户端提供的程序集限定名直接交给Type.GetType()和MakeGenericType()。即使最终创建动作受语言约束限制,攻击者仍可能选择不应公开的应用类型或触发大量封闭组合。外部名称应先映射到一组固定的服务与类型实参。
开放泛型排查应同时记录定义和最终封闭类型。只看到IHandler<>的注册无法说明失败请求用了哪个消息类型;只看到IHandler<Invoice>又可能隐藏它来自一条全局开放注册。诊断信息应把两者和所用生命周期放在一起。
5个问题 · 2道输出预测题 · 1道找错题