Richtige Eingabe: Der unterschätzte Aspekt von sauberem Code

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-Orbiter

Das 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:

//    ,  >= 2 N s void trajectory_correction(double momentum) { if (momentum < 2 /* N s */) { disintegrate(); } /* ... */ } 

Sie können sich auch vorstellen, dass die Lockheed-Komponente den obigen Code folgendermaßen aufgerufen hat:

 void main() { trajectory_correction(1.5 /* lbf s */); } 

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 Primitiven

Eine 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:

 //  ,   >= 2 N s void trajectory_correction(Ns momentum) { if (momentum < Ns{ 2 }) { disintegrate(); } /* ... */ } 

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.

Zustandsraum

Probleme 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.

RAII

RAII 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(); /*  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.

Fazit

Wir 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.

Source: https://habr.com/ru/post/de460149/


All Articles