教程:使用可为空和不可为空的引用类型表达设计意图
在本教程中,你将构建一个小型库,用来模拟一次调查的执行过程。数据有两种不同的模式,可为空引用类型让你能够区分它们:
- 调查问题必须始终存在。问题列表和每个问题的文本都不能为
null。 - 问题的回答可能缺失。受访者可以拒绝回答部分或全部问题,而模型应当明确表达这种情况。
你将使用不可为空和可为空引用类型来声明这些规则。随后,只要代码的行为与设计不一致,编译器就会发出警告。
在本教程中,你将:
- 创建应用程序。
- 构建调查问题。
- 创建问卷调查。
- 测试非空要求。
- 构建响应类型。
- 创建受访者。
- 生成一个调查响应。
- 构建一组调查响应。
- 检查调查结果。
三个类共同构成调查模型:
SurveyQuestion:一个问题,问题文本和问题类型都是必需的。SurveyRun:问题集合以及受访者列表。SurveyResponse:一个受访者的答案,其中可能存在缺失。
每种类型都使用不可为空引用类型表示必需值,使用可为空引用类型表示缺失值。
先决条件
- 最新的 .NET SDK。
- Visual Studio Code 编辑器。
- C# Dev Kit。
本教程假定你熟悉 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 文件夹中。你可以尝试在可为空与不可为空类型之间切换。在设计允许缺失值的位置删除一个 ?,会产生编译器警告,指出所有受缺失值影响的位置。











暂无评论内容