.NET
C#
25 February 2018

Опять про пустые перечисления в C#

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

public static bool IsNullOrEmpty<T>(this IEnumerable<T> items)
{
  return items == null || !items.Any();
}

будет достаточно, но на мой взгляд, подобный подход применим далеко не всегда.

Дело в том, что IEnumerable – это, по сути, фабрика для IIterator, но с точки зрения вызывающего кода, затраты на создание итератора совершено непредсказуемы. Ситуацию усложняет тот факт, что в C# и для списков, и для массивов, и для IEnumerable можно использовать один и тот же оператор foreach и одни и те же методы LINQ, которые скрывают создание IIterator и делают в сознании многих разработчиков неразличимыми списки, массивы, и IEnumerable. Однако же разница может быть колоссальной — при вызове с виду безобидного кода items.Any() могут происходить разные ресурсоемкие операций такие как: создание подключения и запрос к базе данных, вызов REST Api или просто множество тяжелых вычислений:

private IEnumerable<int> GetItemsDb()
{
    using (var connection = new SqlConnection("connection string"))
    {
        connection.Open();
        using (var command = new SqlCommand("SELECT Id FROM Table"))
        {
            using (SqlDataReader reader = command.ExecuteReader())
            {
                while (reader.Read())
                {
                    yield return reader.GetInt32(0);
                }
            }
        }
    }
}

private IEnumerable<int> GetItemsLinq()
{
    return Enumerable
        .Range(0, 100)
        .Reverse()
        .Select(
            i =>
            {
                Thread.Sleep(100);
                return i;
            })
        .Where(i => i < 10);
}

Но что же делать в том случае, если необходимо узнать вернет ли подобный итератор какие-нибудь элементы или нет? Например, нам нужно написать код суммирующий все элементы возвращаемые GetItemsLinq(), но если элементов нет, то код должен вернуть null:


int? result = GetSum(GetItemsLinq());
...

private static int? GetSum(IEnumerable<int> items)
{
    ...
}

Думаю, что многие бы реализовали метод GetSum следующим образом:


private static int? GetSum(IEnumerable<int> items)
{
    return items != null && items.Any() ? (int?)items.Sum() : null;
}

и… столкнулись бы с ситуаций, что GetSum(GetItemsLinq()) выполняется в течении 19,1 секунд вместо ожидаемых десяти. Дело в том, что для items.Any() необходимо перебрать 91 оригинальный элемент для того что бы понять, что на выходе что-то есть, а на каждый элемент мы тратим по 100 миллисекунд. Давайте попробуем чуть оптимизировать метод GetSum:

private static int? GetSumm(IEnumerable<int> items)
{
    var list = items as IReadOnlyCollection<int> ?? items?.ToList();

    return list != null && list.Count > 0 ? (int?)list.Sum() : null;
}

Теперь код выполняется за ожидаемые 10 секунд, но ценой выделения в памяти промежуточного буфера. А если элементов будет гораздо больше чем 10? В общем, можно еще оптимизировать:


private static int? GetSum(IEnumerable<int> items)
{
    if (items == null) return null;

    using (var enumerator = items.GetEnumerator())
    {
        if (enumerator.MoveNext())
        {
            int result = enumerator.Current;
            while (enumerator.MoveNext())
            {
                result += enumerator.Current;
            }

            return result;
        }
        return null;
    }
}

Теперь код выполняется за 10 секунд и не потребляет лишней памяти, но… писать подобный код каждый раз, когда нужно проверить наличие элементов в IEnumerable мне бы не хотелось. Можно ли этот код как-то переиспользовать? Мое предложение, это создать extension метод который бы принимал в качестве аргумента функцию, в которую мы передадим заведомо непустой IEnumerable. Предполагается что эта функция вернет результат обработки этого IEnumerable. “Заведомо непустой IEnumerable” это обертка над уже открытом итератором которая при запросе первого элемента вернет первый уже полученный элемент и затем продолжит перечисление:


public static class EnumerableHelper
{
    public static TRes ProcessIfNotEmpty<T, TRes>(
         this IEnumerable<T> source, 
         Func<IEnumerable<T>, TRes> handler, 
         Func<TRes> defaultValue)
    {
        switch (source)
        {
            case null: return defaultValue();
            case IReadOnlyCollection<T> collection:
                return collection.Count > 0 ? handler(collection) : defaultValue();
            default:
                using (var enumerator = new DisposeGuardWrapper<T>(source.GetEnumerator()))
                {
                    if (enumerator.MoveNext())
                    {
                        return handler(Continue(enumerator.Current, enumerator));
                    }

                }
                return defaultValue();                
        }
    }

    private static IEnumerable<T> Continue<T>(T first, IEnumerator<T> startedEnumerator)
    {
        yield return first;
        while (startedEnumerator.MoveNext())
        {
            yield return startedEnumerator.Current;
        }
    }

    private class DisposeGuardWrapper<T> : IEnumerator<T>
    {
       ...
    }
}


Полный исходный код тут

Используя этот вспомогательный метод, мы сможем решить нашу задачу следующим образом:

int? result = GetItemsLinq().ProcessIfNotEmpty(items=> items.Sum(), () => (int?)null);

К сожалению, у этого подхода есть один существенный недостаток, мы не можем вынести работу с items за пределы ProcessIfNotEmpty, поскольку итератор созданный для проверки наличия элементов не будет должным образом закрыт. Для предотвращения подобных случав создан класс DisposeGuardWrapper и следующий код будет выдавать исключение:

int? result = GetItemsLinq().ProcessIfNotEmpty(items=> items, () => null).Sum();

Неприятно конечно, но на мой взгляд, всяко лучше, чем тратить лишние ресурсы или каждый раз писать шаблонный код. Возможно кто-то предложит лучший вариант.

+11
8k 37
Comments 46