首页 / C# 入门教程 / 泛型约束

C# 入门教程

泛型约束

本教程共 100 篇 · 第 64 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程编程语言泛型泛型约束where

64. 泛型约束

本节目标:学完你能用 where 给类型参数加约束,并在泛型内部放心调用对象成员。

上一章的 Max 用了 Comparer<T>.Default 来比较。其实更规范的做法是告诉编译器:「我的 T 一定能比较」。这就需要泛型约束(constraints)。

为什么需要约束

没有约束时,编译器把 T 当成最底层的 object,你只能调用 object 上有的方法。一旦想写 a.CompareTo(b)、或 new T(),编译器就报错:它不知道 T 到底有没有这些能力。

约束的作用,就是给 T 立规矩:「只接受满足某条件的类型」。这样一来,编译器放行你调用对应成员,类型安全也更强。

常用约束一览

约束含义
where T : classT 必须是引用类型
where T : structT 必须是非可空值类型
where T : new()T 必须有公开无参构造函数
where T : 基类T 必须继承自某基类
where T : 接口T 必须实现某接口
where T : notnullT 不可为 null
where T : unmanagedT 必须是非托管类型(用于底层互操作)
Note

还有可空变体:where T : class? 允许可空引用类型,where T : struct? 允许可空值类型(如 int?)。notnullclass 更严,连可空引用也被挡掉。

接口约束:解锁成员调用

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; } = "";
}

输出:小明classnew() 常组合使用:既要引用类型,又要能实例化。

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 是像 intfloat 这类「非托管」类型,常见于和底层内存、互操作打交道的代码,初学者较少手写,但认识它有助读懂他人代码。

组合多个约束

多个约束用逗号连写,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 保证能 Lognew() 保证能 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 保证能 Describenew() 保证能初始化默认项——三者各管一摊,组合出精确的能力契约。

约束与重写方法

在派生类重写(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

这条规则保证了「多态时行为一致」:调用方用基类引用也能放心,不用担心派生类偷偷加了更严的约束。

一个决策清单

写泛型时到底要不要约束、加哪些,按这个顺序想:

  1. 方法里要不要 new T()?要 → 加 new()
  2. 要不要调用某接口成员?要 → 加接口约束。
  3. 要不要访问基类成员?要 → 加基类约束。
  4. 要不要区分值/引用?要 → 加 struct/class
  5. 键或值要不要杜绝 null?要 → 加 notnullclass/struct(非 ?)。

其余一律不加,保持开放。约束越少,调用方越自由;约束越准,编译期保护越强。

小结

泛型约束用 whereT 立规矩,让编译器既能放行成员调用,又把不合适的类型挡在编译期。接口、基类、class/structnotnullunmanagednew() 是最常用的几类。组合约束时记住 new() 殿后。