Rahalaskenta kokonaisina sentteinä, ja alennus joka katosi
Annoimme viidenkymmenen sentin alennuksen, eikä kirjanpito kirjannut siitä mitään. Liukuluku ei ollut edes syyllinen. Kenttäopas rahalaskentaan kokonaisluvuilla: rajaus, alennuksen jakaminen riveille ja se, miten yksikköhintojen kirjanpito summautuu tarkalleen oikeaan loppusummaan.
Jokainen ohjelmoija oppii varhain, ettei 0,1 plus 0,2 ole 0,3, joten tallennamme rahan kokonaisina sentteinä ja tunnemme olomme turvalliseksi. Sitten rakennat jotain oikeaa, kuten prosenttialennuksen koko ostoskorille, ja huomaat että kokonaisluvut ratkaisevat vain ensimmäisen ongelman. Toinen ongelma on se, ettei jakolasku välitä sinun kirjanpidostasi.
Ne viisikymmentä senttiä, jotka katosivat
Tässä on asetelma joka puri meitä, luurangoksi riisuttuna. Ostoskorissa on 100 kappaletta yhden dollarin tuotetta. Asiakas saa tilauksesta viisikymmentä senttiä alennusta. Välisumma on 10 000 senttiä, alennus 50 senttiä ja alennettu loppusumma 9 950 senttiä. Tähän asti pelkkiä kokonaislukuja.
Kirjaa nyt tuo myynti kirjanpitoon, joka tallentaa yksikköhinnan riviä kohden, niin kuin kirjanpitojärjestelmät haluavat. Alennettu yksikköhinta on 9 950 jaettuna 100:lla, eli 99,5 senttiä. Se ei ole kokonaisluku, joten se pyöristyy sataan. Kirjanpito sanoo nyt 100 kappaletta hintaan 100 senttiä: täyshintainen myynti. Alennus ei kutistunut. Se katosi, ja samaan aikaan käyttöliittymä lupasi asiakkaalle 9 950 ja kirjat sanoivat 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 integerJako näyttää nipottavalta, kunnes näet mitä sillä saa. Viisikymmentä kappaletta hintaan 100 senttiä plus viisikymmentä kappaletta hintaan 99 senttiä on tarkalleen 9 950. Jokainen rivi tallentaa yhä rehellisen kokonaislukuisen yksikköhinnan, määrät täsmäävät edelleen, ja kirjanpidon summa vastaa kuitin lukua senttiä myöten. Mikään tästä ei ole mahdollista yhdellä pyöristetyllä hinnalla.
Riveille jakaminen tarvitsee täsmäytyskierroksen
Sama tauti ilmestyy tasoa ylemmäs, kun tilaustason alennus leviää useammalle riville. Anna jokaiselle riville sen suhteellinen osuus ja pyöristä, niin osuudet summautuvat melkein alennukseen. Joskus sentti yli, joskus sentti alle, silloin tällöin tarkalleen oikein, mikä on kaikista huonoin lopputulos, koska se piilottaa bugin pinnalliselta testaamiselta.
// 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 negativeKaksi yksityiskohtaa tuossa silmukassa ansaitsivat paikkansa kantapään kautta. Ylijäämä leviää mille tahansa riville jolla on kapasiteettia, ei vain viimeiselle riville, koska viimeinen rivi voi olla kuuden sentin tuote jolla ei ole tilaa imeä kolmea ylijäävää senttiä. Ja jokainen osuus rajataan oman rivinsä välisummaan, koska riviä suurempi alennus tarkoittaa negatiivista yksikköhintaa, ja negatiiviset yksikköhinnat ovat se tapa jolla kirjanpito alkaa valehdella toiseen suuntaan.

Rajaa jokaisella reunalla
- Alennukset rajataan välille nollasta välisummaan. Sadankymmenen prosentin alennus on kirjoitusvirhe, ei hyvitys.
- Jäsennä käyttäjän syöttämät summat varovasti: tyhjä ja ei-numeerinen tarkoittavat nollaa, ei koskaan NaN:ia kesken laskutoimituksen.
- Määrät rajataan siihen mitä on olemassa. Rahakoodi perii jokaisen varastobugin ylävirrastaan.
- Testaa ne loppusummat jotka eivät jakaudu tasan. Kolme kymmenen dollarin tuotetta yhden dollarin alennuksella on parempi testi kuin mikään tasaluku.
Mikään tästä ei ole hohdokasta, ja juuri siksi se lipsahtaa katselmoinnin ohi. Laskutoimitus näyttää oikealta koska se on oikea, aina siihen asti kun kokonaislukujako hiljaa heittää pois jäännöksen joka kuului jollekulle. Kirjoita invariantti muistiin, osien summa on kokonaisuus, ja laita testi vartioimaan ovea.
Kokonaisluvut pitävät rahan rehellisenä. Täsmäytys pitää kokonaisluvut rehellisinä.