教程:使用可为空和不可为空的引用类型表达设计意图

Tip

不熟悉可为 null 的引用类型? 请先阅读 可为 null 的引用类型。 本教程假定你已经了解不可为 null 的引用类型和可为 null 的引用类型之间的区别,以及编译器如何跟踪 null 状态。

来自另一种语言? 如果你用过 Kotlin 的可空类型、TypeScript 的 strictNullChecks 或 Swift 的可选类型,其概念模型是直接对应的。 此处的练习是关于 表达设计意向,而不是学习语法。

在本教程中,你将构建一个用于对调查流程进行建模的小型库。 数据有两种不同的模式,而可为空引用类型可让你区分它们:

  • 调查问题必须始终存在。 问题列表和每个问题的文本绝不能为 null
  • 问题的回复 可能缺失。 受访者可以拒绝回答一些或所有问题,模型应该明确。

通过不可为 null 和可为 null 的引用类型来声明这些规则。 然后,每当代码的行为与设计不匹配时,编译器都发出警告。

在本教程中,你将了解:

  • 创建应用程序。
  • 生成调查问题。
  • 创建调查问卷。
  • 测试非空要求。
  • 生成响应类型。
  • 创建受访者。
  • 生成一个调查响应。
  • 生成一组调查响应。
  • 检查调查结果。

三个类对调查建模:

  • SurveyQuestion:一个问题。 文本和问题类型是必需的。
  • SurveyRun:问题集合以及被调查者列表。
  • SurveyResponse:一个受访者的答案,可能缺失。

每种类型对于必需值使用不可为 null 的引用类型,对于缺失值使用可为 null 的引用类型。

先决条件

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

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

创建名为 NullableIntroduction: 的新控制台应用程序:

dotnet new console -n NullableIntroduction
cd NullableIntroduction

构建调查问题

向项目中添加一个名为 SurveyQuestion.cs 的新文件,并将其内容替换为以下代码。 文本和问题类型不可为 null,因此构造函数必须初始化两者:

namespace NullableIntroduction;

public enum QuestionType
{
    YesNo,
    Number,
    Text
}

public class SurveyQuestion(QuestionType typeOfQuestion, string text)
{
    public string QuestionText { get; } = text;
    public QuestionType TypeOfQuestion { get; } = typeOfQuestion;
}

构造函数参数是不可为 null 的引用类型,因此编译器会在任一参数可能 null时警告调用方。

创建问卷调查

接下来,向项目中添加一个名为 SurveyRun.cs 的新文件,并定义一个用于保存问题列表的 SurveyRun 类:

namespace NullableIntroduction;

public class SurveyRun
{
    private List<SurveyQuestion> surveyQuestions = [];

    public void AddQuestion(QuestionType type, string question) =>
        AddQuestion(new SurveyQuestion(type, question));

    public void AddQuestion(SurveyQuestion surveyQuestion) =>
        surveyQuestions.Add(surveyQuestion);
}

surveyQuestions 字段是非空的 List<SurveyQuestion>。 它使用 集合表达式 初始化空列表。 这两个 AddQuestion 重载均接受不可为 null 的参数,因此编译器会强制调用方不传递 null

Program.cs 中,创建一个 SurveyRun,并添加三个问题:

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 的参数,请尝试添加以下行并重新生成:

surveyRun.AddQuestion(QuestionType.Text, default);

编译器发出警告 CS8625,因为对于引用类型,default 的计算结果为 null,而 AddQuestion 需要不可为 null 的 string。 在继续操作之前,请删除该行。

生成响应类型

受访者可以拒绝参加调查,即使他们参与调查,他们也可以跳过个别问题。 这两种形式的“缺失”都是有效的结果,类型系统应使它们可见。 你用 null 来表示这两种形式。

向项目中添加一个名为 SurveyResponse.cs 的新文件,并定义一个 SurveyResponse 类。 使用 主构造函数(参数在类型本身上声明,并且在整个类型主体中都可用)来接收始终必需的 Id

namespace NullableIntroduction;

public class SurveyResponse(int id)
{
    public int Id { get; } = id;
}

创建受访者

添加 静态工厂方法 (创建 static 并返回类型的新实例的方法,这是直接调用构造函数的替代方法),该构造函数使用随机 ID 创建受访者:

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

生成一个调查响应

接下来,添加向受访者发起调查的方法。 将答案存储在可空字典中,以便类型本身表明被调查者可能会拒绝回答:

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!";
    }
}

字段 surveyResponsesDictionary<int, string>?. 如果在不首先检查 null的情况下取消引用字段,编译器会发出警告。 在 AnswerSurvey 内部,编译器会跟踪到 surveyResponses 在紧接着 new 表达式之后立即为 非 null,因此循环体不需要额外检查。

生成一组调查响应

SurveyRun 上添加一个方法,用于逐步汇总受访者列表,直到有足够多的人同意参与:

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

respondents 字段为 List<SurveyResponse>?,在调查运行之前它一直是 null

PerformSurvey 调用 Main

surveyRun.PerformSurvey(50);

检查调查结果

若要报告结果,请从SurveyResponseSurveyRun中暴露几个辅助函数。 在 SurveyResponse 中,添加用于处理可为 null 的字典的 表达式主体成员(即使用 => 和单个表达式定义,而不是使用 { ... } 块定义的成员):

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

AnsweredSurvey 根据 null 检查该字段。 Answer使用?. null 条件运算符(当左侧为null时,计算结果为null,而不是引发异常)来安全地取消引用,并使用?? null 合并运算符(当左操作数为null时,使用右操作数替代)来提供非 null 的回退值。 方法的返回类型不可为 null string,因此调用方不需要 null 检查。

SurveyRun 中,添加用于公开参与者和问题列表的表达式主体成员:

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

AllParticipants 返回非 null 序列,即使 respondents 可能为 null。 运算符 ?? 在尚未填充字段时替换 Enumerable.Empty<SurveyResponse>() 。 如果删除该 ?? 子句,编译器会警告该方法可能返回 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");
    }
}

请注意,participantsurveyRun.QuestionssurveyRun.GetQuestion(i) 都不需要进行空值检查。 这些类型将这些值声明为不可为 null,因此编译器在整个循环中将它们视为 非 null

运行应用程序:

dotnet run

每次运行时的输出都是不同的,因为受访者是随机生成的,但每行都报告参与者的答案或他们拒绝的笔记。

结束语

完成的示例位于 dotnet/samples 存储库的 csharp/NullableIntroduction 文件夹中。 尝试在可空类型和非可空类型之间进行切换。 在设计允许缺失值的地方移除一个 ? 会产生编译器警告,指出所有受该缺失值影响的位置。