Decimals and Scales
A Decimal<I, S> is a whole number of steps, held in an
integer I, at a scale S that says how many decimals a step is. 12.34 at two
decimals is 1,234 steps of 0.01:
fn a_decimal_is_a_whole_number_of_steps() {
use ddust::{D64, Fixed, dec};
let price: D64<2> = dec!(12.34);
assert_eq!(price.steps(), 1_234, "1,234 steps of 0.01");
assert_eq!(price.decimals(), 2);
assert_eq!(D64::from_steps(1_234, Fixed::<2>), price);
assert_eq!(size_of::<D64<2>>(), size_of::<i64>(), "the scale takes no room");
}
The steps are the decimal’s whole state. Adding two values of one scale adds their steps, comparing them compares their steps, and nothing is normalized or rescaled behind the caller’s back.
The Integer
I is one of Rust’s ten integers, which Int names; the five signed ones are
Signed too. Each has an alias at a static scale of D decimals:
| Signed | Unsigned | Bytes | Digits every value has room for |
|---|---|---|---|
D8<D> | UD8<D> | 1 | 2 |
D16<D> | UD16<D> | 2 | 4 |
D32<D> | UD32<D> | 4 | 9 |
D64<D> | UD64<D> | 8 | 18, and 19 unsigned |
D128<D> | UD128<D> | 16 | 38 |
The scale splits those digits between the whole part and the fraction: a
D64<8> has room for ten whole digits and eight decimals, and a D128<18> for
twenty and eighteen. The integer’s range is the decimal’s, so a D64<2> runs
from −92,233,720,368,547,758.08 to 92,233,720,368,547,758.07.
The Scale
Fixed<D> is a scale the compiler knows: zero-sized, so a
decimal of it is its integer alone, and two values of it never differ in scale.
D is at most MAX_DECIMALS, 38, since
is the largest power of ten a 128-bit integer holds; a Fixed<39> fails the
build. Dynamic carries its scale at run time, in a byte beside the steps,
and has a chapter of its own, Run-Time Scales.
Two static scales are two types, so their values never meet until one is converted:
//! Two static scales are two types: their values never add.
use ddust::{D64, dec};
fn main() {
let (tenths, hundredths): (D64<1>, D64<2>) = (dec!(1.5), dec!(1.25));
let _sum = tenths + hundredths;
}
error[E0308]: mismatched types
--> tests/compile_fail/two_static_scales_never_mix.rs:7:25
|
7 | let _sum = tenths + hundredths;
| ^^^^^^^^^^ expected `1`, found `2`
|
= note: expected struct `Decimal<i64, Fixed<1>>`
found struct `Decimal<i64, Fixed<2>>`
Making a Decimal
A program reads a decimal from text, as Text shows, or writes it as a
literal. dec! takes its type from where it goes, or from a type named after
a colon, and is checked when the constant is evaluated: a literal with more
decimals than its scale, past its range, or negative for an unsigned decimal
fails the build. The value is the literal exactly, never rounded.
fn a_literal_is_checked_when_the_constant_is_evaluated() {
use ddust::{D64, D128, dec};
const FEE: D64<4> = dec!(0.0025);
const RATE: D128<18> = dec!(1.2e-5);
assert_eq!(FEE.steps(), 25);
assert_eq!(RATE.to_string(), "0.000012");
assert_eq!(dec!(-7.5: D64<1>).steps(), -75, "a type named after a colon");
}
From digits known at run time, new takes the digits,
their decimals and the scale, and makes
exactly, or refuses it with a
ConvertError: TooManyDecimals for a non-zero digit past the scale,
PosOverflow or NegOverflow past the range.
new_round rounds where new refuses, and
to_digits goes back:
fn digits_and_decimals_make_a_decimal_exactly() {
use ddust::round::HalfEven;
use ddust::{ConvertErrorKind, D64, Fixed, dec};
let half: D64<1> = D64::new(600_005, 1, Fixed).expect("one decimal fits one");
assert_eq!(half, dec!(60_000.5));
let refused = D64::new(600_005, 1, Fixed::<0>).expect_err("a decimal past the scale");
assert_eq!(refused.kind(), ConvertErrorKind::TooManyDecimals);
assert_eq!(D64::new_round(600_005, 1, Fixed::<0>, HalfEven), Ok(dec!(60_000: D64<0>)));
assert_eq!(half.to_digits(4), Ok(600_005_000), "back to digits, at four decimals");
}
from_steps and steps
are the raw representation, for storage and for types built on a decimal.
from_int makes a whole number, and
from_f64 reads a double, as Floats
shows.
Widths
A value moves to a wider integer at the same scale by From, since every value
fits, and back by TryFrom, which refuses one that does not.
widen moves to the integer twice as wide, from D8
to D16 up to D64 to D128, and the unsigned alike, where a product or more
digits have room:
fn a_value_moves_to_a_wider_integer_freely_and_back_by_a_check() {
use ddust::{ConvertErrorKind, D32, D64, dec};
let small: D32<2> = dec!(19.99);
let wide: D64<2> = small.into();
assert_eq!(D32::try_from(wide), Ok(small), "it fits again");
let large: D64<2> = dec!(30_000_000);
let refused = D32::<2>::try_from(large).expect_err("past an i32's 21,474,836.47");
assert_eq!(refused.kind(), ConvertErrorKind::PosOverflow);
}
Values of two widths never mix, as two integers do not:
//! Two integers are two types: values of two widths never add until one is converted.
use ddust::{D32, D64, dec};
fn main() {
let (wide, narrow): (D64<2>, D32<2>) = (dec!(1.5), dec!(1.25));
let _sum = wide + narrow;
}
error[E0308]: mismatched types
--> tests/compile_fail/two_widths_never_mix.rs:7:23
|
7 | let _sum = wide + narrow;
| ^^^^^^ expected `Decimal<i64, Fixed<2>>`, found `Decimal<i32, Fixed<2>>`
|
= note: expected struct `Decimal<i64, Fixed<2>>`
found struct `Decimal<i32, Fixed<2>>`
help: call `Into::into` on this expression to convert `Decimal<i32, Fixed<2>>` into `Decimal<i64, Fixed<2>>`
|
7 | let _sum = wide + narrow.into();
| +++++++
Products
A product’s scale is the sum of its factors’ decimals, so a product of two
decimals is exact. Its type is a Sum of the two scales,
which converts into the Fixed of their sum with its steps unchanged; a Fixed
of any other decimals fails the build:
fn a_product_takes_the_sum_of_its_factors_scales() {
use ddust::{D64, dec};
let (price, quantity): (D64<2>, D64<4>) = (dec!(1.25), dec!(0.0003));
let notional: D64<6> = (price * quantity).into();
assert_eq!(notional.steps(), 375, "1.25 × 0.0003 is 0.000375, exactly");
}
A product’s scale could be written in the type, Fixed<{ A + B }>, only with
the unstable and incomplete generic_const_exprs; Sum gives the same result
without it.