In der Zwischenzeit (ab 1985) wurde eine Spracherweiterung von C geboren. Ursprünglich hieß die neue Sprache C mit Klassen, erhielt aber bald den treffenderen Titel C++. Man braucht kein großer Prophet zu sein, um für C++ eine ähnliche Revolution in der Programmerstellung vorherzusehen, wie sie bei C seit langem stattgefunden hat.
Im US-amerikanischen Standardisierungsinstitut wurde dann eine Normungsgruppe gebildet, die die Basissprache C normen sollte. Sie sah sich einer sehr diffizilen Aufgabe gegenüber. Auf der einen Seite sollte der Hauptteil des existierenden Codes weiterverwendbar sein, auf der anderen Seite aber sollte C wesentlich verbessert werden.
Das Standardisierungskommitte sollte so etwas wie die Quadratur des Kreises schaffen. Die große Linie des Lösungsansatzes war, soweit als möglich den bisherigen Code zumindest vorläufig zu unterstützen, aus der neuen Variante C++ die Typsicherheit weitgehend zu übernehmen und zusätzlich auf die Normierungsbestrebungen zu hören, die unter den Namen POSIX und X/OPEN bekannt wurden.
Das Ergebnis ist ein sehr pragmatischer Standard, der eine Migration (sprich vorsichtige Umstellung) von alt zu neu gestattet. Für jeden Programmierer ist es daher eine wünschenswerte Aufgabe, seine Programme so weit wie möglich zukunftssicher zu gestalten. Dieses Kapitel will hierzu eine Übersicht liefern. Der erste Teil stellt die Unterschiede dar, wohingegen der zweite Teil sich den Neuerungen zuwendet.
Informationen eines Prototyps
Mit den Prototypen bekommt der Programmierer zwei entscheidende Vorteile. Zum einen wird bei jeder Verwendung der Funktion, also beim Aufruf, eine Überprüfung der Schnittstellenparameter vorgenommen, und zum anderen kann bei der Parameterübergabe bei Bedarf eine automatische Typwandlung zwischen den aktuellen Parametern und den Typen der Formalparameter vorgenommen werden.
Beim Vergleich zwischen der K&R-Schreibweise und der ANSI-Schreibweise wird der Unterschied deutlich.
Die leeren Klammern der Funktion in der K&R-Schreibweise geben eine ungeprüfte Schnittstelle an. Da im Beispiel 1 beim Aufruf eine int-Variable übergeben wird, ist das Ergebnis mit Sicherheit falsch, da die Funktion sin() als Parameter einen double-Wert erwartet.
Programmierer unter K&R mußten nur für den Rückgabetyp einer Funktion eine Deklaration schreiben, und in den Fällen, in denen die Funktion "int" lieferte, unterblieb zumeist auch noch diese einfache Deklaration.
Bild 12-2: Aufruf nach ANSI mit angepaßtem Parameter
Das gleiche Beispiel (im Bild 2) mit einer Deklaration nach ANSI verhält sich korrekt. Obwohl wieder als aktueller Parameter eine int-Variable gewählt wurde, konnte der Compiler eine automatische Typkonvertierung auf double durchführen, da der Typ des formalen Parameters aus der Deklaration bekannt war.
In den Fällen, in denen keine automatische Konvertierung möglich ist, kann der Compiler schon zur Übersetzungszeit den entsprechenden Fehler melden. Programme mit ANSI-Deklarationen (Prototypen) sind also wesentlich fehlersicherer.
In einem zweiten Experiment kann man anstatt einer erwarteten double-Variablen eine float-Variable übergeben. In diesem Fall erhalten wir in beiden Fällen (ohne oder mit Prototyp) ein korrektes Ergebnis. Wieso eigentlich?
Bei K&R ist es üblich, bei der Übergabe von Parametern eine Anpassung vorzunehmen. Alle Variablen, deren Darstellung kleiner ist als eine int-Variable, werden als int übergeben. Ein char wird daher immer auf int gewandelt. Ebenso werden keine float-Variable übergeben, sondern ausschließlich double. Der im Beispiel 3 eingebaute Fehler einer float-Übergabe an eine Funktion, die double erwartet, wirkt sich somit nicht aus.
Bild 12-3: Automatische Konvertierung nach K&R
Für Programmierer, die neuen und alten Code bearbeiten müssen, ist die Anpassung der Schnittstellen mit die wichtigste Aufgabe. Sehen wir uns daher nach den Deklarationen der Schnittstellen noch einmal die Definition an. Mit main() kann der Unterschied einfach demonstriert werden.
Unter K&R gab man in den runden Klammern der Funktionsdefinition nur eine Liste der Parameternamen an. Die eigentliche Definition geschah dann zwischen Parameterliste und Funktionsblock.
Wurden in einem Programm nur Funktionen benutzt, die int als Rückgabetyp lieferten, wurden häufig die Deklarationen weggelassen. Im Beispiel wurde keine Informationsdatei eingelesen. Der Compiler erstellte automatisch eine eigene Deklaration mit leerer Schnittstelle und der Rückgabe von int.
Bei der Definition konnte man außerdem den Rückgabetyp weglassen, wenn sowieso int benutzt wurde. Und schließlich war es durchaus üblich, auch die Rückgabe wegzulassen. In den ersten Compilern gab es das Schlüsselwort void noch nicht, sodaß es keine Möglichkeit gab, fehlende Rückgaben dem Compiler mitzuteilen.
Bild 12-4: Funktionsdefinition nach K&R
Funktionen, die nach ANSI geschrieben werden, definieren die Parameter im Funktionskopf, spezifizieren den Rückgabetyp und geben immer dann, wenn nicht ausdrücklich void angegeben wurde, einen Wert mit return zurück.
Bild 12-5: Funktionsdefinition nach ANSI
Programmierern, die vor dem Problem stehen, Code für alte und neue Schreibweisen zu erstellen, oder die alten Code auf die neue Schreibweise umstellen wollen, können die folgenden Empfehlungen helfen.
Liegen die Quellen vor, dann kann man sich auf die Rückwärtskompatibilität von ANSI-C verlassen und den vorhandenen Code einfach neu übersetzen. Zwar werden dabei normalerweise eine größere Anzahl von Warnungen erzeugt, aber der Code sollte lauffähig sein.
Bild 12-6:Anpassungen für ANSI und K&R
Die nächste Möglichkeit ist, den Code schrittweise anzupassen. Zuerst wird man nur die Deklarationen in den Informationsdateien (header files) ändern. Um dieselbe Informationsdatei in einer K&R- und in einer ANSI-Umgebung verwenden zu können, kann man mit Hilfe eines vordefinierten Makros __STDC__ Teile bedingt übersetzen. Dieses Makro steht während der Übersetzung mit einem ANSI-Compiler zur Verfügung.
Allerdings gibt es hier möglicherweise ein Problem. Der Standard sagt, daß bei ANSI-konformen Compilern __STDC__ definiert sein soll und den Wert "1" (für die erste Version des Standards) enthält. Da aber nun manche nicht-konforme Compiler dieses Makro ebenfalls definieren, aber den Ersetzungswert mit "0" vorbesetzen, darf man nicht nur nach der Existenz von __STDC__ fragen (mit "#ifdef"), sondern es muß zusätzlich der Wert geprüft werden. Dies geschieht im Bild 6 mit "#if".
Bild 12-7: Mischen von K&R-Bibliothek und ANSI-Code
Sollten Sie einen ANSI-Compiler verwenden, dann kann die Datei stdio.h als Beispiel dienen. Bei TurboC++ muß die Compileroption Source ist ANSI eingeschaltet werden (Options/Compiler/Source/ANSI).
Ein anderes Problem stellt sich, wenn Bibliotheken vorliegen und man will neuen ANSI-Code schreiben, der die Bibliotheken benutzt. In diesem Fall kann man den oben angesprochenen Weg gehen und die Informationsdatei anpassen. Mit einer von Hand angepaßten Informationsdatei kann zumindest neuer Code die Vorteile der Schnittstellenprüfung wahrnehmen. Bei der Anpassung der Informationsdatei ist jedoch erhebliche Vorsicht geboten.
Da K&R-Compiler automatisch Typkonvertierungen der Parameter vornehmen, dürfen in den ANSI-kompatiblen Deklarationen von Funktionen, die als Bibliothek im K&R-Stil vorliegen, nur bestimmte Datentypen verwendet werden. Dies sind: int, double und long. Da der kleinste übergebene Parameter vom Typ int ist, darf kein Prototyp für bestehende K&R-Funktionen char oder short verwenden. Natürlich ist auch so etwas wie unsigned char nicht erlaubt.
Weiter wird float auf double angepasst und kann daher ebenfalls nicht verwendet werden.
Besondere Aufmerksamkeit ist bei Typen notwendig, die mit einer typedef-Anweisung erzeugt wurden. Hier muß man in jedem Einzelfall überprüfen, ob sie auf einen erlaubten oder verbotenen Datentyp abgebildet werden. Handelt es sich um eine Abbildung auf einen verbotenen Datentyp (char, short, float), muß man auf den typedef-Namen verzichten und den elementaren Datentyp angeben.
Soll der neue Code möglicherweise auch von Compilern nach K&R übersetzt werden können, müssen bei der Deklaration in den Informationsdateien und bei der Definition jeweils beide Versionen geschrieben und mit einer bedingten Übersetzung ausgewählt werden.
Im Bild 6 ist das zugehörige Beispiel.
Die Änderung der Funktionsschnittstelle ist die deutlichste Änderung des Standards in der Syntax, d. h. in der Art und Weise, wie man eine Funktion schreibt. Die wichtigste Änderung der Semantik, d. h. der Bedeutung, die ein Stück Code haben kann, liegt im Übergang von der Vorzeichenerhaltung zur Werterhaltung bei einer Anpassung des Datentyps.
Bild 12-8: Vergleich: Wert- vs. Vorzeichenerhaltung
Im Beispiel (BIld 8) werden zwei Variablen definiert. Die eine ist vom Typ int, die zweite ein char ohne Vorzeichen. Bei einer mathematischen Verknüpfung der beiden mit einer Addition, kommt es nun darauf an, wie char erweitert wird.
Unter ANSI ist die Idee, daß die Erweiterung von unsigned char auf int erfolgt, da int der nächstgrößere Datentyp ist, der die Zahl in der unsigned char-Variablen korrekt aufnehmen kann.
Unter K&R hat man die Vorstellung der Vorzeichenerhaltung. Hier ist der nächstgrößere Datentyp, der die unsigned char-Variable aufnehmen kann und ebenfalls kein Vorzeichen hat, unsigned int.
Die Addition hat bei ANSI zwei int-Variablen zu addieren, bei K&R eine int und eine unsigned int-Variable.
Betrachtet man das Additionsergebnis auf Bitebene, ist das Ergebnis klar: Es gibt ein Ergebnis, in dem alle Bits auf "1" gesetzt sind. Aber was ist das nun? Ist es die Darstellung für "-1", oder ist es die größte Zahl, die man vorzeichenlos darstellen kann? Der Vergleich testet das Ergebnis gegen "15". Ein negatives Ergebnis ist sicher kleiner als der Vergleichswert, ein vorzeichenloses Ergebnis größer als der Vergleichswert.
Bei ANSI ist das Ergebnis wieder ein int und hat den Wert "-1". Das Programm wird nach der Übersetzung mit einem ANSI-Compiler daher die Meldung Vergleich wahr (ANSI) am Bildschirm anzeigen.
Bild 12-9: K&R Verhalten erzwingen mit Typwandlung
Da bei K&R die Typen gemischt sind, gewinnt die vorzeichenlose Darstellung, und das Ergebnis ist die größte darstellbare Zahl ohne Vorzeichen. Die Ausgabe zeigt unter K&R dann Vergleich falsch (K&R) am Bildschirm an.
Solche Bedeutungsänderungen wirken sich sehr selten aus, da das gewählte Beispiel eben kein typischer Code ist. Trotzdem sollte man erhebliche Vorsicht bei der Umstellung vorhandener Quellen auf ANSI walten lassen. Manche Compiler (z. B: der AT&T-Compiler) melden solche Stellen als Warnung. Für den Programmierer bleibt dann nur noch übrig, solchen Warnungen einzeln nachzugehen.
Soll der Originalcode erhalten werden, dann kann man mit Hilfe einer expliziten Typangabe die ursprüngliche Auswertung wieder herstellen.
Bild 12-10: Übergabe von variablen Parameterlisten (K&R)
Allerdings haben nur wenige Funktionen davon Gebrauch gemacht. Außer den printf() und scanf()-Varianten gibt es im Standard keine Funktionen mit variabler Parameteranzahl.
Ein- und Ausgabefunktionen machen aber auch in anderen Sprachen gelegentlich Ausnahmen (siehe readln und writeln in Pascal).
Innerhalb von Funktionsdefinitionen kann mit Hilfe eines Satzes von Makros auf die übergebenen Parameter zugegriffen werden. Allerdings muß ein erster Parameter angeben, welche Argumente nach Typ und Anzahl erwartet werden.
Bild 12-11: Übergabe variabler Parameterlisten (ANSI)
Sowohl die Makros als auch die verwendeten Informationsdateien unterscheiden sich zwischen K&R und ANSI. Mit Hilfe des Makros __STDC__ kann man nun wieder Code schreiben, der mit beiden Umgebungen übersetzt werden kann.
K&R verwendete varargs.h, ANSI kennt die Datei stdarg.h.
Die Bearbeitung der Parameterliste beginnt mit einem Startmakro va_start(). Die eigentliche Parametergewinnung bewältigt dann das Makro va_arg() und schließlich endet die Bearbeitung mit va_end().
Die beiden Makros va_arg() und va_end() arbeiten in beiden Versionen gleich. Unterschiedlich sind die Namen der Informationsdateien sowie der Beginn der Bearbeitung mit va_start().
Der Funktionskopf könnte mit __STDC__ für beide Compiler kompatibel aufgebaut werden. Die K&R-Version verwendet dazu ein weiteres Makro.
#ifdef __STDC__
void summe (char * Format, ...)
va_list #else
void summe (va_alist) va_dcl
#endif
Nach dem Funktionskopf wird in beiden Fällen mit dem Typ va_list() eine Parameterliste angelegt. Die Initialisierung der Liste geschieht danach wieder unterschiedlich.
#ifdef __STDC__
va_start (val, Format);
#else
char * Format;
va_start (avl);
Format = va_arg (avl, char *)
#endif
Während die neuen Makros den ersten Parameter, der im Normalfall immer anzugeben ist, berücksichtigen, muß bei den älteren Makros der erste Parameter explizit abgeholt werden.
Die mit dem Typ va_list angelegte Variable wird während der Benutzung verändert. Sollte die Parameterliste innerhalb der Funktion erneut durchsucht werden, dann muß nach dem Aufruf von va_end() erneut ein va_start() folgen.
Bild 12-12: Ausschalten der Optimierung mit volatile
Optimierungsmöglichkeiten sind für den Compiler gegeben, wenn er feststellt, daß eine lokale Variable in einem Register gehalten werden könnte oder daß in einer Schleife eine Variable nur gelesen wird. Im ersten Fall kann er sich den Platz für eine lokale Variable sparen und schneller auf Register zugreifen, als er das bei Variablen könnte. Im zweiten Fall würde es im Normalfall genügen, wenn er die Variable einmal liest, in einem Register hält und für alle Bearbeitungsschritte nur auf das Register zugreift.
Die Optimierung muß für einzelne Variablen in bestimmten Fällen ausgeschaltet werden. Folgende Fälle sind denkbar:
Bild 12-13: Arbeiten mit const
Im Beispiel (Bild 13) werden drei unterschiedliche Variable mit dem Attribut const angelegt. Die globale Variable PI wird beim Anlegen initialisiert. Damit kann PI nicht mehr auf der linken Seite einer Zuweisung stehen, d. h. sie kann nicht mehr (legal) verändert werden.
Der zweite Fall, in dem const verwendet wird, ist in der Definition des Parameters cp in der Funktion. Hier wird nicht der Parameter mit dem Attribut const versehen, sondern die Stelle, auf die der Zeiger zeigt. Damit kann die Kombination *cp nicht auf der linken Seite der Zuweisung auftauchen. Das Versprechen, das der Programmierer damit demjenigen gibt, der die Funktion aufruft, heißt: Dieser Parameter greift nicht schreibend auf den übergebenen Text zu.
Manchmal kann man auch solche Versprechen umgehen. Dazu braucht nur einem zweiten Zeiger den Parameter zuzuweisen und kann dann doch wieder zugreifen. Dies wäre dem Anwender der Funktion gegenüber nicht fair, und das Resultat wäre aus Compilersicht undefiniert.
Typische Funktionen, die einen Parameter mit dem Attribut const verwenden, sind die Funktionen der printf() und scanf()-Familie. Deren erster Parameter wird von der Funktion immer nur zum Lesen verwendet.
Die letzte Variable ccp wurde gleich mit zwei Attributen const versehen. Das erste const bezieht sich wieder auf den Platz, auf den der Zeiger zeigt. Er kann also nicht überschrieben werden. Das zweite const bezieht sich auf den Zeiger selbst. Auch der Zeiger kann daher nicht verändert werden und zeigt damit immer auf die gleiche Stelle im Speicher.
Manche Compiler fassen alle explizit und implizit definierten Konstanten zusammen und legen sie in einem eigenen Datensegment mit dem Namen const ab. Ein solches Segment könnte in ein EPROM geladen werden. Da solche Spezialitäten nicht im Standard festgelegt sind, ist dies eine Eigenschaft des jeweiligen Compilers.
Insbesondere Programmierer, die Funktionen für andere Benutzer schreiben, sollten -wann immer möglich- Parameter mit const kennzeichnen und in der zugehörigen Informationsdatei dem Benutzer mitteilen.
Die angegebenen Namen sind reserviert und dürfen vom Programmierer nicht für eigene Zwecke benutzt werden. Als reserviert gelten auch Namen, die mit zwei Unterstrichen beginnen.