Ответ 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.
К сожалению, у компилятора С# сегодня есть ошибка. Если вы находитесь в этой ситуации, мы выбираем, какую стратегию следует следовать непоследовательно. Поведение сегодня:
-
если переменная, зависящая от значения, которая мутируется с помощью метода, является обычным локальным, то она обычно изменяется в мутации
-
но если это локализованная локальная (потому что это закрытая переменная анонимной функции или в блоке итератора), то локальная фактически создается как поле только для чтения, и механизм, обеспечивающий это мутации происходят с копией.
К сожалению, спецификация дает мало рекомендаций по этому вопросу. Ясно, что что-то нарушается, потому что мы делаем это непоследовательно, но то, что нужно делать, совсем не ясно.