Ссылочные типы, допускающие значение null

Подсказка

Вы новичок в разработке программного обеспечения? Начните с руководств Начало работы.

Есть опыт на другом языке? Если вы работали с nullable-типами Kotlin, strictNullChecks в TypeScript или опциональными типами Swift, эта модель вам знакома. C# использует статический анализ и предупреждающую диагностику вместо отдельного типа. Бегло ознакомьтесь с выражением намерений с помощью аннотаций и анализом null-состояния, а затем перейдите к руководству: выражение проектных намерений с помощью ссылочных типов, допускающих и не допускающих значение NULL, чтобы применить эту возможность.

Ссылочные типы, допускающие значение NULL — это набор возможностей, которые сводят к минимуму вероятность того, что ваш код вызовет System.NullReferenceException. Вы объявляете, какие переменные предназначены для хранения null и которые не являются, и компилятор предупреждает, когда эти объявления не соответствуют их применению в коде. Поведение среды выполнения программы не изменилось. Ссылочные типы, допускающие значение NULL, полностью являются функцией времени компиляции.

Три базовых компонента работают вместе:

  • Аннотации переменных (string vs. string?) показывают, какие ссылки должны допускать null.
  • Анализ состояния NULL отслеживает, является ли значение выражения не null или , возможно, null в каждой точке кода.
  • Атрибуты в API описывают более подробные контракты, такие как "этот аргумент может быть null, но возвращаемое значение равно NULL только в том случае, если аргумент имеет значение NULL".

Компилятор объединяет эти сигналы для создания диагностики. Предупреждения о переменной, не допускающей значения null, означают, что переменная может получить значение null. Предупреждения о переменной, допускающей значение NULL, означают, что код может разыменовывать его без проверки null. Разыменование означает использование значения, на которое ссылается переменная. Например, чтобы вызвать у него метод (variable.Method()), прочитать свойство (variable.Property) или обратиться к нему по индексу (variable[0]). Разыменование переменной, которая имеет значение null, вызывает исключение во время выполнения программы. Любое предупреждение означает, что поведение кода не соответствует заданному дизайну.

Контекст, допускающий значение NULL

Проекты, созданные на основе новых шаблонов .NET, устанавливают <Nullable>enable</Nullable> в файле проекта, поэтому рекомендации, приведённые в этой статье, применимы в том виде, в котором они представлены. Если вы работаете в более старой версии проекта, откройте .csproj и проверьте <PropertyGroup> , содержит ли она следующую строку; добавьте ее, если она отсутствует:

<Nullable>enable</Nullable>

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

Выражение намерения с заметками

Каждая переменная ссылочного типа по умолчанию не допускает значение NULL . Добавьте ?, чтобы объявить ссылочный тип, допускающий значение NULL:

public static void Annotations()
{
    string required = "always set";   // non-nullable: assigning null produces a warning
    string? optional = null;          // nullable: holding null is allowed

    Console.WriteLine(required.Length);

    if (optional is not null)
    {
        Console.WriteLine(optional.Length);
    }
}

Заметка не изменяет тип среды выполнения. string и string? оба являются System.String. ? сообщает компилятору о вашем замысле. Это намерение формирует предупреждения, создаваемые компилятором:

  • Переменная, не допускающая значения NULL, имеет состояние null по умолчанию не-null. Компилятор предупреждает, если вы назначите значение, которое может быть null.
  • Переменная, допускающая значение null, по умолчанию имеет null-состояниевозможно, null. Компилятор предупреждает, если вы разыменовываете переменную, не проверяя ее.

Используйте заметку, чтобы сделать обязательные и необязательные значения видимыми в системе типов. В следующем типе FirstName и LastName объявлены как не допускающие значение NULL — у каждого человека должны быть оба, — а MiddleName как допускающий значение NULL, поскольку он есть не у всех:

public sealed class Person(string firstName, string lastName)
{
    public string FirstName { get; } = firstName;
    public string? MiddleName { get; init; }
    public string LastName { get; } = lastName;

    public override string ToString() => MiddleName is null
        ? $"{FirstName} {LastName}"
        : $"{FirstName} {MiddleName} {LastName}";
}

public static void DesignIntent()
{
    Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
    Console.WriteLine(p1);
    // Output: Ada King Lovelace

    Person p2 = new("Grace", "Hopper");
    Console.WriteLine(p2);
    // Output: Grace Hopper
}

Аннотации определяют реализацию ToString. Так как FirstName и LastName не допускают значения null, переопределение использует их непосредственно в интерполированной строке ($"..." — синтаксис, который внедряет выражения в {} места заполнения) без проверки на null. MiddleName допускает значение null, поэтому переопределение сначала сравнивает его с null и включает его только если оно присутствует. Компилятор обеспечивает соблюдение этого различия: код, который передаёт значение, которое может быть null, в место, где ожидается значение, не допускающее null, вызывает предупреждение, и конструктор, который оставляет неинициализированным член, не допускающий null, также вызывает предупреждение.

Анализ нулевого состояния

Компилятор отслеживает состояние NULL каждого выражения. Состояние является одним из двух значений:

  • not-null: известно, что выражение не равно null.
  • может иметь значение NULL: выражение может быть null.

Состояние null локальной переменной обновляется при анализе кода компилятором. Это меняют две вещи: присваивания и проверки на null. После присваивания null-состояние переменной соответствует выражению в правой части. Если выражение равно null или допускает значение null, переменная может иметь значение null. Если выражение является ненулевым литералом, переменная становится ненулевой. После проверки NULL состояние переменной отражает любую ветвь.

public static void NullStateTracking()
{
    string? message = null;

    // Warning: dereference of a possibly null reference.
    Console.WriteLine(message.Length);

    message = "Hello, World!";

    // No warning: the compiler tracks that message is now not-null.
    Console.WriteLine(message.Length);
}

В предыдущем примере первое разыменовывание вызывает предупреждение, так как message имеет значение maybe-null. После присваивания ненулевого литерала компилятор знает, что messageне равно null, поэтому второе разыменование безопасно.

Анализ нулевых состояний работает с проверками if, сопоставлением с образцом (выражениями, такими как is null или is { }, которые проверяют структуру значения), а также в потоке управления с циклами или ранним выходом:

 public sealed class Node(string name)
 {
     public string Name { get; } = name;
     public Node? Parent { get; init; }
 }

 public static void FlowAnalysis(Node start)
 {
     Node? current = start;
     while (current is not null)
     {
         // Inside the loop, the compiler knows current is not-null.
         Console.WriteLine(current.Name);

         current = current.Parent;
     }
}

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

Игнорируйте предупреждения с помощью !

Иногда вы знаете больше, чем компилятор. Оператор,допускающий! значение NULL, объявляет, что выражение не равно NULL, даже если анализ говорит в противном случае:

public static void NullForgiving()
{
    // "ada" matches a switch arm that returns a non-null string,
    // but the return type is string? so the compiler treats the
    // result as maybe-null.
    string? maybeName = LookUpName("ada");

    // The ! tells the compiler "trust me, this isn't null." We just
    // passed "ada", which the switch maps to "Ada Lovelace".
    int length = maybeName!.Length;
    Console.WriteLine(length); // => 12
}

// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
    "ada" => "Ada Lovelace",
    _ => null,
};

Используйте ! только в крайнем случае. Каждый такой случай — это место, где компилятор уже не сможет вас защитить. Предпочитайте добавлять проверку null, реструктуризацию кода или аннотировать соответствующий API, чтобы компилятор достиг правильного вывода самостоятельно.

Атрибуты, описывающие контракты API

Аннотации параметра или возвращаемого значения не всегда достаточно информативны. Метод может принимать аргумент, который может быть null, но гарантировать ненулевой результат. Метод теста может возвращать true только в том случае, если его аргумент не имеет значения NULL. Используйте атрибуты анализа, допускающие значение NULL , для передачи этих контрактов:

public static bool IsPresent([NotNullWhen(true)] string? value) =>
    !string.IsNullOrEmpty(value);

public static void NullAnalysisAttributes()
{
    string? input = ReadInput();

    if (IsPresent(input))
    {
        // No null-forgiving operator needed: the attribute tells the compiler
        // input is not-null when IsPresent returns true.
        Console.WriteLine(input.Length);
    }
}

private static string? ReadInput() => "hello";

NotNullWhenAttribute сообщает компилятору, что, когда IsPresent возвращает true, аргумент не равен null. В блоке if компилятор обрабатывает value как ненуловый оператор без обязательного оператора, допускающего значение NULL. По состоянию на .NET 5 все API среды выполнения .NET аннотированы, поэтому анализ дает преимущества любому коду, который вызывает их.

Известные ошибки

Два сценария могут привести к тому, что недопускающая значения NULL ссылка будет содержать null без предупреждения. Оба шаблона являются ограничениями статического анализа, а не ошибок в коде.

Структуры по умолчанию

Можно создать структуру с полями ссылок, не допускающими значение NULL, с помощью default или new(). Этот подход оставляет поля структуры неинициализированными:

public struct Student
{
    public string FirstName;
    public string? MiddleName;
    public string LastName;
}

public static void DefaultStructPitfall()
{
    Student s = default;            // No warning, but FirstName and LastName are null.
    Console.WriteLine(s.FirstName?.Length ?? -1);
}

Поля содержат null на этапе выполнения, но компилятор не выдаёт предупреждения. Если необходимо использовать структуру, отдавайте предпочтение обязательным членам — членам, которые вызывающий код должен инициализировать с помощью инициализатора объекта, — либо параметризованному конструктору, который вызывающий код обязан вызвать.

Массивы ссылок и структур

Новый массив ссылочного типа, не допускающего значения NULL, содержит все null элементы, пока не назначьте каждый из них:

public static void ArrayPitfall()
{
    string[] values = new string[3];      // Elements are null at run time.
    Console.WriteLine(values[0]?.Length ?? -1);

    string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
    Console.WriteLine(initialized[0].Length);
}

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

Инициализируйте элементы массива при создании массива. Выражения-коллекции (литеральный синтаксис [1, 2, 3]) и типизируемый по целевому типу new (запись new(), когда компилятор может вывести тип) делают полную инициализацию краткой.