泛型约束
本教程共 100 篇 · 第 64 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
64. 泛型约束
本节目标:学完你能用
where给类型参数加约束,并在泛型内部放心调用对象成员。
上一章的 Max 用了 Comparer<T>.Default 来比较。其实更规范的做法是告诉编译器:「我的 T 一定能比较」。这就需要泛型约束(constraints)。
为什么需要约束
没有约束时,编译器把 T 当成最底层的 object,你只能调用 object 上有的方法。一旦想写 a.CompareTo(b)、或 new T(),编译器就报错:它不知道 T 到底有没有这些能力。
约束的作用,就是给 T 立规矩:「只接受满足某条件的类型」。这样一来,编译器放行你调用对应成员,类型安全也更强。
常用约束一览
| 约束 | 含义 |
|---|---|
where T : class | T 必须是引用类型 |
where T : struct | T 必须是非可空值类型 |
where T : new() | T 必须有公开无参构造函数 |
where T : 基类 | T 必须继承自某基类 |
where T : 接口 | T 必须实现某接口 |
where T : notnull | T 不可为 null |
where T : unmanaged | T 必须是非托管类型(用于底层互操作) |
Note还有可空变体:
where T : class?允许可空引用类型,where T : struct?允许可空值类型(如int?)。notnull比class更严,连可空引用也被挡掉。
接口约束:解锁成员调用
用 IComparable<T> 约束后,Max 内部可以直接比较,不再依赖运行时 Comparer:
Console.WriteLine(Max(3, 7));
Console.WriteLine(Max("apple", "banana"));
T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
输出:
7
banana
因为约束保证 T 实现了 CompareTo,编译器才允许你写 a.CompareTo(b)。
Tip约束的本质是「能力契约」。你承诺传入的类型具备某种能力,编译器便放你使用该能力。
new() 约束:在内部创建实例
想在泛型里 new T()?必须加 new() 约束,证明这个类型能无参构造:
Person p = Create<Person>();
p.Name = "小明";
Console.WriteLine(p.Name);
T Create<T>() where T : class, new()
{
return new T();
}
class Person
{
public string Name { get; set; } = "";
}
输出:小明。class 和 new() 常组合使用:既要引用类型,又要能实例化。
struct 与 class 约束
限制值类型或引用类型,常用于容器类:
var intBox = new Container<int>(5);
Console.WriteLine(intBox.Data);
class Container<T> where T : struct
{
public T Data { get; set; }
public Container(T data) => Data = data;
}
输出:5。若试图写 new Container<string>("x"),编译直接报错,因为 string 是引用类型,不满足 struct。
基类约束:访问父类成员
约束成某个基类后,泛型内部能直接调用基类成员:
var box = new AnimalContainer<Dog>();
box.Data = new Dog();
box.Data.Speak();
class Animal
{
public virtual void Speak() => Console.WriteLine("动物叫");
}
class Dog : Animal
{
public override void Speak() => Console.WriteLine("汪汪");
}
class AnimalContainer<T> where T : Animal
{
public T Data { get; set; }
}
输出:汪汪。T 被约束为 Animal,所以 Data.Speak() 编译通过。
notnull 与 unmanaged 约束
notnull 常用于字典的键,保证键不会是 null:
var dict = new SafeDict<string, int>();
dict.Put("a", 1);
Console.WriteLine(dict.Get("a"));
class SafeDict<TKey, TValue> where TKey : notnull
{
private Dictionary<TKey, TValue> _store = [];
public void Put(TKey k, TValue v) => _store[k] = v;
public TValue Get(TKey k) => _store[k];
}
unmanaged 约束则要求 T 是像 int、float 这类「非托管」类型,常见于和底层内存、互操作打交道的代码,初学者较少手写,但认识它有助读懂他人代码。
组合多个约束
多个约束用逗号连写,new() 必须放最后:
Service<ConsoleLogger> s = new();
s.Run();
interface ILogger { void Log(string m); }
class ConsoleLogger : ILogger
{
public void Log(string m) => Console.WriteLine(m);
}
class Service<T> where T : ILogger, new()
{
public void Run()
{
T logger = new T();
logger.Log("服务启动");
}
}
输出:服务启动。ILogger 保证能 Log,new() 保证能 new T(),二者配合才成立。
每个类型参数各自约束
当有多个类型参数,每个都可以有独立约束:
class Map<TKey, TValue>
where TKey : notnull
where TValue : class
{
public void Put(TKey key, TValue value) =>
Console.WriteLine($"{key} -> {value}");
}
var m = new Map<string, Person>();
m.Put("a", new Person());
Note给不同参数分别写
where,中间用换行分隔即可。约束越精准,泛型越安全。
约束 vs 内部强制转换
有人会想:不在 where 里约束,而是在方法里把 T 强制转成 IComparable 不也行?技术上能跑,但更糟:
T MaxBad<T>(T a, T b)
{
var ca = (IComparable)a; // 运行时才可能抛 InvalidCastException
return ca.CompareTo(b) >= 0 ? a : b;
}
不加约束,编译期放行任何 T,一旦传入不能比较的类型,错误要等到运行时才炸。加约束把检查提前到编译期,调用方写错立刻被拦下。
Warning能用约束表达的能力,就别用方法内强制转换。约束让问题在编译期暴露,转换把问题藏到运行时,后者更难排查。
什么时候该加约束、加多少
约束不是越多越好。原则是「刚好够用」:
- 需要调用某接口成员 → 加接口约束。
- 需要
new T()→ 加new()。 - 需要区分值/引用类型 → 加
struct/class。 - 不确定就先不加;调用方若真需要更宽的范围,少约束反而更灵活。
新手容易踩的坑
第一,new() 必须写在约束列表最后,写成 where T : new(), class 会编译错误。
第二,约束越多,可用类型越少。不要为了「显得严谨」加多余约束,那样会限制调用方。
第三,where T : struct 不接受可空值类型(如 int?)。需要可空时改用 where T : struct, IEquatable<T> 这类更宽松的组合,或直接用 struct?。
第四,约束解决不了「运行时类型」的灵活度问题。若业务真要任意类型,反射或 object 才是出路,泛型并非万能。
最佳实践速览
- 约束是「能力契约」,让编译器在编译期放行成员调用。
- 组合约束时
new()永远殿后。 - 优先用约束,而不是方法内强制转换。
- 约束宜少不宜多,刚好覆盖逻辑所需即可。
可空的约束变体
约束也能表达「是否允许 null」:
where T : class只接受非可空引用类型;where T : class?允许可空引用类型。where T : struct只接受非可空值类型;where T : struct?允许可空值类型(如int?)。where T : notnull一律不许null,比class更严,常用来给字典键上保险。
class Bag<T> where T : class?
{
public T? Item { get; set; }
}
这样 Bag<string> 和 Bag<string?> 都被接受,但值类型进不来。是否放行可空,取决于你的 API 想让调用方多自由。
综合例子:带约束的注册表
把 notnull 和接口约束合起来,做一个「键不可为空、值可序列化」的注册表:
var reg = new Registry<Person>();
reg.Register(new Person("小明", 18));
Console.WriteLine(reg.Describe());
interface IDescribable { string Describe(); }
class Person : IDescribable
{
private string _name;
private int _age;
public Person(string name, int age) { _name = name; _age = age; }
public string Describe() => $"{_name}, {_age}岁";
}
class Registry<T> where T : class, IDescribable, new()
{
private T _item = new();
public void Register(T item) => _item = item;
public string Describe() => _item.Describe();
}
输出:小明, 18岁。class 保证引用类型,IDescribable 保证能 Describe,new() 保证能初始化默认项——三者各管一摊,组合出精确的能力契约。
约束与重写方法
在派生类重写(override)基类泛型方法时,不能加新的约束。约束必须在最初声明的地方定死,重写只能沿用:
class Base
{
public virtual T Make<T>() where T : new() => new T();
}
class Derived : Base
{
public override T Make<T>() => new T(); // 不能再加 where T : new()
}
Note这条规则保证了「多态时行为一致」:调用方用基类引用也能放心,不用担心派生类偷偷加了更严的约束。
一个决策清单
写泛型时到底要不要约束、加哪些,按这个顺序想:
- 方法里要不要
new T()?要 → 加new()。 - 要不要调用某接口成员?要 → 加接口约束。
- 要不要访问基类成员?要 → 加基类约束。
- 要不要区分值/引用?要 → 加
struct/class。 - 键或值要不要杜绝
null?要 → 加notnull或class/struct(非?)。
其余一律不加,保持开放。约束越少,调用方越自由;约束越准,编译期保护越强。
小结
泛型约束用 where 给 T 立规矩,让编译器既能放行成员调用,又把不合适的类型挡在编译期。接口、基类、class/struct、notnull、unmanaged、new() 是最常用的几类。组合约束时记住 new() 殿后。