From 3d627e2c328f993afe228964a195691c9302e642 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 25 Mar 2026 23:57:43 +0300 Subject: [PATCH] math: add the decimal library (fixes #25010) --- vlib/math/decimal/README.md | 23 +++ vlib/math/decimal/decimal.v | 256 +++++++++++++++++++++++++++++++ vlib/math/decimal/decimal_test.v | 86 +++++++++++ 3 files changed, 365 insertions(+) create mode 100644 vlib/math/decimal/README.md create mode 100644 vlib/math/decimal/decimal.v create mode 100644 vlib/math/decimal/decimal_test.v diff --git a/vlib/math/decimal/README.md b/vlib/math/decimal/README.md new file mode 100644 index 000000000..72b16dee5 --- /dev/null +++ b/vlib/math/decimal/README.md @@ -0,0 +1,23 @@ +## Description + +`math.decimal` provides arbitrary-precision fixed-point decimal arithmetic. +It stores values as an integer coefficient plus a decimal scale, so addition, +subtraction, and multiplication stay exact. + +Division is exposed through `div_prec`, which rounds half up to a caller-chosen +number of decimal places. The `/` operator uses +`decimal.default_division_precision`. + +## Example + +```v +import math.decimal + +price := decimal.from_string('19.99')! +fee := decimal.from_string('1.50')! +total := price + fee +assert total.str() == '21.49' + +share := total.div_prec(decimal.from_int(3), 2)! +assert share.str() == '7.16' +``` diff --git a/vlib/math/decimal/decimal.v b/vlib/math/decimal/decimal.v new file mode 100644 index 000000000..6f288bc62 --- /dev/null +++ b/vlib/math/decimal/decimal.v @@ -0,0 +1,256 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module decimal + +import math.big +import strings + +// default_division_precision is used by the `/` operator when the quotient +// needs a rounded decimal expansion. +pub const default_division_precision = 16 + +// zero is the canonical zero value for Decimal. +pub const zero = Decimal{ + coefficient_value: big.zero_int + scale_value: 0 +} + +// Decimal represents an arbitrary-precision fixed-point decimal value. +// Its numeric value is `coefficient * 10^-scale`. +pub struct Decimal { + coefficient_value big.Integer + scale_value int +} + +// new creates a decimal from `coefficient * 10^-scale`. +pub fn new(coefficient big.Integer, scale int) Decimal { + if scale < 0 { + panic('math.decimal: scale cannot be negative') + } + return normalize(coefficient, scale) +} + +// from_int creates a decimal from an int. +pub fn from_int(value int) Decimal { + return Decimal{ + coefficient_value: big.integer_from_int(value) + scale_value: 0 + } +} + +// from_i64 creates a decimal from an i64. +pub fn from_i64(value i64) Decimal { + return Decimal{ + coefficient_value: big.integer_from_i64(value) + scale_value: 0 + } +} + +// from_u64 creates a decimal from a u64. +pub fn from_u64(value u64) Decimal { + return Decimal{ + coefficient_value: big.integer_from_u64(value) + scale_value: 0 + } +} + +// from_string parses a decimal string without exponent notation. +pub fn from_string(input string) !Decimal { + value := input.trim_space() + if value.len == 0 { + return error('math.decimal: empty input') + } + mut negative := false + mut seen_dot := false + mut digit_count := 0 + mut scale := 0 + mut digits := strings.new_builder(value.len) + for index, ch in value { + if index == 0 && (ch == `+` || ch == `-`) { + negative = ch == `-` + continue + } + if ch >= `0` && ch <= `9` { + digits.write_u8(ch) + digit_count++ + if seen_dot { + scale++ + } + continue + } + if ch == `.` && !seen_dot { + seen_dot = true + continue + } + return error('math.decimal: invalid decimal value `${input}`') + } + if digit_count == 0 { + return error('math.decimal: invalid decimal value `${input}`') + } + mut coefficient_string := digits.str() + if negative { + coefficient_string = '-' + coefficient_string + } + coefficient := big.integer_from_string(coefficient_string)! + return new(coefficient, scale) +} + +// coefficient returns the integer coefficient stored by `d`. +pub fn (d Decimal) coefficient() big.Integer { + return d.coefficient_value +} + +// scale returns the number of decimal places stored by `d`. +pub fn (d Decimal) scale() int { + return d.scale_value +} + +// is_zero returns true when `d == 0`. +pub fn (d Decimal) is_zero() bool { + return d.coefficient_value.signum == 0 +} + +// abs returns the absolute value of `d`. +pub fn (d Decimal) abs() Decimal { + return Decimal{ + coefficient_value: d.coefficient_value.abs() + scale_value: d.scale_value + } +} + +// neg returns the negated value of `d`. +pub fn (d Decimal) neg() Decimal { + return Decimal{ + coefficient_value: d.coefficient_value.neg() + scale_value: d.scale_value + } +} + +// str returns the canonical decimal representation of `d`. +pub fn (d Decimal) str() string { + if d.coefficient_value.signum == 0 { + return '0' + } + if d.scale_value == 0 { + return d.coefficient_value.str() + } + mut prefix := '' + if d.coefficient_value.signum < 0 { + prefix = '-' + } + absolute := d.coefficient_value.abs().str() + if d.scale_value >= absolute.len { + padding := strings.repeat(`0`, d.scale_value - absolute.len) + return '${prefix}0.${padding}${absolute}' + } + split := absolute.len - d.scale_value + return '${prefix}${absolute[..split]}.${absolute[split..]}' +} + +// + returns the exact sum of `left` and `right`. +pub fn (left Decimal) + (right Decimal) Decimal { + scale := max_scale(left.scale_value, right.scale_value) + return new(left.rescaled_coefficient(scale) + right.rescaled_coefficient(scale), scale) +} + +// - returns the exact difference of `left` and `right`. +pub fn (left Decimal) - (right Decimal) Decimal { + scale := max_scale(left.scale_value, right.scale_value) + return new(left.rescaled_coefficient(scale) - right.rescaled_coefficient(scale), scale) +} + +// * returns the exact product of `left` and `right`. +pub fn (left Decimal) * (right Decimal) Decimal { + return new(left.coefficient_value * right.coefficient_value, left.scale_value + + right.scale_value) +} + +// / divides `dividend` by `divisor` using `default_division_precision`. +pub fn (dividend Decimal) / (divisor Decimal) Decimal { + return dividend.div_prec(divisor, default_division_precision) or { panic(err) } +} + +// div_prec divides `dividend` by `divisor` and rounds half up to `precision` +// digits after the decimal point. +pub fn (dividend Decimal) div_prec(divisor Decimal, precision int) !Decimal { + if precision < 0 { + return error('math.decimal: precision cannot be negative') + } + if divisor.coefficient_value.signum == 0 { + return error('math.decimal: cannot divide by zero') + } + mut quotient, remainder := scaled_div_mod(dividend, divisor, precision) + if remainder.signum != 0 { + twice_remainder := remainder * big.two_int + if !(twice_remainder < scaled_divisor(dividend, divisor)) { + quotient += big.one_int + } + } + if dividend.coefficient_value.signum * divisor.coefficient_value.signum < 0 { + quotient = quotient.neg() + } + return new(quotient, precision) +} + +// == returns true when `left` and `right` represent the same value. +pub fn (left Decimal) == (right Decimal) bool { + return left.scale_value == right.scale_value + && left.coefficient_value == right.coefficient_value +} + +// < returns true when `left` is smaller than `right`. +pub fn (left Decimal) < (right Decimal) bool { + scale := max_scale(left.scale_value, right.scale_value) + return left.rescaled_coefficient(scale) < right.rescaled_coefficient(scale) +} + +fn normalize(coefficient big.Integer, scale int) Decimal { + if coefficient.signum == 0 { + return zero + } + mut normalized_coefficient := coefficient + mut normalized_scale := scale + for normalized_scale > 0 { + quotient, remainder := normalized_coefficient.div_mod(big.c10) + if remainder.signum != 0 { + break + } + normalized_coefficient = quotient + normalized_scale-- + } + return Decimal{ + coefficient_value: normalized_coefficient + scale_value: normalized_scale + } +} + +fn (d Decimal) rescaled_coefficient(scale int) big.Integer { + if scale <= d.scale_value || d.coefficient_value.signum == 0 { + return d.coefficient_value + } + return d.coefficient_value * pow10(scale - d.scale_value) +} + +fn pow10(exponent int) big.Integer { + if exponent <= 0 { + return big.one_int + } + return big.c10.pow(u32(exponent)) +} + +fn scaled_divisor(dividend Decimal, divisor Decimal) big.Integer { + return divisor.coefficient_value.abs() * pow10(dividend.scale_value) +} + +fn scaled_div_mod(dividend Decimal, divisor Decimal, precision int) (big.Integer, big.Integer) { + numerator := dividend.coefficient_value.abs() * pow10(divisor.scale_value + precision) + return numerator.div_mod(scaled_divisor(dividend, divisor)) +} + +fn max_scale(a int, b int) int { + if a > b { + return a + } + return b +} diff --git a/vlib/math/decimal/decimal_test.v b/vlib/math/decimal/decimal_test.v new file mode 100644 index 000000000..884dd6973 --- /dev/null +++ b/vlib/math/decimal/decimal_test.v @@ -0,0 +1,86 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +import math.big +import math.decimal + +fn must_decimal(value string) decimal.Decimal { + return decimal.from_string(value) or { panic(err) } +} + +fn assert_parse_error(value string, expected string) { + decimal.from_string(value) or { + assert err.msg() == expected + return + } + assert false +} + +fn assert_div_error(dividend string, divisor string, precision int, expected string) { + must_decimal(dividend).div_prec(must_decimal(divisor), precision) or { + assert err.msg() == expected + return + } + assert false +} + +fn test_new_and_getters() { + value := decimal.new(big.integer_from_string('123400') or { panic(err) }, 4) + assert value.str() == '12.34' + assert value.coefficient().str() == '1234' + assert value.scale() == 2 +} + +fn test_from_ints() { + assert decimal.from_int(42).str() == '42' + assert decimal.from_i64(-99).str() == '-99' + assert decimal.from_u64(123456789).str() == '123456789' +} + +fn test_from_string_normalizes_trailing_zeroes() { + assert must_decimal('00123.4500').str() == '123.45' + assert must_decimal('-0.5000').str() == '-0.5' + assert must_decimal('.25').str() == '0.25' + assert must_decimal('5.').str() == '5' + assert must_decimal('0.000').is_zero() +} + +fn test_from_string_rejects_invalid_input() { + assert_parse_error('', 'math.decimal: empty input') + assert_parse_error('abc', 'math.decimal: invalid decimal value `abc`') + assert_parse_error('1.2.3', 'math.decimal: invalid decimal value `1.2.3`') + assert_parse_error('+-1', 'math.decimal: invalid decimal value `+-1`') + assert_parse_error('.', 'math.decimal: invalid decimal value `.`') +} + +fn test_add_sub_and_mul() { + assert (must_decimal('1.23') + must_decimal('4.5')).str() == '5.73' + assert (must_decimal('4.5') - must_decimal('1.23')).str() == '3.27' + assert (must_decimal('12.5') * must_decimal('0.04')).str() == '0.5' + assert (must_decimal('9999999999999999999999999999.99') + must_decimal('0.01')).str() == '10000000000000000000000000000' +} + +fn test_neg_abs_and_compare() { + assert must_decimal('-1.20').neg().str() == '1.2' + assert must_decimal('-1.20').abs().str() == '1.2' + assert must_decimal('1.20') == must_decimal('1.2') + assert must_decimal('-2') < must_decimal('-1.99') + assert must_decimal('0.009') < must_decimal('0.01') +} + +fn test_div_prec_rounds_half_up() { + assert must_decimal('1').div_prec(must_decimal('3'), 2)!.str() == '0.33' + assert must_decimal('1').div_prec(must_decimal('6'), 2)!.str() == '0.17' + assert must_decimal('-1').div_prec(must_decimal('6'), 2)!.str() == '-0.17' + assert must_decimal('10').div_prec(must_decimal('4'), 2)!.str() == '2.5' +} + +fn test_div_operator_uses_default_precision() { + assert (must_decimal('1') / must_decimal('8')).str() == '0.125' + assert (must_decimal('1') / must_decimal('3')).str() == '0.3333333333333333' +} + +fn test_div_prec_errors() { + assert_div_error('1', '0', 2, 'math.decimal: cannot divide by zero') + assert_div_error('1', '2', -1, 'math.decimal: precision cannot be negative') +} -- 2.39.5