Nullable Reference типы в C# 8.0 и статический анализ




Не секрет, что Microsoft достаточно давно работает над выпуском восьмой версии C#. В недавно состоявшемся релизе Visual Studio 2019 новая версия языка (C# 8.0) уже доступна, но пока ещё только в качестве beta релиза. В планах этой новой версии есть несколько возможностей, реализация которых может показаться не совсем очевидной, или точнее, не совсем ожидаемой. Одним из таких нововведений стала возможность использования Nullable Reference типов. Заявленным смыслом данного нововведения является борьба с Null Reference Exception'ами (NRE).

Picture 9

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

Плюсы и минусы нововведения

Для начала стоит напомнить, что в последней beta версии C# 8.0, доступной на момент написания данной статьи, Nullable Reference по умолчанию выключены, т.е. поведение ссылочных типов не изменится.

Что же представляют из себя nullable reference типы в C# 8.0, если их включить? Это тот же старый добрый reference тип с тем отличием, что переменные этого типа нужно теперь помечать с помощью '?' (например, string?), по аналогии с тем, как это уже делается для Nullable<T>, т.е. nullable значимых типов (например, int?). Однако теперь тот же string без '?' уже начинает интерпретироваться как non-nullable reference, т.е. это reference тип, переменная которого не может содержать значения null.

Null Reference Exception - это одно из самых неприятных исключений, поскольку оно мало что говорит об источнике проблем, особенно если в методе, выбросившем исключение, встречается несколько разыменований подряд. Возможность запретить передачу null в переменную reference типа выглядит отлично, но если раньше в метод передавался null, и на это была завязана какая-то логика дальнейшего исполнения, то что делать теперь? Конечно, можно вместо null передавать литерал, константу или просто "невозможное" значение, которое по логике работы программы не может быть больше нигде присвоено в эту переменную. Однако падение всей программы может подмениться дальнейшим "тихим" некорректным исполнением. Далеко не всегда это будет лучше, чем увидеть ошибку сразу.

А если вместо этого кидать исключение? Осмысленное исключение в месте, где что-то пошло не так, всегда лучше, чем NRE где-то выше или ниже по стеку. Но хорошо, если речь идёт о нашем собственном проекте, где мы можем поправить потребителей и вставить блок try-catch, а при разработке библиотеки, используя (non) Nullable Reference, мы берем на себя ответственность, что некоторый метод всегда возвращает значение. Да и не всегда даже в собственном коде получится (по крайней мере просто) подменить возвращение null на выброс исключения (слишком много кода может быть задето).

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

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

Типы теперь будут более наглядными. По сигнатуре метода возможно определение его поведения, есть в нём проверка на null или её нет, может он вернуть null или не может. Теперь, если попробовать обратиться к nullable reference переменной без проверки, компилятор выдаст предупреждение.

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

#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.

Аналогичным образом можно обойти и warning компилятора при обращении к nullable reference переменной без проверки:

canBeNull!.ToString();

Можно написать и более эмоционально:

canBeNull!!!?.ToString();

Данный синтаксис на самом деле трудно представить в реальном проекте, ставя null-forgiving оператор говорим компилятору: тут всё нормально, проверка не нужна. Добавляя элвис оператор мы говорим: а вообще может и не нормально, давай проверим.

И теперь возникает законный вопрос - а почему, если концепция non-nullable reference типа подразумевает, что переменная такого типа не может содержать null, мы всё же можем так легко его туда записать? Дело в том, что "под капотом", на уровне IL кода, наш non-nullable reference тип остаётся... всё тем же "обычным" reference типом. А весь nullability синтаксис фактически является только аннотацией для встроенного в компилятор статического анализатора (и, по нашему мнению, не самого удобного анализатора, но об этом позже). На наш взгляд, включать в язык новый синтаксис только как аннотацию для стороннего инструмента (пусть даже и встроенного в компилятор), это не самое "красивое" решение, т.к. для использующего этот язык программиста то, что это только аннотация, может быть совсем не очевидно - ведь очень похожий синтаксис для nullable структур работает совсем по другому.

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

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

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

Как PVS-Studio ищет Null Reference Exception'ы

Dataflow механизмы в C# анализаторе PVS-Studio отслеживают возможные значения переменных по ходу анализа. В том числе, PVS-Studio проводит и межпроцедурный анализ, т.е. пытается определить возможное значение, возвращаемое методом, а так же методами, вызываемыми в этом методе, и т.п. Помимо прочего, анализатор запоминает и переменные, которые могут потенциально принимать значение null. Если в дальнейшем анализатор видит разыменование без проверки такой переменной, опять же, или в текущем проверяемом коде, или внутри метода, вызываемого в этом коде, будет выдано предупреждение V3080 о потенциальном Null Reference Exception.

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

PVS-Studio же ругается только если видит null (в локальном контексте, или приходящем из метода). При этом, даже если переменная является non-nullable reference переменной, поведение анализатора не изменится - он всё равно будет ругаться, если увидит, что в неё записывался null. Такой подход нам кажется более правильным (или, по крайней мере, удобным пользователю анализатора), т.к. он не требует "обмазывать" весь код проверками на null для нахождения потенциальных разыменований - это ведь можно было делать и раньше, без Nullable Reference, например, теми же контрактами. К тому же, анализатор теперь можно использовать и для дополнительного контроля за теми же non-nullable reference переменными. Если они используются "честно", и в них никогда не присваивается null - анализатор промолчит. Если null присваивается и переменная разыменовывается без проверки - анализатор об этом предупредит сообщением V3080:

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

Рассмотрим далее некоторые примеры таких срабатываний диагностики V3080 в коде самого Roslyn'а. Мы не так давно проверяли этот проект, но в этот раз рассмотрим только потенциальные Null Reference Exception срабатывания, которых не было в прошлых статьях. Посмотрим, как анализатор PVS-Studio может находить потенциальные разыменования нулевых ссылок, и как можно эти места исправить с использованием нового Nullable Reference синтаксиса.

V3080 [CWE-476] Possible null dereference inside method. Consider inspecting the 2nd argument: 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 может принимать значение null в одной из веток выполнения кода. Затем 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-ей версии. После этого анализатор стал падать при проверке определённого кода, на котором он раньше не падал. При этом падать анализатор стал не внутри нашего кода, а внутри кода самого Roslyn'а - падать с Null Reference Exception. И дальнейшая отладка показала, что в том месте, где Roslyn теперь падает, ровно на пару строк выше, есть та самая проверка на null через Debug.Assert. И она, как мы видим, не спасла.

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

Рассмотрим следующий пример срабатывания PVS-Studio.

V3080 Possible null dereference. Consider inspecting 'effectiveRuleset'. RuleSet.cs 146

var effectiveRuleset = 
  ruleSet.GetEffectiveRuleSet(includedRulesetPaths);
effectiveRuleset = 
  effectiveRuleset.WithEffectiveAction(ruleSetInclude.Action);

if (IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, ....))
   effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;

В данном предупреждении отмечено, что вызов метода WithEffectiveAction может вернуть null, но результат используется без проверки (effectiveRuleset.GeneralDiagnosticOption). Тело метода WithEffectiveAction, который может вернуть null, записываемый в переменную effectiveRuleset:

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 и корректно будет переписать метод, выбрасывая исключение вместо того, чтобы возвращать null. Но поднимаясь по вызовам выше, мы видим, что перехват находится высоко и последствия могут быть довольно непредсказуемыми. Посмотрим на потребителя переменной 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 Possible null dereference. Consider inspecting '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;
}

С использованием nullable reference типа, вызов изменится так:

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

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

V3080 Possible null dereference. Consider inspecting '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, но в контексте ненулевых ссылок код станет выглядеть как-то так:

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

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

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

Заключение

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

Всегда нужно помнить об ограничениях, присущих данному подходу, и о том, что включённый режим Nullable Reference не защищает от ошибок с разыменованием нулевых ссылок, а при неправильном использовании может и даже приводить к ним. Стоит рассматривать использование современного статического анализатора, например PVS-Studio, поддерживающего межпроцедурный анализ, как дополнительный инструмент, который может, совместно с Nullable Reference, защитить вас от разыменования нулевых ссылок. У каждого из этих подходов - как глубокого межпроцедурного анализа, так и аннотации сигнатур методов (что по сути и делает Nullable Reference), есть как свои плюсы, так и минусы. Анализатор позволит вам получить список потенциально опасных мест, а также при изменении существующего кода увидеть все последствия таких изменений. Если вы присваиваете null в каком-то случае, анализатор должен сразу указать всех потребителей переменной, где она не проверяется перед разыменованием.

Вы можете самостоятельно поискать ещё какие-то ошибки как в рассмотренном проекте, так и в с своих собственных. Для этого просто необходимо скачать и попробовать анализатор PVS-Studio.



Найдите ошибки в своем C, C++, C# и Java коде

Предлагаем попробовать проверить код вашего проекта с помощью анализатора кода PVS-Studio. Одна найденная в нём ошибка скажет вам о пользе методологии статического анализа кода больше, чем десяток статей.

goto PVS-Studio;


Найденные ошибки

Проверено проектов
344
Собрано ошибок
12 970

А ты совершаешь ошибки в коде?

Проверь с помощью
PVS-Studio

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

goto PVS-Studio;