Experience Embedded

Professionelle Schulungen, Beratung und Projektunterstützung

10 kleine Dinge, die C++ einfacher machen

Autor: Dominik Berner, bbv Software Services

Beitrag - Embedded Software Engineering Kongress 2018

 

Die neuen Standards haben die Programmiersprache C++ merklich modernisiert und teilweise ganz neue Programmierparadigmen in die Welt von C++ eingebracht.

Die "großen" Änderungen, wie Variadic Templates, auto, Move-Semantik, Lambda-Ausdrücke und weitere, haben für viel Diskussionsstoff gesorgt und sind dementsprechend weit herum bekannt. Nebst den Sprachfeatures hat auch die Standard-Bibliothek eine merkliche Erweiterung erfahren, und viele Konzepte aus Bibliotheken wie boost wurden so standardisiert. Nebst diesen sehr spürbaren (und teilweise auch umstrittenen) Features gibt es eine ganze Menge an kleinen, aber feinen Spracherweiterungen, die oft weniger bekannt sind oder übersehen werden.

Gerade weil diese Features oft sehr klein und teilweise fast unsichtbar sind, haben sie großes Potential, um im Programmiereralltag das Leben einfacher zu machen und Code ohne schwerwiegende Eingriffe sanft zu modernisieren. Es ist oft so, dass man bei der Arbeit mit bestehendem Code nicht die Möglichkeit hat, große strukturelle oder von außen sichtbare Änderungen vorzunehmen, aber genau hier können die "kleinen Features" helfen, Code aktuell und wartbar zu halten.

Moderner, wartbarer Code

Wartbarkeit, Lesbarkeit und Code-Qualität sind Themen die aus der heutigen Software-Entwicklung nicht mehr wegzudenken sind. Der Vorteil von Software gegenüber Hardware ist, das sie sich relativ leicht anpassen und überarbeiten lässt und insbesondere dort, wo agil gearbeitet wird, geschieht das oft sehr bewusst und immer wieder. Durch diese Volatilität werden gewinnen diese Qualitätsmerkmale noch mehr an Gewicht, denn schlechter Code macht den Vorteil der einfachen Bearbeitung mehr als zunichte. Dinge wie Clean Code, das SOLID-Prinzip oder Paradigmen wie Low Coupling, Strong Cohesion sind wichtige Aspekte von Codequalität, aber Qualität beginnt bereits bei der Verwendung der Sprache selbst. Das Verwenden der zur Verfügung gestellten Sprachfeatures und -funktionen hilft, die Absicht hinter dem geschriebenen Code zu verdeutlichen, und erleichtert oft auch das automatische Verifizieren dieser Absichten. Zudem kann oft dadurch die Menge von geschriebenem Code reduziert werden, was dem Prinzip von "Less code means less bugs" in die Hände spielt.

Ein Beispiel zur Illustration

Ein einfacher Algorithmus kann sehr kompliziert zu verstehen sein, wenn die Schreibweise nicht den Erwartungen entsprechen oder der Autor sich einen besonders schlauen Hack zur Optimierung einfallen ließ. So kann zum Beispiel das Tauschen von zwei Variablen x und y wie folgt geschrieben werden (s. Abbildung im PDF).

Dieser XOR-Swap ist zwar speichereffizient und hat in ganz spezifischen Fällen seine Daseinsberechtigung, aber intuitiv lesbar ist die Operation nicht. Selbst mit einem Code-Kommentar versehen zwingt dieses einfache Beispiel dem Leser unnötige Denkarbeit auf. Dem gegenübergestellt liest sich das folgende Beispiel viel einfacher (s. Abbildung im PDF).

Die folgenden 10 kleinen Features und Erweiterungen hauptsächlich aus den modernen C++-Standards helfen, Code kompakt und lesbar zu halten und somit die Code-Qualität zu verbessern.

Vererbung kontrollieren mit override und final

Vererbung ist für viele Programmierer Fluch und Segen gleichermaßen. Einerseits hilft sie oft, Code-Duplizierung zu vermeiden, andererseits gibt es dabei - insbesondere in C++ - viele Stolpersteine, die beachtet werden müssen. Gerade bei Refactorings an Basisklassen kommt es immer wieder vor, dass die abhängigen Klassen vergessen werden und man dies erst zur Laufzeit merkt. Das Schlüsselwort override schafft hier seit C++11 Abhilfe. Wann immer eine Funktion in einem Vererbungsbaum überschrieben wird, sollte override verwendet werden. Damit wird eine überschriebene Funktion automatisch virtuell, und der Compiler erhält die Möglichkeit zu überprüfen, ob auch tatsächlich eine Methode überschrieben wird und ob die überschriebene Methode auch tatsächlich virtuell ist. (s. Abbildung im PDF)

Noch mehr Kontrolle über den Vererbungsbaum erhält man, wenn man die Vererbung ab einem gewissen Punkt komplett unterbinden kann. Der Spezifikator final zeigt an, dass eine Klasse oder virtuelle Funktion nicht weiter überschrieben werden kann. Dies verringert zwar den Schreibaufwand nicht, aber kommuniziert ganz klar eine Absicht hinter einen Stück Code, nämlich dass keine weitere Vererbung erwünscht ist. Hier hilft sogar der Compiler mit indem die Kompilierung fehlschlägt, sollte man dies doch versuchen. (s. Abbildung im PDF)

using-Deklarationen und Konstruktorenvererbung

Code zu duplizieren ist dem Programmierer ein Graus, selbst wenn es sich hier um generierten Code handelt. using-Deklarationen erlauben es dem Programmierer, ein Symbol von einer deklarativen Region, wie Namensräume, Klassen und Strukturen in einen anderen zu "importieren", ohne dass zusätzlicher Code generiert wird. Bei Klassen ist dies vor allem nützlich, um Konstruktoren von Basisklassen direkt zu übernehmen, ohne dass alle Varianten neu geschrieben werden müssen. Ein weiteres Beispiel ist um kovariante Implementierungen in abgeleiteten Klassen explizit zu gestalten. Damit wird dem Leser klar signalisiert, dass hier eine "fremde" Implementation verwendet wird, die keine funktionale Modifikation erfahren hat. (s. Abbildung im PDF

Für Klassen und Strukturen funktioniert das schon länger; seit C++17 funktioniert das übernehmen von Symbolen auch für (verschachtelte) Namensräume: (s. Abbildung im PDF)

Weiterleiten von Konstruktoren

Andere High-Level-Programmiersprachen kennen das "Verketten" von Konstruktoren schon länger, und seit C++11 ist dies auch in endlich C++ möglich. Die Vorteile von weniger dupliziertem Code und damit einfacherer Lesbarkeit und somit bessere Wartbarkeit liegen dabei auf der Hand. Gerade bei Konstruktoren, die intern komplizierte Initialisierungen und/oder Checks durchführen, hilft dies sehr und fördert die Umsetzung des RAII (Resource Allocation is Initialisation) Paradigmas. (s. Abbildung im PDF

Im Zusammenhang mit der Verwendung der oben genannten Konstruktorenvererbung mit using lässt sich Code so noch weiter komprimieren. (s. Abbildung im PDF)

= delete - Löschen von Funktionen

Weniger Code heißt weniger Bugs, auch bei generiertem Code. Also erleichtern wir dem Compiler doch die Arbeit, Code zu generieren, den wir gar nicht wollen und brauchen. Das Keyword delete für Funktionsdeklaration - nicht zu verwechseln mit dem entsprechenden Ausdruck, um Objekte zu Löschen - ist eine weitere sehr starke Erweiterung in C++11, mit der ein Programmierer eine Absicht nicht nur signalisieren, sondern auch vom Compiler durchsetzen lassen kann. Mit der Verwendung von = delete kann explizit sichergestellt werden, das gewisse Operationen, wie zum Beispiel Kopieren eines Objektes, nicht vorgesehen und möglich sind. Natürlich sollte die "Rule of Five" auch beim Löschen von Funktionen beachtet werden. (s. Abbildung im PDF)

Garantiertes verhindern von Kopien

Die garantiere Verhinderung von Kopien (engl. guaranteed copy elision) ist für den Programmierer meist unsichtbar, aber dahinter verbirgt sich großes Potential für kleineren und saubereren Code. Diese Tilgung verhindert, dass unnötige Kopien von temporären Objekten erstellt werden, wenn sie unmittelbar nach dem Erstellen einem neuen Symbol zugewiesen werden. Einige Compiler wie gcc unterstützen dies zwar schon länger, aber mit C++17 wurde das Auslassen von Kopien als garantiertes Verhalten in den Standard aufgenommen. Nebst dem Effekt, dass so weniger Code generiert wird, lässt sie den Programmierer seine Absicht, dass ein Objekt nicht kopiert oder verschoben werden darf, mit noch größerer Konsequenz umsetzen. Unter Verwendung des oben genannten = delete lässt sich dies sehr deutlich ausdrücken. (s. Abbildung im PDF)

Structured Bindings

Klassen und Strukturen sind nicht die einzige Möglichkeit, um das Handling von Daten zu strukturieren. Die Standardbibliothek stellt zudem eine ganze Menge Datencontainer für genau diese Zwecke zur Verfügung. Mit std::tuple und std::array wurden in C++11 zwei Datenstrukturen mit zur Compile-Time bekannter Größe eingeführt. Während std::array eine relativ simple Modernisierung von C-Arrays darstellt, wurde mit std::tuple eine generische Möglichkeit geschaffen, um heterogene Daten bequem im Programm herumzureichen, ohne dass der Programmiere reine Datenklassen oder structs erstellen muss.

Seit C++17 ist der Zugriff auf die Inhalte dieser Datenstrukturen durch die strukturierten Bindings sehr leichtgewichtig möglich (s. Abbildung im PDF).

Zu beachten ist, dass alle Variablen hier dieselbe const-ness haben und entweder alle als Referenz oder By-Value gelesen werden. Die Structured Bindings funktionieren auch im Zusammenhang mit Klassen, allerdings ist dies etwas problematisch, da die Semantik von Klassenmembers keine starke Reihenfolge der Member vorsieht. Es gibt Möglichkeiten, diese Semantik zu reimplementieren, allerdings ist dies vergleichsweise aufwändig.

Stark typisierte Enums

Einer der wohl am häufigsten verwendeten Möglichkeiten, für eigene Datentypen mit klaren Wertebereichen zu erstellen, waren schon in C die enums, und auch heute werden sie noch oft und gerne verwendet. Ein oft zitiertes dabei Ärgernis ist, dass die Typensicherheit bei der Verwendung von Enums nur ungenügend sichergestellt ist. So war es in der Vergangenheit möglich, einen Wert eines Enum-Typs einer Variable eines anderen Enum-Typs zuzuweisen. Mit den neuen Standards gehört dies bei korrekter Verwendung der Vergangenheit an. Wird einer enum Definition das Keyword class oder struct hinzugefügt, wird daraus ein stark typisierter Datentyp, und die Verwendung mit einem anderen enum-Typ führt je nach Konfiguration zu einer Warnung oder einem Fehler beim Kompilieren. Sozusagen als zusätzlicher Bonus kann seit C++11 auch der unterliegende Datentyp für ein enum explizit angegeben werden, was der Portabilität des Codes zugute kommt. (s. Abbildung im PDF)

Zeit-Literale mit <chrono>

Eine sehr häufige Verwendung von Daten mit klaren, aber nicht immer linearen Wertebereiten ist insbesondere bei Applikationen mit strikten Zeitanforderungen natürlich die Zeit selbst. Das Handling von Zeiteinheiten ist für viele Programmierer ein Albtraum. Die Gründe sind vielfältig, von der nicht-linearen Aufteilung von Sekunden, Minuten und Stunden bis hin dazu, dass schnell mal Verwirrung entsteht, um welche Zeiteinheit sich bei einem Aufruf wie sleep(100)handelt. Handelt es sich hier um Sekunden? Millisekunden? Mit der Einführung von std::chrono in C++11 und dem Hinzufügen von Zeitliteralen wird das Handling um einiges einfacher. Mit den Literalen können Zeitangaben mit einem einfachen Suffix im Code mit einer fixierten Einheit beziehungsweise mit einer fixen Auflösung deklariert werden. <chrono> liefert dabei alles zwischen Mikrosekunden und Stunden. Durch die Verwendung der von std::chrono mitgelieferten Zeiteinheiten lassen sich Zeitwerte bereits zur Compile-Time konvertieren, und das lästige manuelle Umrechnen zur Laufzeit gehört der Vergangenheit an. (s. Abbildung im PDF)

Verzweigungen mit Initialisierung

Auf den ersten Blick ist die Einführung direkten Initialisierung in if- und switch-Statements in C++17 eine Möglichkeit, Code noch ein kleines bisschen kompakter schreiben. Ein weiterer, etwas versteckter Vorteil ist, dass der Programmierer seine Absicht, dass ein Symbol nur innerhalb einer Verzweigung verwendet wird, deutlicher ausdrücken kann. Die Initialisierung direkt neben beziehungsweise in der Bedingung zu haben verhindert auch die Gefahr, dass sie bei Refactorings (unabsichtlich) von der Verzweigung getrennt wird. (s. Abbildung im PDF)

 Im Zusammenhang mit den oben genannten Structured Bindings kann die direkte Initialisierung sehr elegant verwendet werden. Im folgenden Beispiel wird versucht, einen bereits existierenden Wert in einer std::map zu überschreiben. Der Rückgabewert von insert wird direkt in einen Iterator und das Flag, ob die Operation erfolgreich war, entpackt und kann somit direkt innerhalb der Abfrage verwendet werden.

(s. Abbildung im PDF)

Standardattribute

Wann immer ein Programmierer eine Annahme trifft, sollte dies im Code dokumentiert sein. Mit den Standardattributen können einige solcher Annahmen mit wenig Aufwand dokumentiert werden. Attribute sind seit längerem für verschiedene Compiler bekannt, allerdings war die Notation für die verschiedenen Compiler oft unterschiedlich. Seit C++17 wurde diese als [[ attribute ]] standardisiert, was den Code lesbarer macht. Zudem wurden verschiedene, von allen Compilern unterstützte Standardattribute eingefügt, welche es dem Programmierer erlauben, seine Absichten für gewisse Konstrukte explizit zu formulieren:

  • [[noreturn]]                                     
    Zeigt an, dass eine Funktion nicht zurückkehrt, z.B. weil sie immer eine Exception wirft
  • [[deprecated]]  /  [[deprecated("reason")]]                      
    Zeigt an, dass die Verwendung dieser Klasse, Funktion oder Variable zwar erlaubt, aber nicht mehr empfohlen ist
  • [[fallthrough]]                            
    Verwendet in switch-Statement, um anzuzeigen, dass ein case:-block mit Absicht kein break beinhaltet
  • [[nodiscard]]                                  
    Produziert eine Compiler-Warnung, falls ein so markierter Rückgabewert nicht verwendet wird
  • [[maybe_unused]]                         
    Unterdrückt Compiler-Warnungen bei nicht verwendeten Variablen. z.B. in Debug-Code <

Fazit

Diese 10 kleinen Features und Funktionen sind natürlich nur ein kleiner Teil davon, was modernes C++ ausmacht. Aber durch deren konsequente Anwendung kann Code mit relativ wenig Aufwand lesbarer und einfacher Verständlich gemacht werden, ohne dass die komplette Struktur einer existierenden Codebase gleich umgeschrieben werden muss.

Zusammenfassung

Die Einführung der neuen Standards C++11/14/17 hat C++ merklich modernisiert. Nebst solchen großen Sprachfeatures wie smart-pointers, move semantics und varaidic templates gibt es auch noch eine ganze Menge an kleineren Erweiterungen, die oftmals unter dem Radar fliegen. Aber gerade diese Features können helfen, C++ Code merklich zu vereinfachen und wartbarer zu machen. Dies gekoppelt mit neuen Features in der STL kann helfen, viele kleine Fehlerchen schon beim Schreiben des Codes zu verhindern. Dass der Code sich dabei auch noch leichter liest und stabiler wird, sind weitere erfreuliche Nebeneffekte.

Autor

Dominik Berner ist ein Senior Software-Ingenieur bei der bbv Software Services AG mit einer Leidenschaft für modernes C++. Die Wartbarkeit von Code ist für ihn kein Nebeneffekt, sondern ein primäres Qualitätsmerkmal, das für die Entwicklung von langlebiger Software unabdingbar ist. Als Blogger und Speaker auf Konferenzen und Meetups weiß er, wie Inhalte zu verpacken sind, damit für das Publikum ein Mehrwert entsteht. 

 

Beitrag als PDF downloaden


Implementierung - unsere Trainings & Coachings

Wollen Sie sich auf den aktuellen Stand der Technik bringen?

Dann informieren Sie sich hier zu Schulungen/ Seminaren/ Trainings/ Workshops und individuellen Coachings von MircoConsult zum Thema Implementierung /Embedded- und Echtzeit-Softwareentwicklung.

 

Training & Coaching zu den weiteren Themen unseren Portfolios finden Sie hier.


Implementierung - Fachwissen

Wertvolles Fachwissen zum Thema Implementierung/ Embedded- und Echtzeit-Softwareentwicklung steht hier für Sie zum kostenfreien Download bereit.

Zu den Fachinformationen

 
Fachwissen zu weiteren Themen unseren Portfolios finden Sie hier.