Programmieren ohne null

02 Juni 2016
Alexander Baier

Nachteile von Null

Alle möglichen Werte eines Referenztyps sind die Objekte, die durch den zugehörigen Konstruktor erstellt werden können. Diese Aussage ist richtig, sofern man den Wert null nicht betrachtet. Denn null ist ein gültiger Wert für jeden möglichen Referenztyp. Nimmt man als Beispiel eine Klasse User mit beliebiger Implementierung, so sind sowohl alle Instanzen dieser Klasse, als auch null mögliche Werte für eine Variable vom Typ User.


Aus dieser Erkenntnis folgt, dass man im Allgemeinen nicht sicher sein kann, ob ein von „außen“ erhaltener Wert eine Objektinstanz oder null ist. Konsequenterweise müssten deshalb alle Werte unbekannter Herkunft explizit auf null geprüft werden, bevor man diese verwendet. Dies führt wiederum zu defensiver Programmierung, sodass die ersten paar Zeilen einer jeden Methode aus null-Checks für die übergebenen Parameter bestehen. Außerdem müsste der Rückgabewert eines jeden Aufrufes ebenfalls überprüft werden. Der hierbei entstehende Boilerplate-Code lenkt dann von der eigentlichen Aufgabe der Methode ab. Das Verstehen der Methode wird also erschwert. Dieses Problem wird in folgendem Code-Beispiel besonders deutlich. Wir modellieren einen Referenztyp User, welcher einen anderen User als Freund haben kann. Die Methode GetFriendOfFriend() „berechnet“ den Freund des Freundes des gegebenen Benutzers. Sowohl der Benutzer, als auch dessen ermittelter Freundesfreund, werden als ID angegeben. Eine User-Instanz kann über die Methode GetUserById() erhalten werden. Dieser Aufruf gibt allerding null zurück, wenn die übergebene ID in der Datenbank nicht vorhanden ist.

  class User
  {
      public readonly int Id;
      public readonly int FriendId;
  }

  int? GetFriendOfFriend(int id)
  {
      User user = GetUserById(id);
      if (user != null)
      {
          User friend = GetUserById(user.FriendId);
          if (friend != null)
          {
              User friendOfFriend = GetUserById(friend.FriendId);
              if (friendOfFriend != null)
              {
                  return friendOfFriend.Id;
              }
          }
      }
      return null;
  }

Wie wir sehen, ist die eigentliche Intention des Codes durch konsequenten Einsatz von defensiver Programmierung undurchsichtiger geworden.
In der GetFriendOfFriend()-Methode ohne Überprüfung auf null ist die Intention dagegen leicht zu erkennen.

  int? GetFriendOfFriend(int id)
  {
      User user = GetUserById(id);
      User friend = GetUserById(user.FriendId);
      User friendOfFriend = GetUserById(friend.FriendId);
      return friendOfFriend.Id;
  }

Ein weiteres Problem entsteht durch die menschliche Faulheit und Vergesslichkeit. Es kann schnell passieren, dass benötigte null-Checks ausgelassen werden und somit weder zur Kompilierzeit noch zur Laufzeit null-Werte zeitnah aufgespürt werden. Hiermit meine ich eine Situation, in welcher der null-Wert erst mehrere Methodenaufrufe nach dem null-Erzeuger entdeckt wird. Dies passiert dann meistens durch eine NullReferenceException. In einer solchen Situation ist es oft schwierig den null-Erzeuger ausfindig zu machen.

Das Vorhandensein von null in jedem Referenztyp verhindert es zudem, einen Typen zu modellieren, welcher niemals null sein kann. Ein solcher „non-nullable“ Typ ist zum Beispiel hilfreich als Rückgabetyp einer Methode, die nicht fehlschlagen kann. Auf Aufruferseite müsste dann nicht auf null geprüft werden, da das Typsystem sicherstellt, dass dieser Wert nicht zurückgegeben werden kann.

Zusammenfassend führt der flächendeckende Einsatz von null also zu defensiver Programmierung und erschwert dadurch das Codeverständnis. Außerdem kann es häufig schwierig sein den Erzeuger einer NullReferenceException aufzuspüren. Zuletzt fehlt die Möglichkeit, non-nullable Typen zu benutzen, um sichere Methoden zu kennzeichnen. Im nächsten Abschnitt möchte ich einen Lösungsvorschlag vorstellen, welcher diese Probleme beseitigt.

Non-Nullable Refernztyp

In diesem Lösungsansatz wird das Typsystem benutzt, um das Fehlen eines Wertes im Allgemeinen zu modellieren. Die Idee ist das Erstellen eines Datentyps, welcher sich zu jeder Zeit in einem von zwei möglichen Zuständen befindet. Der Datentyp heißt Option und befindet sich entweder in dem Some-, oder dem None-Zustand. Ersterer repräsentiert das Vorhandensein eines Wertes und beinhaltet auch ebendiesen Wert. Letzterer stellt das Abhandensein da.

Weitere Konzepte werde ich nun anhand der Implementierung vorstellen.

  public struct Option<T>
  {
      private readonly TValue value;
      public readonly bool isNone;
      public readonly bool isSome;

      public Option(T value)
      {
          this.value = value;
          isNone = value == null;
          isSome = !isNone;
      }
  }

  public static class Option
  {
      public static Option<T> Some<T>(T value)
      {
          if (value == null)
          
              throw new ArgumentNullException("value must not be null.");
          }
          
          return new Option<T>(value);
      }

      public static Option<T> None<T>()
      {
          return new Option<T>(null);
      }
  }

Wir sehen hier zunächst die reine „Einpackfunktionalität“. Option dient also als eine Schicht, die um einen Wert vom Typ T gelegt wird. Der Konstruktor setzt die erstellte Option in den None-Zustand, wenn der übergebene Wert null ist, sonst in den Some-Zustand. Es werden außerdem noch zwei statische Methoden zum bequemen Erstellen neuer Instanzen eingefuehrt: Some und None . Benutzen kann man diesen Datentypen nun überall, wo sonst null zurückgegeben wird. Die Signatur der GetUserById()-Methode aus dem vorherigen Abschnitt würde also wie folgt aussehen:

  Option<User> GetUserById(int id)

Der Typ des Rückgabewertes verhindert nun, dass auf die User-Instanz zugegriffen wird, wenn diese nicht vorhanden ist. Momentan ist es allerdings so, dass es auch nicht möglich ist, auf eine vorhandene User-Instanz zuzugreifen. Ausschließlich das Abfragen des Zustandes über isSome und isNone ist möglich. Um im Some-Zustand den Wert zu extrahieren, erweitern wir die Option-Struktur um die Match()-Methode:

  public <T> Match<U>(Func<T, U> some, Func<U> none)
  {
        return isNone ? none() : some(value);
  }

Diese erwartet zwei Funktionen: eine für jeden der beiden möglichen Zustände. Die Funktion für den Some-Fall wird mit dem vorhandenen Wert als Parameter aufgerufen. Die None-Funktion wird hingegen ohne Parameter aufgerufen, da es in diesem Fall keinen Wert gibt, mit dem etwas gemacht werden könnte. Beide Funktionen müssen den gleichen Rückgabetyp haben. Die Match()-Funktion gibt den Wert zurück, welcher von der jeweilig aufgerufenen Funktionen zurückgegeben wird.

Nun kann auf den Wert in einer Option zugegriffen werden:

  int? GetFriendOfFriend(int id)
  {
      return GetUserById(id).Match(
          some: user => GetUserById(user.FriendId).Match(
              some: friend => GetUserById(friend.FriendId).Match(
                  some: friendOfFriend => friendOfFriend.Id,
                  none: () => null),
              none: () => null),
          none: () => null);
  }

Wie wir sehen, sind wir nun gezwungen, Match() aufzurufen, um an den von GetUserById() zurückgegebenen Benutzer zu kommen. Es ist uns also nicht möglich, versehentlich einen null-Check zu vergessen. Desweiteren ist garantiert, dass die User-Variable im Some-Fall niemals null ist. Trotz der zusätzlichen Garantien ist der Code weiterhin schwierig zu lesen, da viel Boilerplate vorhanden ist. Zusätzlich muss nun, im Gegensatz zum manuellen null-Checking, in jedem Match explizit null zurückgegeben werden.

Diese unschöne Syntax werden wir im nächsten Abschnitt beseitigen.

Funktionen für Option<T>

Um die umständliche Syntax aus dem letzten Abschnitt zu beseitigen, führen wir im Folgenden eine neue Funktion ein:

  static Option<U> Bind<T, U>(this Option<T> self, Func<T>, Option<U> f)
  {
      return self.Match(
          some: value => f(value),
          none: () => Option.None<U>());
  }

Bind() ist eine Extensionmethod auf Option und hilft uns mit Funktionen zu arbeiten, welche Option-Werte zurückgeben. Im Speziellen hilft sie uns dabei Berechnungen zu verketten, in denen jeder einzelne Schritt von dem Ergebnis des vorherigen Schritts abhängt. Dies ist zum Beispiel in der Methode GetFriendOfFriend() der Fall: Um an den Freund zu kommen, müssen wir zunächst den Benutzer selbst holen, bevor wir die FriendId und damit eine User-Instanz des Freundes bekommen. Der zweite Aufruf von GetUserById() ist also abhängig von dem ersten. Desweiteren soll der zweite Aufruf nur ausgeführt werden, wenn der erste erfolgreich war. Ansonsten soll abgebrochen und null zurückgegeben werden. Diese Eigenschaften werden von Bind() implementiert. Der Parameter self ist das Ergebnis einer vorherigen Berechnung, der Parameter f ist die nachfolgende Berechnung. Wenn self im Some-Fall ist, so wird der enthaltene Wert der Funktion f übergeben, deren Ergebnis wiederum das Ergebnis von Bind() ist. Befindet sich self hingegen im None-Fall, wird f nicht aufgerufen und stattdessen einfach None zurückgegeben.

Das folgende Listing zeigt Bind() im Einsatz:

  int? GetFriendOfFriend(int id)
  {
      return GetUserById(id).Bind(
          user => GetUserById(user.FriendId).Bind(
              friend => GetUserById(friend.FriendId).Match(
                  some: friendOfFriend => friendOfFriend.Id,
                  none: () => null)));
  }

Nun haben wir einen einzigen Match()-Aufruf am Ende übrig. Der Rest unseres Codes muss sich nicht um null-Checking kümmern. Wir rufen wiederholt Bind() auf der zurückgegeben Option auf, um an das Ergebnis der vorherigen Berechnung zu kommen. Sollte diese Berechnung fehlgeschlagen haben (sprich eine Option im None-Zustand zurückgeben), so werden nachfolgende Berechnungen nicht ausgeführt und es wird schlussendlich null zurückgegeben.

Wir können null komplett aus GetFriendOfFriend() entfernen und statt einem int? ein Option<int> zurückgeben. Dies vereinfacht den Code ein bisschen, da wir das letzte Match() durch ein Bind() ersetzen können.

  Optionint GetFriendOfFriend(int id)
  {
      return GetUserById(id).Bind(
          user => GetUserById(user.FriendId).Bind(
              friend => GetUserById(friend.FriendId).Bind(
                  friendOfFriend => Some(friendOfFriend.Id))));
  }

Hier ist zu beachten, dass der letzte Lambda-Ausdruck sein Ergebnis speziell in eine Option einpacken muss, damit wir Bind() benutzen können. Dies ist leichter ersichtlich, wenn wir die Signaturen des Lambdas und des Parameters zu Bind() vergleichen. Das Lambda, welches wir eigentlich hinschreiben möchten, sieht so aus: friendOfFriend => friendOfFriend.Id. Der Typ dieses Ausdrucks ist Func<User, int>. Der Typ des Parameters von Bind() ist wie folgt: Func<User, Option<int>>. Wie wir sehen erwartet Bind() eine Funktion, welche Option zurückgibt, was dazu führt, dass wir unser Lambda anpassen müssen.

Anders betrachtet haben wir eine Option und möchten auf dessen Inhalt eine Funktion ausführen. Da dies eine häufig auftretende Situation ist, wenn man mit Option arbeitet, wollen wir hier eine weitere Funktion definieren. Diese Funktion heißt Map() und wird wie folgt implementiert:

  static Option<U> Map<T, U>(this Option<T> self, Func<T, U> f)
  {
      return self.Match(
          some: value => Some(f(value)),
          none: () => None<U>());
  }

Hier werden per Match() die beiden Fälle betrachtet, in denen sich self befinden kann. Enthält die Option einen Wert, so wird f mit diesem als Parameter aufgerufen und der Rückgabewert wird danach durch Some wieder in eine Option eingepackt. Im None-Fall wird f nicht aufgerufen und wir geben eine leere Option zurück. Hiermit gewappnet, können wir unser Lambda nun unmodifiziert benutzen:

  Optionint GetFriendOfFriend(int id)
  {
      return GetUserById(id).Bind(
          user => GetUserById(user.FriendId).Bind(
              friend => GetUserById(friend.FriendId).Map(
                  friendOfFriend => friendOfFriend.Id)));
  }

Dies ist wertvoller, als es hier zunächst scheint. Map() ermöglicht es uns bereits definierte Funktionen, welche nichts von Option wissen, auf Werte anzuwenden, welche in Option eingepackt sind. Dazu müssen weder diese Funktionen noch Option angepasst werden. Im folgenden Beispiel haben wir eine Funktion plus5(), welche 5 auf einen gegebenen int-Wert addiert. Außerdem haben wir einen in eine Option eingepackten int-Wert. Plus5 kann nun mit der Hilfe von Map() auf diesen Wert angewendet werden:

  Func<int, int> Plus5 = (x => x + 5);
  Option<int> seven = Some(7);           // Some(7)
  Option<int> twelve = seven.Map(Plus5); // Some(12)

Mit Map() und Bind() haben wir nun zwei Funktionen, welche es uns leichter machen mit Option-Werten zu arbeiten. Was unser konkretes Beispiel GetFriendOfFriend() angeht, haben wir nun eine Funktion, welche fast ausschließlich mit der Domänenlogik beschäftigt ist. Die null-Checks sind nicht mehr sichtbar, da diese durch Bind() und Map() automatisch behandelt werden. Das extreme Einrücken ist allerdings weiterhin ein Problem.

Syntaktischer Zucker

Schaut man sich die Signaturen von Map() und Bind() an und vergleicht diese mit Select() und SelectMany(), so fällt auf, dass sich diese sehr ähnlich sehen:

  Option     <U> Map   <T,U>(this Option    <T> self, Func<T, U> f)
  IEnumerable<U> Select<T,U>(this Enumerable<T> self, Func<T, U> selector)

  Option     <U> Bind      <T,U>(this Option    <T> self, Func<T, Option     <U>> f)
  IEnumerable<U> SelectMany<T,U>(this Enumerable<T> self, Func<T, IEnumerable<U>> selector)

Tauscht man Option mit IEnumerable aus, so kann man aus Map() und Bind()Select() und SelectMany() machen. Diese Methoden erlauben es uns wiederum die Querynotation auch für Option-Werte zu benutzen. Hier ist das ursprüngliche GetFriendOfFriend()-Beispiel in Querynotation:

  int? GetFriendOfFriend(int id)
  {
      var friendOfFriendId =
          from user in GetUserById(id)
          from friend in GetUserById(user.FriendId)
          from friendOfFriend in GetUserById(friend.FriendOfFriend)
          select friendOfFriend.Id;

      return friendOfFriendId.Match(
          some: value => value,
          none: () => null);
  }

Mit Option<int> als Rückgabewert sieht das Ganze dann so aus:

  Option<int> GetFriendOfFriend(int id)
  {
      return from user in GetUserById(id)
             from friend in GetUserById(user.FriendId)
             from friendOfFriend in GetUserById(friend.FriendOfFriend)
             select friendOfFriend.Id;
  }

Zusammenfassung und Ausblick

Abschließend lässt sich festhalten, dass wir einen Weg gefunden haben, null zu umgehen. Wir können die Abwesenheit von Werten mit dem None-Fall von Option modellieren, anstatt auf null zurückgreifen zu müssen. Wenn die Option-Struktur konsequent anstelle von null verwendet wird, erhalten wir eine ehrlichere Methodensignatur. Denn ohne die Implementierung der Methode zu Rate zu ziehen, kann festgestellt werden, ob diese für jede Eingabe eine garantierte Ausgabe liefert. Zum Beispiel wird eine Funktion mit Rückgabetyp User niemals null zurückgeben, also braucht der Aufrufer sich um NullReferenceExceptions keine Sorgen machen. Sieht man hingegen Option<User>, ist sofort gewiss, dass dieser Wert möglicherweise nicht vorhanden ist. Dies ermöglicht ein schnelleres Code-Verständnis.

Wir haben null jedoch nur eliminiert, wenn Option auch konsequent eingesetzt wird. Die Sprache gibt uns leider keine Möglichkeit, diesen Einsatz zu forcieren. Es gibt allerdings einen Weg, das Benutzen von null so früh wie möglich zu einem Laufzeitfehler zu machen. https://github.com/Fody/NullGuard ist ein Plugin für die IL-Rewriting-Bibliothek Fody. Dieses Plugin fügt an den Anfang und an das Ende einer jeden Methode einen null-Check ein. Wird einer von NullGuard präparierten Methode null übergeben, oder gibt diese Methode null zurück, so wird eine Exception geworfen. Diese Checks werden nach der Kompilierung in den IL-Code eingefügt, sodass der Quellcode selbst frei von unhandlichen null-Checks bleibt. Der Einsatz dieses Plugins sollte Entwickler relativ schnell dazu bewegen, auf Option umzusteigen.

Zuletzt möchte ich noch klarstellen, dass diese Ideen keineswegs neu sind, oder von mir stammen. Alles, was ich hier vorgestellt habe, gibt es schon lange in funktionalen Sprachen wie OCaml, Haskell, F# und vielen weiteren. Dort wird Option auch manchmal Maybe genannt. Desweiteren bin ich nicht der Erste, der diese Konzepte in C# umsetzt, es gibt mehrere Artikel und Bibliotheken im Netz, welche das bereits getan haben. Eines dieser Projekte möchte ich hier herausheben, da es (zumindest meiner Recherche nach zu urteilen) das vollständigste ist: https://github.com/louthy/language-ext.