Blogbeitrag:

Typen, die die Welt retten

Nein, mit dem Titel sind nicht die Mitarbeiter von sepp.med gemeint, auch wenn wir einiges tun können, um mit Qualität Ihren Erfolg zu sichern. In diesem Beitrag geht es buchstäblich um Typen, nämlich um die Typsysteme von Programmiersprachen, von denen viele durch mangelnde Ausdruckskraft und fatale Lücken den größten Teil der Probleme verursachen, die wir mit Software verbinden. Denken sie alleine an Null-Pointer und Exceptions. Ich möchte Sie heute mit einigen Konzepten vertraut machen, wie sie nicht nur, aber vor allem in der Funktionalen Programmierung verwendet werden.

Ein Fehler für die Ewigkeit?

Kaum jemand kennt Tony Hoare, aber fast jeder Softwareexperte kennt das, was er selbst einmal als seinen „billion dollar mistake“ bezeichnet hat: Beim Entwurf des Typsystems von Algol W hat er 1965 das Konzept der Null-Referenz eingeführt („simply because it was so easy to implement“), das erlaubte, dass eine vermeintliche Zeichenkette auch mal keine Zeichenkette war. Der Gesamtschaden dürfte vermutlich über den von ihm geschätzten Milliarden-Bereich hinaus gehen, denn seit nunmehr einem halben Jahrhundert schlagen wir uns mit den Folgen dieser fatalen Entwurfsentscheidung herum, die sich durch fast alle imperativen Sprachen zieht: Java ist neben der Geschwätzigkeit seines Codes vor allem für seine NPEs (NullPointerExceptions) berüchtigt. C++ ist da aber auch keinen Deut besser (vielleicht sind C-Programmierer einfach nur gebrannte Pointer-Arithmetik-Kinder). Und wer in JavaScript noch nicht auf ein (noch dazu oft in der Browser-Konsole verstecktes) „Undefined is not a function“ gestoßen ist, hat vermutlich nur äußerst triviale Dinge damit programmiert.

Passen Sie immer auf? Oder freuen Sie sich, wenn Ihr System mitdenkt?

Edsger Dijkstra hat eine schöne Definition von Kompetenz formuliert: „The competent programmer is fully aware of the strictly limited size of his own skull“. Ja, man kann es nicht oft genug sagen: Demut ist das A und O in unserer Profession. Selbstüberschätzung (und schlechtes Werkzeug) sind – verbunden mit eher zunehmendem Termindruck – der sicherste Weg zu schlechter Qualität. Diese Erkenntnis war Ausgangspunkt der agilen Bewegung. Und ist der Grund des Strebens nach immer besseren Entwicklungstechnologien. Der Wunsch nach modernen Sprachen und Werkzeugen hat nichts mit Coolness zu tun, wie gerne von den Verfechtern etablierter Technologien abgetan wird, sondern mit der Einsicht in die eigene Beschränktheit. (Davon abgesehen, ist es schon irgendwie cooler, sich professionell zu verhalten als andere für den eigenen Unwillen zur Veränderung zahlen zu lassen)
Natürlich kann man Fehler wie NullPointerExceptions vermeiden, genau so wie man auch in Java threadsicheren Code schreiben kann. Aber das erfordert Bewusstsein und Konzentration, die dann an anderen Stellen fehlen – und es geht erheblich zu Lasten der Produktivität. Je komplexer Software wird, umso mehr muss man unnötige mentale Belastung vermeiden. Eine Möglichkeit, mit unausrottbaren Fehlern umzugehen, habe ich Ihnen vor kurzem am Beispiel Elixir/Erlang/OTP erläutert: Die „Let it crash!“-Philosophie überwachter, message-basierter Aktorensysteme. Wer massiv verteilte dynamische Systeme mit diversen Softwareständen und Varianz entwickelt, kann sich eben nicht auf übergreifende Absicherung durch einen Compiler verlassen. Und alle Versuche, übergreifende Schemata zu etablieren, sind weitgehend an der Komplexität gescheitert, weil sie nicht dynamisch genug waren. Am Ende sind wir naxh XML, WDSL, SOA und Co. bei JSON gelandet.
Aber wer ein zusammenhängendes Stück Software (vor allem ein großes) entwickelt, ist gut beraten, das nach Möglichkeit zur Entwurfszeit so sicher wie möglich zu machen, zumal wenn kein superstabiles Aktorsystem seine Laufzeitfehler kompensiert. Und der beste Weg das zu tun, ist ein wasserdichtes, ausdrucksstarkes Typsystem mit einem Compiler, der mit möglichst hilfreichen Fehlermeldungen auf Entwurfsfehler aufmerksam machen. Ein solches möchte ich Ihnen heute vorstellen. Ich mache das am Beispiel von Elm. (Ja, ich weiß, wieder eine „Mode“-Sprache. Habe ich vor kurzem auch noch gedacht, bis ich sie erlernte. Ich werde gelegentlich in einem separaten Beitrag erläutern, warum ich Elm für das derzeit beste Entwicklungssystem für Browser-Frontends halte. Hier und heute geht es nur um seine sehr  schlanke, aber unglaublich sichere Syntax – und guter Code ist bekanntlich auf allen Tiers wichtig.)

Wenn es manchmal kein String ist, sollte es kein String sein

Bekannte Situation: Sie haben eine Datenstruktur, in der ein Element eines bestimmten Typs vorkommt, aber es gibt Situationen, wo dieses Element keinen Wert hat, z.B. eine optionale Telefonnummer oder abweichende Lieferadresse. Genau dieses Problem lag der „Erfindung“ der Null-Referenz zugrunde. Das Problem ist, dass man die Möglichkeit einer Null-Referenz an jeder Verwendungsstelle bedenken muss.  Und wenn der Typ Adresse ist, dann wird es früher oder später (eher früher) vorkommen, dass ein Programmierer diese Variable als Adresse verwendet, ohne in der Dokumentation (räusper!) zu lesen, dass sie auch null sein kann. Und die verlässlichste Dokumentation ist eh der Code. Aber dazu muss er lesbar und ausdrucksstark sein.
Was kann man gegen NPEs tun? Die Antwort steht eigentlich schon im letzten Absatz: Wenn es sich um einen optionalen Wert handelt, dann sollte der Typ das auch ausdrücken. In einigen Sprachen gibt es dafür den Option-Typ, in Elm heißt er (vielleicht noch etwas deutlicher) Maybe:

-- Elm-Typ, der ausdrückt, nur möglicherweise vom Typ a zu sein
type Maybe a = Just a | Nothing
-- Verwendung am Beispiel einer Bestellung. Die hat auf jeden Fall 
     einen Kunden, aber nur vielleicht eine Lieferadresse
type Order { customer: Customer,
             deliveryAdress: Maybe Adress,
             ... }

Davon abgesehen, dass Sie diese Syntax noch nicht kennen (das obere Konstrukt werden wir weiter unten noch kennen lernen), sollten Sie sie intuitiv verstehen: Der Wert deliveryAdresss ist nur vielleicht eine Adresse, vielleicht aber auch nichts. Optional halt.
Der Vorteil ist nicht nur, dass hier auf den ersten Blick ein optionaler Wert als solcher zu erkennen ist, sondern vor allem, dass ich durch den Compiler gezwungen werde, mich damit auseinanderzusetzen: So wie Elm mir nicht erlaubt, eine Order mit null als customer anzulegen (es gibt keinen Wert dieses Namens, nur den Nothing-Fall von Maybe), ist es mir nicht möglich, direkt mit dem vermeintlichen Wert von deliveryAdress zu arbeiten. Ich muss mich mit den (hier: zwei) Fällen auseinandersetzen. Nehmen wir an, es gibt eine Funktion, die entscheidet, wie und wohin zu versenden ist. In diesem Zusammenhang könnte die Ermittlung der Versandadresse dann so ausehen:

shipto order =
    ...
    -- Kundenadresse, wenn keine abweichende Lieferadresse angegeben wurde
    case order.shippingAdress of
       Just adress   -> adress
       Nothing       -> order.customer.adress
    ...

Ich hätte das Pattern Matching hier auch anders kodieren könnten, aber ich wollte durch diese Schreibweise möglichst die Nähe zu den beiden oben gezeigten Fällen von Maybe a zeigen: Just a oder Nothing. a ist eine sogenannte Typvariable, die hier durch den Typ Adresse ersetzt wird. (Übrigens, falls jemand skeptisch ist wegen möglicher NPEs bei order.customer.adress: Hier kann ja nichts passieren, weil customer und adress in ihren Containerstrukturen nicht als Maybe definiert sind – sonst hätte mir der Compiler gar nicht erlaubt, das so hinzuschreiben).
Das Gute bei Elm: Der Compiler zwingt mich, alle Fälle abzudecken. Dadurch wird vermieden, dass jemand denkt, „Naja, ich weiß ja, dass immer eine Lieferadresse angegeben wird“. Denn genau diese Annahme hat ja immer zu NPEs geführt. Und wenn wirklich immer eine Lieferadresse vorausgesetzt wird, sollte der Typ nicht Maybe sein…
Übrigens lässt sich mit Maybe natürlich auch das alte Problem lösen, wie man mit dem Rückgabewert einer Lookup-Funktion o.ä. umgehen soll. Z.B. ist es doch sinnvoller, wenn eine indexOf-Funktion als Rückgabewert Maybe Int angibt statt int (mit dem Kommentar „-1, wenn nicht gefunden“…).

Manchmal geht’s gut – und manchmal nicht

Mancher erinnert sich noch an die „gute“ alte Sitte von C-APIs, Fehler von Integerfunktionen mit negativen Zahlen zu kodieren, weil ein primitiver Datentyp nicht null werden kann. Später hat man dafür oft Exceptions verwendet, was ebenso Missbrauch ist, denn Exceptions waren mal für Ausnahmesituationen gedacht, nicht für den Kontrollfluss bei zu erwartenden Fehlern, z.B. durch Benutzereingaben.
Funktionen, die ihren Erfolg nicht garantieren können, sollten das im Typ ihres Rückgabewertes ausdrücken. Dazu gibt es in Elm den Typ Result (in Scala heißt er Try):

-- error und value sind die Typparamter für die jeweiligen Rückgaben
type Result error value = Err error | Ok value
-- parst eine Zeichenkette zu einer Telefonnummer oder zu einen Fehlertext
parseTelefonnummer: String -> Result String Telefonnummer
-- Verwendung, z.B. beim Einlesen einer E-Mail, einer Benutzereingabe o.ä.
...
    case parseTelefonnummer inputString of
      Ok tel         ->    -- verwende tel (hat Typ Telefonnummer)
      Err message    ->    -- verarbeite Fehler (hat Typ String)

Dieser Code sollte selbsterklärend sein: Zunächst wieder die Elm-Definition des Typs Result (diesmal mit zwei Typparametern, dem Typ der Fehlerbeschreibung und des gewünschten Ergebnisses). Dann die Typdefinition einer fachlichen Funktion zum Lesen von Telefonnummern aus Zeichenketten, die im Erfolgsfall einen Wert vom Typ Telefonnummer liefern wird, aber eben auch fehlschlagen kann. Und schließlich als letztes die Verwendung mit dem schon bekannten Muster der Fallunterscheidung.
Übrigens ist es sinnvoll, dass auch der Typ für den Fehlerfall spezifiziert werden kann. Das ermöglicht hier auch spezifische Typen mit z.B. Kompensations-/ Korrekturmöglichkeiten („Did you mean …?“) anzugeben und zu behandeln. Ein gutes Typsystem erzwingt nicht Phantasie, was gemeint ist, sondern erlaubt, es im Code auszudrücken. So waren ja auch Exception-Hierarchien gedacht, aber dort war das Grundkonzept des Werfens falsch.
Ähnliche Typen gibt es z.B. auch für die Behandlung des Erfolgs und möglicher (wahrscheinlicher) Fehler bei z.B. asynchronen Aufrufen o.ä. Deren Erörterung würde uns hier jetzt aber nicht weiterführen. Schauen wir uns lieber an, was wir als Essenz dieser Typen für unseren Code heraus ziehen können.

Lass Typen sprechen – Algebraische Datentypen (ADTs)

Verfechter der objekt-orientierten Programmierung verteidigen Konzepte wie Vererbung mit dem Begriff des Polymorphismus (Vielgestaltigkeit). Und in der Tat ist die saubere (und eben auch polymorphe) Abstraktion von Typen der ursprüngliche Treiber der OOP gewesen, bevor diese sich völlig im Sumpf der Wiederverwendung durch Implementierungsvererbung verloren hat – von anderen Missständen wie veränderbaren Zuständen etc. abgesehen. Funktionale Programmiersprachen wie Elm (auch Haskell hat ein sehr starkes Typsystem) fördern dieses Konzept (das in den imperativen Sprachen neben Unterklassen in komischen Zwitterkonstrukten wie enum aufgegangen ist, was durch die Mischung aus Klassen und „primitiven“ Typen nötig war) wieder sauber zutage. Der in der FP allgemein gebräuchliche Begriff lautet algebraische Datentypen (ADT). In Elm heißen sie Union Types, weil es ja quasi die Vereinigungsmenge spezifischer Typen zu einem gemeinsamen abstrakten Typ ist.
Interessanter als die Benennung des Konzepts ist seine Arbeitsweise. Sie haben diese in den bisherigen Codebeispielen schon kennengelernt: Ein ADT / ein Union Type wird in Elm wie eine Gleichung über eine mit Oder verbundene Typen-Menge definiert: Ein Result kann ein Fehler oder Ok sein. Wichtiges Detail ist, dass jeder spezifische Typ eine eigene Datenstruktur haben kann. Auch das versteht man unter polymorph.
Belassen sie es nicht bei den ADTs, die Ihre Programmiersprache mitliefert. Nutzen Sie die Ausdrucksstärke und die semantische Sicherheit dieses wichtigen Konzepts auch für eigene Typen. In gutem Programmcode kommen nicht viel mehr, vor allem aber nicht weniger Abstraktionen vor als in der abgebildeten Domäne.
Denken Sie z.B. an einen Cloudservice, der Angebote ohne Anmeldung, für angemeldete Gratisuser und für Premium-User bietet. Natürlich kann man das mit einer User-Klasse und vielen if-then-else-Abfragen überall im Programmcode machen. Besser, lesbarer  (und sicherer) modelliert ist es aber mit ADTs:

type PremiumOption = UnlimitedStreaming | LiveSports | HomeImprovement
type alias LoginName = String
type User = Anonymous
          | BasicCustomer LoginName
          | PremiumCustomer LoginName (List PremiumOption)
-- Mit solchen Aufrufen können User dann erzeugt werden (z.B. in Testcode)
Anonymous   -- ein Singleton!
BasicCustomer "AintGonnaPayForAnything"
PremiumCustomer "MeWithSquareEyes" [UnlimitedStreaming]
PremiumCustomer "DerSeher" [UnlimitedStreaming, LiveSports]
PremiumCustomer "Sportsfan" [LiveSports]
PremiumCustomer "Bastelhannes" [HomeImprovement]

Schöne sprechende Typen, oder? Und sie erlauben schönen sprechenden Code. Sehen Sie sich z.B. folgende Funktion an, die das UI für eine PremiumOption und einen User steuert. Sehr schön wird hier deutlich, aufgrund welcher Regeln auf welche Oberflächen „dispatched“ wird. Wie diese vom Aufrufer verwendet werden (als Komponente/Widget irgendwo eingebunden oder als separate Seite dargestellt), spielt für diese Logik keine Rolle. So geht Wiederverwendung in FP. Hier übrigens ermöglicht durch ein Konzept, das besonders Elm sehr starkt nutzt und das sehr stark mit dem Denken in Typen verknüpft ist: Functions as Data. Der Rückgabetyp Html Msg ist nämlich noch nicht das Html, sondern nur dessen strukturelle Beschreibung, die erst viel weiter „außen“ und ggf. erst nach funktionalem Einbinden / Umformen / Filtern zu Browser-Content wird. Das macht solche Funktionen übrigens ausgezeichnet testbar. Zur Erinnerung: das Ganze ist rein funktionale Code ohne lokale Variablen o.ä. und eine der vielen Möglichkeiten, Funktionen sicher und beliebig komponieren zu können – solange die Typen stimmen 😉

-- Mit einer solchen optionalen Typdefinition verdeutlicht man Intention 
     und Signatur einer Funktion in Elm (wird vom Compiler geprüft)
premiumView: PremiumOption -> User -> Html Msg
premiumView option user =
    case user of
      PremiumCustomer _name options ->
          if( option in options )
              case option of
                  UnlimitedStreaming     -> streamingOverview user
                  LiveSports ->             liveSportsChannel user
                  HomeInprovementChannel -> homeImproventChannel user
                  ...
          else
              offerUgradeToOption option user
      BasicCustomer ->
              motivateBasicCustomerToBecomePremiumOne option user
      Anonymous ->
              motivateToRegisterInOrderToGainOption option user

Sogar ein Fachmann (z.B. aus dem Marketing) sollte diese Codestrecke verstehen und verifizieren können (sicher ist sie ohnehin durch FP-Prinzipien). Besonders Funktionsnamen sollten klar die Intention ausdrücken. Guter Code sollte sich (fast) wie eine Spezifikation lesen!

Mit Abstraktionen wachsen und gedeihen Programme!

Mit sauberen Abstraktionen und ADTs können Sie sauberen, sicheren und ausdrucksstarken Code schreiben, wie oben gezeigt. Einen weiteren Vorteil bietet Ihnen ein guter Compiler wie Elm: Stellen Sie sich vor, Sie fügen später einen weiteren User-Typen oder – wahrscheinlicher – eine weitere Premiumoption hinzu. Bei imperativer Programmierung war es immer ein nicht zu vernachlässigendes Problem, an alle Programmstellen zu denken, wo sie eine weitere Fallunterscheidung einbauen müssen (ich habe mal ein Uralt-Legacy-System betreut, wo spezifische Strukturen/Regeln des zentralen Datentyps Artikelnummer an ca. 137.000 Codestellen in über 1000 COBOL-Programmen verteilt waren, die manuell hätten gefunden und angepasst werden müssen). Der Elm-Compiler macht sie sofort und mit sehr gut verständlichen Fehlermeldungen darauf aufmerksam, wenn in einem Case-Matching ein Fall nicht abgedeckt wird – auch wenn man nur eine Handvoll Codestellen hat, ist das ein gutes Sicherheitsnetz.

Fazit

Grady Booch hat einmal sehr schön postuliert: „The primary task of software engineering is to create the illusion of simplicity“. Auch wenn er damit die Anmutung von Einfachheit gegenüber dem Anwender meinte, so sollten wir auch gegenüber Kollegen (und uns selbst) Software nicht komplizierter machen als unbedingt nötig.
Einen Schlüssel zu beherrschbarem Code habe ich Ihnen heute nahegelegt: Gute Abstraktionen zu modellieren, wobei ein starkes Typsystem mit ADTs nicht unabdingbar, aber sehr hilfreich ist (vor allem wegen der Compilerunterstützung). Zusammen mit den Vorzügen der Funktionalen Programmierung, die ich in anderen Beiträgen erläutere, lässt sich damit einfacher, sicherer und produktiver Code schreiben, wie er dem 21. Jahrhundert angemessen ist, denn: Wenn unsere Domänen komplexer werden, müssen wir das mit Einfachheit fördernden Implementierungstaktiken kompensieren. Genau das war die Intention bei Einführung des Begriffs „Höhere Programmiersprachen“.
 
 
Sie haben Interesse an solchen Themen oder generell an Möglichkeiten, Softwareentwicklung produktiver und sicherer zu machen? Bitte hinterlassen Sie einen Kommentar oder eine Frage oder sprechen Sie mich einfach an. Ich freue mich auf das Gespräch mit Ihnen.
Michael_Pul_Paulsen_.png

Michael „Pul“ Paulsen
– Principal Consultant, seppmed gmbh –

Print Friendly, PDF & Email

2 Kommentare

  • Matthias says:

    Hallo Michael,
    zum Thema „nullref ist böse“:
    Bitte korrigiere mich, aber ist nicht das maybe genauso eine nullref wie null, nil, NULL…? Der echte Vorteil erwächst meiner Meinung nach erst aus der sinnvollerweise vom Compiler erzwungenen Behandlung aller Fälle incl. der nullref.
    Natürlich haben einige Sprachen inzwischen erkannt, daß es wichtig ist, nullref besser sichtbar zu machen. Die Idee ist aber nicht neu:
    C#: Nullable
    C/C++: Instanzen die vom Programm dynamisch angelegt werden sind per se nullable (. vs ->)
    Das Wissen um die NULL ist meines erachtens eher selten das Problem (gewesen). Der Wille diese Fälle zu behandeln schon eher. Und wenn es behandelt wird, dann so: (Obacht! Schlechtes Beispiel)
    if (a != null ) {
    if (b != null) {
    if (c != null) {
    // do sth.
    }
    }
    }
    und wenn etwas nicht funktioniert, weiß keiner warum. Da Lobe ich das erzwungene Else.
    Frage: Was macht das erzwungene CaseMatching, wenn eine neue Version einer Lib einen neuen Case eingeführt hat? Muß dann das verwendende Programm neu comiliert werden (wenn ich nur das Binary habe, warten auf die neue Version)? Stichwort: Backward compatibility.
    Schönen Gruß,
    Matthias

    • Michael "Pul" Paulsen says:

      Hallo Matthias,
      Danke für deine Antwort. Ja, es geht darum, dass der Compiler den Entwickler zwingt, sauber zu arbeiten. Geeignete Typen sind ein Beitrag dazu.
      Zu deiner Frage: Zufügen eines Cases in einem Union Case ist eine API-Änderung. Selbstverständlich müsste eine verwendende Codestrecke angepasst werden. Die Frage wäre in diesem Fall: sollte das über mehrere Bibliotheken verteilt werden? FP versucht (wie andere Ansätze auch) lose coupling und strong cohesion anzustreben.
      Und ein Union type kann z.b. Nur an einer Stelle definiert werden und nicht woanders erweitert werden (wie eine final class).
      Viele Grüße
      Pul

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Teile diesen Beitrag:

Share on facebook
Share on linkedin
Share on twitter
Share on xing
Share on whatsapp
Share on google
Share on email
Share on print