Почему BCL Collections использует структурные перечислители, а не классы?

Мы все знаем, что изменчивые структуры являются злыми в целом. Я также уверен, что, поскольку IEnumerable<T>.GetEnumerator() возвращает тип IEnumerator<T>, структуры сразу же помещаются в ссылочный тип, стоимость которых больше, чем если бы они были просто ссылочными типами для начала.

Итак, почему в общих коллекциях BCL все перечисляемые переменные структуры? Разумеется, должна была быть веская причина. Единственное, что происходит со мной, это то, что структуры легко копируются, тем самым сохраняя состояние перечислителя в произвольной точке. Но добавление метода Copy() к интерфейсу IEnumerator было бы менее проблематичным, поэтому я не считаю это логическим оправданием самостоятельно.

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

Ответ 1

Действительно, это по соображениям производительности. Команда BCL провела много исследований по этому вопросу, прежде чем решила пойти с тем, что вы правильно называете подозрительной и опасной практикой: использование изменяемого типа значений.

Вы спрашиваете, почему это не вызывает бокса. Это потому, что компилятор С# не генерирует код для ввода содержимого IEnumerable или IEnumerator в цикле foreach, если он может его избежать!

Когда мы видим

foreach(X x in c)

первое, что мы делаем, это проверить, есть ли у c метод GetEnumerator. Если это так, то мы проверяем, имеет ли тип, который он возвращает, метод MoveNext и свойство current. Если это так, то цикл foreach генерируется полностью с помощью прямых вызовов этих методов и свойств. Только если "шаблон" не может быть сопоставлен, мы возвращаемся к поиску интерфейсов.

Это имеет два желаемых эффекта.

Во-первых, если коллекция является, скажем, коллекцией ints, но была написана до того, как были изобретены типичные типы, тогда не требуется штраф бокса в боксе за значение Current to object, а затем распаковывает его в int. Если Current является свойством, которое возвращает int, мы просто используем его.

Во-вторых, если перечислитель является типом значения, то он не помещает перечислитель в IEnumerator.

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

Например, рассмотрим следующее:

struct MyHandle : IDisposable { ... }
...
using (MyHandle h = whatever)
{
    h = somethingElse;
}

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

Теперь предположим, что у вас было:

struct MyHandle : IDisposable { ... }
...
using (MyHandle h = whatever)
{
    h.Mutate();
}

Что здесь происходит? Вы можете разумно ожидать, что компилятор будет делать то, что он делает, если h является полем только для чтения: сделать копию и скопировать копию, чтобы обеспечить что метод не выбрасывает материал в значение, которое должно быть удалено.

Однако это противоречит нашей интуиции о том, что должно произойти здесь:

using (Enumerator enumtor = whatever)
{
    ...
    enumtor.MoveNext();
    ...
}

Мы ожидаем, что выполнение MoveNext внутри блока использования переместит перечислитель на следующий, независимо от того, является ли это структурой или типом ref.

К сожалению, у компилятора С# сегодня есть ошибка. Если вы находитесь в этой ситуации, мы выбираем, какую стратегию следует следовать непоследовательно. Поведение сегодня:

  • если переменная, зависящая от значения, которая мутируется с помощью метода, является обычным локальным, то она обычно изменяется в мутации

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

К сожалению, спецификация дает мало рекомендаций по этому вопросу. Ясно, что что-то нарушается, потому что мы делаем это непоследовательно, но то, что нужно делать, совсем не ясно.

Ответ 2

Методы Struct inline, когда тип структуры известен во время компиляции, а метод вызова через интерфейс медленный, поэтому ответ: из-за причины производительности.