Skip to content

Double vs DecimalHvordan lagre penger - Double Trouble

Profilbilde av Håkon Anders Strømsodd

Forfatter

Håkon Anders Strømsodd
6 minutter lesetid

Har du noen gang jobbet med finansielle kalkulasjoner i prosjektet ditt? Kanskje har du jobbet med bankapplikasjoner eller integrasjoner mot betalingsløsninger?

Da har du forhåpentligvis vært borti datatyper som er designet for å håndtere sensitiv finansiell data som for eksempel pengesummer eller rentesatser. I de fleste programmeringsspråk har vi innebygde typer som er laget for å håndtere denne typen data. I C# finner vi typen decimal og i Java har vi BigDecimal. Begge disse er laget for å unngå flyttallsfeil som oppstår i de mer vanlige datatypene som float og double.

Hva er egentlig en flyttallsfeil? Man kan observere flyttallsfeil ganske enkelt hvis man prøver å regne ut 0.1 + 0.2, for eksempel i C# Interactive:

> 0.1 + 0.2
0.30000000000000004

Her påstår den at svaret ikke er 0.3, men litt høyere. Denne oppførselen kan være veldig skummel hvis man for eksempel hadde brukt denne datatypen til å beregne en saldo etter et kontoinnskudd. I et slikt system kan man etter 100 000 000 000 000 (100 billioner) overføringer faktisk ende opp med 4 ekstra øre på konto!

Grunnen til at vi ser denne oppførselen er fordi man i de fleste programmeringsspråk bruker flyttall til å representere rasjonelle tall. Disse datatypene, i likhet med heltallstyper som int og long representerer tallene i totallssystemet bak kulissene, og det er her feilene oppstår. Det er nemlig noen tall som vi rett og slett ikke kan representere i totallsystemet. Det samme gjelder faktisk vårt eget titallssystem! Hvis man spør noen hva 100 delt på 3 er ville de kanskje svart 33.33 eller liknende. Her gjør vi akkurat den samme forenklingen som float og double gjør i utregningen over. Vi klarer ikke å representere 33 + 1/3​ i titallssystemet, så vi kutter heller bare bort noen siffer og sier oss fornøyde.

Problemet vi står overfor må tas hensyn til i ethvert system som ønsker å oppfylle kravene for CIA (Confidentiality, Integrity, Availability). Integriteten til dataen vi lagrer og prosesserer står faktisk på spill dersom vi ikke beskytter oss mot flyttallsfeil. Det varierer selvsagt i hvilken grad man trenger å unngå flyttallsfeil for å oppfylle krav om integritet av data. Dette er helt avhengig av domenet vi er i, men som en tommelfingerregel skal alle finansielle applikajsoner bruke datatyper som er beskyttet mot denne typen feil.

Bruk aldri double når penger er involvert!

*Nesten aldri. For eksempel går det kanskje bra med double hvis du skal regne ut et estimat. I det tilfellet kan ytelse være viktigere enn nøyaktighet, selv om penger er involvert.

Så hvordan sikerer datatypene i C# og Java at vi unngår disse feilene? Det finnes noen valgmuligheter for hvordan man kan løse dette. Den enkelste løsningen for oss utviklere er å bruke datatyper som C# sin decimal eller Java sin BigDecimal for alle sensitive tall. Måten disse datatypene løser problemet på er nokså enkel. De lagrer desimaltall ved hjelp av tre tall: Ett heltall, en tiereksponent og en fortegnsbit. Heltallet lagrer tallet vårt som et heltall ved å ignorere komma, tiereksponenten sier hvilken tierpotens man må dele dette tallet på for at komma skal stå på rett plass igjen og fortegnsbiten sier om tallet er negativt eller positivt. Ved hjelp av denne metoden unngår vi flyttallsfeil og vi kan være sikre på at banktransaksjoner ikke trekker fra eller legger på noen ekstra nanokroner her og der.

Så hvis det er så enkelt å unngå flyttallsfeil, hvorfor bruker man ikke alltid decimal eller BigDecimal? På grunn av måten disse typene lagrer tall bruker de dessverre mange flere CPU-sykler på å gjøre helt vanlige aritmetiske regneoperasjoner. Dersom vi i tillegg begynner å gjøre utregninger med eksponenter og røtter kan kjøretiden fort eskalere, noe som vil være spesielt merkbart i systemer med mye trafikk eller med tunge utregninger. Vi bruker derfor flyttall hvis ytelseskravene våre overgår kravene for dataintegritet. Altså, kun dersom vi kan tillate oss litt usikkerhet i desimaltallene våre.

Alternativer til decimal og BigDecimal

Dersom man kun trenger å lagre eller gjøre enkle regneoperasjoner på pengeverdier, er en annen løsning å bruke to variabler av typen int for å representere kroner og øre hver for seg. Denne løsningen kan være litt mer knotete å forholde seg til som utvikler, men sikrer integriteten til verdiene som lagres. I tillegg kan domeneprimitiver brukes til å forenkle interaksjon med denne representasjonen. Alternativt kan man bruke én long og lagre alle pengeverdier i øre. Begge disse løsningene er helt valide for de fleste finansielle regneoperasjoner og bør vurderes dersom man ikke trenger høyere nøyaktighet. Dersom dette ikke er nok og man trenger høyere nøyaktighet for mellomregninger bør man heller bruke datatyper som decimal og BigDecimal.

Til slutt skal vi se på i hvor stor grad bruken av decimal påvirker ytelsen sammenliknet med double i .NET 8.

I kodeblokken under ser vi en klasse med to metoder som regner ut en inflasjonsjustert pris etter 1000 perioder med 5% inflasjon. Begge metodene utfører akkurat samme kalkulasjon men ved hjelp av hver sin datatype. Mange av regneoperasjonene her er strengt tatt helt unødvendige, men er lagt til for å tydeliggjøre forskjellene.

public class DoubleMoney
{
    public double InflationAdjustedMoney_Double()
    {
        double money = 34567d;
        double inflation = 0.05d;
        double aggregated = Enumerable
            .Range(0, 1000)
            .Select(_ => 0d)
            .Aggregate((a, b) => (a + 1) * (inflation + 1) - 1);
        return money * aggregated;
    }

    public decimal InflationAdjustedMoney_Decimal()
    {
        decimal money = 34567m;
        decimal inflation = 0.05m;
        decimal aggregated = Enumerable
            .Range(0, 1000)
            .Select(_ => 0m)
            .Aggregate((a, b) => (a + 1) * (inflation + 1) - 1);
        return money * aggregated;
    }
}

Ved hjelp av Benchmark DotNet kan vi se tiden det tar å kjøre disse to kodesnuttene:

Selv om begge utregningene går unna på bare få mikrosekunder er dommen likevel tydelig. Utregningen som bruker decimal tar sirka 20 ganger lenger tid sammenliknet med utregningen som bruker double.

Denne forskjellen er så stor at man ikke kan ignorere den i mange tilfeller og bør tas i betraktning under design av systemer hvor ytelse er viktig. Samtidig er det en pris som er vel verdt å betale for at dataen vår ikke endres på vei gjennom systemet vårt.