Wissenswertes zu C++ in alphabetischer Ordnung
Geschwurbel von Daniel Schwamm (13.02.1995-08.03.1995)
Inhalt
Die Basisklasse(n) einer Klasse werden in der sog. BASISLISTE
angeführt und oft mit den (Zugriffs-)Spezifizieren "virtual", "public",
"protected" und "private" garniert. Unten werden kurz die Unterschiede zwischen
einer public- und einer virtual public-Ableitung verdeutlicht:
struct X{}; X X struct X{}; X
struct Y:X{}; => | | struct Y:virtual X{}; ==> / \
struct Z:X{}; Y Z struct Z:virtual Y{}; Y Z
struct A:Y,Z{}; \ / struct A:Y,Z{}; \ /
Eine Klasse Y, die private abgeleitet wird von der Basisklasse X, kann auf
alle nicht-privaten Datenelemente und Elementfunktionen von X zugreifen, aber
nur innerhalb ihres Geltungsbereichs. Ein Zugriff selbst auf public-Elemente
von X ist über ein Objekt von Y und dem Punkt- bzw. Pfeiloperator nicht
möglich. protected-Elemente werden bei der private-Ableitung nur eine
Stufe tief vererbt und sind dann private; eine weitere Vererbung ist im Gegensatz
zur public-Ableitung nicht mehr möglich!
struct X{int i;};
struct Y:private X{};
void main(){
Y y;
cout << y.i; // FEHLER!
}
Der Zugriffsspezifizierer protected macht die public-Elemente der
Basisklasse zu protected-Elementen der abgeleiteten Klasse. private- und
protected-Elemente behalten ihren Status.
Eine Klasse Y, die public abgeleitet wird von der Basisklasse X, kann auf
alle nicht-privaten Datenelemente und Elementfunktionen von X zugreifen. Im
Gegensatz zur private-Ableitung kann hier mittels des Pfeil- bzw. Punktoperators und
einem Objekt von Y auf public-Elemente von X zugegriffen werden. Obiges Beispiel
würde also problemlos funktionieren. Zu beachten ist aber, dass die
protected-Elemente weiterhin geschützt sind. Sie können jedoch im
Gegensatz zur private-Ableitung problemlos weitervererbt werden.
Eine Klasse Y, die von der Klasse X private abgeleitet ist, verbirgt vor
dem Anwender alle Elemente der Basisklasse, d.h. auch die public-Elemente.
Über einen Trick kann der Anwender diese aber dennoch ansprechen: Er nimmt
einen Cast von Y* auf X* vor, dann kann er über das X*-Objekt auf die
public-Elemente von X zugreifen.
Da bei einer public-Ableitung die Schnittstelle der Basisklasse
vollständig weitervererbt wird, besitzen die abgeleiteten Klassen neben
ihren eigenen Eigenschaften auch die der Basisklasse(n). Die abgeleiteten
Klassen-Objekte sind also auch immer Objekte der Basisklassen. Dies entspricht
in der Modellierung der IS_A-Struktur.
Die private-Ableitung schränkt die Fähigkeiten der abgeleiteten
Objekte wesentlich ein, sodass diese keine IS_A-Struktur präsentieren
können. Die abgeleitete Klasse ist keine Basisklasse, benutzt diese aber.
Man kann hier von einer USES_A-Struktur sprechen.
Eine Klasse mit nur EINER rein virtuellen Funktion ist bereits abstrakt.
Von solch einer Klasse können keine Objekte erzeugt werden,
unabhängig davon, ob es noch irgendwelche Datenelemente und
nicht-virtuelle oder nicht-rein-virtuelle Elementfunktionen gibt.
Übrigens: Nur virtuelle Elementfunktionen können mit dem Zusatz "=0"
deklariert werden!
Rein abstrakte Funktionsdeklarationen können ausserhalb der
Klassendeklaration sehr wohl eine Default-Implementierung erhalten, die von
abgeleiteten Klassen über den Geltungsbereich-Operator :: (auch
Qualifizierer genannt) angesprochen werden kann. Vorteil: Im Gegensatz zu einer
normalen virtuellen Funktion MÜSSEN solche Funktionen von abgeleiteten
Klassen überschrieben werden (auch wenn sie dabei die Basisklassen-Version
benutzen können), da sie sonst selbst auch abstrakt wären.
Eine Klasse, die von einer abstrakten Klasse abgeleitet wird, muss ALLE
rein virtuellen Elementfunktionen definieren, falls sie selbst nicht abstrakt
sein möchte. "virtual" braucht nur dann vor die Elementfunktionen
geschrieben zu werden, falls die Klasse weiteren Ableitungen zugänglich
ist.
class A{virtual void f()=0;void g(){}};
void A::f(){} // Definition erlaubt!
class B:A{};
void main(){
B b; // Fehler, denn B ist eine abstrakte Klasse!
}
Von abstrakten Klassen sind keine Objekte erzeugbar, daher dürfen sie
auch nicht als formale Argumente oder Rückgabewerte verwendet werden (der
Compiler würde protestieren). Möglich ist aber die Verwendung von
Referenzen und Pointern auf abstrakte Klassen, da diese auf abgeleitete Objekte
verweisen können.
Eine Aktion ist eine Reaktion, die ein Objekt nach einer
Zustandsänderung hervorbringt. Sie kann eine Zeitdauer besitzen oder
zeitlos sein. Im ersteren Fall wirkt sie nur auf das eigene Objekt zurück
(=Methodenaufruf, der etwas berechnet), im zweiten Fall kann sie ein Ereignis
für ein anderes Objekt darstellen (=Nachrichtenverbindung zu einem anderen
Objekt, sprich: Aufruf einer Methode eines anderen Objekts). Aktionen sind also
entweder mit einem Zustand assoziiert (sofortige Aktion auf eigenes Objekt)
oder mit einem Zustandsübergang (Methodenaufruf eines anderen Objekts).
Eine Aktion eines Objekts ist z.B. visualisiert als ausgehender Pfeil in einem
Ereignisfolge-Diagramm, während eingehende Pfeil Ereignisse darstellen,
die u.U. durch Aktionen anderer Objekte hervorgerufen werden.
Darstellungsmethode für den Zuständen-Ablauf mehrerer
Task-Objekte. Legende: ---=running, ...=suspended, nichts=finished.
Task X|---...---.......-----
Task Y|------......------...
-----------------------------> Zeit-/Ereignisachse
Über dieses Diagramm kann ein Programmierer versuchen, die
Aktivitäten der einzelnen Threads mittels sleep()-Aufrufe zu koordinieren.
Dies ist jedoch ausgesprochen mühsam. Besser ist, dass ein
Prozess sich Schlafen legen kann und einem anderen Task über
wait_for_exit() (nach Schader wäre der Aufruf terminationAwaited() besser)
mitteilt, dass dieser in nach Abschluss seiner Arbeit zu wecken
hat.
Wenn Threads von der Idee her unabhängig sein sollen, wozu ist dann
eine Serialisierung überhaupt notwendig? Aufgrund von begrenzten
Ressourcen, wie z.B. Diskettenlaufwerke oder auch das cout-Objekt. Die
Koordination kann über sleep() oder wait_for_exit() erfolgen, aber auch
über Prioritäten und Task-Scheduler erfolgen. Am sichersten jedoch
ist die Verwendung von Semaphore, so existiert bei Borland eine
Nachtwächter-Klasse namens TMutex. Solche Semaphore können auch
verhindern, dass sich Duplikate eines Objektes in verschiedene CPUs
laden, sodass es bei der Ausgabe zu Inkonsistenzen kommen kann.
Die TMutex-Klasse führt als Attribut eine Mutex-ID und geht eine
Beziehung mit einer Lock-Klasse ein. Nur wenn ein Task den Konstruktor der
Lockklasse erfolgreich aufrufen kann, kann er auf die geschützte Resource
zugreifen. Kritisch anzumerken ist, dass keine explizite Freigabe des
Lock-Objekts möglich ist, z.B. Unlock(), sondern dass der
Destruktor-Aufruf am Ende eine Block-Geltungsbereiches abgewartet werden
muss.
Die Antwortmenge ist eine Metrik, die besonders auch für objektorientierte SW
geeignet ist. Aufsummiert werden hier alle Methoden einer Klasse, sowie alle
klassenfremden Methoden, die innerhalb der eigenen Methoden aufgerufen werden.
Dies gibt die Komplexität einer Klasse wieder.
Eine Anweisung (Statement) wird vollständig abgearbeitet und alle
Seiteneffekte dabei vermerkt. Erst dann wird die nächste Anweisung
abgearbeitet. So wird z.B. garantiert, dass eine
Postfix-Dekrement-Operation in einer Anweisung in der nächsten Anweisung
bereits vollständig durchgeführt ist. C++ wird daher auch
Seiteneffekt-Programmierung genannt (jeder Funktionsaufruf gilt bereits als
Seiteneffekt). Ein einzelnes ";" ist eine vollständige Anweisung!
Anweisungen steuern die Verarbeitung und den Kontrollfluss von
Programmen. Funktionsrümpfe, Deklarationen, Ausdrücke mit
abschliessendem ";" und Blöcke stellen Anweisungen dar.
Jede Anweisung kann mehrfach mit Labels markiert werden.
Anwendungsrahmen dienen im Wesentlichen der Erzeugung von Human Interface Clients (HIC)
für Applikationen, denen sie damit ein typisches Look&Feel aufzwingen. Im Gegensatz
zu Toolkits können mit ihnen aber auch Objekte für die Applikation erzeugt werden,
so enthält die OWL z.B. auch Container-Klassen mit impliziter Sortierung der Elemente.
Operatoren können links- oder rechts-assoziativ sein.
Links-assoziative Operatoren sind i.d.R. zweistellige Operatoren, die von links
nach rechts ausgewertet werden (Ausnahme: Zuweisungsoperator), d.h. es gilt:
a/b/c <==> (a/b)/c oder a[1][2] <==> (a[1])[2]. Die einstelligen
Operatoren dagegen sind meist rechts-assoziativ, d.h. sei werden von rechts
nach links ausgewertet.
Im OOA-Modell können atomare Attribute verwendet werden. Das sind
Attribute, die mehrere Attribute umfassen können, wie z.B. das Attribut
"Anschrift". Im OOD-Modell müssen diese atomaren Attribute dann wieder
Normalisiert werden. Auch abgeleitete Attribute und die Surrogate, von denen im
OOA-Modell abstrahiert werden kann, sind im OOD-Modell explizit zu
benennen.
Attribute, die in abstrakten Klassen auftauchen und in den abgeleiteten
Klassen, zeigen dem OOP-Entwicklern an, dass diese Attribute in der
abstrakten Klasse rein virtuell zu deklarieren sind.
Bei Attributen, die in der OOA ermittelt werden, sind folgende
SONDERFÄLLE zu beachten: Ein Attribut, das nicht zu allen Objekten einer
Klasse passt, verlangt u.U. eine Neustrukturierung der Klasse; eine
Klasse, die nur ein Attribut führt, muss darauf geprüft werden,
ob sie nicht als Attribut einer anderen Klasse genügt; gleiche Attribute
sollten nicht in mehreren Klassen Verwendung finden; ableitbare Informationen
sollten nicht in eigenen Attributen untergebracht werden (erst in der OOD);
lässt sich für eine Klasse kein Attribut finden, muss die
Notwendigkeit der Klasse überprüft werden.
Der Aufrufoperator kann benutzt werden, um einem Datentyp den Charakter
einer Funktion zu geben, die einen frei definierbaren Funktionswert
zurückliefert. So lässt sich z.B. eine Polynomklasse denken, deren
Objekt p für ein int-Argument x einen double-Funktionswert y=p(x)
zurückliefert.
double Polynom::operator()(int x){
double px=a[n];
for(int i=n-1;i>=0;i--)
px=px*x+a[i];
return px;
}
void main(){
double a[]={1.1,2.2,3.3};
Polynom p(2,a);
cout << p(6) << endl;
}
Enumeratoren-Konstanten können mit gleichen Werten initialisiert
werden. Doch die Sinnhaftigkeit eines solchen Vorgehens kann bezweifelt werden.
Insbesondere beim Casten gibt es dann durch Mehrdeutigkeiten Probleme,
z.B.:
enum Moral{gut=1,schlecht=1};
void main(){
Moral m1=1;// Fehler, weil 1 falscher Typ
Moral m2=(Moral)1;// Fehler, weil mehrdeutig
}
Jede Enumerator-Deklaration ist IMMER auch Deklaration! Ihre Aufgabe ist
in erster Linie, die Lesbarkeit von Programmen zu erhöhen oder als
Konstanten in Klassen zu dienen.
Ausdrücke (Expressions) sind kleinere Einheiten in C++-Programmen als
Anweisungen, d.h. eine Anweisung kann einen Ausdruck oder mehrere
Ausdrücke enthalten, nicht aber umgekehrt.(?) Bei der Ermittlung des Werts
eines Ausdrucks sind Seiteneffekte möglich, d.h. Operanden können
sich durch Operatoren ändern. Bei der Auswertung eines Ausdrucks ist die
Assoziativität und die Priorität der Operatoren zu beachten.
Was ist der Sinn der Ausnahmebehandlung? Es gilt, dass ein Programmierer
einer Bibliothek oft nicht in der Lage ist, Fehler in intelligenter Weise
abzufangen, da seine Programme anwendungsunabhängig sind. Der Benutzer
einer Bibliothek dagegen weiss sehr wohl, wie er mit Fehlern in seinem Programm
verfahren sollte.
Als Anwendungsbeispiel einer Fehlerbehandlung sei folgendes Programm
aufgeführt, welches einen Indexüberlauf abfängt:
struct Vector{
int *p; int size;
class Überlauf{int ix;Überlauf(int i):ix(i){}}
int& operator[](int i){
if(i>=0 && i<size)return p[i];
throw Überlauf(i);
}
}
void main(){
Vector v(3);int i;
try{char *s;gets(s);i=atoi(s);cout<<v[i]<<endl;}
catch(Vector::Überlauf u){
cout << u.i << "=falscher Index";
}
catch(...) ...
// wenn Fehler nicht abgefangen wird, dann Terminierung.
In Fehlerklassen sind auch Templates, Enumeratoren und Ableitungen
erlaubt. Enumeratoren können z.B. dazu verwendet werden, innerhalb einer
catch-Anweisung eine weitere Selektion mittels switch-Anweisungen
durchzuführen.
In neueren Compilern ist die Ausnahmebehandlung integriert. So wirft der
new-Handler per Default bereits bei Misserfolg einen Fehler aus, der
abgefangen werden muss, damit das Programm nicht terminiert. Dadurch
ändern sich bestehende Programme, da bisher nur eine Null
zurückgeliefert wurde, was unbehandelt folgenlos blieb. Um die Anwender
nicht zu umständlichen Umschreibearbeiten mit try-Blöcken zu zwingen,
gestattet Borland mittels "new_handler(0);" die Deaktivierung des
Fehlerauswurfs.
Die Funktion exit() ruft noch den Destruktor von automatischen Objekten
auf, die Funktion abort() tut nicht einmal das. Will man auch alle Heap-Objekte
zerstören, so wird eine Ausnahme-Behandlung unumgänglich.
Das Baseball-Modell von Coad und Nicola betont das frühe
Verfügbarmachen von Analyse- und Designergebnissen. Nach ein wenig
Analyse, wird ein wenig Design betrieben, dann ein wenig Implementierung, dann
beginnt man wieder von vorne, wobei das Ergebnis immer komplexer wird. Wichtig
dabei ist, dass die Trennung zwischen den Designkomponenten nicht
verwischt wird, evtl. sogar von verschiedenen Teams realisiert wird. Durch die
Prototyping-Philosophie kann der Benutzer bereits sehr früh in die
Entwicklung von Systemen mit einbezogen werden.
Beziehungen werden nicht in der Strukturschicht, sondern in der Service-
oder Attributschicht des OOA-Modells modelliert. Werden sie in der
Attributschicht gebildet, heissen sie Instanzverbindungen, die durch Linien mit
Kardinalitäten ins Modell einzuzeichnen sind. Werden sie in der
Serviceschicht gebildet, heissen sie Nachrichtenverbindungen, die als Pfeile
einzuzeichnen sind (und die evtl. den Namen der aufzurufenden Methode tragen).
Instanzverbindungen sind immer dann nötig, wenn ein Objekt zur
Ausführung seiner Aufgabe ein anderes Objekt benötigt.
Nachrichten-Verbindungen sind immer dann nötig, wenn ein Objekt zur
Ausführung seiner Aufgabe die Methoden eines anderen Objekts
benötigt.
Insbesondere wenn Viele-zu-Viele-Instanzverbindungen in einem OOA-Modell
identifiziert werden können, sollte man sich überlegen, ob zwischen
diesen Verbindungen nicht noch eine weitere Klasse einzufügen ist (z.B.
eine Ereignisklasse, die zu erinnernde Attribute führt).
Bei Objektbeziehungen der OOA sind folgende SONDERFÄLLE zu beachten:
Bei (*:n):(*,m)-Beziehungen sollte evtl. eine Zwischenklasse eingefügt
werden, die beziehungsrelevante Informationen festhält; auch bei einfachen
Beziehungen sollten in einer der beteiligten Klassen Attribute geschaffen
werden, die beziehungsrelevante Informationen führen; geht eine Klasse nur
eine Beziehung mit sich selbst ein, kann zur näheren Beschreibung der
Beziehungsart eine Zwischenklasse eingefügt werden; zwischen Klassen
können mehrere verschiedenartige Beziehungen bestehen - sie alle sind zu
modellieren (und evtl. durch Attribute oder Zwischenklassen näher zu
beschreiben); u.U. genügt es, wenn ein Objekt die Beziehung verwaltet,
weswegen nur eine Kardinalitätsangabe nötig ist.
C++ unterscheidet zwei Arten von Bindung:
-
Interne Bindung: Variablen/Funktionen mit interner Bindung gelten nur
innerhalb der Datei, in der sie deklariert sind. Andere Programmdateien
können mit ihren Namen nichts anfangen (auch nicht über
extern-Deklarationen). Die lokalen und Funktionsgeltungsbereiche (aber nicht
Funktionsnamen!) sind immer intern gebunden! Auch inline-, const-ohne-extern-
und static-Variable des globalen Geltungsbereichs sind intern gebunden, ebenso
typedefs, enums, Templates, sowie zuletzt Klassen mit nur inline-Funktionen und
ohne static-Objekte, die nicht in Deklarationen mit externer Bindung
auftauchen. Beispiel: "const int i=2;" bedeutet, dass die Konstante
"i" nur in einer Programmdatei erkannt wird. Besonderheit: static bindet Typen
immer intern, ausser bei Klassenelementen; die werden seltsamerweise
extern!
-
Externe Bindung: Variablen/Funktionen mit externer Bindung können
in jeder Programmdatei verwendet werden, sofern ihre Namen nicht durch
gleich lautende Variablen verdeckt werden. Nötig ist dazu jedoch eine
extern-Deklaration (im Falle von Klassen der komplette Deklarationsrumpf ohne
extern). Per Default sind globale Variable und Funktionen extern gebunden.
Nicht-inline-Elementfunktionen und nicht-static-Klassenelemente sind
zwangsweise extern gebunden, ebenso globale Variablen mit den Spezifizierer
"extern". Mittels const, typedef und enum werden globale Variablen
"interniert", da für sie keine adressierbaren Symbole
existieren, sondern diese direkt in den Code eingetragen werden.
Ausschlaggebend für die Bindung ist nicht die Deklaration, sondern
die Definition. So werden Klassen erst durch eine Definition ausserhalb
der Klassendeklaration extern. Da auch static-Klassenelemente und
friend-Funktionen ausserhalb des Deklarationsrumpfes definiert werden,
machen sie Klassen extern. Und ebenso wirken inline-Klassendefinition
ausserhalb des Deklarationsrumpfes.
Neuer Datentyp von C++, der bisher aber nur vom g++ direkt
unterstützt wird. Werte: TRUE=1, FALSE=0.
Das CM von Meyer betrachtet nicht die Entwicklung von Modellen für
bestimmte Problembereiche, sondern vielmehr die Entwicklung von Clustern. Das
sind logisch zusammengehörende Klassen. In einem dreistufigen Prozess
(SPEC, DESIMPL, VALGEN) können in zeitlicher Überschneidung mehrere
Cluster parallel und unabhängig voneinander entworfen werden. Ein
besonders bzgl. von Reuse-Pattern-Entwicklung interessanter Ansatz.
Das Schlüsselwort const kann vor oder hinter einem Datentyp stehen.
Einmal wird dadurch der Variableninhalt, einmal der Zeiger auf die Variable
zur Konstanten gemacht. Zwischen diesen abgeleiteten Datentypen ist keine
Standardkonversion möglich!
const int i=2;
int const *j=&i; // ERROR
int const *k=(int*)i; // OK
Wird eine Referenz eines konstanten Objekt an eine Funktion
übergeben, und diese Referenz dann verändert, so erzeugt der Compiler
mit Warnung eine temporäre Kopie der Referenz. Diese Kopie ist
änderbar, jedoch wird der call-by-reference-Mechanismus ausgeschaltet,
d.h. bei Rückkehr aus der Funktion hat sich die Referenz nicht
geändert.
void f(int &i){i++;} // i ist temporäre Kopie von ci, trotz
void main( // call-by-reference mittels &-Operator
const int ci=2;
f(ci);
};
In Elementfunktionen, die mit const deklariert wurden, können
durchaus Variablen verändert werden, ohne dass sich der Compiler
beschwert. Diese Variablen müssen aber lokal sein. Datenelemente der
Klasse können nur dann verändert werden, wenn sie mit "mutable"
deklariert werden. Alternativ dazu kann aber auch eine Kopie des aktuellen
Objekts angelegt werden, welches lokalen Geltungsbereich besitzt und damit frei
veränderlich ist.
struct X {
int i;
int f() const {
X *THIS=(X*)this; // const MUSS mit (X*) weg-gecastet werden!
THIS->i++; // ist jetzt erlaubt, weil THIS lokal ist.
return THIS->i; // auch in this wurde i mitverändert!
}
};
Es können konstante Objekte erzeugt werden mittels "const X
x;". Zu beachten ist jedoch, dass solche Objekte keine Funktionen
aufrufen können, die nicht const spezifiziert wurden (ausser sie sind
inline definiert und ändern nichts am Objekt). Die static-Elemente sind
weiterhin änderbar, da sie objektunabhängig sind. Der this-Zeiger ist
bei nicht-const-Funktionen vom Typ X*const, bei const-Funktionen vom Typ const
X*const. Es existiert keine implizite Konversion von X*const nach const
X*const, weswegen der const-Spezifizierer als Unterscheidungskriterium
für überladene Funktionen genügt. Und ausserdem können
aus const-Funktionen keine nicht-const-Funktionen aufgerufen werden!
Elementfunktionen können const sein. Wird in einer solchen Funktion
das this-Objekt verändert, bricht der Compiler ab. Dennoch
lässt sich durch einen Trick die Veränderung des this-Objekts
erreichen, indem einfach eine Kopie von this erzeugt wird:
class X{
int i;
void f()const{
i=5; // Fehler
this->i=5; // Fehler
X *This=(X*)this; // Referenzkopie erstellen
X->i=5M; // OK: this-Objekt wurde verändert
}
};
Containerklassen sich auf drei Arten in C++ realisieren:
-
void-Pointer-Version: Es wird eine Listenstruktur erzeugt, die
void*-Objekte aufnehmen kann. Vorteil ist die einfache Implementierung und
Geschwindigkeit eines solchen Codes. Nachteilig ist jedoch, dass gemischte
Objekte in der Liste ablegbar sind, ohne spezielles Typ-Attribut aber beim
Zugriff nicht auf den ursprünglichen Typ zurück gecastet werden kann,
da man ihn nicht kennt. In Zukunft soll es jedoch dazu eine Funktion
"typeid(TYP)" geben.
-
Basisklassen-Version: Die Basisklasse vererbt an die abgeleitete
Klassen, die die Container-Elemente führen, seine virtuelle
public-Schnittstelle (mit Default-Implementationen). Dieses Vorgehen ist
übersichtlich und objektorientiert, kann bei vielen verschiedenen
Elementtypen aber rasch zu einer "Klassen-Explosion" führen.
"fat-interfaces" sind übrigens zu vermeiden. Nur die virtuellen
inline-Funktionen werden im Headerfile dargestellt.
-
Template-Version: Ein und derselben Container-Klasse wird beim Aufruf
der Elementtyp mit übergeben, wodurch der Compiler selbstständig eine
spezielle Version der Container-Klasse erzeugt. So kommt es zwar immer noch zu
einer Klassen-Explosion, jedoch muss sich dazu kein Programmierer
bemühen. Der Programmcode kann dadurch erheblich verlängert werden,
der Code ist in den Headern offen gelegt, die hohe Flexibilität und 100%
Typ-Sicherheit ist jedoch ein grosser Pluspunkt.
Es ist zu beachten, dass die in der Container-Liste geführten
Elemente meistens nicht von der Existenz des Containers abhängen sollten,
d.h. dass eine Löschung eines Objekts aus dem Container nur den
Pointer bzw. die Referenz darauf löscht, nicht aber das Objekt selbst
zerstört! Werden jedoch wie etwa bei der Template-Implementation jeweils
Kopien der Objekte angelegt, sind diese selbstredend zu zerstören. Ob man
sich für eine Pointer-Container oder Kopien-Container entscheidet, ist
designabhängig. Ausbaufähige Idee: Template- und void*-Container
mischen, dann erhält man im Idealfall Typ-Sicherheit ohne
Code-Duplizierung.
Bei void*-Container findet keine Typ-Prüfung statt, bei
Object-Ansatz-Container erst zur Laufzeit, und bei Templates während der
Übersetzung. Typ-Kontrolle kann durch static-Variablen, String-Vergleiche
der Klassennamen, sicherere benutzerdefinierte Casts, Indizierung und
Enumeratoren nachträglich realisiert werden. Eine Typ-Prüfung zur
Laufzeit ist für C++ derzeit in Arbeit. Beim Object-Ansatz müssen
auch für eingebaute Typen wie int eigenen Klassen abgeleitet werden.
Templates lassen den Code aufgedeckt und machen Programme länger; aber auch sie
erlauben Vererbung, sodass verschiedene Objekte in ein Container ablegbar
sind! Da Templates auf virtual verzichten können, sind sie schneller, da
sie Elementfunktionen inline deklarieren können. Zu beachten ist,
dass für Sortierfunktionen u.ä. die entsprechenden Operatoren
überschrieben werden.
Bei Vererbungsstrukturen ist zu beachten, dass der Copy-Konstruktor auch
die Teile der Basisklasse zu konstruieren, sprich: initialisieren hat. I.d.R.
geschieht dies in der Initialisiererliste durch Aufruf des Copy-Konstruktors
der Basisklasse(n). Dasselbe gilt in ähnlicher Weise auch für den
Zuweisungsoperator (hier ist ein A::operator=(a) nötig).
Copy-Konstruktoren sind Konversionsfunktionen. Sie werden bei
Initialisierungen (über "=" oder "()"), nicht aber Zuweisungen aufgerufen.
Wichtig sind sie, wenn Objekte mit Zeigern auf den Heap dupliziert werden
sollen. Mittels Copy-Konstruktor lassen sich neue Speicherbereiche im Heap
anlegen und initialisieren (Deep Copy), statt einfach einen Zeiger auf die alte
Resource umzubiegen, sodass Änderungen an einem Objekt
Änderungen am kopierten Objekt mit sich bringen..
Copy-Konstruktoren dürfen als formales Argument kein Objektwert
führen, da dies eine Endlosrekursion bewirken würde. Auch Pointer
sind verboten! Als Argumente zulässig sind jedoch Referenzen auf
(const-)Objekte.
Um den Compiler am Erzeugen eines Standard-Copy-Konstruktors zu hindern,
muss ein eigener Copy-Konstruktor angelegt werden. Befindet sich dieser im
private-Teil, so kann er vom Anwender nicht aufgerufen werden. Diese
Eigenschaft vererbt sich auch weiter, d.h. existiert auch nur eine Basisklasse
mit private-Copy-Konstruktor, erzeugt der Compiler für die abgeleiteten
Klassen keinen Standard-Copy-Konstruktor (da diese ja den Copy-Konstruktor der
Basisklasse aufrufen können müssen).
Das Coupling gibt die äussere Verflechtung einer Klasse wieder,
d.h. der Grad, mit dem sie mit anderen Klassen Beziehungen eingeht. Dazu
gehören Objektbeziehungen, Nachrichten-Verbindungen und
Aggregationsstrukturen (aber nicht Vererbung). Das Coupling sollte
so gering wie möglich gehalten werden, um Domino-Effekte bei
Klassenänderungen zu minimieren.
static-Element-Member können als Zuweisungswerte an Argumente in
Standard-Konstruktoren verwendet werden!
struct X{
static int Anzahl;
X(int i=Anzahl){/*...*/}; // OK
};
Die Präprozessor-Anweisungen eignen sich auch zum Definieren von
regelrechten Funktionen (allerdings nur void-Funktionen). So kann z.B. eine
Variable wegen der Überladbarkeit von cout von BELIEBIGEM Typ sein und
dennoch folgendermassen ausgegeben werden:
#define F(x) cout << x << endl;
void main(){
F(2);
F(2.2);
F("Text");
}
Enumeratoren zählt man zu den Deklarationen, da über sie
NEÜ Namen mit NEÜR Semantik im Programm erzeugt werden. Dagegen
gelten typedef-Anweisungen nicht als Deklarationen, da bei ihnen nur alte Namen
neü Namen erhalten, ohne dass sie ihre Semantik verändern.
Deklarationen sind Anweisungen (und nicht nur Ausdrücke).
Deklarationen sind dann Definitionen, wenn der Compiler die Grösse der
Deklaration erkennen kann, wenn z.B. in Klassendeklarationen keine dynamischen
Felder enthalten sind. Es gilt:
class IntVek1; // nur Deklaration
class IntVek2{ // Deklaration UND Definition, da
int i; // Compiler bereits Objekte von IntVek
IntVek2(); // erzeugen kann.
};
class IntVek3{ // reine Definition (Aber: Definitionen
int i; // sind immer auch Deklarationen)
IntVek(){i=0;}
};
Im Beispiel "class X{void f();};" gilt void als
Deklarationsspezifizierer und f() als Funktionsdeklarator. Eine Klasse ist
erst dann DEFINIERT, wenn ALLE Elementfunktionen und static-Datenelemente
definiert sind,. Nicht-static-Datenelemente können erst in Konstruktoren
definiert werden, da sie objektabhängig sind, d.h. "class X{double
d=1.2;};" ist ein Fehler.
Da Destruktoren keine formalen Argumente führen dürfen,
können sie nicht überladen werden. Aus diesem Grund besitzt jede
Klasse entweder keinen oder genau einen Destruktor. Der kann auf drei Arten
aufgerufen werden: Beim Programmende in globalen Objekten oder static-lokalen
Objekten, beim Verlassen eines Blocks in lokalen Objekten, oder explizit beim
Löschen von Objekten mittels "delete". Ein Standard-Destruktor
zerstört nur die einfachen Datenelemente, löscht aber keine
verpointerten Strukturen im Heap. Destruktoren sind objektabhängig und
zerstören Objekte, daher dürfen sie weder static noch const
deklariert sein. Im Gegensatz zu Konstruktoren können sie jedoch virtual sein,
was den Vorteil hat, dass abgeleitete Objekte nicht explizit die
Destruktoren ihrer Teilobjekte aufrufen müssen.
Das OOA-Modell identifiziert Dienste in der Service-Schicht. Unterschieden
werden dabei die einfachen Dienste wie erzeugen(), verbinden(), ändern()
und löschen(), die nicht explizit zu benennen sind, und komplexe Dienste.
Typische Vertreter der komplexen Dienste sind etwa berechne()-Funktionen, die
sich auf die eigenen Datenelemente beschränken, und
überwache()-Funktionen, die i.d.R. Methoden wie initialisiere() und
beende() enthalten und mit externen Objekten eine Verbindung eingehen.
Eine in eine andere Klassendeklaration eingefügte Klassendeklaration
bezeichnet man als eingebettete Klasse. Der eingebetteten Klasse entstehen
dadurch so gut wie keine Zugriffsvorteile auf die umgebende Klasse (das Gleiche
gilt auch umgekehrt) - es muss immer vollständig (über Objekte)
quantifiziert werden. Daher ist es meist besser, auf eingebettete Klassen zu
verzichten und sie ausserhalb zu deklarieren. Eine eingebettete Klasse Y
in der Klasse X kann übrigens auch über "X::Y y;" Objekte
erzeugen, ohne dass es Objekte von X geben muss! Externe
Funktionsdefinitionen erfolgen dann in diesem Fall vollständig
qualifiziert über "void X::Y::f(){...};".
Eingebettete Klassen sind nicht etwa im globalen Namensraum deklariert,
sondern im Geltungsbereich der umgebenden Klasse. Sie ist lokal zur umgebenden
Klasse. Dies bemerkt man schon dadurch, dass nur über verkettete
Pfeil-/Punktoperatoren auf eine eingebettete Klasse zugegriffen werden kann.
Vorteil: Weniger globale Namen.
Über nur einen Aufruf können mehrere Elementfunktionen
aufgerufen werden, wenn diese einen Zeiger auf ihr Objekt
zurückliefern.
struct X{
X f(){cout << 1; return *this;}
X g(int i){cout << i; return *this}
};
void main(){
X x;
x.f().g(2).f().g(3).g(4); // OK!
}
In C++ lassen sich Datentypen ableiten, die Pointer auf Datenelemente bzw.
Elementfunktionen darstellen:
struct X{
int i;
int f(double &d){return d*i;}
static int is;
static int fs(double &d){return d*is;}
};
int X::is=1;
void main(){
int X::*dp=&X::i;
int (X::*fp)(double&)=&X::f; // Funktionen sind einzuklammern
int *dsp=&X::is; // static-Version mit normalem Pointer!
int (*fsp)(double&)=&X::f2;
X x;
x.*dp=2;
cout << (x.*fp)(3.3); // Funktionen sind einzuklammern
cout << *dsp;
cout << (*fsp)(3.3);
}
Zeiger auf Elementfunktionen werden i.d.R. durch virtuelle Funktionen
ersetzt. Die Deklaration eines Element-Pointers legt nur den Typ fest, nicht auf
welches Element er letztlich zeigen wird. Das verleiht ihnen eine gewisse
Flexibilität beim Einsatz. Auf static-Elementfunktionen können keine
Element-Pointer gebogen werden, da Element-Pointer immer implizit einen
this-Zeiger mitführen. Daher lassen sich auch keine globalen Funktionen
mit Element-Pointern verbinden. Normale Pointer lassen sich dagegen sehr wohl
auf static-Elemente setzen.
Die Programmentropie ist das Mass dafür, inwieweit ein
Programm durch Modifikationen in "Unordnung" geraten ist.
Enumerationen sind immer Definitionen, nie nur Deklarationen. Über
sie werden neue Namen in ein Programm eingeführt, die einen konstanten
Wert führen. Dieser Wert wird allerdings nicht in einer bestimmten
Speicherzelle gespeichert. Der Compiler setzt den Wert vielmehr direkt in den
Code ein. Daher gelten enums (wie typedefs) als bindungslos (tatsächlich
sind sie aber intern gebunden).
Achtung: Befinden sich im Namensraum von enum-Definitionen Variablen mit
gleichem Namen, so werden stets die enums verdeckt (evtl. mit Warnung vom
Compiler)!
Ein Ereignis wird durch ein Objekt oder ein externes System (z.B.
Datenquelle/-senke) ausgelöst. Es ist ZEITLOS, weswegen mehrere Ereignisse
gleichzeitig in einem System auftreten können. Bestimmte Ereignisse
bewirken bei bestimmten Objekten einen genau definierten Zustandsübergang,
d.h. sie überführen ein Objekt von einem Zustand in einen neuen
Zustand, i.d.R. indem sie eine entsprechende Methode des Objekts aufrufen (da
nur diese auf die Attribute zugreifen dürfen). Es gilt also, dass
Ereignisse immer einen Sender und meistens einen Empfänger haben. Sie
können einem Empfänger Informationen zutragen. Implizit führen
sie z.B. immer das Attribut des Zeitpunkts. Ereignisse können
untereinander abhängig oder auch unabhängig sein. Ein Objekt kann ein
Ereignis regelrecht erwarten.
Eine blockfreie Anweisung (die also globalen Geltungsbereich besitzt, d.h.
dem Geltungsbereich Datei) der Form "int i;" ist nicht nur Deklaration, sondern
auch Definition der Variable i. Zudem wird dieselbe auch noch mit dem Wert 0
initialisiert! Anders sieht es aus, wenn ein "extern" vorgeschaltet wird:
"extern int i;" ist nur Deklaration, keine Definition! Dagegen ist "extern int
i=2;" wieder eine Definition, die nur einmal im Programm vorgenommen werden
kann und muss (da sich sonst der Linker über die unbekannte Variable i
beschwert). Ist doch simpel, oder?
In der Daten-Management-Komponente muss entscheiden werden, ob
Klassen und Objekte persistent (räumlich UND zeitlich dauerhaft) oder
transient zu konzipieren sind. DM wird nötig, wenn sehr grosse
Objekte anfallen, wenn Objekte mehrfach genutzt werden (Data Sharing), wenn sie
als Kommunikationsmedium dienen, und wenn nur wenige Objekte zu einem
Zeitpunkt relevant sind (sodass das Halten der restlichen Objekte
Platzverschwendung darstellt).
ASPEKTE der externen Datenhaltung sind die Geschwindigkeit, die deutlich
abnimmt, die Fragen des Speicherformats und der Dateninterpretation, die
Zugriffssprachen, und die Konsistenz der Objekte. All diese Aspekte werden von
RDBS berücksichtigt, die aber durch ihre mengenorientierte embedded SQL
nicht besonders objektorientiert sin. Die Zukunft von OODBS ist dagegen sehr
unsicher, denn es ist wahrscheinlicher, dass die Objekte sich in einem LAN
irgendwann selbst koordinieren können. Dies verlangt jedoch neue HW und
neue BS.
Bevor Objekte in DBS abgebildet werden können, benötigt man
zunächst ein in sich konsistentes konzeptionelles Modell. Dies kann ein
OOA-Modell oder ein ERM sein, wichtig ist, dass dabei die statischen
(Gültigkeitsbedingungen an Objekte) und dynamischen (Einschränkungen
der Operationen) Integritätsbedingungen definiert werden, und dass
die Objekte, ihre Beziehungen und ihre Methoden beschrieben werden. Nachteilig
an Relationen ist, dass Objekte mit Beziehungsattributen
"verschmutzt" werden, und dass bei Erweiterungen neue
Beziehungstabellen zu entwickeln sind, was die Flexibilität
einschränkt.
Realisiert wird die externe Datenhaltung i.d.R. durch DBS, auch wenn hier
mit Impedance Mismatch (Reibungsverluste) beim Zugriff und Speichern gerechnet
werden muss. Fragen: Wie die Operationen auf die Objekte speichern, wie
die Beziehungen und Strukturen ablegen, welches Datenmodell anwenden, welche
Zugriffssprache (SQL) verwenden, wie die Objektidentität sicherstellen,
wie verschiedene Versionen verwalten? Zur Beantwortung dieser Fragen sind
Zugriffs- und Benutzerprofile zu erstellen, (objektorientierte) DBS (z.B.
ObjectStore) oder gar BS (NeXTStep) zu begutachten,
Nebenläufigkeit-Methoden zu studieren, usw.
Wir unterscheiden objektabhängige und klassenabhängige
Persistenz. Bei beiden Persistenz-Typen müssen transiente bzw. transient gedachte
Objekte den Funktionsoverhead der persistenten Objekte mitführen. Heute wird
der Einfachheit halber i.d.R. nur eine klassenabhängige Persistenz
geboten. Surrogate für die Objektidentität können hier durch
Object-Server bereitgestellt werden. Objekte können sich selbst speichern
(objektorientiert gedacht), sich selbst speichern lassen (nicht mehr so stark
objektorientiert), oder sie werden gespeichert (kaum noch objektorientiert).
Letztere Methode wird über einen Object-Server erreicht, der friend der
persistenten Klassen sein muss, um auf deren innere Struktur zugreifen zu
können.
Speichermöglichkeiten von persistenten Objekten mit
Vererbungsstrukturen. Beispiel:
/Student\
Person HiWi
\Angest /
-
Repeat Class Model: Jede Basisklasse wiederholt sich in den abgeleiteten
Klassen. Die Objekte werden in allen Klassen abgespeichert, in denen sie
vorkommen.
-
Universal Class Modell: Es wird eine Universal-Klassen-Tabelle aus allen
Attributen aller Klassen gebildet.
-
Leaf Overlap Model: Wie bei Repeat Class Model, aber die Objekte werden
nur in ihrer "Blatt"-Klasse abgelegt.
-
Split Instance Model: Die Instanzen werden aufgesplittet, sodass
jede Klasse nur die eigenen Attribute führt. Dadurch verteilt sich ein
Objekt zwangsläufig auf alle Klassen, in denen es vorkommt.
Speicherung von Beziehungen und Aggregations-Strukturen:
-
1:1-Beziehungen (Fahrzeug-Lenkrad): Eine Klasse führt als Attribut
die Schlüsselattribute der Beziehungsklasse.
Fahrzeug: #FID Hubraum LID Lenkrad: #LID Form
1 1100 2 1 rund
2 2000 1 2 oval
-
1:n-Beziehungen (Liste-Element): Wie die 1:1-Beziehung, nur dass
Objekte, die Mehrfach-Beziehungen eingehen, auch mehrfach in der Relation
gebildet werden.
Liste: #LID Titel EID Element: #EID Inhalt
1 Gemüse 1 1 Bohnen
1 Gemüse 2 2 Karotten
2 Obst 3 3 Äpfel
-
m:n-Beziehung (Fahrzeug-Strecke): Hierfür wird eine eigene
Relation gebildet, die aus den Schlüsselattributen beider Klassen besteht.
Zu beachten ist, dass nirgendwo Nullwerte geführt werden dürfen
(da sich diese keinen Objekten eindeutig zuweisen lassen).
Fahrzeug: #FID Hubraum Strecke: #LID Start
1 1100 1 Mannheim
2 2000 2 Hannover
Fahrzeug-Strecke: #FID #LID
1 1
1 2
2 1
2 2
Statt in RDBS können Objekte auch in normalen Textdateien
gespeichert werden, die z.B. eine "getaggte" Strukturnotation
verwenden (ähnlich ASN.1). Auch die Auflösung von Objekten in
binäre Relationen ist denkbar; dadurch geht zwar jegliche
Strukturinformation verloren und man erhält eine Unzahl von Relationen,
doch sofern die Objekte ihren eigenen Aufbau kennen, können sie alle
Informationen selbst zusammensuchen. Auch bei Benutzung eines Object-Servers ist
es nicht ungewöhnlich, wenn Objekte sich über istream- oder ostream-Objekte
selbst laden/speichern, während die Surrogateerzeugung (z.B. über
eine static-Variable) dem Server obliegt. Die Persistenz kann in den Programmen
durch simple "#ifndef"-Direktiven an und aus gestellt werden.
Es gibt keine Felder der Grösse Null, keine Felder mit Referenzen,
keine Felder mit void-Pointern und keine Felder mit Funktionen (aber
Funktionspointern!). Achtung: C++ prüft keine Feldergrenzen. Der
Bezeichner eines Feldes ist nicht modifizierbar, d.h. direkte Zuweisungen der
Form "char ch[2]; ch="a";" sind nicht möglich, im Gegensatz zu
"char *cp; cp="a"". An Pointer können auch Felder zugewiesen
werden, aber an Felder keine Pointer (Feldbezeichner sind konstant)! Zeiger
sind verbiegbar, Felder nicht.
Wann immer der Compiler auf einen Feldbezeichner mit Indexoperator
stösst, wandelt er diesen sofort in einen Pointer auf das erste bzw.
angegebene Element um. Seltsam ist, dass Grössenargumente als
Funktionsargumente NICHT konstant sein müssen!
void f(int size){char ch[size];} // OK
In DOS und WINDOWS sind Felder auf ein einzelnes 64 kB-Segment
beschränkt. Unter UNIX können sie erheblich grösser sein.
Indizierte Zeiger sind Zeiger, die mit dem Indexoperator versehen wurden,
um die gewöhnungsbedürftige Zeigerarithmetik zu umgehen. Im Gegensatz
zu Feldbezeichnern sind indizierte Zeiger jedoch jederzeit änderbar.
Für eine komplette Simulation von Feldern durch indizierte Zeiger sollten
diese const deklariert werden. Damit erhält man die wohl einzig sinnvolle
Verwendungsmöglichkeit von const-Pointern! Aber: Indizierte Zeiger zeigen stets in
den Heap, Felder auf den Stack.
Kritik: Die Grösse, die konstant sein muss, eines Feldes ist nicht
überall feststellbar - z.B. innerhalb von Funktionen, bei denen das Feld
als Argument übergeben wurde (da es Compiler implizit in Pointer gewandelt
hat). Zuweisungen an Feldbezeichner sind nicht möglich im Gegensatz zu
Pointern, da Feldern der Zuweisungsoperator fehlt.
x[2][2]={1,2,3} bewirkt x[0][0]=1, x[0][1]=2, x[1][0]=3. Es wird also
immer von hinten nach vorne aufgefüllt. Nur die Angaben für die erste
Dimension kann entfallen, damit der Compiler die Offsets berechnen kann. Auch
bei Deklarationen ohne Definition kann die Grösse weggelassen werden.
Operatoren können mit Feldern nichts anfangen (ausser
"sizeof" und "&"), sie werden daher stets in Pointer
umgewandelt: "a[0]" wird sofort zu "*ap", und
"a[3]" sofort zu "*(a+3)". Da [] links-assoziativ ist, gilt:
a[1][1]=x; <==> (a[1])[2]=x;
Ein Pointer "fp" auf ein Feld "int f[5][3]" ist
folgendermassen zu erstellen: "int (*fp)[3];", d.h. der zweite
(und dritte ...) Index ist mit anzugeben. Grund: Der Compiler muss für
die Zeigerarithmetik die Offsets kennen. Dies erlaubt die Zugriffsformen auf
das Feld[2][2]: "fp[2][2]=1; (*fp)[2][2]=1; (*(fp+2))[2]=1;
(*(*(fp+2))+2)=1;"
Welchen Sinn könnten es haben, wenn eine Basisklasse friend ihrer
abgeleiteten Klasse ist? Im unten stehenden Beispiel kann nur A ein Objekt von B
erzeugen, da es als einzige Klasse Freund von B ist. Das macht A in gewisser
Weise zu einer abstrakten Objektklasse. Dies alleine wäre auch durch eine
protected-Ableitung ohne friend zu erreichen gewesen. Aber dann könnten
auch von A abgeleitete Objekte auf A zugreifen. Dies geht bei friends nicht, da
diese nicht transitiv sind.
class B{
friend class A;
B();
void f();
};
class A:private B{f();}; // OK
class C:public B {f();}; // FEHLER
Warum verstossen friends nur bedingt gegen das Data-Hiding? Sie
werden vom Programmierer der Klasse vergeben (also nicht vom späteren
Anwender). Sie sind nicht transitiv, wirken also nur eine Ebene weit (von der
mit friend versehenen Klassen abgeleitete Klassen haben nichts davon!).
friends gehören NICHT zum Geltungsbereich der Klasse, weswegen sie
auch einen Objektverweis als Argument übergeben bekommen müssen, bzw.
ein Objekt im Rumpf deklarieren müssen. Auf einen solchen Objektverweis
kann nur verzichtet werden, wenn ausschliesslich auf static-Member
zugegriffen werden soll.
Wenn die Wahl zwischen friends und Elementfunktion besteht, so sollte man
sich i.d.R. für Elementfunktionen entscheiden, insbesondere wenn
Manipulationen am Objekt vorgenommen werden. Dadurch werden auch die globalen
Namen klein gehalten. Ausserdem müssen virtuelle Funktionen,
Konstruktoren und Destruktoren Elementfunktionen sein. Für
friend-Funktionen spricht eigentlich nur z.T. die
"natürlicherer" Schreibweise: Statt x.f() schreibt man dann
f(x).
Können friend-Funktionen virtuell sein? Wenn sie Elementfunktionen
sind und in der zugehörigen Klasse als virtuell deklariert sind, sind sie
virtuell. Normale Funktionen können nicht virtuell deklariert sein. Jedoch
können an sie Objekt-Referenzen übergeben werden, die dann innerhalb
der Funktion den virtual-Mechanismus benutzen. Das macht sie zu quasi-virtuelle
friend-Funktionen.
Funktionen sind abgeleitete Datentypen. Ihre formalen Argumente und
Rückgabewerte werden über Initialisierungen realisiert, nicht
einfache Zuweisungen. Ihr Geltungsbereich ist lokal, d.h. sie sind nur
temporär existent. Häufig sind die Compiler intelligent genug, um zu
erkennen, ob es genügt, temporäre Variablen nur in der CPU zu
erzeugen und für die Dauer ihrer Existenz zu speichern.
Aktuelle Argumente, die hart im Codesegment stehen, z.B. "void
f("t")", besitzen keine Adressen, die der Compiler zum Zeitpunkt der Kompilierung
ermitteln könnte. Wird nun aber als formales Argument eine Referenz
gefordert, z.B. "void f(char& c)", hilft sich der Compiler
dadurch, dass er im Stack eine temporäre Variable für "t"
erzeugt.
Die formalen Argumente werden beim Funktionsaufruf mit den aktuellen
Argumenten INITIALISIERT, wobei es also zu (Standard-)Konversionen kommen kann.
Ausserdem können Werte an const-Variablen zugewiesen werden! Auch der
Rückgabewert wird initialisiert; die Funktion ist dabei nur als lvalue zu
verstehen, wenn eine Referenz zurückgeliefert wird. Wenn eine Funktion nie
aufgerufen wird und auch ihre Adresse nirgendwo zu berechnen ist, so muss
sie nicht definiert werden!
Bei der Rückgabe von Funktionswerten ist eine Kopie meistens
unvermeidbar. Gibt man nur eine Referenz auf das lokale Objekt zurück,
liefert man Unsinn zurück, da das Objekt mit Verlassen des
Funktionsrumpfes zerstört wird. Erzeugt man auf dem Heap ein Objekt, so
kann man eine Referenz darauf zurückgegeben. Allerdings besitzt das Objekt
dann keinen Namen mehr, kann also nicht mehr gelöscht werden! Aus diesem
Grund gibt man am ehesten Pointer auf Heap-Objekte oder Kopien von Objekten
zurück.(?)
Im Gegensatz zu den meisten anderen Sprache lassen sich in C++ sogar Zeiger auf
Funktionen realisieren, d.h. Zeiger erzeugen, die in das Codesegment zeigen.
Aufgrund der Standardkonversion lassen sich Funktionszeiger auf verschiedene
Arten initialisieren und benutzen:
int f(int &i){return i;}
void main(){
int (*zf)(int&); // oder typedef (*ZF)(int&); ZF zf;
zf=&f; // Initialisierung. Kürzer: zf=f;
(*zf)(2); // Aufruf. Kürzer: zf(); aber NICHT: *zf()
}
Der Spezifizierer "typedef" kann verwendet werden, um Funktionszeiger mit
einfacheren Namen zu versehen. Diese Datentypen können auch als
Klassennamen in Klassen-Template-Funktionen verwendet werden (es können
die Funktionszeiger aber auch direkt innerhalb der eckigen Klammern angegeben
werden).
void f(){cout << "hello";}
template<class A>class AA{
A fz;
void f(){fz();}
};
void main(){
typedef void (*ZF)();
AA<ZF>a; // Aufruf über typedef-Datentyp
a.fz=fz;
a.f();
AA<void(*)()>b; // ebenfalls korrekt.
}
Vor dem Aufruf müssen Funktionspointer normalerweise mittels "*"
dereferenziert werden. Zusätzlich ist ihr Namen zu umklammern, da "()"
eine höhere Priorität besitzt als "*". Es gilt aber auch die
einfacherer Schreibweise wie folgt:
double f(int &i,char *cp){/*...*/}
void main(){
double (*FP)(int&,char*)=f;
(*FP)(1,"tst1"); // formal korrekt
FP(2,"tst2"); // auch OK
}
Für Funktionspointer existieren keine Standardkonversionen,.
ausser die nach void*. Konversionen müssen hier also
benutzerdefiniert werden.
C++ kennt vier verschiedene Geltungsbereiche, die das statische System
betreffen, und abhängig sind vom Ort der Deklaration:
-
Lokal: Alle in einem Block eingeschlossenen Deklarationen, die nicht
Klassen- oder Funktionsgeltungsbereich besitzen. Auch Funktionsargumente
gehören zu diesem Geltungsbereich.
-
Global (Datei): Alle in keinen Block eingeschlossenen Deklarationen.
Ihre Anzahl sollte aufgrund der Gefahr von Ambiguitäten mit anderen
Programmdateien gering gehalten werden. Sinnvoll sind hier v.a. typedefs und
Funktionen.
-
Funktion: Ausschliesslich Label-Deklarationen für z.B.
goto-Sprunganweisungen innerhalb der aktuellen Funktion.
-
Klasse: Alle Deklarationen innerhalb eines Klassen- bzw.
Struktur-Deklarationsrumpfes. Der Zugriff darauf erfolgt indirekt über
Elementfunktionen und/oder direkt über den Punkt-, Pfeil- oder
Geltungsbereichs-Operator.
In Zukunft sollen sich noch sogenannte Name-Spaces einrichten lassen.
Damit lassen sich über vollständige Qualifizierungen eindeutige
globale Variablen in sichererer Weise als bisher realisieren. Beispiel:
namespace X{void f(const int&);};
void main(){X::f(2);};
Die Sprunganweisung goto springt zu Marken mit dem Geltungsbereich
Funktion, d.h. man kann mit ihr nur innerhalb der Klassendefinition
herumspringen. Zu beachten ist, dass die Marke vor der Sprunganweisung
deklariert wird.
Zum Designen der Benutzerschnittstelle sollten Benutzer-Profile erstellt
werden. Vor älteren Anwendern muss z.B. die Komplexität
verborgen werden (alte Sekretärin vor MS WORD!). Ein Trennung der
Benutzergruppen in Anfänger, Fortgeschrittene und Experten ist meist
sinnvoll (eine HIC für nur eine Benutzergruppe zu schreiben ist einfach,
aber eine für alle schwierig). Bestimmte Konventionen sind einzuhalten,
z.B. das Datumsformat. Die Sprache sollte aus dem Problembereich stammen (kein
EDV-Jargon). (Kontextsensitive) Hilfssysteme erleichtern den Umgang mit dem
Programm. Fehler sollten dokumentiert werden (keine Nummern wie bei OS/2!).
Ideal sind Human Interface Components, die in den Sitzungen lernen, sich auf
ihre Anwender anzupassen (bieten z.B. auf Knopfdruck das Laden bestimmter Files
an). Wichtig ist die Feedback-Gabe; der Anwender erfährt so gleich, ob
seine Operation erfolgreich war.
Folgende Dialogformen werden unterschieden:
-
benutzergeführte Dialoge: Der Anwender bestimmt frei, was er als
nächstes tun will. Z.B. bei Kommandosprachen gegeben.
-
systemgeführte Dialoge: Eine begrenzte, kontextsensitive Auswahl
wird angezeigt. Ist bei Menüs und Eingabemasken gegeben.
-
gemischte Dialoge: Der Anwender hat eine begrenzte Auswahl von
Möglichkeiten, nachdem er sich durch Mausklick für ein bestimmtes
Objekt entschieden hat. Z.B. bei WINDOWS gegeben. Der Erfolg, z.B. Löschen
eines Icons, wird direkt angezeigt, d.h. Feedback ist unmittelbar gegeben.
Es gibt 8 goldene Regeln für das Dialogdesign:
- In Menüs Operationen logisch gruppieren.
- Hot Keys für Profis.
- Konsistenter Aufbau und Dokumentation.
- Kontrolle der Benutzer-Inputs. Keine Gedächtnisüberforderung.
- Fehler dokumentieren, nicht nur nummerieren. UNDO-Option.
- Feedback-Gabe des Systems.
- Menütiefe und -breite beschränkt.
- Hilfssystem (kontextsensitiv).
Hilfssysteme gelten als Extra, wenn sie nicht direkt in das Programm
designt wurden und selbstständig aufgerufen werden müssen. Ansonsten
spricht man von integrierten Hilfssystemen, die i.d.R. kontextsensitiv auf
Fehler und Hilfsanfragen reagieren. Bei Extra-Hilfssystemen gibt es welche, bei
denen man sich über Indizes zur gesuchten Information navigieren
muss. Es gibt auch welche, die Suchfunktionen anbieten, die gezieltes
Anfragen gestatten. Zuletzt gibt es auch welche, die aus dem Programm gestartet
werden können und dann kontextsensitiv Hilfen aufweisen - und diese
Alternative ist sicher die beste.
Wrapper-Klassen werden um externe Systeme gelegt, um so deren
Funktionalität (und nur ihre Funktionalität!) im eigenen System
verfügbar zu machen. Beispiel: Generierung einer Sensor-Klasse, die einen
physikalischen Sensor simuliert.
Eine HIC-Architektur, bestehend aus 4 Teilen. Die E/A-Einheit (das, was
man sieht, z.B. Pulldowns) wird getrennt betrachtet von der Werkzeug-Einheit
und der Dialog-Einheit (Regelwerk, Fehler-Handling) auf dem Rechner und der
Organisationseinheit (Umsetzung des Problembereichs; Integration der
Werkzeuge). Jede Einheit kann für sich extra entwickelt und bewertet
werden. Vorläufer des Seeheim-Modells. Wie dieses ist das IFIP-Modell nur
ein Beschreibungsmodell, kein Implementationsmodell wie der MVC.
Neues Konzept bei Borland C++: Statt dass Objekte aus Bibliotheken in
statischer Weise als Ganzes in EXE-Programme gelinkt werden, erkennt der Linker
bei Verwendung der IMPLIB die für das Programm relevanten Teile und linkt
nur diese zum EXE-Programm, welches dadurch deutlich kürzer wird. Wird
z.B. eine Elementfunktionen im Programm nie aufgerufen, so muss auch ihre
Definition nicht in den Codeteil kopiert werden. Der Compiler betrachtet nur die
aktuelle Programmdatei, während der Linker das ganze Programm sieht; er
bestimmt, ob für eine Variable einmal oder mehrfach Speicherplatz zu
allokieren ist.
Welchen Vorteil bietet das Überladen des Indexoperators? Es erlaubt,
auch Nicht-Integers als Indizes zu übergeben. So könnte z.B. ein
double als Index benutzt werden, deren Integer-Teil und Nachkomma-Teil
verschiedene Auswahloptionen bieten könnten. Auch mehrstellige Indizes
sind auf diese Weise möglich. Der Rückgabewert ist beliebig.
Da an Indizes von Objekten i.d.R. auch Zuweisungen möglich sein
sollten, muss deren Funktionsrumpf einen this-Zeiger zurückliefern. Da ein
solcher Funktionsrumpf nicht const deklariert sein kann, bringt ein Aufruf der
Form "cout << a[4];" Probleme mit sich, da "cout" ein const-Objekt
voraussetzt. Dies lässt sich umgehen, indem der Indexoperator noch einmal
überladen wird: Er behält seinen Rumpf bei, aber die Operatorfunktion
wird const deklariert ("int X::operator[](double d) const;").
Initialisierungen sind nicht mit Zuweisungen identisch, da mit einer
Initialisierung die Allokation von Speicherplatz einhergeht, während eine
Zuweisung einer Variable nur den Wert einer vorher allokierten Speicherzelle
zuweist. Zuweisungen an const-Variablen sind nicht möglich, aber Initialisierungen
im Moment ihrer Definition. Der vielleicht wesentlichste Unterschied ist aber,
dass Initialisierungen den Konstruktor aufrufen, während Zuweisungen
mit dem Zuweisungsoperator arbeiten!
Bei der Übergabe von Argumenten an Funktionen findet eine
Initialisierung statt und nicht nur eine Zuweisung! D.h. es werden Variablen
zur Aufnahme der aktuellen Argumente des Aufrufs erzeugt und deren Werte in
diese kopiert. Der Geltungsbereich der formalen Argumente ist dann lokal.
Eine Klasse, die keine static-Datenelemente, keine Basisklassen, keinen
Konstruktor und keine virtuellen Elementfunktionen enthält und die
vollständig public spezifiziert ist, kann über eine
Initialisierungsliste initialisiert werden: "struct X{int i;}; X
x[]={{"1"},{"2"}};".
Initialisierer-Listen bewirken nicht, dass die Objekte in der dort
angegebenen Reihenfolge initialisiert werden. Nur die Reihenfolge der
Deklarationen ist ausschlaggebend. D.h. man muss darauf achten, dass
in einer Klasse die Datenelement-Deklarationen vertauscht werden, damit die
Initialisiererliste korrekt abgearbeitet wird (weil z.B. die Initialisierung
einer Variable die Initialisierung einer anderen Variable voraussetzt).
Nur die Elementfunktionen einer Klasse haben unbeschränkten Zugriff
auf Klassenelemente, während andere Zugriffsmöglichkeiten durch
Zugriffsrechte näher spezifiziert werden können. Der Anwender einer
Klasse sieht nur das von der Klasse, was er auch sehen soll.
Der Informationsfluss ist eine Metrik, die sich auch für
objektorientierte Programme eignet. Unterschieden wird hier ein globaler
Informationsfluss von A nach B (Objekt A ändert z.B. C, auf das B
zugreift) und ein lokaler Informationsfluss von A nach B (A benutzt
Methoden von B, A greift über C auf B zu). Informationsflüsse, die in
eine Klasse eingehen, heissen fan-in, Informationen, die nach aussen
getragen werden, fan-out. Fan-in und fan-out geben die Komplexität einer
Klasse wieder. Es kann eine Trennung zwischen statischer und dynamischer Sicht
stattfinden.
Dieser Spezifizierer vor (Element-)Funktionen bewirkt, dass der
Funktionsrumpf an jede Stelle eines Aufrufs der Funktion kopiert wird.
Intelligente Compiler erkennen sogar statische Wertzuweisungen und setzten
diese direkt ein. Sowie im Programm irgendwo die Adresse einer inline-Funktion
verrechnet wird, schaltet der Compiler die inline-Funktion aus (an der internen
Bindung ändert sich dadurch jedoch nichts).
inline bewirkt in jedem Fall, dass die Funktion interne Bindung
erhält - auch wenn die Funktion letztlich nicht wirklich inline vom
Compiler realisiert wird. Grund: Es existiert kein Coderumpf mehr im Codeteil,
dessen Adresse nach aussen exportierbar wäre. Das bedeutet auch,
dass der inline-Funktionscode bei mehreren Programmdateien MEHRFACH
erzeugt wird, wenn inline nicht aktiviert wird.
inline-Definitionen, die nicht in Headern untergebracht sind, müssen
in allen Programmdateien 100%ig EXAKT wiederholt werden. Diese Kontrolle
obliegt alleine dem User, da durch die durchgehenden inline-Definitionen eine
Klasse interne Bindung erhält, der Compiler und Linker also diese Klasse
jeweils nur isoliert für eine Programmdatei betrachten können.
C++ verfügt über keinen eingebauten I/O-Operatoren, sondern
diese werden über eine Bibliothek nachgeladen. Als Ausgabe- und
Eingabe-Stream kann jeweils nur eine Instanz "cout" und
"cin" erzeugt werden, d.h. wenn mehrere Tasks (oder Threads)
Ausgaben tätigen, sollte das "cout"-Objekt zuvor über
Semaphore allokiert werden müssen.
Ist die Wahl ">>" und "<<" für die
Eingabe- und Ausgabeoperatoren klug gewesen? Ja, denn sie haben eine niedrige
Priorität (dies erlaubt Ausgaben von Objekten ohne Klammersetzung), sie
sind mnemonisch (ihre Funktion ist bereits visuell verständlich), sie sind
symmetrisch (Eingabe genau umgedreht zur Ausgabe), und sie sind links-assoziativ
(bei cout << a << b; wird a vor b ausgegeben).
Hierzu gehören while-, do- und for-Anweisungen.
Parametrisierten Klassen können in den eckigen Klammern
Klassenschablonen zugewiesen werden, die NIE innerhalb der Klassendeklaration
bzw. Klassendefinition auftauchen. Dies ist bei Template-Funktionen NICHT
möglich.
Die Klassenschablonen, die an eine Klasse übergeben werden,
können dazu benutzt werden, um als Klassennamen für
Template-Klassen-Deklarationen zu dienen.
Die Übergabe von Template-Klassen als Klassennamen geht nicht direkt,
da sich der Compiler über die doppelten eckigen Klammern beschwert. Dieses
Problem lässt sich aber über eine typedef-Definition umgehen.
template<class A>class AA{A a;};
template<class B>class BB{B b;};
void main(){
typedef AA<int> CC;
BB<CC>bb; // OK
BB<AA<int>>bb; // Fehler
}
Möchte man eine NORMALE Klasse von einem Klassen-Template ableiten,
so muss im Klassenkopf der Versionstyp des Klassen-Templates durch Angabe des
gewünschten Datentyps angegeben werden. Ist die abgeleitete Klasse jedoch
ebenfalls parametrisiert, so kann die Typ-Angabe auf den ersten Aufruf der
abgeleiteten Klasse verlagert werden, sofern im Klassenkopf die
Klassenschablone an die Basisklasse weitergegeben wird.
template<class A>class AA{A a;};
template<class A>class BB:AA<A>{A a;};
void main(){BB<int>bb;};
Im Gegensatz zu Template-Funktionen können bei Klassen-Templates
Konstruktoren mit Default-Argumenten korrekt arbeiten. Voraussetzung ist
jedoch, dass der Default-Parameter sich IMPLIZIT in den gewählten
Klassennamen konvertieren lässt. Da dies bei benutzerdefinierten
Datentypen praktisch nie der Fall ist, ist von Standard-Konstruktoren mit
Default-Parametern bei Klassen-Templates generell abzuraten. Übriges:
Funktioniert die Konversion nicht implizit, beschwert sich der Compiler
über das Problem, auch wenn der Default-Parameter niemals aktiviert
wird!
Werden Elementfunktionen von Klassen-Templates nicht im Deklarationsrumpf
definiert, so muss ihren Definitionen das Schlüsselwort "template"
vorangestellt werden. Der Name der Klassenschablone ist dabei unabhängig
von der Klassenschablone der Klassen-Deklaration. Diese "outline"- Definitionen
können auch dazu benutzt werden, um für bestimmte Klassennamen
spezielle Versionen der Elementfunktion zu erzeugen (dies entspricht dem
Überladen bei nicht-parametrisierten Klassen). Zu beachten ist, dass dabei
auf "template" zu verzichten ist!
template<class A>class AA{A a;int b;A f(AA*);};
template<class B>B f(AA<B> *aa){cout << aa->a;return aa->a;}
int f(AA<int>*aa){cout << aa->b;return aa->b;}
void main(){
AA<double>a;
a.f(&a); // Ausgabe von a.a
AA<int>b;
b.f(&b); // Ausgabe von b.b
a.f(&b); // FEHLER, weil keine Konvertierung möglich ist.
}
An Klassentemplates können in den eckigen Klammern auch konstante
Grössen mitgeteilt werden. Beim Aufruf müssen diese jedoch als
konstante Typen übergeben werden.
template<class T,int size>struct X{int size;T *inhalt;};
void main(){X<char,5>x;}
Die Verwendung von ">" bei Template-Deklarationen ist nicht
immer glücklich gewählt. Grund: ">>" wird als
Input-Operator gewertet, und muss mit Whitespace getrennt werden.
Beispiel:
template<class T>class X{int f(T*);};
template<class T>class Y{int g(T*);};
void main(){
X<int>x; // OK
Y<X<int>>y1; // Fehler
Y<X<int> >y2; // OK!
}
Eingebettete Klassen-Templates sind verboten, also:
"template<class T>class X{template<class T>class Y{};};"
ist ein Fehler. Aber natürlich dürfen Klassen-Templates eingebettete
normale Klassen führen.
Die Kohäsion gibt wieder, inwieweit eine Klasse nach innen wirkt, wobei
hier auch ganze Vererbungsstrukturen zu der Klasse gehörend empfunden
werden. Die Kohäsion ist möglichst ausgeprägt zu designen, d.h. Methoden sollen
eigene Methoden verwenden, Klassen auf eigene Methoden oder vererbte Methoden
zurückgreifen. Grund: Information Hiding. Problematisch ist hier die
selektive Vererbung anzusehen, da sie nicht echt "is_a"-Strukturen
modelliert.
Über Komma-Operatoren lassen sich mehrteilige Anweisungen in einer
Zeile realisieren. So liefer z.B. folgendes Programm eine "5"
zurück: "int t; cout << (t=3,t+2) << endl;".
Zeiger auf Konstante Objekte können verändert werden, so lange
nicht schreibend auf das referenzierte Objekt zugegriffen wird.
void f(const char *p){cout << p++ << endl;} // OK
Neben symbolischen Konstanten, die durch den Spezifizierer const
deklariert werden, gibt es Literalkonstanten, die sich durch ihre Schreibweise
als Konstanten auszeichnen, und zwar derart, dass der Compiler i.d.R.
selbstständig ihren Typ erkennen kann. Zu beachten ist, dass der Typ
solcher Literalkonstanten maschinenabhängig ist: So hat "66000"
auf den PC den Typ "long int", während auf UNIX-Kisten nur ein
"int" herausspringt. Der Typ kann aber auch durch eine Typ-Endung
spezifiziert werden: "L "steht für "long",
"U" für "unsigned". Der Wert kann dezimal (erste
Ziffer!=0), hexadezimal (1.Ziffer=0, 2.Ziffer=x) oder oktal (1.Ziffer=0)
dargestellt werden. Es gilt, dass Gleitpunktzahlen ohne Typ-Endung immer
vom Typ "double" sind; durch abschliessendes "F"
werden sie zu "float", durch "L" zu "long
double".
Ein Objekt einer abgeleiteten Klasse kann erzeugt werden, auch wenn der
Konstruktor der abgeleiteten Klasse nicht explizit den Konstruktor der
Basisklasse(n) aufruft. Dies funktioniert aber nur, sofern die Basisklasse
über einen Default-Konstruktor verfügt, denn nur diesen kann der
Compiler für die abgeleitete Klasse implizit erzeugen.
Ein Konstruktor mit dynamischer Speicherallokation lässt sich elegant
folgendermassen realisieren:
struct X{
int size;
char Inhalt;
X(int i){inhalt=new char[size=i];}
~X(){delete[]Inhalt;}
};
Im Konstruktor-Kopf - in der Initialisiererliste - wird initialisiert, im
Konstruktor-Rumpf wird zugewiesen. Bei der Initialisierung wird anders als bei
der Zuweisung einer Variable nicht nur ein Wert zugewiesen, sondern auch
entsprechender Speicherplatz für sie allokiert. Bei dynamischer
Allokation findet die Initialisierung oft auch innerhalb des Funktionsrumpfes
statt. Übrigens: Initialisiert werden können nur Variablen der
aktuellen Klasse und der DIREKTEN Basisklasse.
struct X{
int i;
int &ri;
const char *str;
char *fp;
X(int a, int b, char *txt):ri(b), str(txt){
i=a;
fp=new char[i];
}
};
Die Aufrufreihenfolge von Konstruktor und Destruktor verdeutlicht
folgendes Beispiel:
struct X{X(){cout << "x" << endl;}~X(){cout << "-x" << endl;}};
struct A{A(){cout << "a" << endl;}~A(){cout << "-a" << endl;}};
struct B:A{B(){cout << "b" << endl;}~B(){cout << "-b" << endl;}};
struct C:B{X x;C(){cout << "c" << endl;}~C(){cout << "-c" << endl;}};
void main(){
C c; // Ausgabe: a,b,x,c,-c,-x,-b,-a
}
Hierzu gehören if- und switch-Anweisungen.
Um benutzerdefinierte Datentypen konvertieren zu können, müssen
i.d.R. die zugehörigen Operatoren überladen werden. Im folgenden
Beispiel wird X in einen Integerwert konvertiert:
struct X{
int i;
operator int(){return i;} // KEIN Rückgabewert vorne angegeben!
};
void main(){
X x;
cout << (int)x << int(x) << x; // liefert immer x.i
}
Nicht jede Konversion lässt sich über den Konstruktor
realisieren, da dieser nur Objekte des eigenen Typs zurückliefern kann.
Konversionsfunktionen liefern dagegen den Typ zurück, den sie
überladen (es kann kein Funktionswert explizit angegeben werden).
Eine Konversion von einen Pointer auf einen anderen Pointer funktioniert
nur dann vollständig, wenn der neue Pointer mindestens genauso viele Bits
aufnehmen kann wie der alte Pointer. Konversionen kommen (implizit) zum Einsatz
bei Initialisierungen, z.B. bei der Übergabe lokaler Argumente an formale
Argumente einer Funktion und bei der Rückgabe von Funktionswerten, und bei
Zuweisungen.
Bei der Standardkonversion wird eine Zuweisung den Typ zugewiesen
erhalten, der am längsten ist. Dass heisst, "long int + int"
liefert "long int" als Ergebnis. Das ist sehr praktisch und
effizient.Leider wird NICHT von "long int" nach "unsigned long
int" und nach "long double" konvertiert.
Es kann auf 3 Arten explizit konvertiert werden:
-
Cast-Version: double d=(double)2;
-
Funktionsversion: double d=double(2);
-
Spezifizierer-Versionen:
-
double d=static_cast<double>2; Achtung: Liefert nur bei Referenzen Lvalues!
-
const_cast um const für den aktuellen Aufruf zu entfernen. Sinnvoll z.B. um
const-Heap-Objekte zu löschen!
-
reinterpret_cast um C++-Typ-Prüfung auszuschalten!
-
dynamic_cast für typsichere Laufzeitprüfung (alle anderen Casts
sind statisch!). Auch RTTT genannt. Liefert eine Null zurück, wenn der
Cast misslingt.
Der Ausdruck "4u + 2.0l" hat den Typ "long
double"!
Gehört zum Design der Problembereichskomponente und stärker noch
zur OOP. Folgende Massnahmen lassen sich hier treffen:
-
const: Das "Verconsten" von Programmen erleichtert und vertieft
wesentlich die statische Fehlersuche.
-
register: Optional versucht der Compiler (temporäre) Variablen in die
schnellen Register abzulegen, z.B. Schleifen-Indizes. Insbesondere RISC-Rechner
haben über 100 Register.
-
inline: Optional fügt der Compiler den Funktionsrumpf an die Stelle
des Aufrufs ein. Dadurch spart man sich den Aufwand der Stack-Ablegung der
Argumente. Der Code verlängert sich durch die Redundanz jedoch u.U.
erheblich. Vorsicht auch bei mehreren Programmdateien, denn die explizite
externe Einbindung von inline-Code führt zur Duplizierung desselben, auch
wenn der Compiler aufgrund der Grösse des Codes die normale
Funktionsaufruf-Version beibehält!
-
abgeleitete Attribute:
-
- "Cache": Speichervariablen einrichten, die zu setzende Werte wie
Grössen eines Feldes enthalten, damit dieser Wert nicht jedes Mal
berechnet werden muss. Nachteil: Wartung aufwendig.
-
20:80-Regel: Nur 20 des Codes werden zu 80% der Laufzeit benötigt. In
erster Linie ist dieser Codeteil zu optimieren (und verifizieren).
-
virtuelle Funktionen gezielt einsetzen. Die Vererbung der Schnittstelle
und evtl. (Default-)Implementierungen kann bei der statischen Fehlersuche
hilfreich sein.
-
statt Indexoperatoren Zeigerarithmetik verwenden.
-
Die neuartigen BOOL-Variablen kennen nur zwei Werte und werden sehr
schnell abgearbeitet.
-
Auch der Spezifizierer "mutable" ist brandneu. Dadurch lassen
sich Variablen erzeugen, die innerhalb von const-Elementfunktionen
veränderbar sind!
-
Container-Klassen: Man hat hier in der Implementierung die
designabhängige Alternativen zwischen Typ-Sicherheit, Codeduplizierung und
Programmieraufwand.
-
Referenzen-Zählen: Über diese Methode lässt sich
Speicherplatz sehr effizient nutzen. Statt mehrfacher Allokation und Freigabe
wird einmal allokiert und mehrfach belegt und erst am Ende freigegeben.
-
Klassen für systemnahe Aufgaben: Speziell auf die HW ausgerichtete
Klassen, die evtl. sogar in Assembler codiert sind, können die
Geschwindigkeit eines Systems wesentlich erhöhen. Das geht auf Kosten der
Portabilität (!!) und sollte ähnlich wie Treiber
ordnungsgemäss verkapselt werden. Auch Vereinfachungen wie
C-Funktionen in eine C++-Klasse packen, gehört hier dazu (Beispiel
DIR-Klasse).
-
Verwendung von friends: Die friends brechen nur beschränkt die
Kapselung auf, erlauben aber u.U. sehr effizienten Zugriff auf Datenkonstrukte,
sowie designspezifisches Verhalten von Funktionen.
Für die Grösse einer Liste sollte ein Datenelement gewartet
werden. Vorstellbar wäre darüber hinaus ein Flag der Form
"grösseOK", dass bei jeder Listenänderung auf "FALSE" gesetzt
wird.
Wird die Elementfunktion "getSize()" aufgerufen, kann über das Flag
geprüft werden, ob die Liste durchzuzählen ist oder ob direkt
"anzahl" zurückgegeben werden kann.
Deklariert man "anzahl" und "grösseOK" als "mutable int", dann
können diese Werte auch in const-Elementfunktionen verändert werden.
Designtechnisch kann man so zwischen semantischem und bit-weisem "Consten"
Unterscheidungen treffen. Achtung: Nicht jeder Compiler beherrscht die
mutable-Deklarationen. Über "#ifndef"-Anweisungen muss also evtl. der
this-Pointer kopiert und über "This->grösseOK=1" verändert
werden, ohne die const-Elementfunktion zu verletzen.
Die Lines Of Code als Metrik zu benutzen, ist zu kurz gedacht. Ihr Einsatz
in Formeln wie "Produktivität=LOC/(Aufwand*Zeit)" erscheint aber
durchaus sinnvoll zu sein. Geklärt werden muss, was als Zeilen gilt:
Auch Kommentare, logische oder "physikalische" Screen-Zeilen? Zu
beachten ist, dass das LOC-Mass im hohen Masse
Sprachabhängig ist (z.B. Assembler < C), und keine Hilfe für Reuse
von Spezifikationen und Code darstellt.
Ein Lvalue ist ein Ausdruck, der auf eine Funktion oder ein Objekt
verweist, d.h. auf eine bestimmte Speicherregion. Z.B. ist der Name eines
Objekts auf der linken Seite einer Zuweisung ein Lvalue.
Lvalues können im Gegensatz zu Rvalüs veränderbar sein.
Verschiedene Operatoren verlangen daher die Benutzung von Lvalues, ansonsten
beschwert sich der Compiler. Lvalues wie const-Objekte oder Funktionspointer
sind nicht veränderbar. Ein Funktionsaufruf ist nur dann ein Lvalue, wenn
als Funktionswert eine Referenz auf ein Objekt zurückgeliefert wird.
Alle Präfix-Operatoren liefer Lvalues zurück, weshalb möglich ist:
"int i; ------i;". Die Postfix-Operationen liefern dagegen einen
Rvalue zurück. Erst nach dem Aufruf wird per Seiteneffekt der Objektwert
inkrementiert/dekrementiert. Daher ist nicht möglich: "int i;
i----;".
Das überladen von Funktionsnamen ist die eine Form des
Polymorphismus, die es vermutlich nur in C++ bzw. C gibt. Welcher
Funktionsprototyp letztlich mit welchem Aufruf aktiviert wird, legt der
Compiler STATISCH fest, d.h. zur Kompilierungszeit. Dabei gilt das Motto:
ÜBERSETZUNGSFEHLER SIND BESSER ALS LAUFZEITFEHLER! Um die beste Funktion
für einen Aufruf zu ermitteln, geht der Compiler folgendermassen
vor:
1.Schritt: Der Compiler sucht nach exakter Übereinstimmung der
aktuellen Argumente mit den formalen Argumenten. U.U. kommt es auch zu
TRIVIALEN Konversionen, wie z.B. von "int" nach "int&",
da sich hier am Bitmuster nichts ändert (int bleibt int, wird nicht zu
void, char o.ä.).
2.Schritt: Auch wenn 1.Schritt erfolgreich war, sucht der Compiler nach
Mehrdeutigkeiten, um sie bitterlich beklagen zu können. Ohne den Verlust
von Genauigkeit nimmt er eine TYP-ANGLEICHUNG der aktuellen und formalen
Argumente vor, so z.B. von "char" nach "int" (weil
char<int).
3.Schritt: Der Compiler schuftet weiter. Jetzt betreibt er die eingebaute
STANDARDKONVERSION, die auch mit Informations-Verlusten leben kann. Aus
"long int" wird ruck-zuck "int", aus "double" wird
"int" usw. Besonders wird die "0" behandelt, die nun auch als
Null-Pointer interpretiert wird. Und jeder Pointer wird auf den void-Pointer
konvertiert!
4.Schritt: Der Compiler berücksichtigt auch die Arbeit der
Programmierer und nimmt eine BENUTZERDEFINIERTE Konversion der Argumente vor.
Im einfachsten Falle ruft er dazu Konstruktoren mit EINEM Argument auf.
5.Schritt: Zu guter Letzt sieht sich der Compiler noch nach UNSPEZIFIZIERTEN
Argumenten um, d.h. um Funktionsprototypen mit "..." als formale Argumente. Der
5.Schritt gilt immer als schlechteste Alternative, d.h. wenn 1-4 ausreicht,
wird 5 nicht beachtet!
Etwas kompliziert wird der Matching-Prozess noch durch das
Vorhandensein MEHRERER Argumente. Hierbei verfährt der Compiler nach der
Strategie, dass die Funktion genommen wird, die in jedem Punkt mindesten
gleich gut wie jede andere Funktion ist, in wenigsten einem Punkt jedoch besser
ist. Übrigens: Der FUNKTIONSWERT spielt beim Matching überhaupt keine
Rolle!
Bei der Analyse von Metriken wird folgendermassen vorgegangen:
-
Definition der Metrik: Formel mit Erklärung der Komponenten.
-
Grundlegende Annahmen: Erläuterung, über was das Mass
Aussagen macht. Anschliessend eine Behauptung der Form: Je mehr dieses
Mass vorliegt, desto mehr/weniger ... Besonders auf Komplexität,
Cohesion und Coupling eingehen. Hinweis auf dynamische Aspekte geben. Die fixen
Bestandteile der Formel sind zu begründen.
-
Ziele bei Einsatz: Wiederholung der Behauptung von (2),
darüber hinaus aber auch weitere Behauptungen ableiten, in der Form: Das
Mass erlaubt Rückschlüsse über ... und ..., dies hat
Auswirkungen auf ... und ... Eingehen auf den Standpunkt der Programmierer.
Aufdeckung von Schwachstellen in Analyse/Design als besonderer Vorteil.
-
Richtwert/Idealwert: Wenn Richtwert okay, dann Verbindlichkeit oder
Faustwert-Charakter (Indikator-Wirkung) herausarbeiten. Fallunterscheidung, wenn
möglich. Wenn Mass schwierig quantifizierbar ist, dann Hinweis auf
fehlende Untersuchungen oder generelle Unmöglichkeit, sowie der Hinweis,
dass das Mass im relativen Vergleich eingesetzt werden kann, dann
aber eine Abweichung kontextspezifisch zu interpretieren ist. Daraus
resultierende Probleme für Empfehlungen wie "So viel wie nötig,
so wenig wie möglich" andeuten.
-
Anwendbarkeit: Hinweis auf alle Formelteile, die nicht eindeutig sind,
Fragen dazu, um sie näher einzugrenzen. Kosten-Nutzen-Relation beachten.
Auf Abweichungen von der OOE-Philosophie besonders hinweisen. Liegen solche
vor, kann es kein alleiniges Mass sein.
-
Einsetzbarkeit im SW-Lebenszyklus: Frühesten Einsatzpunkt unter
welchen Bedingungen erläutern. Es gilt, dass Analyse und Designphase
als früh gelten. Beachtung von statischen (Klassen-) und dynamischen
(Objekt-)Versionen. Wenn erst in OOP-Phase erzeugbar, dann Hinweis auf evtl.
Sprachabhängigkeit. Abschliessend den einfachsten Fall - z.B. die
direkte Ableitung aus dem Analysemodell - beschreiben (aber auch dadurch
anfallenden Beschränkungen). Wenn erst später Einsatz möglich
ist, die Idee von Schätzwerten und Erfahrungswerten analysieren.
-
Anmerkungen/Erweiterungen: Plausibel machen der Behauptungen aus (2) und
(3), aber Hinweis auf fehlende Sektoren. Daraus neue Synthese bilden (i.d.R.
Kombination der Masse, Normierung der Komponenten, sinnvolle
Vereinfachungen). Unplausible Formelbestandteile kritisieren. Bemerkung der
Form "auch wenn Idealwert erreicht, dann ..." machen.
Gegenüberstellung von Kosten und Nutzen, v.a. auch unter dem Gesichtspunkt
der Erweiterung.
Bei der Berechnung von Metriken muss extra darauf hingewiesen werden,
wenn in der Formel benutzte Komponenten aus dem Analysemodell nicht ersichtlich
sind. Kandidaten hierzu sind z.B. einfache Methoden oder fehlende
Nachrichtenverbindungen. Werden abgeleitete Klassen geprüft, sind für
sie auch die höher stehenden Klassen einzubeziehen (z.B. bezüglich der
Methodenanzahl oder Nachrichtenverbindungen)! Übrigens:Metriken sind v.a.
nur dann sinnvoll, wenn sie in CASE-Tools (z.B. MAOOAM) direkt eingebaut sind!
Dadurch können Qualitätsnormen eingehalten werden, die erst ein
SW-Engineering ermöglichen.
Sicher sinnvolles Ziel: Übertragung des Function-Point-Verfahrens und
des COCOMO-Kostenschätzung-Modells auf OOE! Bei ersterem werden die
Funktionen gezählt und ihre Komplexität geschätzt, sowie mit
ihrer Wichtigkeit gewichtet. Diese Werte können über firmeninterne
Erfahrung in Personen-Monate umgerechnet werden. COCOMO berücksichtigt
dagegen, dass verschieden ausgebildete Teams verschiedene Kosten bei der
SW-Entwicklung verursachen (Schätzwerte), und zudem zwischen Basic-,
Intermediate- und Detailed-COCOMO unterschieden.
Die verschiedenen Metriken lassen sich - wenn auch nicht immer eindeutig -
folgendermassen klassifizieren: Zunächst sind Metriken als
Qualitätsmasse nur ein Bestandteil der Bewertung von
SW-Engineering-Prozessen. Man unterscheidet hier Produkt-, Prozess- und
Ressourcen-Qualitätsmasse. Unser Interesse gilt in erster Linie
Produktmassen, die sich in objektorientierte und nicht-objektorientierte
Produktmasse trennen lassen. Die ooPQM betrachten entweder Klassen,
Objekte oder ganze Systeme. Sie werden in strukturelle und interne Masse
getrennt: Erstere betrachten die Beziehungen, Nachrichtenverbindungen und
Aggregationsstrukturen zwischen Klassen, das Zweite das Information Hiding und
die Vererbung einzelner Klassen. Die Qualitätsmasse lassen sich auch
weiter trennen in direkte und indirekte Masse, wobei für uns nur
indirekte Masse (wie Informationsfluss) relevant sind, die sich aus
direkten Massen (wie die LOC) zusammensetzen. Die Qualitätsmasse
lassen sich zuletzt in bewertende und vorhersagende Masse unterteilen.
Erstere Masse betrachten den konkreten Fall, während das Zweite auch
auf zukünftige Aspekte (wie Reuse) eingehen.
Mutex ist ein Semaphor, der knappen Ressourcen wie "cout" vor
Mehrfachzugriff schützt. Threads können ein Mutex-Objekt mit
eingebetteter Lock-Klasse erhalten. Sowie ein Thread ein Lock-Objekt erzeugt
hat, hat es exklusiven Zugriff. Nach der Zerstörung von Lock ist Mutex
wieder frei. Beispiel:
struct MeinTask:public Thread{
void run(){
for(int i=0; i<5;i++){
{
Mutex::Lock l(mtx); // Blockiere, bis Lock erzeugt
cout << ID << endl;
} // zerstöre Lock
sleep(1);
}
}
};
Mutex mtx;
void main(){
MeinThread t1,t2;
t1.start(); t2.start(); // starte Tasks
t1.terminationAwaited(); t2.terminationAwaited();
}
Die Model-View-Controller-Architektur schreibt vor, wie ereignisgesteuerte
Programme erstellt werden sollten, wodurch Programme mit einer interaktiven
Oberfläche versehen werden. View und Controller bilden jeweils ein Paar,
das auf bestimmte Model-Komponenten abgestimmt ist. Die Model-Komponente muss
daher nichts über die Datenrepräsentation oder Eingabeverarbeitung
wissen, sondern kann sich voll auf die Problemlösung konzentrieren.
* Problematisch am MVC ist, dass für jede Model-Komponente spezielle
View-Controller-Paare aus den Standardtypen abzuleiten sind. Dies führt
rasch zu einer "Explosion" von abgeleiteten Klassen. Stark abgemildert wurde
dieses Problem durch die Einführung von Template-Parametrisierung. Dadurch
bedingt muss aber die Model-Komponente nun wissen, mit welchen Datentypen sie
bei welchen Eingaben zu reagieren hat, d.h. die Trennung zwischen Modell und
Oberfläche wird zwangsläufig etwas verwässert.
Die HIC-Architektur MVC erleichtert das Reuse, erlaubt er doch die
Modellierung von View- und Controller-Komponenten unabhängig von einer
Model-Komponente. Das erlaubt die Entwicklung von
Oberflächen-Bibliotheken. Ein Model verfügt i.d.R. über viele
Views. Views und Controller gehen meist eine 1:1-Beziehung ein -
tatsächlich ist die Controller-Komponente oft implizit in die Views
eingearbeitet, verfügt daher nicht immer über eigene Klassen (die
konzeptionelle Trennung rührt eigentlich nur aus SMALLTALK her). Oft
stellt der Controller nicht mehr dar, als eine Ewigschleife, die alle Events
(z.B. Drücken von F1) abfängt, und darüber die Views und evtl.
das Model informiert.
Als Werkzeuge für das Oberflächen-Design kommen infrage:
-
Tools, die aus OOA-Modell automatisch Oberfläche generieren. So werden
abstrakte Klassen z.B. zu Menütiteln, die abgeleiteten Klassen zu
Menüpunkten, Aggregationsklassen zu Fenster, die Zerlegungsklassen zu
Unterfenstern, usw.
-
4GL
-
Application Frames: Bibliotheken mit View- und Controller-Klassen, die ein
typisches Look&Feel der HIC erzwingen. Im Gegensatz zu UIMS können
damit neben der Oberfläche aber durchaus auch Applikationen programmiert
werden. Beispiele: OWL von Borland für WINDOWS; XVT für virtuelle
APIs, wodurch es z.Z. für 7 BS die gleichen Oberflächen anbieten
kann.
-
UIMS (Toolkit): Arbeiten interpretativ, d.h. entwickeln zur Laufzeit zu
Application die passende Oberfläche nach dem Generator-Ansatz oder
Editor-Ansatz (?). Die gegebenen Möglichkeiten sind aber schlecht
erweiterbar, was die Anwendbarkeit von UIMS stark beschränkt (werden am
Lehrstuhl nicht mehr eingesetzt). Beispiel: DevGuide von SUN für
OpenWindows.
Nach Rumbaugh sollte jedes System nur die unbedingt nötige
Komplexität nach aussen zeigen. Ein Interface hierfür ist zu
erstellen, welches eine möglichst von der Anwendung unabhängige
Oberfläche kontaktiert, die aus mehreren Einzelteilen besteht. Das Modell
kennt nur die semantische Information, zu dessen Entwicklung jedoch der
Problembereich bekannt sein muss. Danach kann ermittelt werden, was
mittels Views anzuzeigen ist; falls vorhanden, können alte Klassen
wiederverwendet werden. Zuletzt muss designed werden, wie Views und Modell
miteinander interagieren. Die Controller überführen dazu die
Benutzereingaben in passende Operationen. Jede Schicht kann also ziemlich
für sich alleine entwickelt werden, auch wenn die Reihenfolge Modell -
View - Controller einzuhalten ist. Es kommt zu keinen Zirkulationen. Am besten
wird eine abstrakte Klasse für alle Modell-Objekte geschaffen, an die die
Views und Controller Nachrichten versenden. Widgets sind Werkzeuge aus
Application Frames, die frei verwendbare View-Controller-Einheiten
darstellen.
MCV ist ein Implementationsmodell (Gegensatz zu Seeheim und IFIP), d.h. es
werden hier auch programmtechnische Aspekte beschrieben. Neben einer Trennung
von Oberfläche und Anwendung findet daher auch eine Trennung der
Oberfläche in Interaktionsteil und Präsentationsteil statt. Das
erlaubt eine effektivere Modularisierung bei der Programmierung.
Die OOA nach Coad und Yourdon ist ein geordneter Prozess, der aus
folgenden Schritten besteht:
- Erstellung statisches Modell: Hier werden die Schichten
Klassen&Objekte, Subjekte, Attribute, Methoden und Strukturen aus der
Problemspezifikation erstellt. Dadurch erhält man alle statisch relevanten
Informationen bzgl. der Systemverantwortlichkeit.
- Erstellung dynamisches Modell: Hier wird durch Szenarien,
Ereignisfolge-Diagrammen, Objektfluss-Diagrammen (OFDs) und
Zustands-Diagrammen der zeitliche Ablauf von Methodenaufrufen des Systems
beschrieben und geprüft. OFDs entsprechen in etwa einer SA ohne Data
Dictionary, die i.d.R. nur 3 Ebenen tief sind.
- Erstellung funktionales Modell: Mittels Struktogrammen und strukturierter
Sprache (Pseudocode) werden die Algorithmen der Methoden des Systems
wiedergegeben.
Moderne Ansätze sehen vor, aus dem statischen Analyse-/Designmodell
automatisch MCK-Prototypen zu generieren. Folgende Richtlinien scheinen dabei
sinnvoll zu sein:
-
Klassen: Klassen => Menütitel+ 2 Fenster (Einzelobjekt-Fenster,
List-Fenster); Klassenname => Fenstertitel; Attribute => I/O-Felder,
Methoden => Buttons für Aktionen.
-
Vererbungsstrukturen: Basisklasse => Menütitel, wenn nicht
abstrakt, dann auch erster Menüpunkt; abgeleitete Klassen =>
Menüpunkte (bzw. Untermenüpunkte); in den zugehörigen Fenstern
werden nach Möglichkeit die vererbten Eigenschaften mit angezeigt; wenn
mehrstufig, dann Fenster IN Fenster IN Fenster; wenn multiple, dann Fenster
NEBEN Fenster IN Fenster.
-
Aggregation/Beziehungen: Aggregation => Menüpunkt + Fenster; Teil
=> 2 Fenster (Einzelteil, List-Fenster); wenn Strukturbeziehung optional,
dann auch Teil als Menüpunkt aufnehmen; zwischen den Fenstern von
Objekten, die eine Beziehung eingehen, kann direkt gewechselt werden;
existenzielle Aggregationsstrukturen bei der Generierung neuer Teilfenster zu
beachten.
-
Subjekte: Subjektname => Menütitel im Hauptprogramm; durch Aufruf
der Menupunkte gelangt man in Unterprogramme mit Menütiteln, die aus den
dort vorhandenen Objekten bestehen.
Das OCP ist für Klassenbibliotheken einzuhalten. Es bedeutet,
dass Klassen offen für Erweiterungen über Vererbungen sein
sollen, aber geschlossen gegenüber Änderungen ihres Innenlebens. Erreicht
wird dies durch niedriges Coupling und hohe Kohäsion. Dies garantiert eine hohe
Wiederverwendbarkeit der Klassen.
Immer wiederkehrende Klassen(-Gruppen) in Problembereichen werden als
Pattern bezeichnet. Solche Klassen können z.B. sein: Person, Rolle, usw.
Sie sind allgemeingültig (nicht nur für OOA und OOD, sondern auch
für OOP) und daher effektiv für Black Box Reuse einsetzbar. Meist
bestehen sie aus einer Vererbungsstruktur bzw. Verbund von 2 bis 5 Klassen
(aber auch einfache Beziehungen sind möglich). Sie müssen unabhängig von
Anwendungen generiert werden. Meist verfügen sie über eine eigene
Speicherverwaltung mittels new und delete. Besonderheiten wie
Referenzenzählen oder Umschlag-für-Briefe-Funktionen können sie
besonders wertvoll machen.
Typische von Schader aufgeführte Patterns sind:
-
Gruppierung-Mitglied: Die Gruppe führt die Gesamtfunktionalität,
z.B. "koordinieren", während die Mitglieder individuelle
Fähigkeiten besitzen. Dasselbe gilt für die Status.
-
Gegenstand-Beschreibung: Besitzen viele Klassenobjekte die gleichen
Attributeausprägungen, so können diese in einem Beschreibungsklasse
ausgelagert werden. Das spart Speicherplatz.
-
Instrument-Ereignisregistrierung: Erzeugen Objekte einen Output, der
über längere Zeit relevant ist, so kann dieser in einem
Ereignisnotierungsobjekt gesichert werden.
- Objekt-Beziehungsbeschreibung-Objekt: Zur näheren Beschreibung von
Beziehungen zwischen Objekten, lassen sich Beschreibungsobjekte
einführen.
-
Darsteller-Rollen: Personen verfügen über Grundattribute,
darüber hinaus aber auch besondere Fähigkeiten, die sie nur in
bestimmten Rollen ausleben können. Es ist sinnvoll, diese Rollen extra zu
modellieren.
Mit der Persistenz von Objekten ist in erster Linie ein
Identitätsproblem verbunden. Auch nach längerer Speicherzeit muss
sichergestellt sein, dass jedes Objekt eines Systems über sein - für
den Programmierer transparentes - Surrogat eindeutig identifiziert werden
kann.
Implementierung: Meist wird eine spezielle Klasse PERSISTENZ mit den
Elementfunktionen save()/load() gebildet, die als Basisklasse für alle
persistenten Klassen herangezogen werden kann. In EIFFEL z.B. lässt sich
dies leicht - ohne jede Codeänderung - durch die Klasse HERE realisieren,
von der jede normale Klasse sowieso automatisch abgeleitet ist. Ein anderer Weg
der Implementierung ist die Schaffung eines Servers/Containers, der Objekte
laden und speichern kann. Dies ermöglicht insbesondere eine
objektabhängige Persistenz, d.h. Objekte einer Klasse können auch
transient bleiben. Zu klären ist weiterhin, ob Objekte ihre
Zustandsänderungen sofort auf Platte "durchschreiben" sollten, und ob
neben einem atomaren Zugriff (dazu wird die ganze vernetzte Struktur in den
Hauptspeicher geladen) auch ein Zugriff auf Interobjekte möglich sein
sollte. Für beide Ideen gilt, dass ihre Nutzen nicht den dazu nötigen
Aufwand rechtfertigen.
Persistenz verlangt eine eindeutige räumliche und zeitliche
Identität der Objekte. Dazu müssen sie unabhängig von ihrem
Zustand, d.h. Attributen, sein. Die Objekte müssen dazu u.a. mit
verschiedenen Versionen ihrer selbst umgehen können.Die Integrität
ist durch das Information Hiding relativ leicht zu realisieren. Durch
Persistenz sollte sich an vorhandenen Systemen nicht viel ändern, d.h. die
Objekte verhalten sich wie bisher, und jedes Objekt kann unabhängig von
seinem Typ persistent sein. Man unterscheidet weiterhin die Persistenz durch
Erreichbarkeit (die ganze vernetzte Struktur ist persistent) von der
einfacheren expliziten Persistenz (nur Vererbungsstruktur ist persistent).
Der Pfeiloperator "x->i", der einen Zugriff auf das
Klassenelemente i des Objekts x erlaubt, ist nur eine Kurzschreibweise von
"(*x).i".
Um über einen Basiszeiger eine Funktion der abgeleiteten Klasse
aufzurufen, obwohl die Basisklasse eine gleichnamige Funktion definiert, muss
diese Elementfunktion in der Basisklasse "virtual" sein!
struct A{void f(){}};
struct B:A{void f(){}};
void main(){
B b;
A *ap=&b;
ap->f(); // Aufruf von A::f, nicht wie gewollt von B::f!
}
Der C-Präprozessor erlaubt die Einfügung von Zeichen (#include),
eine bedingte Übersetzung des Codes (#ifnotdef) und die Benutzung von
parametrisierten (!) Makros (#define).
Erweiterung des statischen Analysemodells um abstrakte Klassen,
abgeleitete Datentypen, aufgebrochene Attribute plus deren näheren
Beschreibung, Aufnahme von Surrogaten, Änderungen der Kardinalitäten,
usw. Die Coad/Yourdon-Notation erlaubt leider nicht alle OOP-spezifischen
Besonderheiten darzustellen, z.B. virtuelle Basisklassen, selektive Vererbung,
protected-Ableitungen, usw. Ein weitere Schritt ist die Untersuchung von
Bibliotheken, ob diese nicht bereits passende (oder anpassbare) Objekte
enthalten. Im Gegensatz zur Analyse geht das Design also stärker Bottom-up
als Top-down vor (die OOP selbst funktioniert nach dem Gegenstrom-Prinzip). Um
Objekte zu finden, muss eine solche Bibliothek Kriterien wie einheitliche
Notation, Angebot von Suchfunktionen, Klassifizierungen,
Analyse-/Design-Ergebnisse, usw. erfüllen. Hier sind dann jedoch
zusätzlich Aspekte der Fehleranfälligkeit bzw. Fehlerhaftung und des Copyrights
zu berücksichtigen. Beispiele: tools.h++, OWL, NIHCL, MACL (Mannheim Class
Library), XVT.
Man unterscheidet zwischen Programmieren im Kleinen und Programmierung im
Grossen. Ersteres umschreibt die direkte Umsetzung von Spezifikationen in
den Programmcode. Als Hilfsmittel ist hier nur eine Programmierumgebung
(Editor, Compiler) bekannt. Doch erst beim Programmieren im Grossen,
welches alle Phasen des SW-Lebenszyklus umfassen, kann die OOE ihr volles
Potenzial ausfahren. De Marco behauptet: "Nur was sich messen
lässt, ist kontrollierbar." Verlangt ist daher ein Clean Room
Engineering, d.h. das fehlerlose Entwickeln von Anfang an, möglichst als
Reengineering (Reuse) verstanden. Neuartige wissensbasierte CASE-Tools helfen
bei der Wahl zwischen den verschiedenen OOP, OOD-Diagrammen und der
Kostenabschätzung. Lower-/Upper-CASE-Tools unterstützen Programmierung im
(Kleinen)/Grossen, doch nur die seltenen Integrated-CASE-Tools
unterstützen auch die Programmierung im Vielfachen: Sie bringen eine komplette
SW-ENTWICKLUNGSUMGEBUNG mit sich.
Die strukturierte Programmierung basiert auf den Prinzipien Sequenz,
Iteration und Selektion, sowie auf der (einspurigen) EVA-Methode. Als
Hilfsmittel sind v.a. Pseudocodes und Struktogramme zu nennen. Eine Erweiterung
stellt das modulare Programmieren dar, welches bereits mit Import/Export von
Schnittstellen und Data Hiding operiert. Bis zur objektorientierten Programmierung
mit Vererbung, Polymorphismus und Information Hiding ist es da nicht mehr weit.
Folgende Stufen in der Prozessorganisation lassen sich identifiziert:
-
Anfangsniveau: Jeder arbeitet individuell für sich. 85% der US-Unternehmen
halten sich an dieser rückständigen Prozessorganisation.
-
Wiederholbar: Es existiert eine informelle Organisationsnorm, die
vorschreibt, wie es innerhalb der Firma gemacht werden sollte. Immerhin
12% der Unternehmen haben solche Normen eingeführt.
-
Gemanagt: Die interne Prozessnorm wird von speziell abgestellten
Managern auf Einhaltung überwacht. Nur 3% der Unternehmen haben den Sinn
solcher Formalisierungsmassnahmen erkannt.
-
Messbar: Die interne Firmennorm wird nicht nur von speziellen
Managern geleitet, sondern ihre Ergebnisse werden einer laufenden Prüfung
unterzogen, um so eine ständige Vergleichsbasis zu erhalten. Ebenso wie
Niveau 5 ist dieses Niveau der Prozessorganisation so gut wie noch nicht
implementiert worden.
-
Optimiert: Die Ergebnisse, die die laufende Kontrolle der Prozesse
liefert, werden benutzt, um rückkoppelnd Verbesserungen entwickeln zu
können. So bildet sich mit der Zeit ein optimierter Ablauf der
Arbeitsprozesse heraus.
Um Pointer an Referenz-Variablen zuzuweisen, müssen diese zuvor
über den Inhalts-Operator * dereferenziert werden. So kann man z.B. einer
Referenz-Variablen die dynamische Speicheradresse einer Integer-Variablen
zuweisen.
int &i=*new int; // new int=Typ int*, daher Dereferenzierung nötig
i=2;
cout << i;
Liefert eine Funktion eine Referenz auf ein Objekt zurück, kann der
Funktion über den Zuweisungsoperator = ein Wert zugewiesen werden, der
letztlich dem zurückgegebenem Objekt zugewiesen wird.
int& MAX(int &a,int &b){
return (a>b)?a:b;
}
void main(){
int a=2, b=3;
MAX(a,b)=4;
cout << b; // liefert 4 zurück!
}
Der Typ void& ist nicht erlaubt! Eine Referenz ist in ihrer
Ausprägung objektabhängig. Ebenso verboten sind Konstrukte wie
TYP&* oder TYP&&. Erlaubt sind dagegen Referenzen auf Pointer von
void-Objekten, also void*&!
Referenzen sind eine Art const-Pointer. Und ebenso wie diese können
sie nur genau einmal initialisiert werden! Zuweisungen an Referenzvariablen
bewirken keine weitere Initialisierung mehr, sondern eine Änderung des
Objekts, auf den die Referenz verweist! Ihr Einsatzgebiet ist damit stark
eingeschränkt, was Referenzen zu einer Art Bürger 2.Klasse macht.
Referenzen MÜSSEN initialisiert werden (wie auch konstante Zeiger).
Als einziger Unterschied zu const-Pointern gilt, dass Referenzen direkt auf
ihren referenzierten Inhalt verweisen, während const-Pointer dazu mittels
* dereferenzieren müssen. Es gilt:
int i=1; int &ri=i; ri+=21; // i wird um 21 erhöht.
T& ist IDENTISCH mit T& const! Mittels const T& können
Rvalues, mittels T& aber nur Lvalues mit berechenbarer Adresse referenziert
werden (in Blöcken erzeugt der Compiler für Literals eine
temporäre Variable, sodass "int &i=1;" möglich ist, nicht
jedoch im globalen Geltungsbereich, da sie hier fest im Code stehen und somit
keine berechenbare Adresse besitzen). Die Adresse der Referenz kann niemals
herausbekommen werden! Aus diesem Grund kann es auch keine Pointer oder
Referenzen auf Referenzen geben, ebenso wenig Felder mit Referenzen. Auch
über new können keine Referenzen erzeugt werden. Eine
void&-Deklaration ist nicht zulässig.
Referenzen als formale Argumente simulieren call-by-values, d.h. die
übergebenen Objekte können verändert werden (nur der
Referenzzeiger ist const, nicht der Inhalt, auf den er zeigt), und grosse
Objekte können so sehr schnell übergeben werden.
Warum ist "int &i=1;" ein Fehler? Weil "1"
konstant ist, int& aber ein Lvalue erwartet. Okay ist dagegen "const
int &i=1;".
Auf Referenzen existieren kaum Operatoren (nur sizeof und &). Alle
Operatoren operieren auf die Objekte, die Referenzen referenzieren. Es gilt:
"double &rd=d <==> double &const rd=d". Referenzen
müssen mit einem Lvalue initialisiert werden, weswegen "int
&ri=i++" ein Fehler ist. Werden Literale referenziert, erzeugt der
Compiler temporäre Kopien der Literale, die er referenziert. So gilt:
const char &rch="1"; <==> char tmp="1"; const char &rch=tmp;
Ein Objekt führt ein Attribut, welches zählt, wie oft auf das
Objekt verwiesen wird. Ist der Wert Null, kann das Objekt gefahrlos
gelöscht bzw. durch ein anderes Objekt überschrieben werden. Auf
diese Art kommt es nie zu referenziellen Inkonsistenz, einmal allokierter
Speicherplatz kann aber mehrfach belegt werden.
Mögliches Einsatzgebiet: Vektorklasse. Es ist nicht nötig,
für jeden Vektor ein eigenes Objekt zu erzeugen, wenn Vektoren mit
gleichen Werten anfallen. Statt durch den Copy-Konstruktor eine Kopie eines
referenzierten Vektors anzulegen, wird nur der Referenzzähler des
Vektors erhöht und seine Adresse zurückgeliefert. Aber Achtung: Eine
Änderung an diesem Vektor bewirkt natürlich eine Änderung an
"allen" Vektoren (den Programmen erscheint es ja so, als hätten
sie jeweils eigene Vektoren erzeugt). Es empfiehlt sich daher, im Konstruktor
zwischen Lesevektoren und Schreibvektoren zu unterscheiden; nur das Zweite
erzeugen echte Kopien.
Früher betraf das Reuse nur das Reuse von SW-ICs, die aus der
SW-Krise helfen sollten. Heute versteht man darunter auch viel umfassender die
Wiederverwendung von Analyse-, Design- und Dokumentationsergebnissen. Deshalb
muss auch zwischen Bausteinkatalogen und Bausteinbibliotheken
unterschieden werden: Nur letztere beinhalten neben der Spezifikation auch den
Quellcode. Ziel des Reuse ist in erster Linie die Steigerung von
Produktivität und Qualität, ausserdem erlaubt es die Verfolgung
eines Bottom-up-Ansatzes! Beim Black Box Reuse können alten Klassen direkt
eingebunden werden, während White Box Reuse Änderungen und
Erweiterungen an der Klasse verlangen.
Warum hat sich das Reuse noch nicht allgemein durchgesetzt?
- nicht-technische Problemfelder
- psychologische Barrieren: Not invented here-Syndrome. Misstrauen
gegenüber Entwicklung anderer. Suchaufwand für Reuse-Klasse ohne
Findungsgarantie.
- wirtschaftliche Barrieren: Mehraufwand ist teuer.
- juristische Barrieren: Haftungsregeln, Urheberrecht.
- organisatorische Barrieren: Wer verantwortlich? Wie Reuse organisieren? Ist
ein Belohnungssystem einzurichten?
- technische Problemfelder: Wie erkennt man SW-ICs? Wo bindet man das Reuse
im SW-Entwicklungsprozess ein? Wie klassifiziert, spezifiziert,
dokumentiert und findet man SW-ICs? Wie sichert man ihre hohe
Qualität?
Minderung der nicht-technischen Problemfelder ist möglich durch ein
Management, das Measurements (Kontrollsysteme) und Incentives
(Belohnungssysteme) mithilfe von Metriken etabliert. Neben der Initialisierung
wird die Population und Qualität der SW-ICs bewertet. Die Benutzer von
SW-ICs müssen ebenfalls belohnt werden. Das Management verteilt in einem
gross angelegten Reuse-Programm die Verantwortung auf Erzeuger,
Administratoren, Gutachter und Benutzergruppen von/für SW-ICs, legt
Analyse- und Entwurfswerkzeuge fest, und kümmert sich um die Schulung. Als
Metriken kommen infrage: Shipped Source Instructions (LOC ohne Kommentare),
Reused Source Instructions, Reuse Frequency (Nutzung meiner ICs durch andere),
Part Value (Nutzung meiner Zeilen durch andere), Reuse Percentage (Grad der
Wiederverwendung).
Die technischen Problemfelder wird durch allgemeine Zugänglichkeit
der Reuse-Optionen gemildert. Folgende Alternativen stellen sich hier zur
Verfügung: Funktionsbibliotheken (z.B. SPSS), SW-Schablonen, Generatoren
(z.B. Ressourcen-Compiler von Borland C++), abstrakte Datentypen,
Objektorientierung (Klassen, Vererbung, Polymorphismus, generische Klassen,
Standardisierung durch OMG) und zuletzt ganzen Anwendungsarchitekturen
(ähnlich SW-Schablone, nur dass mit Objekten aufgefüllt wird; -:
Jeweils nur für bestimmte Problembereiche geeignet). Die Objektorientierung
erscheint hier als der geeignetste Weg, um Black Box Reuse, White Box Reuse und
Reuse for Design verwirklichen zu können. Doch zunächst müssen
die allgemein verwendbare SW-ICs eines Problembereichs gefunden werden. Dies
geschieht über eine Domain Analysis (horizontal genannt, wenn für
mehrere Problemfelder, sonst vertikal), die mithilfe von
Problembereichs-EXPERTEN durchgeführt wird. Dazu wird ein Projekt geplant,
Daten gesammelt, analysiert, klassifiziert und evaluiert. Wichtig ist,
dass die Qualitätskriterien Korrektheit, Allgemeinheit,
Portabilität, Erweiterbarkeit, Integrierbarkeit und Selektierbarkeit
beachtet werden. Klassifizieren kann man die ICs disjunkt-hierarchisch (was
aber trotz evtl. Querverweise und XPS-Hilfe eine umständliche
Navigations-Suche abverlangt) oder mittels Facetten, die durch Terme
beschrieben werden, die die Such-Deskriptoren vorgeben. Die Facette
"Qualität" setzt sich z.B. aus den Termen "gut",
"sehr gut" und "ISO9000-zertifiziert" zusammen. So etwas
lässt sich über RDBS ähnlich wie IRS realisieren. Ein
Konzeptgraph, der Ähnlichkeiten zwischen den Klassen festhält, kann
bei interaktiven Fragesystemen helfen. Der Verwendungskontext ist durch die
Beziehungen, Aggregationen (Zusammenfassungen), Nachrichtenverbindungen, Vererbung und
Versionsverweise gegeben. CASE-Tools können bei der Generierung von SW-ICs
helfen, Konzepte wie RPC, CORBA, DCE usw. bei der Verteilung im Netz. Das
solche (verteilten) Facetten-Bibliotheken keine Zukunftsmusik mehr sind,
beweist IBM mit den Produkten ReDiscovery (nur KATALOG für OS/2 mit
AUTOMATISCHER Beschreibungsgenerierung) und Corporate Reuse Environment
(realisiert als DB2-Datenbank).
Bei einer Vererbung der Schnittstelle MIT Implementation, kann es durch
Überschreiben von Funktionen zu schizophrenen Objekten kommen. Über
einen "basierten" Zeiger gelingt es dann nämlich nicht mehr, die
Funktion der abgeleiteten Klasse aufzurufen, da der bitterböse Compiler
sich stattdessen lieber für die Basisklassen-Funktionen entscheidet.
struct A{void f(){/*...*/}};
struct A:B{void f(){/*...*/}};
void main(){
B *bp=new B;
A *p=bp;
p->f(); // FEHLER: Aufruf von A.f(), statt B.f()
}
Die Schnittstelle einer Basisklasse kann auf drei Arten an abgeleitete
Klasse vererbt werden:
-
rein virtuell: Die abgeleitete Klasse muss die rein virtuellen
Funktionen überschreiben (obwohl diese durchaus in der Basisklasse
definiert sein können, was aber expliziten Zugriff mittels :: erfordert!).
Sie erbt damit EXPLIZIT nur die Schnittstelle.
-
virtuell: Die abgeleitete Klasse sollte die virtuellen Funktion
überschreiben, kann jedoch auch auf eine Default-Implementation davon in
der Basisklasse zurückgreifen. Neben der Schnittstelle wird hier auch
Funktionalität vererbt.
-
public: Die abgeleitete Klasse sollte die in der Schnittstelle
aufgeführten public-Funktionen nicht überschreiben, sondern auf die -
notwendige - Implementationen der Basisklasse zugreifen. Die abgeleitete Klasse
erbt hier IMPLIZIT die Interface-Funktionalität der Basisklasse. Achtung:
Gefahr der schizophrenen Objekte.
Eine HIC-Architektur (Referenzmodell für Oberflächen) für
UIMS. Zu bemängeln ist hier, dass für eine direkte Manipulation
(z.B. verschieben eines Icons) u.U. alle Schichten zu durchwandern sind, da
sich die Darstellung und der Zustand des Objekts gleichermassen
ändern kann. Das Seeheim-Modell besteht aus vier Komponenten (nur 3
berühren die Oberfläche direkt):
-
Präsentation: Darstellung der Oberfläche; NIMMT EINGABEN ENTGEGEN.
-
Dialog-Steuerung: Führt Änderungen an Objekten nach oben und
unten durch.
-
Anwendungsinterface: Bietet abstrakte Anwendungsobjekte; heute oft in (2) integriert.
-
Anwendung: Anwendungsobjekte; Ausgaben werden indirekt über (2) abgewickelt.
Neue OOP erlauben es, an abgeleitete Objekte nur die Teile der Basisklasse
zu vererben, die diese auch wirklich benötigen. Hier liegt dann jedoch
keine echte "is_a"-Beziehung vor, weshalb die selektive Vererbung der
OOE-Philosophie widerspricht.
C++ kennt zwei Speicherklassen:
-
Automatisch: Objekte dieser Speicherklasse werden vom Compiler nach dem
Deklarationsaufruf auf dem Stack erzeugt und beim Verlassen des aktuellen
Blocks oder des Programms automatisch zerstört.
-
Statisch: Objekte dieser Speicherklasse werden nicht vom Compiler,
sondern explizit vom Programmierer auf dem Heap erzeugt (und auch wieder
gelöscht). Im Gegensatz zu automatischen Objekten können statische
Objekte vor Verlassen einer Blockanweisung oder des Programmendes zerstört
werden.
C++ kennt die Spezifizierer class, friend, typedef, extern, static,
register, enum, inline, virtual, struct, union, public, private und
protected.
C++ kennt folgende Sprunganweisungen:
- goto identifier // springt zu aktuellem Label.
- continue; // springt ans Ende von Schleifen
// und prüft Bedingungen.
- break; // verlässt aktuelle Anweisung
// OHNE Prüfung der Bedingungen.
- return expression; // springt zum Aufruf zurück.
template<class T>class Stack{
T *anf, *end;
int size;
public:
Stack(int s){anf=end=new T[size=s];}
~Stack(){delete []anf;}
T pop(){return *--end;}
void push(T a){*end++=a;}
int Size(){return end-anf;}
};
In Klassen können neben Datenelementen auch Elementfunktionen static
deklariert werden. Innerhalb solcher static-Element-Funktionen steht kein
impliziter this-Pointer auf das aktuelle Objekt zur Verfügung.
Tatsächlich muss gar kein Objekt existieren, um die static-Funktion
über den Geltungsbereichs-Operator :: aufrufen zu können! Bei allen
anderen Element-Funktionen muss mindesten ein Objekt davon erzeugt
sein.
static-Objekte in Funktionen haben wie gewöhnliche Objekte dort
lokalen Geltungsbereich. Ansonsten verhalten sie sich aber ähnlich wie
globale Objekte, z.B. werden sie wie diese nicht auf dem Stack, sondern im
Datenteil abgelegt. Und egal wie oft die Funktion durchlaufen wird, die
static-Objekt-Definition und -Initialisierung findet nur beim ersten Mal
Beachtung.
static-Datenelemente, static-Elementfunktionen und Konstruktoren
können nicht virtuell sein. Warum eigentlich nicht? Der Grund dafür
ist, weil der Virtual-Mechanismus einen einem Objekt zugewiesenen virtual table
pointer benötigt, static-Members aber ebenso wie Konstruktoren (noch) kein
tragendes Objekt anbieten können.
Der Spezifizierer static vor globalen Variablen bewirkt, dass deren
interne Default-Bindung "externiert" wird. Fortan kann auf diese
Variablen auch noch ausserhalb der Definitionsdatei zugegriffen
werden.
Der Vorteil von static-Datenelementen gegenüber globalen Variablen ist,
dass sie weniger Gefahr laufen, durch lokale Variablen verdeckt zu werden.
Generell gilt, dass der globale Namensraum so klein als möglich zu halten
ist.
static-Elementfunktionen werden nur mit extern bei der Deklaration
spezifiziert, nicht auch noch bei einer ausserhalb liegenden Definition.
Grund: static sorgt für eine externe Bindung der Klasse, die durch ein
static im Definitionsbereich in eine interne Bindung umgewandelt werden
müsste. Dies ist aber nicht möglich.
Eine static-Variable "static int i;" kann auf drei Arten mit "0"
initialisiert werden: (1) static int i; (2) static int i=0; (3) static int
i(0);
Template-Funktionen in Klassentemplates können nicht direkt mit
static deklariert werden (der Compiler beschwert sich über
Speicherklassen-Fehler). In der Klassendeklaration ist jedoch der Spezifizierer
static möglich, sofern die Definition ausserhalb des Deklarationsrumpfes und
ohne static erfolgt. Dadurch wird die Klasse extern gebunden.
In der Strukturschicht der OOA werden Generalisierungs-/Spezialisierungs-
und Aggregations-/Zerlegungs-Strukturen unterschieden. Erstere liest man im
Modell von unten nach oben mit "ist ein", das Zweite liest man von oben nach
unten mit "hat ein". Bei Whole/Part-Strukturen ist dabei zu beachten, dass die
angegebenen Kardinalitäten immer gelten müssen (und nicht nur
für eine bestimmte, zufällige Ausprägung). Bei allen Strukturen
ist zu prüfen, ob sie nicht auch einfach durch zusätzliche Attribute
modelliert werden könnten!
Bei der Whole/Part-Struktur unterscheiden wir folgende typische
Formen:
-
Ganzes-Teil: Meist Kardinalität von 1,m bei Ganzes (=physisch Existentes).
-
Container-Inhalt: Meist Kardinalität von 0,m bei Container
-
Gruppierung-Mitglied: Meist Kardinalität von 0,m bei Gruppierung
(=konzeptionell Existentes)
Die SA besteht aus Datenflussdiagrammen (DFD), der
Prozessspezifikation und einem formal aufgebautem Data Dictionary.
Zunächst wird in einem Kontextdiagramm ein Hauptprozess mit all
seinen Datenquellen/-senken und Datenflüssen dargestellt. Dieser
Prozess wird in das DFD der Ebene 0 in die Prozesse 1, 2, ..., n
aufgeteilt, und evtl. um Dateien ergänzt. Jeder Prozess kann in
weiteren DFD in die Prozesse 1.1, 1.2, ... bzw 2.1, 2.2, ... aufgespaltet
werden. Wichtige Konsistenzbedingung: Die Anzahl eingehender und ausgehender
Datenflüsse eines DFD der Ebene i+1 stimmt mit der Anzahl eingehender und
ausgehender Datenflüsse in den Prozess der Ebene i überein. Ziel
ist es, jeden Prozess schliesslich auf jeweils einer DIN A4-Seite in
natürlicher Sprache, Entscheidungstabellen (Wenn/Dann:Regeln) oder
Entscheidungsbäumen beschreiben zu können (Prozessspezifikation;
P-Spec, Min-Spec). Stärker formalisiert können dann die Datentypen
und ihre Zusammenhänge in einem Data Dictionary wiedergegeben werden.
Das SW-Science-Mass ist eine Metrik für die Komplexität
eines Programms. Es werden dabei die verschiedenen Operatoren und Operanden
ihrer jeweiligen Häufigkeit in einem Programm gegenübergestellt und
mit der Programmlänge multipliziert. Als objektorientierte Metrik ist ihr
Einsatz nicht zu empfehlen, trotz ihrer Automatisierbarkeit.
Szenarios gehören zur dynamischen Systemanalyse und beschreiben die
Wirkung von Ereignisse auf Objekte, die sich in bestimmten Zuständen
befinden. Es gilt, dass jedes in einem System mögliche Ereignis in mindestens
einem Szenario berücksichtigt wird (und sei es nur, um darauf hinzuweisen,
dass ein Ereignis keine Zustandsänderung der Objekte hervorruft,
sondern ignoriert wird). Aus den Szenarien werden Ereignisfolge-Diagramme
generiert, die zum besseren Verständnis das Zusammenspiel von Ereignisse,
Zuständen und Aktionen noch einmal visualisiert wiedergeben. Die im
Szenario erwähnten Ereignisse, Objekte und Methodenaufrufe müssen mit
dem statischen Modell abgeglichen werden!
Bei Betrachtung eines OOA-Modells sind Überlegungen anzustellen, ob
asynchron arbeitende Objekte darin vorkommen müssen. Solche Objekte lassen
sich als Task-Objekte betrachten, die voneinander völlig unabhängig
sein können. Besonders bei Multitasking-BS werden solches Tasks
interessant, wenn z.B. massiv externe Systeme auftreten, die über
Hüll-Klassen verfügbar gemacht wurden.
Die Task-Erzeugung erfolgt über den von POSIX standardisierten Aufruf
von fork(). Dies aktiviert einen neuen Prozess (Prozess dynamisch mit
Signalmaske, Stack, Heap, Code, Umgebung wie Pfadnamen >> statisches
Programm!). Nachteilig an fork() ist, dass er die ganzen Prozesse kopiert
(Heavyweight-Konzept); nur über den Resultat-Wert auf dem Stack
lässt sich feststellen, ob man sich in Vater (fork() liefer Sohn-PID)
oder Sohn (fork() liefer 0) befindet. Stirbt der Vater, wird das Kind nur kurz
zum Waisen, weil es vom Initialisierungsprozess (PID=1) adoptiert wird. Im
Sohn-Teil kann über exec("Programmname") der Sohn-Code gestartet werden,
damit der Vater diesen nicht unnötig mitschleifen muss. Achtung: Alle
Einträge nach exec() werden ignoriert - nach der return-Anweisung stirbt
der Sohn, und der (über "wait(&status)" wartende) Vater
agiert weiter.
Moderne Application Frames wie die NIHCL realisieren ein
Lightweight-Konzept. Hier wird die Umgebung nicht neu kopiert, sondern die alte
von Vater und Sohn gemeinsam genutzt. Noch leichter arbeiten Thread-basierte BS
wie z.B. OS/2, Solaris oder die KSR mit ihren 100 Prozessoren.
"Sohn-Prozesse" sind hier nur neue Threads, die einem
Vaterprozess zugeordnet sind. Sie benötigen keine eigene Umgebung,
keinen eigenen Heap oder Datenteil, jedoch wegen der Interrupt-Abarbeitung
einen eigenen Registersatz, sowie zur Abarbeitung von Aufrufen einen eigenen
Stack. Die Thread-Kommunikation wird über den globalen Datenteil
realisiert. Statt max. 20 Prozesse sind über 10.000 Threads erzeugbar!
Voraussetzung dazu ist aber ein preemptives Scheduling, d.h. es obliegt alleine
den CPUs, die Threads zu suspendieren, nicht ihnen selbst (obwohl ihnen diese
Möglichkeit gegeben ist)!
Ein Task-Objekt wird alternativ durch einen Prozess, einen
abgeleiteten Prozess, oder - moderner - durch einen Thread gebildet. Im
wesentlich jedoch identisch sind die Zustände nach dem POSIX-Standard, die
ein Task-Objekt einnehmen kann:
-
created: Nach Aufruf von Thread() wird ein Thread erzeugt. Da Threads
unabhängig voneinander arbeiten, empfiehlt sich auch ihre jeweilige
Einschliessung mit einem try-Block - so bedeutet ein Thread-Fehler noch
nicht das Ende des ganzen Prozesses!
-
running: Wird im Zustand created die Elementfunktion start() aufgerufen,
gerät der Task in den running-Zustand und ruft run() auf. Übrigens:
Auch der sleep()-Befehl wird in diesem Zustand abgearbeitet!
-
suspended: Neben einer Suspendierung durch die CPU kann sich der Thread
auch mittels suspend() selbst Schlafen legen. Dadurch gerät er vom Zustand
suspended oder running in den Zustand suspended. Über ein von aussen
kommendes resume() kann er wieder aufgeweckt werden, d.h. er geht wieder in den
Zustand running über.
-
finished: Ist der Thread abgearbeitet, gelangt er vom Zustand running in
den Zustand finished. Zu beachten ist, dass das Task-Objekt in diesem
Moment immer noch existiert, das selbst start() noch einmal aufrufbar ist!
Eigenen Threads sind von Thread abzuleiten und die rein virtuelle Funktion
run() ist zu überschreiben. Werden kritische Ressourcen benötigt,
muss jeweils ein Mutex::Lock-Objekt erzeugt werden. Informationen
erhält MeinThread t über den Konstruktor. t.start() bewirkt die
Erzeugung des Threads im BS, wobei es einen Zeiger auf this und
Thread::beginThread() erhält. Das BS ruft sofort beginThread() auf,
welches den übergebenen this-Pointer "basiert" und virtuell die
Funktion run() aufruft - MeinThread ist damit im Zustand running:
struct MeinThread:public Thread{
int ticks;
MeinThread(const int i){ticks=i;}
void run(){Mutex::Lock l(mtx); sleep(ticks); cout << "Hurz";}
};
void main(){
MeinThread t(5);
t.start();
t.terminationAwaited(); // teile t mit, dass main() auf sein Ende wartet
}
Was ist mit dem Aufruf terminate()? In den meisten Thread-BS ist er nicht
direkt vorgesehen, denn zu viele Gefahren sind damit verbunden: Offene Dateien
bleiben offen, blockierte Ressourcen bleiben blockiert, usw. Er lässt
sich relativ leicht simulieren.
Wie findet man die Task-Objekte? Man sucht nach Objekten, die nur zu
bestimmten Zeiten aktiv sind. Oder die nach bestimmten Ereignissen aktiv sind.
Man ermittelt die für sie angemessene Priorität (lohnt sich v.a. bei
mehreren CPUs!). Beherrscht das BS keine preemptives Scheduling, so kann die
Entwicklung eines Task-Koordinators sinnvoll sein.
Einige typischen Thread-Anwendungen:
- Timed Task: Bekommt im Konstruktor Pointer auf Alarmfunktion und Weckzeit.
Nach start() wird in run() bis zur Weckzeit gewartet und dann Alarmfunktion
aufgerufen.
- Periodic Task: Ähnlich wie Timed Task, nur dass run() Ewigschleife
ist.
- File Wait: Testet in run() bis zum Timeout, ob eine bestimmte Datei erzeugt
wurde. Wenn ja, wird über Mutex::Lock ein Exklusiv-Zugriff besorgt, die
Datei ausgegeben und terminiert.
- Poll&Notify: run() bewirkt hier das regelmässige Pollen einer
übergebenen Prüffunktion. Wenn Bedingung erfüllt, dann wird
Event-Objekt, das direkt nach start() suspendiert wird, running gesetzt.
- Task-Koordinator: Führt eine Liste<Thread*> und führt
Methoden für alle darin aufgeführten Threads, z.B. setAllPrioritys(),
suspendAll(), runAll(), add(Thread), remove(Tread), ...
- Delay,Timer, Mischer: Delay ist ein Thread, der eine übergebene Zeit
schläft. Timer ist ein Thread, der Delay setzt und nach dessen Abarbeitung
über eine Empfänger-ID einen Ereignis-Namen aufruft
[(Empfänger-ID*)t->Ereignis-Name()]. Mischer ist ein Objekt, welches mit
Drehzahl=0 initialisiert wird. In mische() wird die Drehzahl hochgesetzt, und es
wird an Timer der Ereignis-Namen setzte_Drehzahl übergeben. Timer wartet
Delay ab und setzt Drehzahl wieder auf 0.
TEMPLATE-FUNKTIONEN müssen ALLE Klassenschablonen als formale
Argumente erhalten. Nicht alle ARGUMENTE müssen aber parametrisiert
sein.
Wird eine Klassenschablone NICHT als Argument übergeben, so kann sie
auch nicht im Funktionsrumpf angewendet werden. Wird sie als Argument
übergeben, so kann sie innerhalb des Klassenrumpfs zur Definition von
NEÜN Variablen verwendet werden. Bei der Initialisierung INNERHALB des
Funktionsrumpf funktionieren triviale Konversionen implizit. Andere
Konversionen müssen explizit vorgenommen werden.
Die Übergabe der KLASSENNAMEN in eckigen Klammern (wie bei
Klassen-Templates) ist verboten. Der Compiler erzeugt alleine aus den
aktuellen Argumenten die Instanzen der Template-Funktionen.
An die Funktionsschablonen des Funktionskopfes dürfen zwar DEFAULT-
Parameter übergeben werden, jedoch haben diese nicht die gewünschte
Wirkung: Es müssen nach wie vor explizite Typangaben gemacht werden, damit
der Compiler weiss, welche Version er zu erzeugen hat. Nicht-parametrisierte
Argumente sind im Gegensatz dazu aber weiterhin defaultierbar.
Da Templates nur den Geltungsbereich Funktion (bzw. Klasse) besitzen,
kann z.B. die Klassenschablone der Klassen genauso heissen wie die der
Funktionen, auch wenn ihr letztlich verschiedene Klassennamen zugewiesen
werden.
Beispiel für eine funktionierende Template-Funktion:
template<class X>X f(X x1){
X x2=x1+5.2; // funktioniert nur, wenn triviale Konversion möglich ist.
return x2;
}
void main(){
f(2);
f(3.3);
}
Der this-Pointer kann benutzt werden, um dynamische von statischen
Konstruktoraufrufen zu unterscheiden, denn nur bei ersterem ist this gleich
Null, weil noch nicht implizit Speicherplatz allokiert wurde.
struct X{
X(){
if(this==0)
cout << "dynamisch"
else cout << "statisch";
}
};
void main(){
X *x=new X; // dynamisch
X x; // statisch
}
Der implizite this-Zeiger erfährt nie eine (automatische) Konversion,
da er für den Compiler nicht sichtbar ist. Das ist v.a. bei
überladenen Operatoren zu beachten, die als Elementfunktionen definiert
wurden, die nicht static sind, oder die nicht als friend bzw. global definiert
sind. Die Nicht-Konvertierbarkeit bringt es auch mit sich, dass solche
Operatorfunktionen i.d.R. nicht kommutativ sind, d.h. das die Reihenfolge z.B.
beim *-Operator zuerst den benutzerdefinierten Datentyp und dann das
formale Argument fordert - umgedreht würde sich der Compiler
beschweren.
Der this-Zeiger gestattet es dem Compiler, den Elementfunktionscode einer
Klasse einmalig im Code-Segment abzulegen (sofern er nicht inline spezifiziert
wurde). Denn nur über diesen Zeiger ist es möglich, dass die in
den Elementfunktionen auftauchenden impliziten Datenelemente den richtigen
Objekten zugewiesen werden können. Der this-Zeiger kann auch explizit
hingeschrieben werden, wie er auch durch X:: substituierbar ist. Dies ist z.B.
nötig, wenn ein Datenelement durch eine lokale Variable verdeckt wird.
In C++ unterscheidet man elementare Typen wie "int", "double" und "char"
von abgeleiteten Typen wie Funktionspointern, Referenzen, Konstanten, Unions
und benutzerdefinierten Typen (wie z.B. Klassen). Bisweilen werden letztere
auch als eigenständige Typklasse aufgezählt.
Funktionen gelten in C++ als einfache abgeleitete Datentypen. Z.B. besitzt
der Funktionsprototyp "int MAX(int a,int b)" den Datentyp
"int(int,int)". Von Standardargumenten wird dabei übrigens stets
abstrahiert.
Es existieren keine typedef-Definitionen - trotz des Namens. Ein typedef ist
immer nur eine Deklaration, denn sie bringt nur einen neue Namen in ein Programm
ein, ohne diesen Namen jedoch einen Speicherplatz zuzuordnen. Aus diesem Grund
gelten typedefs auch als bindungslos (tatsächlich sind sie aber intern gebunden).
Wir eine Funktion mit anderen Funktionen überladen, erkennt der
Compiler über die Argumente beim Aufruf, welche Version er anzuspringen
hat. Dabei muss jedoch bedacht werden, dass der Überladungsmechanismus
nur innerhalb gleicher Geltungsbereiche arbeitet. Werden lokalen Deklarationen
vorgenommen, die vom Namen her die globalen überschreiben, so sind ALLE
globalen Deklarationen dieses Namens ausgeblendet, unabhängig davon, wie
sehr sie sich in den formalen Argumenten unterscheiden.
int f(int i){return i;}
void main(){
int f(); // f(int) ausgeblendet
f(); // OK!
f(1); // ERROR!
}
int f(){cout << "Hurz!;}
Warum können die Operatoren ".", "::", ".*", "sizeof" und "?:" nicht
überladen werden? Für "?:" lässt sich im Zusammenhang mit
benutzerdefinierten Typen keine sinnvolle Semantik finden. Die anderen
Operatoren können nicht überladen werden, da sie als Operanden den
jeweils benutzerdefinierten Datentyp automatisch zugewiesen bekommen.
In C++ können zwar die meisten Operatoren überladen werden, es
können jedoch keine neuen Operatoren gebildet werden. Beim
Überladen sind Stelligkeit und Assoziativität zu beachten. Als
formales Argument benötigen sie einen Verweis auf den Datentyp oder sie
führen einen impliziten this-Zeiger mit (bei Elementfunktionen gegeben).
Default-Werte für Argumente sind bei überladene Operatoren nicht
erlaubt. Alle Operatoren bis auf den Zuweisungsoperator werden
weitervererbt.
Auch abgeleitete Datentypen wie "void*" lassen sich als
Konversionsfunktionen überladen. Eine solche Funktion ist z.B. nützlich
zum Ende erkennen in einer Listen-Implementation:
operator void*(){
if(head)return head;
return NULL;
}
Nur Funktionen können im gleichen Geltungsbereich überladen
werden, d.h. mehrfach definiert werden. Mit Variablen ist dies nicht
möglich, da hier Argumente oder ähnliches fehlen, um eine eindeutige
Unterscheidung treffen zu können. typedef-Deklarationen sind übrigens
als Unterscheidungskriterium ungeeignet, da sie nur neue Typ-Namen, aber keine
neuen Typen in ein Programm einführen. "T*" ist zu
"T*const" zu ähnlich, als dass das eine mit dem anderen
überladen werden könnte. Die Überladbarkeit ist abhängig
von der nicht-impliziten Initialisierbarkeit: "T*" und "const
T*" sind z.B. als Argumente erlaubt, da "T*" nicht mit
"const T*" initialisiert werden kann. Auch "int*i" und
"int i[10]" sind zu ähnlich zum überladen, während
"int*i" und "int i[][3]" okay ist.
Templates komplizieren noch das Matching der Argumente. Jedoch werden
Templates nur bei exakter und bester Alternative genommen. Ist der Aufruf
genauso gut wie bei einer Nicht-Template-Funktion, wird diese genommen.
Warum machen die Funktionen "void f(unsigned int);" und
"void f(float);" Probleme bei den Aufrufen "f(1);" und
"f(1.1)"? Es sieht zuerst so aus, als wäre
die "1" viel näher an "unsigned int", und "1.1" näher an "float".
Tatsache ist aber, dass "1" ein int-Typ und "1.1" ein double-Typ ist,
dass also in jedem Fall eine Standard-Konversion durchgeführt werden
muss. Und der Compiler unterscheidet nicht zwischen int nach unsigned int
oder float bzw. double nach unsigned int oder float. Daher ist das Matching
mehrdeutig; der Compiler beschwert sich.
User Interface Management Systems helfen bei der Realisation von
interaktiven Benutzerschnittstellen für objektorientierte Programme, wobei
sie über ihre definierten Widgets und Werkzeuge ein typisches
Look&Feel implementieren. Im Gegensatz zu Application Frames können
damit keine Anwendungsobjekte erzeugt werden! Eine Verbindung zwischen
Oberfläche und Programm wird durch eine ereignisgesteuerte Programmierung
erzielt. Hier bestimmt nicht das Programm, sondern der Benutzer den Ablauf. Die
Architektur richtet sich dabei meist nach dem Model-View-Controller-Konzept von
SMALLTALK. Beispiel für ein Toolkit: DevGuide von SUN.
Über zusätzliche 4GL erhalten UIMS ähnliche
Funktionalität wie Application Frameworks, können also auch
Anwendungen mit Objekten versorgen. Die Nachteile bleiben jedoch: Die
Oberfläche wird weiterhin interpretativ abgearbeitet, für direkte
Manipulationen fehlen die passenden abstrakten Objekte, die jeweilige
Spezifizierungssprache ist mühsam zu erlernen, und der Interpreter macht
Erweiterungen fast unmöglich (da er dazu neu zu programmieren.
wäre).
Eine union-Deklaration kann helfen, Speicherplatz zu sparen, indem je nach
Aufruf nur ein Datenelement von mehreren Alternativen initialisiert wird. Aber
Achtung: Borlands C++ allokiert für ein Union-Objekt immer so viel
Speicher, wie dass grösste Element darin einnimmt!
union X{ // immer public!
int i;
double d;
char s[10]; // es werden immer 10 Bytes allokiert!
X(int j=2){i=j;} // es sei denn, double-Variablen benötigen
X(double e)[d=e;} // noch mehr Speicherplatz!
};
Use Cases (von Jacobson) werden im Vorfeld von OOA durchgeführt. Das
Modell wird als Black Box betrachtet, dessen externes Verhalten zu analysieren
ist. Ein System reagiert auf Eingaben durch Actors, das sind externe
interaktive Objekte wie z.B. Mensch, Task, Rechner usw., die es zu bestimmen
gilt. Jeder Actor spielt eine Rolle, die etwas ganz bestimmtes von einem Modell
erwartet, ohne dessen innere Details kennen zu müssen. Diese Erwartungen
werden in Use Cases beschrieben, die etwa das für Szenarien darstellen,
was Klassen für Objekte sind. Jedes Modell ist durch endlich viele Use
Cases vollständig beschreibbar. Use Cases sind dazu über
"extends" und "uses" ähnlich wie Klassen erweiterbar.
Dennoch werden sie nur in natürlicher Sprache beschrieben, etwa in der
Form:
- Bezeichnung: Anforderung an Pegelstand.
- Zusammenfassung: Turbinen benötigen für Arbeit grosse Pegeldifferenz.
- Actors: Energie-Versorgung
- Voraussetzungen: -
- Beschreibung: Pegelstand auszutarieren wegen Flussverschmutzungsgefahr.
- Fehlerfälle: Pegelstand zu hoch, zu niedrig, Wasserverschmutzung.
- Ergebnis: Optimale Geschwindigkeit für Energiegewinnung und Flusssauberkeit.
static-Member und Konstruktoren sind objektunabhängig, der virtuelle
Mechanismus aber ist objektabhängig, daher funktioniert er für
erstere nicht. Ebenso wirft er bei Downcasts Probleme auf, da hierbei nicht der
korrekte virtual table pointer angesprochen werden kann.
Sind virtuelle Funktionen in inline-Form möglich? Ja, aber das ist wenig
sinnvoll, da virtuelle Funktionen i.d.R. über einen Basiszeiger aufgerufen
werden, der abgeleitete Klassen referenziert. Virtuelle Funktionen sind damit
zur Laufzeit objektabhängig, d.h. der Compiler muss ihre Adresse
kennen, um sie aufrufen zu können. Eine Adressberechnung ist aber nur
möglich, wenn die Funktion an einer adressierbaren Stelle im Code
steht, d.h. der inline-Mechanismus ist hier stets zuvor ausser Kraft
gesetzt worden.
Die vom Compiler generierten Standard-Destruktoren sind nicht virtuell.
D.h., wird ein Basiszeiger, der abgeleitete Klassen referenziert, mittels
delete gelöscht, und die Basisklasse verfügt über keinen
virtuellen Destruktor oder überhaupt keinen Destruktor, dann wird nicht
der Destruktor der abgeleiteten Klasse, sondern der Basisklasse aufgerufen.
Dadurch wird nur der Basisteil des abgeleiteten Objekts gelöscht,
während der Rest als namenlose Leiche im Speicher stehen bleibt.
In C++ ist sichergestellt, dass jeder Pointer implizit auf einen
void* konvertiert werden kann. Die Rückwandlung ist zwar möglich,
verlangt aber einen expliziten Cast, woraus folgt, dass der User Buch
führen muss über den Typ, den er dem void* zugewiesen hat.
Da void* auf beliebige Objekte zeigen kann, deren Grösse
variieren und über die er nichts aussagen kann, ist eine Zeigerarithmetik
mit void* NICHT möglich.
Bei der vollständigen Qualifikation wird eine Funktion bzw. Datum so
aufgerufen, dass alle formalen Argumente exakt übereinstimmen, so wie dass
der Zugriff in eindeutiger Weise geklärt ist. Diese Methodik ist
nötig, wenn z.B. durch mehrfache Vererbung Ambiguitäten existieren
oder lokale Deklarationen die globalen verdecken. Übrigens gilt hier: Der
Geltungsbereichsoperator muss nicht auf die aktuelle Klasse verweisen, er
kann auch auf die Basisklasse zeigen.
struct Y{int f(){return 1;}};
struct X:Y{int f(int i=2){return i;}};
void main(){
X x;
x.Y::f(2); // vollständige Qualifikation, Fehler z.B.: x.f();
}
WEB ist ein Entwicklungssystem, welches gestattet, Code und Dokumentation
in nur einem Dokument mit einer Art TEX-Syntax gleichzeitig bearbeiten zu
können. Nach Abschluss der Schreibarbeit kann das Dokument einmal mit
einem TANGLE-Compiler zu einem lauffähigen Pascal-Programm, zum anderen
mit dem WEAVE-Compiler zu einer dazu passenden Programm-Dokumentation
entwickelt werden. Inzwischen existiert auch CWEB.
XVT++: Ein Werkzeug ähnlich dem Ressourcen-Compiler von
Borland C++ für WINDOWS (stellt dort exakt die gleichen RC-Files her).
Eignet sich damit zur Erstellung von Human Interface Clients unabhängig vom BS.
Body-Zeiger sind Zeiger innerhalb von Klassenobjekt-Handles, die seit
kurzem die public-Schnittstelle der Klasse nach aussen exportieren. Vorteil
für die Entwickler: Die private-Elementfunktionen sind nicht mehr wie
früher in den Header-Dateien offen zu erkennen. Dadurch bleibt das Know-how
der Klassen-Programmierer besser verborgen.
Indizierte Zeiger, die sich ähnlich wie Felder verwenden lassen, im
Unterschied zu diesen aber "umgebogen" werden können, lassen
sich folgendermassen realisieren: "int *i=new int[100];". Zu
Beachten ist, dass hierbei zweimal initialisiert wird, nämlich einmal
"*i" und einmal das namenlose Objekt "int[100]" auf dem Heap. Da C++ im
Gegensatz zu Smalltalk (noch) keine Garbage Collection besitzt, kann "int[100]"
nur über "*i" wieder gelöscht werden. Kontrolliert werden kann dies
übrigens über die Funktion "coreleft()" aus der Datei "alloc.h".
Zeiger könne auf 4 Arten initialisiert werden:
- new-Anweisung: int *ip=new int;
- Null-Pointer-Zuweisung: int *ip=NULL;
- Referenz-Zuweisung: int i=2; int *ip=&i;
- Pointer-Zuweisung: int i=2; int *ip=&i; int *jp=ip;
Die Priorität des Dereferenzierungsoperators "*" ist zu beachten. So
bedeutet "*x++" nicht etwa "(*x)++", sondern
"*(x++)". Damit wird also nicht der Inhalt, auf den "x"
verweist, inkrementiert und ausgegeben, sondern zuerst der Zeiger inkrementiert
und dann der Inhalt der neuen Adresse ausgegeben.
Zugriffsdeklarationen sind ein elegantes Mittel, um die über die
Zugriffsspezifizierer relativ global geschalteten Zugriffsrechte für
einzelne Elemente umgehen zu können. Im folgenden Beispiel wird ein durch
die private-Ableitung verborgenes public-Datenelement i der Basisklasse X
wieder aufgedeckt:
struct X{
int i;
int j;
};
struct Y:private X{
X::i; // Zugriffsdeklaration
};
void main(){
Y y;
y.i=2; // OK, aber y.j=2 Zugriffsfehler
}
Zustände sind mit konkreten Objekten assoziiert. Sie geben wieder,
welche Attribute-Ausprägung und welche Beziehungen ein Objekt
innehält (und welches Ereignis diesen Zustand herbeigeführt hat). Ein
Attributwert- oder Beziehungswechsel geht also stets mit einer
Zustandsänderung einher. Jeder Zustandsübergang wird durch ein
Ereignis hervorgerufen. Dabei sind drei Formen möglich: (1) Zustand ->
Ereignis -> neuer Zustand; (2) Zustand -> Ereignis -> neuer Zustand +
sofortige Aktion; (3) Zustand -> Ereignis -> neuer Zustand + sofortige
Aktion + warten auf Ereignis. Welcher Zustand auf welches Ereignis folgt, ist
am Ereignisfolge-Diagramm erkenntlich: Jeder eingehende Pfeil in ein Objekt
bedeutet einen Zustandsübergang des Objekts, jeder ausgehende Pfeil des
Objekts bedeutet eine Aktion des Objekts (die ein Ereignis für ein anderes
Objekt darstellt). Wichtig ist, dass Zustände im Gegensatz zu Ereignissen
dauerhaft sind, und dass gleiche Ereignisse bei gleichen Zuständen
stets die gleichen Zustandsübergänge bewirken.
In Zustandsdiagrammen sind alle Ereignisse aus den
Ereignisfolge-Diagrammen zu übernehmen. Jede dortige Spalte entspricht
einem konkretem Objekt mit seinen möglichen Zuständen. Im Zustandsdiagramm
werden für ein Objekt nur die Attribute und Beziehungen
berücksichtigt, die seinen Zustand hinreichend identifizieren.
Ein überladener Zuweisungsoperator wird nicht vererbt. Der Compiler
erzeugt aber nur einen Default-Zuweisungsoperator, wenn die Basisklassen-Teile
public sind und wenn die Klasse keine const- oder Referenz-Datenelemente
führt. Wird der Zuweisungsoperator überladen, so lässt sich
über ihn - ähnlich wie mithilfe eines Copy-Konstruktors - ein
Deep-Copy realisieren, d.h. Zeiger auf den Heap werden nicht einfach kopiert,
sondern die zugehörigen Objekte neu erzeugt. Als Funktionswert liefert der
Zuweisungsoperator einen this-Zeiger zurück (return *this;). Um
Komplikationen mit Zuweisungen der Form "y=y;" zu vermeiden, fügt man in
den Funktionsrumpf "if(this=&argument) return *this;" ein. Der this-Zeiger
macht es auch nötig, dass Zuweisungsoperatoren nicht static deklariert
werden!
Zuweisungen sind in C++ nicht automatisch bei Typengleichheit ohne
Konversion gestattet! Voraussetzung für eine direkte Zuweisung ist
vielmehr Namensäquivalenz der Datentypen (Synonyme mittels typedef sind
aber möglich)! Es gilt also:
struct C1{int i;}; struct C2{int i;};
void main(){
C1 c1; C2 c2; c1.i=2;
c2=c1; // FEHLER, obwohl Typen identisch!
typedef C1 C3;
c2=(C3)c1; // OK
}
Zuweisungen sind Ausdrücke, keine Anweisungen! Dadurch Operationen
wie "int i; char ch[(i=5)];" möglich. Ausdrücke sind stets
Lvalues, die auswertbar sind! Zuweisungsanweisungen (mit ";" am Ende) sind
nicht im globalen Geltungsbereich erlaubt, d.h. "int i=2;" ist als
Initialisierung okay, aber "int i; i=2;" ist hier ein Fehler.
Die zyklomatische Zahl von McCabe ist ein Mass für die
Programm-Komplexität. Sie wird nicht aus dem Code, sondern aus
Programm-Graphen abgeleitet. Dabei gilt, dass Knoten die ZZ verringern,
Kanten sie aber vergrössern. Nach Möglichkeit sollte ein
Gleichgewicht zwischen Knoten und Kanten bestehen, sodass sich die ZZ bei
"1" einpendelt. Ist automatisierbar.