Aus Linux-Magazin 06/2019

Modernes C++ in der Praxis – Folge 46

© Gleb TV, 123RF

Funktionen dienen dem Entwickler als Bausteine seines Programms. Doch nicht alle Funktionen sind gleich, beispielsweise gibt es reine und nicht-reine. Was Entwickler mit reinen Funktionen anfangen und wie sie Funktionen in gut verdaulichen Häppchen arrangieren, das verraten die C++ Core Guidelines.

Entwickler meistern Komplexität, indem sie massive Aufgaben zuerst in kleine Teilaufgaben zerlegen und anschließend zu einem großen Ganzen zusammenfügen. Typische Einheit für eine Teilaufgabe ist die Funktion.

In den C++ Core Guidelines finden sich ungefähr 40 Regeln zu Funktionen [1]. Zu viele, um sie in einem einzigen Artikel abzuhandeln, dachte der Autor zunächst. Doch Not macht erfinderisch. Der Artikel stellt die Regeln daher aus der Vogelperspektive dar.

Namen sind die für Programmierer wohl wichtigsten Komponenten, wenn es um gut gemachten Quellcode geht. Das trifft natürlich auch explizit auf die Funktionsnamen zu. Anekdote gefällig?

Vor ein paar Jahren fragte mich ein Entwickler: “Wie soll ich meine Funktion am besten nennen?” Ich empfahl ihm, »verbObjekt()« als Notation zu verwenden. Dabei steht »verb« für eine Aktion, die die Funktion auf dem »Objekt« ausführt. Der Entwickler erwiderte, das sei nicht möglich. Seine Funktion müsse in diesem Fall »getTimeAndModifyPhonebook()« oder einfach »processData()« heißen, weil diese Funktion mehr als eine Aufgabe ausführe.

Damit lieferte er ein schönes Beispiel für einen klaren Fall für ein Refaktoring. Eine Funktion sollte genau eine Sache erledigen – nicht zuletzt, weil es dann auch mit dem Namen klappt.

Reine Funktionen

Reine Funktionen sind Funktionen, die immer das gleiche Ergebnis zurückgeben, wenn sie die gleichen Argumente verarbeiten. Diese Eigenschaft heißt auch referenzielle Transparenz [2]. Nicht-reine Funktionen sind im Umkehrschluss Funktionen wie »random()« oder »time()«, die nicht immer das gleiche Ergebnis zurückliefern, obwohl sie die gleichen Argumente erhalten. Oder, etwas abstrakter formuliert, es handelt sich um Funktionen, die den Zustand (State) außerhalb ihres Funktionskörpers modifizieren (siehe auch Tabelle 1).

Tabelle 1

Reine und unreine Funktionen

Reine Funktionen

Unreine Funktionen

Geben immer das gleiche Ergebnis zurück, wenn sie mit den gleichen Argumenten aufgerufen wurden

Können verschiedene Ergebnisse zurückgeben, wenn sie mit den gleichen Argumenten aufgerufen wurden

Besitzen keine Seiteneffekte

Können Seiteneffekte besitzen

Verändern nicht den globalen Zustand des Programms

Können den globalen Zustand des Programms verändern

Reine Funktionen besitzen ein paar sehr interessante Charakteristiken. Entwickler sollten sie daher den nicht-reinen Funktionen vorziehen, vorausgesetzt dies ist möglich und sinnvoll. Diese Charakteristiken oder Eigenschaften ziehen sehr weitreichende Konsequenzen nach sich. Dank ihnen betrachtet ein Entwickler eine solche Funktion in Isolation. Er kann dann zum Beispiel

  • die Korrektheit der Funktion einfacher verifizieren,
  • die Funktion mit weniger Aufwand refaktorieren und testen,
  • das Ergebnis des Funktionsaufrufs zwischenspeichern,
  • reine Funktionen umsortieren oder in anderen Threads ausführen.

Programmierer bezeichnen reine Funktionen auch gern als mathematische Funktionen. Standardmäßig besitzt C++, anders als etwa die rein funktionale Programmiersprache Haskell, keine reinen Funktionen. Die »constexpr()«-Funktionen [3] gelten immerhin als nahezu rein. Dennoch basiert die Reinheit in C++ am Ende vor allem auf der Disziplin der Entwickler.

Der Vollständigkeit halber sei an dieser Stelle noch die Template-Metaprogrammierung erwähnt. Dabei handelt es sich um eine rein funktionale Subsprache, eingebettet in die imperative Sprache C++. Wer neugierig ist, findet unter [4] weitere Informationen dazu.

Eingabe

Ist eine Funktion rein, kommuniziert sie mit der Außenwelt nur über ihre Ein- und Ausgabeparameter. Listing 1 zeigt mehrere Varianten der Argument-Übergabe. Welche Bedeutung sie besitzen, ist schnell erklärt. Wer zusätzliche Details benötigt, liest am besten den Artikel “Gut verträglich” [5]. Hier folgen nur die wichtigsten Fakten.

Listing 1

Übergabe der Funktionsargumente

01 func(value);
02 func(pointer*);
03 func(reference&);
04 func(std::unique_ptr uniq);
05 func(std::shared_ptr shared);

Im Listing steht »func()« dabei für den Prototyp einer Funktion. Die Frage lautet: Welche Semantik besitzen die verschiedenen Varianten?

  • »func(value)«: »func()« hütet eine unabhängige Kopie des Wertes und ist ihr Besitzer. Am Ende ihres Funktionskörpers räumt sie automatisch auf.
  • »func(pointer*)«: Der Zeiger »pointer« ist nur geliehen. »func()« ist nicht der Besitzer und darf die Ressource daher auch nicht löschen. Zusätzlich muss sie vor jedem Einsatz des Zeigers prüfen, ob es sich bei ihm um einen Nullzeiger handelt.
  • »func(reference&)«: Die Referenz »reference« ist nur geliehen. »func()« ist nicht der Besitzer des Zeigers, darf die Referenz also ebenfalls nicht löschen. Im Gegensatz zum Zeiger sichert die Referenz jedoch zu, dass sie stets einen Wert besitzt.
  • »func(std::unique_ptr uniq)«: »func()« ist der neue Besitzer des Zeigers. Der Aufrufer der Funktion hat die Besitzverhältnisse explizit auf diese Funktion übertragen, sie räumt daher am Ende ihres Funktionskörpers automatisch auf.
  • »func(std::shared_ptr shar)«: »func()« ist ein zusätzlicher Besitzer des Zeigers. Damit verlängert »func()« die Lebenszeit des Zeigers und der ihm zugrunde liegenden Ressource.

Ausgabe

Erzeugt eine Funktion einen Wert und will sie ihn zurückgeben, ist dies denkbar einfach. Ein simples Kopieren erledigt den Job. Die Frage lautet allerdings, ob die Funktion dabei sparsam mit Storage und Compute umgeht. Die einfache Antwort lautet “Ja”.

RVO steht für Return Value Optimisation und bedeutet, dass der Compiler unnötige Copy-Operationen entfernen darf. Was bisher ein Optimierungsschritt war, muss der Compiler in C++17 zusichern. Genau dies zeigt Listing 2. Die paar Zeilen beinhalten gleich zwei unnötige Copy-Operationen, die erste in Zeile 2, die zweite in Zeile 4. Mit C++17 darf das nicht mehr passieren.

Listing 2

Return Value Optimisation (RVO)

01 MyType func(){
02     return MyType{};       // No copy with C++17
03 }
04 MyType myType = func();    // No copy with C++17

Besitzt der Rückgabewert einen Namen, heißt dieses Konstrukt NRVO. Das Akronym steht für Named Return Value Optimisation, Listing 3 zeigt die Optimierung in Aktion. Der feine Unterschied besteht darin, dass der Compiler mit C++17 den Wert »myValue« einmal kopieren darf. Aber in Zeile 5 findet definitiv kein Kopieren statt.

Listing 3

Named Return Value Optimisation (NRVO)

01 MyType func(){
02     MyType myVal;
03     return myVal;            // One copy allowed
04 }
05 MyType myType = func();      // No copy with C++17

C++ wäre nicht C++, wenn das bereits die ganze Geschichte zur Ein- und Ausgabe von Funktionen wäre. Es gibt auch Parameter, die sowohl Ein- als auch Ausgabewerte sind. So erhält der Vektor in Listing 4 über die Funktion »appendElements()« neue Elemente. Konsequent nimmt die Funktion »appendElements()« ihren Vektor per Referenz entgegen.

Listing 4

Ein- und Ausgabe-Parameter

01 void appendElements(std::vector<int>& vec){
02   // append elements to vec
03   [...]
04 }

Verweise auf lokale Variablen

Gibt eine Funktion einen Zeiger oder eine Referenz auf eine lokale Variable zurück, gilt dies als undefiniertes Verhalten. Das bedeutet, der Entwickler kann keine verlässliche Aussage mehr über das Verhalten des Programms treffen. Das umfasst nicht nur den ausgeführten Code nach dem definierten Verhalten, sondern auch den Code davor.

Für undefiniertes Verhalten verwendet die C++-Community den Ausdruck Catch-fire-Semantik. Das heißt, alles Mögliche kann passieren. Ein Beispiel dafür liefert Listing 5. Die »main()«-Schleife ruft die Funktion »makeLambda()« (Zeilen 5 bis 8) auf. Die Funktion retourniert eine Lambda-Funktion, die eine Referenz auf die lokale Variable »val&« in Zeile 7 besitzt. Der zugehörige Rückgabetyp der Funktion »makeLambda()« ist dann »std::function< std::string()>«.

Listing 5

Undefiniertes Verhalten

01 #include <functional>
02 #include <iostream>
03 #include <string>
04
05 std::function<std::string()> makeLambda() {
06   const std::string val = "on stack created";
07   return [&val]{ return val; };
08 }
09
10 int main(){
11
12   auto bad = makeLambda();
13   std::cout << bad();
14
15 }

Bei »std::function« handelt es sich um einen polymorphen Funktions-Wrapper. Das bedeutet, dass dieser alles annehmen kann, was seiner Signatur »std::string()« genügt. Im gezeigten Fall in Listing 5 signalisiert die Signatur, dass die Funktion kein Argument annehmen darf und einen »std::string« zurückgeben muss. Diesem impliziten Wunsch entspricht die Lambda-Funktion.

In Zeile 13 nimmt das Unglück seinen Lauf, denn diese führt die Lambda-Funktion »bad()« aus. In der steckt allerdings eine Referenz auf die lokale Variable »val«. Die wiederum hängt von der Lebenszeit des Gültigkeitsbereichs der Funktion »makeLambda()« ab und wird entsprechend genau in der Zeile 8 ungültig.

Fast jeder Compiler und alle Compilerversionen von GCC, Clang oder MSVC produzieren ein anderes Verhalten, wenn sie das Programm ausführen. Natürlich hängt das Verhalten auch von der eingesetzten Optimierung und der aktuellen Momd-Phase ab. Undefiniertes Verhalten hat viele Gesichter.

Abbildung 1: Eine zuf&auml;llige Textausgabe ist nur eines der m&ouml;glichen Resultate eines Programms mit undefiniertem Verhalten.

Abbildung 1: Eine zufällige Textausgabe ist nur eines der möglichen Resultate eines Programms mit undefiniertem Verhalten.

Zum Beispiel kann das Programm nur eine vermeintlich richtige Ausgabe (»on stack created«) generieren. Oder das Programm erzeugt gar keine Ausgabe. Im Test lieferte es an einer Stelle auch lediglich ein »H« zurück, an einer weiteren nur den String »created«. Abbildung 1 zeigt als mögliches Resultat einen zufälligen Text. Einmal brach das Programm mit einem Segmentation Fault ab, ein anderes Mal mit einem Coredump (Abbildung 2).

Abbildung 2: Auch ein Coredump kann die &uuml;berraschende Folge von undefiniertem Verhalten sein.

Abbildung 2: Auch ein Coredump kann die überraschende Folge von undefiniertem Verhalten sein.

Und die Moral von der Geschichte lautet: Steckt undefiniertes Verhalten in einem Programm, erübrigt sich jeder weitere Versuch einer Analyse desselben. Der Entwickler muss das undefinierte Verhalten zunächst beheben.

Wie geht’s weiter?

C++ ist insbesondere eine objektorientierte Programmiersprache. Daher wundert es auch nicht, dass die C++ Core Guidelines mehr als 100 Regeln zu Klassen und Klassenhierarchien enthalten. Der nächste Artikel stellt die wichtigsten davon vor.

Infos

  1. Funktionen in den Core Guidelines: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#S-functions

  2. Referenzielle Transparenz: https://de.wikipedia.org/wiki/Referenzielle_Transparenz

  3. »constexpr«-Funktionen: Rainer Grimm, “Konstante Magie”: Linux-Magazin 06/15, http://www.linux-magazin.de/ausgaben/2015/06/c-11/

  4. Rainer Grimm, “Magischer Mechanismus”: Linux-Magazin 01/11, S. 108,http://www.linux-magazin.de/ausgaben/2011/01/c/?category=0

  5. Rainer Grimm, “Gut verträglich”: Linux-Magazin 02/19, S. 86

Der Autor

Rainer Grimm ist Trainer für C++ und Python. Seine zahlreichen C++-Bücher, zuletzt “The C++ Standard Library” und “Concurrency with modern C++”, sind bei O’Reilly und Leanpub erschienen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 3 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben