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

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

![1017_NullableReferenceTypes_ru/image1.png](https://import.viva64.com/docx/blog/1017_NullableReferenceTypes_ru/image1.png)

## Введение

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

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

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

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

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

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

Пример:

```cpp
string? str = null;
```

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

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

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

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

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

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

```cpp
object obj = GetPotentialNull(false)!;
```

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

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

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

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

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

При помощи атрибутов можно указать компилятору null\-состояние различных элементов\. Здесь будут рассмотрены наиболее интересные из них\. С полным списком атрибутов можно ознакомиться [в документации](https://learn.microsoft.com/ru-ru/dotnet/csharp/language-reference/attributes/nullable-analysis)\.

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

#### AllowNull

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

```cpp
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_:

```cpp
[AllowNull]
public string Name
```

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

#### NotNullWhen

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

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

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

Данный метод проверяет параметр _obj_ на _null_ и возвращает значение типа _bool_ в зависимости от результата этой проверки\.

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

```cpp
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_:

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

Этот атрибут принимает в качестве первого аргумента значение типа _bool_\. При помощи _NotNullWhen_ мы связываем null\-состояние аргумента с возвращаемым значением метода\. В данном случае мы "говорим" компилятору, что если метод вернёт _true_, то аргумент имеет значение, отличное от _null_\.

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

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

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

```cpp
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_**

```cpp
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'_\. Аналогично предыдущему примеру предупреждение обосновано\. Вместо оператора '\!\=' используется '\=\='\.

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

```cpp
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_, и наоборот\.

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

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

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

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

```cpp
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_ для возвращаемого значения метода:

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

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

Атрибут _NotNullIfNotNull_ в качестве первого аргумента принимает значение типа _string_ – имя параметра, на основании null\-состояния которого задаётся null\-состояние возвращаемого значения\. Теперь компилятор имеет информацию о связи между _obj_ и возвращаемым значением метода: если _obj_ не _null_, то и возвращаемое значение метода не будет _null,_ и наоборот\.

#### MemberNotNull

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

```cpp
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_:

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

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

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

#### MemberNotNullWhen

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

```cpp
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_:

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

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

#### DoesNotReturn и DoesNotReturnIf

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

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

```cpp
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_\. Однако, если _str_ – _null_, выполнение метода не дойдёт до участка кода с присваиванием, так как будет выброшено исключение\. Таким образом, в момент присваивания переменная _str_ не может быть равна _null_\.

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

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

```cpp
[DoesNotReturn]
private void ThrowException()
```

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

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

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

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

Компилятор будет считать, что _throwException_ не вернёт управление в вызывающий метод, если параметр _flag_ принимает значение _true_\.

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

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

![1017_NullableReferenceTypes_ru/image2.png](https://import.viva64.com/docx/blog/1017_NullableReferenceTypes_ru/image2.png)

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

```cpp
<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\-контекст выключен\):

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

## Заключение

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

Данный механизм также не лишён недостатков\. О них, да и в целом о nullable reference типах, рассказывали в статьях: [раз](https://pvs-studio.ru/ru/blog/posts/csharp/0631/), [два](https://pvs-studio.ru/ru/blog/posts/csharp/0764/)\. Возможность добавления атрибутов имеет смысл во многом из\-за несовершенства используемого статического анализатора\. Поэтому необходимо добавлять аннотации на методы, поля и т\. д\. вручную, т\. к\. анализатор не может понять некоторые связи\. Например, связь между возвращаемым значением метода и null\-состоянием переменной\.

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