
Die beiden ersten Folgen dieses dreiteiligen C++-Kurses haben erläutert, wie sich bewährte Programmiertechniken für die aspektorientierte Programmierung (AOP) nutzen lassen. Gezeigt wurden andererseits Grenzen dieses Paradigmas bei der Behandlung von Ausnahmen. Dieser letzte Teil thematisiert die Verbindung von Aspekten mit freien Funktionen. Die kompletten Listings sind wie üblich über den iX-Webserver verfügbar.
Soll einer Funktion nur ein bestimmter Aspekt hinzugefügt werden, scheint die Lösung verhältnismäßig einfach. Im Namensraum aspects wird eine Funktion gleichen Namens definiert. Diese führt zuerst den vorangehenden Aspektcode auf, ruft dann die Original-Funktion auf und führt schließlich den nachfolgenden Aspektcode aus. Importiert der Namensraum composed den Funktionsnamen aus dem Namensraum original, erhält man die Original-Funktion ohne Aspektcode, importiert er ihn aus aspects, ist es die Funktion mit Aspektcode.
Aufwendiger gestaltet es sich, mehrere Aspekte in wechselnder Reihenfolge und unterschiedlicher Zahl mit einer Funktion zu verbinden (Listing 1). Der Kern der Lösung besteht darin, einen generischen Rahmen zu schaffen, in dem beliebig zusammengesetzter Aspektcode laufen kann und die Original-Funktion aufgerufen wird. Diese Aufgabe kommt dem Funktions-Template factorial zu (Listing 1, Zeile 78), dessen Template-Parameter der Typ einer durch Komposition gewonnenen Aspektklasse ist. Damit die Aspekte die Funktions- und Rückgabeparameter inspizieren und modifizieren sowie auf das Auftreten einer Ausnahme reagieren können, werden alle diese Größen an ein Exemplar der synthetisierten Aspektklasse als Parameter übergeben (Zeile 84). Das Ergebnis des Aufrufs der Original-Funktion steht in result (Zeile 87) und die Information über das Auftreten einer Ausnahme in dem Flag exception (Zeile 92). Beide sind lokale Variablen einer Ausprägung von factorial und stehen somit während der gesamten Lebensdauer aller Aspekte zur Verfügung. Die drei Variablen n, result und exception bilden dabei einen gemeinsamen Zustand aller Aspekte. Jeder Aspekt kann daher in der ihm eigenen Weise auf eine Ausnahme reagieren. Die Implementierung des Funktions-Templates factorial bleibt davon unberührt.
Um die Aspekte in beliebiger Reihenfolge zusammenzufügen, wird jeder einzelne, beispielsweise Tracing, als Klassen-Template realisiert, dessen Parameter der Typ des nächsten Aspekts ist. Dadurch lassen sich die Aspekte wie in einer einfach verketteten Liste miteinander verbinden. Die Klasse NilAspect (Zeile 29) sorgt für den Abschluss dieser Liste. Wird kein nächster Aspekt als Template-Parameter angegeben, ist NilAspect Standardvorgabe (Zeile 35).
Bei der Implementierung der Aspektklassen ist zu bedenken, welche Parameter der Original-Funktion nur einsehbar und welche änderbar sein sollen. Das Beispiel verwaltet die Parameter in Form konstanter Referenzen. Im Kopf des Funktions-Templates factorial wird jedoch der Parameter n aufgrund der Übergabe als Wert kopiert (Zeile 79). Deswegen kann nach einem const_cast in der Initialisiererliste von NotNegative (Zeile 67) eine nichtkonstante Referenz auf n_ initialisiert werden, die nachfolgend die Veränderung des Werts des Übergabeparameters erlaubt.
Im Namensraum composed erfolgt die Komposition der gewünschten Aspekte innerhalb einer weiteren Definition von factorial in einer der Aspektkomposition für Klassen ähnlichen Weise (zum Beispiel Zeile 114).
Das Beispiel aus Listing 1 zeigt einen interessanten Effekt. Der Aspektcode wird grundsätzlich nur vor und nach dem ersten Aufruf der rekursiv implementierten Funktion factorial() ausgeführt. Das erklärt sich dadurch, dass innerhalb von factorial die Original-Funktion gerufen wird und nicht die im Namensraum composed enthaltene, die mit Aspekten verbunden ist (Zeile 87). Hätte man den Aspektcode direkt in den Quellcode der Originalfunktion übernommen, würde er jedesmal bei rekursiven oder indirekt rekursiven Aufrufen der Funktion ausgeführt werden. Die für AspectJ verwendete Terminologie unterscheidet hier zwischen Empfang einer Nachricht (Reception) und Ausführung einer Methode beziehungsweise Funktion (Execution). Diese Unterscheidung ist sowohl für Memberfunktionen - einschließlich Selbstaufrufen - als auch für freie Funktionen relevant.
Die bisher beschriebenen Mechanismen erlauben keine Implementierung einer rekursiven Ausführung von Aspektcode für freie Funktionen und für früh gebundene Memberfunktion. Der Compiler setzt eben an der Stelle eines rekursiven oder indirekt rekursiven Funktionsaufrufs oder früh gebundenen Memberfunktionsaufrufs die Adresse der betreffenden Funktion ein.
Für Memberfunktionen mit später Bindung existiert jedoch eine Technik, die es erlaubt, den rekursiven oder nicht-rekursiven Aufruf von Aspektcode zu kontrollieren. Listing 2 zeigt dies anhand einer rein objektorientierten Umsetzung der factorial()-Funktion aus Listing 1. Die Lösung ist verhältnismäßig einfach. Für den nicht-rekursiven Aufruf des Aspektcodes wird die im ersten Teil [1] beschriebene Weiterleitungstechnik eingesetzt. Hierdurch verliert die Memberfunktion factorial() sozusagen ihre späte Bindung (Listing 2, Zeile 45). Für den rekursiven Aufruf des Aspektcodes kommt wie bisher Vererbung zum Einsatz, wodurch factorial() im resultierenden Code seine späte Bindung behält (Zeile 56).
Der erste Teil des Tutorials ist ausführlich auf die unterschiedlichen Arten von Code (fachlicher, Aspekt-und Clientcode) eingegangen, hat allerdings bisher nur den Fall betrachtet, dass fachlicher und Clientcode verschieden sind. Was würde beispielsweise geschehen, wenn man einem IntStack und seinen Int-Elementen den Aspekt Tracing hinzufügen würde? Da erst der Clientcode den Namensraum composed importiert, würden zwar dort die Bezeichner IntStack für IntStack
Tracing und Int für Int
Tracing stehen, aber im fachlichen Code hätte Int lediglich die Bedeutung von Int. Bei jedem Übergang von Client- in Fachcode und zurück würde eine Änderung von IntStack
Tracing nach Int und umgekehrt erfolgen. Die Frage ist, wie man fachlichen Code auch in der Rolle als Clientcode in der gewünschten Weise vollständig mit Aspekten verbinden kann.
Dies verdeutlicht ein letztes Beispiel. Die Klassen Boy und Girl sehen beide einen Zeiger auf ein Exemplar der jeweils anderen Klasse vor, nämlich myGirlfriend und myBoyfriend (Listing 3, Zeilen 79 und 58). Mit der Memberfunktion setFriend() kann der Zeiger jeweils auf ein geeignetes Exemplar einer Klasse gerichtet werden. Die Memberfunktion talk() bewirkt, dass für den Zeiger auf den Freund die Memberfunktion listen() aufgerufen wird. Somit verwenden sich die beiden Klassen direkt gegenseitig, sind also in höchstem Maße gekoppelt.
Eine so enge Kopplung erzwingt die Trennung zwischen der Definition der beteiligten Klassen und ihrer Implementierung. Darüber hinaus ist zumindest eine schwebende Deklaration erforderlich. Definierte man beispielsweise die Klasse Girl zuerst, müsste man zuvor mittels class Boy; die Klasse Boy bekannt machen. Anschließend darf der Typ Boy als Zeiger und als Referenz dienen, jedoch ohne auf Memberfunktionen oder -attribute zugreifen zu können. Die Definition der Klasse Girl sieht beispielsweise folgendermaßen aus:
class Boy;
class Girl
{
public:
Girl();
void setFriend(Boy* g);
void talk();
void listen(const char* msg);
private:
Boy* myBoyfriend;
};
Es empfiehlt sich daher, für jede Klasse eigene Definitions- (.h-Datei) und eigene Implementierungsdateien (.cpp-Datei) anzulegen. Damit ergeben sich insgesamt fünf Dateien: boy.h, girl.h, boy.cpp, girl.cpp und test.cpp mit der Funktion main(), die Exemplare von Boy und Girl anlegt und deren Verwendung testet.
Der nächste Schritt besteht darin, eine möglichst einfache und generische Implementierung etwa des Aspekts Tracing zu erstellen, die die Erzeugung und Zerstörung von Exemplaren sowie die Aufrufe von setFriend(), talk() und listen() protokolliert.
Hierzu ist der Code so zu umzuschreiben, dass in girl.h, girl.cpp und test.cpp anstelle von Boy überall Tracing<Boy> verwendet wird, und anstelle von Girl in boy.h, boy.cpp und test.cpp stets Tracing<Girl>.
Anschließend möchte man erreichen, dass alle nötigen Änderungen aus den betroffenen Dateien herausgezogen und in einer einzigen Datei, nämlich composed.h, lokal zusammengefasst werden. Die Dateien von Listing 3 zeigen, wie dies geht.
Da Boy innerhalb seiner Definition und der Implementierung wirklich Boy bedeuten muss, andererseits aber in der Definition und Verwendung der Klasse Girl sowie in test.cpp Boy oder Tracing<Boy> sein kann, folgt daraus zwingend, dass die Klassen Boy und Girl nicht länger im gleichen Namensraum stehen können. Jede Klasse erhält daher ihren eigenen Namensraum, dessen Bezeichner sich aus dem Präfix original_ gefolgt von dem Namen der in ihm enthaltenen Klasse zusammensetzt, also original_Girl und original_Boy. Nun kann etwa innerhalb des Namensraums original_Girl der Bezeichner Boy entweder als Boy (also ohne Aspekt) oder als Tracing<Boy> eingeführt werden.
In übereinstimmender Weise müssen die Bezeichner innerhalb des Namensraums composed definiert werden. Die Datei composed.h importiert zudem alle benötigen Dateien mit Aspektdefinitionen und -implementierungen. Anschließend ist nur noch darauf zu achten, dass alle Implementierungsdateien composed.h anstelle der ursprünglichen Definitionsdaten einbinden. In composed.h können nun alle möglichen Konfigurationen beschrieben werden: beide Klassen ohne Tracing, nur Boy
Tracing, nur Girl
Tracing oder beide Klassen mit Tracing. Der besseren Übersicht wegen sind die unterschiedlichen Konfigurationen in eigene Dateien ausgelagert, die ihrerseits in composed.h per Include-Anweisung eingebunden werden.
Will man vorhandenen Code an diese Form der aspektorientierten Programmierung anpassen, dürfte das meist einen gewissen Aufwand erfordern: Definition und Implementierung jeder Klasse und jeder Funktion erfolgen zwingend in einer separaten Datei innerhalb eines eigenen Namensraums. Auch die Erstellung der Konfiguration composed.h erfordert einige Disziplin. Nachteilig ist zudem, dass mit der Angabe des Namensraums vollständig qualifizierte Bezeichner, also etwa original_Girl::Girl, grundsätzlich den Zugriff auf den Bezeichner ohne Aspekt erlauben und somit nicht verwendet werden dürfen. Damit lässt sich die eingangs aufgestellte Forderung nach minimalen Eingriffen in existierenden Code nicht im gewünschten Umfang erfüllen. Andererseits mag der Aufwand akzeptabel erscheinen, wenn die dadurch gewonnen Möglichkeiten aspektorientierter Programmierung entscheidend sind. Bei von vornherein neu zu entwickelndem Code sollte es einfach sein, das beschriebene Schema von Anfang an mit minimalem Zusatzaufwand zu berücksichtigen.
Viele im Zusammenhang von C++ und AOP relevante Punkte haben wir (noch) nicht untersucht und überprüft, etwa die Komposition von Aspekten und Member-Templates, Funktions-Templates und partiell oder vollständig spezialisierten Templates. Da C++ das Überladen von Operatoren gestattet und Expression Templates [[#literatur 3]] eine mächtige Möglichkeit sind, um zur Übersetzungszeit Parse-Bäume von Ausdrücken zu erzeugen, zu analysieren und zu manipulieren, wäre auch die Untersuchung der Komposition von Aspekten mit Sequenzen und Ausdrücken lohnenswert.
Eine interessante Erkenntnis ist, dass als Implementierungstechnik für die aspektorientierte Programmierung in C++ anstelle der Transformation von Quellcode oder der dynamischen Metaprogrammierung auch ausschließlich spracheigene Mittel, vornehmlich Typ-Komposition, Überladen von Funktionsnamen, Verwendung von Alias-Namen und Template-Programmierung, eingesetzt werden können. Angesichts der Komplexität von C++, die auch die Entwicklung eines entsprechenden Präprozessors für AOP erheblich erschwert, ist das erstaunlich.
Die tiefgehende Beschäftigung mit diesem Programmierparadigma wirft die Frage nach dem Umfang von AOP an sich auf. Welche Sprachmerkmale können sinnvoll mit Aspekten kombiniert werden? Welche Eigenschaften muss eine Sprache aufweisen, so dass allein mit Sprachmitteln Aspekte implementiert und ihre Komposition mit den primären Sprachmerkmalen beschrieben werden kann? Analog zur Abgeschlossenheit einer Menge bezüglich einer Operation könnte man dies als Abgeschlossenheit einer Programmiersprache bezüglich AOP bezeichnen. Wie lässt sich die Komposition von Aspekten beschreiben und durchführen? Die Beschäftigung mit der letzten Frage zeigt viele Bezüge zwischen aspektorientierter und generativer Programmierung.
Wem die Aufbereitung vorhandener Quellen von Hand zu mühsam ist, wird dafür vielleicht ein entsprechendes Werkzeug entwickeln. Wir würden uns freuen, wenn Sie Ihre Erfahrungen mit uns und anderen Lesern teilen und uns über die Entwicklung relevanter Werkzeuge informieren würden.
Dr.-Ing. Krzysztof Czarnecki
ist im Bereich Softwaretechnik der DaimlerChrysler-Forschung tätig.
Lutz Dominick
arbeitet in der Zentralabteilung Technik der Siemens AG auf dem Gebiet innovativer Softwareentwicklungstechniken.
Dr. Ulrich W. Eisenecker
ist Professor für Informatik an der Fachhochschule Kaiserslautern, Standort Zweibrücken.
Literatur
[1] Krzysztof Czarnecki, Lutz Dominick, Ulrich W. Eisenecker; Erste Aussichten; Aspektorientierte Programmierung in C++: Teil 1; iX 8/2001, S. 143 ff.
[2] Krzysztof Czarnecki, Lutz Dominick, Ulrich W. Eisenecker; Multiple Aussichten; Aspektorientierte Programmierung in C++: Teil 2; iX 9/2001, S. 142 ff.
[3] Krzysztof Czarnecki, Ulrich W. Eisenecker; Generative Programming. Methods, Tools, and Applications; Addison-Wesley, Reading 2000
| iX-TRACT |
|
Dieser Text ist der Zeitschriften-Ausgabe 10/2001 von iX entnommen.
Parallelprogrammierung - die Kunst der Multi-Core-Nutzung
Agile ALM - agile Praktiken im Application Lifecycle Management
Webentwicklung - Applikationen für mobile Clients