Блоки итераторов, Часть вторая: Почему нет out- или ref- параметров?
Долгое и подробное обсуждение того, как именно мы реализовали блоки итераторов отняло бы довольно много моего времени, и продублировало бы работу, уже хорошо выполненную другими. Я призываю вас начать с плавного введения - цикла статей Реймонда: часть 1, часть 2, часть 3. Если вы хотите подробнее рассмотреть, как готовят эти сосиски, то статья Джона весьма глубока.
Коротко говоря, мы реализуем итераторы при помощи:
- Порождения класса, который реализует нужные интерфейсы.
- Вытягивания локальных переменных блока итератора в поля этого класса.
- Переписывания блока итератора в виде метода «MoveNext» этого класса.
- Отслеживания места, где выполнился последний yield в методе MoveNext, и перехода сразу в это место при следующем вызове MoveNext.
- Переноса логики блоков «finally» (или их эквивалентов, типа блоков finally, автоматически сгенерированных для операторов lock и using) в метод Dispose, чтобы гарантировать очистку всего при завершении или отмене итерирования.
Это – не единственная стратегия реализации, которой мы могли последовать. Как я говорил в прошлый раз, мы могли реализовать полноценные продолжения и построить всю штуку из них; любая структура передачи управления, которуя только можно себе представить, может быть построена из продолжений. Но правильная реализация продолжений требует значительной поддержки со стороны среды .Net, и была бы очень дорогой. Мы можем обойтись меньшим, и обходимся.
Аналогично, мы могли бы построить это из «волоконных сопрограмм». Волокно похоже на поток тем, что у него есть собственный стек, но код волокна сам решает, когда можно прервать его исполнение. Опять же, это стратегия, – реализовать сопрограммы, – которую, как я упоминал в прошлый раз, мы отбросили как слишком дорогую и сложную для реализации в CLR. Снова мы можем обойтись меньшим, и так и делаем.
Но наш выбор достижимой, эффективной по затратам стратегии реализации приводит к внесению ограничений в пространство дизайна; мы вынуждены отказаться от некоторой общности для того, чтобы заставить эту стратегию работать.
Первое, что мы теряем – это возможность использовать для блоков итераторов методы, которые принимают out- или ref-параметры. Для сохранения значений локальных переменных и формальных параметров между вызовами MoveNext, мы вытягиваем переменные и параметры в поля класса, генерируемого компилятором.
Как я обсуждал ранее, перед дизайнерами CLR стояла задача реализовать возможность воспользоваться преимуществами производительности системного стека. Что происходит, когда вы записываете ссылку в поле? Ссылка может указывать куда-то в стек, но поле может прожить дольше, чем этот объект в стеке. Дизайнеры CLR стояли перед выбором одного из четырёх:
- Построить систему, столь же хрупкую и кошмарную, как неуправляемый код на Си – языке, в котором случайное сохранение ссылки на что-то слишком короткоживущее является нередким источником ужасных багов с падениями и повреждением данных.
- Запретить (*) ссылаться на локальные переменные; любая переменная, использованная по ссылке, должна быть вытянута и стать полем ссылочного типа, подлежащего сборке мусора. Безопасно, но медленно.
- Запретить хранение ссылок в полях.
- Изобрести еще что-то для решения этой проблемы.
Дизайнеры CLR выбрали третий вариант. Это означает, что наш выбор вытягивания параметров в поля сразу приводит к проблеме; у нас нет безопасного способа хранить ссылки на другие переменные в поле. Ссылка может ссылаться на стек, но итератор может прожить дольше, чем эта переменная в стеке. Столкновение решений, принятых в реализациях, привело теперь к неудачному ограничению дизайна, которое выглядит случайным. Мы потеряли немножко общности для выигрыша в простоте реализации и лучшей производительности типичных случаев.
Теперь подумайте над этим решением с точки зрения более общей цели. Помните, задача была не в том, чтобы «превратить любой возможный метод в блок итератора». Задача была «облегчить написание кода, который перебирает элементы коллеции». Параметры ref или out существуют исключительно для того, чтобы разрешить методу изменять внешнюю переменную и, таким образом, передавать информацию обратно вызывающему при окончании работы метода.
Зачем бы вам хотелось такого в методе, единственное назначение которого – возвращать последовательность значений, хранимых в коллекции? Каков бы вообще был смысл изменений значения переменной, если блок итератора изменяет её много раз за время работы итератора? Обычно такие штуки невидимы (за исключением неуклюжих многопоточных сценариев) вплоть до окончания работы метода; но блок итератора возвращает управление вызывающему много раз перед тем, как закончить работу.
Это похоже на необычный, странный и трудно реализуемый сценарий.
И, более того, есть способы добиться изменения внешних переменных в процессе итерирования без передачи ссылки на переменную; вы можете передать делегат типа Action. Вместо вот этой неверной программы:
IEnumerable<int> Frob(out string s)
{
yield return 1;
s = "goodbye";
}
void Grob()
{
string s = "hello";
foreach(int i in Frob(out s)) …
}
вы всегда можете добиться эквивалентного поведения вот такой верной программой:
IEnumerable<int> Frob(Action<string> setter)
{
yield return 1;
setter("goodbye");
}
void Grob()
{
string s = "hello";
foreach(int i in Frob(x=>{s=x;})) …
}
Поскольку получение более общей возможности, с учетом наших ограничений реализации (1) трудно или невозможно, (2) является граничным случаем, а не основным сценарием и (3) достижимо обходным путём, очевидным решением будет наложение ограничения на дизайн и запрет ref- и out-параметров.
В следующий раз: почему нет yield в блоке finally?
***********
(*) Или, мягче, сделать это разрешённым, но неверифицируемым. Это почти одно и то же с точки зрения разработчиков компилятора; мы стараемся никогда не генерировать неверифицируемый код, кроме случая, когда он специально помечен «unsafe».