Введение: ловушка, в которую попадались многие
Представьте ситуацию: вы пишете приложение для онлайн-библиотеки. Вам нужен собственный класс 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, вы рискуете сломать внутреннюю логику, которая от этого метода зависит.
Проблема №1: Нарушение Liskov Substitution Principle (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 этого не предусматривает. Вы просто сломаете контракт.
Проблема №2: Нарушение инкапсуляции
Наследуя от List<T>, вы открываете весь его публичный API наружу. Ваш класс Library получит методы Sort, BinarySearch, Reverse, Insert и т.д. Но что, если вы хотите, чтобы книги всегда были отсортированы по автору? Пользователь вашего класса сможет вызвать library.Reverse() и сломать инвариант.
Вы не сможете это предотвратить, не переписав все методы — а их десятки. Это нарушение инкапсуляции: вы выставляете наружу внутреннюю реализацию, которую не можете контролировать.
Заглянем под капот: как устроен List<T> внутри
Чтобы понять, почему наследование опасно, давайте посмотрим, как List<T> работает на самом деле. Это не просто «коробка с элементами».
Внутренний массив и ёмкость
List<T> хранит элементы в обычном массиве T[] _items. Когда массив заполняется, List создаёт новый массив в два раза больше (обычно) и копирует туда все элементы. Это дорогая операция (O(n)).
Если вы унаследуете и добавите свою логику в Add, вы можете случайно вызвать эту операцию слишком часто или, наоборот, не вызвать её вовсе, что приведёт к IndexOutOfRangeException.
Невиртуальные методы и производительность
Разработчики .NET намеренно сделали большинство методов List<T> невиртуальными. Почему? Производительность. Виртуальные вызовы (через vtable) медленнее прямых. List<T> — это класс для работы с тысячами элементов, и каждая микросекунда на счету.
Когда вы наследуете и «переопределяете» метод через new, вы создаёте два разных метода: один для работы через ссылку на базовый класс, другой — через ссылку на ваш. Это приводит к путанице и багам.
Альтернативы: что использовать вместо наследования?
Хорошая новость: у нас есть как минимум три правильных пути. Выбор зависит от вашей задачи.
Вариант 1: Композиция (рекомендуется)
Лучший способ расширить функциональность — включить List<T> внутрь вашего класса, а не наследовать от него. Это называется композиция вместо наследования.
public class Library
{
private readonly List<Book> _books = new List<Book>();
public void Add(Book book)
{
// Ваша валидация
if (string.IsNullOrEmpty(book.Title))
throw new ArgumentException("Название книги не может быть пустым");
_books.Add(book);
}
public Book FindByAuthor(string author)
{
return _books.FirstOrDefault(b => b.Author == author);
}
// Вы сами решаете, какие методы открывать
public IEnumerable<Book> GetAll() => _books.AsReadOnly();
}
Плюсы: полный контроль над API, инкапсуляция, соблюдение LSP. Вы не обязаны предоставлять Reverse, Sort и другие методы, если они не нужны.
Вариант 2: Наследование от Collection<T>
Если вам всё-таки нужно