教學:用可空與不可空的參考類型來表達你的設計意圖

Tip

剛開始接觸可空的參考類型嗎? 先讀 《Nullable referencetypes 》。 這個教學假設你了解不可空參考型別和可空參考型別的差異,以及編譯器如何追蹤空狀態。

來自另一種語言? 如果你用過 Kotlin 的可空型別、TypeScript strictNullChecks或 Swift 的選用值,概念模型會直接映射。 這裡的練習是關於 表達設計意圖,而不是學習語法。

在這個教學中,你會建立一個小型函式庫,模擬執行調查。 資料有兩種明顯的模式,可空參考型別可以區分:

  • 調查問題必須始終存在。 問題清單和每個問題的文本永遠不會是 null
  • 問題的 回答 可能會缺失。 受訪者可以拒絕回答部分或全部問題,模型應該明確說明這一點。

你宣告這些規則時有不可空(non-nullable)和可空(nullable)的參考型別。 編譯器會在程式碼行為與設計不符時發出警告。

在本教學課程中,您會:

  • 建立應用程式。
  • 建立調查題目。
  • 建立一份問卷。
  • 測試非零要求。
  • 建立反應類型。
  • 新增受訪者。
  • 產生一份問卷回應。
  • 建立一套調查回應。
  • 檢視調查結果。

三個類別模擬此調查:

  • SurveyQuestion:一個問題。 必須提供文字和問題類型。
  • SurveyRun:問題集合加上受訪者名單。
  • SurveyResponse:一位受訪者的作答內容,可能缺漏。

每種類型都使用不可空參考型別來表示所需值,並使用可空的參考型別來表示遺失值。

Prerequisites

這個教學假設你熟悉 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 內,編譯器會追蹤 surveyResponsesnew 運算式之後會立即為 非 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 的後援值。 方法的回傳類型是不可空 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");
    }
}

請注意,對於 participantsurveyRun.QuestionssurveyRun.GetQuestion(i),都不需要空檢查。 這些型別宣告這些值為不可空,因此編譯器在迴圈中將這些值視為 非空 值。

執行應用程式:

dotnet run

每次執行的輸出都不相同,因為受訪者是隨機產生的,但每一行不是記錄參與者的回答,就是註明他們拒絕作答。

結論

完成的範例位於 dotnet/samples 倉庫的 csharp/NullableIntroducing 資料夾中。 嘗試在可空除(nullable)和不可空(non-nullable)之間切換型別。 移除在設計上允許缺失值的地方的一個 ?,會產生編譯器警告,指出每一個缺失值會有影響的地方。