Avatar ·

Почему не стоит наследовать от List<T> в C#? Альтернативы и лучшие практики

Всем привет! Я начинающий C# разработчик и столкнулся с ситуацией, где мне нужно создать свой собственный класс, который ведет себя как список, но с дополнительной логикой. Например, мне нужно хранить список заказов, и я хочу, чтобы при добавлении нового заказа автоматически пересчитывалась общая сумма. Я подумал, что самый простой способ — это унаследовать от `List` и добавить свои методы. Вот что у меня получилось: ```csharp public class OrderList : List { public decimal TotalSum { get; private set; } public new void Add(Order order) { base.Add(order); TotalSum += order.Price; } public new bool Remove(Order order) { bool result = base.Remove(order); if (result) TotalSum -= order.Price; return result; } } ``` Вроде бы работает, но я прочитал много статей, где пишут, что наследовать от `List` — это плохая практика. Почему? Ведь это так удобно! Если нельзя наследовать, то как правильно реализовать такую функциональность? Можно ли использовать композицию или что-то другое? Буду благодарен за развернутый ответ с примерами. <<>>

Почему наследование от List — это антипаттерн

Ваш пример отлично иллюстрирует типичную ошибку начинающих разработчиков. На первый взгляд, наследование от List кажется логичным: вы получаете готовую функциональность списка (индексация, сортировка, поиск) и просто добавляете свою логику. Однако на практике это приводит к нескольким серьезным проблемам.

Основные проблемы наследования от List

  • Нарушение принципа подстановки Лисков (LSP). Вы не можете просто заменить List на ваш класс OrderList в любом месте кода. Например, если кто-то вызовет orderList.AddRange(orders), то ваш пересчет суммы не сработает, так как AddRange не переопределен (вы переопределили только Add и Remove).
  • Неполное переопределение. List имеет множество методов: Insert, RemoveAt, Clear, AddRange, RemoveAll, индексатор this[] и другие. Чтобы ваш класс работал корректно, вам пришлось бы переопределить все методы, изменяющие коллекцию. Это огромный объем работы, и вы гарантированно что-то упустите.
  • Проблемы с полиморфизмом. Если метод принимает List, а вы передаете ему OrderList, то внутри метода будут вызываться методы базового класса (List), а не ваши переопределенные. Использование new (как в вашем примере) не решает проблему — это просто скрытие метода, а не переопределение.
  • Нарушение инкапсуляции. List предоставляет доступ к внутренним данным через индексатор и множество методов. Вы не можете контролировать, как именно будут изменяться данные. Пользователь вашего класса может напрямую изменить элемент через индекс: orderList[0] = new Order() — и ваш TotalSum станет неактуальным.

Правильное решение: композиция и Collection

Вместо наследования от List используйте композицию — включите список как приватное поле. А если вам нужна коллекция с дополнительной логикой, наследуйте от Collection или BindingList. Эти классы специально спроектированы для расширения и предоставляют виртуальные методы для переопределения.

Вот как должен выглядеть ваш класс правильно:

public class OrderCollection : Collection<Order>
{
    public decimal TotalSum { get; private set; }

    protected override void InsertItem(int index, Order item)
    {
        base.InsertItem(index, item);
        TotalSum += item.Price;
    }

    protected override void RemoveItem(int index)
    {
        TotalSum -= this[index].Price;
        base.RemoveItem(index);
    }

    protected override void SetItem(int index, Order item)
    {
        TotalSum -= this[index].Price; // вычитаем старый элемент
        TotalSum += item.Price;        // добавляем новый
        base.SetItem(index, item);
    }

    protected override void ClearItems()
    {
        TotalSum = 0;
        base.ClearItems();
    }
}

Почему это лучше?

  • Полный контроль. Все методы, изменяющие коллекцию, проходят через защищенные виртуальные методы InsertItem, RemoveItem, SetItem и ClearItems. Вы переопределяете их один раз и гарантируете, что логика пересчета суммы сработает для любого изменения.
  • Соблюдение LSP. Collection реализует IList, ICollection и IEnumerable. Вы можете передавать ваш класс в любой метод, ожидающий эти интерфейсы, и все будет работать корректно.
  • Инкапсуляция. Вы контролируете, как изменяются данные. Никто не сможет "обойти" вашу логику, так как все пути изменения коллекции ведут к вашим переопределенным методам.

Альтернатива: если вам нужен именно List

Если вам критически важна производительность List (например, для очень больших коллекций) или вам нужны методы вроде Sort() и BinarySearch(), то используйте композицию с явной реализацией интерфейса:

public class OrderListWrapper : IList<Order>
{
    private readonly List<Order> _list = new List<Order>();
    public decimal TotalSum { get; private set; }

    // Явная реализация IList<T>
    public void Add(Order item)
    {
        _list.Add(item);
        TotalSum += item.Price;
    }

    public bool Remove(Order item)
    {
        if (_list.Remove(item))
        {
            TotalSum -= item.Price;
            return true;
        }
        return false;
    }

    // Остальные методы IList<T> реализуются аналогично
    // ...
}

Этот подход дает вам полный контроль, но требует больше кода. Для большинства сценариев Collection — идеальный выбор.

<<>>

Дополнительный совет: используйте ObservableCollection для UI

Если вы разрабатываете WPF, MAUI или другое приложение с привязкой данных (data binding), то лучшим выбором будет ObservableCollection. Она уже предоставляет события об изменении коллекции (CollectionChanged), которые автоматически обновляют UI.

Вы можете просто подписаться на это событие и обновлять свою сумму:

public class OrderViewModel
{
    public ObservableCollection<Order> Orders { get; } = new();
    public decimal TotalSum => Orders.Sum(o => o.Price);

    public OrderViewModel()
    {
        Orders.CollectionChanged += OnOrdersChanged;
    }

    private void OnOrdersChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        // Просто уведомляем UI об изменении свойства TotalSum
        OnPropertyChanged(nameof(TotalSum));
    }
}

Это самый простой и правильный способ для UI-приложений. Не нужно изобретать велосипед — используйте готовые механизмы .NET.

Краткое резюме

  • Не наследуйте от List — это нарушает ООП-принципы и ведет к багам.
  • Используйте Collection, если вам нужна собственная логика при изменении коллекции.
  • Используйте ObservableCollection, если работаете с UI и привязкой данных.
  • Используйте композицию (обертку вокруг List), если вам критична производительность и нужны специфические методы.