Наверняка каждый, кто писал на C#, сталкивался с использованием out-параметров. Кажется, что с ними всё предельно просто и понятно. Но так ли это на самом деле? Для затравки предлагаю начать с задачки для самопроверки. Напомню, что out-параметры должны быть проинициализированы вызываемым методом до выхода из него. А теперь посмотрите на следующий фрагмент кода и ответьте, компилируется ли он. MyStruct — какой-то значимый тип: Если вы уверенно ответили 'да' или 'нет' — приглашаю к дальнейшему прочтению, так как всё не так однозначно... Начнём с небольшой предыстории. Как мы вообще погрузились в изучение out-параметров? Всё началось с разработки очередного диагностического правила для PVS-Studio. Идея диагностики заключается в следующем — один из параметров метода имеет тип CancellationToken. При этом данный параметр в теле метода не используется. Как следствие, программа может не реагировать (или реагировать несвоевременно) на какие-то действия отмены, например, отмены операции со стороны пользователя. В ходе просмотра срабатываний одной из первых версий диагностики нашли код примерно следующего вида: Очевидно, что это было false positive срабатыванием, поэтому я попросил коллегу добавить в набор модульных тестов ещё один, "с out параметрами". Он добавил тестов, в том числе тест такого вида: В первую очередь меня интересовали тесты с инициализаций параметров, но я повнимательнее присмотрелся к этому… И тут меня осенило! А как этот код, собственно, компилируется? И компилируется ли вообще? Код компилировался. Тут я понял, что намечается статья. :) Ради эксперимента решили поменять CancellationToken на какой-нибудь другой значимый тип. Например, TimeSpan: Не компилируется. Что ж, ожидаемо. Но почему компилируется пример с CancellationToken? Давайте вновь вспомним, что за модификатор параметра такой — out. Вот основные тезисы, взятые с docs.microsoft.com (out parameter modifier): Особо прошу обратить внимание на выделенное предложение. Внимание — вопрос. В чём отличие следующих трёх методов, и почему последний компилируется, а первый и второй — нет? Пока закономерности не видно. Может быть есть какие-то исключения, которые описаны в доках? Для типа CancellationToken, например. Хотя это было бы немного странно — что в нём такого особенного? В приведённой выше документации я никакой информации по этому поводу не нашёл. За дополнительными сведениями предлагают обращаться к спецификации языка: For more information, see the C# Language Specification. The language specification is the definitive source for C# syntax and usage. Что ж, посмотрим спецификацию. Нас интересует раздел "Output parameters". Ничего нового — всё то же самое: Every output parameter of a method must be definitely assigned before the method returns.  Что ж, раз официальная документация и спецификация языка ответов нам не дали, придётся немного поковыряться в компиляторе. :) Исходники Roslyn можно загрузить со страницы проекта на GitHub. Для экспериментов я взял ветку master. Работать будем с решением Compilers.sln. В качестве стартового проекта для экспериментов используем csc.csproj. Можно даже его запустить на файле с нашими тестами, чтобы убедиться в воспроизводимости проблемы. Для экспериментов возьмём следующий код: Для проверки, что ошибка на месте, соберём и запустим компилятор на файле, содержащем этот код. И действительно — ошибка на месте: error CS0177: The out parameter 'obj' must be assigned to before control leaves the current method Кстати, это сообщение может стать неплохой отправной точкой для погружения в код. Сам код ошибки (CS0177) наверняка формируется динамически, а вот строка формата для сообщения, скорее всего, лежит где-нибудь в ресурсах. И это действительно так — находим ресурс ERR_ParamUnassigned: По тому же имени находим код ошибки — ERR_ParamUnassigned = 177, а также несколько мест использования в коде. Нас интересует место, где добавляется ошибка (метод DefiniteAssignmentPass.ReportUnassignedOutParameter): Что ж, очень похоже на интересующее нас место! Ставим точку останова и убеждаемся, что это — нужное нам место. По результатам в Diagnostics будет записано как раз то сообщение, которое мы видели:  Что ж, шикарно. А теперь поменяем MyStruct на CancellationToken, иии… Мы всё также проходим эту ветку исполнения кода, в которой ошибка записывается в Diagnostics. То есть, она всё ещё на месте! Вот это поворот. Следовательно, недостаточно отследить место, где ошибка компиляции добавляется, — нужно мониторить дальше. Немного покопавшись в коде, выходим на метод DefiniteAssignmentPass.Analyze, который инициировал запуск анализа, проверяющего, в том числе, что out-параметры инициализируются. В нём обнаруживаем, что соответствующий анализ запускается 2 раза: А немного ниже находится интересное условие: Ситуация понемногу проясняется. В результате работы и, strict, и compat анализа, когда мы пытаемся скомпилировать наш код с MyStruct, оказывается одинаковое количество диагностик, которые мы в результате и выдадим.  Если же мы меняем в нашем примере MyStruct на CancellationToken, strictDiagnostics будет содержать 1 ошибку (как мы уже видели), а в compatDiagnostics не будет ничего.   Как следствие, приведённое выше условие не выполняется и исполнение метода не прерывается. Куда же девается ошибка компиляции? А она понижается до предупреждения: Что здесь происходит в нашем случае при использовании CancellationToken? В цикле происходит обход strictDiagnostics (напоминаю, что там содержится ошибка про неинициализированный out-параметр). Then-ветвь оператора if не исполняется, так как diagnostic.Severity  имеет значение DiagnosticSeverity.Error, а коллекция compatDiagnosticSet пуста. А далее происходит маппинг кода ошибки компиляции на новый код — уже предупреждения, после чего это предупреждение формируется и записывается в результирующую коллекцию. Таким вот образом ошибка компиляции превратилась в предупреждение. :) Кстати, оно имеет достаточно низкий уровень, поэтому при запуске компилятора данного предупреждения может быть не видно, если не установить флаг выдачи предупреждений соответствующего уровня. Выставляем запуск компилятора, указав дополнительный флаг: csc.exe %pathToFile% -w:5 И видим ожидаемое предупреждение:  Теперь мы разобрались, куда пропадает ошибка компиляции, — она заменяется на низкоприоритетное предупреждение. Однако у нас до сих пор нет ответа на вопрос, в чём же особенность CancellationToken и его отличие от MyStruct? Почему при анализе метода с out-параметром MyStruct compat анализ находит ошибку, а когда тип параметра — CancellationToken — ошибка не обнаруживается? Тут я предлагаю заварить чашечку чая или кофе, так как далее нас ждёт более глубокое погружение.  Надеюсь, вы воспользовались советом и подготовились. Мы продолжаем. :) Помните метод ReportUnassignedParameter, в котором происходила запись ошибки компиляции? Поднимаемся немного выше и смотрим вызывающий метод: Разница при выполнении этих методов из strict и compat анализа в том, что в первом случае переменная slot имеет значение 1, а во втором — -1. Следовательно, во втором случае не выполняется then-ветвь оператора if. Теперь нужно выяснить, почему во втором случае slot имеет значение -1. Смотрим метод LocalDataFlowPass.VariableSlot: В нашем случае _variableSlot не содержит слота под out-параметр, соответственно, _variableSlot.TryGetValue(....) возвращает значение false, исполнение кода идёт по alternative-ветви оператора ?:, и из метода возвращается значение -1. Теперь нужно понять, почему _variableSlot не содержит out-параметра.  Покопавшись, находим метод LocalDataFlowPass.GetOrCreateSlot. Выглядит он следующим образом: Из метода видно, что есть ряд условий, когда метод вернёт значение -1, а слот не будет добавлен в _variableSlot. Если же слота под переменную ещё нет, и все проверки проходят успешно, то происходит запись в _variableSlot: _variableSlot.Add(identifier, slot). Отлаживаем код и видим, что при выполнении strict анализа все проверки успешно проходят, а вот при compat анализе мы заканчиваем выполнение метода в следующем операторе if: Значение переменной forceSlotEvenIfEmpty в обоих случаях одинаковое (false), а разница в том, какое значение возвращает метод IsEmptyStructType: для strict анализа — false, для compat анализа — true.  Здесь сразу же возникают новые вопросы и желание поэкспериментировать. То есть получается, что, если тип out-параметра — "пустая структура" (позже мы поймём, что это значит), компилятор считает такой код допустимым и не генерирует ошибку? Убираем в нашем примере из MyStruct поле и компилируем. И этот код успешно компилируется! Интересно… Упоминаний таких особенностей в документации и спецификации я что-то не помню. :) Но тогда возникает другой вопрос: а как же работает код в случае, когда тип out-параметра — CancellationToken? Ведь это явно не "пустая структура" — если посмотреть код на referencesource.microsoft.com (ссылка на CancellationToken), становится видно, что этот тип содержит и методы, и свойства, и поля… Непонятно, копаем дальше. Мы остановились на методе LocalDataFlowPass.IsEmptyStructType: Идём глубже (EmptyStructTypeCache.IsEmptyStructType): И ещё глубже: Выполнение кода идёт через вызов метода EmptyStructTypeCache.CheckStruct: Здесь исполнение заходит в then-ветвь оператора if, т.к. коллекция typesWithMembersOfThisType пустая (см. метод EmptyStructTypeCache.IsEmptyStructType, где она начинает передаваться в качестве аргумента).  Какая-то картина уже начинает вырисовываться — теперь становится понятно, что такое "пустая структура". Судя по названиям методов, это такая структура, которая не содержит экземплярных полей. Но я напоминаю, что в CancellationToken экземплярные поля есть. Значит, идём ещё глубже, в метод EmptyStructTypeCache.CheckStructInstanceFields. В методе обходятся экземплярные члены, для каждого из которых получается 'actualField'. Дальше, если удалось получить это значение (field — не null) опять выполняется проверка: а является ли тип этого поля "пустой структурой"? Соответственно, если нашли хотя бы одну "не пустую структуру", изначальный тип также считаем "не пустой структурой". Если все экземплярные поля — "пустые структуры", то изначальный тип также считается "пустой структурой". Придётся опуститься ещё немного глубже. Не беспокойтесь, скоро наше погружение закончится, и мы расставим точки над 'i'. :) Смотрим метод EmptyStructTypeCache.GetActualField: Соответственно, для типа CancellationToken нас интересует case-ветвь SymbolKind.Field. В неё мы можем попасть только при анализе члена m_source этого типа (т.к. тип CancellationToken содержит только одно экземплярное поле — m_source). Рассмотрим, как происходят вычисления в этой case-ветви в нашем случае. field.IsVirtualTupleField — false. Переходим к условному оператору и разберём условное выражение field.IsFixedSizeBuffer || ShouldIgnoreStructField(field, field.Type). field.IsFixedSizeBuffer — не наш случай. Значение, ожидаемо, false. А вот значение, возвращаемое вызовом метода ShouldIgnoreStructField(field, field.Type), различается для strict и compat анализа (напоминаю, мы анализируем одно и то же поле одного и того же типа). Смотрим тело метода EmptyStructTypeCache.ShouldIgnoreStructField: Посмотрим, что отличается для strict и compat анализа. Хотя, возможно, вы уже догадались самостоятельно. :) Strict анализ: _dev12CompilerCompatibility — false, следовательно, результат всего выражения — false. Compat анализ: значения всех подвыражений — true, результат всего выражения — true.  А теперь сворачиваем цепочку, поднимаясь с самого конца. :) При compat анализе мы считаем, что должны игнорировать единственное экземплярное поле типа CancellationSource — m_source. Таким образом, мы считаем, что CancellationToken — "пустая структура", следовательно для неё не создаётся слот, и не происходит записи в кэш "пустых структур". Так как слот отсутствует, мы не обрабатываем out-параметр и не записываем ошибку компиляции при выполнении compat анализа. Как результат, strict и compat анализ дают разные результаты, из-за чего происходит понижение ошибки компиляции до низкоприоритетного предупреждения. То есть это не какая-то особая обработка типа CancellationToken — есть целый ряд типов, для которых отсутствие инициализации out-параметра не будет приводить к ошибкам компиляции. Давайте попробуем посмотреть на практике, для каких типов компиляция будет успешно проходить. Как обычно, берём наш типичный метод: И пробуем подставлять вместо MyType различные типы. Мы уже разобрали, что этот код успешно компилируется для CancellationToken и для пустой структуры. Что ещё? Если вместо MyType используем MyStruct2, код также успешно компилируется. При использовании этого типа код будет успешно компилироваться, если MyExternalStruct объявлен во внешней сборке. Если в одной сборке с методом CheckYourself — не скомпилируется. При использовании такого типа из внешней сборки код уже не скомпилируется (поменяли уровень доступа поля _field с private на public): При таком изменении типа код тоже не будет компилироваться (поменяли тип поля со String на int): В общем, как вы поняли, здесь есть определённый простор для экспериментов. В общем и целом, out-параметры должны быть проинициализированы до того, как вызываемый метод вернёт управление вызывающему. Однако, как показывает практика, компилятор может внести свои коррективы в это требование, и в некоторых случаях вместо ошибки компиляции будет выдано низкоуровневое предупреждение. Из-за чего именно это происходит мы подробно разобрали в предыдущем разделе. Что же по поводу типов, для которых можно не инициализировать out-параметры? Например, не обязательна инициализация параметра, если тип — структура, в которой нет полей. Или если все поля — структуры без полей. Или вот случай с CancellationToken: с ним компиляция успешно проходит, так как этот тип находится во внешней библиотеке, единственное поле m_source ссылочного типа, а само поле недоступно из внешнего кода. В принципе, несложно придумать и составить ещё своих подобных типов, при использовании которых вы сможете не инициализировать out-параметры и успешно компилировать ваш код. Возвращаясь к вопросу из начала статьи: Компилируется ли этот код? Как вы уже поняли, ни 'Да', ни 'Нет' не являются правильным ответом. В зависимости от того, что такое MyStruct (какие есть поля, где объявлен тип и т. п.), этот код может либо компилироваться, либо не компилироваться. Такое вот у нас сегодня получилось погружение в исходный код компилятора для того, чтобы ответить на, казалось бы, простой вопрос. Я думаю, скоро мы повторим этот опыт, так как тема для следующей подобной статьи уже есть. Так что оставайтесь на связи. ;) Кстати, приглашаю подписаться на мой аккаунт в Twitter, где я также выкладываю статьи и прочие интересные находки. Так точно ничего интересного не пропустите. :) Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Sergey Vasiliev. Should We Initialize an Out Parameter Before a Method Returns?. Независимый эксперт