【C#】IList 与 List 到底有什么区别?为什么接口和实现类不能混用?

很多 C# 开发者都有过灵魂拷问:明明 List<T> 就能搞定一切集合操作,为什么官方框架、开源库、接口规范都偏爱 IList<T>?为什么 List<List<int>> 不能赋给 IList<IList<int>>?本文从 .NET 设计规范、面向接口思想、泛型不变性规则、实战场景四个维度,把两者的本质区别与避坑指南一次性讲透。

一、先把本质说透:接口 vs 实现类,根本不是一类东西

先抛开所有复杂语法,用最直白的话定义两个核心类型。

1. IList<T>:是「接口」,只定规矩,不干活

IList<T> 是集合接口,它的作用只有一个:规定一个”有序可索引集合”必须具备哪些能力

它只定义行为规范,不提供任何一行具体的实现代码:

  • 可以通过下标索引获取/修改元素 list[index]
  • 可以获取元素总数量 Count
  • 可以添加、插入、删除元素
  • 可以遍历元素

它完全不关心:这个集合底层是动态数组、链表、还是只读容器,只要你符合我的规矩,你就可以是 IList<T>

核心特性:接口不能被实例化。 下面这行代码永远编译报错:

1
2
// 错误!接口是抽象约定,不能直接 new 实例化
IList<int> list = new IList<int>();

2. List<T>:是「实现类」,是干活的工具

List<T> 是动态数组的具体实现,它完完整整实现了 IList<T> 接口的所有规范,同时额外提供了超多实用方法:

  • 自带 Sort()Reverse()Find()AddRange() 等数组专属方法
  • 底层用动态数组实现,读写效率极高,是日常开发最常用的集合
  • 可以直接 new 实例化,拿来就能用

它们的关系一句话总结:

List<T>IList<T> 的”亲生实现类”,IList<T>List<T> 必须遵守的”开发规范”。

所以下面的代码完全合法,是 C# 最标准的写法:

1
2
// 左边是接口规范(约定能力),右边是实现类(具体干活)
IList<string> list = new List<string>();

二、核心灵魂问题:既然 List 能用,为什么非要用 IList?

这是 90% 的开发者都想不明白的问题:直接用 List<T> 写代码更简单,方法更多,为什么框架、开源项目、规范文档都要求用 IList<T> 做返回值和参数?

这里就是 C# 开发最核心的设计思想:面向接口编程,而不是面向实现编程

我们分 4 个实战维度,讲清楚 IList<T> 不可替代的价值。

1. 对外提供「最小够用约定」,不暴露多余能力

写方法、写接口时,你只需要告诉调用方”这个集合能做什么”,不需要告诉调用方”我是怎么实现的”。

举个最直白的例子:

你写一个方法返回一批数据,只需要调用方”读取、遍历、按索引取值”,不需要让调用方随意修改、添加、删除数据。

  • 如果返回 List<T>:调用方可以随意调用 Add()Clear()Sort(),你完全无法控制,极易引发业务 bug
  • 如果返回 IList<T>:你可以随时返回一个只读 IList 实现,直接禁用修改能力,调用方只能按照你的约定使用集合

接口的核心作用:收缩权限,只给调用方必要的能力,杜绝不可控的风险。

2. 极致的扩展性:底层实现随便换,对外代码完全不用改

这是大型项目、框架设计的核心命脉:对内修改实现,对外完全兼容

举个实战场景,你最开始写业务,用 List<T> 返回数据:

1
public List<User> GetUserList() { ... }

项目迭代后,你需要把集合改成线程安全的集合、只读集合、或者自定义的分页集合。只要你改了返回值类型,所有调用这个方法的业务代码全都会编译报错,全部要修改。

但如果你的方法签名是:

1
public IList<User> GetUserList() { ... }

不管你内部换成 List<T>ReadOnlyCollection<T>、还是自定义集合,只要实现了 IList 接口,方法签名完全不用改,所有调用方代码零改动

这就是面向接口编程的核心:约定不变,实现随意换,项目永远稳定兼容。

3. 通用性极强,兼容所有符合规范的集合

IList<T> 是 C# 中”有序可索引集合”的通用协议,不只是 List<T> 实现了这个接口:

  • 数组 T[],完全实现了 IList<T> 接口
  • ReadOnlyCollection<T> 只读集合,实现了 IList<T>
  • WPF 的 ObservableCollection<T>,实现了 IList<T>
  • 自定义的业务集合,只要实现接口,也属于 IList<T>

如果你的方法参数用 IList<T>,上面所有类型的集合都可以直接传入,不用做任何类型转换。如果参数限定死 List<T>,传入数组、只读集合都会直接报错,通用性极差。

4. 符合 .NET 官方设计规范,杜绝团队协作坑

微软官方的 .NET 框架设计规范明确规定:

公有方法、属性的返回值、参数类型,优先使用集合接口IListIEnumerableICollection),而不是具体实现类。

我们日常用的所有 .NET 原生库、ASP.NET Core、EF Core,全都是严格遵守这个规范。这也是为什么你在 LeetCode 上会遇到方法要求返回 IList<IList<int>>——这不是刁难人,是标准的框架规范。

三、最容易踩的致命坑:泛型接口的不变性

这是 C# 泛型最经典、最高频的坑:

1
2
3
4
// 错误代码,编译直接报错
List<List<int>> results = new List<List<int>>();
// 方法要求返回 IList<IList<int>>
return results;

报错信息:无法将 List<List<int>> 隐式转换为 IList<IList<int>>

很多同学懵了:List<int> 可以转 IList<int>,为什么外层套一层泛型,就不能转了?

C# 泛型的「不变性」规则

List<A>List<B> 即使 A 可以隐式转换为 B,也绝对不能自动转换。

放到我们的场景里:

  • List<int> → 可以隐式转为 IList<int>(子类转父接口,安全)
  • List<List<int>> → 绝对不能自动转为 List<IList<int>>
  • 更不能直接转为 IList<IList<int>>

为什么 C# 要禁止这个转换?为了类型安全

如果允许 List<List<int>> 转为 IList<IList<int>>,那么你就可以往集合里添加任何实现了 IList<int> 的类型,比如数组、只读集合:

1
2
3
4
// 假设转换被允许(实际不允许)
IList<IList<int>> outer = results;
outer.Add(new int[] { 1, 2, 3 }); // 塞入数组
outer.Add(new ReadOnlyCollection<int>(...)); // 塞入只读集合

但你的底层集合 results 实际只能存放 List<int> 类型,强行放入其他类型,会直接导致运行时崩溃。C# 直接在编译期就禁止了这种危险转换,杜绝运行时崩溃。

正确写法(永远不会错的标准规范)

方法要求返回 IList<IList<int>>,你的集合定义必须写成:

1
2
3
4
5
6
7
8
9
10
// 标准正确写法
// 外层:用 List 实例化(干活)
// 内层:用 IList 接口(符合返回值规范)
var results = new List<IList<int>>();

// 添加元素时,直接传入 List<int> 实例(自动隐式转换为 IList<int>)
results.Add(new List<int> { 1, 2, 3 });

// 直接 return,完全兼容,编译零报错
return results;

一句话记忆:外层容器用实现类,内层元素用接口,完美匹配返回值类型。

补充:为什么 IEnumerable<T> 却可以协变?

细心的你可能发现:IEnumerable<Derived> 是可以隐式转为 IEnumerable<Base> 的。这是因为 IEnumerable<T>out T 标记了协变——它只”输出”元素,不”输入”元素,所以类型安全。

IList<T> 既能读又能写(T this[int] 的 setter、Add(T)),既是输入又是输出,因此只能是不变(invariant)——不能协变也不能逆变。

四、实战开发:什么时候用 IList?什么时候用 List?

给大家总结一套零出错、符合规范、直接照搬的开发准则。

✅ 必须用 IList<T> 的场景

  1. 公有方法的参数、返回值、公有属性:只要是对外暴露的成员,一律用接口,禁止直接暴露 List 实现类
  2. 依赖注入、接口定义、抽象规范:只约定能力,不绑定实现,保证扩展性和兼容性
  3. 需要兼容多种集合类型(数组、只读集合、自定义集合)
  4. 需要限制集合权限,不希望调用方随意修改集合

✅ 只能用 List<T> 的场景

  1. 方法内部私有变量、局部临时集合:内部自己用的集合,直接 new List<T>,能用 SortAddRangeFind 等专属方法,写代码效率最高
  2. 需要高频操作数组、排序、批量添加、查找元素List<T> 是动态数组,读写性能最优,没有接口的额外开销
  3. 确定集合永远不会换实现,就是普通动态数组使用

❌ 绝对禁止的错误写法

  1. 公有方法返回 List<T>,暴露实现类,破坏扩展性
  2. 泛型嵌套集合混用接口和实现,比如 List<List<T>> 对接 IList<IList<T>>
  3. IList<T> 声明局部变量,还用不了 List 的专属方法,画蛇添足

五、终极总结:3 句话彻底记住这个知识点

  1. IList<T> 是接口,定规范、限权限、保兼容,不能实例化;List<T> 是实现类,能实例化、能干活、方法全,是 IList 的具体实现。
  2. 对外暴露用接口(IList),对内实现用类(List),这是 .NET 官方标准设计规范。
  3. 泛型嵌套场景,内层泛型必须和返回值接口完全匹配List<IList<T>> 可以直接返回 IList<IList<T>>List<List<T>> 永远不兼容。

以后遇到接口和实现类混用、泛型转换报错,直接对照这篇文章,所有问题都能一次性解决。