Suchen

Wie C++20 generische Programmierung für Embedded Systeme praxistauglich macht

| Autor / Redakteur: Daniel Penning* / Sebastian Gerstl

Generische Programmierung erleichtert Entwicklern die Arbeit, lassen sich damit doch Code-Teile schnell wiederverwenden. Aber gerade der Embedded-Bereich macht es generell erforderlich, Softwareteile auf spezifische Rahmenbedingungen anzupassen. C++20 erlaubt es endlich nutzerfreundlich, generische Programmierung auch auf Embedded-Entwicklung anzuwenden.

Firmen zum Thema

Generische Programmierung ist ein fundamentales Konzept in der Software-Entwicklung, galt aber für den Embedded-Bereich als kaum anwendbar. Der Einsatz von C++20 macht den Ansatz aber auch hier praxistauglich.
Generische Programmierung ist ein fundamentales Konzept in der Software-Entwicklung, galt aber für den Embedded-Bereich als kaum anwendbar. Der Einsatz von C++20 macht den Ansatz aber auch hier praxistauglich.
(Bild: Clipdealer)

Bei der generischen Programmierung werden Datentypen und Algorithmen so entwickelt und formuliert, dass sie möglichst weit einsetzbar und wiederverwendbar sind. So kann etwa ein effizienter Algorithmus zur Sortierung von einem Experten hocheffizient als generische Funktion implementiert werden. Die Funktion kann dann für alle sortierbaren Datentypen verwendet werden

C++ unterstützt die generische Programmierung mit dem Sprachmittel der Templates. Templates erlauben es, Datentypen und Algorithmen allgemein zu formulieren und den konkreten Typ erst zur Instanziierung festzulegen. Bisher gilt die damit durchgeführte generische Programmierung allerdings eher als Experten-Domäne. Ein Grund dafür ist, dass bei Templates bisher nicht angegeben werden konnte, welche Anforderungen der später eingesetzte Datentyp erfüllen muss. Der Aufruf einer generischen sort-Methode mit einem nicht-sortierbaren Datentyp endete oft in abschreckend langen Fehlermeldungen des Compilers.

Der neue C++20 Standard führt sogenannte Concepts als integralen Sprachbestandteil ein. Mit Concepts können die Anforderungen an einen Datentyp erstmals direkt im Template formuliert werden. Der Compiler prüft diese Voraussetzungen und kann gegebenenfalls eine präzise und damit hilfreiche Fehlermeldung ausgeben. Generische Komponenten werden so auch für Entwickler mit bisher geringer Erfahrung im Schreiben und Verwenden von Templates einsetzbar.

Generische Programmierung

„We don't solve problems, we approximate solutions.“- Sean Parent, A possible future of software development, 2007 [1]

In der Softwareentwicklung stößt man immer wieder auf ähnliche Problemstellungen. Die erwähnte Sortierung einer Menge von Daten ist ein Beispiel, welches in einer Vielzahl unterschiedlicher Anwendungen nötig ist. Dabei werden sich jedoch die spezifischen Randbedingungen unterscheiden:

  • Welche Struktur haben die Daten? (Array, verkettete Liste, …)
  • Welchen Typ haben die Daten? (int, float, User-definierte Datentypen, …)

Die generische Programmierung hat hier das Ziel, den eigentlichen Sortier-Algorithmus vollständig unabhängig von diesen spezifischen Randbedingungen zu formulieren. Die Kernidee wurde bereits in den 80er Jahren formuliert [2]:

„Generic programming centers around the idea of abstracting from concrete, efficient algorithms to obtain generic algorithms that can be combined with different data representations to produce a wide variety of useful software.“

Die damaligen Programmiersprachen ließen jedoch keine effiziente Implementierung dieser Konzepte zu. Das änderte sich Anfang der 90er Jahre. Basierend auf dem jetzt verfügbaren C++ Template Mechanismus realisierte die STL (1992) in einer völlig neuen Herangehensweise die generische Programmierung.

Die STL war extrem erfolgreich und ist inzwischen Teil der C++ Standard Bibliothek und aus dieser nicht mehr wegzudenken. Der damals entworfene Template Mechanismus wurde in den folgenden Jahren und Jahrzehnten verfeinert und mächtiger. Von Beginn an gab es jedoch eine kritische Einschränkung, die Templates schnell den Ruf einer Experten-Domäne bescherten.

Generische Programmierung bis C++17

Die fundamentale Einschränkung war bisher, dass bei der Definition eines Template keine Einschränkung an den später eingesetzten Typ gestellt werden konnte:

template <typename T>T const& min(T const& a, T const& b) {  return (b < a) ? b : a;}

Die min-Funktion gibt den kleineren der beiden Parameter zurück. Dieser „Algorithmus“ kann als Minimal-Beispiel der generischen Programmierung gesehen werden. Ein Aufruf mit zwei Integer-Werten liefert das gewünschte Ergebnis:

int i1 = 10, i2=4;int i3 = min(i1, i2); // i3 = 4

Problematisch wird es, wenn die min-Funktion mit einem Typ aufgerufen wird, der nicht vergleichbar ist. Nehmen wir an, unser Programm definiert einen Datentyp zum Speichern von speziellen Messungen:

struct Measurement {  int voltage_mV;  int current_mA;};

Anschließend soll der kleinere der beiden Messungen gefunden werden:

Measurement m1, m2; // (...) m1 und m2 werden initialisiertMeasurement m3 = min(m1, m2);

Hier kommt es zu einem Compiler-Fehler:

error: no match for 'operator<' (operand types are 'const Measurement' and 'const Measurement') return (b < a) ? b : a;

Die beiden Messwerte sind nicht vergleichbar, weil der Datentyp bisher den Vergleichsoperator „<“ nicht implementiert. An dieser Stelle kann sich der Entwickler die berechtigte Frage stellen, ob zwei solche Messwerte überhaupt vergleichbar sind (soll die Spannung oder der Strom verglichen werden?) und gegebenenfalls einen Operator implementieren.

Problematisch an dieser Fehlermeldung ist jedoch, dass sie aus der min-Funktion heraus generiert wird. Erst in der Codezeile, wo die Funktion den Vergleich durchführt, entsteht der Fehler. Bei einer – wie in diesem Beispiel – extrem kurzen Funktion ist das nicht störend. Ist die Funktion länger oder ruft sie weitere Unterfunktionen auf, wird die eigentliche Fehlerursache schnell verschleiert.

Wendet man die generische Sortierfunktion der C++ Standardbibliothek auf einen Container mit Measurement Werten an, ergibt sich mit einem aktuellen gcc-Compiler eine 229-Zeilen lange Fehlermeldung. Es erfordert viel Erfahrung, aus dieser Fülle von Informationen die relevante Ursache zu extrahieren. Auch in diesem Fall ist die fehlende Vergleichsmöglichkeit für den Fehler verantwortlich.

Warum kann der Compiler hier keine bessere - kurze & prägnante - Fehlermeldung generieren? Weil ihm das Wissen dafür nicht mitgeteilt wurde. Betrachten wir noch einmal die Signatur der min-Funktion:

template <typename T>T const& min(T const& a, T const& b);

Für den Compiler bedeutet diese Signatur, dass er jeden Datentyp für T einsetzen kann. Er kann an dieser Stelle also nicht wissen, dass hier für ein korrektes Programm nur vergleichbare Datentypen eingesetzt werden dürfen.

Die eingeschränkte Template-Signatur lässt sich mit einem void*-Parameter in einer normalen Funktion vergleichen. Auch hier würden vom Compiler beliebige Datentypen als Parameter akzeptiert, obwohl vielleicht spezifische Bedingungen erfüllt sein müssen. Der Unterschied liegt darin, dass ein falscher void*-Parameter zur Laufzeit einen Fehler verursacht, ein falscher Template-Parameter bereits beim Kompilieren.

Diese fundamentale Einschränkung hat dazu geführt, dass umständliche Techniken erdacht wurden, mit denen man indirekt Anforderungen an einen Template-Typ formulieren kann. Beispiele dafür sind SFINAE und Tag Dispatch. Diese Techniken haben weiter zu dem Ruf eines Expertensystems beigetragen. Erst C++20 löst das fundamentale Problem und macht es möglich, direkt in der Signatur Anforderungen an den Template-Typen zu formulieren.

Concepts in C++20

In C++20 kann die min-Funktion jetzt wie folgt formuliert werden:

template <Comparable T>T const& min2(T const& a, T const& b) {  return (b < a) ? b : a;}

Aus dem template <typename T> ist ein template <Comparable T> geworden. Als Template-Typ dürfen nur noch Datentypen eingesetzt werden, die dem Concept Comparable genügen.

Concepts sind der neue Mechanismus, um in Templates die Schnittstelle klarer zu gestalten. Concepts können vom Entwickler selbst definiert werden.

template<class T>concept Comparable = requires(T const& a,    T const& b) {  { a < b } -> std::boolean;};

Comparable fordert lediglich, dass der Datentyp einen Kleiner-Operator implementiert und dass dieser Operator einen bool-Wert zurückgibt. Es ist wichtig zu verstehen, dass diese Anforderungen extern sind. Im Gegensatz zur Vererbung kennt der Datentyp selbst das Concept nicht. Dieses Detail erleichtert den Aufbau von modularen und unabhängigen Software-Teilen. Falls die Bedingungen verletzt werden, kann der Compiler nun eine klare Fehlermeldung generieren.

error: cannot call function 'const T& min(const T&, const T&) [with T = Measurement]'note: constraints not satisfied

Es folgen noch hilfreiche Erklärungen, warum das Concept Comparable von Measurement nicht erfüllt wird. Wichtig ist hier, dass der Compiler-Fehler bereits an der Schnittstelle der min-Funktion entsteht! Eventuell folgende Unteraufrufe finden also überhaupt nicht mehr statt.

Neben der deutlich verbesserten Lesbarkeit potentieller Fehlermeldungen ergibt sich für den Nutzer dieser Funktion noch ein weiterer Vorteil: Die Funktion ist rein von der Schnittstelle her leichter zu verstehen und in sich selbst dokumentiert. Das Beispiel-Concept hat lediglich gezeigt, wie auf das Vorhandensein eines Operators geprüft werden kann. Der Mechanismus erlaubt für umfangreiche, auch kombinierte, Prüfungen auf einen oder mehrere Datentypen:

  • Operatoren und deren Typ
  • Funktionen und deren Signatur
  • Member-Variablen und deren Typ
  • Anforderungen an den Typ selbst, bspw. seine Größe

Eine Reihe von elementaren Concepts sind durch die Sprache bereits vor-definiert [3]. Beispielsweise sollte statt der Eigen-Kreation Comparable besser direkt das umfangreichere Concept totally_ordered verwendet werden.

Generische Programmierung greifbar machen

Durch die besseren Schnittstellen-Definitionen und Fehlermeldungen bringt der neue C++-Standard spürbare Erleichterungen im Umgang mit Template-Code. Es ist allerdings nicht einfach, wiederkehrende Code-Konstrukte als Algorithmus zu interpretieren und in eine generische Funktion auszulagern – selbst wenn man das technische Template-Knowhow außer Acht lässt.

Trainieren lässt sich das Erkennen von wiederkehrendem Code, wenn man als erstes die nicht-modifizierenden Algorithmen [4] der Standardbibliothek betrachtet und schrittweise einsetzt. Dazu gehören Algorithmen wie all_of, any_of, none_of, count, find und search.

In jedem Programm wird man Schleifen über Daten finden, die durch einen der genannten Algorithmen ersetzt werden können. Neben der nahezu garantiert korrekten Implementierung auch für Randfälle machen diese Funktionen den Code in seiner Absicht besser lesbar. Keine dieser Algorithmen verwendet dynamischen Speicher.

Mit der sogenannten Ranges-Bibliothek wird die Standardbibliothek in C++20 um gleichnamige Algorithmen ersetzt, die über Concepts ein klar definiertes Interface aufweisen [5].

Nachdem man einige solcher Konstrukte mit Standard-Algorithmen ersetzt hat, wird es leichter sein, auch Domänen-spezifische Algorithmen zu erkennen. Mit Concepts lassen sich diese anschließend in verständliche generische Funktionen umwandeln.

Zusammenfassung: Generische Programmierung in die Embedded-Entwicklung bringen

Traditionell sahen sich Entwickler gerade im Embedded-Bereich oft gezwungen, bestimmte Code-Teile selbst zu schreiben, bzw. auf ihre Randbedingungen anzupassen. Mit C++20 gibt es nun eine nutzerfreundliche Möglichkeit dieses Problem mit generischer Programmierung anzugehen. Es ist zu erwarten, dass die Akzeptanz von Template-Programmierung durch eine verbesserte Lesbarkeit steigt.

Von Experten geschriebene und in breitem Einsatz bewährte Algorithmen erreichen ein Niveau an Effizienz und Zuverlässigkeit, das sich von kleinen Gruppen oder gar Einzelpersonen nicht erreichen lässt. Generische Programmierung ist eine Chance, hoch-qualitative Embedded Software in einem wirtschaftlichen Rahmen zu entwickeln.

Der Autor

Daniel Penning, Geschäftsführer der embeff GmbH.
Daniel Penning, Geschäftsführer der embeff GmbH.
(Bild: embeff)

*Daniel Penning ist Geschäftsführer der embeff GmbH, die Lösungen für eine effiziente und risikominimierte Embedded-Software-Entwicklung anbietet. Als C++ Trainer und Berater zeigt er, dass qualitativ hochwertige Software auch für ressourcenbeschränkte Systeme möglich ist.

(Dieser Beitrag wurde mit freundlicher Genehmigung des Autors dem Tagungsband Embedded Software Engineering Kongress 2019 entnommen.)

Quellen

Alle Webseiten gültig mit Stand vom September 2019.
[1] Sean Parent, A Possible Future of Software Development, 2007
[2] Musser / Stepanov, Generic Programming, 1988
[3] cppreference, Concepts library
[4] cppreference, Non-modifying sequence operations
[5] Eric Niebler, Standard Ranges

Artikelfiles und Artikellinks

(ID:46828106)