Blog
Mein Notizblog

Julian Strecker
TypeScript, JavaScript, Linux
Jan 1, 2020 - 63 Minuten Lesezeit

JavaScript und Zahlen

Inhaltsverzeichnis

Diesmal ein langer Artikel mit Inhaltsverzeichnis:

1. Das Problem

Wer schon einmal mit JavaScript an Problemen gearbeitet hat, zu deren Lösung ein erheblicher Rechenaufwand notwendig ist und wo auch Präzision eine Rolle spielt und diese gegebenenfalls sogar den Programmablauf gefährden kann, der wird wahrscheinlich schon das Gefühl gehabt haben, dass JavaScript beim Umgang mit Zahlen manchmal nicht so genau ist, wie man sich das vorgestellt hat.

Ein ziemlich prominentes Beispiel ist der Ausdruck 0.1 + 0.2 === 0.3. Hier erwartet man eine Evaluation zu true. Also sollte dieses kleine Programm:

if (0.1 + 0.2 === 0.3)  {
  console.log('Es stimmt!');
}
else {
  console.log('Es stimmt nicht!');
}

eine Ausgabe von "Es stimmt!" erzeugen — tut es aber nicht, denn für JavaScript gilt: 0.1 + 0.2 === 0.30000000000000004.

Ein Nicht so prominentes Beispiel, was ich aber sehr eindrucksvoll finde ist das Folgende: Ich habe den Ausdruck 253+12532^{53}+1-2^{53}. Für diesen Ausdruck ist hoffentlich klar, dass er den Wert 11 hat und dass das gilt, unabhängig von der Reihenfolge der Summanden 11, 253-2^{53} und 2532^{53}. JavaScript sieht das so:

> Math.pow(2,53) + 1 - Math.pow(2,53);
0
> Math.pow(2,53) - Math.pow(2,53) + 1;
1
> 1 + Math.pow(2,53) - Math.pow(2,53);
0
> 1 / (1 + Math.pow(2,53) - Math.pow(2,53))
Infinity

Man sieht hier, dass JavaScript (oder der Computer) Fehler macht bei der Berechnung. Insbesondere der letzte Fall ist sehr unangenehm. Es sollte eigentlich das Ergebnis 1 rauskommen, es kam aber unendlich raus.

Solche Probleme möchte ich in diesem Artikel adressieren, dich in die Lage versetzen zu erkennen, wann sie auftreten könnten und dir erklären, wie du sie lösen oder umgehen kannst. Wenn du durch bis, dann weißt auch, warum sowas doofes passiert:

> Math.round( 3 / 2 );
2
> Math.round( 0.3 / 0.2 );
1

2. Computer und Zahlen

Zuerst ein wenig Theorie und "Geschichte". Elektronische, klassische Computer (nicht Quantencomputer) repräsentieren Zahlen intern durch Bits, welche jeweils einen von zwei Werten annehmen können. Wir dürfen annehmen, dass es sich dabei um die Werte 1 und 0 handelt. Das Zahlensystem des Computers hat also nur zwei Symbole. Man nennt es also Binärsystem.

Wir (die meisten Menschen - wenn sie keine Computer sind) auf der anderen Seite rechnen und schreiben mit dem dezimalen Stellenwertsystem. Dezimal heißt, dass wir zehn Symbole haben (diese sind 0, 1, 2, 3, 4, 5, 6, 7, 8 und 9 — sorry, Japan und andere Nationen mit nicht-arabischem System!). Und Stellenwertsystem heißt dass der Beitrag eines Symbols zur dargestellten Zahl von der Stelle des Symbols in der Zahl abhängt. So ist zum Beispiel 91=9101+1100=90+191 = 9\cdot 10^1 + 1\cdot 10^0 = 90 + 1 etwas anderes als 19=1101+9100=10+919 = 1\cdot 10^1 + 9\cdot 10^0 = 10 + 9.

Ausschweifung 1: Real existierende Computer können mit Hilfe des Binärsystems ziemlich viele Zahlen darstellen, aber nicht alle. Warum nicht alle? Weil es unendlich viele verschiedene Zahlen gibt, gibt es auch eine, die so viele Stellen hat, dass diese nicht in den Computerspeicher passen. Damit kann für jeden Computer eine Zahl gefunden werden, deren Darstellung mit diesem Computer nicht möglich ist. Wollte man mit einem Computer alle Zahlen darstellen, müsste diese unendlich viele Bits speichern können und wäre damit unendlich groß. Dann wäre kin Platz mehr für jemanden, der den Computer bedient.

2.1. Zahlentypen

Verschiedene Typen von Zahlen müssen von Computern verschieden dargestellt werden. Verschiedene sehr schlaue Menschen haben einige Ansätze ausgetüftelt, wie man die verschiedenen Zahlentypen denn in Computern darstellen kann. Vorerst: Welche Zahlentypen gibt es denn? Uns interessieren hier nur 1-4 und eigentlich sind 3 und 4 nicht relevant unterschiedlich.

  1. N\mathbb{N}: Natürliche Zahlen, also (0?), 1, 2, 3, ...
  2. Z\mathbb{Z}: Ganze Zahlen, also N\mathbb{N} zuzüglich 0, -1, -2, -3, ...
  3. Q\mathbb{Q}: Rationale Zahlen, also Brüche ganzer Zahlen 12\frac{1}{2}, 34-\frac{3}{4}, ...
  4. R\mathbb{R}: Reelle Zahlen, also Q\mathbb{Q} zuzüglich irrationaler Zahlen, wie 2\sqrt{2}, π\pi und viel mehr, die sich nicht durch einen Bruch ausdrücken lassen. Die reellen Zahlen, sind also die Zahlen, die den gesamten Zahlenstrahl von -\infty bis ++\infty dicht füllen.
  5. C\mathbb{C}: Komplexe Zahlen, also R\mathbb{R} zuzüglich aller Zahlen die sich ergeben, wenn man Gleichungen der (xa)2+b=0(x-a)^2+b=0 mit positivem bb nach xx löst. Die Lösungen sind hier allgemein x1,2=a±bx_{1,2}=a\pm\sqrt{-b}. Also zum Beispiel die Zahl 5+3i5+3i ist eine komplexe Zahl, wobei i=1i=\sqrt{-1}. Komplexe Zahlen füllen eine zweidimensionale Ebene dicht.

2.2. Natürliche Zahlen

Die natürlichen Zahlen lassen sich im Binärsystem recht einfach darstellen. Man wandle die Zahl einfach ins Binärsystem um. Dafür gibt es einen schönen Algorithmus: Euklid. Beispiel:

Die Zahl 621 soll in das Binärsystem umgewandelt werden.

621 = 310 * 2 + 1
310 = 155 * 2 + 0
155 =  77 * 2 + 1
 77 =  38 * 2 + 1
 38 =  19 * 2 + 0
 19 =   9 * 2 + 1
  9 =   4 * 2 + 1
  4 =   2 * 2 + 0
  2 =   1 * 2 + 0
  1 =   0 * 2 + 1

Die rechten residuen (Reste, hinter den Pluszeichen) von unten nach oben gelesen ergeben: 1001101101. Das ist binär interpretiert die Zahl 1+4+8+32+64+512=621!

So, damit lässt sich also jede beliebige natürliche Zahl binär darstellen. Man braucht dafür bei der Zahl nn genau log2(n)\lceil log_2(n)\rceil Binärstellen. In JavaScript berechnet:

Math.ceil( Math.log2( n ) );

So viele Stellen haben wir oft aber nicht und außerdem ist es noch ganz anders als bisher dargestellt. Anders ausgedrückt ist die Anzahl der darstellbaren Zahlen bei nn Stellen 2n2^n, nämlich alle ganzen Zahlen von 00 bis 2n12^n-1. Mit 8 Bit bzw. 1 Byte könnte man alle Zahlen von 00 bis 281=2552^8-1=255 darstellen.

Der Wert einer so dargestellten Zahl zz der Länge ll wird berechnet nach der Formel (Achtung: der Index 0 ist ganz rechts in der Zahl)

w=i=0l1zi2iw=\sum_{i=0}^{l-1}{z_i\cdot 2^i}

In JavaScript könnte man die Formel so implementieren. Ich möchte mich so nah wie möglich an die mathematische Bedeutung halten und lege hier keinen Wert auf guten Code.

let zahl = '1001101101'; // 621
let wert = 0;
const länge = zahl.length;
let stelle = länge;
while (stelle--) {
  wert += zahl.charAt(stelle) * Math.pow(2, länge - stelle - 1);
}
console.log(wert);

2.3. Ganze Zahlen (Integer)

Mit den ganzen Zahlen kommen nun die negativen Zahlen dazu. Da der Computer leider nur "Strom an" und "Strom aus" kennt, können wir nicht das Symbol "-" einführen (also -101 als -5 geht nicht). Ein Ansatz, auch negative Zahlen darstellen zu können ist das explizite Vorzeichenbit.

Beim expliziten Vorzeichenbit hängen wir ganz vor die Zahl noch ein ein Bit, welches angibt ob die Zahl positiv (0) oder negativ (1) sein soll. Dann sieht die 621 so aus: 01001101101 und die -621 so: 11001101101. Die Formel dafür ist dann für die Zahl zz der Länge ll inklusive explizitem Vorzeichenbit an der Stelle l1l-1:

w=(1)zl1i=0l2zi2iw=(-1)^{z_{l-1}}\cdot\sum_{i=0}^{l-2}{z_i\cdot 2^i}

In JavaScript müsste ich den Code folgendermaßen anpassen:

let zahl = '11001101101'; // -621
let wert = 0;
const länge = zahl.length;
let stelle = länge;
while (stelle-- - 1) {
  wert += zahl.charAt(stelle) * Math.pow(2, länge - stelle - 1);
}
wert = Math.pow(-1, zahl.charAt(0)) * wert;
console.log(wert);

Mit dieser Darstellungsform kann man bei nn Bits alle Zahlen von 2n11-2^{n-1}-1 bis 2n112^{n-1}-1 darstellen. Der Nachteil dieses Verfahrens liegt in der Tatsache begründet, dass die Zahl Null zweimal repräsentiert wird: 10 und 00 bedeutet beides Null, wobei das erste -0 und das zweit +0 darstellt. Es gibt noch weitere Nachteile, die mit Schaltungen (genauer: Schaltkreise, die Arithmetik betrieben) zu tun haben, auf die ich aber nicht eingehe.

2.3.1. Zweierkomplement

Stattdessen kommt jetzt die Lösung — das Zweierkomplement. Ab hier benötigen wir eine feste Stellenzahl (Länge) um es nicht ganz kompliziert zu machen. Diese nennen wir weiterhin ll. Eine Integer-Zahl wird in den heutzutage meisten Fällen so dargestellt, dass ihr Wert berechnet wird durch

w=i=0l2zi2i2l1w=\sum_{i=0}^{l-2}{z_i\cdot 2^i}-2^{l-1}

In JavaScript würde das dann so aussehen.

let zahl = '1001101101'; // Eine Zahl — nicht 621 — gleich mehr dazu.
let wert = 0;
const länge = zahl.length;
let stelle = länge;
while (stelle-- - 1) {
  wert += zahl.charAt(stelle) * Math.pow(2, länge - stelle - 1);
}
wert -= zahl.charAt(0) * Math.pow(2, länge - 1);
console.log(wert);

Die Länge von solchen Integers ist meistens auf 8, 16, 32 oder 64 Bit festgelegt. Dadurch ergeben sich folgende Wertebereiche:

Länge kleinste Zahl größte Zahl Alias
8 Bit -128 127 byte
16 Bit -32768 32767 short
32 Bit −2147483648 2147483647 int
64 Bit −9223372036854775808 9223372036854775807 long
n Bit 2n1-2^{n-1} 2n112^{n-1}-1

Alle Zahlen, die außerhalb dieser Wertebereiche liegen lassen sich überhaupt nicht darstellen. Nichtmal etwa, sondern gar nicht!

Nehmen wir nun unsere 621 bzw. 1001101101 und für diesen Artikel nehmen wir an, dass es in Ordnung ist, eine Länge von 10 Bit zu haben. Wenn wir die letzte Formel anwenden oder den letzt JavaScript Code darauf anwenden, kommen wir bei der Interpretation der Binärdarstellung auf

1+4+8+32+64512=4031 + 4 + 8 + 32 + 64 - 512 = -403

Offenbar kommt was anderes raus, als wir erwarten. Das kommt daher, dass bei 10 Bit (siehe Tabelle) nur Zahlen bis 511 darstellbar sind. Also brauchen wir ein Bit mehr. Wir interpretieren nun die 01001101101 als eine 11 Bit Zahl mit obiger Formel:

1+4+8+32+64+512=6211 + 4 + 8 + 32 + 64 + 512 = 621

Jetzt klappts. Man muss also bei der Umwandlung von binär in die Zweierkomplement-Darstellung bei positiven Zahlen einfach eine 0 anhängen.

Wenn ich jetzt die -621 im 16-Bit-Zweierkomplement darstellen möchte, gehe ich folgendermaßen vor:

  1. Ich schreibe die 621 ins Binäre und fülle links mit Nullen auf: 0000001001101101
  2. Ich invertiere die Zahl: 1111110110010010
  3. Ich addiere 1: 1111110110010011 Prüfen durch Nachrechnen:
1+2+16+128+256+1024+2048+4096+8192+1638432768=6211 + 2 + 16 + 128 + 256 + 1024 + 2048 + 4096 + 8192 + 16384 - 32768 = -621

2.4. Rationale und reelle Zahlen (Float)

Solange wir nur addieren, subtrahieren und multiplizieren und für diese Operationen jeweils zwei Integers nehmen, bleiben wir in den Integers. Das heißt, auch das Ergebnis ist ein Integer. Wenn wir aber beginnen zu dividieren oder Wurzeln zu ziehen, kommen Zahlen aus dem Bereich der reellen Zahlen ins Spiel (xRx\in\mathbb{R}). Diese Zahlen lassen sich alle durch eine (womöglich unendlich lange) Reihe an Stellen vor und nach einem Komma darstellen. Wenn man im Dezimalsystem unterwegs ist nennt man das Dezimalbruch.

Es ist nicht möglich, alle Kommazahlen in einem Computer darzustellen. Es ich nicht einmal möglich alle Kommazahlen zwischen 0 und 1 in einem Computer darzustellen. Es ist nicht einmal möglich alle Kommazahlen zwischen 0.eineMillionNeunen und 1 in einem Computer darzustellen. Der Hintergrund ist der, dass zwischen zwei beliebigen reellen Zahlen — egal wie nah sie sich sein mögen — stets unendlich viel mehr reelle Zahlen liegen als es ganze Zahlen gibt. Und ganze Zahlen gibt es schon unendlich viele.

Die Zahl 452,54993625496757697760 ist analog zu den Zahlen bisher in einem Stellenwertsystem dargestellt. Von links nach rechts gelesen ist jede Stelle ein Zehntel ihres Vorgängers "wert". Es ist

4102+5101+2100+5101+4\cdot 10^2 + 5\cdot 10^1 + 2\cdot 10^0 + 5\cdot 10^{-1}+\ldots

der Punkt oder das Komma in der Zahl markiert also die Stelle, an der die Potenz im Stellenwertsystem negativ wird. Unabhängig von der (Ganzzahligen) Basis des Systems hören beim Komma die Ganzzahlteile der Zahl auf (452) und die Bruchteile beginnen.

Ich könnte jetzt einige ältere oder naive Ansätze zur Darstellung von Dezimalbrüchen in Binärsystemen, ähnlich dem expliziten Vorzeichenbit vorhin, erklären. Das lasse ich aber an der Stelle und erkläre gleich, wie das mit den Kommazahlen heutzutage aller meistens gemacht wird. Und zwar so wie in IEEE 754 festgelegt.

Nach IEEE 754 ist jede Zahl xx dargestellt durch einen Term wie

x=vm2e,x=v\cdot m\cdot 2^e,

wobei vv das Vorzeichen (1 Bit), mm die Mantisse und ee der Exponent ist. Eine Kommazahl, mit doppelter Präzision (double) hat 64 Bit Länge und anhand dieses Formats werde ich das Prinzip erklären. Die 64 Bit der Zahl werden geteilt in

  • 1 Bit für das Vorzeichen,
  • 11 Bit für den Exponenten und
  • 52 Bit für die Mantisse Damit lassen sich Zahlen auf ungefähr 16 Stellen im Dezimalsystem genau darstellen. Das heißt die Zahl da oben (die 452,...) wird mit ihren 23 Stellen wahrscheinlich nicht genau darstellbar sein.

JavaScript speichert alle Zahlen (number) als Double ab. Es gibt dort keinen Unterschied in der Speicherung der Zahl 7 und der Zahl -12.5.

2.4.1. Beispiel Dezimal zu Double

Wir nehmen die 621,057 und verwandeln sie in eine IEEE-754-Double-Zahl.

  1. Wir nehmen den Integer-Teil (621) und Euklids Algorithmus von oben (copy paste)

    621 = 310 * 2 + 1
    310 = 155 * 2 + 0
    155 =  77 * 2 + 1
    77 =  38 * 2 + 1
    38 =  19 * 2 + 0
    19 =   9 * 2 + 1
    9 =   4 * 2 + 1
    4 =   2 * 2 + 0
    2 =   1 * 2 + 0
    1 =   0 * 2 + 1

    und erhalten 1001101101

  2. Jetzt geht's mit dem Nachkommteil weiter (,057) — Achtung dreispaltig:

    Bits 0-9          | Bits 10-19        |Bits 20-29         |Bits 30-39         |Bits 40-49
    ------------------+-------------------+-------------------+-------------------+-------------------
    0.057 = 0.114 / 2 | 0.368 = 0.736 / 2 | 0.832 = 1.664 / 2 | 0.968 = 1.936 / 2 | 0.232 = 0.464 / 2
    0.114 = 0.228 / 2 | 0.736 = 1.472 / 2 | 0.664 = 1.328 / 2 | 0.936 = 1.872 / 2 | 0.464 = 0.928 / 2
    0.228 = 0.456 / 2 | 0.472 = 0.944 / 2 | 0.328 = 0.656 / 2 | 0.872 = 1.744 / 2 | 0.928 = 1.856 / 2
    0.456 = 0.912 / 2 | 0.944 = 1.888 / 2 | 0.656 = 1.312 / 2 | 0.744 = 1.488 / 2 | 0.856 = 1.712 / 2
    0.912 = 1.824 / 2 | 0.888 = 1.776 / 2 | 0.312 = 0.624 / 2 | 0.488 = 0.976 / 2 | 0.712 = 1.424 / 2
    0.842 = 1.648 / 2 | 0.776 = 1.552 / 2 | 0.624 = 1.248 / 2 | 0.976 = 1.952 / 2 | 0.424 = 0.848 / 2
    0.648 = 1.296 / 2 | 0.552 = 1.104 / 2 | 0.248 = 0.496 / 2 | 0.952 = 1.904 / 2 | 0.848 = 1.696 / 2
    0.296 = 0.592 / 2 | 0.104 = 0.208 / 2 | 0.496 = 0.992 / 2 | 0.904 = 1.808 / 2 | 0.696 = 1.392 / 2
    0.592 = 1.184 / 2 | 0.208 = 0.416 / 2 | 0.992 = 1.984 / 2 | 0.808 = 1.616 / 2 | 0.392 = 0.784 / 2
    0.184 = 0.368 / 2 | 0.416 = 0.832 / 2 | 0.984 = 1.968 / 2 | 0.616 = 1.232 / 2 | ...

    Ich lese die Vorkommastellen rechts neben den =-Zeichen von oben nach unten ab und erhalte den Nachkommateil:

621.0571001101101,0000111010010111100011010100111111011111001110110621.057 \approx 1001101101,0000111010010111100011010100111111011111001110110
  1. Höchste Zweierpotenz isolieren 29=5122^9=512 mit Bias-Wert 10231023 addieren. Damit ist der Exponent mit Bias 910+102310=103210=1000000100029_{10}+1023_{10}=1032_{10}=10000001000_2
  2. Ergebnis:

    621.057 in Double
    |0|10000001000|0011011010000111010010111100011010100111111011111001|
    |v| Exponent  |                    Mantisse                        |

    Die erste Null ist das Vorzeichenbit. Der darauf folgende Block ist der mit Bias versehene Exponent. Der letzte Block ist die 53 Bit lange Mantisse. Die erste 1 von der Mantisse lässt man weg, weil da immer eine 1 steht. das liegt daran, weil man durch dieses Verfahren die Zahl implizit binär normiert hat. Normieren heißt hier, dass vor dem Komma genau eine Zahl ungleich Null steht. Im Binären ist das eine Eins. Da also die Mantisse immer mit einer 1 beginnt, kann man sie auch weglassen und bei Evaluationen als gegeben annehmen.

2.4.2. Beispiel Double zu Dezimal

Jetzt rechnen wir die Zahl von oben wieder in eine Dezimalzahl zurück.

  1. Exponent minus Bias: 100000010002011111111112=10012=91010000001000_2 - 01111111111_2 = 1001_2 = 9_{10}. Also ist der Exponent 99.
  2. Mantisse mit 1,1, präfixen: 1,00110110100001110100101111000110101001111110111110011,0011011010000111010010111100011010100111111011111001
  3. Komma in Mantisse verschieben um 9: 1001101101,00001110100101111000110101001111110111110011001101101,0000111010010111100011010100111111011111001
  4. Vorkommastellen umwandeln: 10011011012=621101001101101_2=621_{10}
  5. Nachkommastellen umwandeln:

    Zehner:          1         2         3         4
    Stelle: 12345678901234567890123456789012345678901234
    Wert  : 00001110100101111000110101001111110111110011

    Für alle Stellen (von 1 bis 44) an denen Wert 1 ist addiere ich 2Stelle2^{-Stelle}. Es folgt der Term für den Nachkommateil nn der dargestellten Zahl:

n=25+26+27+29+212+214+215+216+217+221+222+224+226+229+230+231+232+233+234+236+237+238+239+240+243=0,05699999999990268406691029667854309082031250000000\begin{aligned} n &= 2^{-5}+2^{-6}+2^{-7}+2^{-9}+2^{-12}+2^{-14}+2^{-15}+2^{-16}+2^{-17}+2^{-21}+2^{-22}+2^{-24}+2^{-26}+2^{-29}+2^{-30}+2^{-31}+2^{-32}+2^{-33}+2^{-34}+2^{-36}+2^{-37}+2^{-38}+2^{-39}+2^{-40}+2^{-43}\\ &= 0,05699999999990268406691029667854309082031250000000 \end{aligned}

Ich habe einen sehr genau rechnenden Taschenrechner verwendet (SpeedCrunch sudo apt install speedcrunch) um diesen Term auszurechnen. Wie man sieht liegt genau hier ein Problem von Kommazahlen. Durch die Reihe an Summanden der Form 2n2^{-n} lassen sich nicht alle Zahlen darstellen. Andere hingegen sind ganz toll. Ich zeige mal ein paar Nachkommastellen-Beispiele:

.5        = 1
.25       = 01
.75       = 11
.26953125 = 01000101
.2        = 00110011...
.1        = 00011001100110011...

Es gibt drei Möglichkeiten, was mit dem Nachkommateil passiert. Das gilt übrigens für das Dezimalsystem genau wie für das Binärsystem:

  1. Der Dezimalbruch endet irgendwann (38=0.375\frac{3}{8} = 0.375)
  2. Der Dezimalbruch wird periodisch (13=0.3333333\frac{1}{3} = 0.3333333\ldots)
  3. Der Dezimalbruch endet nicht und ist auch nicht periodisch (2=1,41421356237...\sqrt{2}= 1,41421356237...)

Der Nachkommateil der Zahl ist Binär betrachtet übrigens genau dann endlich lang, wenn man den Bruchteil der Zahl als Summe Brüchen darstellen kann, deren Nenner alle eine Zweierpotenz und deren Zähler immer 1 sind.
Die vierte Zahl bei den Beispielen (.26953125) ist 14+164+1256\frac{1}{4}+\frac{1}{64}+\frac{1}{256}.

3. Die Ursachen der Probleme

Nach all der Theorie und Einführung erkläre ich jetzt an den beiden Eingangs aufgezeigten Beispielen, warum diese Auftreten und hoffe dabei, dass du mit dem Hintergrundwissen aus den vorangegangenen Abschnitten verstehst, warum die Probleme so sein müssen. Ich gehe rückwärts vor und starte mit dem zweiten Problem.

3.1. Zweites Problem: Integer

Mit diesem ganzen Hintergrundwissen schauen wir uns nochmal das zweite einleitend beschriebene Problem an:

253+12532^{53}+1-2^{53}

Um zu sehen, was genau hier vor sich geht, ist es hilfreich sich anzusehen, wie die Zahl 2532^{53} nach IEEE 754 dargestellt wird. Wir durchlaufen den Algorithmus, wie bei der 621.057 also jetzt nochmal.

  1. Umwandlung von 2532^{53} ins Binärsystem ergibt eine Eins mit 53 Nullen: 100000000000000000000000000000000000000000000000000000
  2. Nachkommateil: keiner da. Also 0. Ergo: 1(,)00000000000000000000000000000000000000000000000000000,0
  3. Höchste Zweierpotenz: 2532^{53}. Wir rechnen 5310+102310=107610=10000110100253_{10}+1023_{10}=1076_{10}=10000110100_2. Hinter dem in Klammern stehenden Komma (,) beginnt die gespeicherte Mantisse und sie besteht nur aus Nullen.
  4. Ergebnis:

    2^53 in Double
    |0|10000110100|0000000000000000000000000000000000000000000000000000|
    |v| Exponent  |                    Mantisse                        |

    Und jetzt das gleiche für die Zahl 253+12^{53}+1:

  5. Umwandlung von 253+12^{53}+1 ins Binärsystem ergibt eine Eins mit 52 Nullen und einer Eins: 100000000000000000000000000000000000000000000000000001
  6. Nachkommateil: keiner da. Also 0. Ergo: 1(,)00000000000000000000000000000000000000000000000000001,0
  7. Höchste Zweierpotenz: 2532^{53}. Wir rechnen 5310+102310=107610=10000110100253_{10}+1023_{10}=1076_{10}=10000110100_2. Hinter dem in Klammern stehenden Komma (,) beginnt die gespeicherte Mantisse und sie besteht aus 52 Nullen gefolgt von einer Eins. Wie speichern aber nur dir ersten 52 Bit davon. Die Eins entfällt also. Hier entsteht der Fehler!.
  8. Ergebnis:

    2^53 + 1 in Double
    |0|10000110100|0000000000000000000000000000000000000000000000000000|
    |v| Exponent  |                    Mantisse                        |

Für den Computer sehen die beiden Integer Zahlen 2532^{53} und 253+12^{53}+1 in Double-Präzision genau gleich aus. Mit Double lassen sich nur alle Integer Zahlen mit einem Betrag von maximal 2532^{53} Lückenlos darstellen. Ab 2532^{53} lässt sich jede zweite Zahl nicht mehr darstellen. Dabei handelt es sich um die ungeraden Zahlen. 253+22^{53}+2 geht also wieder genau. Ab 2542^{54} wird es schlimmer und es kann nur noch jede vierte ganze Zahl dargestellt werden (die die durch Vier teilbar sind). Mit jeder Erhöhung der Länge der Zahl im Binärsystem vergrößern sich die Lücken also.

3.2. Erstes Problem: Float

Das gleiche gilt natürlich auch für Kommazahlen. Auch sie unterliegen der beschränkten Genauigkeit von Double und die Genauigkeit der Nachkommastellen sinkt mit dem Betrag der Zahl selbst. Und damit kommen wir zum ersten eingangs beschriebenen Problem zurück: 0.1 + 0.2 !== 0.3.

Die Zahl 0.10.1 wird in Double folgendermaßen dargestellt:

0.1 in Double
|0|01111111011|1001100110011001100110011001100110011001100110011011|
|v| Exponent  |                    Mantisse                        |

Ihre Mantisse ist periodisch. Egal wo wir aufhören würden. Binär würden wir die 0.10.1 niemals genau darstellen können. Der Wert der Double Darstellung entspricht

xDouble= 0.100000000000000005551115123126xexakt= 0.1\begin{aligned} x_{Double} =&\ 0.100000000000000005551115123126 \neq \\ x_{exakt} =&\ 0.1 \end{aligned}

Analog sie es bei 0.20.2 aus:

yDouble= 0.200000000000000011102230246252yexakt= 0.2\begin{aligned} y_{Double} =&\ 0.200000000000000011102230246252 \neq \\ y_{exakt} =&\ 0.2 \end{aligned}

Es gilt damit

xDouble+yDouble=0.300000000000000016653345369378=zx_{Double}+y_{Double} = 0.300000000000000016653345369378 = z

und das erklärt noch nicht das Phänomen 0.1 + 0.2 === 0.30000000000000004. Aber das neu berechnete zz muss ja auch in Double abgelegt werden:

zexakt=0.300000000000000016653345369378zDouble=0.300000000000000044408920985006\begin{aligned} z_{exakt} &= 0.300000000000000016653345369378 \neq \\ z_{Double} &= 0.300000000000000044408920985006 \end{aligned}

Und da haben wir sie. Die Ungenauigkeit die eine Gleichheit zur Ungleichheit werden lässt.

4. Lösungsansätze

4.1. Lösung 1 - Mehr Code

Es gibt die Möglichkeit, im Falle der Notwendigkeit von sehr hoher Präzision, Bibliotheken zu verwenden, die eine hinreichende oder teilweise sogar - im Rahmen des RAMs - beliebig hohe Genauigkeit von arithmetischen Operationen und Zahlendarstellung gewährleisten.

Ich habe schon relativ viel mit math.js gearbeitet und kann die BigNumbers-Lösung empfehlen. Sie bietet eine beliebige Präzision und Objekte vom Typ BigNumber werden in weiten Teilen von math.js unterstüzt.

Das Konzept von BigNumbers ist im Rahmen von decimal.js entstanden. Decimal.js unterstützt auch eine große Menge von Operationen und kann als der große Bruder zu einer sehr kleinen Library namens big.js bezeichnet werden, welche schlanker daherkommt und dir ggf. reicht, wenn du mit einem eingeschränkten Set an arithmetischen Operationen auskommst (grob: Addition, Subtraktion, Multiplikation, Division, Potenzen, Quadratwurzeln, Quadrieren).

Letzteres ist wiederum eine Antizipation eines Konzeptes, was in JavaScript built-in vorhanden ist: BigInt. Die Implementation von BigInt ist eine Portierung aus Java und wird von allen guten Browsern unterstützt.

Gute Browser:

  • Chrome
  • Firefox
  • Opera
  • Android Browser
  • Samsung Internet
  • Node ab 10.4.0

Schlechte Browser:

  • alles von Microsoft außer der neue Edge der Chrome ist
  • Safari von Apple und damit auch alle Browser unter iOS

BigInt (core) kannst du also immer verwenden, wenn die iOS- und Windows-Nutzer egal sind, du außerdem keine hohen Ansprüche an arithmetische Operationen hast und eine sehr schöne Integration oder zusätzliche Module bevorzugst.

const myBigNumber = 9007199254740995n;

Alle diese Lösungen haben eines gemeinsam: Sie sind signifikant langsamer als Rechenoperationen auf Number-Instanzen. Hierzu habe ich einen Benchmark erstellt. BigInt ist ca. 215 mal langsamer als Int (Chrome 79) im Addieren, Subrahieren, Dividieren und Multiplizieren zweier Ganzahlen nacheinander.

4.2. Lösung 2 - Mehr Köpfchen

Außerdem gibt es die Möglichkeit, dass dein Wunsch nach einer Library zur Erhöhung der Präzision fehlgeleitet ist und du vor dem Verwenden einer zusätzlichen Library oder von BigInt vielleicht erstmal nachdenken solltest, ob es ggf. auch andere Lösungen gibt, mit denen du dein Problem lösen kannst ohne so große Zahlen verwenden zu müssen. 2532^53 ist 60 Mal die Distanz von der Erde zur Sonne ... in Millimetern gemessen! 2532^53 ist 1.3 mal so groß wie die Anzahl der Minuten, seit dem Urknall, bzw. 50 Mal so groß wie die Anzahl an Millisekunden, seit Gott die Welt erschuf (das war 3761 v.Chr.).

  • Achte darauf, die richtigen Einheite zu verwenden (vor allem bei Ganzzahlen!)
  • Achte darauf, Nullprüfungen und Gleichheitsprüfungen unter Vorhandensein zun Ungenauigkeit zu vermeiden. Statt die Gleicheit zweier Zahlen zu prüfen (a === b) könntest du zum Beispiel das hier machen: (Math.abs((a/b) - 1) < 0.0001). Das heißt nichts anderes als: Die Zahlen unterscheiden sich um weniger als ein hundertstel Prozent voneinander.
  • Benutze hochpräzise Darstellungen nur wo nötig und niemals durchgängig, wenn dir Rechenzeit und Strom lieb ist.