ZurückZum InhaltVorwärts

Von K&R zu ANSI

Brian W. Kernighan und Dennis M. Ritchie haben 1978 ein kleines Buch geschrieben: The C Programming Language. Das Buch war über viele Jahre die Sprachreferenz für Compilerbauer und Programmierer. Allerdings waren nicht alle Aspekte der Sprache vollständig definiert, so daß viele Compiler eigene (kleine) Erweiterungen einbauten. Mit der Entwicklung von C, UNIX und vor allem der Maschinen wuchs die Zahl der Quellen. Je mehr aber die Quellcodemenge wuchs, desto wichtiger wurde eine gemeinsame Basis.

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.

Alte und neue Funktionsdeklarationen

Aus C++ (und aus Pascal) wurde der Begriff der vollständigen Funktionsdeklaration übernommen. Der Name, den C dafür wählte, ist der Prototyp. Ein Prototyp beinhaltet drei grundlegende Informationen.

Informationen eines Prototyps

Bild 12-1: Aufruf nach K&R mit Parametzerfehler

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.

Wenn Sie vorhandenen Code mit ANSI-Compilern...

benutzen wollen, dann gibt es mehrere Strategien, die davon abhängen, ob Sie Quellen oder übersetzte Bibliotheken benutzen.

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".

[Mischen von C-Varianten]

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.

Wenn Sie neuen Code schreiben, ...

sollte man sich an die ANSI-Konventionen halten und nur noch Prototypen und Parameterlisten mit Definitionen verwenden. Soweit wie möglich sollte man sich auch auf die Benutzung der ANSI-Bibliotheksfunktionen beschränken.

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.

Semantikänderung: Wert- oder Vorzeichenerhaltung

In diesem Abschnitt betrachten wir ein Programm, das eine unangenehme Eigenschaft hat. Man kann es sowohl mit K&R- als auch mit ANSI-Compilern fehlerfrei übersetzen. Nur das Ergebnis wird völlig verschieden voneinander sein.

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.

Umstellung von variablen Parameterlisten

Funktionen können in C mit einer von Aufruf zu Aufruf unterschiedlichen Parameteranzahl aufgerufen werden. Die Parameterübergaben werden bei K&R nicht geprüft. Das aufgerufene Unterprogramm ist für die Auswertung der Parameter zuständig.

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.

Neu: volatile

Das neue Schlüsselwort volatile ist ein Attribut für einen Typ. Der Compiler erhält durch volatile die Anweisung, Zugriffe auf diese Variable nicht zu optimieren.

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:

  1. Die Variable wird asynchron zum Programmablauf verändert. Dies trifft bei Variablen zu, die durch Hardwareinterrupts verändert werden können oder durch Signalfunktionen.
  2. Die Variable stellt ein Register dar für MMIO (memory mapped io).
  3. Die Variable wird von mehreren Prozessen benutzt, z. B. zur Synchronisierung der Abläufe.
  4. Die Variable wird in einer Funktion angelegt, die einen "setjmp()"-Aufruf absetzt. Bei der Rückkehr mit "longjmp()" würde eine Variable, die möglicherweise aus Optimierungsgründen in einem Register aufbewahrt werden würde, sicher nicht den richtigen Wert haben. Deshalb sollten solche Variablen ohne Optimierung in den Speicher gelegt werden.

Neu: const

Ein weiteres Attribut für Typen ist const. Mit const wird eine Variable für Schreibzugriffe gesperrt. Man kann sie dann als nur-lese-Variable betrachten. Der Compiler kann allerdings nur Zugriffe abfangen, die direkt mit dem Namen der Variable geschehen. Das Verhalten bei Zugriffen auf diese Variablen mit Hilfe eines Zeigers ist nicht definiert.

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.

Logische Zeilen in ANSI-C

Ganz nebenbei zeigt das letzte Beispiel eine weitere neue Eigenschaft von ANSI-C. Quelltexte können nun an beliebigen Stellen mit Hilfe eines "\" unterbrochen und in der nächsten Zeile fortgeführt werden. Die Kombination aus "\" und Zeilenschaltung wird einfach entfernt. Damit erhalten wir das Konzept der logischen Zeile. In K&R war eine Fortsetzung der Zeile nur in wenigen Ausnahmen möglich z. B. bei Textkonstanten.

Reservierte Namen

Im ANSI-Standard wurden eine Reihe von Namen für zukünftige Erweiterungen reserviert. Viele dieser Namen sind historisch gewachsen, so daß aus der Art des Namens nicht auf eine Reservierung zu schließen ist. Auf den ersten Blick mag dies ein wenig verwirrend sein, aber in der Praxis ergeben sich hier kaum Schwierigkeiten. Die Namen in der Übersicht sind mit Hilfe der regulären Ausdrücke dargestellt. Eckige Klammern geben dabei einen Bereich an. Ein senkrechter Strich ("|") steht für ein logisches Oder. Die runden Klammern enthalten somit eine Auswahlliste. Der einzelne Punkt steht für ein beliebiges Zeichen aus dem Zeichensatz für "C", und das "*" bedeutet eine beliebige Anzahl von Zeichen.

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.


Zum Inhalt