C++ Core Guidelines: Semantik der Funktionsargumente und Rückgabewerte

Modernes C++  –  0 Kommentare

Heute schließe ich meinen Artikel über Funktionen in den C++ Core Guidelines ab. Der letzte Artikel hat die Syntax der Funktionsparameter und Rückgabewerte behandelt. In diesem geht es um deren Semantik.

Bevor ich in die Details dieses Artikels abtauche, hier ist in bekannter Manier erst einmal ein Überblick zu den semantischen Regeln für Parameter, Rückgabewerte und ein paar weitere Regeln rund um Funktionen.

Parameter passing semantic rules:

  • F.22: Use T* or owner<T*> to designate a single object
  • F.23: Use a not_null<T> to indicate "null" is not a valid value
  • F.24: Use a span<T> or a span_p<T> to designate a half-open sequence
  • F.25: Use a zstring or a not_null<zstring> to designate a C-style string
  • F.26: Use a unique_ptr<T> to transfer ownership where a pointer is needed
  • F.27: Use a shared_ptr<T> to share ownership

Value return semantic rules:

  • F.42: Return a T* to indicate a position (only)
  • F.43: Never (directly or indirectly) return a pointer or a reference to a local object
  • F.44: Return a T& when copy is undesirable and "returning no object" isn't an option
  • F.45: Don't return a T&&
  • F.46: int is the return type for main()
  • F.47: Return T& from assignment operators.

Other function rules:

  • F.50: Use a lambda when a function won’t do (to capture local variables, or to write a local function)
  • F.51: Where there is a choice, prefer default arguments over overloading
  • F.52: Prefer capturing by reference in lambdas that will be used locally, including passed to algorithms
  • F.53: Avoid capturing by reference in lambdas that will be used nonlocally, including returned, stored on the heap, or passed to another thread
  • F.54: If you capture this, capture all variables explicitly (no default capture)
  • F.55: Don't use va_arg arguments

Parameter passing semantic

Die Regeln dieses Abschnitts kann ich sehr kompakt abhandeln. Die meisten habe ich bereits im Artikel zu der Guidelines Support Library beschrieben. Wer daher neugierig bist, lese den zitierten Artikel. Ich werde nur kurz auf die erste Regel F.22 eingehen.

F.22: Use T* or owner<T*> to designate a single object

Was heißt, dass T* ein einzelnes Objekt bezeichnen soll? Die Regel beantwortet direkt die Frage. Zeiger können für viele Zwecken verwendet werden. Zeiger können die folgenden Rollen annehmen:

  1. Objekte, die von der Funktion nicht gelöscht werden dürfen
  2. Objekte, die auf dem Heap angelegt wurden und von der Funktion gelöscht werden müssen
  3. Nullzeiger (nullptr)
  4. C-Strings
  5. C-Arrays
  6. Positionen in C-Arrays

Angesichts dieser verschiedenen Rollen, die Zeiger annehmen können, sollte man sie nur für Objekte (1) verwenden, die nicht gelöscht werden dürfen.

Wie bereits angekündigt, werde ich die verbleibenden Regeln F.23 bis F.27 zu Funktionsparametern überspringen.

Value return semantic rules

F.42: Return a T* to indicate a position (only)

Das lässt sich noch besser auf den Punkt bringen. Man soll keine Zeiger verwenden, um Besitzverhältnisse auszudrücken. Das ist ein Missbrauch von Zeigern. Hier ist ein Beispiel:

Node* find(Node* t, const string& s)  // find s in a binary tree of Nodes
{
if (t == nullptr || t->name == s) return t;
if ((auto p = find(t->left, s))) return p;
if ((auto p = find(t->right, s))) return p;
return nullptr;
}

Die Guidelines sind in diesem Punkt sehr eindeutig. Man darf nicht ein Objekt aus einer Funktion zurückgeben, das sich nicht bereits im Bereich der aufrufenden Funktion befindet. Die nächste Regel adressiert genau diesen typischen Programmierfehler.

F.43: Never (directly or indirectly) return a pointer or a reference to a local object

Diese Regel ist sehr einleuchtend, lässt sich aber mit ein paar verschachtelten Funktionsaufrufen allzu leicht aushebeln. Das Unheil nimmt in dem folgenden Beispiel mit der Funktion f seinen Lauf. f gibt einen Zeiger auf ein lokales Objekt zurück.

int* f()
{
int fx = 9;
return &fx; // BAD
}

void g(int* p) // looks innocent enough
{
int gx;
cout << "*p == " << *p << '\n';
*p = 999;
cout << "gx == " << gx << '\n';
}

void h()
{
int* p = f();
int z = *p; // read from abandoned stack frame (bad)
g(p); // pass pointer to abandoned stack frame to function (bad)
}

F.44: Return a T& when copy is undesirable and "returning no object" isn't an option

Die C++-Sprache sichert zu, dass ein Referenz T& immer auf ein Objekt verweist. Daher muss der Aufrufer nicht auf einen Nullzeiger nullptr prüfen, da dies keine Option sein kann. Die Regel stellt kein Widerspruch zu vorherigen Regeln F.43 dar. F.43 besagt, dass man keine Referenz auf ein lokales Objekt zurückgeben soll.

F.45: Don't return a T&&

Mit T&& können Entwickler eine Referenz auf ein bereits zerstörtes Objekt zurückgeben. Das ist sehr bösartig, und sie sind sehr leicht mitten im undefinierten Verhalten (F.43).

Falls der Aufruf f() eine Kopie zurückgibt, erhält man eine Kopie auf ein temporäres Objekt.

template<class F>
auto&& wrapper(F f)
{
...
return f();
}

Die einzigen Ausnahmen zu dieser Regel sind die Funktionen std::move für Move-Semantik und std::forward für Perfect Forwarding.

F.46: int is the return type for main()

Standard C++ kennt zwei Arten, die main-Funktion zu deklarieren. void ist keine Option in C++ und schränkt daher die Portabilität des Codes ein.

int main();                       // C++
int main(int argc, char* argv[]); // C++
void main(); // bad, not C++

Die zweite Form ist äquivalent zu int main(int argc, char** argv).

Die main-Funktion gibt automatisch return 0 zurück, falls eine main-Funktion keine return-Anweisung besitzt.

F.47: Return T& from assignment operators

Der Copy-Zuweisungsoperator sollte T& zurückgeben. In diesem Fall ist er konsistent mit den Containern der Standard Template Library und folgt dem bewährten Prinzip: "do as the ints do".

Es besteht ein feiner Unterschied, ob ein Copy-Zuweisungsoperator sein Ergebnis mittels Referenz oder Copy zurückgibt.

  1. A& operator=(constA& rhs){ ... };
  2. A operator=(constA& rhs){ ... };

Im zweiten Fall führt die Kette von Zuweisungen zu zwei zusätzlichen Copy-Konstruktor und Destruktor Aufrufen.

Other function rules:

F.50: Use a lambda when a function won't do (to capture local variables, or to write a local function)

In C++11 gibt es aufrufbare Einheiten wie Funktionen, Funktionsobjekte und Lambda-Funktionen. Häufig taucht die Frage auf: Wann soll ich eine Funktion oder eine Lambda-Funktion verwenden? Hier sind zwei einfache Faustregeln.

  1. Falls eine aufrufbare Einheit lokale Variablen verwendet oder in einem lokalen Bereich verwendet wird, sollte man eine Lambda-Funktion einsetzen.
  2. Falls man eine aufrufbare Einheit überladen will, setzt man eine Funktion ein.
void print(const string& s, format f = {});

versus

void print(const string& s);  // use default format
void print(const string& s, format f);

F.51: Where there is a choice, prefer default arguments over overloading

Falls man eine Funktion mit einer variablen Anzahl an Argumenten aufrufen muss, zieht man Default-Argumente dem Überladen der Funktion vor. Damit setzt man automatisch das DRY-Prinzip um (don't repeat yourself).

F.52: Prefer capturing by reference in lambdas that will be used locally, including passed to algorithms

Aus Performanz- und Korrektheitsgründen werden Entwickler meist ihre Variablen in Lambda-Funktionen per Referenz verwenden. Aus Effizienzgründen heißt dies entsprechend der Regel F.16, falls für ihre Variable p gilt: sizeof(p) > 4*sizeof(int).

Da man eine Lambda-Funktion lokal verwendest, bekommt man auch keine Lebenszeitproblem mit einer verwendeten Variable message.

std::for_each(begin(sockets), end(sockets), [&message](auto& socket)
{
socket.send(message);
});

F.53: Avoid capturing by reference in lambdas that will be used nonlocally, including returned, stored on the heap, or passed to another thread

Man muss sehr vorsichtig sein, wenn man einen Thread im Hintergrund laufen lässt (detach). Der kleine Codeschnipsel besitzt bereits zwei Race Condition.

std::string s{"undefined behaviour"};
std::thread t([&]{std::cout << s << std::endl;});
t.detach();
  1. Der erzeugte Thread t kann länger leben als sein Erzeuger. Daher existiert std::string eventuell nicht mehr.
  2. Der erzeugte Thread t möchte länger leben als sein Erzeuger. Daher existiert std::cout eventuell nicht mehr.

F.54: If you capture this, capture all variables explicitly (no default capture)

Es scheint, als ob man mit [=] alle Argumente per Copy bindest. Tatsächlich bindet man aber in einem Objekt damit alle Mitglieder per Referenz. Das kann, muss aber nicht die Intention sein.

class My_class {
int x = 0;

void f() {
auto lambda = [=]{ std::cout << x; }; // bad
x = 42;
lambda(); // 42
x = 43;
lambda(); // 43
}
};

Die Lambda-Funktion bindet x per Referenz.

F.55: Don’t use va_arg arguments

Falls eine Funktion eine beliebige Anzahl an Argumenten annehmen soll, setzt man Variadic Templates ein. Im Gegensatz zu va_args kann der Compiler bei diesen automatischen den richtigen Typ bestimmen. Mit C++17 lässt sich auf diese beliebige Anzahl an Argumente direkt ein Operator anwenden.

template<class ...Args>
auto sum(Args... args) { // GOOD, and much more flexible
return (... + args); // note: C++17 "fold expression"
}

sum(3, 2); // ok: 5
sum(3.14159, 2.71828); // ok: ~5.85987

Falls der Codeschnipsel für ungewohnt ausschaut, hier geht es zu meinem Artikel zu Fold Expressions.

Wie geht's weiter?

Klassen sind benutzerdefinierte Typen. Sie erlauben es, Zustand und Verhalten zu kapseln. Dank Klassenhierarchien kannst man Typen organisieren. Daher geht es in dem nächsten Artikel um Regeln für Klassen und Klassenhierarchien.