Nulovatelné odkazové typy

Tip

Začínáte s vývojem softwaru? Začněte s návody Začínáme.

Máte zkušenosti v jiném jazyce? Pokud jste pracovali s typy Kotlin s možnou hodnotou null, TypeScriptem strictNullChecksnebo nepovinnými položkami Swiftu, je model známý. Jazyk C# používá místo samostatného typu statickou analýzu a diagnostická upozornění. Zběžně si projděte Vyjádření záměru pomocí anotací a analýzu stavu null a pak přejděte na Kurz: Vyjádřete záměr návrhu pomocí odkazových typů s možnou hodnotou null a bez hodnoty null, kde se naučíte tuto funkci použít.

Odkazové typy s možnou hodnotou null jsou skupina funkcí, které minimalizují pravděpodobnost, že váš kód vyvolá System.NullReferenceException. Určíte, které proměnné mají obsahovat null a které ne, a kompilátor vás upozorní, když tyto deklarace neodpovídají tomu, jak je váš kód používá. Chování programu za běhu se nezmění. Odkazové typy s možnou hodnotou null jsou zcela funkcí kompilace.

Tři stavební bloky spolupracují:

  • Anotace proměnných (string vs. string?) udávají, které odkazy mají umožňovat null.
  • Analýza stavu null sleduje, jestli hodnota výrazu není null nebo možná null v každém bodě kódu.
  • Atributy rozhraní API popisují více nuancí kontraktů, například "tento argument může být null, ale návratová hodnota je null pouze v případě, že argument má hodnotu null."

Kompilátor tyto signály kombinuje za účelem vytvoření diagnostiky. Upozornění na proměnnou, která není nullable znamená, že proměnná může přijímat null. Upozornění na proměnnou, která může mít hodnotu null, znamenají, že ji kód může dereferencovat bez kontroly na hodnotu null. Dereference znamená použití hodnoty, na kterou proměnná odkazuje. Chcete-li například volat metodu (variable.Method()), číst vlastnost (variable.Property) nebo indexovat do ní (variable[0]). Dereference proměnné, která má hodnotu null, vyvolá za běhu programu výjimku. Buďto druh upozornění znamená, že chování kódu neodpovídá jeho deklarovaném návrhu.

Kontext s možnou hodnotou null

Projekty vytvořené z novějších šablon .NET mají v projektovém souboru nastaveno <Nullable>enable</Nullable>, takže pokyny v tomto článku platí beze změny. Pokud pracujete ve starším projektu, otevřete .csproj a zkontrolujte, jestli <PropertyGroup> obsahuje následující řádek. Pokud chybí, přidejte ho:

<Nullable>enable</Nullable>

Další informace o migraci velké aplikace najdete v článku o strategiích migrace s možnou hodnotou null pro další nastavení a direktivy.

Vyjádření záměru s poznámkami

Každá proměnná typu odkazu je ve výchozím nastavení nenulová . Přidejte ? pro deklaraci referenčního typu s možnou hodnotou null:

public static void Annotations()
{
    string required = "always set";   // non-nullable: assigning null produces a warning
    string? optional = null;          // nullable: holding null is allowed

    Console.WriteLine(required.Length);

    if (optional is not null)
    {
        Console.WriteLine(optional.Length);
    }
}

Poznámka nemění typ modulu runtime. string a string? jsou oba System.String. ? sděluje kompilátoru váš návrhový záměr. Tento záměr určuje, jaká varování kompilátor generuje:

  • Proměnná bez hodnoty null má výchozí stav nullnot-null. Kompilátor upozorní, pokud přiřadíte hodnotu, která může být null.
  • Proměnná s možnou hodnotou null má výchozí stav nullmaybe-null. Kompilátor vás upozorní, pokud proměnnou dereferujete bez první kontroly.

K zobrazení požadovaných a volitelných hodnot v systému typů použijte poznámku. Následující typ Person deklaruje FirstName a LastName jako nenulové — každý člověk musí mít obojí — a MiddleName jako hodnotu, která může být null, protože ji nemá každý:

public sealed class Person(string firstName, string lastName)
{
    public string FirstName { get; } = firstName;
    public string? MiddleName { get; init; }
    public string LastName { get; } = lastName;

    public override string ToString() => MiddleName is null
        ? $"{FirstName} {LastName}"
        : $"{FirstName} {MiddleName} {LastName}";
}

public static void DesignIntent()
{
    Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
    Console.WriteLine(p1);
    // Output: Ada King Lovelace

    Person p2 = new("Grace", "Hopper");
    Console.WriteLine(p2);
    // Output: Grace Hopper
}

Anotace určují implementaci ToString. Protože FirstName a LastName nejsou nullable, přepsání je používá přímo v interpolovaném řetězci (syntaxe $"...", která vkládá výrazy do zástupných symbolů {}) bez kontroly na hodnotu null. MiddleName může být null, takže ji přepisující metoda nejprve zkontroluje vůči null a zahrne ji pouze tehdy, je-li přítomná. Kompilátor toto rozlišení vynucuje: kód, který předá potenciálně nulovou hodnotu tam, kde se očekává nenulovatelná hodnota, vyvolá upozornění, a konstruktor, který ponechá nenulovatelný člen neinicializovaný, také vyvolá upozornění.

Analýza nulového stavu

Kompilátor sleduje stav null každého výrazu. Stav je jedna ze dvou hodnot:

  • not-null: je známo, že výraz není null.
  • možná-null: výraz může být null.

Při analýze kódu kompilátor aktualizuje stav null místní proměnné. Mění ho dvě věci: přiřazení a kontroly na hodnotu null. Po přiřazení se stav null proměnné shoduje s výrazem na pravé straně. Pokud je výraz null nebo nullable, proměnná se stane možná null. Pokud je výraz literálem, který není null, proměnná se změní na hodnotu not-null. Po kontrole hodnoty null odpovídá stav null proměnné podle toho, která větev se převezme.

public static void NullStateTracking()
{
    string? message = null;

    // Warning: dereference of a possibly null reference.
    Console.WriteLine(message.Length);

    message = "Hello, World!";

    // No warning: the compiler tracks that message is now not-null.
    Console.WriteLine(message.Length);
}

V předchozím příkladu první dereference vytvoří upozornění, protože message je možná null. Po přiřazení k literálu, který není null, kompilátor ví message , že není null, takže druhá dereference je bezpečná.

Analýza stavu null funguje napříč kontrolami if, porovnáváním se vzorem (výrazy, jako jsou is null nebo is { }, které testují tvar hodnoty) a tokem řízení, který se opakuje ve smyčce nebo se předčasně ukončí návratem:

 public sealed class Node(string name)
 {
     public string Name { get; } = name;
     public Node? Parent { get; init; }
 }

 public static void FlowAnalysis(Node start)
 {
     Node? current = start;
     while (current is not null)
     {
         // Inside the loop, the compiler knows current is not-null.
         Console.WriteLine(current.Name);

         current = current.Parent;
     }
}

Analýza nezachází do těl metod. Pokud potřebujete metodu pro komunikaci stavu null s volajícími, použijte atributy analýzy s možnou hodnotou null v podpisu.

Přepište varování pomocí !

Někdy víte víc než kompilátor. Operátor ! deklaruje, že výraz není null, i když analýza říká jinak:

public static void NullForgiving()
{
    // "ada" matches a switch arm that returns a non-null string,
    // but the return type is string? so the compiler treats the
    // result as maybe-null.
    string? maybeName = LookUpName("ada");

    // The ! tells the compiler "trust me, this isn't null." We just
    // passed "ada", which the switch maps to "Ada Lovelace".
    int length = maybeName!.Length;
    Console.WriteLine(length); // => 12
}

// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
    "ada" => "Ada Lovelace",
    _ => null,
};

Používejte ! střídmě. Každý výskyt je místo, kde vás kompilátor už nemůže chránit. Preferujte přidání kontroly null, restrukturalizaci kódu nebo přidání poznámek k příslušnému rozhraní API, aby kompilátor dosáhl správného závěru sám.

Atributy, které popisují kontrakty rozhraní API

Poznámky k parametru nebo návratovém typu nejsou vždy dostatečně výrazné. Metoda může přijmout možná null argument, ale zaručit nenulový výsledek. Testovací metoda může vrátit true pouze v případech, kdy jeho argument není null. K vyjádření těchto kontraktů použijte atributy analýzy s možnou hodnotou null :

public static bool IsPresent([NotNullWhen(true)] string? value) =>
    !string.IsNullOrEmpty(value);

public static void NullAnalysisAttributes()
{
    string? input = ReadInput();

    if (IsPresent(input))
    {
        // No null-forgiving operator needed: the attribute tells the compiler
        // input is not-null when IsPresent returns true.
        Console.WriteLine(input.Length);
    }
}

private static string? ReadInput() => "hello";

NotNullWhenAttribute informuje kompilátor, že když IsPresent vrátí true, argument není null. Uvnitř bloku if kompilátor považuje value za nenulovou hodnotu, aniž by byl vyžadován operátor null-forgiving. Od .NET 5 jsou všechna rozhraní API modulu runtime .NET anotována, takže analýza přináší výhody veškerého kódu, který je volá.

Známé nástrahy

Dva vzorce mohou způsobit, že nenulovatelná reference bude mít hodnotu null bez upozornění. Oba vzory jsou omezení statické analýzy, nikoli chyb v kódu.

Výchozí struktury

Strukturu s referenčními poli, která nemohou mít hodnotu null, můžete vytvořit pomocí default nebo new(). Tento přístup ponechává pole struktury neinicializovaná:

public struct Student
{
    public string FirstName;
    public string? MiddleName;
    public string LastName;
}

public static void DefaultStructPitfall()
{
    Student s = default;            // No warning, but FirstName and LastName are null.
    Console.WriteLine(s.FirstName?.Length ?? -1);
}

Pole za běhu obsahují null, ale kompilátor nevydá žádné varování. Pokud musíte použít strukturu, upřednostněte požadované členy, které volající musí inicializovat prostřednictvím inicializátoru objektu, nebo parametrizovaný konstruktor, který volající musí vyvolat.

Pole odkazů a struktur

Nové pole nenulovatelného odkazového typu má u všech prvků hodnotu null, dokud každému z nich nepřiřadíte hodnotu:

public static void ArrayPitfall()
{
    string[] values = new string[3];      // Elements are null at run time.
    Console.WriteLine(values[0]?.Length ?? -1);

    string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
    Console.WriteLine(initialized[0].Length);
}

Stejné nástrahy platí pro pole struktur: každý prvek začíná jako výchozí hodnota struktury, takže referenční pole, která nejsou nullable, začínají jako null.

Inicializace prvků pole jako součást vytváření pole. Výrazy kolekcí (syntaxe literálu [1, 2, 3]) a určení typu podle cíle new (zápis new(), když kompilátor dokáže odvodit typ) umožňují stručný zápis úplné inicializace.