HOWTO: Fließkommadarstellung und Problembehandlung

Die C/C++-Datentypen float und double sorgen immer wieder zu Problemen, die unter anderem darin begründet sind, dass die Anwender dieser Datentypen davon ausgehen, dass der Umgang mit Fließkommazahlen in einer Programmiersprache dem Umgang mit Zahlen im "normalen" Leben entspricht. Leider entspricht das nicht der Realität.

Dieser Artikel versucht die Zusammenhänge zu erläutern und für die möglichen Probleme beim Umgang mit Fließkommazahlen zu sensibilisieren bzw. Lösungsmöglichkeiten anzudeuten.

Darstellung von Fließkommazahlen in C/C++

Fließkommazahlen werden nach dem Standard IEEE 754 dargestellt. Eine Fließkommazahl wird aus einer Mantisse und einem Exponenten dargestellt. Die C/C++-Datentypen float und double haben folgenden Aufbau:

Typ Anzahl signifi-
kante Stellen
Größe Vorzeichen Exponent Mantisse Wertebereich
float
6 - 7
32 Bit
1 Bit
8 Bit
24 Bit
1.175494351 E–38 ...
3.402823466 E+38
double
15 - 16
64 Bit
1 Bit
11 Bit
53 Bit
2.2250738585072014 E–308...
1.7976931348623158 E+308

Aufmerksame Leser werden jetzt feststellen, dass die Summe der Bits in der obigen Tabelle um 1 größer ist, als die Gesamtgröße des Datentyps. Dies liegt daran, dass für die Mantisse die Beziehung gilt:

1 ≤ Mantisse < 2

Damit hat die Mantisse immer eine 1 in der Vorkommastelle, die nicht gespeichert werden muss. Durch diese implizite 1 wird die Stellenzahl der Mantisse und damit die Genauigkeit erhöht. Die gespeicherte Mantisse enthält damit nur noch die Nachkommastellen der tatsächlichen Mantisse. Wer jetzt einwendet, dass das vorgestellte Verfahren der Speicherung von Fließkommazahlen zusätzlichen Rechenaufwand benötigt, hat zwar im Prinzip recht. Allerdings sollte beachtet werden, dass moderne Prozessoren über einen Fließkommaprozessor verfügen, der diese Arbeit perfekt in der Hardware erledigt, ohne dass die Leistungsfähigkeit der Software darunter leidet. Der Aufwand für Fließkommaoperationen ist heute in der gleichen Größenordnung, wie Integeroperationen.

Vorzeichenbit (S), Exponent (E) und Mantisse (M) werden in der genannten Reihenfolge in dem zum Datentyp gehörenden Speicherbereich abgelegt. Für einen float-Datentyp ergibt sich somit folgender Aufbau:

 Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210
SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM

Für double ergibt sich analog folgender Aufbau:

 Byte 7   Byte 6   Byte 5   Byte 4   Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210 76543210 76543210 76543210 76543210
SEEEEEEE EEEEMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM

Die Nummerierung der Bytes entspricht der bei Intel-Prozessoren üblichen Art der Speicherung beginnend bei Byte 0 (little endian).

Wieso sind einfache Zahlen wie 0,3 nicht genau darstellbar? Es wird gerne vergessen, dass Prozessoren nicht im Dezimalsystem arbeiten, sondern binär. Und das ist sowohl beim Exponenten als auch bei der Mantisse der Fall. Der Exponent bezieht sich auf die Basis 2 und die Mantisse wird ebenfalls auf der Basis des Binärsystems gebildet. Das höchstwertigste Bit der Mantisse entspricht 0,5 (2-1), das nächste 0,25 (2-2), dann 0,125 (2-3) usw. bis 2-23 (float) bzw. 2-52 (double). Damit kann für die Mantisse folgende Formel definiert werden:

Mantisse = 1 + 2-1 + 2-2 + 2-3 + ... + 2-n

wobei n = 23 für float und n = 52 für double gilt. Versucht man mit dieser Darstellung 0,3 abzubilden, wird man feststellen, dass dies genau nicht möglich ist. Schaut man sich die Binärdarstellung der Mantisse an, würde sich 1,001100110011... ergeben (der fett dargestellte Teil wiederholt sich unendlich), also ähnlich 1/3 eine nur durch unendlich viele Stellen genau darstellbare Zahl. Wir werden weiter unten betrachten, was der Compiler aus dieser Konstante macht.

Der gespeicherte Exponent einer Fließkommazahl gibt die Zweierpotenz an, mit der die Mantisse multipliziert wird, um die eigentliche Zahl zu erhalten. Allerdings wird hier sofort auffallen, dass zunächst keine Möglichkeit besteht, einen negativen Exponenten anzugeben. Aus diesem Grund wird der Wertebereich des Exponenten in der Mitte geteilt und der Mitte die Zweierpotenz 20 zugewiesen. Bei float (8-Bit-Exponent) liegt die Mitte bei k = 127 und bei double (11-Bit-Exponent) bei k = 1023. Damit ergibt sich die durch die IEEE-Darstellung abgebildete Zahl wie folgt:

Zahl = (1 + 2-1 + 2-2 + 2-3 + ... + 2-n) * 2Exponent - k

Ist das Vorzeichenbit 1, handelt es sich um eine negative Zahl und anderenfalls um eine positive Zahl. Damit ergibt sich die Zahl 1 (1,0000...*20) wie folgt als float:

 Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210
SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
00111111 10000000 00000000 00000000

Als double ergibt sich:

 Byte 7   Byte 6   Byte 5   Byte 4   Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210 76543210 76543210 76543210 76543210
SEEEEEEE EEEEMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM
00111111 11110000 00000000 00000000 00000000 00000000 00000000 00000000

Schaut man sich die obige Formel genauer an, wird man schnell feststellen, dass die 0 so gar nicht dargestellt werden kann. Hier gilt die Konvention, dass eine Fließkommazahl, deren sämtliche Bits 0 sind, die Zahl 0 darstellt. Das hat u. a. den Vorteil, dass man z. B. ein Array von Fließkommazahlen z. B. mit ZeroMemory oder memset auf 0 initialisieren kann.

Schauen wir uns jetzt mal die Darstellung der Zahl 0,3 als float bzw. double an:

 Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210
SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
00111110 10011001 10011001 10011010

 Byte 7   Byte 6   Byte 5   Byte 4   Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210 76543210 76543210 76543210 76543210
SEEEEEEE EEEEMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM
00111111 11010011 00110011 00110011 00110011 00110011 00110011 00110011

Im Debugger kann man sich das Ergebnis anschauen. float wird als 0,30000001 und double als 0.29999999999999999 ausgegeben. Ändert man das das niederwertigste Bit der Mantisse (Byte 0, Bit 0), zeigt sich, dass in diesem Fall der Fehler größer wird (das kann man z. B. in einem Memory-Fenster des Debuggers sehr einfach erledigen (s. Debug -> Windows -> Memory). Wie oben schon erwähnt, kann die Zahl 0,3 nicht genau dargestellt werden.

Schauen wir uns noch weitere Beispiele an. Die Zahl 9 (1,125 * 23) sieht folgendermaßen aus:

 Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210
SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
01000001 00010000 00000000 00000000

 Byte 7   Byte 6   Byte 5   Byte 4   Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210 76543210 76543210 76543210 76543210
SEEEEEEE EEEEMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM
01000000 00100010 00000000 00000000 00000000 00000000 00000000 00000000

also für float (1 + 2-3) * 2130 - 127 und double (1 + 2-3) * 21026 - 1023 und damit in beiden Fällen 1,125 * 8.

Für die Zahl dezimal schon sehr unhandlich aussehende Zahl -0.06640625 (-1,0625 * 2-4) ergibt sich in der Fließkommadarstellung Folgendes:

 Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210
SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
10111101 10001000 00000000 00000000

 Byte 7   Byte 6   Byte 5   Byte 4   Byte 3   Byte 2   Byte 1   Byte 0
76543210 76543210 76543210 76543210 76543210 76543210 76543210 76543210
SEEEEEEE EEEEMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM
10111111 10110001 00000000 00000000 00000000 00000000 00000000 00000000

Damit ergibt sich für float -(1 + 2-4) * 2123 - 127 und für double -(1 + 2-4) * 21019 - 1023 also in beiden Fällen -1,0625 * 0,0625.

Probleme beim Umgang mit Fließkommazahlen und deren Vermeidung

Generell gilt, dass jede Integer auch als Fließkommazahl exakt abgebildet werden kann, wenn bei der Umwandlung in die Fließkommadarstellung keine Bits der Mantisse verloren gehen (die Zahl ist zu groß). Zahlen mit Nachkommastellen können häufig nicht genau abgebildet werden! Man sollte sich aber nicht der Illusion hingeben, dass das Rechnen mit Fließkommazahlen, die genau abgebildet werden können, keine Probleme auftreten können.

Rechenoperationen mit Zahlen, die sich um Größenordnungen unterscheiden

Wenn wir die double Zahlen 10000000000000000,0 und 0,25 addieren, würden wir als Ergebnis 10000000000000000,25 erwarten. Schauen wir uns das Ergebnis an, ergibt sich tatsächlich 10000000000000000,0! Das liegt daran, dass die verfügbare Stellenzahl nicht mehr ausreicht. Dieses Verhalten tritt bei nicht exakt darstellbaren Zahlen schon deutlich früher auf. Während sich 10000000000,0 + 0,25 - 10000000000,0 exakt rechnen läßt, ergibt sich bei 10000000000,0 + 0,3 - 10000000000,0 das Ergebnis 0.29999923706054688 (0,3 wird normalerweise ja durch 0.29999999999999999 abgebildet). Bei dieser Rechnung gehen einige Stellen der Mantisse von 0,3 verloren und das führt zu einem deutlichen Rechenfehler.

Kumulation von Rechenfehlern

Folgendes kleine Beispiel addiert 1000 mal die Zahl 0,3:

double dResult = 0.;
for (int i = 0; i < 1000; ++i)
{
    dResult += 0.3;
}

Als Ergebnis erwarten wir 300. Tatsächlich ist das Ergebnis 300.00000000000563. Das bedeutet, dass sich auch sehr kleine Fehler bei vielen Rechenoperationen deutlich bemerkbar machen.

Bei Multiplikation und Division reichen meistens deutlich weniger Rechenschritte, um einen Fehler zu zeigen:

double dResult = 1.;
for (int i = 0; i < 20; ++i)
{
    dResult *= 0.000016;
}
for (int i = 0; i < 20; ++i)
{
    dResult /= 0.2;
    dResult /= 0.00008;
}

Das erwartete Ergebnis ist 1. Tatsächlich ergibt sich 0.99999999999999678.

Vergleiche von Fließkommazahlen

Ein häufiges Problem ergibt sich beim Vergleich von Fließkommazahlen. Im folgenden Beispiel wird erwartet, dass bEqual true wird:

double dVar1 = 69.82;
double dVar2 = 69.2 + 0.62;
bool bEqual = (dVar1 == dVar2);

Leider ist bEqual false. Schaut man sich die beiden Variablen an, ergibt sich folgendes Bild:

dVar1 = 69.819999999999993
dVar2 = 69.820000000000007

Es ist generell ein Fehler, Fließkommazahlen auf Gleichheit zu prüfen. Stattdessen sollte man prüfen, ob die zu vergleichenden Werte annähernd gleich sind:

double dEpsilon = DBL_EPSILON * 100;
double dVar1 = 69.82;
double dVar2 = 69.2 + 0.62;
bool bEqual = fabs(dVar1 - dVar2) < dEpsilon;

In diesem Beispiel wird bEqual tatsächlich true! Gegen welchen Wert man vergleicht, hängt ein bißchen von den erwarteten Rechenfehlern ab. Wählt man den Wert zu klein, geht der Vergleich schief, wählt man den Wert zu groß, werden Zahlen, die eigentlich unterschiedlich sind, als gleich betrachtet. Das obige Verfahren ist also durchaus nicht sicher, stellt aber einen vernünftigen Kompromiß dar.

Noch ein Wort zu DBL_EPSILON (für float kann FLT_EPSILON verwendet werden). Dieser in float.h definierte Wert ist die kleinste Zahl, für die gilt:

1.0 + DBL_EPSILON != 1.0

Fließkommazahlen meiden

Dies klingt eigentlich paradox. Häufig ist es jedoch möglich, Fließkomma-Rechnungen durch Integer-Rechnungen zu ersetzen. Wenn man ausschließlich mit Zahlen zu tun hat, die nur wenige Nachkommastellen haben und sich ausschließlich in dem durch Integer-Datentypen abbildbaren Wertebereichen bewegen, dann kann man durchaus Rechenschritte mit Integer-Operationen ausführen. So ließe sich unser obiges Beispiel mit der 1000-maligen Addition von 0,3 folgendermaßen realisieren:

double dResult = 0.;
int iResult = 0;
int iAdd = (int) (0.3 * 10.);
for (int i = 0; i < 1000; ++i)
{
    iResult += iAdd;
}
dResult = (double) iResult / 10.;

Dieses Beispiel ist natürlich arg konstruiert, dient aber nur dazu, die generelle Vorgehensweise zu demonstrieren. Hier kommt das exakte Ergebnis heraus. Natürlich hat auch diese Vorgehensweise ihre Tücken. Schauen wir uns das folgende, leicht abgewandelte Beispiel an:

double dResult = 0.;
int iResult = 0;
int iAdd = (int) 0.3 * 10;

for (int i = 0; i < 1000; ++i)
{
    iResult += iAdd;
}
dResult = (double) iResult / 10.;

Hier kommt als Ergebnis (möglicherweise unerwartet) 0 heraus. Das liegt daran, dass der cast auf int im Beispiel zuerst auf 0,3 angewendet wird. Der cast schneidet einfach die Nachkommastellen ab und damit ist iAdd = 0. In einem realen Beispiel muss man also sehr vorsichtig sein, wenn man zwischen Integer und Fließkomma umwandelt. Gegebenenfalls muss zusätzlich gerundet werden.

Mischen von float und double

Die gemeinsame Verwendung von float und double in Berechnungen ist letztlich keine gute Idee. Auch hier lauern Fallstricke. Diese kann man häufig umgehen, aber es ist sinnvoller, sich für einen der beiden Datentypen zu entscheiden.

Zur Abschreckung unser obiges Beispiel mit dem 1000-maligen Addieren von 0.3 nochmal unter Verwendung von float und double.

double dResult = 0.;
float fAdd = 0.3;

for (int i = 0; i < 1000; ++i)
{
    dResult += fAdd;
}

Statt des oben erhaltenen Ergebnisses von 300.00000000000563 ist hier die Abweichung vom exakten Ergebnis mit 300.00001192092896 deutlich größer!

Verwendung spezieller Fließkommabibliotheken und -formate

In einigen Programmiersprachen gibt es weitere Datentypen, die besser für Fließkommaoperationen geeignet sind (z. B. decimal in C#).

Generell kann man das Problem auch durch Verwendung spezieller Fließkomma-Bibliotheken umgehen (z. B. mit Zahlendarstellung im BCD-Format). Es gibt auch Bibliotheken, die mit deutlich höherer Genauigkeit arbeiten können. Unter http://www.jjj.de/hfloat/hfloatpage.html finden sich z. B. einige Links. Dort finden sich auch Beispiele, wie man z. B. die Zahl PI auf 1000 und mehr Stellen berechnen kann.


Copyright © 2004 Dieter Smode