Почему наследовать от List<T> в C# — плохая идея: разбор

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: ловушка, в которую попадались многие

Представьте ситуацию: вы пишете приложение для онлайн-библиотеки. Вам нужен собственный класс 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>

Если вам всё-таки нужно

Блоги

Рекомендаций по книгам