В каких случаях оптимизируется IEnumerable <T>.Count? - PullRequest
13 голосов
/ 02 февраля 2010

Использование отражателя Я заметил, что метод System.Linq.Enumerable.Count имеет условие для его оптимизации для случая, когда переданный IEnumerable<T> фактически равен ICollection<T>. Если приведение завершено успешно, метод Count не должен повторяться по каждому элементу, но может вызвать метод Count объекта ICollection.

Исходя из этого, я начинал думать, что IEnumerable<T> можно использовать как представление коллекции только для чтения, без потери производительности, которую я первоначально ожидал, исходя из API IEnumerable<T>

Меня интересовало, сохраняется ли оптимизация Count, когда IEnumerable<T> является результатом оператора Select над ICollection, но на основе отраженного кода этот случай не оптимизирован и требует итерация по всем элементам.

Делаете ли вы те же выводы из отражателя? Что может быть причиной отсутствия этой оптимизации? Мне кажется, что на эту обычную операцию тратится много времени. Требует ли спецификация , чтобы каждый элемент оценивался, даже если счет может быть определен без этого?

Ответы [ 3 ]

12 голосов
/ 02 февраля 2010

Неважно, что результат Select лениво оценивается. Значение Count всегда эквивалентно количеству исходной коллекции, поэтому его, безусловно, можно было получить напрямую, возвращая конкретный объект из Select, который можно использовать для оценки короткого замыкания метода Count.

Причина, по которой невозможно оптимизировать оценку метода Count() для возвращаемого значения вызова Select из чего-либо с определенным числом (например, List<T>), заключается в том, что это может изменить смысл программы. ,

Функция selector, переданная методу Select, разрешает иметь побочные эффекты , и ее побочные эффекты должны происходить детерминистически, в заранее определенном порядке.

Предположим:

new[]{1,2,3}.Select(i => { Console.WriteLine(i); return 0; }).Count();

Документация требует этот код для печати

1
2
3

Даже если счет действительно известен с начала и может быть оптимизирован , оптимизация изменит поведение программы. Вот почему вы не можете избежать перечисления коллекции в любом случае. Это как раз одна из причин, почему оптимизация компилятора намного проще в чисто функциональных языках.


ОБНОВЛЕНИЕ : По-видимому, не ясно, что вполне возможно реализовать Select и Count, так что Select s на ICollection<T> все равно будет лениво оцениваться, но Count() будет оцениваться в O (1) без перечисления коллекции. Я собираюсь сделать это без изменения интерфейса каких-либо методов. Подобное уже сделано для ICollection<T>:

private interface IDirectlyCountable {
    int Count {get;}
}
private class SelectICollectionIterator<TSource,TResult> : IEnumerable<T>, IDirectlyCountable {
     ICollection<TSource> sequence;
     Func<TSource,TResult> selector;
     public SelectICollectionIterator(ICollection<TSource> source, Func<TSource,TResult> selector) {
         this.sequence = source;
         this.selector = selector;
     }
     public int Count { get { return sequence.Count; } }
     // ... GetEnumerator ... 
}
public static IEnumerable<TResult> Select<TSource,TResult>(this IEnumerable<TSource> source, Func<TSource,TResult> selector) {
    // ... error handling omitted for brevity ...
    if (source is ICollection<TSource>)
       return new SelectICollectionIterator<TSource,TResult>((ICollection<TSource>)source, selector);
    // ... rest of the method ...
}
public static int Count<T>(this IEnumerable<T> source) {
    // ...
    ICollection<T> collection = source as ICollection<T>;
    if (collection != null) return collection.Count;
    IDirectlyCountable countableSequence = source as IDirectlyCountable;
    if (countableSequence != null) return countableSequence.Count;
    // ... enumerate and count the sequence ...
}

Это все равно будет оценивать Count лениво. Если вы измените основную коллекцию, счетчик изменится, и последовательность не будет кэширована. Единственным отличием будет отсутствие побочных эффектов в делегате selector.

1 голос
/ 02 февраля 2010

Изменить 02 февраля 2010 :

На мой взгляд, есть по крайней мере два способа интерпретации этого вопроса.

Почему метод расширения Select<T, TResult>, когда вызывается на экземпляр класса, который реализует ICollection<T>, а не вернуть объект, который обеспечивает Count собственность; и почему Count<T> метод расширения не проверить это свойство, чтобы обеспечить O (1) производительность, когда два методы связаны?

Эта версия вопроса не содержит ложных предположений о том, как работают расширения Linq, и является допустимым вопросом, поскольку вызов ICollection<T>.Select.Count, в конце концов, всегда будет возвращать то же значение, что и ICollection<T>.Count. Вот как Мерадад истолковал вопрос, на который он дал исчерпывающий ответ.

Но Я прочитал вопрос как спрашивающий ...

Если метод расширения Count<T> обеспечивает O (1) производительность для объекта класса реализации ICollection<T>, почему это обеспечивает O (N) производительность для возвращаемое значение Select<T, TResult> метод расширения?

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

Если бы так работали расширения Linq, метод Select мог бы выглядеть примерно так:

public static IEnumerable<TResult> Select<T, TResult>(this IEnumerable<T> source, Func<T, TResult> selector) {
    List<TResult> results = new List<TResult>();

    foreach (T input in source)
        results.Add(selector(input));

    return results;
}

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

На самом деле, я считаю, что реализация метода Select намного ближе к чему-то вроде этого:

public static IEnumerable<TResult> Select<T, TResult>(this IEnumerable<T> source, Func<T, TResult> selector) {
    foreach (T input in source)
        yield return selector(input);

    yield break;
}

Это обеспечивает ленивую оценку и объясняет, почему свойство Count недоступно в O (1) времени для метода Count.

Другими словами, в то время как Мерадад ответил на вопрос почему Select не был разработан по-другому, так что Select.Count будет вести себя по-другому , я предложил свой лучший ответ на вопрос почему Select.Count ведет себя так, как .


ОРИГИНАЛЬНЫЙ ОТВЕТ :

Метод побочных эффектов не является ответом.

Согласно ответу Мехрдада:

Неважно, что результат выбора лениво оценивается.

Я не покупаю это. Позвольте мне объяснить, почему.

Для начала рассмотрим следующие два очень похожих метода:

public static IEnumerable<double> GetRandomsAsEnumerable(int N) {
    Random r = new Random();

    for (int i = 0; i < N; ++i)
        yield return r.NextDouble();

    yield break;
}

public static double[] GetRandomsAsArray(int N) {
    Random r = new Random();

    double[] values = new double[N];
    for (int i = 0; i < N; ++i)
        values[i] = r.NextDouble();

    return values;
}

ОК, что делают эти методы? Каждый из них возвращает столько случайных двойных чисел, сколько пожелает пользователь (до int.MaxValue). Имеет ли значение ленивый метод оценки или нет? Чтобы ответить на этот вопрос, давайте взглянем на следующий код:

public static double Invert(double value) {
    return 1.0 / value;
}

public static void Test() {
    int a = GetRandomsAsEnumerable(int.MaxValue).Select(Invert).Count();
    int b = GetRandomsAsArray(int.MaxValue).Select(Invert).Count();
}

Можете ли вы догадаться, что произойдет с этими двумя вызовами методов? Позвольте мне избавить вас от необходимости скопировать этот код и протестировать его самостоятельно:

Первая переменная , a, (после потенциально значительного промежутка времени) будет инициализирована значением int.MaxValue (в настоящее время 2147483647). секунда один, b, очень вероятно будет прервана OutOfMemoryException.

Поскольку Select и другие методы расширения Linq оцениваются лениво, они позволяют вам делать то, что вы просто не могли бы сделать иначе. Выше приведен довольно тривиальный пример. Но мое главное - оспорить утверждение, что ленивая оценка не важна. Утверждение Мехрдада о том, что свойство Count «действительно известно с самого начала и может быть оптимизировано», фактически ставит вопрос. Проблема может показаться простой для метода Select, но Select не является особенной; он возвращает IEnumerable<T> точно так же, как и остальные методы расширения Linq, и для того, чтобы эти методы «знали» Count их возвращаемых значений , потребовалось бы кэширование полных коллекций и, следовательно, запретить ленивую оценку .

Ленивая оценка - это ответ.

По этой причине я должен согласиться с одним из первоначальных респондентов (чей ответ теперь, кажется, исчез), что ленивая оценка действительно является ответом здесь. Идея о том, что необходимо учитывать побочные эффекты метода, на самом деле вторична, поскольку в любом случае это уже является побочным продуктом ленивой оценки.

Постскриптум: Я сделал очень напористые заявления и подчеркнул свои аргументы главным образом потому, что я хотел уточнить, в чем заключается мой аргумент, а не из-за неуважения к любым другим ответам, включая ответы Мехрдада, которые я считаю проницательными но не попадает в цель.

0 голосов
/ 02 февраля 2010

ICollection знает количество элементов (Количество), которое он содержит. Он не должен повторять какие-либо элементы, чтобы определить его. Возьмем, к примеру, класс HashSet (который реализует ICollection).

IEnumerable<T> не знает, сколько предметов в нем содержится. Вы должны перечислить весь список, чтобы определить количество предметов (Количество).

Включение ICollection в оператор LINQ не делает его более эффективным. Независимо от того, как вы крутите и поворачиваете, ICollection придется перечислять.

...