Никогда не говори никогда. Часть1

Сможете ли вы придумать лямбда-выражение, неявно приводимое к функции Func<T> для любых возможных T ?

.

.

.

.

.

.

.

.

.

.

.

Подсказка: это же лямбда-выражение также неявно приводится к типу Action.

.

.

.

.

.

.

.

.

.

Func<int> function = () => { throw new Exception(); };

Правила присваивания лямбда-выражений делегатам, возвращающим int звучат не так: «тело выражения должно возвращать int». Точнее, правила следующие:

* Все операторы return в блоке кода должны возвращать выражения, приводимые к типу int.

* Последняя точка (end point) этого блока кода не должна быть достижимой.

В нашем случае, оба эти условия выполняются. Первое выполняется, поскольку нуль из нуля операторов return выполняют это условие. Второе условие выполняется, поскольку компилятор может определить, что ни один из путей выполнения не приводит к последней точке в этом блоке кода. Если генерируется исключение, код выполняет бесконечный цикл, или блок выполняется успешно, а потом генерируется исключение; неважно, что именно происходит, но эта функция не может завершиться нормальным образом. Конечно же, эти условия выполняются для любого типа аргумента, а не только для типа int.

Аналогичным образом, это выражение может быть присвоено делегату с типом Action, поскольку отличия в правилах сводятся к тому, что все операторы return в блоке кода не должны содержать никаких выражений. Опять-таки, это условие выполняется.

Правила для лямбда-выражений являются лишь частным случаем правил для обычных функций. И эти правила справедливы по тем же самым причинам:

int I.M()
{
throw new NotImplementedException();
}

Тогда почему применение рефакторинга «выделение метода» (extract method) приводит к поломке кода?

private static void AlwaysThrows()
{
  throw new NotImplementedException();
}
int I.M()
{
  AlwaysThrows();
}

Проблема здесь связана с тем, что компилятор C# не выполняет анализ потока выполнения между разными методами. Мы выполняем анализ одного тела метода за раз, чтобы удостоверится в том, что тип возвращаемого значения является правильным. Тип возвращаемого значения метода AlwaysThrows – void, а это означает, что метод ничего не возвращает, но значит, что возможно, этот метод вернет управление. Таким образом, код, расположенный после вызова метода AlwaysThrows является достижимым, и, таким образом, конец метода M также достижим без возвращения целочисленного значения. Мы-то с вами знаем, что он недостижим, но компилятор не настолько умен, чтобы знать об этом.

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

Frog frog;
try
{
    frog = Animals.MakeFrog();
}
catch(Exception ex)
{
  LogAndThrowTestFailure(ex); // always throws
}
frog.Ribbit();

Компилятор жалуется, что вызов метода frog.Ribbit() является некорректным, поскольку метод MakeFrog может сгенерировать исключение до присваивания переменной frog некоторого значения, и метод LogAndThrowTestFailure, который мы знаем, что всегда генерирует исключение, но компилятор этого не знает, может завершиться нормальным образом, в этом случае переменная frog, в момент вызова метода Ribbit не обязательно будет проиниц0иализирована. Если бы вместо этого был следующий код:

catch(Exception ex)
{
throw LogTestFailureAndReturnAnotherException(ex);
}

тогда компилятор сможет определить, что вызов метода Ribbit возможен только в случае успешной инициализации переменной frog.

Можем ли мы с этим что-либо сделать?

На практике – ничего. Вам придется написать что-то типа такого:

int I.M()
{
  AlwaysThrows();
  return 0;
}

чтобы заставить компилятор замолчать или изменить метод AlwaysThrows, чтобы он возвращал исключение, которое потом генерировать.

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

Как я уже отмечал ранее, мы можем выполнять межпроцедурный анализ, но на практике сложность этого процесса возрастает невероятно быстро. Представьте себе сотню взаимно рекурсивных функций, каждая из которых выполняет бесконечный цикл, генерирует исключение или вызывает другой подобный метод. Разработка компилятора, который сможет определять достижимость сложной топологии вызовов методов, возможна, но, скорее всего, потребует много работы. Кроме того, анализ нескольких функций работает только в том случае, если у вас есть исходный код этих функций; а что, если один из этих методов находится в другой сборке и у нас есть только метаданные? (Более того, как мы увидим в следующий раз, даже анализ потока управления нескольких функций является недостаточным для решения этой задачи в общем случае.)

Для решения этой задачи без анализа множества вызовов в исходном коде, нам понадобится еще один тип возвращаемого значения. Сейчас CLR поддерживает три типа возвращаемого значения. Вы можете возвращать значения (value) значимого типа (value type) или ссылочного типа (reference type), такие как int или string. Вы можете ничего не возвращать, в таком случае метод возвращает void. Или вы можете вернуть синоним переменной (alias to variable). (Язык C# не поддерживает эту последнюю возможность; C# только поддерживает «ссылки» на переменные, передаваемые в метод, но мы также можем реализовать поддержку ссылок на переменные, возвращаемые из метода. Но ждать этой возможности придется достаточно долго.) Нам нужен четвертый тип возвращаемого значения, тип «этот метод никогда не возвращает управление». Этот метод не будет содержать оператора return и его конечная точка не будет достижимой. Конечная точка будет недостижимой, если каждый возможный поток исполнения будет либо генерировать исключение, либо выполнять бесконечный цикл, либо вызывать другой «никогда не завершающийся» метод.

Некоторые языки программирования, например, Curl, поддерживают такой тип возвращаемого значения. Подобная аннотация для функция была предложена для ECMAScript. Но поскольку эта возможность языка C# потребует поддержки со стороны верификатора CLR, то маловероятно, что она будет когда-либо поддерживаться одним из основных языков под CLR. Особенно, при наличии простых обходных путей для тех редких случаев, когда приходится вызывать метод, никогда не возвращающий управление. (*)

В следующий раз: Можем ли мы быть еще умнее? Да и вообще, насколько умными мы можем быть?

(*) Дополнительные мысли о стилях программирования, когда методы никогда не возвращают управление, смотрите мою длинную серию статей о Стиле передачи продолжений.

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