引言:许多人曾掉入的陷阱
想象一下这个场景:您正在编写一个在线图书馆应用程序。您需要一个自定义的Library类来存储书籍,并能够对它们进行排序、搜索和统计。首先想到的就是继承List<Book>。毕竟它已经拥有了一切:Add、Remove、索引、搜索。这很合理,对吧?看起来是的。
但有经验的C#开发者会告诉您:“永远不要继承List<T>”。这个问题在Stack Overflow上获得了数千次浏览,这并非偶然。在看似简单的背后,隐藏着严重的架构、性能问题,最重要的是,违反了面向对象设计原则。
在本文中,我们将深入List<T>的内部,分析为什么继承它是一种反模式,以及应该用什么来代替它。准备好了吗?我们开始吧!
从设计角度看,List<T>有什么问题?
让我们从最重要的开始:List<T>的设计初衷并非作为层次结构的基础,而是作为动态数组的具体、高性能实现。它的公共API是一组用于操作集合的方法,而不是用于扩展的契约。
看看它在.NET源码中的声明(简化版):
public class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable{ private T[] _items; private int _size; public void Add(T item) { /* ... */ } public bool Remove(T item) { /* ... */ } // ... 几十个其他方法}请注意:List<T>没有被标记为abstract。它没有提供可供重写的虚拟方法(除了少数几个,后面会提到)。这意味着您无法安全地更改其行为。如果您重写了Add,您就有可能破坏依赖于此方法的内部逻辑。
问题一:违反里氏替换原则(LSP)
里氏替换原则指出:“子类应该补充而非替换父类的行为”。当您继承List<T>时,您承诺您的类可以在任何期望使用List<T>的地方使用。但这很少能实现。
示例:您创建了一个ReadOnlyLibrary : List<Book>类,并重写了Add方法,使其抛出异常:
public class ReadOnlyLibrary : List<Book>{ public new void Add(Book book) { throw new InvalidOperationException("集合是只读的!"); }}
// 在代码的某处:List<Book> list = new ReadOnlyLibrary();list.Add(new Book()); // 哎呀!不会抛出任何异常!// 因为new并没有重写方法,而是隐藏了方法。// 实际调用的是List.Add,而不是我们的方法。即使使用override(而List.Add不是虚拟的),您也无法禁止添加——IList接口并不允许这样做。您只会破坏契约。
问题二:违反封装原则
通过继承List<T>,您将其整个公共API暴露给了外部。您的Library类将获得Sort、BinarySearch、Reverse、Insert等方法。但是,如果您希望书籍始终按作者排序呢?您的类的用户可以调用library.Reverse()并破坏这个不变量。
如果不重写所有方法(有几十个),您将无法阻止这种情况。这违反了封装原则:您暴露了无法控制的内部实现。
深入内部:List<T>的内部结构
为了理解