Integertypen mit fester Breite

Ich hatte schon öfter mit Code wie dem folgenden zu tun:

#define UINT8 unsigned char
#define INT8 signed char
#define UINT16 unsigned short
#define INT16 short
#define UINT32 unsigned long
#define INT32 long

Das sieht auf den ersten Blick zwar harmlos aus, bringt aber einige Probleme mit sich. Zunächst mal sollte inzwischen auch der Letzte gemerkt haben, dass 64-Bit CPUs, Betriebssysteme und Compiler keine Seltenheit mehr sind. Wenn man das weiß, und außerdem weiß, dass die Größen der einzelnen Datentypen in C und C++ gar nicht fest definiert ist, erkennt man schon das erste Problem: Auf einem 32-Bit Compiler unter Windows oder Linux haben die definierten Typen die richtige Größe, sobald man das ganze aber unter 64 Bit benutzt, kann es sein, dass z.B. der UINT32 aus diesem Beispiel plötzlich 64 Bit groß ist statt 32. Außerdem garantieren weder der C11- noch der C++11-Standard, dass ein char genau 8 Bit groß sein muss.

Ein anderes Problem ergibt sich daraus, dass hier Makros statt typedefs benutzt wurden. Bekanntermaßen wird bei der Auflösung der Makros nur Textersetzung durchgeführt. Wenn wir versuchen, in C++ einen Wert mittels funktionaler Notation zu Konvertieren (auch bekannt als “Constructor-Style-Typecast”) ergibt sich folgendes Problem:

UINT8 x = 10;
std::cout << UINT32(x) << std::endl;

Sieht auf den ersten Blick zwar gültig aus, bein Übersetzen passiert aber folgendes:

error: expected primary-expression before ‘unsigned’
error: expected ‘;’ before ‘unsigned’

Das zweite Problem lässt sich einfach durch einen typedef lösen, das erste ist etwas komplizierter.

Über die Jahre habe ich verschiedene Lösungsansätze gesehen, wobei die meisten aus verschiedenen Gründen ungeeignet sind.

Eine beliebte Variante besteht darin, für verschiedene Compiler und Betriebssysteme unterschiedliche Header mit diesen Typedefs zu verwenden. Alternativ, in einem Header über verschiedenste Compile-Zeit-Tests (ifdef und Konsorten) die einzelnen Architekturen auszuwählen. Solange man relative wenige Compiler zu unterstützen hat, funktioniert das auch ganz gut. Zumindest, solange man auch darauf achtet, dass sich die Wortgröße bei späteren Compilerversion durchaus ändern kann.

In einem Projekt, in dem ich gearbeitet habe, durfte ich ein 32-Bit-Linux-GCC-Projekt auf 64-Bit portieren. Die einzige Unterscheidung, die damals in den Projekten getroffen wurde, war, ob es sich um eine Intel-, ARM- oder eine von diversen 8- und 16-Bit-Mikrocontroller-Architektur handelt. Das Projekt selbst sollte nur für Intel und ARM übersetzt werden, aber die gleichen Headerdateien sollten für alle Projekte der Firma benutzt werden. Diese Konstruktion mit verschiedenen bzw. riesigen Headerdateien hat sich als sehr unpraktisch erwiesen, denn ich habe mehrere Stunden gebraucht, mich durch das ganze Headergewusel durchzukämpfen. Denn natürlich hat der ursprüngliche Entwickler dieser Konstruktion versucht, möglichst schlau einzelne Sachen wiederzuverwenden.

Eine zweite Variante, die man leider auch immer noch sieht, ist zur Compile-Zeit zu prüfen, wie groß die einzelnen fundamentalen Typen wirklich sind:

#if __SIZEOF_INT__ == 4
typedef unsigned int UINT32
#elif __SIZEOF_LONG__ == 4
typedef unsigned long UINT32
#endif

Das funktioniert zwar, ist aber auch unnötiger Aufwand und funktioniert nur auf Compilern mit GNU-Erweiterungen (die __SIZEOF_XX__-Makros sind nicht im C oder C++-Standard definiert).

Das ganze scheint ein unlösbares Problem zu sein. Das haben sich scheinbar auch die Macher des C99-Standard gedacht, denn seit dieser Revision des Standards gibt es eine neue Headerdatei: stdint.h. In C++ ist der Header (mit dem angepassten Namen cstdint) erst ab C++11 verfügbar.

Diese definiert einige neue Typen, die garantiert die richtige Größe haben: uint8_t, uint16_t, uint32_t und uint64_t bei den vorzeichenlosen Typen und int8_t, int16_t, int32_t und int64_t bei den vorzeichenbehafteten Typen. Es kann zwar sein, dass noch nicht für alle Architekturen C99-Compiler zur Verfügung stehen, aber in solchen seltenen Fälle kann man immer noch auf seine eigenenen Definitionen zurückgreifen (oder besser einen Compiler nehmen der das alles bietet, oder bei Open-Source Compilern selber dafür sorgen, dann haben am Ende alle was davon).

Aber selbst das löst nicht alle Probleme. Was ist, wenn ein Typ auf dem Zielsystem einfach nicht verfügbar ist? Wenn z.B. char schon 16 Bit breit ist, existieren die Typen int8_t und uint8_t nicht. Was tut man dann? Der wichtigste Schritt ist, zu überprüfen, ob wirklich eine feste Breite benötigt wird. In den allermeisten Fällen lässt sich der Code so schreiben, dass eine feste Breite überflüssig ist. Mir fallen eigentlich nur drei Gründe ein, warum man Typen fester Größe braucht:

  1. Man muss mit einer Bibliothek arbeiten, die bestimmte feste Integertypen mit einer bibliothekspezifischen Nomenklatur benutzt (z.B. OpenGL mit GLuint u.ä.)
  2. Man will Daten direkt binär in eine Datei schreiben oder über eine Netzwerkverbindung schicken
  3. Man arbeitet auf einer sehr niedrigen Abstraktionsebene mit der Hardware

Der dritte Fall ist am einfachsten zu behandeln: Hier gibt es im Prinzip keine Alternative, wenn die Hardware nunmal 16-Bit große Register hat, dann muss auch eine 16-Bit-Integer benutzt werden, wenn das Dateisystem Blöcke mit 64-Bit-Integern adressiert, muss ein solcher Datentyp benutzt werden.

Im ersten Fall empfiehlt es sich, einfach von vornherein die Datentypen der Bibliothek zu benutzen. Nur so kann man auf Nummer sicher gehen, dass sich Bibliothek und restliche Anwendung über die Größe des Datentyps einig sind.

Und für den zweiten Fall sollte man sich ernsthaft überlegen, ob das sinnvoll ist. Immerhin zieht das noch einen größeren Rattenschwanz nach sich: Man muss sich um das Padding der Daten kümmern (und es ist schwierig, das sauber über verschiedene Architekturen hinzubekommen) und noch viel wichtiger: Die Bytereihenfolge unterscheidet sich zwischen verschiedenen Systemen. Reines binäres Schreiben in die Datei oder auf den Socket bringt also noch eine ganze Menge weiterer Probleme, sobald man nicht mit dem gleichen Rechner die Dateien öffnen will, mit dem man sie auch schreibt oder wenn man mit Rechnern anderer Architekturen kommunizieren will.

Diese Probleme lassen sich ganz einfach umgehen, wenn man von vornherein eine sauber entworfene Serialisierungsbibliothek verwendet. Auf diese Weise lässt sich eine platformunabhängige Darstellung der Daten in Dateien oder auf Netzwerkverbindungen erreichen. Gute Erfahrungen habe ich z.B. mit Google Protcol Buffern gemacht. Die liefern gleich noch einige weitere Funktionen mit um mit den serialisierten Daten zu arbeiten. Und diese sind in vielen Fällen sogar ohne explizite Kompression noch kleiner als bitweise geschriebene Integertypen.

Es gibt allerdings einen Grund, warum man kleinere Datentypen bevorzugen könnte: Wenn der Compiler Autovektorisierung unterstützt, kann er z.B. mittels SSE- oder AVX-Instruktionen dafür sorgen, dass mehrere Werte gleichzeitig verarbeitet werden. Je kleiner der tatsächlich verwendete Datentyp, desto mehr gleichzeitig. Das lohnt sich allerdings auch nur bei größeren Schleifen, die dadurch beschleunigt werden können.

Meine Empfehlung lautet, wo immer es möglich ist, auf int zu setzen und nur an Stellen, an denen es nötig ist (z.B. durch Fremdlibraries, direkte Hardwarezugriffe oder wenn man beim Profiling merkt, dass int einfach zu groß ist und durch Vektorisierung ein ernsthafter Geschwindigkeitsvorteil erzielt werden kann) einen Integertyp fester Größe zu nehmen.

Tags: , ,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: