Tip
不熟悉可为 null 的引用类型? 请先阅读 可为 null 的引用类型。 本教程假定你已经了解不可为 null 的引用类型和可为 null 的引用类型之间的区别,以及编译器如何跟踪 null 状态。
来自另一种语言? 如果你用过 Kotlin 的可空类型、TypeScript 的 strictNullChecks 或 Swift 的可选类型,其概念模型是直接对应的。 此处的练习是关于 表达设计意向,而不是学习语法。
在本教程中,你将构建一个用于对调查流程进行建模的小型库。 数据有两种不同的模式,而可为空引用类型可让你区分它们:
-
调查问题必须始终存在。 问题列表和每个问题的文本绝不能为
null。 - 对 问题的回复 可能缺失。 受访者可以拒绝回答一些或所有问题,模型应该明确。
通过不可为 null 和可为 null 的引用类型来声明这些规则。 然后,每当代码的行为与设计不匹配时,编译器都发出警告。
在本教程中,你将了解:
- 创建应用程序。
- 生成调查问题。
- 创建调查问卷。
- 测试非空要求。
- 生成响应类型。
- 创建受访者。
- 生成一个调查响应。
- 生成一组调查响应。
- 检查调查结果。
三个类对调查建模:
-
SurveyQuestion:一个问题。 文本和问题类型是必需的。 -
SurveyRun:问题集合以及被调查者列表。 -
SurveyResponse:一个受访者的答案,可能缺失。
每种类型对于必需值使用不可为 null 的引用类型,对于缺失值使用可为 null 的引用类型。
先决条件
- 最新的 .NET SDK
- Visual Studio Code 编辑器
- C# 开发套件
本教程假定你熟悉 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!";
}
}
字段 surveyResponses 为 Dictionary<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);
检查调查结果
若要报告结果,请从SurveyResponse和SurveyRun中暴露几个辅助函数。 在 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");
}
}
请注意,participant、surveyRun.Questions 或 surveyRun.GetQuestion(i) 都不需要进行空值检查。 这些类型将这些值声明为不可为 null,因此编译器在整个循环中将它们视为 非 null 。
运行应用程序:
dotnet run
每次运行时的输出都是不同的,因为受访者是随机生成的,但每行都报告参与者的答案或他们拒绝的笔记。
结束语
完成的示例位于 dotnet/samples 存储库的 csharp/NullableIntroduction 文件夹中。 尝试在可空类型和非可空类型之间进行切换。 在设计允许缺失值的地方移除一个 ? 会产生编译器警告,指出所有受该缺失值影响的位置。