Pieniądze liczone w całkowitych groszach i rabat, który zniknął
Daliśmy pięćdziesiąt groszy rabatu, a księgi nie zapisały z tego nic. Liczby zmiennoprzecinkowe nie były nawet winowajcą. Praktyczny przewodnik po arytmetyce pieniędzy na liczbach całkowitych: ograniczanie zakresu, podział proporcjonalny i doprowadzanie sumy cen jednostkowych do dokładnej kwoty.
Każdy programista wcześnie uczy się, że 0,1 plus 0,2 to nie 0,3, więc wszyscy trzymamy pieniądze jako całkowite grosze i czujemy się bezpiecznie. Potem budujesz coś prawdziwego, na przykład rabat procentowy na cały koszyk, i odkrywasz, że liczby całkowite rozwiązują tylko pierwszy problem. Drugi problem jest taki, że dzielenie nic sobie nie robi z Twoich ksiąg.
Pięćdziesiąt groszy, które przepadło
Oto układ, który nas ugryzł, obrany do kości. W koszyku leży 100 sztuk towaru po 100 groszy. Klient dostaje 50 groszy zniżki na całe zamówienie. Kwota częściowa to 10 000 groszy, rabat to 50 groszy, a kwota po rabacie to 9950 groszy. Na razie same liczby całkowite.
Teraz zapisz tę sprzedaż w księdze, która trzyma cenę jednostkową na pozycję, tak jak lubią to systemy księgowe. Cena jednostkowa po rabacie to 9950 podzielone przez 100, czyli 99,5 grosza. To nie jest liczba całkowita, więc zostaje zaokrąglona do 100. Księga mówi teraz: 100 sztuk po 100 groszy, czyli sprzedaż w pełnej cenie. Rabat się nie zmniejszył. Zniknął, podczas gdy interfejs obiecał klientowi 9950, a księgi twierdzą 10 000.
// The trap: one rounded unit price cannot represent every total.const subtotal = 100 * 100; // 10000cconst discounted = subtotal - 50; // 9950cconst unit = Math.round(discounted / 100); // 100c ... the discount is goneconsole.log(unit * 100); // 10000c, not 9950c // The fix: split the line into two prices one cent apart.const qty = 100;const base = Math.floor(discounted / qty); // 99cconst rem = discounted - base * qty; // 50 units get one extra cent// rem units at (base + 1), the rest at base:// 50 * 100 + 50 * 99 === 9950 ✓ exact, and every price is an integerRozdzielenie wygląda na przesadę, dopóki nie zobaczysz, co daje. Pięćdziesiąt sztuk po 100 groszy plus pięćdziesiąt sztuk po 99 groszy to dokładnie 9950. Każdy wiersz nadal trzyma uczciwą całkowitą cenę jednostkową, ilości nadal się sumują, a suma z księgi równa się liczbie z paragonu co do grosza. Nic z tego nie jest możliwe przy jednej zaokrąglonej cenie.
Podział proporcjonalny wymaga przejścia domykającego
Ta sama choroba pojawia się piętro wyżej, gdy rabat na całe zamówienie rozkłada się na kilka pozycji. Daj każdej pozycji jej proporcjonalny udział i zaokrąglij, a udziały zsumują się do prawie całego rabatu. Czasem o grosz za dużo, czasem o grosz za mało, od czasu do czasu idealnie, co jest najgorszym wynikiem ze wszystkich, bo ukrywa błąd przed pobieżnym testowaniem.
// Prorate by share, then push the rounding leftover onto lines with room.let allocated = 0;for (const line of lines) { line.discount = Math.min( line.subtotal, Math.round((discount * line.subtotal) / orderSubtotal), ); allocated += line.discount;}let remainder = discount - allocated; // may be negativefor (const line of lines) { if (remainder === 0) break; const room = remainder > 0 ? line.subtotal - line.discount : line.discount; const move = Math.min(Math.abs(remainder), room) * Math.sign(remainder); line.discount += move; remainder -= move;}// invariant: sum(line.discount) === discount, and no line goes negativeDwa szczegóły w tej pętli zapracowały na siebie ciężko. Reszta rozkłada się na dowolną pozycję z zapasem, a nie tylko na ostatnią, bo ostatnia pozycja może być towarem za sześć groszy, który nie ma jak wchłonąć trzech groszy reszty. A każdy udział jest ograniczony kwotą częściową swojej pozycji, bo rabat większy niż pozycja oznacza ujemną cenę jednostkową, a ujemne ceny jednostkowe to sposób, w jaki księga zaczyna kłamać w drugą stronę.

Ograniczaj zakres na każdym brzegu
- Rabaty ograniczaj do przedziału od zera do kwoty częściowej. Sto dziesięć procent zniżki to literówka, a nie zwrot.
- Kwoty wpisane przez człowieka parsuj defensywnie: puste i nieliczbowe znaczy zero, nigdy NaN w środku arytmetyki.
- Ilości ograniczaj do tego, co istnieje. Kod od pieniędzy dziedziczy każdy błąd magazynu, który leży wyżej.
- Testuj te sumy, które nie dzielą się równo. Trzy sztuki po 10 złotych z 1 złotym rabatu to lepszy test niż jakakolwiek okrągła liczba.
Nic z tego nie jest efektowne i właśnie dlatego przemyka przez przegląd kodu. Arytmetyka wygląda poprawnie, bo jest poprawna, aż do chwili, w której dzielenie całkowite po cichu wyrzuca resztę należną komuś innemu. Zapisz niezmiennik, suma części równa się całości, i postaw test, który będzie tego pilnował.
Liczby całkowite trzymają pieniądze w uczciwości. Domykanie trzyma w uczciwości liczby całkowite.