Hallo Kollegen.
Vor nicht allzu langer Zeit wurde unsere Aufmerksamkeit auf das
fast fertige Buch des Manning-Verlags „Programmieren mit Typen“ gelenkt, in dem die Bedeutung des richtigen Tippens und seine Rolle beim Schreiben von sauberem und langlebigem Code beschrieben werden.

Gleichzeitig fanden wir im Blog des Autors einen Artikel, der anscheinend in den frühen Phasen der Arbeit an dem Buch geschrieben wurde und einen Eindruck von seinem Material hinterlassen konnte. Wir empfehlen zu diskutieren, wie interessant die Ideen des Autors und möglicherweise das gesamte Buch sind
Mars-Klima-OrbiterDas Raumschiff Mars Climate Orbiter stürzte während der Landung ab und fiel in der Marsatmosphäre auseinander, weil die Lockheed-Softwarekomponente den in Pfund-Kraft-Sek. Gemessenen Impulswert ergab, während die andere von der NASA entwickelte Komponente den Impulswert in Newton- nahm. sek
Sie können sich die von der NASA entwickelte Komponente in etwa folgender Form vorstellen:
Sie können sich auch vorstellen, dass die Lockheed-Komponente den obigen Code folgendermaßen aufgerufen hat:
void main() { trajectory_correction(1.5 ); }
Die Pfund-Kraft-Sekunde (lbfs) beträgt ungefähr 4,448222 Newton pro Sekunde (Ns). Aus Sicht von Lockheed sollte es daher völlig normal sein, 1,5 lbfs an die
trajectory_correction
zu übergeben: 1,5 lbfs sind ungefähr 6,672333 Ns und liegen weit über dem Schwellenwert von 2 Ns.
Das Problem ist die Dateninterpretation. Infolgedessen vergleicht die NASA-Komponente lbfs mit Ns ohne Konvertierung und interpretiert die Eingabe in lbfs fälschlicherweise als Eingabe in Ns. Da 1,5 kleiner als 2 ist, ist der Orbiter zusammengebrochen. Dies ist ein bekanntes Antimuster, das als primitive Besessenheit bezeichnet wird.
Besessenheit mit PrimitivenEine Fixierung auf Grundelemente manifestiert sich, wenn wir einen Grundelementdatentyp verwenden, um einen Wert in einer Problemdomäne darzustellen und Situationen wie die oben beschriebenen zuzulassen. Wenn Sie Postleitzahlen als Nummern, Telefonnummern als Zeichenfolgen, Ns und lbfs als Nummern mit doppelter Genauigkeit darstellen, geschieht genau dies.
Es wäre viel sicherer, einen einfachen Typ von
Ns
zu definieren:
struct Ns { double value; }; bool operator<(const Ns& a, const Ns& b) { return a.value < b.value; }
Ebenso können Sie einen einfachen Typ von
lbfs
:
struct lbfs { double value; }; bool operator<(const lbfs& a, const lbfs& b) { return a.value < b.value; }
Jetzt können Sie eine typsichere Variante von
trajectory_correction
implementieren:
Wenn Sie dies mit
lbfs
aufrufen, wie im obigen Beispiel, wird der Code aufgrund von Typinkompatibilität einfach nicht kompiliert:
void main() { trajectory_correction(lbfs{ 1.5 }); }
Beachten Sie, wie die Werttypinformationen, die normalerweise in den Kommentaren angegeben werden (
2 /*Ns */, /* lbfs */
), jetzt in das Typsystem gezogen und im Code ausgedrückt werden: (
Ns{ 2 }, lbfs{ 1.5 }
) .
Natürlich ist es möglich, eine Reduktion von
lbfs
auf
Ns
in Form eines expliziten Operators bereitzustellen:
struct lbfs { double value; explicit operator Ns() { return value * 4.448222; } };
Mit dieser Technik können Sie
trajectory_correction
mithilfe einer statischen Umwandlung aufrufen:
void main() { trajectory_correction(static_cast<Ns>(lbfs{ 1.5 })); }
Hier wird die Richtigkeit des Codes durch Multiplikation mit einem Koeffizienten erreicht. Eine Umwandlung kann auch implizit durchgeführt werden (unter Verwendung des impliziten Schlüsselworts). In diesem Fall wird die Umwandlung automatisch angewendet. Als empirische Regel können Sie hier einen der Python-Coans verwenden:
Explizit ist besser als implizit
Die Moral dieser Geschichte ist, dass wir heute zwar sehr intelligente Mechanismen zur Typprüfung haben, diese aber dennoch genügend Informationen liefern müssen, um diese Art von Fehler zu erkennen. Diese Informationen werden in das Programm aufgenommen, wenn wir Typen unter Berücksichtigung der Besonderheiten unseres Themenbereichs deklarieren.
ZustandsraumProbleme treten auf, wenn ein Programm in einem
schlechten Zustand beendet wird . Typen helfen dabei, das Feld für ihr Auftreten einzugrenzen. Versuchen wir, den Typ als Satz möglicher Werte zu behandeln. Bool ist beispielsweise die Menge
{true, false}
, in der eine Variable dieses Typs einen dieser beiden Werte annehmen kann. In ähnlicher Weise ist
uint32_t
die Menge
{0 ...4294967295}
. Wenn wir die Typen auf diese Weise betrachten, können wir den Zustandsraum unseres Programms als das Produkt der Typen aller lebenden Variablen zu einem bestimmten Zeitpunkt definieren.
Wenn wir eine Variable vom Typ
bool
und eine Variable vom Typ
uint32_t
, ist unser
{true, false} X {0 ...4294967295}
. Es bedeutet nur, dass beide Variablen in jedem für sie möglichen Zustand sein können, und da wir zwei Variablen haben, kann das Programm in jedem kombinierten Zustand dieser beiden Typen enden.
Alles wird viel interessanter, wenn wir die Funktionen betrachten, die die Werte initialisieren:
bool get_momentum(Ns& momentum) { if (!some_condition()) return false; momentum = Ns{ 3 }; return true; }
Im obigen Beispiel nehmen wir Ns als Referenz und initialisieren, wenn eine Bedingung erfüllt ist. Die Funktion gibt
true
zurück
true
wenn der Wert korrekt initialisiert wurde. Wenn die Funktion den Wert aus irgendeinem Grund nicht festlegen kann, wird
false
.
Wenn man diese Situation aus Sicht des Zustandsraums betrachtet, kann man sagen, dass der Zustandsraum ein Produkt von
bool X Ns
. Wenn die Funktion true zurückgibt, bedeutet dies, dass der Impuls gesetzt wurde und einer der möglichen Werte von
Ns
. Das Problem ist folgendes: Wenn die Funktion
false
zurückgibt, bedeutet dies, dass der Impuls nicht gesetzt wurde. Auf die eine oder andere Weise gehört der Impuls zur Menge der möglichen Werte von Ns, ist aber kein gültiger Wert. Oft gibt es Fehler, bei denen sich der folgende inakzeptable Zustand versehentlich zu verbreiten beginnt:
void example() { Ns momenum; get_momentum(momentum); trajectory_correction(momentum); }
Stattdessen müssen wir einfach Folgendes tun:
void example() { Ns momentum; if (get_momentum(momentum)) { trajectory_correction(momentum); } }
Es gibt jedoch einen besseren Weg, wie dies gewaltsam getan werden kann:
std::optional<Ns> get_momentum() { if (!some_condition()) return std::nullopt; return std::make_optional(Ns{ 3 }); }
Wenn Sie
optional
, verringert sich der
bool X Ns
dieser Funktion erheblich: Anstelle von
bool X Ns
wir
Ns + 1
. Diese Funktion gibt entweder einen gültigen
nullopt
oder einen
nullopt
, um keinen Wert anzugeben. Jetzt können wir einfach keine ungültigen
Ns
, die sich im System ausbreiten würden. Auch jetzt kann man nicht mehr vergessen, den Rückgabewert zu überprüfen, da optional nicht implizit in
Ns
konvertiert werden kann - wir müssen ihn speziell entpacken:
void example() { auto maybeMomentum = get_momentum(); if (maybeMomentum) { trajectory_correction(*maybeMomentum); } }
Grundsätzlich bemühen wir uns, dass unsere Funktionen eher ein Ergebnis oder einen Fehler als ein Ergebnis und einen Fehler zurückgeben. Daher schließen wir die Bedingungen aus, unter denen wir Fehler haben, und sind auch vor inakzeptablen Ergebnissen sicher, die dann in weitere Berechnungen einfließen könnten.
Unter diesem Gesichtspunkt ist das Auslösen von Ausnahmen normal, da es dem oben beschriebenen Prinzip entspricht: Eine Funktion gibt entweder ein Ergebnis zurück oder löst eine Ausnahme aus.
RAIIRAII bedeutet, dass Ressourcenbeschaffung Initialisierung ist, aber in größerem Maße ist dieses Prinzip mit der Freigabe von Ressourcen verbunden. Der Name wurde zuerst in C ++
IDisposable
. Dieses Muster kann jedoch in jeder Sprache implementiert werden (siehe z. B.
IDisposable
from .NET). RAII bietet eine automatische Ressourcenbereinigung.
Was sind Ressourcen? Hier einige Beispiele: dynamischer Speicher, Datenbankverbindungen, Betriebssystemdeskriptoren. Im Prinzip ist eine Ressource etwas, das der Außenwelt entnommen wurde und zurückgegeben werden muss, nachdem wir es nicht mehr benötigen. Wir geben die Ressource mit der entsprechenden Operation zurück: Geben Sie sie frei, löschen Sie sie, schließen Sie sie usw.
Da diese Ressourcen extern sind, werden sie in unserem Typsystem nicht explizit ausgedrückt. Wenn wir beispielsweise ein Fragment des dynamischen Speichers auswählen, erhalten wir einen Zeiger, mit dem wir
delete
aufrufen müssen:
struct Foo {}; void example() { Foo* foo = new Foo(); delete foo; }
Aber was passiert, wenn wir dies vergessen oder hindert uns etwas daran,
delete
aufzurufen?
void example() { Foo* foo = new Foo(); throw std::exception(); delete foo; }
In diesem Fall rufen wir nicht mehr
delete
und erhalten ein Ressourcenleck. Grundsätzlich ist eine solche manuelle Reinigung von Ressourcen unerwünscht. Für den dynamischen Speicher haben wir
unique_ptr
, um ihn zu verwalten:
void example() { auto foo = std::make_unique<Foo>(); throw std::exception(); }
Unser
unique_ptr
ist ein Stapelobjekt. Wenn es also
unique_ptr
Bereich
unique_ptr
(wenn die Funktion eine Ausnahme auslöst oder wenn sich der Stapel abwickelt, wenn eine Ausnahme ausgelöst wurde), wird sein Destruktor aufgerufen. Dieser Destruktor implementiert den
delete
. Dementsprechend müssen wir die Speicherressource nicht mehr verwalten - wir übertragen diese Arbeit an den Wrapper, dem sie gehört und der für die Freigabe verantwortlich ist.
Ähnliche Wrapper existieren (oder können erstellt werden) für andere Ressourcen (z. B. kann OS HANDLE von Windows in einen Typ eingeschlossen werden. In diesem Fall ruft sein Destruktor
CloseHandle
).
Die Hauptschlussfolgerung in diesem Fall ist, niemals die Ressourcen manuell zu reinigen. Verwenden Sie entweder den vorhandenen Wrapper, oder wenn es für Ihr spezifisches Szenario keinen geeigneten Wrapper gibt, werden wir ihn selbst implementieren.
FazitWir haben diesen Artikel mit einem bekannten Beispiel begonnen, das die Bedeutung der Eingabe demonstriert, und dann drei wichtige Aspekte der Verwendung von Typen untersucht, um sichereren Code zu schreiben:
- Stärkere Typen deklarieren und verwenden (im Gegensatz zur Besessenheit von Primitiven).
- Reduzieren des Statusraums, Zurückgeben eines Ergebnisses oder Fehlers, nicht eines Ergebnisses oder Fehlers.
- RAII und automatische Ressourcenverwaltung.
Typen helfen also sehr, den Code sicherer zu machen und ihn für die Wiederverwendung anzupassen.