Tip
剛開始接觸可空的參考類型嗎? 先讀 《Nullable referencetypes 》。 這個教學假設你了解不可空參考型別和可空參考型別的差異,以及編譯器如何追蹤空狀態。
來自另一種語言? 如果你用過 Kotlin 的可空型別、TypeScript strictNullChecks或 Swift 的選用值,概念模型會直接映射。 這裡的練習是關於 表達設計意圖,而不是學習語法。
在這個教學中,你會建立一個小型函式庫,模擬執行調查。 資料有兩種明顯的模式,可空參考型別可以區分:
-
調查問題必須始終存在。 問題清單和每個問題的文本永遠不會是
null。 - 問題的 回答 可能會缺失。 受訪者可以拒絕回答部分或全部問題,模型應該明確說明這一點。
你宣告這些規則時有不可空(non-nullable)和可空(nullable)的參考型別。 編譯器會在程式碼行為與設計不符時發出警告。
在本教學課程中,您會:
- 建立應用程式。
- 建立調查題目。
- 建立一份問卷。
- 測試非零要求。
- 建立反應類型。
- 新增受訪者。
- 產生一份問卷回應。
- 建立一套調查回應。
- 檢視調查結果。
三個類別模擬此調查:
-
SurveyQuestion:一個問題。 必須提供文字和問題類型。 -
SurveyRun:問題集合加上受訪者名單。 -
SurveyResponse:一位受訪者的作答內容,可能缺漏。
每種類型都使用不可空參考型別來表示所需值,並使用可空的參考型別來表示遺失值。
Prerequisites
- 最新的 .NET SDK
- Visual Studio Code 編輯器
- C# 開發套件
這個教學假設你熟悉 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;
}
建構子參數為不可空的參考型別,因此編譯器會警告呼叫者若任一參數可能為 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?");
測試非零要求
要了解編譯器如何強制執行不可空的參數,請嘗試新增以下行並重建:
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 的後援值。 方法的回傳類型是不可空 string的,因此呼叫者不需要空檢查。
在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 ,儘管該回傳類型是不可空的。
最後,請在 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),都不需要空檢查。 這些型別宣告這些值為不可空,因此編譯器在迴圈中將這些值視為 非空 值。
執行應用程式:
dotnet run
每次執行的輸出都不相同,因為受訪者是隨機產生的,但每一行不是記錄參與者的回答,就是註明他們拒絕作答。
結論
完成的範例位於 dotnet/samples 倉庫的 csharp/NullableIntroducing 資料夾中。 嘗試在可空除(nullable)和不可空(non-nullable)之間切換型別。 移除在設計上允許缺失值的地方的一個 ?,會產生編譯器警告,指出每一個缺失值會有影響的地方。