首页 / C# 入门教程 / 泛型接口与泛型委托

C# 入门教程

泛型接口与泛型委托

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

C#C# 入门教程编程语言泛型泛型接口泛型委托

65. 泛型接口与泛型委托

本节目标:学完你能定义带类型参数的接口和委托,并理解它们如何复用。

接口(interface)和委托(delegate)也能加泛型。一旦加上 <T>,「契约」和「回调」就不再是 object 的天下,类型安全贯穿到底。

泛型接口是什么

你可能早就在用 IEnumerable<T>——它本身就是泛型接口。它的定义长这样:

public interface IEnumerable<out T>
{
    IEnumerator<T> GetEnumerator();
}

尖括号里的 T 表示「这个接口处理的元素类型」。实现它时,要填具体类型。

自定义一个泛型接口

假设我们要一个通用的「仓库」契约,能存能取某种实体:

IRepository<Person> repo = new PersonRepository();
repo.Add(new Person("小明", 18));
Console.WriteLine(repo.Get(0).Name);

interface IRepository<T>
{
    void Add(T item);
    T Get(int index);
}

class PersonRepository : IRepository<Person>
{
    private List<Person> _list = [];
    public void Add(Person item) => _list.Add(item);
    public Person Get(int index) => _list[index];
}

class Person
{
    public string Name { get; }
    public int Age { get; }
    public Person(string name, int age) { Name = name; Age = age; }
}

输出:小明IRepository<T> 把「操作哪种数据」推迟到实现时决定。换 Product 也一样套用,无需重写契约。

Tip

接口泛型化后,实现类要么固定 T(如 PersonRepository),要么自己也泛型化 class Repo<T> : IRepository<T>。后者更灵活。

实现类自己也可以是泛型

当仓库要服务任意类型,让实现类也带上 T,一份代码通吃:

var repo = new GenericRepo<Person>();
repo.Add(new Person("小红", 20));
Console.WriteLine(repo.Get(0).Name);

class GenericRepo<T> : IRepository<T>
{
    private List<T> _list = [];
    public void Add(T item) => _list.Add(item);
    public T Get(int index) => _list[index];
}

输出:小红GenericRepo<T> 同时满足 IRepository<T>,无论 TPersonProduct 还是别的,逻辑都复用同一份。

IComparer:自定义排序规则

IComparer<T> 是标准库里的泛型接口,用来告诉 Sort「按什么比」。下面按年龄排序:

List<Person> people =
[
    new Person("小明", 18),
    new Person("小红", 20),
    new Person("小刚", 16),
];
people.Sort(new AgeComparer());

foreach (var p in people)
{
    Console.WriteLine($"{p.Name}: {p.Age}");
}

class AgeComparer : IComparer<Person>
{
    public int Compare(Person x, Person y) => x.Age.CompareTo(y.Age);
}

class Person
{
    public string Name { get; }
    public int Age { get; }
    public Person(string name, int age) { Name = name; Age = age; }
}

输出:

小刚: 16
小明: 18
小红: 20

Compare 返回负数、零、正数,分别表示「小于、等于、大于」。这就是 IComparer<T> 的契约。

泛型委托:复用现成的签名

第 55 章讲过内置泛型委托。这里看它如何「复用」:同一个 Func<int,int,int> 既能做加法也能做乘法。

Func<int, int, int> add = (a, b) => a + b;
Func<int, int, int> mul = (a, b) => a * b;

Console.WriteLine(add(3, 4));
Console.WriteLine(mul(3, 4));

输出:

7
12

不用为每种运算新声明委托类型,Func/Action 一套签名反复用。

用泛型委托做回调

泛型委托常作为参数,把「一段逻辑」传进方法。下面把转换函数传给 Transform

int[] numbers = [1, 2, 3];
Transform(numbers, x => x * x);

void Transform(int[] arr, Func<int, int> transform)
{
    foreach (var n in arr)
    {
        Console.Write(transform(n) + " ");
    }
    Console.WriteLine();
}

输出:1 4 9。这里 Func<int,int> 是泛型委托,充当「可传入的算法」。

Note

Action<T> 无返回值,Func<T,TResult> 有返回值。LINQ 里大量方法接收这类泛型委托作为回调,例如 WhereFunc<T,bool>

自定义泛型委托

虽然优先用内置委托,但理解自定义写法也有必要:

delegate TResult Calculator<T1, T2, TResult>(T1 a, T2 b);

Calculator<int, int, int> sum = (x, y) => x + y;
Console.WriteLine(sum(2, 5));

输出:7。现代 C# 更推荐直接写 Func,但老代码和某些 API 仍见自定义泛型委托。

协变与逆变:接口里的 out / in

注意 IEnumerable<out T> 那个 out。它是「协变(covariance)」标记:如果 DogAnimal,那么 IEnumerable<Dog> 可当作 IEnumerable<Animal> 用。反过来,委托 Action<in T> 是「逆变(contravariance)」,允许「能处理更通用类型」的委托赋给「要求更具体类型」的变量。

IEnumerable<Dog> dogs = [new Dog(), new Dog()];
IEnumerable<Animal> animals = dogs;   // 协变:Dog 序列当 Animal 序列用
foreach (var a in animals) a.Speak();

class Animal { public virtual void Speak() => Console.WriteLine("动物叫"); }
class Dog : Animal { public override void Speak() => Console.WriteLine("汪汪"); }

输出:汪汪 汪汪out/in 让泛型接口和委托在类型层级上更灵活,下一章会专门展开。

Note

协变用 out(只读、作为返回值),逆变用 in(只写、作为参数)。方向写反编译器会报错。日常直接用 IEnumerable<T>Func/Action 即可免费享受这些便利。

泛型接口 vs 泛型类:怎么选

两者常配合,但层次不同:

关注点泛型接口泛型类
职责定义「契约」提供「实现」
典型用途抽象仓储、比较器、可枚举写具体可复用的容器或逻辑
多态不同实现可互换一种实现

经验:先想「要不要让多种实现可以替换、可被 mock 测试」,要的话就抽泛型接口;否则直接写泛型类。

Warning

别为了「看起来更通用」就无脑抽泛型接口。只有当确实有多种实现要互换、或可 mock 测试时,它才值回票价;否则多一层接口只是多一层阅读负担。先写具体实现,等真的出现第二种实现再抽接口也不迟。

新手容易踩的坑

第一,实现泛型接口时类型必须一致。class Repo : IRepository<Person> 不能写成 IRepository<string>,否则不算实现。

第二,泛型接口和泛型类可以同名不同参数,例如 IEnumerableIEnumerable<T>,后者继承前者,别混淆。

第三,泛型委托做参数时,注意协变逆变(见上)。直接把 Func<Student,bool> 赋给 Func<Person,bool> 变量可能编译不过,要看 T 的方向。

第四,自定义泛型委托已逐渐被 Func/Action 取代。新代码优先用内置委托,少自己 delegate

最佳实践速览

  • 需要多种实现可替换、可测试,抽泛型接口(如 IRepository<T>)。
  • 回调与算法优先用内置 Func/Action,少写自定义 delegate
  • 实现泛型接口,类型参数务必对齐;要通用就让实现类也泛型化。
  • 直接享受 IEnumerable<T> 等标准接口的协变,少自己造轮子。

泛型接口带多个类型参数

接口也能带多个类型参数,描述「键—值」「请求—响应」这类成对关系:

IPair<int, string> p = new IntStringPair(1, "一");
Console.WriteLine(p.Describe());

interface IPair<TKey, TValue>
{
    TKey Key { get; }
    TValue Value { get; }
    string Describe();
}
class IntStringPair : IPair<int, string>
{
    public int Key { get; }
    public string Value { get; }
    public IntStringPair(int key, string value) { Key = key; Value = value; }
    public string Describe() => $"{Key}={Value}";
}

输出:1=一IPair<TKey, TValue> 把「键类型」和「值类型」分别参数化,比塞进单个 T 表达力更强。

泛型委托在 LINQ 中无处不在

LINQ 的标准方法几乎全是「接收泛型委托、返回新序列」的泛型方法。把上节的 Func 串起来,就能写出流式管道:

var result = Numbers()
    .Where(n => n % 2 == 0)     // Func<int,bool>
    .Select(n => n * n)         // Func<int,int>
    .Take(3);

foreach (var n in result) Console.WriteLine(n);

IEnumerable<int> Numbers()
{
    for (int i = 1; i <= 10; i++) yield return i;
}

输出:4 16 36WhereFunc<int,bool> 做筛选,SelectFunc<int,int> 做映射,Take 限量。没有泛型委托,这套组合拳就写不出来。

Tip

当你要把「一段逻辑」作为参数传进方法时,第一反应应是 Func/Action,而不是专门 delegate。泛型委托把「算法」变成可传递的值,是 LINQ 的基石。

用泛型接口给业务解耦

泛型接口最大的价值,是让上层代码只依赖「契约」,不依赖「具体实现」。下面用仓储接口隔离数据来源:

IRepository<Person> repo = new MemoryRepository();
repo.Add(new Person("小刚", 16));
Console.WriteLine(repo.Get(0).Name);

interface IRepository<T>
{
    void Add(T item);
    T Get(int index);
}
class MemoryRepository : IRepository<Person>
{
    private List<Person> _list = [];
    public void Add(Person item) => _list.Add(item);
    public Person Get(int index) => _list[index];
}
class Person
{
    public string Name { get; }
    public Person(string name) => Name = name;
}

输出:小刚。哪天要把存储换成数据库,只要再写一个 DbRepository : IRepository<Person>,上层一行不用改。泛型接口在这里既给了类型安全,又给了「可替换实现」的弹性。

Action 与 Func 怎么选

两者都是内置泛型委托,差别只在「要不要返回值」:

  • 只要「做一件事、不返回」→ Action<T>
  • 需要「算出结果返回」→ Func<T,TResult>
Each(["a", "b"], s => Console.WriteLine($"项:{s}"));

void Each<T>(IEnumerable<T> items, Action<T> action)
{
    foreach (var item in items) action(item);
}

输出:

项:a
项:b

Action<T> 专注副作用,不需要 return。选哪个,看你的回调要不要产出值——能想清这一点,泛型委托就不再是模糊概念。

小结

泛型接口让「契约」也能参数化类型,典型如 IRepository<T>IComparer<T>。泛型委托(Func/Action)让回调与算法可复用。两者把类型安全从类一路延伸到接口和函数指针层面,并借协变/逆变在类型层级间灵活转换。