Не секрет, что Microsoft давно работает над 8-й версией языка C#. Новая языковая версия (C# 8.0) уже доступна в последнем выпуске Visual Studio 2019, но все еще находится в стадии бета-тестирования. Эта новая версия будет иметь несколько функций, реализованных несколько неочевидным или, скорее, неожиданным образом. Nullable Reference типы являются одним из них. Эта функция объявлена ​​как средство борьбы с исключениями нулевых ссылок (NRE).

Приятно видеть, что язык развивается и приобретает новые функции, помогающие разработчикам. По стечению обстоятельств, некоторое время назад мы значительно расширили возможности C#-анализатора PVS-Studio для обнаружения NRE. И теперь мы задаемся вопросом, должны ли статические анализаторы вообще и PVS-Studio в частности утруждать себя диагностикой потенциальных нулевых разыменований, поскольку, по крайней мере, в новом коде, который будет использовать Nullable Reference, такие разыменования станут «невозможными»? Попробуем это прояснить.

Плюсы и минусы новой функции

Одно напоминание, прежде чем мы продолжим: в последней бета-версии C# 8.0, доступной на момент написания этой статьи, ссылочные типы, допускающие значение Null, отключены по умолчанию, т. е. поведение ссылочных типов не изменилось.

Так что же такое ссылочные типы, допускающие значение NULL, в C# 8.0, если мы включим эту опцию? В основном это те же старые добрые ссылочные типы, за исключением того, что теперь вам нужно будет добавить '?' после имени типа (например, string?), аналогично Nullable‹T›, то есть типы значений, допускающие значение NULL (например, int?). Без '?' наш строковыйтип теперь будет интерпретироваться как ссылка, не допускающая значения NULL, т. е. тип ссылки, которому нельзя присвоить значение null.

Исключение Null Reference Exception — одно из самых неприятных исключений для вашей программы, потому что оно мало что говорит о своем источнике, особенно если метод броска содержит несколько операций разыменования подряд. Возможность запретить присваивание null переменной ссылочного типа выглядит круто, но как насчет тех случаев, когда передача null методу зависит от некоторой логики выполнения? Вместо null мы могли бы, конечно, использовать литерал, константу или просто «невозможное» значение, которое логически не может быть присвоено переменной где-либо еще. Но это чревато заменой краха программы «молчаливым», но некорректным выполнением, что зачастую хуже, чем сразу столкнуться с ошибкой.

Как насчет того, чтобы бросить исключение? Значимое исключение, созданное в месте, где что-то пошло не так, всегда лучше, чем NREгде-то вверху или внизу стека. Но это хорошо только в вашем собственном проекте, где вы можете исправить потребителей, вставив блок try-catch, и это исключительно ваша ответственность. При разработке библиотеки с использованием (не) Nullable Reference нам необходимо гарантировать, что определенный метод всегда возвращает значение. В конце концов, даже в вашем собственном коде не всегда возможно (или, по крайней мере, просто) заменить возврат null генерацией исключения (поскольку это может повлиять на слишком большой объем кода).

Nullable Reference можно включить либо на глобальном уровне проекта, добавив свойство NullableContextOptions со значением enable, либо на уровне файла с помощью директивы препроцессора:

#nullable enable 
string cantBeNull = string.Empty;
string? canBeNull = null;
cantBeNull = canBeNull!;

Функция Nullable Reference сделает типы более информативными. Сигнатура метода дает представление о его поведении: есть ли у него проверка на null или нет, может ли он возвращать null или нет. Теперь, когда вы попытаетесь использовать ссылочную переменную, допускающую значение NULL, без проверки, компилятор выдаст предупреждение.

Это довольно удобно при использовании сторонних библиотек, но также увеличивает риск введения пользователя библиотеки в заблуждение, поскольку по-прежнему можно передать null с помощью нового оператора, не допускающего null (!). То есть добавление всего одного восклицательного знака может разрушить все дальнейшие предположения об интерфейсе с использованием таких переменных:

#nullable enable 
String GetStr() { return _count > 0 ? _str : null!; }
String str = GetStr();
var len = str.Length;

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

Кстати, вы могли бы написать один и тот же код, используя несколько операторов !, поскольку C# теперь позволяет это делать (и такой код прекрасно компилируется):

cantBeNull = canBeNull!!!!!!!;

Написав таким образом, мы как бы подчеркиваем мысль: «смотрите, это может быть null!!!» (мы в нашей команде называем это «эмоциональным» программированием). Фактически, при построении синтаксического дерева компилятор (от Roslyn) интерпретирует ! так же, как он интерпретирует обычные круглые скобки, а это значит, что вы можете написать столько !, сколько захотите — точно так же, как и со скобками. Но если написать их достаточно, можно «сбить» компилятор. Возможно, это будет исправлено в финальной версии C# 8.0.

Точно так же вы можете обойти предупреждение компилятора при доступе к ссылочной переменной, допускающей значение NULL, без проверки:

canBeNull!.ToString();

Добавим больше эмоций:

canBeNull!!!?.ToString();

Однако вы вряд ли когда-нибудь увидите такой синтаксис в реальном коде. Написав оператор сохраняющий null , мы сообщаем компилятору: «Этот код в порядке, проверка не требуется». Добавляя оператор Элвиса, мы говорим ему: «А может, и нет; давай проверим на всякий случай».

Теперь вы можете разумно спросить, почему вы все еще можете так легко присвоить null переменным ссылочных типов, не допускающих значение null, если сама концепция этих типов подразумевает, что такие переменные не могут иметь значение ноль? Ответ заключается в том, что «под капотом», на уровне IL-кода, наш необнуляемый ссылочный тип по-прежнему… старый добрый «обычный» ссылочный тип, а весь синтаксис обнуляемости на самом деле просто аннотация для встроенного в компилятор анализатор (который, на наш взгляд, не совсем удобен в использовании, но об этом позже). Лично мы не находим «аккуратным» решением включать новый синтаксис просто как аннотацию для стороннего инструмента (даже встроенного в компилятор), потому что тот факт, что это просто аннотация, может быть совсем не очевиден. программисту, так как этот синтаксис очень похож на синтаксис для структур, допускающих значение NULL, но работает совершенно по-другому.

Возвращаясь к другим способам взлома типов Nullable Reference. На момент написания этой статьи, когда у вас есть решение, состоящее из нескольких проектов, передача переменной ссылочного типа, например, String из метода, объявленного в одном проекте, в метод в другом проекте. для которого включен параметр NullableContextOptions, компилятор предположит, что он имеет дело со строкой, не допускающей значение NULL, и компилятор промолчит. И это несмотря на множество атрибутов [Nullable(1)], добавленных к каждому полю и методу в коде IL при включении Nullable References. Эти атрибуты, кстати, следует учитывать, если вы используете отражение для обработки атрибутов и предполагаете, что код содержит только ваши пользовательские.

Такая ситуация может вызвать дополнительные проблемы при адаптации большой базы кода к стилю Nullable Reference. Этот процесс, скорее всего, будет продолжаться некоторое время, проект за проектом. При осторожности, конечно, можно постепенно интегрировать новую фичу, но если у вас уже есть работающий проект, любые изменения в нем опасны и нежелательны (работает — не трогайте!). Вот почему мы позаботились о том, чтобы вам не пришлось изменять исходный код или помечать его для обнаружения потенциальных NRE при использовании анализатора PVS-Studio. Чтобы проверить местоположения, которые могут вызвать NullReferenceException, просто запустите анализатор и найдите предупреждения V3080. Нет необходимости изменять свойства проекта или исходный код. Нет необходимости добавлять директивы, атрибуты или операторы. Нет необходимости изменять устаревший код.

При добавлении поддержки Nullable Reference в PVS-Studio нам нужно было решить, должен ли анализатор предполагать, что переменные неnullable reference типов всегда имеют ненулевые значения. Изучив, как эта гарантия может быть нарушена, мы решили, что PVS-Studio не должен делать такое предположение. В конце концов, даже если в проекте повсеместно используются необнуляемые ссылочные типы, анализатор может дополнить эту функцию, выявляя те конкретные ситуации, когда такие переменные могут иметь значение null.

Как PVS-Studio ищет исключения нулевых ссылок

Механизмы потока данных анализатора C# PVS-Studio отслеживают возможные значения переменных в процессе анализа. Это также включает межпроцедурный анализ, то есть отслеживание возможных значений, возвращаемых методом и его вложенными методами, и так далее. Кроме того, PVS-Studio запоминает переменные, которым можно было присвоить значение null. Всякий раз, когда он видит, что такая переменная разыменовывается без проверки, будь то в текущем анализируемом коде или внутри метода, вызванного в этом коде, он выдает предупреждение V3080 о потенциальном исключении нулевой ссылки.

Идея этой диагностики состоит в том, чтобы анализатор злился только тогда, когда он видит нулевое присвоение. В этом принципиальное отличие поведения нашей диагностики от встроенного анализатора компилятора, работающего с типами Nullable Reference. Встроенный анализатор укажет на каждое разыменование непроверенной ссылочной переменной, допускающей значение NULL, при условии, что она не была введена в заблуждение использованием оператора ! или даже просто сложной проверкой (она должна однако следует отметить, что абсолютно любой статический анализатор, и PVS-Studio здесь не является исключением, так или иначе может быть «введен в заблуждение», особенно если вы намерены это сделать).

PVS-Studio, с другой стороны, предупреждает вас, только если видит null (будь то в локальном контексте или в контексте внешнего метода). Даже если переменная имеет необнуляемый ссылочный тип, анализатор будет продолжать указывать на нее, если увидит нулевое присвоение этой переменной. Такой подход мы считаем более подходящим (или, по крайней мере, более удобным для пользователя), так как не требует «замазывания» всего кода проверками на null для отслеживания возможных разыменований — ведь такая возможность была доступна и до Nullable Reference были введены, например, посредством использования контрактов. Более того, теперь анализатор может обеспечить лучший контроль над самими необнуляемыми ссылочными переменными. Если такая переменная используется «справедливо» и никогда не получает значение null, PVS-Studio не скажет ни слова. Если переменной присвоить значение null, а затем разыменовать ее без предварительной проверки, PVS-Studio выдаст предупреждение V3080:

#nullable enable 
String GetStr() { return _count > 0 ? _str : null!; }
String str = GetStr();
var len = str.Length; <== V3080: Possible null dereference. 
                                 Consider inspecting 'str'

Теперь давайте посмотрим на несколько примеров, демонстрирующих, как эта диагностика запускается кодом самого Roslyn. Недавно мы уже проверяли этот проект, но на этот раз мы рассмотрим только потенциальные исключения Null Reference, не упомянутые в предыдущих статьях. Мы увидим, как PVS-Studio выявляет потенциальные NRE и как их можно исправить с помощью нового синтаксиса Nullable Reference.

V3080 [CWE-476] Возможное разыменование null внутри метода. Рассмотрите возможность проверки второго аргумента: chainedTupleType. Microsoft.CodeAnalysis.CSharp TupleTypeSymbol.cs 244

NamedTypeSymbol chainedTupleType;
if (_underlyingType.Arity < TupleTypeSymbol.RestPosition)
  { ....  chainedTupleType = null; }
else { .... }
return Create(ConstructTupleUnderlyingType(firstTupleType,
  chainedTupleType, newElementTypes), elementNames: _elementNames);

Как видите, переменной chainedTupleType может быть присвоено нулевое значение в одной из ветвей выполнения. Затем он передается в метод ConstructTupleUnderlyingType и используется там после проверки Debug.Assert. Это очень распространенный шаблон в Roslyn, но имейте в виду, что Debug.Assert удален в релизной версии. Поэтому анализатор по-прежнему считает разыменование внутри метода ConstructTupleUnderlyingType опасным. Вот тело этого метода, где происходит разыменование:

internal static NamedTypeSymbol ConstructTupleUnderlyingType(
  NamedTypeSymbol firstTupleType, 
  NamedTypeSymbol chainedTupleTypeOpt, 
  ImmutableArray<TypeWithAnnotations> elementTypes)
{
  Debug.Assert
    (chainedTupleTypeOpt is null ==
     elementTypes.Length < RestPosition);
  ....
  while (loop > 0)
  {   
    ....
    currentSymbol = chainedTupleTypeOpt.Construct(chainedTypes);
    loop--;
  }
  return currentSymbol;
}

На самом деле спорный вопрос, должен ли анализатор учитывать такие Assert (некоторые наши пользователи этого хотят) — ведь анализатор учитывает контракты из System.Diagnostics.Contracts. Вот один небольшой пример из жизни из нашего опыта использования Roslyn в собственном анализаторе. Недавно добавив поддержку последней версии Visual Studio, мы также обновили Roslyn до 3-й версии. После этого PVS-Studio начал падать на каком-то коде, на котором раньше никогда не падал. Сбой, сопровождаемый Null Reference Exception, произойдет не в нашем коде, а в коде Roslyn. Отладка показала, что фрагмент кода, где Roslyn теперь давал сбой, имел ту самую проверку null на основе Debug.Assert несколькими строками выше — и эта проверка явно не помогала.

Это наглядный пример того, как могут возникнуть проблемы с Nullable Reference из-за того, что компилятор рассматривает Debug.Assert как надежную проверку в любой конфигурации. То есть, если вы добавите #nullable enable и пометите аргумент chainedTupleTypeOpt как ссылку, допускающую значение null, компилятор не выдаст никаких предупреждений о разыменование внутри метода ConstructTupleUnderlyingType.

Перейдем к другим примерам предупреждений от PVS-Studio.

V3080 Возможное разыменование null. Рассмотрите возможность проверки «efficientRuleset». RuleSet.cs 146

var effectiveRuleset = 
  ruleSet.GetEffectiveRuleSet(includedRulesetPaths);
effectiveRuleset = 
  effectiveRuleset.WithEffectiveAction(ruleSetInclude.Action);
if (IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, ....))
   effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;

Это предупреждение говорит о том, что вызов метода WithEffectiveAction может вернуть null, при этом возвращаемое значение, присвоенное переменной efficientRuleset, перед использованием не проверяется ( efficientRuleset.GeneralDiagnosticOption). Вот тело метода WithEffectiveAction:

public RuleSet WithEffectiveAction(ReportDiagnostic action)
{
  if (!_includes.IsEmpty)
    throw new ArgumentException(....);
  switch (action)
  {
    case ReportDiagnostic.Default:
      return this;
    case ReportDiagnostic.Suppress:
      return null;
    ....     
      return new RuleSet(....);
     default:
       return null;
   }
}

Включив Nullable Reference для метода GetEffectiveRuleSet, мы получим два места, где нужно изменить поведение кода. Поскольку метод, показанный выше, может генерировать исключение, логично предположить, что вызов его заключен в блок try-catch и было бы правильно переписать метод так, чтобы он генерировал исключение, а не возвращал нуль. Однако, если вы проследите несколько вызовов в обратном направлении, вы увидите, что перехватывающий код находится слишком далеко, чтобы надежно предсказать последствия. Давайте взглянем на потребителя переменной EffectiveRuleset, метода IsStricterThan:

private static bool 
  IsStricterThan(ReportDiagnostic action1, ReportDiagnostic action2)
{
  switch (action2)
  {
    case ReportDiagnostic.Suppress:
      ....;
    case ReportDiagnostic.Warn:
      return action1 == ReportDiagnostic.Error;
    case ReportDiagnostic.Error:
      return false;
    default:
      return false;
  }
}

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

Подпись WithEffectiveAction изменится:

#nullable enable
public RuleSet? WithEffectiveAction(ReportDiagnostic action)

Вот как будет выглядеть вызов:

RuleSet? effectiveRuleset = 
  ruleSet.GetEffectiveRuleSet(includedRulesetPaths);
effectiveRuleset = 
  effectiveRuleset?.WithEffectiveAction(ruleSetInclude.Action);
if (IsStricterThan(effectiveRuleset?.GeneralDiagnosticOption ?? 
                     ReportDiagnostic.Default,
                   effectiveGeneralOption))
   effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;

Поскольку IsStricterThan выполняет только сравнение, условие можно переписать, например, так:

if (effectiveRuleset == null || 
    IsStricterThan(effectiveRuleset.GeneralDiagnosticOption,
                   effectiveGeneralOption))

Следующий пример.

V3080 Возможное разыменование null. Рассмотрите возможность проверки «propertySymbol». BinderFactory.BinderFactoryVisitor.cs 372

var propertySymbol = GetPropertySymbol(parent, resultBinder);
var accessor = propertySymbol.GetMethod;
if ((object)accessor != null)
  resultBinder = new InMethodBinder(accessor, resultBinder);

Чтобы исправить это предупреждение, нам нужно посмотреть, что дальше произойдет с переменной propertySymbol .

private SourcePropertySymbol GetPropertySymbol(
  BasePropertyDeclarationSyntax basePropertyDeclarationSyntax,
  Binder outerBinder)
{
  ....
  NamedTypeSymbol container 
    = GetContainerType(outerBinder, basePropertyDeclarationSyntax);
  if ((object)container == null)
    return null;
  ....
  return (SourcePropertySymbol)GetMemberSymbol(propertyName,
    basePropertyDeclarationSyntax.Span, container,
    SymbolKind.Property);
}

Метод GetMemberSymbol также может возвращать null при определенных условиях.

private Symbol GetMemberSymbol(
  string memberName, 
  TextSpan memberSpan, 
  NamedTypeSymbol container, 
  SymbolKind kind)
{
  foreach (Symbol sym in container.GetMembers(memberName))
  {
    if (sym.Kind != kind)
      continue;
    if (sym.Kind == SymbolKind.Method)
    {
      ....
      var implementation =
        ((MethodSymbol)sym).PartialImplementationPart;
      if ((object)implementation != null)
        if (InSpan(implementation.Locations[0],
            this.syntaxTree, memberSpan))
          return implementation;
    }
    else if (InSpan(sym.Locations, this.syntaxTree, memberSpan))
      return sym;
  }
  return null;
}

С включенными ссылочными типами, допускающими значение NULL, вызов изменится на это:

#nullable enable
SourcePropertySymbol? propertySymbol 
  = GetPropertySymbol(parent, resultBinder);
MethodSymbol? accessor = propertySymbol?.GetMethod;
if ((object)accessor != null)
  resultBinder = new InMethodBinder(accessor, resultBinder);

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

V3080 Возможное разыменование null. Рассмотрите возможность проверки «simpleName». CSharpCommandLineParser.cs 1556

string simpleName;
simpleName = PathUtilities.RemoveExtension(
  PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path));
outputFileName = simpleName + outputKind.GetDefaultExtension();
if (simpleName.Length == 0 && !outputKind.IsNetModule())
  ....

Проблема в строке с проверкой simpleName.Length. Переменная simpleName является результатом выполнения длинной последовательности методов, и ей может быть присвоено значение null. Кстати, если вам интересно, вы можете взглянуть на метод RemoveExtension, чтобы увидеть, чем он отличается от Path.GetFileNameWithoutExtension. Проверки simpleName != null было бы достаточно, но для ссылочных типов, не допускающих значение null, код изменится примерно на такой:

#nullable enable
public static string? RemoveExtension(string path) { .... }
string simpleName;

Вот как может выглядеть вызов:

simpleName = PathUtilities.RemoveExtension(
  PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? 
  String.Empty;

Вывод

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

Однако всегда помните об ограничениях этого подхода и имейте в виду, что включение режима Nullable Reference не защищает вас от NRE и что при неправильном использовании он сам может стать источником этих ошибок. Мы рекомендуем вам дополнить функцию Nullable Reference современным инструментом статического анализа, таким как PVS-Studio, который поддерживает межпроцедурный анализ для защиты вашей программы от NRE. Каждый из этих подходов — глубокий межпроцедурный анализ и аннотирование сигнатур методов (что, собственно, и делает режим Nullable Reference) — имеет свои плюсы и минусы. Анализатор предоставит вам список потенциально опасных мест и позволит вам увидеть последствия изменения существующего кода. Если где-то есть нулевое присваивание, анализатор без проверки укажет на каждого потребителя переменной, где она разыменовывается.

Вы можете проверить этот проект или свои проекты на наличие других дефектов — просто скачайте PVS-Studio и попробуйте.