>
>
>
Помоги компилятору, и он поможет тебе. …

Никита Паневин
Статей: 10

Помоги компилятору, и он поможет тебе. Тонкости работы с nullable reference типами в C#

Nullable reference типы появились в C# 3 года назад. За это время они смогли найти свою аудиторию. Но даже те, кто имеет дело с этим зверем, скорее всего, не знают всех его возможностей. Давайте разберёмся, как более качественно взаимодействовать с этими типами.

Введение

Nullable reference типы призваны помочь в создании более качественной и безопасной архитектуры приложения. На этапе написания кода необходимо понимать, будет ли та или иная ссылочная переменная принимать null или нет, может ли метод возвращать null и так далее.

Можно с уверенностью сказать о том, что каждый разработчик сталкивался с NRE (NullReferenceException). И то, что данное исключение будет получено на этапе разработки, – хороший сценарий, ведь проблему можно исправить сразу. Гораздо хуже, когда её находит пользователь при работе с продуктом. Nullable reference типы помогают защититься от NRE.

В этой статье я расскажу о ряде неочевидных возможностей, связанных с nullable reference типами. Но начать стоит с краткого описания этих типов.

В двух словах о nullable reference

С точки зрения логики выполнения программы, nullable reference тип ничем не отличается от reference типа. Разница между ними лишь в особой аннотации, которая есть у первого. При помощи неё компилятор делает вывод о том, допустимо ли значение null для конкретной переменной или выражения. Чтобы использовать nullable reference типы, необходимо убедиться в том, что nullable-контекст включён для проекта или файла (как это сделать, будет описано далее).

Для объявления nullable reference переменной необходимо добавить '?' в конце имени типа.

Пример:

string? str = null;

Теперь переменная str может принимать null, и компилятор не будет выдавать предупреждение на данный код. Если не добавлять '?' при объявлении переменной и присвоить ей null, будет выдано предупреждение.

Существует возможность подавления предупреждений компилятора о возможной записи null в reference переменную, не помеченную как nullable.

Рассмотрим пример:

object? GetPotentialNull(bool flag)
{
  return flag ? null : new object();
}

void Foo()
{
  object obj = GetPotentialNull(false);
}

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

object obj = GetPotentialNull(false)!;

Используя оператор '!', мы "говорим" компилятору о том, что метод точно не вернёт null. Следовательно, предупреждений на данный участок кода не будет.

Функционал, доступный при работе с nullable reference типами, не ограничен объявлением переменных такого типа (использование '?') и подавлением предупреждений с помощью '!'. Дальше я рассмотрю наиболее интересные возможности при работе с ними.

Управление nullable-контекстом

Существует ряд механизмов для более гибкой работы с nullable reference типами. Разберём некоторые из них.

Управление с помощью атрибутов

При помощи атрибутов можно указать компилятору null-состояние различных элементов. Здесь будут рассмотрены наиболее интересные из них. С полным списком атрибутов можно ознакомиться в документации.

Для более простого изложения мыслей введём термин null-состояния. null-состояние – информация о том, может ли переменная или выражение иметь значение null в данный момент.

AllowNull

Разберём работу этого атрибута на примере:

public string Name
{
  get => _name;
  set => _name = value ?? "defaultName";
}

private string _name;

Если записать в свойство Name значение null, то компилятор выдаст предупреждение: Cannot convert null literal to non-nullable reference type. Но из реализации свойства видно, что оно предполагает возможность записи null. В этом случае полю _name присваивается строка "defaultName".

Если к типу свойства просто добавить '?', то компилятор будет считать, что:

  • set-аксессор может принимать null (это корректно);
  • get-аксессор может вернуть null (это ошибочно).

Для корректной реализации стоит разметить свойство атрибутом AllowNull:

[AllowNull]
public string Name

После этого компилятор будет считать, что в Name допустимо присваивание null, хотя тип свойства не помечен как nullable. Если присвоить значение этого свойства переменной, не допускающей значение null, то предупреждений возникать не будет.

NotNullWhen

Представим ситуацию, когда есть метод, который проверяет переменную на null. В зависимости от результата этой проверки он возвращает значение типа bool. Такой метод информирует нас о null-состоянии переменной.

Рассмотрим синтетический пример:

bool CheckNotNull(object? obj)
{
  return obj != null;
}

Данный метод проверяет параметр obj на null и возвращает значение типа bool в зависимости от результата этой проверки.

Используем результат работы этого метода в условии:

public void Foo(object? obj1)
{
  object obj2 = new object();

  if (CheckNotNull(obj1))
    obj2 = obj1;
}

На этот код компилятор выдаст предупреждение: Converting null literal or possibly null value to non-nullable type. Но такой сценарий невозможен, так как условие гарантирует, что в then-ветке obj1 не null. Проблема в том, что компилятор этого не понимает, поэтому мы должны ему помочь.

Изменим сигнатуру метода CheckNotNull, добавив туда атрибут NotNullWhen:

bool CheckNotNull([NotNullWhen(true)]object? obj)

Этот атрибут принимает в качестве первого аргумента значение типа bool. При помощи NotNullWhen мы связываем null-состояние аргумента с возвращаемым значением метода. В данном случае мы "говорим" компилятору, что если метод вернёт true, то аргумент имеет значение, отличное от null.

Существует особенность, связанная с этим атрибутом.

Рассмотрим несколько примеров:

Использование модификатора out

bool GetValidOrDefaultName([NotNullWhen(true)] out string? validOrDefaultName, 
                           string name)
{
  if (name == null)
  {
    validOrDefaultName = name;
    return true;
  }
  else
  {
    validOrDefaultName = "defaultName";
    return false;
  }
}

Здесь компилятор выдаст предупреждение: Parameter 'validOrDefaultName' must have a non-null value when exiting with 'true'. Оно вполне оправдано, так как в условии вместо оператора '!=' используется '=='. В данной реализации метод возвращает true, когда validOrDefaultName имеет значение null.

Использование модификатора ref

bool SetDefaultIfNotValid([NotNullWhen(true)] ref string? name)
{
  if (name == null)
    return true;

  name = "defaultName";
  return false;
}

На данный код мы также получим предупреждение: Parameter 'name' must have a non-null value when exiting with 'true'. Аналогично предыдущему примеру предупреждение обосновано. Вместо оператора '!=' используется '=='.

Без использования модификатора

bool CheckingForNull([NotNullWhen(true)] string? name)
{
  if (name == null)
    return true;

  Console.WriteLine("name is null");
  return false;
}

Ситуация схожа с предыдущими кейсами. Если name равняется null, то метод возвращает true. Следуя логике прошлых примеров, здесь тоже должно быть выдано предупреждение: Parameter 'name' must have a non-null value when exiting with 'true'. Однако его нет. Тяжело сказать, чем это обусловлено, но выглядит странно.

NotNullIfNotNull

Данный атрибут позволяет установить связь между аргументом и возвращаемым значением метода. Если аргумент не null, то возвращаемое значение тоже не null, и наоборот.

Рассмотрим пример:

public string? GetString(object? obj)
{
  return obj == null ? null : string.Empty;
}

Метод GetString возвращает null или пустую строку в зависимости от null-состояния аргумента.

Использование этого метода:

public void Foo(object? obj)
{
  string str = string.Empty;

  if(obj != null)
    str = GetString(obj);
}

Предупреждение компилятора на данный код: Converting null literal or possibly null value to non-nullable type. В данном случае он лжёт. Присваивание производится в теле if, условие которого гарантирует, что GetString не вернёт null. Чтобы помочь компилятору, добавим атрибут NotNullIfNotNull для возвращаемого значения метода:

[return: NotNullIfNotNull("obj")]
public string? GetString(object? obj)

Примечание. Начиная с C# 11, получить имя параметра можно с помощью выражения nameof. В данном случае было бы nameof(obj).

Атрибут NotNullIfNotNull в качестве первого аргумента принимает значение типа string – имя параметра, на основании null-состояния которого задаётся null-состояние возвращаемого значения. Теперь компилятор имеет информацию о связи между obj и возвращаемым значением метода: если obj не null, то и возвращаемое значение метода не будет null, и наоборот.

MemberNotNull

Начнём с примера:

class Person
{
  private string _name;

  public Person()
  {
    SetDefaultName();
  }

  private void SetDefaultName()
  {
    _name = "Bob";
  }
}

На этот код компилятор выдаст предупреждение: Non-nullable field '_name' must contain a non-null value when exiting constructor. Consider declaring the field as nullable. Однако в теле конструктора вызывается метод SetDefaultName, который и инициализирует единственное поле класса. Значит, сообщение компилятора является ложным. Решить проблему позволяет атрибут MemberNotNull:

[MemberNotNull(nameof(_name))]
private void SetDefaultName()

Этот атрибут принимает аргумент типа string[] c ключевым словом params. Строки должны соответствовать именам членов, которые инициализируются в методе.

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

MemberNotNullWhen

Разберём следующий пример:

class Person
{
  static readonly Regex _nameReg = new Regex(@"^I'm \w*");

  private string _name;

  public Person(string name)
  {
    if (!TryInitialize(name))
      _name = "invalid name";
  }

  private bool TryInitialize(string name)
  {
    if (_nameReg.IsMatch(name))
    {
      _name = name;
      return true;
    }
    else
      return false;
  }
}

TryInitialize будет инициализировать _name, если значение аргумента соответствует некоторому паттерну. Метод возвращает true, когда поле было инициализировано, в противном случае возвращается false. В зависимости от результата выполнения TryInitialize в конструкторе присваивается значение полю _name. В данной реализации _name не может быть не проинициализировано в конструкторе. Однако компилятор выдаст предупреждение: Non-nullable field '_name' must contain a non-null value when exiting constructor. Consider declaring the field as nullable.

Для исправления ситуации необходимо добавить атрибут MemberNotNullWhen:

[MemberNotNullWhen(true, nameof(_name))]
private bool TryInitialize(string name)

Тип первого аргумента – bool, второго – string[] (с ключевым словом params). Атрибут применяется для методов с возвращаемым значением типа bool. Логика проста: если метод возвращает значение, которое соответствует первому аргументу атрибута, то члены класса, переданные в params, будут считаться инициализированными.

DoesNotReturn и DoesNotReturnIf

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

Рассмотрим пример:

private void ThrowException()
{
  throw new Exception();
}

void Foo(string? str)
{
  if (str == null)
    ThrowException();

  string notNullStr = str;
}

На данный код компилятор выдаст предупреждение: Converting null literal or possibly null value to non-nullable type. Однако, если strnull, выполнение метода не дойдёт до участка кода с присваиванием, так как будет выброшено исключение. Таким образом, в момент присваивания переменная str не может быть равна null.

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

Добавим атрибут для ThrowException:

[DoesNotReturn]
private void ThrowException()

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

Атрибут DoesNotReturnIf работает схоже с DoesNotReturn за исключением проверки дополнительного условия.

Рассмотрим пример:

private void ThrowException([DoesNotReturnIf(true)] bool flag)
{
  if(flag)
    throw new Exception();
}

Компилятор будет считать, что throwException не вернёт управление в вызывающий метод, если параметр flag принимает значение true.

Управление на уровне проекта

Чтобы изменить nullable-контекст на уровне проекта, необходимо открыть свойства проекта и в разделе "Build" выбрать и интересующий контекст.

Задать nullable-контекст можно в проектном файле (.csproj). Нужно открыть этот файл и записать значение в свойство Nullable:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>disable</Nullable>               // <=
  </PropertyGroup>
</Project>

Скорее всего, многим известно, что nullable-контекст можно включать и выключать. Соответственно, если его необходимо включить, то используется enable, если требуется выключить – disable. Действительно, всё работает именно так, но есть ещё два варианта контекста.

Warnings

Поведение в контексте предупреждений:

  • знак '?' никак не влияет на анализ;
  • с точки зрения компилятора все значения ссылочного типа по умолчанию могут иметь значение null;
  • если записать знак '?', компилятор выдаст предупреждение о том, что в данном контексте он не должен быть использован;
  • компилятор будет выдавать предупреждение только на те участки кода, где разыменовывается нулевая ссылка;
  • можно указывать на то, что выражение не равно null c помощью оператора '!'.

Этот режим поможет защититься от исключений типа NullReferenceException. Он информирует о разыменовании нулевых ссылок.

Annotations

Поведение в контексте аннотаций:

  • отсутствуют предупреждения, связанные с разыменованием нулевых ссылок и ошибками при работе с nullable reference;
  • при использовании '?' и '!' компилятор не выдаёт предупреждений.

Данный режим поможет осуществить плавный вход в использование nullable reference типов в проекте. Он позволяет размечать переменные, допускающие и не допускающие значение null.

Управление с помощью директив компиляции

Директивы компиляции используются на уровне файла с расширением .cs и позволяют изменить состояния nullable-контекста для участка кода в нём. Принцип работы аналогичен тому, что был описан в предыдущем разделе. Каждая директива начинается с '#'.

Рассмотрим все возможные директивы:

  • #nullable disable – отключает nullable-контекст;
  • #nullable enable – включает nullable-контекст;
  • #nullable restore – возвращает nullable-контекст к его значению на уровне проекта;
  • #nullable disable annotations – отключает контекст аннотаций;
  • #nullable enable annotations – включает контекст аннотаций;
  • #nullable restore annotations – возвращает контекст аннотаций к его значению на уровне проекта;
  • #nullable disable warnings – отключает контекст предупреждений;
  • #nullable enable warnings – включает контекст предупреждений;
  • #nullable restore warnings – возвращает контекст предупреждений к его значению на уровне проекта.

По сути, значение enable представляет собой включённый контекст аннотаций и контекст предупреждений, а disable – наоборот, эти же контексты в выключенном состоянии. Таким образом, директива '#nullable enable' будет эквивалентна написанным вместе '#nullable enable annotations' и '#nullable enable warnings'.

Можно использовать сразу несколько директив в одном файле. Это позволит задавать разный nullable-контекст для разных фрагментов кода.

Рассмотрим пример такого использования (на уровне проекта nullable-контекст выключен):

.... // на данном участке кода nullable-контекст отключен
#nullable enable warnings
.... // на данном участке кода включен контекст предупреждений
#nullable enable annotations
.... // на данном участке кода включен контекст 
     // предупреждений и аннотаций
#nullable disable annotations
.... // на данном участке кода включен только контекст предупреждений
#nullable restore
.... // на данном участке кода nullable-контекст отключен 
     // (так как свойство Nullable – disable)

Заключение

В заключение хотелось бы сказать, что возможность использования nullable reference типов должна принести немало пользы разработчикам. Эти типы позволяют сделать приложение более безопасными и правильными с точки зрения архитектуры.

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

Ряд недостатков обусловлен недостаточно глубоким анализом. Такой анализ нельзя произвести на лету, как это происходит при использовании nullable-контекста. С другой стороны, это и не требуется. nullable-контекст хорошо помогает в процессе написания кода. Когда часть функционала уже готова и её необходимо протестировать, следует использовать инструменты для более глубокого анализа – например, PVS-Studio.