Почему в коллекциях BCL используются структурные перечислители, а не классы?
Мы все знаем, что изменчивые структуры являются злом вообще. Я также уверен, что потому что IEnumerable<T>.GetEnumerator()
возвращает тип IEnumerator<T>
структуры сразу помещаются в ссылочный тип, что стоит больше, чем если бы они были просто ссылочными типами для начала.
Так почему же в универсальных коллекциях BCL все перечислители являются изменяемыми структурами? Конечно, должна была быть веская причина. Единственное, что приходит мне в голову, - это то, что структуры могут быть легко скопированы, таким образом, сохраняя состояние счетчика в произвольной точке. Но добавив Copy()
метод к IEnumerator
Интерфейс был бы менее проблематичным, поэтому я не вижу в этом логического оправдания.
Даже если я не согласен с дизайнерским решением, мне бы хотелось понять причины этого.
2 ответа
Действительно, это из соображений производительности. Команда BCL провела много исследований по этому вопросу, прежде чем принять решение о том, что вы по праву называете подозрительной и опасной практикой: использованием изменяемого типа значения.
Вы спрашиваете, почему это не вызывает бокс. Это потому, что компилятор C# не генерирует код для упаковки содержимого в IEnumerable или IEnumerator в цикле foreach, если он может этого избежать!
Когда мы видим
foreach(X x in c)
первое, что мы делаем, это проверяем, есть ли в c метод GetEnumerator. Если это так, то мы проверяем, имеет ли возвращаемый тип метод MoveNext и свойство current. Если это так, то цикл foreach генерируется полностью с использованием прямых вызовов этих методов и свойств. Только если "шаблон" не может быть сопоставлен, мы возвращаемся к поиску интерфейсов.
Это имеет два желательных эффекта.
Во-первых, если коллекция, скажем, представляет собой коллекцию целых, но была написана до того, как были изобретены универсальные типы, то она не берет штраф за упаковку значения Current для объекта, а затем распаковывает его для 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 внутри блока using переместит перечислитель к следующему независимо от того, является ли он структурой или типом ref.
К сожалению, сегодня в компиляторе C# есть ошибка. Если вы находитесь в такой ситуации, мы выбираем, какой стратегии следовать непоследовательно. Поведение сегодня это:
если типизированная переменная, мутирующая с помощью метода, является нормальной локальной, то она мутирует нормально
но если это локальная локальная переменная (потому что это закрытая переменная анонимной функции или в блоке итератора), то локальная переменная фактически генерируется как поле только для чтения, и механизм, обеспечивающий мутации в копии, занимает над.
К сожалению, в спецификации мало указаний по этому вопросу. Очевидно, что что-то сломано, потому что мы делаем это непоследовательно, но что делать правильно, совсем не ясно.
Методы структуры встраиваются, когда тип структуры известен во время компиляции, а вызов метода через интерфейс происходит медленно, поэтому ответ таков: по причине производительности.