教程:使用可为 null 和不可为 null 的引用类型更清楚地表达设计意向

可空引用类型 与可空值类型对值类型的补充方式相同,互为补充。 通过将 ? 追加到此类型,你可以将变量声明为可为空引用类型。 例如,string? 表示可为 null 的 string。 可以使用这些新类型更清楚地表达设计意图:某些变量 必须始终具有值,其他 可能缺少值

本教程介绍以下操作:

  • 将可为空和不可为空引用类型合并到你的设计中
  • 在整个代码中启用可为空引用类型检查。
  • 编写代码,编译器在其中强制执行这些设计决策。
  • 在自己的设计中使用可为空引用功能

先决条件

本教程假定你熟悉 C# 和 .NET,包括 Visual Studio 或 .NET CLI。

将可为空引用类型合并到你的设计中

在本教程中,你将生成一个运行调查的模型库。 该代码使用可为 null 的引用类型和不可为 null 的引用类型来表示实际概念。 调查问题决不会为 NULL。 被调查者可能不想回答问题。 在这种情况下,响应可能为 null

你将为此示例编写的代码表示该意向,编译器将强制实施该意向。

创建应用程序并启用可为空引用类型

使用 dotnet new console在 Visual Studio 或命令行中创建新的控制台应用程序。 将应用程序命名为 NullableIntroduction。 创建应用程序后,需要指定整个项目都在启用的“可为空注释上下文”中进行编译。 打开 .csproj 文件,并将 Nullable 元素添加到 PropertyGroup 元素。 将其值设置为 enable。 在 C# 11 之前的项目中,必须选择“可为空引用类型”功能。 这是因为一旦启用该功能,现有引用变量声明将变为 非空引用类型。 虽然该决策有助于查找现有代码中可能缺少正确空值检查的问题,但它可能无法准确反映您的原始设计意图。

<Nullable>enable</Nullable>

在 .NET 6 之前,新项目不包括 Nullable 元素。 从 .NET 6 开始,新项目将 <Nullable>enable</Nullable> 元素包含在项目文件中。

设计应用程序的类型

此调查应用程序需要创建多个类:

  • 对问题列表进行建模的类。
  • 建模为调查所联系的人员列表的类。
  • 建模来自参加调查人员的答案的类。

这些类型将同时使用可为 null 和不可为 null 的引用类型来表达需要哪些成员和哪些成员是可选的。 可为空引用类型清楚地传达了设计意图:

  • 作为调查一部分的问题永远不能为空:提出空问题是没有意义的。
  • 回应者永远不能为 NULL。 你需要跟踪你联系的人员,甚至拒绝参与的受访者。
  • 对问题的任何响应可能为 null。 受访者可能会拒绝回答一些或所有问题。

如果你曾经在 C# 中编程,你可能已经习惯了允许 null 值的引用类型,因而可能错过了其他机会来声明不可为 null 的实例。

  • 问题集合应不可为空。
  • 回应者集合应不可为空。

在编写代码时,你将看到不可为空引用类型作为引用的默认值,可避免可能导致 NullReferenceException 的常见错误。 从本教程得出的一个经验是,你应决定哪些变量可为或不可为 null。 该语言没有提供语法来表达这些决定。 现在,它确实存在。

你将生成的应用执行以下步骤:

  1. 创建调查并为其添加问题。
  2. 为调查创建一组伪随机的受访者。
  3. 联系被调查者,直到完成的调查数量达到目标数量。
  4. 写出有关调查响应的重要统计信息。

使用可为 null 和不可为 null 引用类型构建调查

要编写的第一个代码将创建调查。 你将编写类来为调查问题和调查运行建模。 调查有三种类型的问题,以答案格式区分:是/否答案、数字答案和文本答案。 创建 public SurveyQuestion 类:

namespace NullableIntroduction
{
    public class SurveyQuestion
    {
    }
}

编译器将在启用的可为空的注释上下文中的代码的每个引用类型变量声明解释为“不可为空”引用类型。 可以通过添加问题文本的属性和问题类型来查看你的第一个警告,如以下代码所示:

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 的引用类型,因此编译器不会发出任何警告。

接下来,创建一个名为 SurveyRunpublic 类。 此类包含 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

切换到编辑器中的 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 传递给任何应为不可为空引用类型的方法时,将收到警告。 试着将以下行添加到 Main中:

surveyRun.AddQuestion(QuestionType.Text, default);

创建受访者并获取调查答案

接下来,编写生成调查答案的代码。 此过程涉及几个小型任务:

  1. 生成生成被调查者对象的方法。 这些对象表示要求填写调查的人员。
  2. 构建逻辑以模拟向被调查者提问并收集答案或指出被调查者未回答的问题。
  3. 重复,直到足够的受访者回答了调查。

你需要一个表示调查响应的类,所以现在就添加它。 启用可为空支持。 添加 Id 属性和初始化它的构造函数,如以下代码所示:

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

        public SurveyResponse(int id) => 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。 你正在使用新的语言功能来表达您的设计意图,同时向编译器和之后阅读代码的任何人进行声明。 如果在不首先检查是否为 null 值的情况下取消引用 surveyResponses,则会收到编译器警告。 在 AnswerSurvey 方法中不会收到警告,因为编译器可以确定 surveyResponses 变量已设置为上述非 null 值。

对缺少的答案使用 null 强调了处理可为空引用类型的一个关键点:目标不是从程序中删除所有 null 值。 相反,你的目标是确保你编写的代码表达你的设计意图。 缺失值是代码中表达的必要概念。 null 值是表达这些缺失值的明确方法。 尝试删除所有 null 值只会导致需要另一些方法来在没有 null的情况下表示这些缺失值。

接下来,需要在 SurveyRun 类中编写 PerformSurvey 方法。 在 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 和不可为 null 的引用类型的值。 首先,将以下两个表达式体成员添加到 SurveyResponse 类:

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

由于 surveyResponses 是可为 null 的引用类型,因此在取消引用之前,必须进行 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 的引用类型。

获取代码

可以从 csharp/NullableIntroduction 文件夹中的 示例 存储库获取已完成教程的代码。

通过更改可为空和不可为空引用类型之间的类型声明进行试验。 了解如何生成不同的警告以确保不会意外取消引用 null

后续步骤

了解如何在 Entity Framework 中使用可为 null 的引用类型: