In einem Restaurant entspräche ein CPP-Interface einem zwischen Gast (Servicenutzer) und Kellner (Service-Anbieter) vereinbarten Vier-Gänge-Menü. In C++ handelt es sich laut C++ Core Guidelines um den wichtigsten Aspekt beim Organisieren von Code.
Zunächst: Die C++ Core Guidelines bieten gleich 20 verschiedene Regeln zu Interfaces an [1]. Aus diesem Grund müssen Entwickler etwas Pragmatismus zeigen. Zumindest die folgenden drei Regeln sind einfach zu verdauen, konkret geht es um die I.2 (“Make Interfaces precisely and strongly typed”), die I.23 (“Keep the Number of Function Arguments low”) sowie die I.24 (“Keep the Number of Function Arguments low”).
Alle drei Regeln lassen sich zudem positiv umformulieren. Frei nach Scott Meyers [2] lauten sie ungefähr: Schreibe das Interface so, dass es schwierig ist, es falsch zu verwenden.
Ein praktisches Beispiel dafür, wie der Einsatz von Interfaces nicht aussehen sollte, liefert Listing 1. Die Funktion »draw_rect()« lädt förmlich dazu ein, sie regelwidrig zu verwenden. Weder geht aus der Funktionssignatur hervor, wofür die Funktionsargumente stehen, noch wird deutlich, in welcher Reihenfolge sie zum Einsatz kommen. All dies gilt nicht für die Funktion »draw_rectangle()«, denn ihre Argumente stehen für konkrete Objekte.
Listing 1
(Un)elegante Interfaces
01 void draw_rect(int a, int b, int c, int d); 02 03 void draw_rectangle(Point top_left, Point bottom_right);
Während es sich also bei den Argumenten der Funktion »draw_rect()« um vollkommen generische »int«-Werte handelt, erweisen sich die Argumente der Funktion »draw_rectangle()« als konkrete Datentypen mit einer wohldefinierten Semantik.
Globale Probleme mit Variablen und Singletons
Es gibt unter den Interface-Richtlinien auch kontroverse Regeln. Während die meisten Programmierer bei Regel I.2 (“Avoid global Variables”) bereits schwer schlucken, wird der Brocken für viele unverdaulich, wenn es um Regel Nummer I.3 geht: “Avoid singletons”.
Zunächst zur ersten Regel. Warum ist eine globale Variable, insbesondere eine globale, veränderliche Variable, zu vermeiden? Ganz einfach. Sie injiziert eine Abhängigkeit in eine Funktion, die Dritte dem Interface der Funktion nicht ansehen. Listing 2 zeigt das anhand einer einfachen »multiply()«-Funktion.
Listing 2
multiply()
01 int glob{2011};
02
03 int mutiply(int fac){
04 glob *= glob;
05 return glob * fac;
06 }
Der Code um die Funktion »multiply()« verändert als Seiteneffekt den Wert der globalen Variablen »glob«. Dadurch lässt sich die Funktion nicht mehr einfach testen oder in Isolation betrachten. Führen mehrere Threads »multiply()« gleichzeitig aus, gilt es, die Variable »glob« zusätzlich zu schützen. Hier enden die Nachteile globaler, veränderbarer Variablen noch nicht. Ohne die genannten Seiteneffekte würde die Funktion das Ergebnis zwischenspeichern können. Es ließe sich auch wiederverwenden.
Auch ein Singleton, der zweite schwere Brocken, ist leider nur eine besonders schön verpackte globale Variable. Als solche injiziert es ebenfalls eine Abhängigkeit, die das Interface vollkommen ignoriert. Das ist der Tatsache geschuldet, dass C++-Programme Singletons typischerweise als statische Variablen in der Form »Singleton::getInstance()« direkt aufrufen. An diesem Punkt fängt jedoch die Herausforderung, ein Singleton richtig zu verwenden, leider erst an. Der Einsatz wirft noch weitere Fragen auf:
- Wann zerstört das Programm das Singleton?
- Soll es möglich sein, von einem Singleton abzuleiten?
- Wie lässt sich ein Singleton Thread-sicher initialisieren?
- In welcher Reihenfolge initialisiert der Entwickler Singletons, wenn diese in verschiedenen Übersetzungseinheiten stecken, die voneinander abhängen?
Will er nicht auf Singletons verzichten, sollte der Entwickler im Vorfeld aber die Folgen kalkulieren.
Array-Ärger
Setzt der Programmierer ein C-Array als Argument einer Funktion ein, reduziert er es auf einen Zeiger auf sein erstes Element. Auch wenn sie zum alltäglichen Brot eines Entwicklers gehört, erweist sich diese Technik als sehr fehleranfällig. Beispiel gefällig?
Listing 3
Kopieren mit C-Arrays – der falsche Weg
01 void copy_n(const T* p, T* q, int n); // copy from [p:p+n) to [q:q+n) 02 [...] 03 int a[100]; 04 int b[100]; 05 copy_n(a, b, 101);
Das Programm in Listing 3 führt zu undefiniertem Verhalten, da »n« zu groß ist. Im Gegensatz dazu kennt ein Container der Standard Template Library wie »std::std::array« seine Größe und ist gegen die Fehler vollständig immun (Listing 4).
Listing 4
Besser kopieren mit C-Arrays
01 void copy(const std::array& p, std::array& q); // copy p to q 02 [...] 03 std::array<int> a; 04 std::array<int> b; 05 copy (a, b);
Wem gehört was?
Warum sollen Entwickler Besitzverhältnisse nicht mit einem einfachen Zeiger übertragen? Oder, um es provokativer zu formulieren: Welches Problem entsteht, wenn eine eindeutige Regel dazu fehlt, ob ein einfacher Zeiger wie in Listing 5 Besitzverhältnisse übertragen kann?
Listing 5
Übergabe der Argumente per Zeiger
01 double* ptr = new double[];
02
03 void needPointer(double* p){
04 [...]
05 }
06 needPointer(ptr);
Es geht im Kern darum, wer der Besitzer des Speichers ist. Derjenige, der eine Funktion aufruft, oder die Funktion selbst, die das Array mit Hilfe des Zeigers verwendet? Verwaltet die Funktion den Speicher, muss sie ihn auch freigeben. Falls nicht, darf sie keinesfalls aufräumen. Das ist unschön, denn räumt sie ihn nicht auf, entsteht ein Memory-Leck. Räumt sie ihn hingegen auf, obwohl sie nicht Besitzerin ist, führt das zu undefiniertem Verhalten.
Ein Entwickler muss somit eindeutig festlegen, wer der Besitzer einer Ressource ist. Daher trifft es sich gut, dass sich das mit modernem C++ sehr explizit über Interfaces ausdrücken lässt.
Listing 6 spielt fünf verschiedene Varianten für eine Funktion durch. Der Code ist dabei bewusst einfach gehalten. Welche Semantiken die unterschiedlichen Ausprägungen besitzen, zeigt die folgende Aufzählung:
Listing 6
Übergabe der Funktionsargumente
01 func(value); 02 func(pointer*); 03 func(reference&); 04 func(std::unique_ptr uniq); 05 func(std::shared_ptr shared);
- »func(value)«: Die Funktion »func()« besitzt eine unabhängige Kopie des Wertes und ist ihr Besitzer. Am Ende ihres Funktionskörpers räumt sie automatisch auf.
- »func(pointer*)«: Die Ressource ist hier lediglich geliehen. Da »func()« nicht die Besitzerin ist, darf sie die Ressource nicht löschen. Darüber hinaus muss sie vor jedem Einsatz des Zeigers prüfen, ob es sich um einen Nullzeiger handelt.
- »func(reference&)«: Auch diese Ressource ist nur geliehen. Da die Funktion »func()« die Ressource nicht besitzt, darf sie die Ressource nicht löschen. Diese besitzt im Gegensatz zum Zeiger immer einen Wert.
- »func(std::unique_ptr uniq)«: Die Funktion »func()« ist der neue Besitzer der Ressource. Der Aufrufer der Funktion hat die Besitzverhältnisse explizit auf die Funktion übertragen, sie räumt am Ende ihres Funktionskörpers automatisch auf.
- »func(std::shared_ptr shar)«: Hier ist »func()« ein zusätzlicher Besitzer der Ressource. Auf diesem Weg verlängert die Funktion die Lebenszeit der Ressource.
Die Deklaration einer Funktion ist ein Vertrag, er regelt die Besitzverhältnisse zwischen dem Aufrufer und dem Aufgerufenen. Das Vertragsverhältnis lässt sich in C++20 noch deutlich expliziter formulieren.
Verträgliches C++20
Gleich vier Regeln der C++ Core Guidelines beschäftigen sich mit Vor- und Nachbedingungen für eine Funktion (siehe Tabelle 1). Preconditions, Postconditions – aufmerksamen Lesern kommen diese Begrifflichkeiten möglicherweise sehr vertraut vor. Genau: Es geht um Design by Contract ([3], Abbildung 1).
|
Abschnitt |
Bedingung |
|---|---|
|
I.5 |
State Preconditions (if any) |
|
I.6 |
Prefer Expects() for expressing Preconditions |
|
I.7 |
State Postconditions |
|
I.8 |
Prefer Ensures() for expressing Postconditions |
Was ist ein Vertrag?
Ein Contract (Vertrag) ist ein prüfbares Interface für eine Funktion oder eine Methode. Er besteht aus Vorbedingungen (Preconditions), Nachbedingungen (Postconditions) und Zusicherungen (Assertions). Eine Vorbedingung ist ein Prädikat, das gelten muss, bevor der Code eine Komponente aufruft. Ein Prädikat ist eine Funktion, die einen Wahrheitswert zurückgibt. Gilt so eine Funktion nach dem Aufruf einer Komponente, handelt es sich um eine Nachbedingung. Eine Zusicherung ist ein Prädikat, das an der Stelle im Code gelten muss, an der das Prädikat zum Einsatz kommt.

Abbildung 1: Dieses Bild sagt mehr als tausend Worte: Design by Contract in einer schematischen Übersicht.
Vor- und Nachbedingungen platziert der Entwickler in C++20 außerhalb, Zusicherungen innerhalb der Funktionsdefinition. Zwar sind Zusicherungen eigentlich nicht Teil des Interface, sollen aber der Vollständigkeit halber Erwähnung finden.
Nach so viel Theorie folgt in Form von Listing 7 nun wieder ein Beispiel. Das Attribut »expects« ist eine Vorbedingung, das Attribut »ensures« eine Nachbedingung und das Attribut »assert« eine Zusicherung. Laut Vertrag ist die Funktion »push()« nicht voll, bevor sie ein Element erhält. Wurde ein Element ergänzt, ist sie nicht leer. Die Zusicherung »q.is_ok()« gilt an der Stelle im Funktionskörper, an der sie zum Einsatz kommt.
Listing 7
Design by Contract
01 int push(queue& q, int val)
02 [[ expects: !q .full() ]]
03 [[ ensures !q.empty() ]]{
04 [...]
05 [[ assert: q.is_ok() ]]
06 }
Vor- und Nachbedingungen sind Teil des Funktionsinterface. Sie dürfen nicht auf lokale Variablen oder private und geschützte Mitglieder einer Klasse zugreifen. Diese Einschränkung gilt nicht für die Zusicherungen.
Vertrag prüfen
Für jeden Vertrag lässt sich ein so genannter Modifier angeben. Zur Wahl stehen die Modifier »default«, »audit« und »axiom«. Im ersten Fall sind die Kosten, den Vertrag zur Laufzeit zu prüfen, gering. Das ist das Standardverhalten und erklärt auch den Namen. Im zweiten Fall, »audit«, dagegen sind die Kosten, den Kontrakt zur Laufzeit zu prüfen, hoch. Setzt der Entwickler dann im dritten Fall auf »axiom«, prüft C++ das Prädikat nicht zur Laufzeit.
Listing 8
Modifier des Vertrags
01 int mul(int x, int y)
02 [[expects: x > 0]] // implicit default
03 [[expects default: y > 0]]
04 [[ensures audit res: res > 0]]{
05 return x * y;
06 }
Das Beispiel in Listing 8 zeigt die Modifier »default« und »audit« in Aktion. Das Programm wendet den Vertrag zur Übersetzungszeit an. Dafür stehen drei Zusicherungsstufen bereit: »off«, »default« und »audit«. Entscheidet sich der Programmierer für die erste Variante, prüft die Anwendung keinerlei Contracts. Im zweiten Fall, dem Standard, prüft das Programm nur Standardverträge, im dritten Fall zusätzlich Auditverträge.
Zukunft geregelt
Das war ein simpler Einblick in Design by Contract, wie es der nächste C++20-Standard unterstützt. Die Details stehen im Proposal P0542R5 [4]. Das R5 im Namen steht für die fünfte Revision. Sind Verträge mit C++20 nun das nächste große Ding? Kurze Antwort: Ja. Die lange Antwort lieferte bereits Herb Sutter, der langjährige Vorsitzende des ISO-C++-Standardisierungskomitees: “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” [5].
Und wie weiter?
Dank der Guidelines Support Library (GSL) lassen sich Verträge bereits vor C++20 verwenden. Die GSL soll als kleine Bibliothek helfen, die C++ Core Guidelines umzusetzen. Sowohl GCC und Clang als auch MSVC bieten Implementierungen an.
Infos
-
Interfaces: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#S-interfaces
-
Scott Meyers: https://en.wikipedia.org/wiki/Scott_Meyers
-
Design by Contract: https://en.wikipedia.org/wiki/Design_by_contract
-
Proposal PO542: http://open-std.org/JTC1/SC22/WG21/docs/papers/2018/p0542r5.html
-
Herb Sutter über Contracts in C++: https://herbsutter.com/2018/07/02/trip-report-summer-iso-c-standards-meeting-rapperswil/






