今天这篇文章主要分享下为什么微软团队在新的开发包上大量使用了 IReadOnlyList<T>,包括 ASP.NET Core、EF Core、Rosly、MAF、SK等。
这个习惯背后有几个层次的考量,从浅到深,这篇文章我想深入介绍下,包括它的设计动机、性能代价、协变与不变的取舍,以及一些容易被忽略的边界情况。
加起来一共三个成员: 注意泛型参数前的 这是最直观的理由。考虑下面的代码: 调用方拿到 把返回类型改成 但这一层的"防御"其实是表层的,原因有两个: 第一,它不是真正的不可变。下面这段代码完全合法: 第二,调用方能不能拿到 所以"防御外部修改"这个理由如果到此为止,会让人觉得 这一层是 .NET 团队真正在意的。 公共 API 一旦发布出去,就很难改了。如果一个属性返回 你被自己十年前写的那行 返回 调用方代码一行不改。这是面向接口编程的真正价值,远比"封装变化"这种教科书话术要具体得多。 EF Core 就是典型例子。 这一层很多人没意识到。 回到接口定义: 那个 如果你把返回类型写成 为什么 可写集合协变会破坏类型安全。而只读集合不存在 这个特性在领域建模里很有用。考虑一个简单的层次: 如果用 直觉上 场景一:返回 这里没有任何运行时开销。 场景二:foreach 遍历 这里有个坑。 对绝大多数代码这点开销不值一提。但如果是性能敏感的热路径(每秒几万次遍历)、或者大小敏感的容器(避免 GC 压力),就需要意识到这件事。这也是为什么 BCL 在性能极致的地方(比如 Span 相关 API)更倾向直接返回数组或 场景三:索引访问 每次 最后一层是哲学层面的。 注意 API 设计者通过选择不同的接口,把"我对调用方的承诺"和"我对调用方的限制"精确地表达出来。返回 我给你的是一个有序的、只读的快照。你可以用 这种契约表达不靠注释、不靠文档、不靠口头约定,靠类型本身。 返回 可以,通过 如果你想给调用方一个完全无法绕过去的只读视图(即使强转也不行),应该返回 如果你的数据需要在多线程中读取且永远不变(比如配置快照),用 什么时候不该返回 至少三种情况: 第一,结果集很大或惰性产生,返回 第二,调用方需要修改这个集合——比如 DI 容器的 第三,性能极致敏感、且数据布局是连续内存——直接返回 总结一下我对这个接口的态度。 写库代码时,公共 API 默认返回 写业务代码时,类内部用 写性能敏感代码时,停下来想一想:这个集合会被遍历多少次?热路径上的接口分派开销值不值得介意?如果答案是"非常高频",那么直接返回 最后一点:它是什么
IReadOnlyList<T> 是 .NET 4.5(2012 年)引入的接口,定义非常简短:Count、索引器、继承自 IEnumerable<T> 的 GetEnumerator()。out 关键字——它是协变的。这意味着 IReadOnlyList<Dog> 可以赋值给 IReadOnlyList<Animal>,而 List<Dog> 不能赋值给 List<Animal>(因为 List<T> 是不变的)。这个细节后面会讲到,它是一个比"防止外部修改"更深的设计动机。第一层:API 表面的"防御"
List<Order> 后,能调用 Add、Remove、Clear、Sort、Reverse——任何一个调用都会直接改动 OrderRepository 内部的状态。IReadOnlyList<Order> 之后,调用方在编译期就拿不到这些方法。IReadOnlyList<T> 是"只读视图"而不是"不可变集合"。List<T> 实现了 IReadOnlyList<T>,所以底层那个 List 还在原地,强转回去就能改。如果你想要真正的不可变,应该用 ImmutableArray<T> 或 ImmutableList<T>,但那是另一个话题。IList<T> 这种事,靠的是 API 设计者的契约表达,不是物理隔离。一个故意要搞破坏的调用方总能用反射拿到你的 _orders 字段。IReadOnlyList<T> 有点鸡肋。真正的设计动机要往下挖。第二层:留给API 演进的余地(留给别人擦shishan的余地)
List<Order>,那么:List<Order> orders = repo.GetAll();Order[]——破坏现有调用ImmutableArray<Order>——破坏现有调用LazyList<Order> 这样的自定义类型——破坏现有调用IEnumerable<Order>——破坏现有调用return _orders 锁死了。IReadOnlyList<T> 之后,内部实现可以自由更换:IEntityType.Properties 返回的是 IReadOnlyList<IProperty>,但具体实现可能是 List<IProperty>(设计时模型),也可能是 ReadOnlyCollection<IProperty> 包装(运行时模型),还可能是某个内部的高性能数组结构。EF Core 团队在不同版本之间换过多次实现,从来没有破坏过外部 API。第三层:协变带来的灵活性
out T 让接口变成协变的。这导致下面这种代码合法:List<Animal>,这段代码不成立,因为 List<T> 不变,List<Dog> 不能赋值给 List<Animal>。List<T> 不能协变?因为它是可写的。考虑:Add,协变就安全了。List<T>,最后那行会编译失败,必须显式转换、还会产生一次集合复制。用 IReadOnlyList<T> 编译器直接放行。第四层:性能上的细节
IReadOnlyList<T> 是个接口,调用应该比直接调 List<T> 慢——接口分派要查 vtable。这个开销是真实存在的,但通常无关紧要。来看几个具体场景。List<T> 实例本身_orders 是 List<Order> 类型,赋值给 IReadOnlyList<Order> 引用只是改变了引用的静态类型,对象本身一字节都没动。JIT 看到调用方使用接口方法时会做虚调用,但 .NET 的 JIT 在足够热的代码上能做 guarded devirtualization,把虚调用还原成直接调用。List<T> 的 GetEnumerator() 返回的是结构体 List<T>.Enumerator,foreach 直接调用结构体方法,无装箱、无堆分配。但通过 IReadOnlyList<T> 调用 GetEnumerator() 时,返回类型是接口 IEnumerator<T>,结构体会被装箱到堆上。ReadOnlySpan<T>,而不是 IReadOnlyList<T>。orders[i] 都是接口分派,比 List<T> 直接索引慢一些(具体多少取决于 JIT 优化)。同样,绝大多数业务代码感知不到,但写紧密循环时要心里有数。第五层:契约的精确度
List<T>、IList<T>、ICollection<T>、IReadOnlyList<T>、IReadOnlyCollection<T>、IEnumerable<T>——为什么 .NET 设计这么多接口?因为每一个都在表达不同的契约精度。IEnumerable<T>:只承诺能遍历一次,不保证 Count,不保证有序,可能是惰性的IReadOnlyCollection<T>:能遍历 + 知道 Count,但不能按下标访问,可能无序IReadOnlyList<T>:能遍历 + 知道 Count + 能按下标取,且有顺序IReadOnlyList<T> 隐含了顺序保证——既然能按下标 0、1、2 取,那么"第 0 个元素是什么"必须有定义。这就是为什么 HashSet<T> 实现 IReadOnlyCollection<T> 但不实现 IReadOnlyList<T>:哈希集合没有顺序概念,强行实现索引器会违反契约直觉。IReadOnlyList<T> 等于在说:Count 看大小,可以用 [i] 取任意位置,可以遍历。但你不能修改它,也不要假设修改会被允许。需要注意的边界情况
IReadOnlyList<T> 时,调用方能不能拿到具体类型?is 模式匹配或强制转换。但这是调用方的责任和风险,API 设计者不需要为此负责。ReadOnlyCollection<T> 和 IReadOnlyList<T> 是什么关系?ReadOnlyCollection<T>(注意不是接口)是 .NET 2.0 就有的具体类,包装一个 IList<T> 提供只读视图。它实现了 IReadOnlyList<T>。new ReadOnlyCollection<T>(_orders)。但代价是多一个对象、调用要经过包装。绝大多数场景没必要这么做。IReadOnlyList<T> 和 ImmutableArray<T> 有什么差别?IReadOnlyList<T> 是只读视图,底层数据可能被持有原始引用的人改。ImmutableArray<T> 是真正不可变的——任何修改操作(Add、Remove)都返回新实例,原实例永远不变。ImmutableArray<T>。如果只是想表达"我给你一个看的窗口",用 IReadOnlyList<T>。IReadOnlyList<T>?IEnumerable<T> 更合适。强制 ToList() 会触发不必要的物化。IServiceCollection,整个 API 的意义就是让调用方添加东西。ReadOnlySpan<T> 或 ReadOnlyMemory<T>,零接口分派、零装箱。实践态度
IReadOnlyList<T>。这是 .NET 团队总结出来的最佳实践,没有理由不遵循。如果不确定该选哪个集合接口,从这个开始基本不会错。List<T> 没问题,对外暴露的属性和方法尽量用 IReadOnlyList<T>。这点小代价能换来很大的演进余地。List<T>、T[] 或 ReadOnlySpan<T> 是合理的——IReadOnlyList<T> 不是教条,是工程取舍。IReadOnlyList<T> 解决的是 API 设计层面的问题,不是数据安全问题。如果你需要的是真正不可变的数据,用 ImmutableArray<T>,别指望 IReadOnlyList<T> 能挡住所有人。