泛型接口与泛型委托
本教程共 100 篇 · 第 65 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
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>,无论 T 是 Person、Product 还是别的,逻辑都复用同一份。
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 里大量方法接收这类泛型委托作为回调,例如Where收Func<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)」标记:如果 Dog 是 Animal,那么 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>,否则不算实现。
第二,泛型接口和泛型类可以同名不同参数,例如 IEnumerable 与 IEnumerable<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 36。Where 收 Func<int,bool> 做筛选,Select 收 Func<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)让回调与算法可复用。两者把类型安全从类一路延伸到接口和函数指针层面,并借协变/逆变在类型层级间灵活转换。