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

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

在本教程中,你将构建一个小型库,用来模拟一次调查的执行过程。数据有两种不同的模式,可为空引用类型让你能够区分它们:

  • 调查问题必须始终存在。问题列表和每个问题的文本都不能为 null。
  • 问题的回答可能缺失。受访者可以拒绝回答部分或全部问题,而模型应当明确表达这种情况。

你将使用不可为空和可为空引用类型来声明这些规则。随后,只要代码的行为与设计不一致,编译器就会发出警告。

在本教程中,你将:

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

三个类共同构成调查模型:

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

每种类型都使用不可为空引用类型表示必需值,使用可为空引用类型表示缺失值。

先决条件

本教程假定你熟悉 C#,并了解 Visual Studio 或 .NET CLI。

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

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

dotnet new console -n NullableIntroduction
cd NullableIntroduction

构建调查问题

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

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

构造函数的 text 参数是不可为空的引用类型,因此,如果调用方传入的文本可能为 null,编译器就会发出警告。typeOfQuestion 参数则是枚举值类型。

创建问卷调查

接下来,向项目添加一个名为 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。

在 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?");

测试非空要求

要观察编译器如何检查不可为空的参数,请尝试添加以下代码行,然后重新生成项目:

surveyRun.AddQuestion(QuestionType.Text, default);

编译器会发出警告 CS8625,因为对于引用类型,default 的计算结果为 null,而 AddQuestion 要求一个不可为空的 string。继续之前,请删除这一行。

构建响应类型

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

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

namespace NullableIntroduction;

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

创建受访者

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

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

surveyResponses 字段的类型是 Dictionary<int, string>?。如果未经 null 检查就解引用该字段,编译器会发出警告。在 AnswerSurvey 内部,紧接着 new 表达式之后,编译器就能跟踪到 surveyResponses 的状态为非空,因此循环体中不需要额外检查。

构建一组调查响应

在 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。

从 Main 调用 PerformSurvey(本示例在 Program.cs 的顶级语句中调用):

surveyRun.PerformSurvey(50);

检查调查结果

为了报告调查结果,需要从 SurveyResponse 和 SurveyRun 中公开几个辅助成员。在 SurveyResponse 中添加处理可为空字典的表达式主体成员。这类成员使用 => 和单个表达式定义,而不使用 { ... } 代码块:

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

AnsweredSurvey 检查该字段是否为 null。Answer 使用 ?. 空条件运算符安全地解引用:当左侧为 null 时,结果为 null,而不是抛出异常。它还使用 ?? 空合并运算符提供非空的回退值:当左操作数为 null 时,以右操作数代替。该方法的返回类型是不可为空的 string,因此调用方不需要进行空值检查。

在 SurveyRun 中添加表达式主体成员,以公开受访者列表和问题列表:

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

尽管 respondents 可能为 null,AllParticipants 返回的序列仍不可为空。当该字段尚未填充时,?? 运算符会使用 Enumerable.Empty<SurveyResponse>() 代替。如果删除 ?? 子句,编译器会警告该方法可能返回 null,尽管它声明的返回类型不可为空。

最后,在 Main 的末尾编写报告(本示例将代码添加到 Program.cs 顶级语句的末尾):

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

注意,participant、surveyRun.Questions 和 surveyRun.GetQuestion(i) 都不需要进行空值检查。这些类型已将相应值声明为不可为空,因此编译器会在整个循环中将它们视为非空。

运行应用程序:

dotnet run

由于受访者是随机生成的,每次运行的输出都会不同。输出会列出参与者的答案,或说明他们拒绝了调查。

结束语

完整示例位于 dotnet/samples 仓库的 csharp/NullableIntroduction 文件夹中。你可以尝试在可为空与不可为空类型之间切换。在设计允许缺失值的位置删除一个 ?,会产生编译器警告,指出所有受缺失值影响的位置。

相关内容

来源与许可

原文:Microsoft Learn / Microsoft .NET 文档贡献者,《教程:使用可为空和不可为空的引用类型表达设计意图》;英文文档源文件。

本文为中文译校版本,统一了术语与表达,校正了构造函数参数类型和调用方向的说明,补充了顶级语句与 Main 的对应关系,并将输出说明调整为包含参与者标题、答案和拒绝调查的情况;示例代码保持原样。正文依据 知识共享署名 4.0 国际许可(CC BY 4.0) 使用,许可依据见官方文档仓库的 LICENSE。本译校版本亦按 CC BY 4.0 提供。

示例代码依据 MIT 许可 使用。以下保留原版权声明、许可与免责声明:

The MIT License (MIT)

Copyright (c) Microsoft Corporation

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容