Parsen statt Validieren (2019)
(lexi-lambda.github.io)- In der typsicheren, typgetriebenen Entwicklung erhöht Parsen – also Prüfresultate in einem präziseren Typ festzuhalten – die Sicherheit des nachfolgenden Codes stärker als bloßes Validieren, bei dem Eingaben nur geprüft und dann verworfen werden.
- Funktionen wie
head :: [a] -> a, die bei manchen Eingaben fehlschlagen, können zwar durch einen schwächeren Rückgabetyp entschärft werden, doch der Aufrufer muss dann weiterhin unnötige Fehlerzweige mittragen. NonEmpty abewahrt im Typ, dass eine Liste nicht leer ist, und reduziert dadurch doppelte Prüfungen und Fehlerbehandlung für Fälle, die „niemals eintreten sollten“.- Wenn im gesamten Verarbeitungscode provisorische Validierungen verteilt werden, entsteht shotgun parsing: Eingabefehler werden dann womöglich erst nach einigen Zustandsänderungen entdeckt.
- In der Praxis sollte man zuerst die gewünschte Datenrepräsentation in der Funktionssignatur ausdrücken und Invarianten mit
Map, abstrakten Typen, Smart Constructors usw. an die Typgrenze verlagern.
Der Ausgangspunkt typgetriebener Entwicklung
- „Parse, don’t validate“ ist eine Formulierung, die typgetriebene Entwicklung in drei Worte fasst.
- Ein statisches Typsystem macht schon vor dem Schreiben des Codes sichtbar, ob sich eine Funktion überhaupt implementieren lässt.
- Im Haskell-Beispiel
foo :: Integer -> Voidlässt sich kein echter Wert erzeugen, weilVoidkeine Werte besitzt. - Auch
head :: [a] -> aist nicht für alle Eingaben definiert, da eine leere Liste[]übergeben werden kann.- GHC warnt davor, dass das Pattern Matching
[]nicht behandelt. - Es handelt sich also um eine partielle Funktion, die nicht für alle möglichen Eingaben definiert ist.
- GHC warnt davor, dass das Pattern Matching
Zwei Wege, partielle Funktionen in totale Funktionen zu verwandeln
-
Den Rückgabetyp abschwächen
- Wenn man
head :: [a] -> Maybe averwendet, kann bei einer leeren ListeNothingzurückgegeben werden, und die Funktion wird total. - Die Implementierung wird dadurch einfacher, aber der Aufrufer muss immer die Möglichkeit von
Nothingbehandeln. - Im Beispiel wird die Umgebungsvariable
CONFIG_DIRSgelesen und bereits darauf geprüft, dass die Liste nicht leer ist; trotzdem mussmaindenNothing-Zweig deshead-Ergebnisses erneut behandeln. - Doppelte Prüfungen machen den Code unübersichtlich und können sich in komplexeren Fällen auch als Performance-Kosten summieren.
- Selbst wenn die vorgelagerte Prüfung entfernt wird, wird der weiter hinten liegende Fehlerfall, der „niemals eintreten sollte“, nicht als Typproblem sichtbar.
- Am Ende entsteht also ein Loch im Typsystem, und das Auffinden von Bugs hängt stärker von Tests oder manueller Prüfung ab.
- Wenn man
-
Den Argumenttyp stärken
- Statt den Rückgabetyp abzuschwächen, kann man den Argumenttyp stärken und so ausschließen, dass
headjemals mit einer leeren Liste aufgerufen wird. NonEmpty aausData.List.NonEmptysteht für eine nicht leere Liste.- Die Definition lautet
data NonEmpty a = a :| [a]. - Das erste Element
aund der Rest der Liste[a]sind getrennt, sodass selbst bei leerem Rest immer ein erstes Element vorhanden ist. head :: NonEmpty a -> alässt sich mit einem einzigen Pattern implementieren und wird damit total.- Wenn man etwa den Rückgabetyp zu
getConfigurationDirectories :: IO (NonEmpty FilePath)ändert, bleibt die Information „nicht leer“ im Typ erhalten. nonEmpty :: [a] -> Maybe (NonEmpty a)wandelt eine normale Liste inNonEmptyum.- Die Behandlung von
Nothingerfolgt nur einmal an der Eingabegrenze. - In
mainkann man danninitializeCache (head configDirs)ohne doppelte Verzweigungen verwenden. - Falls
getConfigurationDirectoriesspäter nicht mehr garantieren sollte, dass die Liste nicht leer ist, müsste sich auch der Rückgabetyp ändern, undmainwürde die Typprüfung nicht mehr bestehen.
- Statt den Rückgabetyp abzuschwächen, kann man den Argumenttyp stärken und so ausschließen, dass
Der Unterschied zwischen Validieren und Parsen
validateNonEmpty :: [a] -> IO ()undparseNonEmpty :: [a] -> IO (NonEmpty a)prüfen beide auf eine leere Liste und schlagen im Fehlerfall fehl.- Der Unterschied liegt im Rückgabetyp.
validateNonEmptygibt das informationslose()zurück und verwirft damit das Ergebnis der Prüfung.parseNonEmptygibtNonEmpty azurück und bewahrt das durch die Prüfung gewonnene Wissen im Typsystem.
- Ein Parser lässt sich als Funktion verstehen, die weniger strukturierte Eingaben konsumiert und stärker strukturierte Ausgaben erzeugt.
- In diesem Sinn ist
parseNonEmptyein einfacher Parser, der eine Liste in eine nicht leere Liste parst. - Parsen sorgt dafür, dass Prüfungen an der Grenze zwischen Programm und Außenwelt früh abgeschlossen werden und später nicht immer wiederholt werden müssen.
Parsing-Grenzen im Haskell-Ökosystem
- Haskell-Anwendungen verwenden an den Übergängen zur Außenwelt verschiedene Arten von Parsern.
- aeson: stellt den Typ
Parserbereit, um JSON-Daten in Domänentypen zu parsen. - optparse-applicative: bietet Parser-Kombinatoren für Kommandozeilenargumente.
- persistent, postgresql-simple: bieten Mechanismen zum Parsen von Werten aus externen Datenspeichern.
- servant: parst Haskell-Datentypen aus Pfadbestandteilen, Query-Parametern, HTTP-Headern usw.
- aeson: stellt den Typ
- Die Außenwelt spricht nicht in Produkt- und Summentypen, sondern in Bytestreams; deshalb ist Parsing unvermeidlich.
- Wenn Daten bereits vor ihrer Verwendung vorgelagert geparst werden, lassen sich viele Arten von Bugs vermeiden, von denen manche sogar zu Sicherheitslücken führen können.
- Um alles früh zu parsen, muss man Werte unter Umständen deutlich früher parsen, als sie tatsächlich gebraucht werden.
- In einem statischen Typsystem kompiliert ein Programm nicht, wenn Parsing-Logik und Verarbeitungscode auseinanderlaufen.
Risiken eines validierungszentrierten Ansatzes
- Ad-hoc-Validierungen können zu shotgun parsing führen, einem Begriff aus dem Bereich language-theoretic security.
- Im Paper von 2016 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them wird shotgun parsing als Antipattern beschrieben, bei dem Parsing- und Eingabevalidierungscode verstreut zwischen Verarbeitungscode vermischt werden.
- Wenn Eingaben nicht vollständig im Voraus geparst werden, kann das Programm zunächst einige gültige Teile verarbeiten und Fehler in anderen Teilen erst spät entdecken.
- In diesem Fall müssen bereits ausgeführte Zustandsänderungen rückgängig gemacht werden.
- In manchen Fällen ist das möglich, etwa mit RDBMS-Transaktionen, aber allgemein ist das nicht immer machbar.
- Ein validierungsbasierter Ansatz macht es schwierig oder unmöglich zu überprüfen, ob wirklich alle Validierungen bereits im Voraus abgeschlossen wurden.
- Parsen trennt ein Programm in eine Parsing-Phase und eine Ausführungsphase und beschränkt Fehler durch ungültige Eingaben auf die erste Phase.
Anwendung in der Praxis
- Beim Entwurf schreibt man zuerst die gewünschte Datenrepräsentation in die Typsignatur und überbrückt dann die Differenz zur aktuell vorliegenden Repräsentation.
- Wenn eine Funktion eine Liste
[(k, v)]erhält, in der doppelte Schlüssel nicht erlaubt sein sollen, kann eine separate Prüfung wiecheckNoDuplicateKeys :: ... => [(k, v)] -> m ()leicht vergessen werden. - Besser ist es, die Funktion direkt ein
Mapals Argument annehmen zu lassen, das doppelte Schlüssel strukturell nicht zulässt.- An der Aufrufstelle kann dann die Typprüfung fehlschlagen.
- Entlang der Aufrufkette wird die Umwandlung von der Liste in ein
Mapimmer weiter nach oben verschoben. - Dort, wo Werte erzeugt werden oder wo Duplikate tatsächlich erlaubt sein müssen, fügt man dann eine Prüfung in der Form
[(k, v)] -> m (Map k v)ein.
- Dabei ist das Ergebnis der Prüfung für die spätere Ausführung notwendig, sodass die Prüfung nicht einfach ausgelassen werden kann.
- Immer wieder tauchen zwei Prinzipien auf.
- Datenstrukturen verwenden, die unmögliche Zustände nicht darstellbar machen.
- Die Beweislast so weit wie möglich nach oben verlagern, aber nicht weiter als bis zu dem Punkt, an dem sie tatsächlich gebraucht wird.
Zusätzliche Entwurfsrichtlinien und Grenzen
- Datentypen sollten den Code führen, und man sollte der Versuchung widerstehen, nur wegen der gerade implementierten Funktion einfach ein
Boolin einen Record zu stecken. - Funktionen mit Rückgabewert
m ()sollte man mit Skepsis betrachten.- Wenn sie nur imperative Effekte ausführen und kein sinnvolles Ergebnis haben, können sie notwendig sein.
- Wenn ihr Hauptzweck darin besteht, Fehler auszulösen, gibt es wahrscheinlich eine bessere Lösung.
- Man muss keine Angst davor haben, Daten in mehreren Schritten zu parsen.
- Shotgun parsing zu vermeiden bedeutet nicht, auf Eingabedaten zu reagieren, bevor sie vollständig geparst sind.
- Es ist durchaus möglich, anhand eines Teils der Eingabe zu entscheiden, wie andere Teile geparst werden sollen.
- Denormalisierte Datenrepräsentationen sollte man vermeiden, besonders wenn sie veränderbar sind.
- Wenn dieselben Daten an mehreren Stellen dupliziert werden, entstehen leicht voneinander abweichende Zustände.
- Wenn Denormalisierung unvermeidlich ist, sollte sie hinter einer Abstraktionsgrenze verborgen werden, sodass nur ein kleines vertrauenswürdiges Modul für die Synchronisierung verantwortlich ist.
- Wenn sich bestimmte Invarianten mit den Haskell-Werkzeugen allein nur schwer ausdrücken lassen, kann man mit abstrakten
newtypes und Smart Constructors Validatoren wie Parser aufbauen. - Es ist nicht nötig, singletons einzuführen und die gesamte Anwendung zu refaktorieren, nur um jedes
error "impossible"zu eliminieren; in solchen Fällen sollte man aber sorgfältig vorgehen und Invarianten etwa in Kommentaren festhalten.
Weiterführende Lektüre und praktische Vorsicht
- Um das Haskell-Typsystem gut zu nutzen, braucht man weder einen PhD noch zwingend die neuesten GHC-Spracherweiterungen.
- Der Ausgangspunkt ist eher das einfache Prinzip „Schreibe totale Funktionen“, auch wenn die Anwendung dieses Prinzips im echten Code nicht immer leicht ist.
- Die Haskell-Community ist klein, daher bleiben Entwurfsmuster und Techniken oft eher mündlich überliefert als gut dokumentiert.
- Als weiterführendes Material wird Matt Parsons Type Safety Back and Forth genannt.
- Für fortgeschrittenere Themen behandelt Matt Noonans Paper von 2018 Ghosts of Departed Proofs Techniken, um komplexere Invarianten im Typsystem auszudrücken.
- In realen Programmen kann es schwierig sein, bestimmte Invarianten im Typsystem abzubilden; diese Prinzipien sind daher eher ein anzustrebendes Ideal als starre Anforderungen.
1 Kommentare
Kommentare auf Hacker News
Sehr guter Rat und ein hervorragender Artikel. Es gibt einen Grund, warum er auf dieser Seite gelegentlich wieder hochkommt.
Diese Idee geht über Paradigmen hinaus, auch für Leute, die keine statisch typisierten funktionalen Sprachen verwenden. In der objektorientierten Literatur der 80er und 90er, etwa bei Design by Contract, findet man sehr ähnliche Konzepte, und vermutlich auch in noch älteren Papers, Diskussionen und Spezifikationen.
Auch TypeScript wird meines Erachtens oft so geschrieben, dass Typen zur Laufzeit schrittweise eingegrenzt werden. Design by Contract dürfte auch Clojures
specbeeinflusst haben, obwohl Clojure eine dynamische Sprache ist.Im Kern geht es um Annahmen und Garantien. Wenn man bestimmte Annahmen prüfen und daraus Garantien ableiten kann, müssen andere Teile des Programms dieselben Annahmen nicht erneut prüfen.
Am verwirrendsten ist es, wenn man beim Lesen von Code sieht, dass an anderer Stelle Eigenschaften erneut geprüft werden, die bereits garantiert sind. Das macht Schlussfolgerungen und Verbesserungen schwieriger.
Statistisch gesehen passiert so etwas irgendwann, und dann geraten andere Prozesse, Skripte und Code, die sich auf den „ursprünglichen“ Validierungsprozess verlassen haben, in große Schwierigkeiten.
Man muss es allerdings auch tatsächlich nutzen. Zum Beispiel mit Klassen wie
UncheckedEmail,ValidEmailundVerifiedEmail, sodass der Übergang von einer Stufe zur nächsten zwingend den Prozess der E-Mail-Verifizierung durchlaufen muss.Dann muss man nicht raten, ob eine E-Mail-Adresse ungeprüft, formal gültig oder verifiziert ist, und man braucht auch kein Boolean wie
is_email_verified, dessen Aktualisierung oder Prüfung man vergessen kann. Wenn man an der falschen Stelle den falschen Wert verwendet, schreit der Typprüfer, und Menschen können sich auf Wichtiges konzentrieren.Deshalb wird es manchmal so verstanden, als wolle die Autorin bzw. der Autor überhaupt nicht validieren, sondern nur parsen. Tatsächlich geht es im Artikel darum, wo Daten validiert werden und was man mit dem Ergebnis macht. Es ist kein Plädoyer dafür, jede Validierung abzuschaffen.
Der Artikel ist von 2019, aber immer noch ziemlich guter Rat. Dieses Pattern passt auch sehr gut zu modernem C#, und weil man explizite Variablendeklarationen weglassen kann, spart es zudem Platz.
if(!Whatever.TryParse(input, out var output)) output = some-sane-default;oder
if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");Profi-Tipp: Letzteres nicht in einem Kernel-Mode-Treiber tun.
Eine explizite Behandlung ist immer besser als ein impliziter Defaultwert, der anstelle eines Werts verwendet wird, den man für richtig hielt, der aber falsch war.
Was man tun sollte: früh die Hand heben, den Fall als Parsing-Fehler behandeln und den Ablauf sowie das Protokoll für nicht ladbare Dateien sehr klar definieren. Dann stellt man sich automatisch die schwierigen Fragen, die von den beiden Optionen oben nicht behandelt werden.
Das eigentliche Problem beim jüngsten CrowdStrike-Kernel-Mode-Treiber, der beim Parsen irgendeiner def/config-Datei scheiterte, war, dass Entwickler, Product Owner und Business-Analysten nicht gefragt haben: „Was passiert, wenn versucht wird, eine ungültige Datei zu laden?“
Explizite Behandlung > implizite Behandlung
if(!Whatever.TryParse(input, out var output)) output = some-sane-default;Diesen Ansatz mag ich wirklich nicht. Meiner Ansicht nach sollte ein Fehler durch ungültige Eingaben außerhalb der Parsing-Funktion behandelt werden. In F# ist das einfach.
type Whatever =static member create input =match input with| ValidWhatever x -> Some x| _ -> Nonematch Whatever.create input with| Some x -> // geparste Daten verarbeiten| None -> // Fall behandeln, in dem nicht korrekt geparst wurdeOder man kann mit
Option.map/Option.bindauch eine Pipeline für verkettete Operationen bequemer machen.So lassen sich Instanzen nur über die
create-Methode erzeugen, die die Eingabe parst.In der Praxis wird man vermutlich eher
resultstattoptionverwenden wollen, aber das ist hier nur ein Nebenthema.if(!Whatever.TryParse(input, out var output)) output = some-sane-default;sehen möchte.Wenn eine Eingabe überhaupt nicht angegeben wurde, also ein Parameter optional ist, ergibt ein vernünftiger Defaultwert Sinn.
Wenn aber eine falsche Eingabe angegeben wurde, sollte man nicht so tun, als sei alles in Ordnung.
Wenn jemand in einen Blumenladen kommt und nach Kaffee fragt, ist die richtige Antwort nicht, ihm Rosen zu geben. Wenn er versucht, sie zu trinken, zerreißt er sich den Mund.
Für diese Eingabemenge haben Methode, Modul oder Programm keine definierte Ausgabe. Man sollte das deutlich sichtbar machen, statt stillschweigend etwas Falsches oder Unklares zu tun und das Programm dadurch schnell nicht mehr nachvollziehbar zu machen. Es ist auch für einen selbst besser, das Problem klar auszulösen und einen Stacktrace zu hinterlassen, der direkt zur Problemstelle führt, statt es Monate später als Bug mit merkwürdigem Verhalten auftauchen zu lassen.
Der Rat lautet, ein starkes Typsystem zu nutzen, um Fehlerzustände unmöglich darstellbar zu machen. Das ist sehr gut, um Bugs in Software insgesamt zu reduzieren.
Es kostet mehr Zeit, das Problem gründlicher zu durchdenken und ein solches Design zu entwerfen, aber in vielen Fällen ist diese Zeit es absolut wert.
Natürlich kostet es mehr Zeit, wenn es sich um Sprachen handelt, die beim Modellieren von Daten viele bewusste Schritte erfordern, wie C++, Java, C#, Python, Go oder JavaScript.
„Jetzt habe ich ein kurzes, starkes Motto dafür, was typgetriebenes Design für mich bedeutet, und noch besser: Es sind nur drei Wörter: Parse, don’t validate.“
Mein Motto wäre eher immer nur in einem einzigen Konstruktor validieren. Eine Konstruktorfunktion ist auch in Ordnung.
Dann können ungültige Objekte von Anfang an gar nicht existieren, und es gibt immer eine Single Source of Truth. Wenn man ein Objekt ändern möchte, kann man es so implementieren, dass derselbe Konstruktor erneut aufgerufen wird, um einen neuen Zustand zu erzeugen.
Der Kernpunkt ist, dass die Information später verschwindet, wenn man nur validiert.
Wenn man zum Beispiel nur prüft, ob ein bestimmter
intpositiv ist, ist der Nutzen begrenzt. Denn wenn man diesen Wert nicht als positive ganze Zahl parst, bleibt diese Information später auf Typebene nicht erhalten. Dasselbe gilt für nicht leere Arrays oder Listen: Ein späterer Consumer muss möglicherweise erneut prüfen, ob diese Liste wirklich nicht leer ist.Informationen dieser Art lassen sich nicht immer in einem Objekt oder Konstruktor codieren.
Verwandtes Material: Richard Feldmans Making Impossible States Impossible
https://www.youtube.com/watch?v=IcgmSRJHu_8
Auch früher gab es schon gute Diskussionen
https://news.ycombinator.com/item?id=35053118
https://news.ycombinator.com/item?id=21476261
Jedes Mal, wenn dieses Thema aufkommt, muss ich an Abschnitt 5 von https://cr.yp.to/qmail/guarantee.html denken. Dort stehen Sätze wie „Nicht parsen“ und „In der Welt der Computer gibt es zwei Arten von Befehlsschnittstellen: gute Schnittstellen und Benutzerschnittstellen“.
Wenn ich einen Kurs über Programmierung mittlerer Größe unterrichten würde, nicht über kleine oder große Systeme, würde ich den Studierenden als Aufgabe geben, diese Vorschläge in einem Essay zu vergleichen und gegenüberzustellen. Aus jedem lässt sich etwas lernen, und vielleicht widersprechen sie sich nicht so sehr, wie es auf den ersten Blick scheint.
Ich muss an einen Kommentar denken, den ich Mitte der 2000er während des XML-Hypes gesehen habe. Darin hieß es, dass viele Organisationen domänenspezifische Sprachen, einschließlich Konfigurationssprachen, deshalb in XML implementierten, weil XML einen Parser mitbrachte und die meisten Organisationen keinen eigenen Parser schreiben wollten.
Ich weiß nicht, warum Leute so ungern Parser schreiben. Parser zu schreiben ist nicht so schwer und macht ziemlich viel Spaß.
Das ist einer meiner Lieblingstexte, die ich in meiner Laufbahn gelesen habe. Ich habe oft gesehen, dass Leute nur den Titel lesen und annehmen, Parsing und Validierung seien somehow gegenseitig ausschließend, aber in Wirklichkeit ist das nicht so. Parsing beinhaltet oft Validierung.
Das wird im Abschnitt „Use abstract datatypes to make validators ‘look like’ parsers“ des Artikels behandelt.
Es gehört in denselben Themenbereich wie die Idee, Primitive Obsession zu vermeiden.