Nutzen Sie das Typsystem
(dzombak.com)- Beim Programmieren kann man das Typsystem nutzen, um unterschiedliche Bedeutungen von Daten klar voneinander zu trennen
- Allgemeine Typen wie Strings oder Integer unverändert zu verwenden lässt den Kontext verloren gehen und kann zu Bugs führen
- Selbst bei demselben Basistyp lassen sich durch die Definition neuer, zweckgebundener Typen Fehler zur Compile-Zeit vermeiden
- In der Go-Bibliothek libwx werden Typen zur klaren Trennung von Maßeinheiten definiert, um Fehler durch die Vermischung von
float64zu verhindern - Im Beispielcode werden UUID-Typen in
UserIDundAccountIDaufgeteilt, sodass der Compiler falsche Verwendung blockiert - Auch in Sprachen mit weniger starkem Typsystem wie Go lassen sich mit einfachen Typ-Wrappern Bugs vermeiden
Nutzen wir das Typsystem aktiv
Der Ausgangspunkt des Problems: Vermischung einfacher Typen
- In der Programmierung werden viele Werte oft nur mit grundlegenden Typen wie
string,intoderUUIDdargestellt - Wenn ein Projekt jedoch größer wird, häufen sich Fehler, bei denen solche einfachen Typen ohne Unterscheidung miteinander vermischt werden
- Beispiel: Eine userID-Zeichenkette wird versehentlich als accountID übergeben, oder bei einer Funktion mit drei
int-Argumenten wird die Reihenfolge vertauscht
- Beispiel: Eine userID-Zeichenkette wird versehentlich als accountID übergeben, oder bei einer Funktion mit drei
Die Lösung: Typdefinitionen, die die Absicht sichtbar machen
intoderstringsind nur Bausteine; wenn man sie im ganzen System unverändert weiterreicht, geht bedeutungsvoller Kontext verloren- Um das zu verhindern, sollte man für jede Rolle einen eigenen Typ definieren und verwenden
- Beispiel:
type AccountID uuid.UUID type UserID uuid.UUID func UUIDTypeMixup() { { userID := UserID(uuid.New()) DeleteUser(userID) // kein Fehler } { accountID := AccountID(uuid.New()) DeleteUser(accountID) // Fehler: AccountID kann nicht als UserID verwendet werden } { accountID := uuid.New() DeleteUserUntyped(accountID) // kein Compile-Zeit-Fehler, hohes Risiko für Probleme zur Laufzeit } }
- Beispiel:
- So lassen sich Argumente mit falschem Typ bereits zur Compile-Zeit blockieren
Praxisbeispiel: die Bibliothek libwx
- Der Autor setzt diese Technik in seiner Go-Bibliothek libwx bereits ein
- Für alle Maßeinheiten werden eigene Typen definiert, und Methoden zur Umrechnung von Einheiten sind an diese Typen gebunden
- Beispiel: Mit der Methode
Km.Miles()werden Einheiten klar voneinander getrennt
- Beispiel: Mit der Methode
- Unten ein Beispiel dafür, wie der Compiler eine falsche Argumentreihenfolge und die Verwechslung von Einheiten blockiert:
// Temperatur in Fahrenheit deklarieren temp := libwx.TempF(84) // relative Luftfeuchtigkeit deklarieren (Prozent) humidity := libwx.RelHumidity(67) // Fälschlich an eine Funktion übergeben, die Celsius statt Fahrenheit verlangt fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity)) // der Compiler erkennt den Typ-Mismatch sofort // temp (Typ TempF) kann nicht als TempC verwendet werden // Argumentreihenfolge der Funktion falsch übergeben fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp)) // der Compiler verhindert den Fehler bei den Argumenttypen - Fehler, die bei bloßer Verwendung von
float64auftreten könnten, lassen sich vollständig vermeiden
Fazit: Das Typsystem aktiv nutzen
- Das Typsystem dient nicht nur der Syntaxprüfung, sondern ist ein Werkzeug zur Vermeidung von Bugs
- Für jedes Modell sollte man eigene ID-Typen definieren, und auch Funktionsargumente sollten statt mit
floatoderintmit klaren Typen gekapselt werden - Dieser Ansatz ist auch in Sprachen mit weniger starkem Typsystem wie Go sehr effektiv und einfach umzusetzen
- In der Praxis gibt es wirklich viele Bugs durch die Vermischung von UUID- oder String-Typen
- Der Autor betont, dass es erstaunlich sei, dass diese einfache Methode in produktivem Code nicht häufiger eingesetzt wird
Zugehöriger Code
- Das vollständige Beispiel ist auf GitHub verfügbar:
https://github.com/cdzombak/libwx_types_lab
8 Kommentare
Soweit ich weiß, kann es bei der Verwendung in Kotlin zu Performance-Problemen kommen, weil Primitive in Wrapper verpackt werden und dann nicht auf dem Stack, sondern auf dem Heap gespeichert werden. Natürlich hat in den meisten Use Cases die Wartbarkeit Vorrang. Außerdem lassen sich Performance-Probleme mit
value classminimieren.Die Sprache Ada verfügt in dieser Hinsicht über ein sehr hervorragendes Typsystem. Werte unterschiedlicher Art lassen sich unkompliziert als separate Typen deklarieren, und wenn sie vermischt werden, fängt der Compiler das zuverlässig ab.
Ich frage aus Neugier. Gibt es auch andere Vorteile, die sie von anderen verbreiteten Typsprachen unterscheiden? (
Kotlin,Rust,TypeScript, ...)Der Vorteil von Ada liegt meist eher in der Richtung „besser als C“. In C wird vieles eingeschränkt, weil man dem Entwickler vertraut und dadurch Dinge erlaubt sind, die problematisch sein können. Etwa implizite Typumwandlungen. Die meisten Entwickler scheinen aber trotzdem C mehr zu mögen, vermutlich weil sie daran gewöhnt sind...
Es mag an der Codebasis liegen, an der ich arbeite, aber wir deklarieren und verwenden fast alles als eigene Typen. Primitive Typen verwenden wir eigentlich nur noch für Array-Indizes.
Verstanden, danke.
Hacker-News-Kommentar
Ich mag diesen Ansatz, dieses „make bad state unrepresentable“, also schlechte Zustände schon auf Darstellungsebene unmöglich zu machen. Ein häufiges Problem bei diesem Muster ist aber, dass Entwickler beim ersten Schritt der Typ-Implementierung stehen bleiben. Dann wird alles zu einem Typ, nichts ist mehr richtig kompatibel, und es entstehen viele leicht abgewandelte Typen, wodurch sich Code schwer verfolgen und verstehen lässt. In so einer Situation würde ich fast lieber eine schwach typisierte dynamische Sprache (JS) oder eine stark typisierte dynamische Sprache (Elixir) verwenden. Wenn Entwickler den typgetriebenen Flow jedoch konsequent weiterziehen, also Bedingungslogik in pattern-matchbare Union-Typen verlagern und Delegation gut nutzen, wird die Developer Experience wieder angenehm. Zum Beispiel kann eine Funktion wie
DewPointauch dann natürlich funktionieren, wenn sie mehrere Typen annimmt.Deshalb hätte ich gern, dass mehr Sprachen bounded types nativ unterstützen, also Typen mit Bereichsbeschränkung. Statt nur
x: u32sollte man im Typsystem erzwingen können, dassxnur im Bereich[0,10)liegen darf. Dann wären bei Array-Indizierung keine Bounds-Checks mehr nötig. Auch bei Dingen wieOptionwürden Peephole-Optimierungen deutlich einfacher. In Rust gibt es dank LLVM innerhalb von Funktionen teilweise Unterstützung dafür, aber nicht bei der Übergabe von Variablen zwischen Funktionen.Nur zur Klarstellung: Ruby ist nicht schwach, sondern stark typisiert. Bei einem Ausdruck wie
1 + "1"bekommt man einen Fehler wieTypeError: String can't be coerced into Integer.Das Problem ist dieses „Beim ersten Schritt der Typ-Implementierung stehen bleiben“. Zum Beispiel ist es ein guter Anfang,
intin einestructzu kapseln und als UUID zu verwenden. Aber sobald jemand einfach irgendeinenintnimmt, ihn in diesen Typ wrappt und weiterreicht, ist die Eigenschaft verloren, dass diese UUID tatsächlich eindeutig sein sollte. Am Ende ist „Correct by construction“ entscheidend: Ein Typ, der eindeutig sein muss, wie eine UUID, darf gar nicht erzeugt werden können, solange das nicht wirklich nachgewiesen ist, sei es durch eine Funktion, einen Konstruktor oder etwas Ähnliches, das sonst eine Exception wirft. Dieses Konzept gilt nicht nur für UUIDs, sondern für beliebige Typen und Invarianten.In letzter Zeit folge ich dem Red-Green-Refactor-Muster, aber statt mit fehlschlagenden Tests mache ich das Typsystem strenger, sodass Bugs schon vom Type Checker abgefangen werden. Neue Features, Edge Cases und Bugs, die sich nicht über Typen erzwingen lassen, behandle ich weiterhin mit Tests. Aber Red-Green-Refactor über das Typsystem ist im Allgemeinen schneller und kann eine ganze Klasse von Bugs vollständig verhindern.
Mit strukturellen Typen lässt sich der Großteil dieser Probleme abmildern. Und wenn es wirklich nötig ist, kann man mit nominalen Typen immer noch stärker erzwingen.
Im Umfeld von Exceptions und Typen finde ich auch, dass man checked exceptions sinnvoll nutzen sollte, um sie typspezifisch passend zu behandeln. Ich verstehe nicht, warum checked exceptions in Java so viel Kritik bekommen. In einem Projekt, das ich betreut habe, habe ich ihre Verwendung erzwungen. Anfangs hasste das jeder, aber sobald sich alle daran gewöhnt hatten, bei jedem Exception-Fall den gesamten Code-Flow mitzudenken, fanden es am Ende doch alle gut. Bei Unit-Tests waren wir nicht ganz so streng, aber das Projekt wurde dadurch sehr robust.
Die Beschwerden über checked exceptions in Java kommen daher, dass Exception-Handling zu umständlich ist. Als Bibliotheksautor kann man checked exceptions oft nicht sauber festlegen, und auf Client-Seite muss man bei jedem Funktionsaufruf unnötig Exceptions behandeln, was schnell frustriert. Wenn man Exceptions einfacher in andere Typen oder Runtime-Exceptions umwandeln könnte oder sie auf Modul-/App-Ebene nur deklarieren müsste, wäre vieles besser, aber so ist es einfach zu sperrig. Außerdem brechen Signaturen schnell, weshalb man domänenspezifische Exceptions verwenden sollte, aber Java macht selbst das Umwandeln von Exceptions unbequem. Checked exceptions an sich sind gut, ich mag nur die Usability von Java-Exceptions nicht.
Checked exceptions wurden vor allem wegen ihres Missbrauchs kritisiert. Dass Java sowohl checked als auch unchecked exceptions unterstützt, ist eigentlich eine gute Designentscheidung. Idealerweise nutzt man checked exceptions nur für das, was Eric Lippert „exogenous“ exceptions nennt, und wandelt den Rest in unchecked exceptions um. Zum Beispiel kann eine DB-Verbindung jederzeit abbrechen, aber es ist viel zu umständlich,
throws SQLExceptiondie ganze Call-Stack hochzuschleppen. Man kann das ganz oben mit einem Catch-all behandeln und HTTP 500 zurückgeben. Verwandter ArtikelDer Nachteil checked exceptions gegenüber unchecked ist, dass man bei einer tief im Call-Stack geänderten Funktion, die nun Exceptions werfen kann, möglicherweise nicht nur den Handler, sondern alle Funktionen dazwischen anpassen muss. Das macht Systemänderungen weniger flexibel. Die Diskussion um async function coloring geht in eine ähnliche Richtung: Wenn etwas Exceptions werfen kann, muss man es entweder mit
try/catchumgeben oder der Aufrufer muss ebenfalls deklarieren, dass er Exceptions weiterwirft.C# hat ein klares Typsystem, setzt aber auf unchecked exceptions. Der Error-Stack bleibt damit sauber und das funktioniert gut. Das ist sauberer, als auf jeder Ebene pattern-gematchte Exception-Handler mit maßgeschneiderter Logik zu haben. Mit robusten unwrapped error results käme man vermutlich auf ein ähnliches Ergebnis.
In Java ist die Usability checked exceptions einfach schwach. Ein Beispiel ist die Stream-API: Wenn in
map- oderfilter-Funktionen checked exceptions geworfen werden, wird es richtig unerquicklich. Wenn mehrere Service-Aufrufe jeweils ihre eigenen checked exceptions haben, landet man am Ende entweder beicatch (Exception)oder bei absurd langen Exception-Listen.Insgesamt stimme ich der Idee zu, „eigene Typen zu machen“, aber ich habe oft unter Systemen gelitten, in denen wirklich alles ein eigener Typ ist. Besonders schwierig wird es, wenn Code zum bloßen Verschieben von Bytes mit Domänenlogik vermischt ist.
Ich verstehe das Gefühl. Die benötigten Daten sind eigentlich schon da, aber zuerst muss man herausfinden, wie man überhaupt den Typ oder eine Instanz daraus erzeugt. Ohne Rezept fühlt es sich an, als würde man mit der Dokumentation kämpfen. Zum Beispiel hat man ein Objekt
{x, y, z}, muss aber zuerstcreateVector(x, y, z): Vectoraufrufen, und für einFacedanncreateFace(vertices: Vector[]): Face, wodurch alles unnötig prozedural wird. Bei Dingen wie BouncyCastle hat man vielleicht bereits ein Byte-Array, muss aber erst mehrere Typen bauen und deren Methoden aufrufen, bevor man die eigentlich gewünschte Funktion nutzen kann.In Go ist es ziemlich einfach, einen Type Alias wieder auf den Ursprungstyp zurückzuführen, etwa
AccountID → int. Mit einer sauberen Struktur kann man daher im Stil von Clean Architecture arbeiten: Die Domänenlogik nutzt Type Aliases, während Bibliotheken, denen die Domäne egal ist, auf höherer oder niedrigerer Abstraktionsebene umwandeln und dort verarbeiten. Allerdings braucht man dafür sehr viel Konvertierungscode.Phantom Types sind in solchen Fällen nützlich. Man fügt einen Typparameter hinzu, also ein Generic, verwendet ihn aber nirgends konkret. Ich habe das früher in Scala für Kryptocode genutzt: Zur Laufzeit waren alle Arrays einfach nur Bytes, aber durch Phantom Types ließ sich verhindern, dass unterschiedliche Dinge versehentlich vermischt werden. Verwandtes Beispiel
Im Idealfall würde der Compiler nach der Typprüfung die gesamte verbleibende Domänenlogik einfach auf simples Kopieren von Bytes herunterkompilieren, falls ich deine Absicht richtig verstanden habe.
Ich denke, auch auf Typsysteme trifft die 80/20-Regel zu. Wenn man es übertreibt, wird die Nutzung einer Bibliothek anstrengend und der zusätzliche Nutzen ist gering. Mit
UUIDoderStringist man vertraut, aberAccountIDoderUserIDmuss man erst lernen, und das kostet. Ein elaboriertes Typsystem kann sinnvoll sein oder auch nicht, besonders wenn es ohnehin gute Tests gibt. Siehe auchUm Software überhaupt verwenden zu können, muss man sowieso verstehen, was ein
AccountoderUserist. Daher ist eine Funktion wiegetAccountById, die einAccountIderwartet, meiner Meinung nach nicht schwerer zu verstehen als eine Funktion, die einUUIDerwartet.Tatsächlich ist ein
Stringnur eine Menge von Bytes und trägt von sich aus keinerlei Bedeutung. BeiAccountIDweiß man hingegen meist sofort, dass es um die ID eines Kontos geht. Wenn man wirklich die interne Repräsentation wissen will, kann man sich die Typdefinition ansehen, aber in den meisten Kontexten reicht es zu wissen, was einAccountIDist. Typen werden am Ende einfach leichter nutzbar, wenn sie klare Namen haben. Der Link zu grugbrain.dev ist im Gegenteil fast zu grundlegend; ein grug brain würde diese Art von Typtrennung eher gutheißen.foo(UUID, UUID)ist deutlich schlechter alsfoo(AccountId, UserId). Letzteres ist selbsterklärend, und wenn man beim Aufruf versehentlich die Reihenfolge vertauscht, kann der Compiler das erkennen. Das bleibt auch bei komplexeren Datenstrukturen gut lesbar, ohne dass man neue Typen erfinden muss.Zur Aussage „UUID oder String ist eben schon vertraut“: In der Praxis ist oft gar nicht so leicht zu verstehen, welche Form von UUID gemeint ist, etwa GUIDv1, UUIDv4 oder UUIDv7, und wie sie gespeichert oder konvertiert wird. Ich habe zum Beispiel in einer Java+MS-SQL-Kombination schon selbst Endianness-Probleme bei der Umwandlung zwischen UUID und
uniqueidentifierbeheben müssen. Das erinnert mich an ähnliche Probleme mit automatischen Datenbank-Zeitzonenumwandlungen.Eigentlich musste man diese Typen sowieso kennen, sonst hätte man am Ende nur falsche Daten an Funktionen weitergereicht.
Unser Team hat vor Kurzem auch in C++ Typen für mehrere gemischt verwendete Zahlenwerte eingeführt. Anlass war ein Bugfix: Wir haben sichere Typen eingebaut und dadurch noch drei weitere Stellen gefunden, an denen ähnliche falsche Werte verwendet wurden.
Die Bibliothek mp-units (mp-units-Dokumentation) ist ein gutes Beispiel dafür, wie man solche Probleme mit physikalischen Einheiten adressieren kann. Mit starken Einheitentypen gewinnt man Sicherheit, automatisiert komplexe Umrechnungslogik und kann mit generischem Code unterschiedliche Einheiten verarbeiten. Ich wollte so etwas in die Prolog-Welt übertragen, aber mein Umfeld war nicht besonders begeistert. Beispiel für Prolog
Ich habe einmal an einem Projekt gearbeitet, das viele physikalische Größen behandelte, etwa Distanz, Geschwindigkeit, Temperatur und Druck. Alles wurde einfach als
floatweitergereicht, sodass man problemlos einen Distanzwert an eine Stelle für Geschwindigkeit setzen konnte. Der Compiler akzeptierte das, und der Bug tauchte erst zur Laufzeit auf. Dasselbe galt für falsch übergebene Einheiten wie km/h statt miles/h. Ich hätte solche Probleme gern schon in der Entwicklungsphase durch zusätzliche Typen abgefangen, aber ich war damals noch Junior und konnte das schwer durchsetzen.Ich hatte das Thema typisierte physikalische Einheiten irgendwann aufgegeben, weil es mir zu komplex erschien, aber ich plane nun, mir
mp-unitsgenauer anzusehen. Besonders problematisch ist, dass bei Variablen oft gar nicht klar markiert ist, in welcher Einheit sie vorliegen. Bei externen Datenquellen oder Standardfunktionen fehlt diese Kennzeichnung häufig.In C# definiere ich Typen manchmal so:
Dann kann man etwa schreiben:
Auf diese Weise lassen sich verschiedene Integer-IDs sauber voneinander trennen. Das kann man auch auf
IdGuidoderIdStringerweitern, und für einen neuen Marker-TypMbraucht man nur eine zusätzliche Zeile. In TypeScript und Rust verwende ich ähnliche Varianten.Ich habe ein ähnliches Muster schon einmal verwendet. Und wenn es um
int-IDs geht, wäre einenumwahrscheinlich die Variante mit der geringsten Reibung, aber es wirkte mir zu verwirrend, sodass ich es nie produktiv eingesetzt habe. Verwandte DiskussionDieses Muster nennt man „phantom type“, weil
MFoooderMBarzur Laufzeit gar keinen Wert haben.Für diesen Zweck gibt es auch Bibliotheken wie Vogen. Vogen steht für Value Object Generator und unterstützt per Source Code Generation das Hinzufügen von Value-Object-Typen. Im README finden sich auch ähnliche Bibliotheken und weiterführende Links.
Ich hatte diese Methode schon früher einmal gesehen, aber nicht verstanden, wozu sie gut ist. Heute habe ich wieder eine Funktion mit drei String-Parametern geschrieben und überlegt, ob ich explizites Typ-Parsen erzwingen oder das innerhalb der Funktion erledigen sollte. Da ich die geparsten Werte in Wirklichkeit gar nicht brauchte, war dieser Ansatz genau die Antwort, nach der ich gesucht hatte. Das wird vermutlich meinen Coding-Stil in diesem Jahr am stärksten beeinflussen.
Mein Freund Lukas hat diese Idee einmal als „Safety Through Incompatibility“ beschrieben. Ich habe dieses Muster flächendeckend in Golang-Code angewendet und fand es extrem nützlich. Es verhindert von vornherein, dass versehentlich die falsche ID übergeben wird.
Verwandter Artikel 1
Verwandter Artikel 2
In Swift gibt es zwar das Schlüsselwort
typealias, aber wenn der zugrunde liegende Typ gleich ist, lassen sich die Typen trotzdem frei ineinander umwandeln. Für diesen Zweck ist das also praktisch ungeeignet. Wrapper-Structs sind in Swift idiomatischer, und mitExpressibleByStringLiteralwird das halbwegs bequem. Trotzdem hätte ich gern ein neues Schlüsselwort wie ein „starker Type Alias“ (typecopyoder ähnlich), mit dem man ausdrücken kann: „Das hier ist zwar im Grunde einString, aber einStringmit besonderer Bedeutung, also bitte nicht mit anderen Strings vermischen.“So funktioniert es in den meisten Sprachen. Das gilt zum Beispiel auch für Rust, C und C++. Es ist angenehm, wenn man wie im Go-Beispiel nicht immer Wrapper-Typen bauen muss. In C++ muss man zusätzlich aufpassen: Wenn ein Konstruktor nicht als
explicitmarkiert ist, kann man einenintoft einfach so an Stellen übergeben, an denen einFooerwartet wird.In der Theorie wirkt das elegant, aber in der Praxis kann die Anwendung kompliziert werden, zum Beispiel bei
std::coutin C++ oder bei der Kompatibilität mit Third-Party-Funktionen und Extension Points, die bisher einfachStringerwartet haben.In Haskell gibt es dieses Konzept als
newtype. In OOP-Sprachen kann man, solange ein Typ nichtfinalist, relativ leicht Unterklassen erzeugen und gewünschtes Verhalten ergänzen oder spezialisieren. Das ist günstig und einfach, ohne zusätzliche Wrapper oder Boxing. In Java istStringallerdingsfinal, weshalb dieser Weg dort schwierig ist und sichStringselbst kaum spezialisieren lässt.Mich würde konkret interessieren, wie genau du dir das Verhalten im Unterschied zu einem Wrapper-Struct vorstellst.
Rust wird ja auch auf diese Weise verwendet, das scheint definitiv gut zu sein.
Wenn man eine Sprache mit einem gut ausgearbeiteten Typsystem verwendet, hätte man so etwas vielleicht auch verhindern können ...
Das Verschwinden des NASA Mars Climate Orbiter im September 1999