Februar 2018

Band 33, Nummer 2

Essential .NET: C# 8.0 und Nullwerte zulassende Verweistypen

Von Mark Michaelis | Februar 2018

Nullwerte zulassende Verweistypen? Wie bitte? Lassen nicht alle Verweistypen Nullwerte zu? 

Ich liebe C# und ich finde den sorgfältigen Sprachentwurf fantastisch. Nichtsdestotrotz verfügen wir aktuell und selbst nach 7 Versionen von C# immer noch nicht über eine perfekte Sprache. Damit meine ich Folgendes: Es kann zwar vernünftigerweise erwartet werden, dass C# wahrscheinlich immer neue Features hinzugefügt werden, aber leider gibt es auch einige Probleme. Und mit Problemen meine ich keine Fehler, sondern eher grundsätzliche Probleme. Vielleicht einer der größten Problembereiche (der schon seit C# 1.0 besteht) dreht sich um die Tatsache, dass Verweistypen null sein können und Verweistypen tatsächlich standardmäßig null sind. Hier sind einige der Gründe, warum Nullwerte zulassende Verweistypen nicht ideal sind:

  • Das Aufrufen eines Members für einen Nullwert löst eine System.NullReferenceException-Ausnahme aus, und jeder Aufruf, der zu einer System.NullReferenceException im Produktionscode führt, ist ein Fehler. Leider fallen wir aber bei Nullwerte zulassenden Verweistypen darauf herein, das Falsche und nicht das Richtige zu tun. Der „Reinfall“ besteht darin, einen Verweistyp aufzurufen, ohne auf null zu prüfen.
  • Es gibt eine Inkonsistenz zwischen Verweistypen und Werttypen (nach der Einführung von Nullable<T>), die darin besteht, dass bei Werttypen Nullwerte zulässig sind, wenn der Decorator „?“ (z.B. „int? number“) verwendet wird. Andernfalls lassen sie standardmäßig keine Nullwerte zu. Im Gegensatz dazu lassen Verweistypen standardmäßig Nullwerte zu. Dies ist „normal“ für diejenigen von uns, die schon seit langem in C# programmieren, aber wenn wir das ganze Konstrukt noch einmal neu aufsetzen könnten, würden wir uns wünschen, dass die Standardeinstellung für Verweistypen keine Nullwerte zulässt und das Hinzufügen eines „?“ eine explizite Möglichkeit darstellt, Nullwerte zuzulassen.
  • Es ist nicht möglich, eine statische Flussanalyse auszuführen, um alle Pfade daraufhin zu überprüfen, ob ein Wert null ist, bevor er dereferenziert wird, oder ob er es nicht ist. Nehmen Sie z.B. Aufrufe nicht verwalteten Codes, Multithreading oder Nullzuweisung/-ersetzung basierend auf Laufzeitbedingungen an. (Ganz zu schweigen davon, ob die Analyse auch die Überprüfung aller aufgerufenen Bibliotheks-APIs beinhalten würde.)
  • Es gibt keine vernünftige Syntax, um anzugeben, dass ein Verweistypwert von null für eine bestimmte Deklaration ungültig ist.
  • Es gibt keine Möglichkeit, Parameter mit Decorator-Elementen zu versehen, um null nicht zuzulassen.

Wie bereits gesagt: Trotz alledem liebe ich C# so sehr, dass ich das Verhalten von null einfach als eine Eigenart von C# akzeptiere. Mit C# 8.0 hat sich das C#-Sprachteam jedoch zum Ziel gesetzt, hier eine Verbesserung zu erzielen. Insbesondere hofft das Team, die folgenden Ziele zu erreichen:

  • Bereitstellen von Syntax für die Erwartung von Nullwerten: Entwickler sollen in die Lage versetzt werden, explizit zu identifizieren, wenn ein Verweistyp voraussichtlich Nullwerte enthalten wird, und daher keine Vorkommen kennzeichnen, bei denen null explizit zugewiesen wird.
  • Standardverweistypen so ändern, dass die Unzulässigkeit von Nullwerten erwartet wird: Ändern der Standarderwartung, dass Nullwerte für alle Verweistypen unzulässig sind, und zwar mit einem optionalen Compilerschalter, anstatt den Entwickler plötzlich mit Warnungen für vorhandenen Code zu überschütten.
  • Verringern des Vorkommens von NullReferenceExceptions: Verringern der Wahrscheinlichkeit von NullReferenceException-Ausnahmen durch Verbessern der statischen Flussanalyse, die potenzielle Vorkommen kennzeichnet, bei denen ein Wert nicht explizit auf null geprüft wurde, bevor einer der Member des Werts aufgerufen wird.
  • Ermöglichen der Unterdrückung der Warnung der statischen Flussanalyse: Unterstützen irgendeine Form der Deklaration von „vertrau‘ mir, ich bin ein Programmierer“, die es dem Entwickler erlaubt, die statische Flussanalyse des Compilers außer Kraft zu setzen und somit alle Warnungen einer möglichen NullReferenceException zu unterdrücken.

Lassen Sie uns im restlichen Artikel jedes dieser Ziele untersuchen und zeigen, wie C# 8.0 grundlegende Unterstützung für sie innerhalb der C#-Sprache implementiert.

Bereitstellen von Syntax für die Erwartung von Nullwerten

Zunächst muss eine Syntax vorhanden sein, um zu unterscheiden, wann ein Verweistyp null erwarten sollte und wann nicht. Die offensichtliche Syntax für das Zulassen von Nullwerten ist die Verwendung von ? als Deklaration für zulässige Nullwerte (sowohl für einen Werttyp als auch für einen Verweistyp). Durch die Unterstützung von Verweistypen wird dem Entwickler die Möglichkeit gegeben, sich beispielsweise wie folgt für Nullwerte zu entscheiden:

string? text = null;

Die Hinzufügung dieser Syntax erklärt, warum die kritische Verbesserung für zulässige Nullwerte unter dem scheinbar verwirrenden Namen „Nullwerte zulassende Verweistypen“ zusammengefasst wird. Es liegt nicht daran, dass es einen neuen Verweisdatentyp mit zulässigen Nullwerten gibt, sondern vielmehr daran, dass jetzt explizite optionale Unterstützung für den besagten Datentyp vorhanden ist.

Sie kennen nun die Syntax für Verweistypen mit zulässigen Nullwerten. Aber wie sieht die Syntax für Verweistypen ohne zulässige Nullwerten aus?  Dies:

string! text = "Inigo Montoya"

scheint ein gute Wahl zu sein, führt aber zur der Frage, was mit diesem einfachen Code gemeint ist:

string text = GetText();

Bleiben uns drei Deklarationen, nämlich: Verweistypen mit zulässigen Nullwerten, Verweistypen mit unzulässigen Nullwerten und Ich-weiß-es-nicht-Verweistypen? Nein, bloß nicht!

Wir möchten doch eigentlich nur diese Unterscheidung:

  • Verweistypen mit zulässigen Nullwerten: string? text = null;
  • Verweistypen mit unzulässigen Nullwerten: string text = "Inigo Montoya"

Das bedeutet natürlich, dass die Änderung der Sprache so weit gehen muss, dass für Verweistypen ohne Modifizierer standardmäßig keine Nullwerte zulässig sind.

Standardverweistypen so ändern, dass die Unzulässigkeit von Nullwerten erwartet wird

Das Ändern von Standardverweisdeklarationen (kein Modifizierer mit zulässigen Nullwerten) in unzulässige Nullwerte ist vielleicht die schwierigste aller Anforderungen zum Verringern der Nullwerteigentümlichkeit. Aktuell ist es tatsächlich so, dass „string text;“ zu einem Verweistyp namens „text“ führt, der zulässt, dass „text“ den Wert null aufweist, erwartet, dass „text“ null ist und tatsächlich häufig standardmäßig den Wert null für „text“ verwendet, z.B. für Felder oder Arrays. Wie bei Werttypen sollten jedoch auch hier Verweistypen, die null zulassen, die Ausnahme sein (und nicht der Standard). Wenn wir „text“ null zuweisen oder „text“ mit etwas anderem als null initialisiert haben, wäre es vorzuziehen, wenn der Compiler alle Dereferenzierungen der text-Variablen kennzeichnen würde (der Compiler kennzeichnet bereits die Dereferenzierung einer lokalen Variablen vor deren Initialisierung).

Leider bedeutet dies, dass die Sprache geändert und eine Warnung ausgeben werden muss, wenn Sie null zuweisen (z.B. string text = null) oder einen Verweistyp mit zulässigen Nullwerten zuweisen (z.B. string? text = null; string moreText = text;). Das erste Beispiel (string text = null) ist eine grundlegende Änderung. (Das Ausgeben einer Warnung für etwas, das zuvor keine Warnung ausgelöst hat, ist eine grundlegende Änderung.)  Um zu vermeiden, dass Entwickler mit Warnungen überschüttet werden, sobald sie anfangen, den C# 8.0-Compiler zu verwenden, wird stattdessen die Unterstützung für Nullwerte standardmäßig deaktiviert: also keine grundlegende Änderung. Um die Vorteile dieses Features nutzen zu können, müssen Sie dieses Feature daher optional aktivieren. (Beachten Sie jedoch, dass in der Preview, die verfügbar ist, während ich diesen Artikel schreibe (itl.tc/csnrtp), Nullwerte standardmäßig aktiviert sind.)

Sobald das Feature aktiviert ist, werden die Warnungen natürlich angezeigt, und Sie können eine Auswahl treffen. Wählen Sie explizit aus, ob der Verweistyp Nullwerte zulassen soll oder nicht. Wenn keine Nullwerte zugelassen werden sollen, entfernen Sie die Nullwertzuordnung und damit die Warnung. Dies kann jedoch zu einem späteren Zeitpunkt ggf. zu einer Warnung führen, weil die Variable nicht zugewiesen ist und Sie ihr einen Wert zuweisen müssen, der ungleich null ist. Wenn null explizit beabsichtigt ist (z.B. für „unbekannt“), ändern Sie alternativ den Deklarationstyp wie im folgenden Beispiel in Nullwerte zulassend:

string? text = null;

Verringern des Vorkommens von NullReferenceExceptions

Da es möglich ist, Typen als zulässig oder unzulässig für Nullwerte zu deklarieren, muss nun die statische Flussanalyse des Compilers feststellen, wann die Deklaration möglicherweise verletzt wird. Das Deklarieren eines Verweistyps als für Nullwerte zulässig oder das Vermeiden einer Nullzuweisung zu einem Typ, für den Nullwerte unzulässig sind, funktioniert, aber es können neue Warnungen oder Fehler später im Code auftreten. Wie bereits erwähnt, führen Verweistypen mit unzulässigen Nullwerten später im Code zu einem Fehler, wenn die lokale Variable nie zugewiesen wird (dies galt für lokale Variablen bereits vor C# 8.0). Im Gegensatz dazu kennzeichnet die statische Flussanalyse jeden Dereferenzierungsaufruf eines Typs mit zulässigen Nullwerten, für den sie keine vorherige Überprüfung auf null und/oder keine Zuweisung des zulässigen Nullwerts zu einem anderen Wert als null erkennen kann. Abbildung 1 zeigt einige Beispiele.

Abbildung 1: Beispiele für Ergebnisse der statischen Flussanalyse

string text1 = null;
// Warning: Cannot convert null to non-nullable reference
string? text2 = null;
string text3 = text2;
// Warning: Possible null reference assignment
Console.WriteLine( text2.Length ); 
// Warning: Possible dereference of a null reference
if(text2 != null) { Console.WriteLine( text2.Length); }
// Allowed given check for null

So oder so ist das Endergebnis eine Verringerung der potenziellen NullReferenceExceptions, indem statische Flussanalyse verwendet wird, um einen Intent mit zulässigen Nullwerten zu überprüfen.

Wie bereits erwähnt, sollte die statische Flussanalyse eine Kennzeichnung ausführen, wenn einem Typ mit unzulässigen Nullwerten potenziell null zugewiesen wird (entweder direkt oder wenn ein Typ mit zulässigen Nullwerten zugewiesen wird). Leider ist das nicht narrensicher. Wenn eine Methode z.B. deklariert, dass sie einen Verweistyp ohne zulässige Nullwerte zurückgibt (vielleicht eine Bibliothek, die noch nicht mit Modifizierern für Nullwerte aktualisiert wurde) oder einen Typ, der versehentlich null zurückgibt (vielleicht wurde eine Warnung ignoriert) oder eine nicht schwerwiegende Ausnahme auftritt und eine erwartete Zuweisung nicht ausgeführt wird, ist es immer noch möglich, dass ein Verweistyp mit unzulässigen Nullwerten letztlich einen Nullwert aufweist. Das ist unglücklich, aber die Unterstützung für Verweistypen mit zulässigen Nullwerten sollte die Wahrscheinlichkeit verringern, eine NullReferenceException auszulösen, auch wenn diese Möglichkeit nicht völlig eliminiert wird. (Dies ist analog zur Fehlbarkeit der Compilerprüfung bei der Zuweisung einer Variablen.) Ebenso wird die statische Flussanalyse nicht immer erkennen, dass der Code tatsächlich auf null prüft, bevor er einen Wert dereferenziert. Tatsächlich überprüft die Flussanalyse nur die Nullwerte innerhalb eines Methodenkörpers von lokalen Variablen und Parametern und nutzt Methoden- und Operatorsignaturen, um die Gültigkeit zu bestimmen. Sie untersucht z.B. nicht in den Körper einer Methode namens IsNullOrEmpty, um zu analysieren, ob diese Methode erfolgreich auf null prüft, sodass keine zusätzliche Überprüfung auf null erforderlich ist.

Ermöglichen der Unterdrückung der Warnung der statischen Flussanalyse

Angesichts der möglichen Fehlbarkeit der statischen Flussanalyse stellt sich die Frage, was geschieht, wenn Ihre Überprüfung auf null (vielleicht mit einem Aufruf wie object.ReferenceEquals(s, null) oder string.IsNullOrEmpty()) vom Compiler nicht erkannt wird? Wenn der Programmierer sicher ist, dass ein Wert nicht null sein wird, kann er die Dereferenzierung wie im folgenden Beispiel nach dem Operator ! ausführen (z.B. text!):

string? text;...
if(object.ReferenceEquals(text, null))
{  var type = text!.GetType()
}

Ohne das Ausrufezeichen warnt der Compiler vor einem möglichen Aufruf von null. Ebenso können Sie, wenn Sie einem Wert ohne zulässige Nullwerte einen Wert mit zulässigen Nullwerten zuweisen, den zugewiesenen Wert mit einem Ausrufezeichen versehen, um den Compiler darüber zu informieren, dass Sie (der Programmierer) es besser wissen:

string moreText = text!;

Auf diese Weise können Sie die statische Flussanalyse genau so außer Kraft setzen, wie Sie eine explizite Umwandlung verwenden können. Natürlich findet trotzdem zur Laufzeit die entsprechende Überprüfung statt.

Zusammenfassung

Mit der Einführung des Nullwertmodifizierers für Verweistypen wird kein neuer Typ eingeführt. Für Verweistypen sind immer noch Nullwerte zulässig, und das Kompilieren von string? ergibt IL, die immer noch nur System.String ist. Der Unterschied auf IL-Ebene ist die Verwendung eines Decorator-Elements für geänderte Typen mit zulässigen Nullwerten mit dem folgenden Attribut:

System.Runtime.CompilerServices.NullableAttribute

Auf diese Weise können Downstreamkompilierungen weiterhin den deklarierten Intent nutzen. Darüber hinaus können frühere Versionen von C# (sofern das Attribut verfügbar ist) immer noch auf C# 8.0-kompilierte Bibliotheken verweisen, allerdings ohne jegliche Verbesserungen in Bezug auf Nullwerte. Dies bedeutet vor allem, dass vorhandene APIs (wie z.B. die .NET API) mit Metadaten aktualisiert werden können, für die Nullwerte zulässig sind, ohne die API zu beschädigen. Darüber hinaus bedeutet dies, dass keine Unterstützung für Überladungen basierend auf dem Nullwertmodifizierer vorhanden ist.

Aus der Verbesserung der Nullwertbehandlung in C# 8.0 ergibt sich eine unglückliche Konsequenz. Der Übergang von traditionellen Deklarationen mit zulässigen Nullwerten zu unzulässigen Nullwerten führt zunächst zu einer erheblichen Anzahl von Warnungen. Das ist zwar bedauerlich, aber ich glaube, dass ein vernünftiges Gleichgewicht zwischen Irritation und Verbesserung des Codes gewahrt wurde:

  • Die Warnung, eine Nullzuordnung zu einem Typ mit unzulässigen Nullwerten zu entfernen, beseitigt möglicherweise einen Fehler, weil ein Wert nicht mehr null ist, wenn er es nicht sein sollte.
  • Alternativ dazu verbessert das Hinzufügen eines Nullwertmodifizierers Ihren Code, indem er Ihren Intent expliziter angibt.
  • Im Lauf der Zeit wird sich der Impedanzkonflikt zwischen aktualisiertem Code mit zulässigen Nullwerten und älterem Code auflösen, wodurch die früher aufgetretenen NullReferenceException-Fehler verringert werden.
  • Das Nullwertfeature ist für vorhandene Projekte standardmäßig deaktiviert, sodass Sie seinen Einsatz bis zu einem von Ihnen gewählten Zeitpunkt aufschieben können. Am Ende verfügen Sie über robusteren Code. Für Fälle, in denen Sie es besser wissen als der Compiler, können Sie den Operator ! (als Deklaration „vertrau mir, ich bin ein Programmierer“) wie eine Umwandlung verwenden.

Weitere Verbesserungen in C# 8.0

Für C# 8.0 werden drei weitere Hauptbereiche der Optimierung in Betracht gezogen:

Asynchrone Datenströme: Die Unterstützung für asynchrone Datenströme ermöglicht await-Syntax für die Iteration über eine Sammlung von Tasks (Task<bool>). Sie können z.B. den folgenden Aufruf ausführen:

foreach await (var data in asyncStream)

Der Thread blockiert keine Anweisungen nach await, sondern „fährt mit ihnen fort“, sobald die Iteration abgeschlossen ist. Und der Iterator wird das nächste Element auf Anforderung ausgeben (die Anforderung ist ein Aufruf von Task<bool> MoveNextAsync für den Iterator des aufzählbaren Datenstroms), gefolgt von einem Aufruf von T Current { get; }.

Standardschnittstellenimplementierungen: Mit C# können Sie mehrere Schnittstellen so implementieren, dass die Signaturen der einzelnen Schnittstellen vererbt werden. Weiterhin ist es möglich, eine Memberimplementierung in einer Basisklasse bereitzustellen, sodass alle abgeleiteten Klassen über eine Standardimplementierung des Members verfügen. Leider ist es nicht möglich, mehrere Schnittstellen zu implementieren und außerdem Standardimplementierungen der Schnittstelle bereitzustellen, also Mehrfachvererbung zu verwenden. Mit der Einführung von Standardschnittstellenimplementierungen überwinden wir diese Einschränkung. Unter der Annahme, dass eine vernünftige Standardimplementierung möglich ist, können Sie mit C# 8.0 eine Standardmemberimplementierung einbinden (nur Eigenschaften und Methoden), und alle Klassen, die die Schnittstelle implementieren, verfügen dann über eine Standardimplementierung. Auch wenn die Mehrfachvererbung ein Nebeneffekt sein könnte, ist die wirkliche Verbesserung, die sich daraus ergibt, die Möglichkeit, Schnittstellen um zusätzliche Member zu erweitern, ohne eine grundlegende Änderung der API einzuführen. Sie könnten z.B. IEnumerator<T> eine Count-Methode hinzufügen (obwohl deren Implementierung eine Iteration über alle Elemente der Sammlung erfordern würde), ohne alle Klassen zu zerbrechen, die die Schnittstelle implementiert haben. Beachten Sie, dass dieses Feature ein entsprechendes Frameworkrelease erfordert (etwas, das seit C# 2.0 und generischen Versionen nicht mehr benötigt wurde).

Erweiterungen für alles: Mit LINQ erfolgte die Einführung von Erweiterungsmethoden. Ich erinnere mich aus dieser Zeit an ein Abendessen mit Anders Hejlsberg, bei dem ich nach anderen Erweiterungstypen fragte, z.B. nach Eigenschaften. Anders Hejlsberg teilte mir damals mit, dass das Team nur über das nachdenke, was für die Implementierung von LINQ notwendig sei. Jetzt, 10 Jahre später, wird diese Annahme neu bewertet, und das Team erwägt die Hinzufügung von Erweiterungsmethoden nicht nur für Eigenschaften, sondern auch für Ereignisse, Operatoren und sogar möglicherweise für Konstruktoren (letzteres eröffnet einige interessante Factorymusterimplementierungen). Ein wichtiger Punkt muss besonders bei Eigenschaften beachtet werden: Die Erweiterungsmethoden werden in statischen Klassen implementiert, und es wird daher kein zusätzlicher Instanzzustand für den erweiterten Typ eingeführt. Wenn Sie einen solchen Zustand benötigen, müssen Sie ihn in einer Sammlung speichern, die von der Instanz des erweiterten Typs indiziert wird, um den zugehörigen Zustand abzurufen.


Mark Michaelis ist der Gründer von IntelliTect und arbeitet als leitender technischer Architekt und Trainer. Seit fast zwei Jahrzehnten ist er ein Microsoft MVP und seit 2007 Microsoft-Regionalleiter. Michaelis arbeitet in verschiedenen Microsoft-Softwareentwicklungs-Reviewteams mit, einschließlich C#, Microsoft Azure, SharePoint und Visual Studio ALM. Er hält häufig Vorträge bei Entwicklerkonferenzen und hat viele Bücher geschrieben, einschließlich seines letzten „Essential C# 7.0 (6th Edition)“ (itl.tc/­EssentialCSharp). Sie können ihn auf Facebook unter facebook.com/Mark.Michaelis, über seinen Blog unter IntelliTect.com/Mark, auf Twitter: @markmichaelis oder per E-Mail unter mark@IntelliTect.com erreichen.

Unser Dank gilt den folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Kevin Bost, Grant Ericson, Tom Faust, Mads Torgersen


Diesen Artikel im MSDN Magazine-Forum diskutieren