教學課程:使用可為 Null 與不可為 Null 的參考類型更清楚地表達您的設計意圖

可為 Null 的參考型別,可利用可為 Null 的參考型別補充實值型別的相同方式來補充參考型別。 您可以藉由將 ? 附加至類型,來將變數宣告為可為 Null 的參考類型。 例如,string? 代表可為 Null 的 string。 您可以使用這些新類型更清楚地表達設計意圖:部分變數「永遠都必須有值」,而其他變數「可能會遺漏值」

在本教學課程中,您將了解如何:

  • 在設計中併入可為 Null 與不可為 Null 的參考類型
  • 在整個程式碼中啟用可為 Null 的參考類型檢查。
  • 撰寫程式碼,以使編譯器強制執行這些設計決策。
  • 在您自己的設計中使用可為 Null 的參考功能

必要條件

您將需要設定電腦,以執行 .NET (包括 C# 編譯器)。 C# 編譯器適用于 Visual Studio 2022.NET SDK

本教學課程假設您已熟悉 C# 和 .NET,包括 Visual Studio 或 .NET CLI。

在設計中併入可為 Null 的參考類型

在本教學課程中,您會建置程式庫來將問卷執行模型化。 程式碼會使用可為 Null 的參考類型和不可為 Null 的參考類型來代表真實世界的概念。 問卷問題絕對不能是 Null。 受訪者可能不想回答問題。 在此情況下,回應可能是 null

您將為此範例撰寫的程式碼會表達該意圖,而編譯器會強制執行該意圖。

建立應用程式並啟用可為 Null 的參考類型

在 Visual Studio 中或從命令列中使用 dotnet new console 來建立新的主控台應用程式。 為應用程式 NullableIntroduction 命名。 建立應用程式之後,您必須指定在已啟用可為 Null 的註釋內容中編譯整個專案。 開啟 .csproj 檔案,並將 Nullable 元素新增至 PropertyGroup 元素。 將值設為 enable。 您必須在早於 C# 11 專案中選擇參與可為 Null 的參考型別功能。 這是因為一旦開啟此功能之後,現有的參考變數宣告就會變成不可為 Null 的參考類型。 儘管該決策將可協助您找出現有程式碼可能不具適當 Null 檢查的問題,但它可能不會正確地反映您的原始設計意圖:

<Nullable>enable</Nullable>

在 .NET 6 之前,新專案不會包含 Nullable 元素。 從 .NET 6 開始,新專案會在專案檔中包含 <Nullable>enable</Nullable> 元素。

設計適用於應用程式的類型

此問卷應用程式需要建立一些類別:

  • 用來將問題清單模型化的類別。
  • 用來將已針對問卷連絡之人員清單模型化的類別。
  • 用來將填寫問卷的人員所提供的答案模型化的類別。

這些類型將使用可為 Null 和不可為 Null 的參考類型,來表達哪些成員是必要的,而哪些成員是選擇性的。 可為 Null 的參考類型會清楚地傳達該設計意圖:

  • 屬於問卷一部分的問題絕對不能是 Null:詢問空白的問題並無任何意義。
  • 受訪者絕對不能是 Null。 您會想要追蹤連絡過的人員,甚至是拒絕參與的受訪者。
  • 對於問題的任何回應可能會是 Null。 受訪者可以拒絕回答部分或所有問題。

如果您使用了 C# 進行程式設計,則可能已經習慣允許 null 值的參考型別,而您可能錯過了其他機會來宣告不可為 Null 的執行個體:

  • 問題的集合不應為 Null。
  • 受訪者的集合不應為 Null。

當您撰寫程式碼時,您會看到將不可為 Null 的參考型別作為參考的預設值,可避免可能導致 NullReferenceException 的常見錯誤。 本教學課程的單元之一是您做了哪些變數可以或不可以是 null 的決策。 此語言不提供語法來表達那些決策。 但現在已提供。

您要建置的應用程式將會執行下列步驟:

  1. 建立問卷並在其中新增問題。
  2. 為問卷建立一組虛擬隨機的受訪者。
  3. 連絡受訪者,直到已完成的問卷大小達到目標數目為止。
  4. 寫出有關問卷回應的重要統計資料。

使用可為 Null 與不可為 Null 的參考型別建置問卷

您將撰寫的第一個程式碼會建立問卷。 您會撰寫類別來將問卷問題和問卷執行模型化。 您的問卷具有三種類型的問題,其會依答案的格式來區別:是/否的答案、數字答案,以及文字答案。 建立 public SurveyQuestion 類別:

namespace NullableIntroduction
{
    public class SurveyQuestion
    {
    }
}

針對已啟用可為 Null 註釋內容中的程式碼,編譯器會將每個參考型別變數宣告解譯為不可為 Null 參考型別。 您可以藉由新增問題文字的屬性和問題的類型來查看第一個警告,如下列程式碼所示:

namespace NullableIntroduction
{
    public enum QuestionType
    {
        YesNo,
        Number,
        Text
    }

    public class SurveyQuestion
    {
        public string QuestionText { get; }
        public QuestionType TypeOfQuestion { get; }
    }
}

由於您尚未將 QuestionText 初始化,因此,編譯器會發出警告,表示尚未將不可為 Null 的屬性初始化。 您的設計要求問題文字不可為 Null,因此,您也會新增建構函式來將它和 QuestionType 值初始化。 完成的類別定義看起來類似下列程式碼:

namespace NullableIntroduction;

public enum QuestionType
{
    YesNo,
    Number,
    Text
}

public class SurveyQuestion
{
    public string QuestionText { get; }
    public QuestionType TypeOfQuestion { get; }

    public SurveyQuestion(QuestionType typeOfQuestion, string text) =>
        (TypeOfQuestion, QuestionText) = (typeOfQuestion, text);
}

新增建構函式會移除警告。 建構函式引數也是不可為 Null 的參考類型,因次,編譯器不會發出任何警告。

接著,建立名為 SurveyRunpublic 類別。 這個類別包含 SurveyQuestion 物件和方法的清單,可在問卷中新增問題,如下列程式碼所示:

using System.Collections.Generic;

namespace NullableIntroduction
{
    public class SurveyRun
    {
        private List<SurveyQuestion> surveyQuestions = new List<SurveyQuestion>();

        public void AddQuestion(QuestionType type, string question) =>
            AddQuestion(new SurveyQuestion(type, question));
        public void AddQuestion(SurveyQuestion surveyQuestion) => surveyQuestions.Add(surveyQuestion);
    }
}

和以前一樣,您必須將清單物件初始化為非 Null 的值,否則編譯器會發出警告。 不會在 AddQuestion 的第二個多載中進行任何 Null 檢查,因為不需要:您已將該變數宣告成不可為 Null。 其值不可以是 null

在編輯器中切換至 Program.cs,並使用下列程式碼行來取代 Main 的內容:

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 的註釋內容中,因此您會在將 null 傳遞給任何預期接受不可為 Null 參考型別的方法時收到警告。 將下列這一行新增到 Main 來試用它:

surveyRun.AddQuestion(QuestionType.Text, default);

建立受訪者並取得問卷的答案

接著,撰寫程式碼來產生問卷的答案。 這項過程涉及幾項小型工作:

  1. 建置方法來產生受訪者物件。 這些物件代表系統要求您填寫問卷的人員。
  2. 建置邏輯來模擬向受訪者詢問問題,然後收集答案,或記下受訪者沒有回答。
  3. 重複執行,直到有足夠的受訪者回答問卷為止。

您需要一個類型來代表問卷回應,所以現在要新增該類別。 啟用可為 Null 的支援。 新增 Id 屬性和建構函式來將它初始化,如下列程式碼所示:

namespace NullableIntroduction
{
    public class SurveyResponse
    {
        public int Id { get; }

        public SurveyResponse(int id) => Id = id;
    }
}

接著,新增 static 方法,藉由產生隨機識別碼來建立新的參與者:

private static readonly Random randomGenerator = new Random();
public static SurveyResponse GetRandomId() => new SurveyResponse(randomGenerator.Next());

這個類別的主要責任是產生參與者對問卷問題所做的回應。 這個責任有數個步驟:

  1. 要求參與問卷。 如果該人員不同意,請傳回遺漏 (或 Null) 回應。
  2. 詢問每個問題並記錄答案。 每個答案也可能遺漏 (或 Null)。

將下列程式碼新增至您的 SurveyResponse 類別:

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

問卷答案的儲存體是 Dictionary<int, string>?,指出它可能是 Null。 您正在使用新的語言功能,來向編譯器和稍後要讀取您程式碼的任何人宣告您的設計意圖。 如果您曾經在未先檢查 null 值的情況下為 surveyResponses 取值 (Dereference),則會收到編譯器警告。 您不會在 AnswerSurvey 方法中收到警告,因為編譯器可判斷並未將 surveyResponses 變數設定為上述的非 Null 值。

針對遺漏的問題使用 null,醒目提示使用可為 Null 參考型別時的一項關鍵點:您的目標不是從程式中移除所有 null 值。 您的目標是確保您所撰寫程式碼能夠表達出設計意圖。 遺漏值是在您程式碼中進行表達的必要概念。 null 值是表達那些遺漏值的清楚方式。 嘗試移除所有 null 值只會導向定義其他方式,在不使用 null 的情況下表達那些遺漏值。

接著,您需要在 SurveyRun 類別中撰寫 PerformSurvey 方法。 在 SurveyRun 類別中新增下列程式碼:

private List<SurveyResponse>? respondents;
public void PerformSurvey(int numberOfRespondents)
{
    int respondentsConsenting = 0;
    respondents = new List<SurveyResponse>();
    while (respondentsConsenting < numberOfRespondents)
    {
        var respondent = SurveyResponse.GetRandomId();
        if (respondent.AnswerSurvey(surveyQuestions))
            respondentsConsenting++;
        respondents.Add(respondent);
    }
}

同樣地,您對於可為 Null 之 List<SurveyResponse>? 的選擇,表示回應可能是 Null。 這表示尚未將問卷提供給任何受訪者。 請注意,受訪者會持續新增,直到有足夠的受訪者同意為止。

執行問卷的最後一個步驟是在 Main 方法的結尾處新增執行問卷的呼叫:

surveyRun.PerformSurvey(50);

檢查問卷回應

最後一個步驟是顯示問卷結果。 您將在所撰寫的多個類別中新增程式碼。 此程式碼示範用來區別可為 Null 與不可為 Null 之參考類型的值。 一開始,請將下列兩個運算式主體成員新增至 SurveyResponse 類別:

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

由於 surveyResponses 是不可為 Null 的參考型別,因此在為其取值之前不需要進行任何檢查。 Answer 方法會傳回不可為 Null 的字串,因此我們必須使用 Null 聯合運算子來涵蓋遺漏答案的案例。

接著,將這三個運算式主體成員新增到 SurveyRun 類別:

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

AllParticipants 成員必須將下列因素納入考量:respondents 變數可能是 Null,但傳回的值不可為 Null。 如果您藉由移除 ?? 及其後的空序列來變更該運算式,則編譯器會警告您方法可能傳回 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");
    }
}

您不需要在這個程式碼中進行任何 null 檢查,由於您已設計了基礎介面,因此它們全部都會傳回不可為 Null 的參考類型。

取得程式碼

您可以從 csharp/NullableIntroduction 資料夾的範例存放庫中取得已完成教學課程的程式碼。

藉由在可為 Null 與不可為 Null 的參考類型之間變更類型宣告來進行實驗。 請參閱如何產生不同的警告以確保您不會意外地為 null 取值。

下一步

了解如何在使用 Entity Framework 時使用可為 Null 的參考型別: