Русский
Русский
English
Статистика
Реклама

Roslyn

Duck typing и C

05.01.2021 10:07:35 | Автор: admin

Доброго времени суток. В последнее время я много эксперементрировал с .Net 5 и его Source Generator-ами. И мне внезапно пришла идея как можно использовать Source Generator-ы для реализации "duck typing"-а в C#. Я не мог просто оставить эту идею. В итоге вышла, я бы сказал, чисто акамическая штука(никто не будет использовать это на проде, я надеюсь), но результат получился довольно интересен. Всем кому интересно прошу под кат!


Спойлер

Я не буду очень углубляться в саму реализацию. Её можно посмотреть в репозитории ссылка на который будет внизу. Там нет ничего сложного для тех кто уже баловался с генераторами, а для все остальных потребуется намного большая статья.


Как этим пользоваться


Представим что у нас есть следующий пример:


public interface ICalculator{  float Calculate(float a, float b);}public class AddCalculator{  float Calculate(float a, float b);}

Важно отметить что AddCalculator никаким образом не реализует ICalculator.
Они лишь имеют идентичные сигнатуры. Если мы попытаемся использовать их следующим образом, то у нас ничего не получится:


var addCalculator = new AddCalculator();var result = Do(addCalculator, 10, 20);float Do(ICalculator calculator, float a, float b){  return calculator.Calculate(a, b);}

Компилятор С# скажет следующее:


Argument type 'AddCalculator' is not assignable to parameter type 'ICalculator'


И он будет прав. Но поскольку сигнатура AddCalculator полностью совпадает с ICalculator и нам очень хочеться это сделать, то решением может быть duck typing который не работает в С#. Иммено тут и пригодится nuget пакет DuckInterface. Все что нужно будет сделать, это установить его и немножечко подправить наши сигнатуры. Начнем с интерфейса добавив к нему аттрибут Duckable:


[Duckable]public interface ICalculator{  float Calculate(float a, float b);}

Дальше обновим метод Do. Нужно заменить ICalculator на DICalculator. DICalculator это класс который был сгенерен нашим DuckInterface.
Сигнатура DICalculator полностью совпадает с ICalculator и может содержать неявные преобразования для нужных типов. Все эти неявные преобразования будут генериться в тот момент когда мы пишем код в нашей IDE. Генерится они будуть в зависимости от того как мы используем наш DICalculator.


Итоговый пример:


var addCalculator = new AddCalculator();var result = Do(addCalculator, 10, 20);float Do(DICalculator calculator, float a, float b){  return calculator.Calculate(a, b);}

И это всё. Ошибок компиляции больше нет и все работает как часы.


Как это работает


Здесь используются два независимых генератора. Первый ищет аттрибут Duckable и генерит "базовый" класс для интерфейса. Например, для ICalculator он будет иметь следующий вид:


public partial class DICalculator : ICalculator {  [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]   private readonly Func<float, float, float> _Calculate;          [System.Diagnostics.DebuggerStepThrough]  public float Calculate(float a, float b)  {      return _Calculate(a, b);  }}

Второй генератор ищет вызовы методов и присваивания чтобы понять как duckable интерфейс используется. Расмотрим следующий пример:


var result = Do(addCalculator, 10, 20);

Анализатор увидит что метод Do имеет первый аргумент типа DICalculator, а потом проверит переменную addCalculator. Если её тип имеет все необходимые поля и методы, то генератор расширит DICalculator следующим образом:


public partial class DICalculator{  private DICalculator(global::AddCalculator value)   {       _Calculate = value.Calculate;  }  public static implicit operator DICalculator(global::AddCalculator value)  {      return new DICalculator(value);  }}

Поскольку DICalculator это partial class мы можем реализовать подобные расширения для нескольких типов сразу и ничего не сломать. Этот трюк работает не только для методов, но и для пропертей:


Пример:


[Duckable]public interface ICalculator{    float Zero { get; }    float Value { get; set; }    float Calculate(float a, float b);}// ....public partial class DICalculator : ICalculator {    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]     private readonly Func<float> _ZeroGetter;    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]     private readonly Func<float> _ValueGetter;    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]     private readonly Action<float> _ValueSetter;    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]     private readonly Func<float, float, float> _Calculate;            public float Zero    {         [System.Diagnostics.DebuggerStepThrough] get { return _ZeroGetter(); }    }    public float Value    {         [System.Diagnostics.DebuggerStepThrough] get { return _ValueGetter(); }         [System.Diagnostics.DebuggerStepThrough] set { _ValueSetter(value); }    }    [System.Diagnostics.DebuggerStepThrough]    public float Calculate(float a, float b)    {        return _Calculate(a, b);    }}

Что не работает


На этом хорошие новости закончились. Всетаки реализовать прямо вездесущий duck typing не получится. Поскольку мы скованы самим компилятором. А именно будут проблемы с дженериками и ref struct-урами. В теории часть проблем с дженериками можно починить, но не все. Например, было бы прикольно чтобы мы могли использовать наши интерфейсы вместе с where как-то вот так:


float Do<TCalcualtor>(TCalcualtor calculator, float a, float b)    where TCalcualtor: DICalculator{  return calculator.Calculate(a, b);}

В таком случаи мы могли бы получили прямо zero cost duct typing(и щепотку метапрограмирования, если копнуть глубже), поскольку, мы легко можем заменить partial class на partial struct в реализации нашего duck интерфейса. В результате, было бы сгенерено множестао Do методов для каждого уникального TCalcualtorкак это происходит со структурами. Но увы, компилятор нам скажет, что нечего такого он не умеет.
На этом все. Спасибо за внимание!


Nuget тут: https://www.nuget.org/packages/DuckInterface/
Github тут: https://github.com/byme8/DuckInterface

Подробнее..

Nullable Reference не защищают, и вот доказательства

06.10.2020 12:23:44 | Автор: admin
image1.png

Хотели ли вы когда-нибудь избавиться от проблемы с разыменованием нулевых ссылок? Если да, то использование Nullable Reference типов это не ваш выбор. Интересно почему? Об этом сегодня и пойдёт речь.

Мы предупреждали, и это случилось. Около года назад мои коллеги написали статью, в которой предупреждали о том, что введение Nullable Reference типов не защитит от разыменований нулевых ссылок. Теперь у нас есть реальное подтверждение наших слов, которое было найдено в глубинах Roslyn.

Nullable Reference типы


Сама задумка добавить Nullable Reference (далее NR) типы мне кажется интересной, так как проблема, связанная с разыменованием нулевых ссылок, актуальна и по сей день. Реализация же защиты от разыменований получилась крайне ненадежной. По задумке создателей принимать значение null могут только те переменные, тип которых помечен символом "?". Например, переменная типа string? говорит о том, что в ней может содержаться null, типа string наоборот.

Однако нам все равно никто не запрещает передавать null в переменные non-nullable reference (далее NNR) типов, ведь реализованы они не на уровне IL кода. За данное ограничение отвечает встроенный в компилятор статический анализатор. Поэтому данное нововведение скорее носит рекомендательный характер. Вот простой пример, показывающий, как это работает:

#nullable enableobject? nullable = null;object nonNullable = nullable;var deref = nonNullable.ToString();

Как мы видим, тип у nonNullable указан как NNR, но при этом мы спокойно можем передать туда null. Конечно, мы получим предупреждение о конвертации "Converting null literal or possible null value to non-nullable type.". Однако это можно обойти, добавив немного агрессии:

#nullable enableobject? nullable = null;object nonNullable = nullable!; // <=var deref = nonNullable.ToString();

Один восклицательный знак, и нет никаких предупреждений. Если кто-то из вас гурман, то доступен еще такой вариант:

#nullable enableobject nonNullable = null!;var deref = nonNullable.ToString();

Ну и еще один пример. Создаем два простых консольных проекта. В первом пишем:

namespace NullableTests{    public static class Tester    {        public static string RetNull() => null;    }}

Во втором пишем:

#nullable enable namespace ConsoleApp1{    class Program    {        static void Main(string[] args)        {            string? nullOrNotNull = NullableTests.Tester.RetNull();            System.Console.WriteLine(nullOrNotNull.Length);        }    }}

Наводим на nullOrNotNull и видим вот такое сообщение:

image2.png

Нам подсказывают, что строка здесь не может быть null. Однако мы-то понимаем, что она здесь как раз будет null. Запускаем проект и получаем исключение:

image3.png

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

У NR типов есть еще одна проблема непонятно, включены они или нет. Например, в решении имеется два проекта. Один размечен с помощью данного синтаксиса, а другой нет. Зайдя в проект с NR типами, можно решить, что раз размечен один, то размечены все. Однако это будет не так. Получается, нужно каждый раз смотреть, а включен ли в проекте или файле nullable context. Иначе же можно по ошибке решить, что обычный referenceтип это NNR.

Как были найдены доказательства


При разработке новых диагностик в анализаторе PVS-Studio мы всегда тестируем их на нашей базе реальных проектов. Это помогает в различных аспектах. Например:

  • посмотреть "вживую" на качество полученных предупреждений;
  • избавиться от части ложных срабатываний;
  • найти интересные моменты в коде, о которых потом можно рассказать;
  • и так далее.

Одна из новых диагностик V3156 нашла места, в которых из-за потенциального null могут возникать исключения. Формулировка диагностического правила звучит так: "The argument of the method is not expected to be null". Суть ее в том, что в метод, не ожидающий null, в качестве аргумента может передаваться значение null. Это может привести, например, к возникновению исключения или неправильному исполнению вызываемого метода. Подробнее о данном диагностическом правиле вы можете почитать здесь.

Пруфы тут


Вот мы и дошли до основной части данной статьи. Тут будут показаны реальные фрагменты кода из проекта Roslyn, на которые диагностика выдала предупреждения. Основной их смысл в том, что либо в NNR тип передается null, либо отсутствует проверка значения NR типа. Все это может привести к возникновению исключения.

Пример 1

private static Dictionary<object, SourceLabelSymbol>BuildLabelsByValue(ImmutableArray<LabelSymbol> labels){  ....  object key;  var constantValue = label.SwitchCaseLabelConstant;  if ((object)constantValue != null && !constantValue.IsBad)  {    key = KeyForConstant(constantValue);  }  else if (labelKind == SyntaxKind.DefaultSwitchLabel)  {    key = s_defaultKey;  }  else  {    key = label.IdentifierNodeOrToken.AsNode();  }  if (!map.ContainsKey(key))                // <=  {    map.Add(key, label);  }   ....}

V3156 The first argument of the 'ContainsKey' method is not expected to be null. Potential null value: key. SwitchBinder.cs 121

Сообщение гласит, что key является потенциальным null. Давайте посмотрим, где эта переменная может получить такое значение. Проверим сначала метод KeyForConstant:

protected static object KeyForConstant(ConstantValue constantValue){  Debug.Assert((object)constantValue != null);  return constantValue.IsNull ? s_nullKey : constantValue.Value;}private static readonly object s_nullKey = new object();

Так как s_nullKey не являетcя null, смотрим, что возвращает constantValue.Value:

public object? Value{  get  {    switch (this.Discriminator)    {      case ConstantValueTypeDiscriminator.Bad: return null;  // <=      case ConstantValueTypeDiscriminator.Null: return null; // <=      case ConstantValueTypeDiscriminator.SByte: return Boxes.Box(SByteValue);      case ConstantValueTypeDiscriminator.Byte: return Boxes.Box(ByteValue);      case ConstantValueTypeDiscriminator.Int16: return Boxes.Box(Int16Value);      ....      default: throw ExceptionUtilities.UnexpectedValue(this.Discriminator);    }  }}

Здесь есть два нулевых литерала, но в данном случае мы не зайдем ни в один case с ними. Это происходит из-за проверок IsBad и IsNull. Однако я бы хотел обратить ваше внимание на возвращаемый тип данного свойства. Он является NR типом, но при этом метод KeyForConstant уже возвращает NNR тип. Получается, что в общем случае вернуть null метод KeyForConstant может.

Другой источник, который может вернуть null, метод AsNode:

public SyntaxNode? AsNode(){  if (_token != null)  {    return null;  }  return _nodeOrParent;}

Снова прошу обратить внимание на возвращаемый тип метода это NR тип. Получается, когда мы говорим, что из метода может вернуться null, то это ни на что не влияет. Интересно то, что компилятор здесь не ругается на преобразование из NR в NNR:

image4.png

Пример 2

private SyntaxNode CopyAnnotationsTo(SyntaxNode sourceTreeRoot,                                      SyntaxNode destTreeRoot){    var nodeOrTokenMap = new Dictionary<SyntaxNodeOrToken,                                       SyntaxNodeOrToken>();  ....  if (sourceTreeNodeOrTokenEnumerator.Current.IsNode)  {    var oldNode = destTreeNodeOrTokenEnumerator.Current.AsNode();    var newNode = sourceTreeNodeOrTokenEnumerator.Current.AsNode()                                       .CopyAnnotationsTo(oldNode);            nodeOrTokenMap.Add(oldNode, newNode); // <=  }  ....}

V3156 The first argument of the 'Add' method is not expected to be null. Potential null value: oldNode. SyntaxAnnotationTests.cs 439

Еще один пример с функцией AsNode, которая была описана выше. Только в этот раз oldNode будет иметь NR тип. В то время как описанная выше key имела NNR тип.

Кстати, не могу не поделиться с вами интересным наблюдением. Как я уже описал выше, при разработке диагностики мы проверяем ее на разных проектах. При проверке срабатываний данного правила был замечен любопытный момент. Около 70% всех срабатываний было выдано на методы класса Dictionary. При этом большая их часть пришлась на метод TryGetValue. Возможно, это происходит из-за того, что подсознательно мы не ожидаем исключений от метода, который содержит слово try. Поэтому проверьте свой код на этот паттерн, вдруг у вас найдется что-то подобное.

Пример 3

private static SymbolTreeInfo TryReadSymbolTreeInfo(    ObjectReader reader,    Checksum checksum,    Func<string, ImmutableArray<Node>,     Task<SpellChecker>> createSpellCheckerTask){  ....  var typeName = reader.ReadString();  var valueCount = reader.ReadInt32();  for (var j = 0; j < valueCount; j++)  {    var containerName = reader.ReadString();    var name = reader.ReadString();    simpleTypeNameToExtensionMethodMap.Add(typeName, // <=                            new ExtensionMethodInfo(containerName, name));   }  ....}

V3156 The first argument of the 'Add' method is passed as an argument to the 'TryGetValue' method and is not expected to be null. Potential null value: typeName. SymbolTreeInfo_Serialization.cs 255

Анализатор говорит, что проблема заключается в typeName. Давайте сначала убедимся, что этот аргумент действительно является потенциальным null. Смотрим на ReadString:

public string ReadString() => ReadStringValue();

Так, смотрим ReadStringValue:

private string ReadStringValue(){  var kind = (EncodingKind)_reader.ReadByte();  return kind == EncodingKind.Null ? null : ReadStringValue(kind);}

Отлично, теперь освежим память, посмотрев, куда же передавалась наша переменная:

simpleTypeNameToExtensionMethodMap.Add(typeName, // <=                              new ExtensionMethodInfo(containerName,                                                      name));

Думаю, самое время зайти внутрь метода Add:

public bool Add(K k, V v){  ValueSet updated;  if (_dictionary.TryGetValue(k, out ValueSet set)) // <=  {    ....  }  ....}

Действительно, если в метод Add в качестве первого аргумента передать null, то мы получим исключение ArgumentNullException.

Кстати, интересно, что если в Visual Studio навести курсор на typeName, то мы увидим, что тип у него string?:

image5.png

При этом возвращаемый тип метода просто string:

image6.png

При этом, если далее создать переменную NNR типа и ей присвоить typeName, то никакой ошибки не будет выведено.

Попробуем уронить Roslyn


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

image7.png

Тест 1

Возьмем пример, описанный под номером 3:

private static SymbolTreeInfo TryReadSymbolTreeInfo(    ObjectReader reader,    Checksum checksum,    Func<string, ImmutableArray<Node>,     Task<SpellChecker>> createSpellCheckerTask){  ....  var typeName = reader.ReadString();  var valueCount = reader.ReadInt32();  for (var j = 0; j < valueCount; j++)  {    var containerName = reader.ReadString();    var name = reader.ReadString();    simpleTypeNameToExtensionMethodMap.Add(typeName, // <=                            new ExtensionMethodInfo(containerName, name));   }  ....}

Для того, чтобы его воспроизвести, потребуется вызвать метод TryReadSymbolTreeInfo, но он является private. Хорошо, что в классе с ним есть метод ReadSymbolTreeInfo_ForTestingPurposesOnly, который уже является internal:

internal static SymbolTreeInfo ReadSymbolTreeInfo_ForTestingPurposesOnly(    ObjectReader reader,     Checksum checksum){  return TryReadSymbolTreeInfo(reader, checksum,          (names, nodes) => Task.FromResult(            new SpellChecker(checksum,                              nodes.Select(n => new StringSlice(names,                                                                n.NameSpan)))));}

Очень приятно, что нам прям предлагают протестировать метод TryReadSymbolTreeInfo. Поэтому давайте рядышком создадим свой класс и напишем следующий код:

public class CheckNNR{  public static void Start()  {    using var stream = new MemoryStream();    using var writer = new BinaryWriter(stream);    writer.Write((byte)170);    writer.Write((byte)9);    writer.Write((byte)0);    writer.Write(0);    writer.Write(0);    writer.Write(1);    writer.Write((byte)0);    writer.Write(1);    writer.Write((byte)0);    writer.Write((byte)0);    stream.Position = 0;    using var reader = ObjectReader.TryGetReader(stream);    var checksum = Checksum.Create("val");    SymbolTreeInfo.ReadSymbolTreeInfo_ForTestingPurposesOnly(reader, checksum);  }}

Теперь собираем Roslyn, создаем простое консольное приложение, подключаем все необходимые dll-файлы и пишем вот такой код:

static void Main(string[] args){  CheckNNR.Start();}

Запускаем, доходим до необходимого места и видим:

image8.png

Далее заходим в метод Add и получаем ожидаемое исключение:

image9.png

Напомню, что метод ReadString возвращает NNR тип, который по задумке не может содержать null. Данный пример лишний раз подтверждает актуальность диагностических правил PVS-Studio для поиска разыменования нулевых ссылок.

Тест 2

Ну и раз мы уже начали воспроизводить примеры, то почему бы не воспроизвести еще один. Этот пример не будет связан с NR типами. Однако его нашла все та же диагностика V3156, и мне захотелось о нем рассказать. Вот код:

public SyntaxToken GenerateUniqueName(SemanticModel semanticModel,                                       SyntaxNode location,                                       SyntaxNode containerOpt,                                       string baseName,                                       CancellationToken cancellationToken){  return GenerateUniqueName(semanticModel,                             location,                             containerOpt,                             baseName,                             filter: null,                             usedNames: null,    // <=                            cancellationToken);}

V3156 The sixth argument of the 'GenerateUniqueName' method is passed as an argument to the 'Concat' method and is not expected to be null. Potential null value: null. AbstractSemanticFactsService.cs 24

Скажу честно: делая данную диагностику, я не особо ожидал срабатываний на прямой null. Ведь достаточно странно отправлять null в метод, который из-за этого выбросит исключение. Хотя я видел места, когда это было обосновано (например, с классом Expression), но сейчас не об этом.

Поэтому я был очень заинтригован, когда увидел данное предупреждение. Давайте же посмотрим, что происходит в методе GenerateUniqueName.

public SyntaxToken GenerateUniqueName(SemanticModel semanticModel,                                      SyntaxNode location,                                       SyntaxNode containerOpt,                                      string baseName,                                       Func<ISymbol, bool> filter,                                      IEnumerable<string> usedNames,                                       CancellationToken cancellationToken){  var container = containerOpt ?? location                       .AncestorsAndSelf()                       .FirstOrDefault(a => SyntaxFacts.IsExecutableBlock(a)                                          || SyntaxFacts.IsMethodBody(a));  var candidates = GetCollidableSymbols(semanticModel,                                         location,                                         container,                                         cancellationToken);  var filteredCandidates = filter != null ? candidates.Where(filter)                                           : candidates;  return GenerateUniqueName(baseName,                             filteredCandidates.Select(s => s.Name)                                              .Concat(usedNames));     // <=}

Мы видим, что из метода только один выход, исключения не выбрасываются, да и goto нет. Иными словами, ничего не мешает передать usedNames в метод Concat и получить исключение ArgumentNullException.

Но это все слова, давайте же сделаем это. Для этого смотрим, откуда можно вызвать данный метод. Сам метод находится в классе AbstractSemanticFactsService. Класс является абстрактным, поэтому для удобства возьмем класс CSharpSemanticFactsService, который от него наследуется. В файле этого класса создадим свой, который и будет вызывать метод GenerateUniqueName. Выглядит это следующим образом:

public class DropRoslyn{  private const string ProgramText =     @"using System;    using System.Collections.Generic;    using System.Text    namespace HelloWorld    {      class Program      {        static void Main(string[] args)        {          Console.WriteLine(""Hello, World!"");        }      }    }";    public void Drop()  {    var tree = CSharpSyntaxTree.ParseText(ProgramText);    var instance = CSharpSemanticFactsService.Instance;    var compilation = CSharpCompilation                      .Create("Hello World")                      .AddReferences(MetadataReference                                     .CreateFromFile(typeof(string)                                                     .Assembly                                                     .Location))                      .AddSyntaxTrees(tree);        var semanticModel = compilation.GetSemanticModel(tree);    var syntaxNode1 = tree.GetRoot();    var syntaxNode2 = tree.GetRoot();        var baseName = "baseName";    var cancellationToken = new CancellationToken();        instance.GenerateUniqueName(semanticModel,                                 syntaxNode1,                                 syntaxNode2,                                 baseName,                                 cancellationToken);  }}

Теперь собираем Roslyn, создаем простое консольное приложение, подключаем все необходимые dll-файлы и пишем вот такой код:

class Program{  static void Main(string[] args)  {    DropRoslyn dropRoslyn = new DropRoslyn();    dropRoslyn.Drop();  }}

Запускаем приложение и получаем следующее:

image10.png

Это вводит в заблуждение


Допустим мы соглашаемся с nullable концепцией. Получается, если мы видим NR тип, то считаем, что в нем может находиться потенциальный null. Однако иногда можно увидеть ситуации, когда компилятор нам говорит обратное. Поэтому здесь будет рассмотрено несколько случаев, когда использование данной концепции не является интуитивно понятным.

Случай 1

internal override IEnumerable<SyntaxToken>? TryGetActiveTokens(SyntaxNode node){  ....  var bodyTokens = SyntaxUtilities                   .TryGetMethodDeclarationBody(node)                   ?.DescendantTokens();  if (node.IsKind(SyntaxKind.ConstructorDeclaration,                   out ConstructorDeclarationSyntax? ctor))  {    if (ctor.Initializer != null)    {      bodyTokens = ctor.Initializer                       .DescendantTokens()                       .Concat(bodyTokens); // <=    }  }  return bodyTokens;}

V3156 The first argument of the 'Concat' method is not expected to be null. Potential null value: bodyTokens. CSharpEditAndContinueAnalyzer.cs 219

Сразу смотрим, почему bodyTokens является потенциальным null и видим null-условный оператор:

var bodyTokens = SyntaxUtilities                 .TryGetMethodDeclarationBody(node)                 ?.DescendantTokens();              // <=

Если зайти в метод TryGetMethodDeclarationBody, то мы увидим, что он может вернуть null. Однако он относительно большой, поэтому я оставляю ссылку на него, если вы захотите лично убедиться в этом. С bodyTokens все понятно, но я хочу обратить внимание на аргумент ctor:

if (node.IsKind(SyntaxKind.ConstructorDeclaration,                 out ConstructorDeclarationSyntax? ctor))

Как мы видим, его тип задан как NR. При этом строчкой ниже происходит разыменование:

if (ctor.Initializer != null)

Такое сочетание немного настораживает. Впрочем, вы скажете, что, скорее всего, если IsKind возвращает true, то ctor точно не равен null. Так оно и есть:

public static bool IsKind<TNode>(    [NotNullWhen(returnValue: true)] this SyntaxNode? node, // <=    SyntaxKind kind,    [NotNullWhen(returnValue: true)] out TNode? result)     // <=    where TNode : SyntaxNode {  if (node.IsKind(kind))  {    result = (TNode)node;    return true;  }  result = null;  return false;}

Тут используются специальные атрибуты, которые указывают, при каком выходном значении параметры не будут равны null. В этом же мы можем убедиться, посмотрев на логику метода IsKind. Получается, что внутри условия тип у ctor должен быть NNR. Компилятор это понимает и говорит, что ctor внутри условия будет не null. Однако, чтобы это понять нам, мы должны зайти в метод IsKind и там заметить атрибут. Иначе же это выглядит как разыменование NR переменной без проверки на null. Можно попробовать добавить немного наглядности следующим образом:

if (node.IsKind(SyntaxKind.ConstructorDeclaration,                 out ConstructorDeclarationSyntax? ctor)){    if (ctor!.Initializer != null) // <=    {      ....    }}

Случай 2

public TextSpan GetReferenceEditSpan(InlineRenameLocation location,                                      string triggerText,                                      CancellationToken cancellationToken){  var searchName = this.RenameSymbol.Name;  if (_isRenamingAttributePrefix)  {    searchName = GetWithoutAttributeSuffix(this.RenameSymbol.Name);  }  var index = triggerText.LastIndexOf(searchName,            // <=                                      StringComparison.Ordinal);  ....}

V3156 The first argument of the 'LastIndexOf' method is not expected to be null. Potential null value: searchName. AbstractEditorInlineRenameService.SymbolRenameInfo.cs 126

Нас интересует переменная searchName. null может быть записан в неё после вызова метода GetWithoutAttributeSuffix, но не все так просто. Давайте посмотрим, что же в нем происходит:

private string GetWithoutAttributeSuffix(string value)    => value.GetWithoutAttributeSuffix(isCaseSensitive:                _document.GetRequiredLanguageService<ISyntaxFactsService>()                         .IsCaseSensitive)!;

Давайте зайдем глубже:

internal static string? GetWithoutAttributeSuffix(            this string name,            bool isCaseSensitive){  return TryGetWithoutAttributeSuffix(name, isCaseSensitive, out var result)          ? result : null;}

Получается, метод TryGetWithoutAttributeSuffix, вернет либо result, либо null. Да и метод возвращает NR тип. Однако, вернувшись на шаг назад, мы заметим, что тип метода неожиданно поменялся на NNR. Происходит это из-за притаившегося знака "!":

_document.GetRequiredLanguageService<ISyntaxFactsService>()         .IsCaseSensitive)!; // <=

Кстати, заметить его в Visual Studio довольно сложно:

image11.png

Поставив его, разработчик утверждает нам, что метод никогда не вернет null. Хотя, смотря на предыдущие примеры и зайдя в метод TryGetWithoutAttributeSuffix, лично я не могу быть в этом уверен:

internal static bool TryGetWithoutAttributeSuffix(            this string name,            bool isCaseSensitive,            [NotNullWhen(returnValue: true)] out string? result){  if (name.HasAttributeSuffix(isCaseSensitive))  {    result = name.Substring(0, name.Length - AttributeSuffix.Length);    return true;  }  result = null;  return false;}

Вывод


В заключение я хочу сказать, что попытка избавить нас от лишних проверок на null, это отличная идея. Однако NR типы носят скорее рекомендательный характер, ведь нам никто строго не запрещает передать null в NNR тип. Именно поэтому сохраняют актуальность соответствующие правила PVS-Studio. Например, такие как V3080 или V3156.

Всего вам доброго и спасибо за внимание.


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikolay Mironov. Nullable Reference will not protect you, and here is the proof.
Подробнее..

Оживляем деревья выражений кодогенерацией

02.01.2021 00:07:07 | Автор: admin

Деревья выражений System.Linq.Expressions дают возможность выразить намерения не только самим кодом, но и его структурой, синтаксисом.

Их создание из лямбда-выражений это, по сути, синтаксический сахар, при котором пишется обычный код, а компилятор строит из него синтаксическое дерево (AST), которое в том числе включает ссылки на объекты в памяти, захватывает переменные. Это позволяет манипулировать не только данными, но и кодом, в контексте которого они используются: переписывать, дополнять, пересылать, а уже потом компилировать и выполнять.

Run-time компиляция порождает производительные делегаты, которые часто быстрее тех, что компилируются во время сборки (за счет меньшего оверхеда). Однако сама компиляция происходит до десятков тысяч раз дольше, чем вызов результата компиляции.

(бенчмарк)

Действие

Время, нс

Cached Compile Invoke

0.5895 0.0132 ns

Compile and Invoke

83,292.3139 922.4315 ns

Это особенно обидно, когда выражение простое, например содержит только доступ к свойству (в библиотеках для маппинга, сериализации, дата-байндинга), вызову конструктора или метода (для IoC/DI решений).

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

Для уменьшения времени получения делегатов из деревьев выражений используют:

  • Встроенную интерпретацию.
    Необходимость использования интерпретатора вместо компилятора указывается соответствующим флагом:

    Expression.Compile(preferInterpretation: true)
    

    Происходит через рефлексию, но с накладными расходами на формирование стека инструкций.

    Для платформ Xamarin.iOS, Xamarin.watchOS, Xamarin.tvOS, Mono.PS4 и Mono.XBox стандартная компиляция через генерацию IL (System.Reflection.Emit) долгое время была недоступна и на данный момент под капотом всегда откатывается к этому варианту.

  • FastExpressionCompile от @dadhi.
    Ускоряет компиляцию за счет оптимизиpованной генерации IL и с меньшим количеством проверок совместимости.

    На платформах без поддержки JIT компиляции может использоваться только с включенным Mono Interpreter.

  • Ручную интерпретацию.
    Используется для оптимизации вызовов рефлексии под специальные сценарии использования, например для добавления кэширования отдельных вызовов.

    Интерпретируя вручную, уже можно воспользоваться способами ускорения рефлексии. Самые эффективные из них, например Fasterflect, используют System.Reflection.Emit и на некоторых платформах так же могут требовать включения Mono Interpreter.

Для случаев, когда производительности указанных методов недостаточно, напрашивается решение:

Компилировать выражения или какие-то их части во время написания кода (design-time) или сборки (compile-time).

Для compile-time компиляции делегатов к фрагментам деревьев выражений требуется сгенерировать соответствующий код.

API доступа к делегатам не стоит генерировать вместе с ними, лучше держать его в отдельной сборке. Причина заключается в том, что анализ и использование деревьев выражений часто происходит в проектах, отдельных от их инициализации. Фреймворки для дата-байндинга и DI это сторонние библиотеки, да и сами программы часто разбиваются на несколько сборок из соображений архитектуры.

От самого API требуется только давать нужный делегат по ключу, как в словаре. У интересующих нас фрагментов кода: методов, конструкторов и свойств на стыке run-time и compile-time естественный идентификатор это сигнатура. По ней генерируемый код будет класть делегаты в словарь, а клиенты забирать.

Например, для класса со свойством

namespace Namespace{  public class TestClass  {    public int Property { get; set; }  }}

используемым внутри System.Linq.Expressions.Expression<T> лямбды

Expression<Func<TestClass, int>> expression = o => o.Property;

делегатами чтения и записи в общем виде являются

Func<object, object> _ = obj => ((Namespace.TestClass)obj).Property;Action<object, object> _ => (t, m) => ((Namespace.TestClass)t).Property  = (System.Int32)m;

и генерируемый код для их регистрации будет примерно таким:

namespace ExpressionDelegates.AccessorRegistration{  public static class ModuleInitializer  {    public static void Initialize()    {      ExpressionDelegates.Accessors.Add("Namespace.TestClass.Property",        getter: obj => ((Namespace.TestClass)obj).Property,        setter: (t, m) => ((Namespace.TestClass)t).Property = (System.Int32)m);    }  }}

Генерация

Наиболее известные решения для кодогенерации, на мой взгляд, это:

Отдельная область применения есть у каждого решения, и только Roslyn Source Generators умеет анализировать исходный C# код даже в процессе его набора.

Кроме того, именно Roslyn Source Generators видятся более или менее стандартом для кодогенерации, т. к. были представлены как фича основного компилятора языка и используют Roslyn API, используемый в анализаторах и code-fix.

Принцип работы Roslyn Source Generators описан в дизайн-документе (местами не актуален!) и гайде.

Вкратце: для создания генератора требуется создать реализацию интерфейса

namespace Microsoft.CodeAnalysis{  public interface ISourceGenerator  {    void Initialize(GeneratorInitializationContext context);    void Execute(GeneratorExecutionContext context);  }}

и подключить ее к проекту как анализатор.

Метод Initialize пригодится для выполнения какой-либо единоразовой логики. GeneratorInitializationContext на данный момент может быть полезен только для подключения посетителя узлов синтаксиса кода.

В Execute имеется контекст, из которого можно как собрать информацию по существующему коду, так и, собственно, добавить новый.

Для каждого файла исходного кода Roslyn предоставляет синтаксическое дерево в виде объекта SyntaxTree:

GeneratorExecutionContext.Compilation.SyntaxTrees

а так же семантическую модель:

semanticModel =  GeneratorExecutionContext.Compilation.GetSemanticModel(SyntaxTree)

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

Среди всех узлов синтаксических деревьев сборки нам нужно найти только интересующие нас лямбда-выражения типа System.Linq.Expressions.Expression<T> и отобрать из их узлов-потомков выражения, описывающие доступ к членам классов, создание объектов и вызов методов:

По семантике узла, так называемому символу (Symbol), можно определять:

  • типы, используемые выражением;

  • область видимости;

  • IsStatic, IsConst, IsReadOnly и другие характеристики.

На основе такой информации и будем генерировать подходящий код.

В Roslyn API (Microsoft.CodeAnalysis) построить сигнатуру намного проще, чем c API рефлексии (System.Reflection). Достаточно сконвертировать символ в строку при помощи методаISymbol.ToDisplayString(SymbolDisplayFormat) c подходящим форматом:

Зная сигнатуры свойства/поля, его типа и обладателя формируем строки для добавления делегатов:

Оформляем код добавления делегатов в класс и отдаем компилятору:

var sourceBuilder = new StringBuilder(@"namespace ExpressionDelegates.AccessorRegistration{  public static class ModuleInitializer  {    public static void Initialize()    {");      foreach (var line in registrationLines)      {        sourceBuilder.AppendLine();        sourceBuilder.Append(' ', 6).Append(line);      }      sourceBuilder.Append(@"    }  }}");GeneratorExecutionContext.AddSource(  "AccessorRegistration",  SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));

Этот код обязательно будет добавлен в сборку ...если генератор сможет отработать :)

Дело в том, что хоть Source Generators технически и не фича языка, поддерживаются они только в проектах с C# 9+. Позволить такую роскошь без костылей и ограничений на данный момент могут только проекты на .NET 5.

Совместимость

Поддержку Roslyn Source Generators API для .NET Standard, платформ .NET Core, .NET Framework и даже Xamarin поможет организовать Uno.SourceGeneration.

Uno.SourceGeneration предоставляет собственные копии интерфейса ISourceGenerator и атрибута [Generator], которые при миграции на С# 9 меняются на оригинальные из пространства имен Microsoft.CodeAnalysis простым удалением импортов Uno:

using Uno.SourceGeneration;using GeneratorAttribute = Uno.SourceGeneration.GeneratorAttribute;using ISourceGenerator = Uno.SourceGeneration.ISourceGenerator;
Для подключения достаточно добавить несколько строк в файл проекта.

В проект, где генератор будет использоваться:

<ItemGroup>  <SourceGenerator Include="PATH\TO\GENERATOR.dll" /></ItemGroup>

Например, распространяя генератор через nuget, подключение можно осуществлять вложением MSBuild props файла со следующим путём:

Инициализация

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

Для этих целей отлично подходит Module Initializer. Это конструктор сборки (а точнее ее модуля), который запускается сразу после ее загрузки и до вызовов к остальному коду. Он давно есть в CLR, но к сожалению, в C# его поддержка c атрибутом [ModuleInitializer] добавлена только в 9 версии.

Решение по добавлению конструктора в сборку с более широкой поддержкой платформ есть у Fody плагин Fody.ModuleInit. После компиляции добавляет классы с именами ModuleInitializer в конструктор сборки. В такой класс и будем оборачивать инициализацию сгенерированных делегатов.

Подключение Fody.ModuleInit через MSBuild свойства вместо FodyWeavers.xml исключит конфликты с другими Weaver-ами Fody в проекте клиента.

Использование

Таким образом, при сборке проекта:

  1. Source Generator добавит в сборку код, регистрирующий делегаты для деревьев выражений, в обертке класса ModuleInitializer.

  2. Fody.ModuleInit добавит ModuleInitializer в конструктор сборки.

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

Проверяем:

Expression<Func<string, int>> expression = s => s.Length;MemberInfo accessorInfo = ((MemberExpression)expression.Body).Member;Accessor lengthAccessor = ExpressionDelegates.Accessors.Find(accessorInfo);var length = lengthAccessor.Get("17 letters string");// length == 17

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

Бенчмарки

Сгенерированные делегаты выполняются немного медленнее, чем обычные из-за приведения типов и возможной упаковки параметров значимых типов.

Действие

Время, нс

Вызов простого делегата конструктора

4.6937 0.0443

Вызов сгенерированного делегата конструктора

5.8940 0.0459

Поиск и вызов сгенерированного делегата конструктора

191.1785 2.0766

Компиляция выражения и вызов конструктора

88,701.7674 962.4325

Вызов простого делегата доступа к свойству

1.7740 0.0291

Вызов сгенерированного делегата доступа к свойству

5.8792 0.1525

Поиск и вызов сгенерированного делегата доступа к свойству

163.2990 1.4388

Компиляция выражения и вызов геттера

88,103.7519 235.3721

Вызов простого делегата метода

1.1767 0.0289

Вызов сгенерированного делегата метода

4.1000 0.0185

Поиск и вызов сгенерированного делегата метода

186.4856 2.5224

Компиляция выражения и вызов метода

83,292.3139 922.4315

Полный вариант таблицы, с бенчмарками интерпретации.

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

Flame-график бенчмарка поиска и вызова сгенерированного делегата доступа к свойствуFlame-график бенчмарка поиска и вызова сгенерированного делегата доступа к свойству

Идеи насчёт оптимизации построения сигнатур по System.Reflection.MemberInfo приветствуются. Реализация на момент написания.

Заключение

По итогу получилось современное решение для кодогенерации с актуальной совместимостью и автоматической инициализацией.

Полный код можно посмотреть на: github/ExpressionDelegates, а подключить через nuget.

Для тех, кто будет пробовать Source Generators хотелось бы отметить несколько полезностей:

  • Source Generator Playground (github).
    Позволяет экспериментировать с Roslyn Source Generators в браузере, онлайн.

  • Окно визуализации синтаксиса для Visual Studio.
    Удобный инструмент для знакомства с Roslyn Syntax API на собственном коде.

  • Отлаживается Source Generator вызовом отладчика из его кода. Пример.
    Для этого нужен компонент Visual Studio Just-In-Time debugger и включенная настройка Tools -> Options -> Debugging -> Just-In-Time Debugging -> Managed.

  • В сгенерированных *.cs файлах срабатывают брейкпоинты, проверено в Visual Studio16.8.
    При генерации через Uno.SourceGeneration файлы размещаются по пути: \obj\{configuration}\{platform}\g\.
    С Roslyn Source Generators их появление включается через MSBuild свойство EmitCompilerGeneratedFiles.
    Стандартный путь: \obj\{configuration}\{platform}\generated\, переопределяется в свойстве CompilerGeneratedFilesOutputPath.

  • Source Generators можно конфигурировать свойствами MSBuild.
    При использовании Uno.SourceGeneration значение получают вызовом

    GeneratorExecutionContext.GetMSBuildPropertyValue(string)
    

    Для Roslyn Source Generators требуемые свойства необходимо сперва отдельно обозначить в MSBuild группе CompilerVisibleProperty и только после вызывать:

    GeneratorExecutionContext.AnalyzerConfigOptions.GlobalOptions  .TryGetValue("build_property.<PROPERTY_NAME>", out var propertyValue)
    
  • Из генератора можно кидать предупреждения и ошибки сборки.

    //Roslyn Source GeneratorsGeneratorExecutionContext.ReportDiagnostic(Diagnostic)//Uno.SourceGeneration:GeneratorExecutionContext.GetLogger().Warn/Error().
    
Подробнее..

Тестирование генератора исходного кода

09.02.2021 00:17:30 | Автор: admin

В прошлом году обновление .Net принесло фичу: генераторы исходного кода. Мне стало интересно что это такое и я решил написать генератор моков, чтоб на вход брал интерфейс или абстрактный класс и выдавал моки, которые можно использовать в тестировании с aot компиляторами. Почти сразу встал вопрос: а как тестировать сам генератор? На тот момент официальная поваренная книга не содержала рецепт как это сделать правильно. Позже эту проблему исправили, но, возможно, вам будет интересно посмотреть как работают тесты в моём проекте.

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

  • Создадим компиляцию;

  • Создадим и запустим генератор;

  • Выполним сборку библиотеки и загрузим её в текущий процесс;

  • Найдём там полученный код и выполним его.

Компиляция

Запуск компилятора производится с помощью функции CSharpCompilation.Create. Здесь можно добавить код и подключить ссылки на библиотеки. Исходный код подготавливается с помощью CSharpSyntaxTree.ParseText, а библиотеки MetadataReference.CreateFromFile (есть варианты для потоков и массивов). Как добыть путь? В большинстве случаев всё просто:

typeof(UnresolvedType).Assembly.Location

Однако в некоторых случаях тип находится в базовой (reference) сборке, тогда работает вот это:

Assembly.Load(new AssemblyName("System.Linq.Expressions")).LocationAssembly.Load(new AssemblyName("System.Runtime")).LocationAssembly.Load(new AssemblyName("netstandard")).Location
Как может выглядеть создание компиляции
protected static CSharpCompilation CreateCompilation(string source, string compilationName)    => CSharpCompilation.Create(compilationName,        syntaxTrees: new[]        {            CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Preview))        },        references: new[]        {            MetadataReference.CreateFromFile(Assembly.GetCallingAssembly().Location),            MetadataReference.CreateFromFile(typeof(string).Assembly.Location),            MetadataReference.CreateFromFile(typeof(LightMock.InvocationInfo).Assembly.Location),            MetadataReference.CreateFromFile(typeof(IMock<>).Assembly.Location),            MetadataReference.CreateFromFile(typeof(Xunit.Assert).Assembly.Location),            MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.Linq.Expressions")).Location),            MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.Runtime")).Location),            MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("netstandard")).Location),        },        options: new CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));

Ссылка на код

Запуск генератора и создание сборки

Тут всё просто: дёргается CSharpGeneratorDriver.Create, туда отдаётся генератор, опции компиляции и дополнительные тексты (aka AdditionalFiles из csproj). Потом из CSharpGeneratorDriver.RunGeneratorsAndUpdateCompilation получается обновлённая компиляция, из которой можно получить байт код сборки. На этом этапе можно записать получившиеся ошибки и предупреждения в, например ITestOutputHelper от Xunit для последующего анализа. Это проще, чем тыкать в поля в отладчике и при просмотре выглядит как окошко Output студии.

Как может выглядеть запуск генератора и получени сборки
protected (ImmutableArray<Diagnostic> diagnostics, bool success, byte[] assembly) DoCompile(string source, string compilationName){    var compilation = CreateCompilation(source, compilationName);    var driver = CSharpGeneratorDriver.Create(        ImmutableArray.Create(new LightMockGenerator()),        Enumerable.Empty<AdditionalText>(),        (CSharpParseOptions)compilation.SyntaxTrees.First().Options);    driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out var diagnostics);    var ms = new MemoryStream();    var result = updatedCompilation.Emit(ms);    foreach (var i in result.Diagnostics)        testOutputHelper.WriteLine(i.ToString());    return (diagnostics, result.Success, ms.ToArray());}

Ссылка на код

Загрузка библиотеки и поиск кода

В .Net Core для этого придумали AssemblyLoadContext. Этот класс может загружать и выгружать сборки. После загрузки вы получаете ссылку на Assembly, с которой можно работать. Тут опять ничего сложного: рефлексия спешит на помощь. Остаётся решить к какому типу приводить полученный объект. Вы всегда можете использовать dynamic или приводить к какому-то известному интерфейсу. Интерфейс может лежать в сборке с тестами, ссылку на которую можно, также добавить в компиляцию. Я использую интерфейс, в сборке с тестами и добавляю в компиляцию исходный код с классом, который от этого интерфейса наследуется.

Интерфейс может выглядеть так
public interface ITestScript<T>    where T : class{    IMock<T> Context { get; } // интерфейс для сгенерированного кода    T MockObject { get; } // интерфейс для сгенерированнго объекта    int DoRun(); // чтобы тестировать сгенерированные функции,    // которые сложно пробросить наружу}

Исходный код

Пример дополнительного исходного кода
using System;using Xunit;namespace LightMock.Generator.Tests.Mock{    public class AbstractClassWithBasicMethods : ITestScript<AAbstractClassWithBasicMethods>    {    // этот объект Mock<T> был сгенерирован        private readonly Mock<AAbstractClassWithBasicMethods> mock;        public AbstractClassWithBasicMethods()            => mock = new Mock<AAbstractClassWithBasicMethods>();        public IMock<AAbstractClassWithBasicMethods> Context => mock;        public AAbstractClassWithBasicMethods MockObject => mock.Object;        public int DoRun()        {        // функция Protected() была сгенерирована            mock.Protected().Arrange(f => f.ProtectedGetSomething()).Returns(1234);            Assert.Equal(expected: 1234, mock.Object.InvokeProtectedGetSomething());            mock.Object.InvokeProtectedDoSomething(5678);            mock.Protected().Assert(f => f.ProtectedDoSomething(5678));            return 42;        }    }}

Исходный код

Проверка опций анализатора

Если нужно проверить опции, которые добавляются в файл проекта, то придётся провести дополнительную работу: создать подклассы для AnalyzerConfigOptionsProvider и AnalyzerConfigOptions.

Например так
sealed class MockAnalyzerConfigOptions : AnalyzerConfigOptions{    public static MockAnalyzerConfigOptions Empty { get; }        = new MockAnalyzerConfigOptions(ImmutableDictionary<string, string>.Empty);    private readonly ImmutableDictionary<string, string> backing;    public MockAnalyzerConfigOptions(ImmutableDictionary<string, string> backing)        => this.backing = backing;    public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value)        => backing.TryGetValue(key, out value);}sealed class MockAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider{    private readonly ImmutableDictionary<object, AnalyzerConfigOptions> otherOptions;    public MockAnalyzerConfigOptionsProvider(AnalyzerConfigOptions globalOptions)        : this(globalOptions, ImmutableDictionary<object, AnalyzerConfigOptions>.Empty)    { }    public MockAnalyzerConfigOptionsProvider(AnalyzerConfigOptions globalOptions,        ImmutableDictionary<object, AnalyzerConfigOptions> otherOptions)    {        GlobalOptions = globalOptions;        this.otherOptions = otherOptions;    }    public static MockAnalyzerConfigOptionsProvider Empty { get; }        = new MockAnalyzerConfigOptionsProvider(            MockAnalyzerConfigOptions.Empty,            ImmutableDictionary<object, AnalyzerConfigOptions>.Empty);    public override AnalyzerConfigOptions GlobalOptions { get; }    public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)        => GetOptionsPrivate(tree);    public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)        => GetOptionsPrivate(textFile);    AnalyzerConfigOptions GetOptionsPrivate(object o)        => otherOptions.TryGetValue(o, out var options) ? options : MockAnalyzerConfigOptions.Empty;}

Исходный код: раз, два.

В CSharpGeneratorDriver.Create есть параметр optionsProvider, запихивается туда. У меня в генераторе реализована единственная опция, которая отключает генерацию кода. Проверяется в тесте просто, нашла рефлексия генерируемый код или нет.

Дополнительно

Если занимаетесь разработкой генератора исходного кода не забывайте следить за поваренной книгой она время от времени обновляется.

Вы можете добавить один и тот-же файл в проект как исходный код и как ресурс. Полезно для проверки шаблонов компилятором и их рефакторинга, а также для доступа к именам классов, полей и методов. В проекте можно использовать маски. Будьте осторожны студия на это может реагировать некорректно.

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

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

Незабывайте проверять отмену генерации, через полученный CancellationToken. Так студия меньше фризит.

Генератор моков тут. Это бета версия и к использованию в проде не рекомендуется.

Подробнее..
Категории: C , Net , Генератор , Roslyn , Исходный код

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

13.02.2021 00:15:04 | Автор: admin

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 analysisDiagnosticBag 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.IsVirtualTupleField false. Переходим к условному оператору и разберём условное выражение 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 анализ: _dev12CompilerCompatibility false, следовательно, результат всего выражения false. Compat анализ: значения всех подвыражений true, результат всего выражения true.


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


При compat анализе мы считаем, что должны игнорировать единственное экземплярное поле типа CancellationSource m_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?.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru