C++ Core Guidelines: Ein kleiner Umweg über Kontrakte in C++20

Modernes C++  –  2 Kommentare

Ursprünglich wollte ich in diesem Artikel weiter auf die Regeln zur Fehlerbehandlung eingehen. Aber ich habe meinen Plan geändert und fokussiere mich heute auf die Zukunft: Kontrakte in C++20.

Hier sind die Regeln, die ich heute auslassen werde.

Fabuio (Bild: https://commons.wikimedia.org/w/index.php?curid=38020523)

Warum habe ich meinen Plan geändert? Dazu gibt es mehrere Gründe:

  • Die oben zitieren Regeln der C++ Core Guidelines besitzen nicht genügend Substanz.
  • Ich schrieb bereits zur Regel E.6 einen ganzen Artikel: "Garbage Collection – No Thanks". Klar, ich will mich nicht wiederholen.
  • Vier der fünf Regeln beschäftigen sich mit Design by Contract.

Die Konsequenz aus diesen Punkten ist einfach. Kontrakte scheinen wichtig für die Fehlerbehandlung zu sein, C++20 wird mit hoher Wahrscheinlichkeit Kontrakte besitzen. Daher stelle ich in diesem Artikel Kontrakte in C++20 vor.

Falls du mehr Details zu Kontrakten benötigst, dieser Artikel basiert auf den Proposals P0380R1 und P0542R5.

Was ist ein Kontrakt?

Ein Kontrakt stellt in einer präzisen und prüfbaren Art ein Interface für eine Softwarekomponente dar. Die Softwarekomponente ist typischerweise eine Funktion oder eine Methode, die Vorbedingungen, Nachbedingungen und Invarianten erfüllen muss. Hier sind die Definitionen kurz und kompakt:

  1. Eine Vorbedingung (Precondition) ist ein Prädikat, das gelten muss, bevor die Komponente aufgerufen wird.
  2. Eine Nachbedingung (Postcondition) ist ein Prädikat, das gelten muss, nachdem die Komponente aufgerufen wurde.
  3. Eine Zusicherung (Invariante) ist ein Prädikat, das an der Stelle im Code gelten muss, an der es platziert ist.

Vor- und Nachbedingungen werden in C++20 außerhalb der Funktionsdefinition platziert, Zusicherungen hingegen innerhalb der Funktionsdefinition. Ein Prädikat ist eine Funktion, die einen Wahrheitswert zurückgibt.

Hier ist das erste Beispiel:

int push(queue& q, int val) 
[[ expects: !q .full() ]]
[[ ensures !q.empty() ]]{
...
[[assert: q.is_ok() ]]
}

Das Attribut expects ist eine Vorbedingung, das Attribut ensures eine Nachbedingung und das Attribut assert eine Zusicherung.

Die Kontrakte für die Funktion push sind, dass sie nicht voll ist, bevor ein Element hinzugefügt wird, dass sie nicht leer ist, nachdem ein Element hinzugefügt wurde, und dass die Zusicherung q.is_ok() gilt.

Vor- und Nachbedingungen sind Teil des Funktions-Interface und können damit nicht auf lokale Variablen oder private oder protected Mitglieder eine Klasse zugreifen. Diese Einschränkung gilt nicht für Zusicherungen, da sie Teil der Implementierung sind:

class X {
public:
void f(int n)
[[ expects: n<m ]] // error; m is private
{
[[ assert: n<m ]]; // OK
// ...
}
private:
int m;
};

m ist private und kann damit nicht Bestandteil der Vorbedingung sein.

Im Standardfall führt eine Verletzung eines Vertrags zur Beendigung des Programms. Das ist aber noch nicht die ganze Geschichte zu Kontrakten. Hier kommen mehr Details.

Mehr Details

Der Ausdruck zeigt die vollständige Syntax für Kontrakte: [[contract-attribute modifier: conditional-expression ]]

  • contract-attribute: expects, ensures und assert
  • modifier: steht für Abstufung des Kontrakts und damit für seine Durchsetzung; mögliche Werte sind default, audit und axiom
    • default: die Kosten den Kontrakt zur Laufzeit zu prüfen sind gering; dies ist der Standard
    • audit: die Kosten den Kontrakt zur Laufzeit zu prüfen sind hoch
    • axiom: das Prädikat wird nicht zur Laufzeit geprüft
  • conditional-expression: das Prädikat des Kontrakts

Das ensures-Attribut unterstützt einen identifier: [[ensures modifier identifier: conditional-expression ]] Der Identifier erlaubt es, den Rückgabewert der Funktion anzusprechen:

int mul(int x, int y)
[[expects: x > 0]] // implicit default
[[expects default: y > 0]]
[[ensures audit res: res > 0]]{
return x * y;
}

res als der Identifier ist in diesem Fall ein beliebiger Name. Wie das Beispiel zeigt, können mehrere Kontrakte derselben Art eingesetzt werden.

Jetzt werde ich mir die Modifier genauer anschauen und damit mit der Frage beschäftigen: Was passiert bei einer Verletzung des Kontrakts?

Der Umgang mit einer Verletzung des Kontrakts

Eine Übersetzung besitzt drei Zusicherungsabstufungen (assertion build levels):

  • off: keine Kontrakte werden geprüft.
  • default: default: Kontrakte werden geprüft; das ist der Default.
  • audit: default und audit: Kontrakte werden geprüft.

Wenn eine Verletzung eines Kontrakts eintritt – dies ist, wenn das Prädikat zu false evaluiert –, wird der violation handler ausgerufen. Er ist eine Funktion vom Typ noexcept, die ein Argument const std::contract_violation annimmt und void zurückgibt. Da die Funktion vom Typ noexcept ist, bedeutet das, dass im Fall einer Verletzung eines Kontrakts die Funktion std::terminate aufgerufen wird. Ein Anwender kann einen eigenen violation handler einsetzen.

Die Klasse std::contract_violation bietet die Information zur Verletzung eines Kontrakts an:

namespace std{ 
class contract_violation{
public:
uint_least32_t line_number() const noexcept;
string_view file_name() const noexcept;
string_view function_name() const noexcept;
string_view comment() const noexcept;
string_view assertion_level() const noexcept;
};
}
  • line_number: Zeilennummer der Verletzung des Kontrakts
  • file_name: Dateiname der Verletzung des Kontrakts
  • function_name: Funktionsname der Verletzung des Kontrakts
  • comment: das Prädikat des Kontrakts
  • assertion_level: Zusicherungsabstufung (assertion_level) des Kontrakts

Für das Deklarieren eines Kontrakts gilt es ein paar Regeln im Kopf zu behalten.

Deklaration eines Kontrakts

Ein Kontrakt kann bei der Deklaration einer Funktion angegeben werden. Dies schließt die Deklaration von virtuellen Funktionen und Funktions-Templates ein.

  • Die Deklarationen eines Kontrakts müssen identisch sein. Deklarationen, die keinen Kontrakt angeben, erhalten den Kontrakt der ersten Deklaration.
int f(int x) 
[[expects: x>0]]
[[ensures r: r>0]];

int f(int x); // OK. No contract.

int f(int x)
[[expects: x>=0]]; // Error missing ensures and different expects condition
  • Ein Kontrakt kann in einer überschreibenden Methode nicht verändert werden.
struct B{
virtual void f(int x)[[expects: x > 0]];
virtual void g(int x);
}

struct D: B{
void f(int x)[[expects: x >= 0]]; // error
void g(int x)[[expects: x != 0]]; // error
};

Beide Definitionen der Kontrakte in der Klasse D sind fehlerhaft. Der Kontrakt der Methode f unterscheidet sich vom dem der Methode B::f. Die Methode D::g fügt hingegen einen Kontrakt zu B::g hinzu.

Abschließende Gedanken

Beeindruckt? Ich auf jeden Fall! Ich kann mir noch nicht vorstellen, wie stark Kontrakte die Art und Weise verändern werden, wie wir Funktionen schreiben und über Interfaces und Fehlerbehandlungen denken werden. Vielleicht geben dir Herb Sutters' Gedanken in "Sutters's Mill" eine erste Idee: "contracts is the most impactful feature of C++20 so far, and arguably the most impactful feature we have added to C++ since C++11."

Wie geht's weiter?

Mit meinem nächsten Artikel werde ich wieder ein Stück zurücktreten und über die Gegenwart schreiben, denn es geht weiter mit den Regeln zur Fehlerbehandlung.

Weitere Informationen

Wow! Fast 200 Leser haben sich zur Wahl des nächsten PDF-Päckchens beteiligt. Hier sind die Gewinner.

  • Deutsches PDF-Päckchen: Embedded: Performanz zählt
  • Englisches PDF-Päckchen: C++ Core Guidelines: Concurrency and Parallelism

Hier sind nochmals alle Details zu der Wahl:

Ich werde eine gute Woche benötigen, um die PDF-Päckchen zusammenzustellen und gegenzulesen.