Skip to content

✨ Add rounding functions to Decimal #1030

Description

@LVMVRQUXL

🎯 Goal

Add rounding capabilities to the Decimal type, allowing users to:

  • reduce the number of fractional digits of a Decimal while staying within the decimal domain,
  • exit to the nearest Integer,
  • exit to platform floating-point types (Float, Double) for interoperability with systems that expect them (e.g. Protocol Buffers float fields, general-purpose Double usage).

πŸ“ Specifications

Common specifications

  • These functions MUST be available for all platforms.
  • Architecture Decision Records (see documentation/decisions directory) MAY be updated.
  • Changelog (see CHANGELOG.md file) MAY be updated.

Rounding mode

  • All rounding functions introduced in this ticket use HALF_UP (rounds half away from zero), hardcoded β€” no RoundingMode parameter is introduced.
  • This is a deliberate choice favoring the rounding behavior most users intuitively expect (the "school" convention) over statistical neutrality. A more rigorous alternative β€” HALF_EVEN ("banker's rounding"), which avoids the cumulative upward bias of HALF_UP β€” is the default in IEEE 754, Python's decimal module, and .NET's decimal type, but was set aside here in favor of intuitiveness for general usage.
  • This rationale must be documented in the KDoc of round() and roundToInteger(), to preempt questions from contributors familiar with Python's or .NET's conventions.
  • roundToFloat()/roundToDouble() (see below) don't follow this HALF_UP convention: they delegate to the platform's native, correctly-rounded decimal-string-to-binary-floating-point conversion, which uses IEEE 754's round-to-nearest-ties-to-even. This is intentional and shouldn't be "fixed" to HALF_UP β€” it's the universally expected behavior for this specific kind of conversion, distinct from the decimal-precision rounding round()/roundToInteger() perform.

round(fractionDigits: NonNegativeInteger): Decimal

  • Returns a Decimal with at most fractionDigits fractional digits β€” if the canonical representation of this decimal already has fewer fractional digits, it's returned unchanged.
  • The result goes through the same trailing-zero stripping (normalize()) as the existing arithmetic operators (plus, minus, times), for consistency.
  • Implemented entirely in commonMain via Integer arithmetic (div/rem on powers of ten), following the same approach as the existing scaleUpTo/normalize internals. No platform-specific decimal API is used or available β€” java.math.BigDecimal/RoundingMode is JVM-only and has no Kotlin/JS or Kotlin/Native equivalent.

roundToInteger(): Integer

  • Returns the Integer nearest to this decimal, using HALF_UP rounding.
  • Semantically equivalent to round(NonNegativeInteger.ZERO) followed by an exit to Integer, exposed as its own function because the return type differs.
  • Naming follows the roundToX convention used throughout this ticket for approximating boundary exits β€” as opposed to the toX convention (toInteger(), toLong()) reserved for conversions that are always exact when they succeed.

roundToFloat() / roundToDouble()

  • Two distinct floating-point exits, each justified independently:
    • Double is the general-purpose Kotlin floating-point type.
    • Float is required for interoperability with systems using 32-bit floats, such as Protocol Buffers float fields (chosen there for bandwidth efficiency, e.g. for proportions).
  • Implemented via the existing canonical toString() representation, delegating to the common-stdlib String.toFloat() / String.toDouble() extensions, which are correctly-rounded on all three platform targets (JVM, JS, Native) β€” no manual floating-point conversion logic is needed.
  • These functions throw ArithmeticException if the magnitude of this decimal exceeds the finite range representable by Float/Double β€” they never silently return Float.POSITIVE_INFINITY/Double.POSITIVE_INFINITY (or their negative counterparts). This mirrors Integer.toLong()'s behavior on overflow, and avoids propagating a silent error, which would conflict with this library's "correct by design" principle.
  • They keep the roundToX naming (rather than toX) deliberately: unlike toLong(), which returns the exact value whenever it doesn't throw, these functions return an approximation of this decimal on essentially every successful call β€” Float/Double cannot exactly represent most decimal values, regardless of magnitude. The roundToX prefix signals this approximation even on the success path, consistent with roundToInteger().
  • roundToFloatOrNull() / roundToDoubleOrNull() return null instead of throwing, mirroring Integer.toLongOrNull(). Like it, they're hidden from Java via @JvmSynthetic, since nullability isn't explicit in Java's type system.

🧩 API

File: subprojects/library/src/commonMain/kotlin/org/kotools/types/number/Decimal.kt

public class Decimal {
    // -------------------------- Rounding functions ---------------------------

    /**
     * Returns a [Decimal] representing this decimal rounded to at most
     * [fractionDigits] fractional digits, using HALF_UP rounding (rounds half
     * away from zero).
     */
    public fun round(fractionDigits: NonNegativeInteger): Decimal

    /**
     * Returns the [Integer] nearest to this decimal, using HALF_UP rounding
     * (rounds half away from zero).
     */
    public fun roundToInteger(): Integer

    /**
     * Returns an approximation of this decimal as a [Float], or throws
     * [ArithmeticException] if this decimal's magnitude is out of [Float]'s
     * range.
     */
    public fun roundToFloat(): Float

    /**
     * Returns an approximation of this decimal as a [Float], or returns
     * `null` if this decimal's magnitude is out of [Float]'s range.
     */
    public fun roundToFloatOrNull(): Float?

    /**
     * Returns an approximation of this decimal as a [Double], or throws
     * [ArithmeticException] if this decimal's magnitude is out of [Double]'s
     * range.
     */
    public fun roundToDouble(): Double

    /**
     * Returns an approximation of this decimal as a [Double], or returns
     * `null` if this decimal's magnitude is out of [Double]'s range.
     */
    public fun roundToDoubleOrNull(): Double?
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions