Void не вариантен

[ДОПОЛНЕНИЯ ниже]

Некоторое время назад я описал вид вариантности, который мы поддерживаем, начиная с C# 2.0. При присваивании группы методов делегату такого типа, что и выбранный метод, и делегат возвращают ссылочный тип, то разрешено ковариантное преобразование. То есть, вы можете сказать:

Giraffe GetGiraffe() { … }

Func<Animal> f = GetGiraffe;

Это работакт логично, потому что всякий, кто вызывает f, обязан уметь обрабатывать любое животное, переданное в ответ. Фактический метод обещает возвращать только животных, и, на самом деле, даёт еще более сильное обещание возвращать только жирафов.

Это работает в CLR потому, что биты, представляющие ссылку на экземпляр Giraffe в точности такие же, что и биты, представляющие ссылку на Giraffe, проинтерпретированный как экземпляр Animal. Мы можем позволить этому магическому преобразованию происходить, потому что CLR гарантирует его работу без вмешательства и манипуляции с битами.

И по той же причине этот трюк работает только со ссылочными типами. Метод, возвращающий, скажем, double, невозможно привести при помощи ковариантного преобразования к делегату, тип которого требует метода, возвращающего object. Пришлось бы сгенерировать где-то там код, который бы брал возвращённый double и упаковывал его в object; биты double и биты ссылки на упакованный double не имеют ничего общего.

Но почему этот трюк не работает с типом void? Вот у нас метод, который возвращает что-то вроде кода ошибки или успеха. Может быть, нам неважно, что он вернёт.

static bool DoSomething(bool b)
{
  if (b) return DoTheThing();
  else return DoTheOtherThing();
}

Action<bool> action = DoSomething;

Это не работает. Почему? Тот, кто вызывает действие, даже не собирается использовать возвращаемое значение, так что ни один его бит не важен! Не должен ли «void» считаться супертипом всех возможных типов в целях ковариантных преобразований типов от групп методов к делегатам?

Нет, и я объясню, почему.

Подумайте, что происходит, когда вы делаете вот это:

bool x = DoSomething(true);

Мы выплёвываем IL, который делает следующее:

  1. Помещает true на управляемый стек – стек становится на единичку глубже
  2. Вызывает DoSomething() – аргумент снимается со стека и возвращённое значение кладется на стек. Итого, глубина стека остаётся неизменной
  3. Снимает значение с вершины стека и записывает в локальную переменную x – стек теперь возвращается к своей исходной глубине.

Теперь подумайте, что происходит, когда вы делаете вот это:

DoSomething(true);

Мы выплёвываем IL, который делает первые два шага так же. Но мы не можем на этом остановиться! Теперь у нас на управляемом стеке есть bool, который нужно оттуда убрать. Мы генерируем инструкцию pop, чтобы отразить необходимость выбросить возвращённый bool.

А теперь подумайте, что происходит, когда вы делаете вот что:

action(true);

Компилятор считает, что action возвращает void, так что он не генерирует инструкцию pop. Если бы мы позволили вам засовывать DoSomething в action, то вы бы могли сломать выравнивание стека!

Но разве я не говорил, что стек – деталь реализации? Да, но это про другой стек. Спецификация CLI описывает «виртуальную машину», которая передаёт аргументы и возвращаемые значения через стек. Реализация CLI обязана предлагать что-то такое, что ведёт себя как описываемая машина, но не обязана делать это каким-то определённым образом. Не обязательно использовать предоставляемый операционной системой стек-в-миллион-байт для реализации управляемого стека; это, конечно, удобная структура, но факт её использования – деталь реализации.

(В сторону: когда мы реализовывали скриптовые движки, мы тоже в первую очередь описали нашу собственную стекоориентированную виртуальную машину. Когда мы её реализовывали, то решили разместить информацию об «адресах возврата» - то есть «какой код я исполняю дальше?» на системном стеке, но аргументы и возвращаемые значения скриптовых функций мы размещаем в стекообразном блоке памяти, который выделяем самостоятельно. Это упростило построение сборщика мусора для JScript.)

На практике, JIT-компилятор для некоторых вещей использует системный стек, а для некоторых других – регистры. Возвращаемые значения часто передаются через регистры, а не стек. Но эта деталь реализации не помогает нам при принятии решений о правилах преобразования типов; мы вынуждены предполагать, что реализация делает не больше того, что предписано ей спецификацией CLI. Если бы спецификация CLI говорила «возвращаемое значение любой функции передаётся через «виртуальный регистр, то мы бы могли сделать возвращающие void делегаты совместимыми с функциями, возвращающими что угодно. Всегда можно просто проигнорировать значение в регистре. Но это не то, что указано в спецификации CLI, так что этого мы сделать не можем.

[ДОПОЛНЕНИЕ]

Несколько людей спросили в комментариях, почему бы нам просто не генерировать вспомогательный метод, который делает то, чего вы хотите. То есть, когда вы говорите

Action<bool> action = DoSomething;

реализовывать это как

static void DoSomethingHelper(bool b)
{
   bool result = DoSomething(b); // result is ignored
}
...
Action<bool> action = DoSomethingHelper;

Мы могли бы это делать. Но где вы хотите провести границу? Должны ли вы иметь возможность присвоить ссылку на метод, возвращающий int, переменной типа Func<Nullable<int>>? Мы могли бы порождать вспомогательный метод, который конвертирует int в nullable int. Как насчёт Func<double>? Мы могли бы порождать вспомогательный метод, который конвертирует int в double. Как насчёт Func<object>? Мы могли бы порождать вспомогательный метод, который упаковывает int, неожиданно выделяя память на хипе при каждом вызове. Что насчёт Func<Foo>, где определено пользовательское неявное преобразование из int в Foo?

Мы могли бы порождать компенсирующие методы произвольной сложности, которые бы незаметно «делали то, что вы хотели сказать», и нам бы пришлось где-то остановиться. Точная семантика того, что мы компенсируем, а что нет, должна была бы быть спроектирована, специфицирована, реализована, протестирована, задокументирована, доставлена потребителям и поддержана навсегда. Это затраты. Плюс, всякий раз, как мы добавляем новое правило преобразования в язык, мы вносим ломающие изменения. Стоимость этих ломающих изменений для наших потребителей тоже должна быть учтена.

Но, более фундаментально, один из принципов дизайна C# - «если вы говорите что-то неправильное, то мы сообщаем об этом, а не пытаемся угадать, что вы имели в виду». JScript намеренно спроектирован как язык «делай ерунду, но лучшую из возможных»; C# - нет. Если вы хотите получить делегат для вспомогательного метода, то вы выражаете это намерение путём прямого написания этого метода.

оригинал статьи