Udostępnij za pośrednictwem


Всегда пишите спецификацию. Часть 2

Во время просмотра спецификации, даже не глядя на мой код, Крис нашел ошибку и упущение.

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

Ошибка же заключается в следующей строке:


если x – это поле экземпляра, которое передается как ref/out-параметр, тогда добавляем var temp=instance в список L и ref/out temp.field в список A2.


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

struct S { public int q; }
static void M(ref int r) { r = r + 1; }
static int Zero() { Console.WriteLine("hello!"); return 0; }
...
S[] arr = new[] { new S() };
M(ref arr[Zero()].q);
Console.WriteLine(arr[0].q); // 1

Здесь мы имеем выражение в форме instance.field, которое обладает побочным эффектом – выводит на экран “Hello”.

Согласно спецификации, мы перепишем этот код таким образом:

S temp = arr[Zero()];
M(ref temp.q);
Console.WriteLine(arr[0].q); // 0 !

Это еще раз подтверждает то, что изменяемые значимые типы (value types) являются воплощением зла. Мы просто изменили temp.q, которая является копией arr[0].

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

Я много думал об этом и пришел к выводу, что болезненный случай возникает только когда мы имеем дело с полем экземпляра, который передается в качестве ref/out-параметра, обладает побочным эффектом и при этом экземпляром является переменная значимого типа. Вот он пропущенный случай. Более того, мы можем решить эту проблему путем рекурсии!


  • если x – это поле экземпляра, которое передается как ref/out-параметр и является экземпляром значимого тип, тогда применяем рекурсию. Вычисляем T("ref instance"), как если бы мы вызывали метод, принимающий единственный параметр этого типа. Это даст нам результирующий список объявлений, который мы добавляем в конец списка L, и результирующий список аргументов с одним элементом (r.Add "ref r.field") добавляем в список A2.
  • если x – это поле экземпляра, но экземпляр не является значимым типом, тогда генерируем var temp=instance для списка L и ref temp.field для списка A2.

Поэтому, если мы имеем, скажем: M(ref arr[index].q), тогда мы сгенерируем: var t1 = arr, var t2 = index, M(ref t1[t2].q), и это будет корректно.

Я исправил свою реализацию, написал несколько тестов для проверки спецификации и отправил ее на тестирование для дальнейшего анализа.

Спецификация все еще содержала ошибку, которую нашел Дэвид, наш тестировщик, читая спецификацию и предложенную реализацию. Намек: проблема не касалась семантики ref/out-параметров, это была более фундаментальная проблема в рассуждениях.

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

Ошибка была в первом случае:


Если x не обладает побочными эффектами – сохраняем x в соответствующую позицию списка A2.


Совершенно не важно, обладает x побочными эффектами или нет. Важно лишь то, зависит ли выполнение x от порядка каких угодно побочных эффектов или нет!

Например, согласно нашей спецификации мы получим, что

int k = 0;
M( k, arr[k=1] );

тоже самое, что

int k = 0;
var t1 = arr;
var t2 = (k = 1);
M(k, t1[t2]);

что совершенно неверно. Первый аргумент должен равняться 0, а не 1. Совершенно не важно, что первый аргумент не обладает побочным эффектом, главное, что он зависит от побочного эффекта второго аргумента, который вступает в силу после вычисления значения первого аргумента.

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

Замечу, что мы можем сделать исключение в случае, когда, скажем, значением является константа времени компиляции. Ясно, что вычисление этого значения не зависит от порядка вычисления других побочных эффектов. Но для предотвращения дальнейшего усложнения спецификации и реализации эту деталь я убрал, поскольку, скорее всего, об этом позаботится оптимизатор. Один из комментаторов, Павел Минаев (Pavel Minaev) нашел еще несколько проблем, которые мы пропустили. Самая большая из них связана с тем, что мы должны внести ясность в поведение с многомерными массивами. Фактически, в моей реализации для многомерных массивов содержится ошибка. Спасибо, Павел! Он также указал на несколько тонкостей. Одна из них заключается в том, что доступ к статическому полю обладает побочным эффектом: если это обращение является первым обращением к классу, тогда это приведет к вызову статического конструктора, который может обладать видимыми побочными эффектами. Другая тонкость такого преобразования связана с тем, что несколько меняется точная семантика того, когда генерируются исключения на плохие аргументы.

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

Кто-нибудь еще видит какие-то проблемы с этой спецификацией?

Кто-нибудь?

А у кого-нибудь появились идеи, для чего мне понадобилось реализовывать эту функцию?

Оригинальное сообщение: Always write a spec, Part Two