Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 10 Min.

Dreimal null bleibt null

Der Umgang mit null hat sich seit der C#-Version 8 deutlich verändert. Wie nutzt man die neuen – und teils auch nicht mehr so neuen – Möglichkeiten am besten?
Tony Hoare hat das Konzept von null 1965 in der Sprache ALGOL entwickelt. Im Jahr 2009 bezeichnete er dies als seinen „Billion Dollar Mistake“ [1]. Nach ALGOL haben viele Sprachen das Konzept übernommen, so auch C#. Inzwischen ist klar, dass dies nicht die allerbeste Idee war. Also müssen wir nun mehr oder weniger mühsam einen anderen Umgang mit null finden, denn so einfach werden wir das Konzept auch nicht wieder los. Doch bevor wir dazu kommen, brauchen wir einige Grundlagen.

Value Type versus Reference Type

In C# wird grundsätzlich zwischen Value Types und Reference Types unterschieden. Value Types wie beispielsweise int nehmen direkt einen Wert im Speicher auf. Bei Reference Types dagegen enthält die Variable eine Speicheradresse, an der dann der eigentliche Wert steht.

Value Type: int i = 100; 
Die Variable i enthält den Wert 100.

Reference Type: string s = "Hallo"; 
Die Variable s zeigt auf eine Speicheradresse. An dieser ist der String Hallo abgelegt.Der wichtigste Unterschied ergibt sich, wenn Variablen als Parameter an Methoden übergeben werden. Etwas komplizierter wird es, weil Parameter in C# explizit mit out oder ref markiert werden müssen, um sie mittels Call by Reference zu übergeben. Steht kein ref oder out an der Parameterdeklaration, wird mittels Call by Value übergeben. Da wir bei den Typen zwischen Value Type und Reference Type unterscheiden, ergeben sich daraus vier Kombinationsmöglichkeiten:
  • Value Type / Call by Value
  • Value Type / Call by Reference
  • Reference Type / Call by Value
  • Reference Type / Call by Reference
Je nach Kombination ergibt sich daraus eine Konsequenz für den Aufrufer. Nehmen wir zunächst die zwei einfachen ­Fälle:
  • Value Type / Call by Value (etwa ein int-Parameter)
  • Reference Type / Call by Value (etwa ein string-Parameter)
In beiden Fällen ist es so, dass eine Änderung des übergebenen Parameters innerhalb der Methode keinen Einfluss auf die Variable des Aufrufers hat:

[Test]
public void Value_type_call_by_value() {
  var i = 100;
  
  void ChangeMeIfYouCan(int x) {
    x = 200;
  }

  ChangeMeIfYouCan(i);
  
  Assert.That(i, Is.EqualTo(100));
}

[Test]
public void Reference_type_call_by_value() {
  var s = "x";
  
  void ChangeMeIfYouCan(string x) {
    x = "Hallo";
  }

  ChangeMeIfYouCan(s);
  
  Assert.That(s, Is.EqualTo("x"));
} 
Bei der Übergabe eines Parameters mittels Call by Value, das ist der Standard in C#, kann der Wert in der aufgerufenen Methode geändert werden, ohne dass dies beim Aufrufer eine Auswirkung hat. Das liegt daran, dass der Inhalt der Variablen vor dem Aufruf auf den Stack kopiert wird. Somit liegt innerhalb der aufgerufenen Methode eine Kopie vor, die natürlich beliebig geändert werden kann.Übrigens lässt sich lange diskutieren, ob es sinnvoll ist, den Wert eines Parameters innerhalb der Methode zu ändern. Ich bin da pragmatisch und gestatte mir das bei überschaubar kurzen Methoden. Und da alle Methoden überschaubar kurz sein sollen, habe ich kein grundsätzliches Problem mit dem Ändern von Parameterwerten. In Legacy-Code beziehungsweise allgemein bei eher undurchsichtigem Code würde ich davon abraten, um nicht den Überblick zu verlieren.Bevor wir zur null-Problematik kommen, betrachten wir noch die beiden anderen Fälle beim Umgang mit Parametern:
  • Value Type / Call by Reference (etwa ein ref int-Parameter)
  • Reference Type / Call by Reference (etwa ein ref string-
    Parameter)

[Test]
public void Value_type_call_by_ref() {
  var i = 100;

  void ChangeMeIfYouCan(ref int x) { x = 200; }

  ChangeMeIfYouCan(ref i);
  
  Assert.That(i, Is.EqualTo(200));
}

[Test]
public void Reference_type_call_by_ref() {
  var s = "x";
  
  void ChangeMeIfYouCan(ref string x) {
    x = "Hallo";
  }

  ChangeMeIfYouCan(ref s);
  
  Assert.That(s, Is.EqualTo("Hallo"));
} 
Wie nicht anders zu erwarten, kann sich bei Call by Reference für den Aufrufer der Wert ändern. Aus diesem Grund hat das C#-Team seinerzeit entschieden, Call by Reference nur durch ein zusätzliches Schlüsselwort zu ermöglichen. Die beiden Varianten out und ref müssen sowohl bei der Deklaration als auch bei jeder Verwendung angegeben werden. So wird beim Lesen eines Aufrufs sofort klar, dass hier ein Call by Reference vorliegt. Dies ist eine wichtige Information, weil dadurch Werte, die an Methoden übergeben werden, geändert werden könnten.

Sonderfall Properties

Einen speziellen Fall gibt es noch zu berücksichtigen: die Änderung von Properties eines übergebenen Objekts:

[Test]
public void Class_type_change_property() {
  var a = new Adresse { Strasse = "x" };

  void ChangeMeIfYouCan(Adresse x) {
    x.Strasse = "Straße";
  }

  ChangeMeIfYouCan(a);
  
  Assert.That(a.Strasse, Is.EqualTo("Straße"));
}

[Test]
public void Struct_type_change_property() {
  var n = new Name { Vorname = "x" };

  void ChangeMeIfYouCan(Name x) {
    x.Vorname = "Paul";
  }

  ChangeMeIfYouCan(n);
  
  Assert.That(n.Vorname, Is.EqualTo("x"));
}
public class Adresse
{
  public string Strasse { get; set; }
}

public struct Name
{
  public string Vorname { get; set; }
} 
Hier ist es beim Reference Type (class) so, dass die Änderung an einer Property sich auf den Aufrufer auswirkt. Das liegt daran, dass die Methode im obigen Beispiel eine Referenz auf das Objekt a erhält. Durch das verwendete Call by Value kann zwar a selbst verändert werden, ohne dass dies den Aufrufer betrifft. Wird aber eine Eigenschaft von a geändert, betrifft dies den Aufrufer sehr wohl! Das liegt daran, dass a auf eine Speicherstelle zeigt. Der Zeiger selbst kann lokal geändert werden, ohne Auswirkung auf den Aufrufer. Wird jedoch eine Eigenschaft des Objekts geändert, führt dies zu einer Änderung im Speicher, auf den der Parameter a verweist.Das Risiko, dass eine Methode Änderungen vornimmt, wo ich es als Aufrufer nicht erwarte, liegt auch bei Datenstrukturen wie List<T> vor. Übergibt man eine Liste an eine Methode, kann diese den Inhalt der Liste modifizieren. Manchmal ist das genau das, was man erwartet, manchmal ist es ­eine Überraschung, nach der man lange im Debugger sucht.Will man diesen Effekt verhindern, kann man zumindest bei Objekten mit struct statt mit class arbeiten. Da dies zu ­einem Value Type führt, wird der gesamte Inhalt des struct auf den Stack kopiert und Änderungen an Properties bleiben lokal innerhalb der Methode. Im Codebeispiel trifft das auf die Datenstruktur Name zu. Sie ist als struct deklariert; somit lassen sich Properties lokal in der Methode ändern, ohne Auswirkungen außerhalb. Für Listen oder Ähnliches steht dieser Mechanismus nicht zur Verfügung, da diese Datenstrukturen im Framework als class deklariert sind. Es wäre auch wenig sinnvoll, solche Datenstrukturen als struct anzulegen, weil sie dann über den Stack in die Methode kopiert würden.

Zwischenfazit

In der Regel wird man als C#-Entwickler mit den möglichen Kombinationen aus Typ und Call-Mechanismus selten Pro­bleme haben. Wichtig festzuhalten: C# unterstützt Call by Reference und mildert die damit verbundene Gefahr durch die Schlüsselwörter out und ref deutlich ab. In Java hat man sich ganz gegen Call by Reference entschieden. Eine zu strenge Entscheidung, wie ich meine.

Keine Referenz – null

Nachdem nun geklärt ist, dass wir zwischen Value Type und Reference Type unterscheiden müssen, stellt sich die Frage, wie es zur „Erfindung“ von null kam.Jede Variable wird von der Laufzeitumgebung mit einem Standardwert initialisiert. Technisch wird dazu der Speicherbereich vollständig mit Bits aufgefüllt, die den Wert 0 haben. Bei einem int ist der Standardwert daher 0, bei bool ist es ­false, bei einem Reference Type ist der Standardwert null. Dieser Standardwert für Reference Types drückt aus, dass die Referenz auf keine Speicheradresse verweist.Und hier beginnt der Ärger: Greift man im Programmfluss versehentlich auf eine mit null initialisierte Variable zu, löst die Laufzeitumgebung eine NullReferenceException aus. Bei Value Types passiert das nicht. Da keine Verweise auf Spei­cherbereiche im Spiel sind, kann auf Value-Type-Variablen immer zugegriffen werden. Sie enthalten im Zweifel den Default-Wert. Reference-Type-Variablen können dagegen auf „nichts“ verweisen, weil dies der Default-Wert ist.Einerseits ist dies ein großer Schritt nach vorne, da bei ­einer Managed-Plattform wie .NET die Kombination aus Compiler und Laufzeitumgebung verhindert, dass Referenzen auf ungültige Speicherbereiche verweisen. Wenn ein Verweis nicht null ist, dann ist er gültig. Bei Unmanaged-Code ist das nicht der Fall. In C++ kann schnell eine Referenz erzeugt werden, die ungültig ist, beispielsweise indem in einem Array die Indexgrenzen nicht berücksichtigt werden.

Null loswerden

Mindestens aus Gründen der Kompatibilität kann null in C# nicht gänzlich abgeschafft werden. Dann würden große Teile des existierenden C#-Codes nicht mehr kompilieren. Doch im Lauf der Jahre wurde C# ständig erweitert, sodass wir null nun fast loswerden können. Der wichtigste Schritt fand in der C#-Version 8 statt: Seit C# 8 können wir bei Reference Types zwischen nullable und non-nullable unterscheiden. Mit C# 9 und 10 wurden die Analysen nochmals verbessert.Um die neuen Features nutzen zu können, muss Nullable auf Projekt- oder Dateiebene aktiviert sein: In einer .csproj-Datei geschieht das mit:

&lt;Nullable&gt;enable&lt;/Nullable&gt; 
In einer C#-Datei aktivieren wir es mit:

#nullable enable 
Durch Aktivierung dieses Features wird die Zuweisung von null an eine Variable eines Reference Type nur dann kommentarlos erlaubt, wenn die Variable als nullable deklariert wurde. Im anderen Fall erzeugt der Compiler eine Warnung:

string s = null;
// Warning CS8600 : Das NULL-Literal oder ein möglicher // NULL-Wert wird in einen Non-Nullable-Typ konvertiert. 
Mit aktiviertem Nullable-Feature sind alle Reference Types non-nullable. Der Compiler zeigt im obigen Beispiel die Warnung, weil die Variable s vom Typ non-nullable string ist.Die Warnung kann auf zwei mögliche Arten eliminiert werden. Entweder der Typ der Variablen wird geändert, um anzuzeigen, dass null erlaubt ist, oder die Intention der Zuweisung wird klar ausgedrückt:

string? s = null;   // s ist nun ein nullable string
string s = null!;   // Warnung einmalig deaktivieren 
Das Fragezeichen hinter einem Reference Type definiert bei aktiviertem Nullable-Feature, dass die Variable null sein kann. Es ist also jetzt ein Nullable Reference Type. Damit ist nach Aktivierung des Nullable-Features quasi alles wie früher: Man kann munter mit null arbeiten. Insofern wird bereits klar: Offensichtlich sollte man mit Nullable Types – dem Fragezeichen hinter einem Reference Type – sparsam umgehen.Wird bei einer Zuweisung das Ausrufezeichen verwendet, wird damit eine mögliche null-Warnung deaktiviert. Ich drücke also klar meine Intention aus: Hier will ich null zuweisen und bin mir des Risikos bewusst.Das Ausrufezeichen kann auch beim Dereferenzieren verwendet werden, um eine Warnung zu deaktivieren:

string? s = null;
int i = s!.Length; 
Ohne das Ausrufezeichen würde der Compiler anmerken, dass beim Zugriff auf s potenziell auf null zugegriffen wird. Das liegt daran, dass der Typ von s als nullable string deklariert ist. Definiert man s dagegen als non-nullable, verschwindet die Warnung ebenfalls. Stattdessen wird dann vermutlich an anderen Stellen eine Warnung erscheinen, nämlich überall dort, wo eine mögliche null-Zuweisung stattfinden könnte.Übrigens verhindert das Ausrufezeichen keine NullReferenceException! Wird im obigen Beispiel der Ausdruck s!.Length ausgewertet, während s den Wert null hat, dann … bumsdi.Auch hier sollte klar werden: Den Code mit Ausrufezeichen zu spicken schaltet zwar die Warnungen ab, räumt aber nicht die Laufzeitprobleme aus dem Weg. Nullable Reference ­Types und Ausrufezeichen sollten aus diesem Grund sehr sparsam verwendet werden.

Null Coalescing

Um den C#-Code leichter lesbar zu machen, wurde der Null-Coalescing-Operator ?? eingeführt.

string? name = null;

var s = name ?? "";
Assert.That(s, Is.EqualTo(""));

name = "Stefan";
s = name ?? "";
Assert.That(s, Is.EqualTo("Stefan")); 
Das doppelte Fragezeichen steht als Abkürzung für ein if-Statement:

s = name ?? ""; 
entspricht

if (name != null)
  s = name;
else
  s = ""; 
Man kann auch Zuweisungen abkürzen:

s ??= t; 
ist äquivalent zu

s = s ?? t; 
Dies bedeutet: s wird zu t, sofern t nicht null ist.Auf diese Weise kann man sehr eleganten Code schreiben, der den Übergang von nullable zu non-nullable erleichtert.

Was tun?

Nun stellt sich die Frage, wie wir im Alltag mit den neuen Möglichkeiten umgehen sollten.Eines sei gleich vorweggenommen: „Weiter wie bisher“ ist keine gute Idee. Bestehende Projekte werden nicht automatisch geändert, da dies typischerweise zu vielen Warnungen führen würde.Doch meine erste Empfehlung lautet: Aktivieren Sie das Nullable-Feature schrittweise in Ihren Projekten. Wer über Jahre hinweg keine Zeit investiert, die neuen Features zu aktivieren, steht irgendwann vor einer Codebasis, die nur mit sehr viel Aufwand auf den aktuellen Stand gebracht werden kann. Immer wieder mit den neuen .NET-Framework-Ver­sionen Schritt zu halten ist genauso wichtig.Man kann das Feature projektweise aktivieren und sich dann die Warnungen anschauen. Manche Codebereiche lassen sich leicht anpassen, bei anderen ist mehr Aufwand erforderlich. Aber Achtung! Die Lösung ist nicht, das Feature zu aktivieren und dann hinter jeden Typ ein Fragezeichen zu schreiben.Die eigentliche Frage lautet: Warum machen wir einen Unterschied in der Verwendung von Reference Types und Value Types? Ich meine nun nicht die technischen Unterschiede. Die habe ich oben erläutert. Es geht mir um die Frage, wa­rum wir bei Value Types mit einem Standardwert arbeiten, bei Reference Types aber nicht:

int i    // i ist 0
string s;    // s ist null. Warum nicht ""? 
Bei Value Types akzeptieren wir, dass ein bool false ist und ein int 0. Warum setzen wir also nicht Reference Types ebenfalls auf einen gültigen Standardwert?

string s = "";
Customer c = new(); 
Durch die Kurznotation des Konstruktoraufrufs hält sich das Rauschen im Code in Grenzen. Nun sind aber s und c mit Standardwerten initialisiert und wir müssen weniger Angst vor null haben. Das setzt sich auf Properties fort:

public class Customer
{
  public string Name { get; set; } = "";
} 
Nun kann die Name-Property sofort ausgegeben werden, ohne dass es zu einer NullReferenceException kommt. Aber ein leerer Name ist doch kein gültiger Kundenname? Stimmt. ­Genauso wenig ist 0 aus Domänensicht der beste Default für alle int-Properties. In diesem Schritt geht es darum, null drastisch zu reduzieren. An den automatisierten Tests zur Prüfung der Domänenlogik geht ohnehin kein Weg vorbei. In diesen testen wir, ob das Kundenobjekt einen Namen erhalten hat. Falls nicht, ist er leer, aber immerhin nicht null.

Fazit

Als Entwickler und Entwicklerin braucht man durchaus einige Zeit, um sich mit den neuen syntaktischen Möglichkeiten rund um null auseinanderzusetzen. Das Nullable-Feature immer wieder zu deaktivieren bedeutet, auf altem Stand weiterzuarbeiten. Wenn Sie noch Projekte haben, die mit .NET Framework 4.7 oder Ähnlichem arbeiten, wissen Sie, wie groß der Sprung plötzlich geworden ist. Da hilft nur, regelmäßig dranzubleiben.So ist es auch mit null. Aktivieren Sie die Nullable-Analysen und machen Sie sich ein Bild von den erforderlichen Änderungen. Wenn Sie unsicher sind, wie Sie die neuen Möglichkeiten im Produktionscode am besten einsetzen, erstellen Sie kleine Übungsprojekte, an denen Sie die Auswirkungen studieren können. Das Leben ist ein lebenslanges Lernen.Die wichtigsten Regeln: Nullable aktivieren und dann sehr sparsam mit Nullable Reference Types umgehen (nur in Ausnahmen ein Fragezeichen hinter den Typ). Das Ausrufezeichen ebenfalls nur sparsam verwenden und besser auf Null Coalescing oder explizite null-Prüfung mittels if setzen.[1] Tony Hoare, Null References: The Billion Dollar Mistake, www.dotnetpro.de/SL2402Null1

Neueste Beiträge

DWX hakt nach: Wie stellt man Daten besonders lesbar dar?
Dass das Design von Websites maßgeblich für die Lesbarkeit der Inhalte verantwortlich ist, ist klar. Das gleiche gilt aber auch für die Aufbereitung von Daten für Berichte. Worauf besonders zu achten ist, erklären Dr. Ina Humpert und Dr. Julia Norget.
3 Minuten
27. Jun 2025
DWX hakt nach: Wie gestaltet man intuitive User Experiences?
DWX hakt nach: Wie gestaltet man intuitive User Experiences? Intuitive Bedienbarkeit klingt gut – doch wie gelingt sie in der Praxis? UX-Expertin Vicky Pirker verrät auf der Developer Week, worauf es wirklich ankommt. Hier gibt sie vorab einen Einblick in ihre Session.
4 Minuten
27. Jun 2025
„Sieh die KI als Juniorentwickler“
CTO Christian Weyer fühlt sich jung wie schon lange nicht mehr. Woran das liegt und warum er keine Angst um seinen Job hat, erzählt er im dotnetpro-Interview.
15 Minuten
27. Jun 2025
Miscellaneous

Das könnte Dich auch interessieren

UIs für Linux - Bedienoberflächen entwickeln mithilfe von C#, .NET und Avalonia
Es gibt viele UI-Frameworks für .NET, doch nur sehr wenige davon unterstützen Linux. Avalonia schafft als etabliertes Open-Source-Projekt Abhilfe.
16 Minuten
16. Jun 2025
Mythos Motivation - Teamentwicklung
Entwickler bringen Arbeitsfreude und Engagement meist schon von Haus aus mit. Diesen inneren Antrieb zu erhalten sollte für Führungskräfte im Fokus stehen.
13 Minuten
19. Jan 2017
Evolutionäres Prototyping von Business-Apps - Low Code/No Code und KI mit Power Apps
Microsoft baut Power Apps zunehmend mit Features aus, um die Low-Code-/No-Code-Welt mit der KI und der professionellen Programmierung zu verbinden.
19 Minuten
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige