C++ Core Guidelines: Interfaces I

Modernes C++  –  36 Kommentare

Interfaces sind ein Vertrag zwischen dem Serviceanbieter und dem Servicenutzer. Die C++ Core Guidelines stellen 20 Regeln für diesen Vertrag auf, denn "interfaces is probably the most important single aspect of code organization".

Bevor ich die Regeln vorstelle, gibt es hier einen kompakten Überblick.

  • I.1: Make interfaces explicit
  • I.2: Avoid global variables
  • I.3: Avoid singletons
  • I.4: Make interfaces precisely and strongly typed
  • I.5: State preconditions (if any)
  • I.6: Prefer Expects() for expressing preconditions
  • I.7: State postconditions
  • I.8: Prefer Ensures() for expressing postconditions
  • I.9: If an interface is a template, document its parameters using concepts
  • I.10: Use exceptions to signal a failure to perform a required task
  • I.11: Never transfer ownership by a raw pointer (T*)
  • I.12: Declare a pointer that must not be null as not_null
  • I.13: Do not pass an array as a single pointer
  • I.22: Avoid complex initialization of global objects
  • I.23: Keep the number of function arguments low
  • I.24: Avoid adjacent unrelated parameters of the same type
  • I.25: Prefer abstract classes as interfaces to class hierarchies
  • I.26: If you want a cross-compiler ABI, use a C-style subset
  • I.27: For stable library ABI, consider the Pimpl idiom
  • I.30: Encapsulate rule violations

Ich kann die Regeln nicht im Detail vorstellen. Dafür sind es zu viele. Daher werde ich in diesem Artikel über die ersten zehn schreiben und mich im nächsten den verbleibenden zehn widmen. Los geht's.

I.1: Make interfaces explicit

In dieser Regel geht es um Korrektheit. Das bedeutet, dass Annahmen über die Funktionalität einer Funktion im Interface ausgedrückt werden sollen. Falls das nicht geschieht, können diese Annahmen leicht übersehen werden und der Code ist schwierig zu testen.

int round(double d)
{
return (round_up) ? ceil(d) : d; // don't: "invisible" dependency
}

Zum Beispiel drückt die Funktion round nicht aus, dass ihr Ergebnis von der globalen Variable round_up abhängt.

I.2: Avoid global variables

Die Regel ist natürlich offensichtlich, aber sie spricht explizit von veränderlichen globalen Variablen. Globale Konstanten sind unproblematisch, da sie keine Abhängigkeiten in eine Funktion injizieren und keine Race Conditions verursachen können.

I.3: Avoid singletons

Singletons sind verkleidete, globale Objekte. Daher solltest du sie vermeiden.

I.4: Make interfaces precisely and strongly typed

Die Begründung der Regel ist sehr überzeugend: Datentypen sind die einfachste und expliziteste Dokumentation, besitzen eine wohldefinierte Semantik und werden durch den Compiler automatisch geprüft.

Hier ist ein Beispiel:

void draw_rect(int, int, int, int);   // great opportunities for mistakes
draw_rect(p.x, p.y, 10, 20); // what does 10, 20 mean?

void draw_rectangle(Point top_left, Point bottom_right);
void draw_rectangle(Point top_left, Size height_width);

draw_rectangle(p, Point{10, 20}); // two corners
draw_rectangle(p, Size{10, 20}); // one corner and a (height, width) pair

Wie leicht kann es passieren, die Funktion draw_rect falsch zu verwenden? Vergleiche die Funktion mit der Funktion draw_rectangle. Der Compiler sichert zu, dass diese nur mit Point- oder Size-Objekten verwendet werden kann.

Du solltest daher in deinem Prozess der Codesäuberung nach Funktionen Ausschau halten, die viele eingebaute Datentypen als Argument verwenden; oder noch schlimmer, die den Datentyp void* als Argument einsetzen.

I.5: State preconditions (if any)

Wenn möglich, solltest du Vorbedingungen an deine Funktion, wie x darf in double sqrt(double x) nicht negativ sein, als Zusicherungen formulieren.

Dank der Funktion Expects() der Guideline support library (GSL) kannst du deine Vorbedingungen direkt ausdrücken.

double sqrt(double x) { Expects(x >= 0); /* ... */ }

Contracts, bestehend aus Vorbedingungen, Nachbedingungen und Zusicherungen, sind eines der Feature, auf das wir in C++20 hoffen können. Hier ist das offizielle Proposal: p03801.pdf.

I.6: Prefer Expects() for expressing preconditions

Diese Regel ist der vorherigen relativ ähnlich, legt aber ihren Fokus auf einen anderen Aspekt. Du sollst Expects() und nicht zum Beispiel if-Anweisungen, Kommentare oder assert()-Anweisungen verwenden, um die Vorbedingungen an deinen Code zu stellen.

int area(int height, int width)
{
Expects(height > 0 && width > 0); // good
if (height <= 0 || width <= 0) my_error(); // obscure
// ...
}

Der Expects() Ausdruck ist einfach zu identifizieren und wird sich aller Voraussicht nach mit C++20 prüfen lassen.

I.7: State postconditions, I.8: Prefer Ensures() for expressing postconditions

Entsprechend der Argumente einer Funktion solltest du dir Gedanken zu ihrem Rückgabewert machen. Die Regeln zu den Nachbedingungen ähneln denen der gerade beschriebenen Vorbedingungen.

I.9: If an interface is a template, document its parameters using concepts

Mit hoher Wahrscheinlichkeit werden wir mit C++20 Concepts bekommen. Concepts sind Prädikate für Template-Parameter, die sich zur Compile-Zeit auswerten lassen. So kann ein Concept die Menge der Argumente reduzieren, die sich für einen Template-Parameter verwenden lassen. Ich habe bereits vier Artikel zu Concepts geschrieben, da sie noch deutlich mehr zu bieten haben.

Die Regel zu Concepts ist sehr einleuchtend: Du sollst sie einfach verwenden.

template<typename Iter, typename Val>
requires InputIterator<Iter> && EqualityComparable<ValueType<Iter>>, Val>
Iter find(Iter first, Iter last, Val v)
{
// ...
}

Der generische find Algorithmus fordert, das sein Template-Parameter Iter ein InputIterator und sein zugrundeliegender Wert EqualityComparable ist. Falls du find mit einem Template-Argument aufrufst, dass diese Bedingungen nicht erfüllt, erhältst du eine lesbare und einfach verständliche Fehlermeldung.

I.10: Use exceptions to signal a failure to perform a required task

Hier kommt die Begründung: "It should not be possible to ignore an error because that could leave the system or a computation in an undefined (or unexpected) state."

Die Regel bietet ein schlechtes und ein gutes Beispiel für den zitierten "Undefined (or unexpecead) State" an.

int printf(const char* ...);    // bad: return negative number if output fails

template <class F, class ...Args>
// good: throw system_error if unable to start the new thread
explicit thread(F&& f, Args&&... args);

Im schlechten Anwendungsfall kannst du die Ausnahme ignorieren, wodurch dein Programm ein undefiniertes Verhalten aufweist.

Falls du keine Ausnahmen anwenden kannst, solltest du Wertpaare zurückgeben. Dank dem C++17-Feature Structured Binding kannst du diese Regel elegant umsetzen.

auto [val, error_code] = do_something();
if (error_code == 0) {
// ... handle the error or exit ...
}
// ... use val ...

Wie geht's weiter?

Im nächsten Artikel schaue ich mir die verbleibenden Regeln zu Zeigern, der Initialisierung von globalen Variablen, Funktionsparametern und der ABI (Application Binary Interface) genauer an. In modernem C++ gibt es viel zum guten Interface-Design zu lernen.