Welche Varianten Sie beim Interface-Design kennen sollten und wie diese in den Programmiersprachen C und C++ implementierbar sind, verrät dieser Beitrag.
Das Interface-Konzept und Designvarianten
Ein Software-Interface stellt eine Summe von Funktionen mit der kompletten Semantik (Name, Parameter, Parametertypen, Rückgabetypen, spezielle Modifizierer) für den Zugriff und die Realisierung bereit.
Mindestens ein Element (Accessor) greift auf das Interface zu. Der Zugreifer erwartet das Interface (Required Interface).
Mindestens ein Element (Realization) muss das Interface implementieren. Die Realisierung stellt das Interface bereit (Provided Interface).
Das Interface dient zur Entkopplung zwischen dem Zugreifer und der Realisierung, weiter übergeordnet zur Entkopplung zwischen Architekturelementen.
Bei verschiedene Designvarianten zwischen Zugreifer und Interface(es):
greift ein Zugreifer auf ein oder mehrere Interfaces z
greifen mehrere Zugreifer auf ein oder jeweils ein Interface zu
greifen mehrere Zugreifer auf ein oder mehrere Interfaces zu
greifen mehrere Zugreifer auf unterschiedliche Interface-Ebenen zu ode
greifen mehrere Zugreifer auf jeweils unterschiedliche Interface-Ebenen zu
Bei den Designvarianten der Interface-Realisierung sind möglich:
ein Interface mit einer Realisierung
ein Interface mit mehreren oder partiellen Realisierungen
mehrere Interfaces in einer oder mehreren Realisierungsvarianten
Interface-Typisierung
Interfaces lassen sich (abhängig von verschiedenen Zugreifern) thematisch aufteilen, beispielsweise pro Architekturelement jeweils eines für die Konfiguration, die Diagnose und den Normalbetrieb. Generell lassen sich Interfaces in drei unterschiedliche Typen kategorisieren:
Call-Interface
Das Call-Interface bietet dem Zugreifer aus dem Architekturelement A beispielsweise Funktionen an, um Werte aus dem Architekturelement B zu lesen, Werte hineinzuschreiben oder dort Algorithmen auszulösen.
Callback-Interface
Das Callback-Interface meldet aktiv neue Werte oder Ereignisse vom Architekturelement B an A. Bei der Struktur ist zu beachten, dass die Realisierung des Callback-Interfaces über Architekturelement-Grenzen hinweg geht und sich hier in Architekturelement A befindet. Im Zusammenspiel mit dem Call-Interface ist so in der Summe immer noch eine unidirektionale Abhängigkeit zwischen den beiden Architekturelementen A und B erreichbar.
Callback-Registrierungsinterface
Falls die Registrierung des Callbacks dynamisch zur Laufzeit und nicht statisch zur Compilezeit durchgeführt wird, ist dafür ein spezielles Element (Manager) in der Software-Architektur verantwortlich. Dieser Manager kann mittels eines speziellen Callback-Registrierungsinterfaces ein oder mehrere Callback-implementierende Elemente registrieren und de-registrieren.
Zur Umsetzung der in Bild 2 dargestellten Struktur lässt sich das Observer-Pattern anwenden.
Das konkrete Subjekt erfährt einen neuen Wert. Durch den Aufruf einer entsprechenden Funktion aus dem Observer meldet das konkrete Subjekt allen zuvor registrierten konkreten Observern den neuen Wert. Das Observer-Pattern hat sich inzwischen zu einem sehr populären Pattern für Embedded-Software entwickelt.
Das Semantik-Interface und die Funktionen
Die Interface-Typisierung spiegelt sich in den vergebenen <<Stereotypen>> und in den Interfacenamen als Prefix ic (interface), icb (interface callback) und icbreg (interface callback registration) wider. Als Interfacename eignet sich ein aussagekräftiges und bedeutungsvolles Substantiv.
In allen Interface-Typen sind die Funktionen öffentlich (public). Bei Funktionen sind programmiersprachenabhängige Modifizierer wie virtual, const, static, inline, … zu berücksichtigen.
Als Funktionsnamen eigenen sich Verb-/Substantiv-Kombinationen. Bei Callback-Interface-Funktionen bietet sich als Verb melde (notify) an. Bei Callback-Registrierungsinterface-Funktionen bieten sich die Verben registriere (register) und de-registriere (unregister) an.
Funktionsparameter sind optional und sollten eine maximale Anzahl von sieben bis zwölf nicht überschreiten. Die Richtung ist durch in | out | inout vor dem Parameternamen visualisierbar. Unterstützt die Programmiersprache (z.B. C++) einen Default-Parameterwert, ist dieser spezifizierbar.
Die Reihenfolge der Funktionsparameter kann die Performance beeinflussen. Es sollte mit den Standard-Datentypen begonnen werden, da der Compiler diese per CPU-Register in die Funktion übergeben kann (sofern noch Register frei sind). Sobald ein komplexer Datentyp, wie beispielsweise eine Struktur, per Wert übergeben wird, reicht der Compiler diesen und alle folgenden Parameter per Stack in die Funktion.
Als Parametername eignen sich ein Substantiv oder eine Kombination aus mehreren aussagekräftigen und bedeutungsvollen Substantiven.
Parameter– und Returntypen sind optional void. Als Datentypen können entweder Standard-Datentypen aus der Programmiersprache oder bereits definierte eigene Datentypen zum Einsatz kommen, jedoch keine, die erst in der Zukunft definiert werden.
Aus Gründen der Performance ist immer eine Übergabe als Zeiger / Referenz (keine Kopie!) gegenüber einer Übergabe als Wert (Kopie!) zu bevorzugen.
Daten-Typenamen sollten durch ein Postfix _t gekennzeichnet sein.
Wie bei Funktionen gibt es auch bei Typen programmiersprachenabhängige Modifizierer wie const, static, *, &,[], … .
Ansätze der Interface-Implementierung
Im einfachsten Fall ist ein Interface ein Header-File mit einer Summe deklarierter Funktionen. Der Zugreifer inkludiert das Interface-Header-File für den Aufruf der Interface-Funktionen. Ein oder mehrere realisierende Module inkludieren das Header-File zur Implementierung der Interface-Funktionen. Dieser einfachste Implementierungsansatz wird hier nicht weiter betrachtet. Vielmehr soll es um fortschrittlichere Implementierungsoptionen in C++ und, wo dies sinnvoll möglich ist, auch in C gehen.
Nicht-polymorphe Struktur bedeutet, dass das Interface genau eine Implementierung besitzt, während polymorphe Struktur das mehrfache Vorhandensein von Implementierung meint. Bei polymorphen Strukturen lässt sich die Art der Bindung zwischen Objekt / Funktionszeiger und Funktion unterscheiden. Dynamische Bindung (Bindung zur Laufzeit) ermöglicht einen vom Kontext abhängigen Aufruf verschiedener Interface-Funktionsimplementierungen. Bei der statischen Bindung (Bindung zur Compilezeit) bindet der Compiler bereits eine zur Laufzeit nicht veränderbare Interface-Funktionsimplementierung.
Die Auswahl des richtigen Ansatzes hängt sehr stark von den zu erfüllenden Software-Qualitätsanforderungen ab. Ist eine ausgeprägte Flexibilität zur Laufzeit gefordert, so sind polymorphe Strukturen mit dynamischer Bindung die richtige Wahl. Dominiert die funktionale Sicherheit die Flexibilität, so sind nicht-polymorphe Strukturen oder polymorphe Strukturen mit statischer Bindung zu bevorzugen.
Assoziation ohne Interfaceklasse
Aus dem Software-Subsystem Controller greift die Klasse cController über zwei Zeiger direkt auf je ein Objekt vom Typ cUpCounter und cDownCounter zu. Dabei ergeben sich zwischen den beiden Software-Subsystemen Controller und Counter zwei Abhängigkeiten (Include-Pfade).
Hier wurde mit Absicht nicht die Assoziation von cController auf cCounter gezogen, damit keine virtuellen Funktionen bzw. Funktionszeiger notwendig sind, aber zum Preis der stärkeren Kopplung.
Aggregation ohne Interfaceklasse
Im Vergleich zu dem vorherigen Assoziationsbeispiel übernimmt hier die Klasse cController direkt die Instanziierung der benötigten Objekte vom Typ cUpCounter und cDownCounter. Die Instanziierung erfolgt hier dynamisch auf dem Heap mittels malloc() in C und new() in C++. Damit erreichen wir die Grundidee der Weitergabe (des „Ausbaus“) der erzeugten Counter-Objekte bei der Aggregation. Der Einsatz des Heaps in der Embedded-Softwareentwicklung ist in vielen Projekten verboten, da er u.a. mit Risiken der Fragmentierung verbunden und damit nicht vorhersagbar bzw. nicht echtzeitfähig ist.
Wie bei der Anwendung der Assoziation ergeben sich auch bei der Aggregation für dieses Beispiel zwei Abhängigkeiten. Ebenfalls wurde gezielt auf die Anwendung von virtuellen Funktionen / Funktionszeiger verzichtet.
Komposition ohne Interfaceklasse
Bei den Varianten mit Assoziation und Aggregation erfolgen die Objektzugriffe jeweils mit Zeigern (optional mit Referenzen). Der komplette Verzicht auf Zeiger führ zur Anwendung der Komposition.
Hierbei enthält die Klasse cController als Members Objekte der Klassen cUpCounter und cDownCounter. Die Anzahl der Abhängigkeiten bleibt bei zwei, wobei die Kopplung durch die eingebetteten Objekte bei der Komposition gegenüber der Assoziation und Aggregation verstärkt wird.
Fassade-Pattern
Das Fassade-Pattern bietet dem Zugreifer cController das Interface icCounter an, welches bereits Daten und Funktionsimplementierungen enthält.
Was sich hinter der Fassade icCounter verbirgt, ist für den Zugreifer nicht wissenswert und auch nicht sichtbar.
Wie bei den vorherigen Beispielen enthält diese Implementierung keine virtuellen Funktionen oder Funktionszeiger. Es ergibt sich nur eine Abhängigkeit (Include-Pfad) und damit eine geringe / lose Kopplung. Das Interface (Fassade) enthält ein Zähler- und ein Grenzwert-Prüfobjekt, die gemeinsam die komplette Funktionalität der Fassade realisieren.
Interfaceklasse mit rein virtuellen Funktionen
Ein klassischer, aus der objektorientierten Welt stammender Interfaceansatz ist die Verwendung von rein virtuellen Funktionen (nur Deklarationen ohne Implementierungen) im Interface. Des Weiteren enthält das Interface icCounter keine Daten.
Der Zugriff auf das Interface erfolgt im cController durch einen Zeiger / eine Referenz vom Typ des Interfaces. Dieser Zeiger / diese Referenz muss später auf ein Objekt der Interface-realisierenden Klassen zeigen.
In C gibt es keine virtuellen Funktionen, daher müssen dort die Mechanismen des C++ Compilers manuell mit Hilfe von Funktionszeigertabellen nachgebildet werden.
Was sich hinter dem Interface verbirgt, ist für den Zugreifer cController zunächst nicht wissenswert und auch nicht sichtbar. Erst beim Initialisieren der Zeiger / Referenzen müssen konkrete Objekte von cUpCounter und cDownCounter vorhanden sein.
Interfaceklasse mit nicht nur rein virtuellen Funktionen
Diese Implementierungsvariante basiert auf dem C++ Idiom Non-Virtual Interface (NVI). Bei der Implementierung von rein virtuellen Interfaces ergeben sich im Falle mehrerer Implementierungen typischerweise redundante Programmcode-Anteile.
Das Idiom Non-Virtual Interface implementiert diesen gemeinsamen Code bereits in der Interface-Funktion. Nur die kleinen varianten Anteile der typspezifischen Implementierung sind im Interface als rein virtuelle Funktionen deklariert (isInRange()) und (count_raw()) und bereits in anderen implementierten Funktionen (count()) aufgerufen.
Nur die beiden typspezifischen virtuellen Funktionen isInRange() und count_raw() sind jeweils in den Klassen cUpCpounter und cDownCounter individuell implementiert.
In den Beispielen mit virtuellen Funktionen in Interfaces oder/und Klassen führt das Ergebnis auf dem Taget zu einer dynamischen Bindung. Dadurch entstehen verschieden Aufwände, die aber im Vergleich zum Nutzen in dem meisten Fällen vernachlässigbar sind:
1. Programmspeicher (und Compilezeit)
Zur Compilezeit erzeugt die Toolkette zu jeder Klasse, die eine oder mehrere virtuelle Funktionen deklariert oder/und implementiert, eine VMT (Virtual Method Table). Diese legt der Linker / Lokator üblicherweise in den Programmspeicher. Die VMT enthält die Funktionseinsprung-Adressen zu den klassenspezifischen Funktionsimplementierungen.
2. Datenspeicher und Laufzeit
Objekte, instanziiert aus einer Klasse mit virtuellen Funktionen, enthalten als zusätzliches, erstes Attribut die Einsprung-Adresse in dessen Klassen-VMT. Dieses Attribut fügt der Compiler automatisch hinzu, und der Konstruktor initialisiert es ebenfalls automatisch.
3. Laufzeit
Beim Aufruf einer virtuellen Funktion für ein bestimmtes Objekt über einen Zeiger oder eine Referenz wird über dessen VMT-Einsprung-Adresse die Funktionsadresse aus der VMT gelesen und anschließend zu dieser Funktionsadresse gesprungen (-> eine In-Direktion mehr als beim Aufruf nicht-virtueller Funktionen). Dies ist die Funktionalität der dynamischen Bindung. Sie erlaubt so die Programmierung der dynamischen Polymorphie. Diesen Mechanismus führt die C++ Toolkette automatisch aus. In C ist die dynamische Bindung mit etwas mehr Programmieraufwand manuell nachbildbar.
Interface als Template-Parameter
Die in den Beispielen mit rein virtuellen Interfaces und nicht rein virtuellen Interfaces beschriebenen Aufwände und die damit verbundenen Risiken lassen sich durch die Anwendung von Template-Klassen eliminieren. Der Preis dafür ist der Verzicht auf die dynamische Polymorphie, die in vielen Embedded-Softwaresystemen nicht zwingend erforderlich ist. Es ergibt sich nur eine statische Polymorphie.
Der Interface-Zugreifer tcController bekommt als Template-Parameter die Objekt- / Interfacetypen CounterA_T und CounterB_T, deren Realisierungen cUpCounter und cDownCounter er adressieren möchte. Eine direkte Abhängigkeit im Programmcode zwischen den Software-Subsystemen Controller und Counter gibt es nicht mehr. Die indirekte Abhängigkeit entsteht durch die Template-Typisierung bei der Instanziierung. Die spezifizierten Typen cUpCounter und cDownCounter müssen alles bereitstellen, was der Zugreifer tcController aufruft. Ist das nicht der Fall, meldet dies bereits der Compiler als Fehler (kein Laufzeitfehler!).
Das Interface selbst ist bei diesem Interface-Design nicht eindeutig sichtbar und nur indirekt im Zugreifer durch dessen Aufrufe spezifiziert. Diese Problematik verbessert sich durch die Anwendung des Curiously Recurring Template Pattern (CRTP) [3].
Resümee
Die Entscheidung für das „richtige“ Interface-Design ist immer von den geltenden Software-Anforderungen abhängig.
Interfaces unterstützen positiv die Umsetzung von Software-Qualitätsmerkmalen, wie beispielsweise Wiederverwendbarkeit, Portabilität, Austauschbarkeit und Erweiterbarkeit. Interface-Konzepte sind ein geeignetes Mittel zur Erfüllung von Software-Entwurfsprinzipien, z.B. lose Kopplung, Externalisierung von Abhängigkeiten, Modularisierung und Erreichen einer hohen Kohäsion.
Ein weiterführendes Konzept zu und mit Interfaces sind Ports. Ein Port vereint thematisch null bis unendlich viele bereitgestellte Interfaces und null bis unendlich viele erwartete Interfaces und lässt sich mit anderen kompatiblen Ports verbinden.