Должен ли out-параметр быть проинициализирован до возврата из метода?

    0800_OutParamsCs_ru/image1.png


    Наверняка каждый, кто писал на C#, сталкивался с использованием out-параметров. Кажется, что с ними всё предельно просто и понятно. Но так ли это на самом деле? Для затравки предлагаю начать с задачки для самопроверки.


    Напомню, что out-параметры должны быть проинициализированы вызываемым методом до выхода из него.


    А теперь посмотрите на следующий фрагмент кода и ответьте, компилируется ли он.


    void CheckYourself(out MyStruct obj)
    {
      // Do nothing
    }

    MyStruct — какой-то значимый тип:


    public struct MyStruct
    { .... }

    Если вы уверенно ответили 'да' или 'нет' — приглашаю к дальнейшему прочтению, так как всё не так однозначно...


    Предыстория


    Начнём с небольшой предыстории. Как мы вообще погрузились в изучение out-параметров?


    Всё началось с разработки очередного диагностического правила для PVS-Studio. Идея диагностики заключается в следующем — один из параметров метода имеет тип CancellationToken. При этом данный параметр в теле метода не используется. Как следствие, программа может не реагировать (или реагировать несвоевременно) на какие-то действия отмены, например, отмены операции со стороны пользователя. В ходе просмотра срабатываний одной из первых версий диагностики нашли код примерно следующего вида:


    void Foo(out CancellationToken ct, ....)
    {
      ....
      if (flag)
        ct = someValue;
      else
        ct = otherValue;
      ....
    }

    Очевидно, что это было false positive срабатыванием, поэтому я попросил коллегу добавить в набор модульных тестов ещё один, "с out параметрами". Он добавил тестов, в том числе тест такого вида:


    void TestN(out CancellationToken ct)
    {
      Console.WriteLine("....");
    }

    В первую очередь меня интересовали тесты с инициализаций параметров, но я повнимательнее присмотрелся к этому… И тут меня осенило! А как этот код, собственно, компилируется? И компилируется ли вообще? Код компилировался. Тут я понял, что намечается статья. :)


    Ради эксперимента решили поменять CancellationToken на какой-нибудь другой значимый тип. Например, TimeSpan:


    void TestN(out TimeSpan timeSpan)
    {
      Console.WriteLine("....");
    }

    Не компилируется. Что ж, ожидаемо. Но почему компилируется пример с CancellationToken?


    Модификатор параметра out


    Давайте вновь вспомним, что за модификатор параметра такой — out. Вот основные тезисы, взятые с docs.microsoft.com (out parameter modifier):


    • The out keyword causes arguments to be passed by reference;
    • Variables passed as out arguments do not have to be initialized before being passed in a method call. However, the called method is required to assign a value before the method returns.

    Особо прошу обратить внимание на выделенное предложение.


    Внимание — вопрос. В чём отличие следующих трёх методов, и почему последний компилируется, а первый и второй — нет?


    void Method1(out String obj) // compilation error
    { }
    
    void Method2(out TimeSpan obj) // compilation error
    { }
    
    void Method3(out CancellationToken obj) // no compilation error
    { }

    Пока закономерности не видно. Может быть есть какие-то исключения, которые описаны в доках? Для типа 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.


    0800_OutParamsCs_ru/image2.png


    Что ж, раз официальная документация и спецификация языка ответов нам не дали, придётся немного поковыряться в компиляторе. :)


    Погружаемся в Roslyn


    Исходники Roslyn можно загрузить со страницы проекта на GitHub. Для экспериментов я взял ветку master. Работать будем с решением Compilers.sln. В качестве стартового проекта для экспериментов используем csc.csproj. Можно даже его запустить на файле с нашими тестами, чтобы убедиться в воспроизводимости проблемы.


    Для экспериментов возьмём следующий код:


    struct MyStruct
    {
      String _field;
    }
    
    void CheckYourself(out MyStruct obj)
    {
      // Do nothing
    }

    Для проверки, что ошибка на месте, соберём и запустим компилятор на файле, содержащем этот код. И действительно — ошибка на месте: error CS0177: The out parameter 'obj' must be assigned to before control leaves the current method


    Кстати, это сообщение может стать неплохой отправной точкой для погружения в код. Сам код ошибки (CS0177) наверняка формируется динамически, а вот строка формата для сообщения, скорее всего, лежит где-нибудь в ресурсах. И это действительно так — находим ресурс ERR_ParamUnassigned:


    <data name="ERR_ParamUnassigned" xml:space="preserve">
      <value>The out parameter '{0}' must be assigned to 
             before control leaves the current method</value>
    </data>

    По тому же имени находим код ошибки — ERR_ParamUnassigned = 177, а также несколько мест использования в коде. Нас интересует место, где добавляется ошибка (метод DefiniteAssignmentPass.ReportUnassignedOutParameter):


    protected virtual void ReportUnassignedOutParameter(
      ParameterSymbol parameter, 
      SyntaxNode node, 
      Location location)
    {
      ....
      bool reported = false;
      if (parameter.IsThis)
      {
        ....
      }
    
      if (!reported)
      {
        Debug.Assert(!parameter.IsThis);
        Diagnostics.Add(ErrorCode.ERR_ParamUnassigned, // <=
                        location, 
                        parameter.Name);
      }
    }

    Что ж, очень похоже на интересующее нас место! Ставим точку останова и убеждаемся, что это — нужное нам место. По результатам в Diagnostics будет записано как раз то сообщение, которое мы видели:


    0800_OutParamsCs_ru/image3.png


    Что ж, шикарно. А теперь поменяем MyStruct на CancellationToken, иии… Мы всё также проходим эту ветку исполнения кода, в которой ошибка записывается в Diagnostics. То есть, она всё ещё на месте! Вот это поворот.


    Следовательно, недостаточно отследить место, где ошибка компиляции добавляется, — нужно мониторить дальше.


    Немного покопавшись в коде, выходим на метод DefiniteAssignmentPass.Analyze, который инициировал запуск анализа, проверяющего, в том числе, что out-параметры инициализируются. В нём обнаруживаем, что соответствующий анализ запускается 2 раза:


    // Run the strongest version of analysis
    DiagnosticBag strictDiagnostics = analyze(strictAnalysis: true);
    ....
    // Also run the compat (weaker) version of analysis to see 
       if we get the same diagnostics.
    // If any are missing, the extra ones from the strong analysis 
       will be downgraded to a warning.
    DiagnosticBag compatDiagnostics = analyze(strictAnalysis: false);

    А немного ниже находится интересное условие:


    // If the compat diagnostics did not overflow and we have the same 
       number of diagnostics, we just report the stricter set.
    // It is OK if the strict analysis had an overflow here,
       causing the sets to be incomparable: the reported diagnostics will
    // include the error reporting that fact.
    if (strictDiagnostics.Count == compatDiagnostics.Count)
    {
      diagnostics.AddRangeAndFree(strictDiagnostics);
      compatDiagnostics.Free();
      return;
    }

    Ситуация понемногу проясняется. В результате работы и, strict, и compat анализа, когда мы пытаемся скомпилировать наш код с MyStruct, оказывается одинаковое количество диагностик, которые мы в результате и выдадим.


    0800_OutParamsCs_ru/image4.png


    Если же мы меняем в нашем примере MyStruct на CancellationToken, strictDiagnostics будет содержать 1 ошибку (как мы уже видели), а в compatDiagnostics не будет ничего.


    0800_OutParamsCs_ru/image5.png


    Как следствие, приведённое выше условие не выполняется и исполнение метода не прерывается. Куда же девается ошибка компиляции? А она понижается до предупреждения:


    HashSet<Diagnostic> compatDiagnosticSet 
      = new HashSet<Diagnostic>(compatDiagnostics.AsEnumerable(), 
                                SameDiagnosticComparer.Instance);
    compatDiagnostics.Free();
    foreach (var diagnostic in strictDiagnostics.AsEnumerable())
    {
      // If it is a warning (e.g. WRN_AsyncLacksAwaits), 
         or an error that would be reported by the compatible analysis, 
         just report it.
      if (   diagnostic.Severity != DiagnosticSeverity.Error 
          || compatDiagnosticSet.Contains(diagnostic))
      {
        diagnostics.Add(diagnostic);
        continue;
      }
    
      // Otherwise downgrade the error to a warning.
      ErrorCode oldCode = (ErrorCode)diagnostic.Code;
      ErrorCode newCode = oldCode switch
      {
    #pragma warning disable format
        ErrorCode.ERR_UnassignedThisAutoProperty 
          => ErrorCode.WRN_UnassignedThisAutoProperty,
        ErrorCode.ERR_UnassignedThis             
          => ErrorCode.WRN_UnassignedThis,
        ErrorCode.ERR_ParamUnassigned                   // <=      
          => ErrorCode.WRN_ParamUnassigned,
        ErrorCode.ERR_UseDefViolationProperty    
          => ErrorCode.WRN_UseDefViolationProperty,
        ErrorCode.ERR_UseDefViolationField       
          => ErrorCode.WRN_UseDefViolationField,
        ErrorCode.ERR_UseDefViolationThis        
          => ErrorCode.WRN_UseDefViolationThis,
        ErrorCode.ERR_UseDefViolationOut         
          => ErrorCode.WRN_UseDefViolationOut,
        ErrorCode.ERR_UseDefViolation            
          => ErrorCode.WRN_UseDefViolation,
        _ => oldCode, // rare but possible, e.g. 
                         ErrorCode.ERR_InsufficientStack occurring in 
                         strict mode only due to needing extra frames
    #pragma warning restore format
      };
    
      ....
      var args 
         = diagnostic is DiagnosticWithInfo { 
             Info: { Arguments: var arguments } 
           } 
           ? arguments 
           : diagnostic.Arguments.ToArray();
      diagnostics.Add(newCode, diagnostic.Location, args);
    }

    Что здесь происходит в нашем случае при использовании CancellationToken? В цикле происходит обход strictDiagnostics (напоминаю, что там содержится ошибка про неинициализированный out-параметр). Then-ветвь оператора if не исполняется, так как diagnostic.Severity имеет значение DiagnosticSeverity.Error, а коллекция compatDiagnosticSet пуста. А далее происходит маппинг кода ошибки компиляции на новый код — уже предупреждения, после чего это предупреждение формируется и записывается в результирующую коллекцию. Таким вот образом ошибка компиляции превратилась в предупреждение. :)


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


    Выставляем запуск компилятора, указав дополнительный флаг: csc.exe %pathToFile% -w:5


    И видим ожидаемое предупреждение:


    0800_OutParamsCs_ru/image6.png


    Теперь мы разобрались, куда пропадает ошибка компиляции, — она заменяется на низкоприоритетное предупреждение. Однако у нас до сих пор нет ответа на вопрос, в чём же особенность CancellationToken и его отличие от MyStruct? Почему при анализе метода с out-параметром MyStruct compat анализ находит ошибку, а когда тип параметра — CancellationToken — ошибка не обнаруживается?


    Тут я предлагаю заварить чашечку чая или кофе, так как далее нас ждёт более глубокое погружение.


    0800_OutParamsCs_ru/image7.png


    Надеюсь, вы воспользовались советом и подготовились. Мы продолжаем. :)


    Помните метод ReportUnassignedParameter, в котором происходила запись ошибки компиляции? Поднимаемся немного выше и смотрим вызывающий метод:


    protected override void LeaveParameter(ParameterSymbol parameter, 
                                           SyntaxNode syntax, 
                                           Location location)
    {
      if (parameter.RefKind != RefKind.None)
      {
        var slot = VariableSlot(parameter);
        if (slot > 0 && !this.State.IsAssigned(slot))
        {
          ReportUnassignedOutParameter(parameter, syntax, location);
        }
    
        NoteRead(parameter);
      }
    }

    Разница при выполнении этих методов из strict и compat анализа в том, что в первом случае переменная slot имеет значение 1, а во втором — -1. Следовательно, во втором случае не выполняется then-ветвь оператора if. Теперь нужно выяснить, почему во втором случае slot имеет значение -1.


    Смотрим метод LocalDataFlowPass.VariableSlot:


    protected int VariableSlot(Symbol symbol, int containingSlot = 0)
    {
      containingSlot = DescendThroughTupleRestFields(
                         ref symbol, 
                         containingSlot,                                   
                         forceContainingSlotsToExist: false);
    
      int slot;
      return 
        (_variableSlot.TryGetValue(new VariableIdentifier(symbol, 
                                                          containingSlot), 
                                   out slot)) 
        ? slot 
        : -1;
    }

    В нашем случае _variableSlot не содержит слота под out-параметр, соответственно, _variableSlot.TryGetValue(....) возвращает значение false, исполнение кода идёт по alternative-ветви оператора ?:, и из метода возвращается значение -1. Теперь нужно понять, почему _variableSlot не содержит out-параметра.


    0800_OutParamsCs_ru/image8.png


    Покопавшись, находим метод LocalDataFlowPass.GetOrCreateSlot. Выглядит он следующим образом:


    protected virtual int GetOrCreateSlot(
      Symbol symbol, 
      int containingSlot = 0, 
      bool forceSlotEvenIfEmpty = false, 
      bool createIfMissing = true)
    {
      Debug.Assert(containingSlot >= 0);
      Debug.Assert(symbol != null);
    
      if (symbol.Kind == SymbolKind.RangeVariable) return -1;
    
      containingSlot 
        = DescendThroughTupleRestFields(
            ref symbol, 
            containingSlot,
            forceContainingSlotsToExist: true);
    
      if (containingSlot < 0)
      {
        // Error case. Diagnostics should already have been produced.
        return -1;
      }
    
      VariableIdentifier identifier 
        = new VariableIdentifier(symbol, containingSlot);
      int slot;
    
      // Since analysis may proceed in multiple passes, 
         it is possible the slot is already assigned.
      if (!_variableSlot.TryGetValue(identifier, out slot))
      {
        if (!createIfMissing)
        {
          return -1;
        }
    
        var variableType = symbol.GetTypeOrReturnType().Type;
        if (!forceSlotEvenIfEmpty && IsEmptyStructType(variableType))
        {
          return -1;
        }
    
        if (   _maxSlotDepth > 0 
            && GetSlotDepth(containingSlot) >= _maxSlotDepth)
        {
          return -1;
        }
    
        slot = nextVariableSlot++;
        _variableSlot.Add(identifier, slot);
        if (slot >= variableBySlot.Length)
        {
          Array.Resize(ref this.variableBySlot, slot * 2);
        }
    
        variableBySlot[slot] = identifier;
      }
    
      if (IsConditionalState)
      {
        Normalize(ref this.StateWhenTrue);
        Normalize(ref this.StateWhenFalse);
      }
      else
      {
        Normalize(ref this.State);
      }
    
      return slot;
    }

    Из метода видно, что есть ряд условий, когда метод вернёт значение -1, а слот не будет добавлен в _variableSlot. Если же слота под переменную ещё нет, и все проверки проходят успешно, то происходит запись в _variableSlot: _variableSlot.Add(identifier, slot). Отлаживаем код и видим, что при выполнении strict анализа все проверки успешно проходят, а вот при compat анализе мы заканчиваем выполнение метода в следующем операторе if:


    var variableType = symbol.GetTypeOrReturnType().Type;
    if (!forceSlotEvenIfEmpty && IsEmptyStructType(variableType))
    {
      return -1;
    }

    Значение переменной forceSlotEvenIfEmpty в обоих случаях одинаковое (false), а разница в том, какое значение возвращает метод IsEmptyStructType: для strict анализа — false, для compat анализа — true.


    0800_OutParamsCs_ru/image9.png


    Здесь сразу же возникают новые вопросы и желание поэкспериментировать. То есть получается, что, если тип out-параметра — "пустая структура" (позже мы поймём, что это значит), компилятор считает такой код допустимым и не генерирует ошибку? Убираем в нашем примере из MyStruct поле и компилируем.


    struct MyStruct
    {  }
    
    void CheckYourself(out MyStruct obj)
    {
      // Do nothing
    }

    И этот код успешно компилируется! Интересно… Упоминаний таких особенностей в документации и спецификации я что-то не помню. :)


    Но тогда возникает другой вопрос: а как же работает код в случае, когда тип out-параметра — CancellationToken? Ведь это явно не "пустая структура" — если посмотреть код на referencesource.microsoft.com (ссылка на CancellationToken), становится видно, что этот тип содержит и методы, и свойства, и поля… Непонятно, копаем дальше.


    Мы остановились на методе LocalDataFlowPass.IsEmptyStructType:


    protected virtual bool IsEmptyStructType(TypeSymbol type)
    {
      return _emptyStructTypeCache.IsEmptyStructType(type);
    }

    Идём глубже (EmptyStructTypeCache.IsEmptyStructType):


    public virtual bool IsEmptyStructType(TypeSymbol type)
    {
      return IsEmptyStructType(type, ConsList<NamedTypeSymbol>.Empty);
    }

    И ещё глубже:


    private bool IsEmptyStructType(
      TypeSymbol type, 
      ConsList<NamedTypeSymbol> typesWithMembersOfThisType)
    {
      var nts = type as NamedTypeSymbol;
      if ((object)nts == null || !IsTrackableStructType(nts))
      {
        return false;
      }
    
      // Consult the cache.
      bool result;
      if (Cache.TryGetValue(nts, out result))
      {
        return result;
      }
    
      result = CheckStruct(typesWithMembersOfThisType, nts);
      Debug.Assert(!Cache.ContainsKey(nts) || Cache[nts] == result);
      Cache[nts] = result;
    
      return result;
    }

    Выполнение кода идёт через вызов метода EmptyStructTypeCache.CheckStruct:


    private bool CheckStruct(
      ConsList<NamedTypeSymbol> typesWithMembersOfThisType, 
      NamedTypeSymbol nts)
    {
      .... 
      if (!typesWithMembersOfThisType.ContainsReference(nts))
      {
        ....
        typesWithMembersOfThisType 
          = new ConsList<NamedTypeSymbol>(nts, 
                                          typesWithMembersOfThisType);
        return CheckStructInstanceFields(typesWithMembersOfThisType, nts);
      }
    
      return true;
    }

    Здесь исполнение заходит в then-ветвь оператора if, т.к. коллекция typesWithMembersOfThisType пустая (см. метод EmptyStructTypeCache.IsEmptyStructType, где она начинает передаваться в качестве аргумента).


    Какая-то картина уже начинает вырисовываться — теперь становится понятно, что такое "пустая структура". Судя по названиям методов, это такая структура, которая не содержит экземплярных полей. Но я напоминаю, что в CancellationToken экземплярные поля есть. Значит, идём ещё глубже, в метод EmptyStructTypeCache.CheckStructInstanceFields.


    private bool CheckStructInstanceFields(
      ConsList<NamedTypeSymbol> typesWithMembersOfThisType, 
      NamedTypeSymbol type)
    {
      ....
      foreach (var member in type.OriginalDefinition
                                 .GetMembersUnordered())
      {
        if (member.IsStatic)
        {
          continue;
        }
        var field = GetActualField(member, type);
        if ((object)field != null)
        {
          var actualFieldType = field.Type;
          if (!IsEmptyStructType(actualFieldType, 
                                 typesWithMembersOfThisType))
          {
            return false;
          }
        }
      }
    
      return true;
    }

    В методе обходятся экземплярные члены, для каждого из которых получается 'actualField'. Дальше, если удалось получить это значение (field — не null) опять выполняется проверка: а является ли тип этого поля "пустой структурой"? Соответственно, если нашли хотя бы одну "не пустую структуру", изначальный тип также считаем "не пустой структурой". Если все экземплярные поля — "пустые структуры", то изначальный тип также считается "пустой структурой".


    Придётся опуститься ещё немного глубже. Не беспокойтесь, скоро наше погружение закончится, и мы расставим точки над 'i'. :)


    Смотрим метод EmptyStructTypeCache.GetActualField:


    private FieldSymbol GetActualField(Symbol member, NamedTypeSymbol type)
    {
      switch (member.Kind)
      {
        case SymbolKind.Field:
          var field = (FieldSymbol)member;
          ....
          if (field.IsVirtualTupleField)
          {
            return null;
          }
    
          return (field.IsFixedSizeBuffer || 
                  ShouldIgnoreStructField(field, field.Type)) 
                ? null 
                : field.AsMember(type);
    
          case SymbolKind.Event:
            var eventSymbol = (EventSymbol)member;
            return (!eventSymbol.HasAssociatedField || 
                   ShouldIgnoreStructField(eventSymbol, eventSymbol.Type)) 
                 ? null 
                 : eventSymbol.AssociatedField.AsMember(type);
      }
    
      return null;
    }

    Соответственно, для типа CancellationToken нас интересует case-ветвь SymbolKind.Field. В неё мы можем попасть только при анализе члена m_source этого типа (т.к. тип CancellationToken содержит только одно экземплярное поле — m_source).


    Рассмотрим, как происходят вычисления в этой case-ветви в нашем случае.


    field.IsVirtualTupleFieldfalse. Переходим к условному оператору и разберём условное выражение field.IsFixedSizeBuffer || ShouldIgnoreStructField(field, field.Type). field.IsFixedSizeBuffer — не наш случай. Значение, ожидаемо, false. А вот значение, возвращаемое вызовом метода ShouldIgnoreStructField(field, field.Type), различается для strict и compat анализа (напоминаю, мы анализируем одно и то же поле одного и того же типа).


    Смотрим тело метода EmptyStructTypeCache.ShouldIgnoreStructField:


    private bool ShouldIgnoreStructField(Symbol member, 
                                         TypeSymbol memberType)
    {
      // when we're trying to be compatible with the native compiler, we 
         ignore imported fields (an added module is imported)
         of reference type (but not type parameters, 
         looking through arrays)
         that are inaccessible to our assembly.
    
      return _dev12CompilerCompatibility &&                             
             ((object)member.ContainingAssembly != _sourceAssembly ||   
              member.ContainingModule.Ordinal != 0) &&                      
             IsIgnorableType(memberType) &&                                 
             !IsAccessibleInAssembly(member, _sourceAssembly);          
    }

    Посмотрим, что отличается для strict и compat анализа. Хотя, возможно, вы уже догадались самостоятельно. :)


    Strict анализ: _dev12CompilerCompatibilityfalse, следовательно, результат всего выражения — false. Compat анализ: значения всех подвыражений — true, результат всего выражения — true.


    А теперь сворачиваем цепочку, поднимаясь с самого конца. :)


    При compat анализе мы считаем, что должны игнорировать единственное экземплярное поле типа CancellationSourcem_source. Таким образом, мы считаем, что CancellationToken — "пустая структура", следовательно для неё не создаётся слот, и не происходит записи в кэш "пустых структур". Так как слот отсутствует, мы не обрабатываем out-параметр и не записываем ошибку компиляции при выполнении compat анализа. Как результат, strict и compat анализ дают разные результаты, из-за чего происходит понижение ошибки компиляции до низкоприоритетного предупреждения.


    То есть это не какая-то особая обработка типа CancellationToken — есть целый ряд типов, для которых отсутствие инициализации out-параметра не будет приводить к ошибкам компиляции.


    Давайте попробуем посмотреть на практике, для каких типов компиляция будет успешно проходить. Как обычно, берём наш типичный метод:


    void CheckYourself(out MyType obj)
    {
      // Do nothing
    }

    И пробуем подставлять вместо MyType различные типы. Мы уже разобрали, что этот код успешно компилируется для CancellationToken и для пустой структуры. Что ещё?


    struct MyStruct
    { }
    
    struct MyStruct2
    {
      private MyStruct _field;
    }

    Если вместо MyType используем MyStruct2, код также успешно компилируется.


    public struct MyExternalStruct
    {
      private String _field;
    }

    При использовании этого типа код будет успешно компилироваться, если MyExternalStruct объявлен во внешней сборке. Если в одной сборке с методом CheckYourself — не скомпилируется.


    При использовании такого типа из внешней сборки код уже не скомпилируется (поменяли уровень доступа поля _field с private на public):


    public struct MyExternalStruct
    {
      public String _field;
    }

    При таком изменении типа код тоже не будет компилироваться (поменяли тип поля со String на int):


    public struct MyExternalStruct
    {
      private int _field;
    }

    В общем, как вы поняли, здесь есть определённый простор для экспериментов.


    Подытожим


    В общем и целом, out-параметры должны быть проинициализированы до того, как вызываемый метод вернёт управление вызывающему. Однако, как показывает практика, компилятор может внести свои коррективы в это требование, и в некоторых случаях вместо ошибки компиляции будет выдано низкоуровневое предупреждение. Из-за чего именно это происходит мы подробно разобрали в предыдущем разделе.


    Что же по поводу типов, для которых можно не инициализировать out-параметры? Например, не обязательна инициализация параметра, если тип — структура, в которой нет полей. Или если все поля — структуры без полей. Или вот случай с CancellationToken: с ним компиляция успешно проходит, так как этот тип находится во внешней библиотеке, единственное поле m_source ссылочного типа, а само поле недоступно из внешнего кода. В принципе, несложно придумать и составить ещё своих подобных типов, при использовании которых вы сможете не инициализировать out-параметры и успешно компилировать ваш код.


    Возвращаясь к вопросу из начала статьи:


    void CheckYourself(out MyStruct obj)
    {
      // Do nothing
    }
    public struct MyStruct
    { .... }

    Компилируется ли этот код? Как вы уже поняли, ни 'Да', ни 'Нет' не являются правильным ответом. В зависимости от того, что такое MyStruct (какие есть поля, где объявлен тип и т. п.), этот код может либо компилироваться, либо не компилироваться.


    Заключение


    Такое вот у нас сегодня получилось погружение в исходный код компилятора для того, чтобы ответить на, казалось бы, простой вопрос. Я думаю, скоро мы повторим этот опыт, так как тема для следующей подобной статьи уже есть. Так что оставайтесь на связи. ;)


    Кстати, приглашаю подписаться на мой аккаунт в Twitter, где я также выкладываю статьи и прочие интересные находки. Так точно ничего интересного не пропустите. :)


    Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Sergey Vasiliev. Should We Initialize an Out Parameter Before a Method Returns?.

    PVS-Studio
    Статический анализ кода для C, C++, C# и Java

    Похожие публикации

    Комментарии 67

      +9

      Блин. Я всю статью ждал, что в очередном абзаце будет мемасик. Но так и не дождался.


      Похоже, придётся поставить самому

      image

        +6

        Возьму себе на заметку для следующей статьи. :)

          +1

          Да! Хочу единорожку-деппа!

            +2

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

        +3
        О, сколько нам открытий чудных…
        Интересно, нет ли похожих закавык и с ссылочными параметрами — вдруг некоторые из них не обязательно инициализировать перед передачей методу?
          +1
          Не только ссылочные параметры. Там вообще полная вакханалия. Можно не инициализировать поля в конструкторах структур. Можно не инициализировать локальные переменные структурных типов, перед их использованием.
          Дичь
          struct Empty 
          {
              //int gameChanger;
          }
          
          struct DeepEmpty
          {
              public Empty field;
          
              public DeepEmpty(int arg)
              {
                  //field = new Empty();
              }
          }
          class Program
          {
              static void RefTest (ref DeepEmpty arg) 
              {
                  Console.WriteLine(arg);
              }
              static void Main(string[] args)
              {
                  DeepEmpty empty;
          
                  //empty = new DeepEmpty();
          
                  RefTest(ref empty);
          
                  Console.ReadKey();
                  return;
              }
          }
          

            0

            Не, ну это как раз ожидаемое поведение. Вы можете инициализировать вложенные структуры, не вызывая конструкторы на каждый чих, а просто определив все поля. И это хорошо с точки зрения производительности.

              +1
              не вызывая конструкторы на каждый чих, а просто определив все поля

              Не понял, что подразумевается под определением полей?
              Пример выше компилируется, только до тех пор, пока тип Empty является пустой структурой. Если раскомментировать поле gameChanger, то придётся раскомментировать и инициализацию поля field в пользовательском конструкторе, и инициализацию локальной переменной empty в методе Main. На мой взгляд, ситуация аналогична описанной в статье.
              В принципе, логично, что для пустых структур сделано исключение, и их не требуется инициализировать, не только в случае с out параметрами, но и во всех остальных случаях, когда C# требует явной инициализации. И было бы странно, если бы это касалось только out параметров. Но это становится очевидным, только если вы уже знаете о том, что для пустых структур существует некое исключение из правила. А информация эта не очень широко известная, в частности из-за того, что она не упоминается там, где её логично было бы упомянуть, например здесь.
                0
                Не понял, что подразумевается под определением полей?

                Вот это:


                DeepEmpty empty;
                empty.field.gameChanger = ...;

                вместо явных вызовов конструкторов.

              0

              Жаль, что раньше этот комментарий не увидел и написал ниже то же. Есть ли логическое объяснение этой дичи? Зачем делать исключение для пустых структур, особенно если учесть, что пустая структура бессмысленна сама по себе?

                0
                Есть, выше же ссылку кидали. Если вам лень искать и читать то вот краткая выдержка

                So what's the bug?

                The bug that you have discovered is: as a cost savings, the C# compiler does not load the metadata for private fields of structs that are in referenced libraries. That metadata can be huge, and it would slow down the compiler for very little win to load it all into memory every time.

                And now you should be able to deduce the cause of the bug you've found. When the compiler checks to see if the out parameter is definitely assigned, it compares the number of known fields to the number of fields that were definite initialized and in your case it only knows about the zero public fields because the private field metadata was not loaded. The compiler concludes «zero fields required, zero fields initialized, we're good.»

                Like I said, this bug has been around for more than a decade and people like you occasionally rediscover it and report it. It's harmless, and it is unlikely to be fixed because fixing it is of almost zero benefit but a large performance cost.
                  0

                  Ну, "large performance cost" — это был аргумент для старого компилятора. Roslyn, как видно из статьи, всё равно выдаёт предупреждение, и от замены его на ошибку производительность не ухудшится.


                  Судя по коду, основной аргумент за такое поведение сейчас — совместимость со старым компилятором :-(

                    +1

                    Ну так всегда и бывает: сначала захачили ради перфоманса, а потом "исторически сложилось"

                    0

                    Спасибо. Я только вчера завел тут аккаунт. Не привык еще.

              0
              Или если все поля — структуры без полей.

              Или если поля — константы, любого типа.
                +4

                Константы никак не относятся к экземпляру структуры. Так что они тут не причём.

                +13
                Отличный вопрос для собеседования! (нет)
                  +6

                  Да-да, мы с Andrey2008 тоже отшучивались на эту тему. :)

                    +1
                    Поздно, HRы уже дописали его в свой «Вопросы к компьютерщику.docx».
                      0

                      Почему нет? Просто не надо ожидать на него правильный ответ, зато если такой будет то это + много очков кандидату

                        +1
                        Таким вопросом вы разве что поймёте, читает ли кандидат Хабр. При этом, давно подмечено, что чем мачурнее специалист, тем меньше он углубляется в несущественные особенности платформы наподобие тех, что описаны в статье (если это не область его изучения, как у автора). Поэтому, такими вопросами вы скорее отобьёте желание работать у вас.
                          0

                          А кто сказал что мне нужен супер-мачурный специалист? Мб просто читающий хабр миддл который интересуется кишочками подойдет?

                            0
                            Опять же, миддлов много, но среди них вы отдадите предпочтение тем, кто читает хабр, и то не всем, а только тем, кто заметил данную конкретную статью.
                            А кроме хабра, кстати, есть множество интересных изданий. У меня есть гипотеза, почему Хабр умирает: русские программисты наконец-то начали учить английский, поэтому формат русских сайтов их перестал интересовать. А Хабр — это такой ламповый ресурс, куда иногда приятно прийти, почитать статьи типа этой. Редко, но бывает.
                              +2
                              Опять же, миддлов много, но среди них вы отдадите предпочтение тем, кто читает хабр, и то не всем, а только тем, кто заметил данную конкретную статью.

                              Такое ощущение, что это единственный вопрос.


                              По этой причине вообще ничего по нюансам спрашивать нельзя, ведь все эти нюансы описаны только в одном-двух %blogname%


                              Я же не предлагаю делать далекоидущие выводы, особенно если человек не знает. Не знает — и ладно. Знает — прекрасно


                              У меня есть гипотеза, почему Хабр умирает: русские программисты наконец-то начали учить английский, поэтому формат русских сайтов их перестал интересовать. А Хабр — это такой ламповый ресурс, куда иногда приятно прийти, почитать статьи типа этой. Редко, но бывает.

                              Альтернатив хабру я не знаю. Медиум — довольно помоечный и без комментов, реддит — там кто в лес, кто по дрова, в целом уже ближе, но все ещё не то. Уровень контента не очень выдержан. ycombinator/hackernews тоже странный формат имеют.


                              Если у вас есть примеры годных изданий — поделитесь, пожалуйста

                      +5
                      Что ж, раз официальная документация и спецификация языка ответов нам не дали

                      Ну так ноги растут из того, каким образом компилятор C# работает со структурами из отдельных сборок.
                      Липпет говорит о том, что подобное поведение было известно еще лет 10 назад.
                      тут: stackoverflow.com/a/58633459
                      и Тут: docs.microsoft.com/en-us/archive/blogs/ericlippert/a-definite-assignment-anomaly
                        +3

                        Спасибо за ссылки, интересно.


                        Тем не менее, момент этот неочевиден по понятным причинам (опять же, доки, книги и т.п.). По крайней мере, я был удивлён и заинтригован. Поспрашивал некоторых знакомых — они тоже были не в курсе.


                        Так что я решил, что статья должна выйти интересной — вуаля, она перед вами! Я получил большое удовольствие при разборе нюансов и написании, читатели, надеюсь, получат не меньшее при чтении. :)


                        Ну и можно потом коллег поспрашивать, которые не в курсе. Думаю, многие будут удивлены.


                        P.S. Мне, кстати, в последнее время очень достовляет полазить в коде некоторых продуктов от Microsoft. Roslyn, MSBuild, CoreCLR, например. Не то, чтобы я по вечерам читал исходники, но если нужно что-то раскопать — с удовольствием. Так что в некотором будущем ещё чем-нибудь порадую, надеюсь. :)

                          0
                          момент этот неочевиден

                          На мой взгляд, с пустыми структурами, логику понять можно — для них нет других вариантов кроме default(MyEmptyStruct).
                          А вот с другими сборками — да это довольно интересное поведение, которое трудно хоть как-то объяснить:)
                        0
                        Цитируя документацию, вы выделили жирным немного не тот кусок. Обратите внимание на
                        do not have to be initialized before being passed in a method call

                        Естественно что буден некорректным ваш вывод про
                        out-параметры должны быть проинициализированы
                          0

                          Правильно, out параметры не должны быть инициализированы до вызова метода, но они должны быть инициализированы в теле метода.


                          public bool GetRegripPosition(MeshCalculationContext context, 
                                      ILinearAxis axis, Position axisOffset, 
                                      [NotNullWhen(returnValue:true)] out Position regripPosition)
                          {
                                      if (ВСЕ ХОРОШО)
                                      {
                                             regripPosition = ЧТО-ТО ОСМЫСЛЕННОЕ;
                                             return true;
                                      }
                          
                                      regripPosition = default!; // ЭТО ВООБЩЕ НЕ ИМЕЕТ СМЫСЛА И ВРЕМЕНАМИ БЕСИТ, НО СТАНДАРТ ТРЕБУЕТ
                                      return false;
                          }
                          
                            0
                            но они должны быть инициализированы в теле метода.

                            Сама статья началась с примера где инициализация отсутствовала. Так что нет, не должны.
                              0

                              RouR, я прочитал статью. И вижу, что в текущей реализации C# компилятора есть расхождение со стандартом. Тоесть это баг, который возможно будет исправлен в следующих версиях.


                              DistortNeo, отвечу тут, так как у меня коментарии все еще на премодерации.
                              [NotNullWhen] не более, чем атрибут, который указывает синтаксическому анализатору о том, что он должен игнорировать проверки на null, если метод возвращает false. Out параметр приходится инициализировать в любом случае. Если такое поведение не устраивает, то можно использовать ref параметр, который не требует обязательной инициализации в теле метода. В стандарте с логикой все в порядке и мое "бесит" — очень субъективно. :)


                              Просто исторически так сложилось, что код вроде


                              if (int.TryParse("99", out int num))
                              {
                                 .....
                              }

                              использует out параметры. По идее, это дает гарантию, что переменная num всегда будет инициализирована каким-либо значением после вызова метода. Но, как видно из статьи, это не всегда так.

                              0
                              // ЭТО ВООБЩЕ НЕ ИМЕЕТ СМЫСЛА И ВРЕМЕНАМИ БЕСИТ, НО СТАНДАРТ ТРЕБУЕТ

                              Можно попробовать внести изменение в стандарт. Чтоб вместо


                              [NotNullWhen(returnValue:true)]

                              можно было писать


                              [MayBeNotInitializedWhen(returnValue:false)]

                              Соответственно, вызывающий код не имел бы права использовать переменную, не проверив результат функции.

                              0

                              Я думаю, возникло некоторое недопонимание.


                              Вы привели фрагмент из документации касаемо вызывающего метода, который использует аргументы для соответствующих out-параметров. Про него говорится следующее:


                              Variables passed as out arguments do not have to be initialized before being passed in a method call.

                              Я же приводил фрагмент касаемо вызываемого метода и требований к его out-параметрам. Собственно, этот же кейс и рассматривается в статье. Про него:


                              However, the called method is required to assign a value before the method returns.
                              –3

                              Небольшое дополнение, чисто поржать...


                                      private static void CheckYourself(out ITestInterface obj)
                                      {
                                      }
                                      private interface ITestInterface
                                      {
                                      }
                              

                              Так нельзя!!!
                              WeatherForecast.cs(21, 29): [CS0177] The out parameter 'obj' must be assigned to before control leaves the current method


                                      private static void CheckYourself(out MyStruct obj)
                                      {
                                      }
                              
                                      private struct MyStruct : ITestInterface
                                      {
                                      }
                              
                                      private interface ITestInterface
                                      {
                                      }

                              А вот так — нормально!!! :)

                                +2

                                А что не так-то? В первом случае out-параметром может быть экземпляр любого типа, реализующего ITestInterface, либо null. Во втором случае — вполне конкретный тип.

                                  0

                                  Не так то, что в обоих случаях я объявил пустые типы данных. И поведение компилятора разное. Потому что в случае со структурой компилятор знает, что ему нечего инициализировать, и не генерирует


                                  // [38 13 - 38 27]
                                      IL_0001: ldarg.0      // obj
                                      IL_0002: initobj      WebUiTranslate.WeatherForecast/MyStruct

                                  Это можно рассматривать как оптимизацию. Ок, тогда пусть он оптимизирует инициализацию вроде obj = default;, но все же требует ее присутсвие в исходном файле.


                                  А то сейчас и это компилируется, когда структура пустая:


                                          MyStruct test;
                                          CheckYourself(ref test);
                                          private struct MyStruct 
                                          {
                                              //public int a;
                                          }
                                           private static void CheckYourself(ref MyStruct obj)
                                          {
                                              //obj = default;
                                          }
                                    0

                                    Понимаете, что в чём прикол: структуру можно инициализировать двумя способами.


                                    Первый — инициализация нулями:


                                    MyStruct test = new MyStruct();

                                    То же самое:


                                    MyStruct test = default;

                                    Второй — через определение значений полей:


                                    MyStruct test;
                                    test.Field1 = ...;
                                    test.Field2 = ...;

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


                                    Ок, тогда пусть он оптимизирует инициализацию вроде obj = default;, но все же требует её присутствие в исходном файле.

                                    Тогда это вступит в противоречие со вторым способом инициализации.

                                      0

                                      Почему же только два способа? Например, ниже третий и четвертый одновременно. :)


                                              MyStruct test = new MyStruct(1, 2) {a = 3, b = 4};
                                              CheckYourself(ref test);
                                      
                                              private struct MyStruct 
                                              {
                                                  public MyStruct(int ia, int ib)
                                                  {
                                                      a = ia;
                                                      b = ib;
                                                  }
                                                  public int a;
                                                  public int b;
                                              }
                                      

                                      В любом случае мы должны должны инициализировать все поля структуры. Либо через конструктор, либо явно, либо через список инициализации. И понятно, что если нечего инициализировать, то можно считать структуру проинициализированной. Тут я протормозил, спасибо за объяснение. Но все же я остаюсь при своем мнении в плане того, что это решение неправильное, так как приводит к различному поведению компилятора в случае пустых структур и тех, которые содержат поля. Нет никакого противоречия требовать явной инициализации пустой структуры по default или new MyStruct(), так как в этом случае добавление поля в структуру гарантированно не вызовет никаких изменений в вызывающем коде.


                                      А сейчас это не так. Впрочем, это не особо критическая проблема. Но пообщаться было интересно.

                                        0
                                        Почему же только два способа? Например, ниже третий и четвертый одновременно. :)

                                        Потому что их реально только два. Всё остальное — синтаксический сахар.


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


                                        Второй способ: инициализация полей.


                                        Вызов конструктора для структур эквивалентен вызову магического статического метода с первым out-параметром. Вместо


                                        var myStruct = new MyStruct(args...);

                                        можно писать


                                        MyStruct.Init(out var myStruct, args...);

                                        IL-код будет идентичным с точностью до сигнатуры вызываемого метода. Ну а инициализация этого out-параметра в Init-методе сводится к одному из вышеперечисленных способов. Можете написать в нём myStruct = default, тогда будет сделан initobj, а можете просто определить поля.


                                        Либо через конструктор, либо явно, либо через список инициализации.

                                        Насчёт списка инициализации ошибаетесь. Это просто синтаксический сахар для вызова конструктора (или нулевого инициализатора initobj в случае конструктора по умолчанию) с последующим присвоением значений полей.


                                        То есть вот эта строчка:


                                        MyStruct test = new MyStruct(1, 2) { a = 3, b = 4 };

                                        эквивалентна:


                                        MyStruct test = new MyStruct(1, 2);
                                        // Здесь всё поля test полностью определены
                                        test.a = 3;
                                        test.b = 4;

                                        Нет никакого противоречия требовать явной инициализации пустой структуры по default или new MyStruct()

                                        Противоречие есть, потому что стоит добавить поле — и подобная явная инициализация больше не нужна.


                                        Например, у меня в проекте есть обработчик, который имеет некоторое количество полей, описывающих состояние, возможно, нулевое. Для хранения этих полей используется структура. И инициализируется она единообразным способом без всяких new:


                                        SomeStructType someStruct;
                                        someStruct.States.State1 = value1;
                                        someStruct.States.State2 = value2;

                                        Следуя вашей логике, для пустой структуры мне пришлось бы её отдельно инициализировать:


                                        SomeStructType someStruct;
                                        someStruct.States = default;

                                        То есть закономерность нарушается.

                                          –1

                                          Я, возможно, неправильно понимаю слово "закономерность". Но вроде бы это должно означать, что пустая структура инициализируется так же, как и не пустая. Да, пустую структуру было бы неплохо инициализировать. В ином случае посмотрите пример ниже, где в NET 5 структура CancellationToken внезапно начала требовать инициализации. Причина — раньше ее не нужно было явно инициализировать.


                                          Еще раз повторюсь: явная инициализация пустой структуры не мешает, вы ее всегда можете переписать при введении новых полей. Если кода инициализации нет вообще — его нужно будет написать. И в этом разница, когда приходится расширять структуру с 0 полей до N, или с N до N+X.

                                      0
                                      Не так то, что в обоих случаях я объявил пустые типы данных. И поведение компилятора разное.

                                      Вот только переменная типа MyStruct имеет размер 0 байт, а ITestInterface — 4/8 байт.


                                      Соответственно, в первую переменную можно положить только одно значение (default), а во вторую — дофига разных (как минимум null и default(MyStruct), причём упакованных структур может быть сколько с разными адресами).


                                      Соответственно, нет никакого смысла спрашивать программиста что он положит в out MyStruct obj — но важно спросить что он положит в out ITestInterface obj.

                                        0
                                        Вот только переменная типа MyStruct имеет размер 0 байт

                                        Вообще-то 1 байт.

                                          0

                                          Это "приколы" выравнивания, значимых байт в ней ноль.

                                          0

                                          Когда мы работаем с ref и out в метод передается указатель (адрес). Так что с 0 байтов в любом случае мимо. Да, во втором случае имплементацией интерфейса могут быть структуры, классы. А так как объект может поддерживать много интерфейсов, то без явной инициализации не обойтись. Претензии есть к первому варианту, так как при изменении MyStruct смысл что-то спрашивать у программиста внезапно появляется.

                                        0

                                        Проверил этот код не только на NET 5, но и на Mono 6.8.0.105. Поведение ref и out абсолютно идентичное: пустая структура не требует инициализации. Я не знаю чем это объяснить, готов поверить в мировой заговор рептилоидов. :)

                                      –3
                                      >> Особо прошу обратить внимание на выделенное предложение.
                                      Которое на английском…
                                        +3

                                        А что не так? Оно вроде простое.
                                        Гласит, что вызываемый метод должен записать в out-параметр значение до того, как вернет управление.

                                          0
                                          Извините, не подумал что меня заминусят, за то что попросил перевода ( больше никогда такого не будет.
                                            –1

                                            Я за обсуждение технических моментов больше минусов отхватил. Но буду продолжать, пока не надоест. :)

                                        0

                                        Отличный разбор, понравилось, спасибо.


                                        Ещё один 547й повод не использовать out/ref параметры никогда.


                                        Кстати, бонус: в .net5.0 баг не воспроизводится и ошибка компиляции показывается верно


                                        img
                                        img

                                          0

                                          А что если вместо версии рантайма менять версию языка?


                                          https://docs.microsoft.com/ru-ru/dotnet/csharp/language-reference/configure-language-version

                                            0

                                            Поставил C# 2.0 — поведение не поменялось. Можете сами проверить:


                                            1.


                                            <Project Sdk="Microsoft.NET.Sdk">
                                                <PropertyGroup>
                                                    <OutputType>Exe</OutputType>
                                                    <TargetFramework>netcoreapp3.1</TargetFramework>
                                                    <LangVersion>2</LangVersion>
                                                </PropertyGroup>
                                            </Project>

                                            2.


                                            <Project Sdk="Microsoft.NET.Sdk">
                                                <PropertyGroup>
                                                    <OutputType>Exe</OutputType>
                                                    <TargetFramework>net5.0</TargetFramework>
                                                    <LangVersion>2</LangVersion>
                                                </PropertyGroup>
                                            </Project>

                                            Код


                                            using System;
                                            using System.Threading;
                                            
                                            class Program
                                            {
                                                static void Main(string[] args)
                                                {
                                                    Console.WriteLine("Hello World!");
                                                }
                                            
                                                static void Foo(out CancellationToken token)
                                                {
                                            
                                                }
                                            }
                                              +3

                                              Нет смысла менять версию C# в проекте, там проблема в библиотечном коде.


                                              Определение в NET 4.8


                                              public struct CancellationToken
                                                  {
                                                      // The backing TokenSource.  
                                                      // if null, it implicitly represents the same thing as new CancellationToken(false).
                                                      // When required, it will be instantiated to reflect this.
                                                      private CancellationTokenSource m_source;
                                                      //!! warning. If more fields are added, the assumptions in CreateLinkedToken may no longer be valid
                                              ..................
                                               }

                                              Теперь в NET 5.0


                                                public readonly struct CancellationToken
                                                {
                                                  private readonly 
                                                  #nullable disable
                                                  CancellationTokenSource _source;
                                              .............................
                                              }

                                              readonly структура требует readonly полей, включая приватные. Эти поля мы можем инициализировать только в конструкторе. Определить конструктор по умолчанию мы не имеем возможности, так как это структура. В итоге логика с нулем публичных полей больше не работает — мы приплыли! :)


                                              Воспроизводим поведение CancellationToken из NET 5


                                                      MyStruct test; 
                                                      CheckYourself(ref test);
                                              
                                                      public readonly struct MyStruct
                                                      {
                                                          private readonly object _test;
                                              
                                                          public MyStruct(object obj)
                                                          {
                                                              _test = obj;
                                                          }
                                                      }

                                              Не компилируется, требует инициализации.

                                                +1

                                                Спасибо, действительно! Получается, что проблема в компиляторе в целом осталось, но проблема конкретно с CancellationToken'ом за нас решена таким вот образом.


                                                Ну, на безрыбье и рак — ридонли структура.


                                                Правда объяснение немного некорректное получается


                                                readonly структура требует readonly полей, включая приватные. Эти поля мы можем инициализировать только в конструкторе. Определить конструктор по умолчанию мы не имеем возможности, так как это структура. В итоге логика с нулем публичных полей больше не работает — мы приплыли! :)

                                                У нас как было 0 публичных полей — так и осталось.


                                                Воспроизводим поведение CancellationToken из NET 5

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

                                                  +1

                                                  Тогда не все так просто и объяснение неправильное. Только что откомпилировал следующий код без проблем.


                                                          public Task StopAsync(CancellationToken cancellationToken)
                                                          {
                                                              CancellationToken test;
                                                              Test(ref test);
                                                  
                                                              Test2(out CancellationToken test2);
                                                  
                                                              return Task.CompletedTask;
                                                          }
                                                  
                                                          private void Test(ref CancellationToken token)
                                                          {
                                                  
                                                          }
                                                  
                                                          private void Test2(out CancellationToken token)
                                                          {
                                                  
                                                          }
                                                  

                                                  $ dotnet --version
                                                  5.0.103


                                                  Теперь я создаю во внешней сборке


                                                      public struct MyStruct
                                                      {
                                                          private int _test1;
                                                      }

                                                  и вызов


                                                  Test3(out MyStruct test3);
                                                  private void Test3(out MyStruct test)
                                                          {
                                                  
                                                          }

                                                  не компилируется.


                                                    <PropertyGroup>
                                                      <TargetFramework>netstandard2.1</TargetFramework>
                                                      <Nullable>enable</Nullable>
                                                    0

                                                    Все, получилось!


                                                      <PropertyGroup>
                                                        <TargetFramework>net5.0</TargetFramework>
                                                        <LangVersion>9.0</LangVersion>
                                                      </PropertyGroup>

                                                    Теперь оба не компилируются


                                                            private void Test2(out CancellationToken token)
                                                            {
                                                    
                                                            }
                                                    
                                                            private void Test3(out MyStruct test)
                                                            {
                                                    
                                                            }
                                                    

                                                    Честно говоря, теперь у меня нет логического объяснения, как все это работает.

                                                      0

                                                      Попробуйте в MyStruct поменять тип поля на String, например. Или на тот же CancellationTokenSource — должно начать компилироваться c предупреждением. У меня начало, по крайней мере. Кстати, VS сразу начала подсвечивать это место, и warning появился.


                                                      image

                                                        0

                                                        Сделал поле не value типа


                                                            public struct MyStruct
                                                            {
                                                                private string _test1;
                                                            }

                                                        Поведение такое же, как в случае с int.


                                                        Моя структура проекта (стрелками показаны зависимости):
                                                        Главный модуль -> библиотека 1 -> библиотека 2


                                                        MyStruct объявлен в "библиотека 2", вызывающий код в "библиотека 1"


                                                        Проблема в том, что при схеме
                                                        net5.0 -> netstandard2.1 -> netstandard2.1
                                                        все компилируется. И CancellationToken без явной инициализации, и оба варианта MyStruct (string и int)


                                                        При схеме net5.0 -> net5.0 -> netstandard2.1
                                                        CancellationToken не компилируется. MyStruct c с полем типа int не компилируется, замена int на string приводит к успешной компиляции.


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


                                                        P.S. Спасибо за статью. Очень интересная тема :)

                                                          0

                                                          Для .NET 5, в общем, нужная новая статья — сиквел...


                                                          P.S. Спасибо за статью. Очень интересная тема :)

                                                          Рад, что понравилась.
                                                          В будущем надеюсь ещё чем-нибудь порадовать. :)

                                                            0
                                                            Пусть меня опять заминусят, но мне этот цирк не нужен: я просто пропишу в coding conventions требование обязательной явной инициализации структур.

                                                            С чего я и начал: просто не используйте ref/out и будет счастье. Чем помнить нюансы разминирования с одной рукой связанной за спиной лучше не пользоваться дорогой с минами

                                                            +1

                                                            Посмотрел, кстати, вербозные логи сборки.
                                                            Для .NET 5 проектов компилятор запускается с /warn:5, для .NET Core 3.1 — с /warn:4. Отсюда разница в предупреждениях.

                                                  0

                                                  Спасибо за оценку! :)


                                                  Да, с .NET 5 интересный момент. В его случае на "пустые структуры" уже начинает выдаваться warning и VS даже сразу подсветочку организует, а пример с CancellationToken действительно не компилируется. По крайней мере, если собирать через dotnet build или из Visual Studio.


                                                  А вот если напрямки позвать компилятор, то поведение остаётся таким же (по крайней мере, в моих экспериментах, которые я сейчас наспех сделал). Возможно, при использовании сборочной системы при сборке .NET 5 проектов как-то иначе компилятор зовётся или что-нибудь в этом духе.


                                                  Но это всего лишь предположение. Чтобы поточнее сказать, надо бы уже на уровень выше подняться, потрошить MSBuild, CscTask, и с его этого уровня постепенно опускаться, исследовать.

                                                    +1

                                                    Посмотрел по поводу CancellationToken для .NET Core 3.1 проектов и .NET 5 проектов. Один solution, 2 проекта (под соответствующие фреймворки). Код один и тот же, вынесен в общий файл. Сам фрагмент кода классический, который мы рассматривали в статье:


                                                    public void CheckYourself(out CancellationToken ct)
                                                    { }

                                                    Ниже буду приводить названия методов из статьи, так что придётся немного в неё возвращаться, чтобы вспоминать, что происходит. :)


                                                    Для .NET Core 3.1 ожидаемо компилируется.
                                                    Для .NET 5 CancellationToken перестаёт считаться пустой структурой.


                                                    При анализе полей типа CancellationToken находится поле, для которого GetActualField возвращает значение не null. Для этого же поля ShouldIgnoreStructField возвращает false из-за значения подвыражения IsIgnorableType(memberType).


                                                    Как следствие, мы получаем non-null значение переменной field, и потом проверяем тип полученного поля. Тип — не пустая структура -> возвращаем false, считаем CancellationToken не пустой структурой -> создаём слот -> проводим анализ, получаем ошибку -> кол-во ошибок strict и compat анализа одинаковое, выдаём их.


                                                    Что за поле такое, спросите?


                                                    А вот, что:

                                                    image


                                                    Выглядит странно, да? Смотрим, откуда же наш такой интересный CancellationToken подтянулся: System.Runtime.dll.


                                                    Пути до этой библиотеки явно указываются в строке вызова компилятора (смотрел вербозные логи MSBuild) и отличаются для .NET Core 3.1 и .NET 5.


                                                    А теперь, внимание. Ниже 2 скриншота ildasm для CancellationToken из разных библиотек разных фреймворков.


                                                    .NET 5

                                                    image


                                                    .NET Core 3.1

                                                    image


                                                    Заметили разницу? В случае с .NET Core 3.1 нет _dummyPrimitive.
                                                    Из-за этого один CancellationToken — пустая структура, а другой — нет. Как следствие, в одном случае — компилируется, в другом — нет.


                                                    P.S. Если запустить приложение на .NET 5, использущее CancellationToken, взять у него тип и спросить сборку, можно заметить, что тип вообще из другой тянется.

                                                    +1
                                                    тезисы, взятые с docs.microsoft.com

                                                    Кажется я нашел пункт который говорит, что Empty структуры в Out параметрах можно не инициализировать в теле функции:
                                                    Тут
                                                    Definite assignment

                                                    A struct_type variable is considered definitely assigned if each of its instance variables is considered definitely assigned.
                                                      +1

                                                      Получается, что такое поведение структур описано в стандарте, как минимум в драфте версии 6.0
                                                      https://github.com/ljw1004/csharpspec/blob/gh-pages/variables.md


                                                      In order to determine that each used variable is definitely assigned, the compiler must use a process that is equivalent to the one described in this section.

                                                      The compiler processes the body of each function member that has one or more initially unassigned variables. For each initially unassigned variable v, the compiler determines a definite assignment state for v at each of the following points in the function member.

                                                      Процесс сложный, в связи с этим наблюдаются забавные расхождения в поведении компилятора, когда компилируются две сборки с CancellationToken под net5.0 и netstandard2.1.

                                                        +1

                                                        Хм, интересно.


                                                        Ачивмент ваш. :)

                                                        image


                                                        Могли бы при описании out параметров в спецификации сразу дать ссылочку и на этот раздел что-ли. Вот здесь, например:


                                                        Every output parameter of a method must be definitely assigned before the method returns.
                                                        0
                                                        Все поля структур должны быть проинициализированы. А если полей нет, зачем требовать?
                                                        Имхо, довольно логично добавить код, проверяющий, что «пустая» структура не должна требовать инициализации. Например, такая:

                                                        public struct MyStruct { public string _test => "Test"; }

                                                        static void CheckYourself(out MyStruct obj) { } // no compilation error

                                                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                        Самое читаемое