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

Ссылочные типы, допускающие значение NULL , дополняют ссылочные типы точно так же, как типы значений, допускающие значение NULL, дополняют типы значений. Чтобы объявить переменную как имеющую ссылочный тип, допускающий значение null, добавьте ? к типу. Например, string? соответствует типу string, допускающему значение null. Эти новые типы можно использовать для более четкого выражения проектного замысла: некоторые переменные всегда должны содержать значение, а в других они могут отсутствовать.

В этом руководстве вы узнаете, как:

  • Внедрять в проекты ссылочные типы, допускающие и не допускающие значение null.
  • Включать в коде проверку ссылочных типов, допускающих значение null.
  • Писать код, в котором компилятор принудительно применяет эти проектные решения.
  • Использовать ссылочный тип, допускающий значение null, в собственных проектах.

Предварительные требования

Вам потребуется настроить компьютер для запуска .NET, включая компилятор C#. Компилятор C# доступен в Visual Studio 2022 или пакете SDK для .NET.

В этом руководстве предполагается, что вы знакомы с C# и .NET, включая Visual Studio или .NET CLI.

Внедрение в проекты ссылочных типов, допускающих значение null

В этом руководстве вы создадите библиотеку, моделирующую выполнение опроса. Код использует оба ссылочных типа (допускающий и не допускающий значение null) для представления реальных понятий. Вопросы в опросе никогда не могут принимать значение null. Респондент может предпочесть не отвечать на вопрос. В этом случае ответы могут принимать значение null.

Код, который вы напишете для этого примера, выражает это намерение, а компилятор применяет его.

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

Создайте новое консольное приложение в Visual Studio или из командной строки с помощью dotnet new console. Присвойте приложению имя NullableIntroduction. Создав приложение, вам нужно указать, что весь проект компилируется c включенным контекстом заметок, допускающим значение NULL. Откройте файл .csproj и добавьте элемент Nullable в элемент PropertyGroup. Задайте для него значение enable. Необходимо выбрать функцию ссылочных типов, допускающих значение NULL , в проектах более ранних версий, чем C# 11. Это связано с тем, что когда эта функция будет включена, существующие объявления ссылочных переменных становятся ссылочными типами, допускающими значение null. Хотя это решение поможет найти проблемы, где существующий код может не иметь правильных проверок нулевых значений, оно может не точно отражать исходное намерение проекта.

<Nullable>enable</Nullable>

В версиях, предшествующих .NET 6, новые проекты не включают элемент Nullable. Начиная с .NET 6 все файлы новых проектов содержат элемент <Nullable>enable</Nullable>.

Проектирование типов для приложения

Для этого приложения опроса требуется создать ряд классов:

  • Класс, моделирующий список вопросов.
  • Класс, моделирующий список людей, с которыми связались для опроса.
  • Класс, моделирующий ответы человека, принявшего участие в опросе.

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

  • Вопросы, входящие в опрос, не могут иметь значение null: Нет смысла задавать пустой вопрос.
  • Респонденты никогда не могут принимать значение null. Следует отслеживать людей, с которыми вы связались, включая респондентов, которые отказались участвовать.
  • Любой ответ на вопрос может принимать значение null. Респонденты могут отказаться отвечать на некоторые или все вопросы.

Если вы программировали на C#, возможно, вы так привыкли ссылаться на типы, допускающие значение null, что упустили другие возможности для объявления экземпляров, не допускающих значение null:

  • Набор вопросов не должен допускать значение null.
  • Набор ответов не должен допускать значение null.

При написании кода вы увидите, что если ссылочный тип, не допускающий значение null, является типом по умолчанию для ссылок, это позволяет избежать распространенных ошибок, которые могут привести к исключениям из-за пустых ссылок (NullReferenceException). В этом руководстве описана важность принятия решений о том, какие переменные могут или не могут принимать значение null. Язык не предоставлял синтаксис для выражения этих решений, а теперь предоставляет.

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

  1. Создание опроса и добавление в него вопросов.
  2. Создание псевдослучайного набора респондентов для опроса.
  3. Связь с респондентами, пока размер опроса не достигает целевого значения.
  4. Запись важных статистических данных на основе ответов на опрос.

Создание опроса с типами, допускающими и не допускающими значение NULL

Первый код, который вы напишете, создает опрос. Вы напишете классы для моделирования вопроса и выполнения опроса. Опрос состоит из вопросов трех типов, различающихся форматом ответов: да или нет, числовые ответы и текстовые ответы. Создайте класс public SurveyQuestion:

namespace NullableIntroduction
{
    public class SurveyQuestion
    {
    }
}

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

namespace NullableIntroduction
{
    public enum QuestionType
    {
        YesNo,
        Number,
        Text
    }

    public class SurveyQuestion
    {
        public string QuestionText { get; }
        public QuestionType TypeOfQuestion { get; }
    }
}

Так как вы еще не инициализировали QuestionText, компилятор выдает предупреждение о том, что свойство, не допускающее значение null, не инициализировано. Для проекта требуется, чтобы текст вопроса не принимал значение null, поэтому необходимо добавить конструктор, чтобы инициализировать свойство и значение QuestionType. Законченное определение класса выглядит следующим образом:

namespace NullableIntroduction;

public enum QuestionType
{
    YesNo,
    Number,
    Text
}

public class SurveyQuestion
{
    public string QuestionText { get; }
    public QuestionType TypeOfQuestion { get; }

    public SurveyQuestion(QuestionType typeOfQuestion, string text) =>
        (TypeOfQuestion, QuestionText) = (typeOfQuestion, text);
}

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

Затем создайте класс public с именем SurveyRun. Этот класс содержит список объектов SurveyQuestion и методов для добавления вопросов в опрос, как показано в следующем коде:

using System.Collections.Generic;

namespace NullableIntroduction
{
    public class SurveyRun
    {
        private List<SurveyQuestion> surveyQuestions = new List<SurveyQuestion>();

        public void AddQuestion(QuestionType type, string question) =>
            AddQuestion(new SurveyQuestion(type, question));
        public void AddQuestion(SurveyQuestion surveyQuestion) => surveyQuestions.Add(surveyQuestion);
    }
}

Как и раньше, необходимо инициализировать объект списка с помощью значения, отличного от null, или компилятор выдаст предупреждение. При второй перегрузке AddQuestion не будет проверок значений null, так как они не требуются: вы объявили, что переменная не допускает значение null. Это значение не может быть равно null.

Переключитесь на Program.cs в редакторе и замените содержимое метода Main следующими строками кода:

var surveyRun = new SurveyRun();
surveyRun.AddQuestion(QuestionType.YesNo, "Has your code ever thrown a NullReferenceException?");
surveyRun.AddQuestion(new SurveyQuestion(QuestionType.Number, "How many times (to the nearest 100) has that happened?"));
surveyRun.AddQuestion(QuestionType.Text, "What is your favorite color?");

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

surveyRun.AddQuestion(QuestionType.Text, default);

Создание респондентов и получение ответов на опрос

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

  1. Создать метод, генерирующий объекты-респонденты. Они представляют людей, которых просят пройти опрос.
  2. Создать логику, чтобы имитировать процесс задания вопросов респонденту и собирать ответы или отмечать, что респондент не ответил.
  3. Повторять опрос до тех пор, пока нужное количество респондентов не ответит на вопросы.

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

namespace NullableIntroduction
{
    public class SurveyResponse
    {
        public int Id { get; }

        public SurveyResponse(int id) => Id = id;
    }
}

Затем добавьте метод static для создания новых участников путем генерации случайного идентификатора:

private static readonly Random randomGenerator = new Random();
public static SurveyResponse GetRandomId() => new SurveyResponse(randomGenerator.Next());

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

  1. Запросите участие в опросе. Если пользователь не согласен, верните отсутствующий (или null) ответ.
  2. Задайте каждый вопрос и запишите ответы. Любой ответ может отсутствовать (или иметь значение null).

Добавьте приведенный ниже код к классу SurveyResponse:

private Dictionary<int, string>? surveyResponses;
public bool AnswerSurvey(IEnumerable<SurveyQuestion> questions)
{
    if (ConsentToSurvey())
    {
        surveyResponses = new Dictionary<int, string>();
        int index = 0;
        foreach (var question in questions)
        {
            var answer = GenerateAnswer(question);
            if (answer != null)
            {
                surveyResponses.Add(index, answer);
            }
            index++;
        }
    }
    return surveyResponses != null;
}

private bool ConsentToSurvey() => randomGenerator.Next(0, 2) == 1;

private string? GenerateAnswer(SurveyQuestion question)
{
    switch (question.TypeOfQuestion)
    {
        case QuestionType.YesNo:
            int n = randomGenerator.Next(-1, 2);
            return (n == -1) ? default : (n == 0) ? "No" : "Yes";
        case QuestionType.Number:
            n = randomGenerator.Next(-30, 101);
            return (n < 0) ? default : n.ToString();
        case QuestionType.Text:
        default:
            switch (randomGenerator.Next(0, 5))
            {
                case 0:
                    return default;
                case 1:
                    return "Red";
                case 2:
                    return "Green";
                case 3:
                    return "Blue";
            }
            return "Red. No, Green. Wait.. Blue... AAARGGGGGHHH!";
    }
}

Хранилищем для ответов на опрос является Dictionary<int, string>?, из которого видно, что оно может принимать значение null. Используйте новую языковую функцию, чтобы объявить намерение проекта как компилятору, так и всем, кто будет работать с кодом позже. Если вы когда-нибудь разыменуете surveyResponses, не проверив значения null, компилятор отобразит предупреждение. В методе AnswerSurvey предупреждение не отображается, так как компилятор может определить, что переменной surveyResponses ранее было присвоено значение, отличное от null.

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

Далее необходимо написать метод PerformSurvey в классе SurveyRun. Добавьте в класс SurveyRun следующий код:

private List<SurveyResponse>? respondents;
public void PerformSurvey(int numberOfRespondents)
{
    int respondentsConsenting = 0;
    respondents = new List<SurveyResponse>();
    while (respondentsConsenting < numberOfRespondents)
    {
        var respondent = SurveyResponse.GetRandomId();
        if (respondent.AnswerSurvey(surveyQuestions))
            respondentsConsenting++;
        respondents.Add(respondent);
    }
}

В этом случае выбор List<SurveyResponse>? с возможностью принимать значение null указывает на то, что ответ может отсутствовать. Это значит, что респонденты еще не участвовали в опросе. Обратите внимание, что респонденты добавляются до тех пор, пока не будет достигнуто целевое значение.

Для запуска опроса осталось добавить вызов в конце метода Main:

surveyRun.PerformSurvey(50);

Анализ ответов на опросы

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

public bool AnsweredSurvey => surveyResponses != null;
public string Answer(int index) => surveyResponses?.GetValueOrDefault(index) ?? "No answer";

Так как surveyResponses является ссылочным типом, допускающим значение null, прежде чем отменять ссылку на него, укажите, что проверка требуется. Метод Answer возвращает строку, не допускающую значения null, поэтому мы должны охватить вариант отсутствующего ответа, используя оператор объединения со значением null.

Затем добавьте эти три элемента, воплощающие выражение, в класс SurveyRun:

public IEnumerable<SurveyResponse> AllParticipants => (respondents ?? Enumerable.Empty<SurveyResponse>());
public ICollection<SurveyQuestion> Questions => surveyQuestions;
public SurveyQuestion GetQuestion(int index) => surveyQuestions[index];

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

Наконец, добавьте следующий цикл в нижней части метода Main:

foreach (var participant in surveyRun.AllParticipants)
{
    Console.WriteLine($"Participant: {participant.Id}:");
    if (participant.AnsweredSurvey)
    {
        for (int i = 0; i < surveyRun.Questions.Count; i++)
        {
            var answer = participant.Answer(i);
            Console.WriteLine($"\t{surveyRun.GetQuestion(i).QuestionText} : {answer}");
        }
    }
    else
    {
        Console.WriteLine("\tNo responses");
    }
}

В этом коде не требуется никаких проверок null, так как вы разработали базовые интерфейсы так, чтобы они возвращали ссылочные типы, не допускающие значения null.

Получите код

Вы можете получить код этого руководства из нашего репозитория samples в папке csharp/NullableIntroduction.

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

Следующие шаги

Узнайте, как использовать ссылочный тип, допускающий значение NULL, при использовании Entity Framework: