Hi all,
As promised, I've gathered some API- and policy-level tweaks that I think should be considered with the implementation-level renewal of Decimal. I'd like to pitch those here:
Revising certain behaviors of Decimal
- Proposal: SF-NNNN
- Authors: Xiaodi Wu
- Review Manager: TBD
- Status: Awaiting review
- Bug:
- Implementation: 1
- Review: (pitch)
Introduction
This document proposes to specify and regularize certain behaviors of the Decimal type and to deprecate some APIs retained from an earlier stage of Swift's evolution. Explicitly omitted are any changes in the type's representation or new APIs.
Motivation
Foundation.Decimal is Swift's existing general-purpose decimal floating-point type. It pre-dates Swift's floating-point protocols and differs from standard library binary floating-point types in fundamental respects: for example, Decimal does not represent infinity or negative zero.
Recent implementation work has substantially rewritten Decimal arithmetic, formatting, conversion, and normalization for performance and correctness. With that, it is now possible to deliver on certain behavior guarantees that were previously inconsistently honored or underspecified.
Proposed solution
Arithmetic rounding mode
Specify that Decimal arithmetic operations use .bankers rounding mode (i.e., round to nearest, ties to even) instead of .plain rounding mode.
This would be in line with the rounding convention for ordinary binary floating-point arithmetic (and also implicitly used already in some Decimal APIs). It is also what some users have said they expect for a decimal floating-point type for financial systems.
The change would not affect legacy NSDecimal* functions, which explicitly accept a rounding mode and continue to permit callers to request .plain or another mode.
Changing a default generally deserves considerable caution. Here, however, existing behavior is a particularly weak compatibility constraint: many arithmetic operations did not in fact round as documented (and/or did not have the precision necessary to do so). With recent improvements in correctness and precision, now is a key time to establish a consistent, desirable default.
Conversions between Double and Decimal
Specify explicit semantics for conversion in each direction.
There are (at least) two reasonable ways to convert a Double to a Decimal: the result can be either the nearest to the Double (at the full precision afforded by Decimal), or it can be the shortest (i.e., one with the least precision necessary, ties to nearest or even) for which the original value is the nearest Double.
It's possible that we will eventually want explicit APIs for both ways of converting. However, irrespective of hypothetical new APIs, there's an existing unlabeled converting initializer that's not going anywhere. Its current implementation doesn't convert precisely enough to align with either of the desired behaviors. A revised implementation that would improve on it needs to wait for a policy decision (this proposal) on what the behavior should be:
Decimal.init(_ value: Double) should give the shortest Decimal representation of value, just as value.description gives the shortest decimal string representation. This preserves a useful and intuitive relationship: when a user expresses a Double by its shortest roundtripping decimal representation, converting that value to Decimaldoesn't result in additional decimal digits that arise solely from the binary representation:
let x = 0.1
Decimal(x) == Decimal(string: x.description)
Decimal's float literal initializer would adopt the same semantics so that near-equivalent ways of constructing a Decimaldo not unexpectedly produce different values.
In the other direction, Decimal.doubleValue should give the nearest Double representation, such that Double-to-Decimal roundtrip conversion (unless the result is overlarge, etc.) parallels the lossless behavior of Double-to-Stringroundtrip conversion.
The idea is that these behaviors would be the least astonishing for most users.
Constants and floating-point classification
Correct some existing constants and floating-point classification properties.
Currently, Decimal.leastNonzeroMagnitude has exponent -127 when it should have exponent -128 (because that's the exponent of the actual least nonzero magnitude). The current state of affairs arose as a typo; it was corrected for a time in swift-corelibs-foundation but never synchronized in the Apple overlay, and it has now regressed for all platforms in swift-foundation.
Currently, Decimal.pi is rounded toward zero, in line with prior semantics for FloatingPoint.pi; it should now be rounded to nearest in tandem with SE-0552 adopting a revised rounding requirement for FloatingPoint.pi.
Subnormal values
Currently, Decimal classifies every finite nonzero value as normal, since it doesn't use an IEEE format. But, just like subnormal values in IEEE formats, finite nonzero values with sufficiently small exponent can only be represented using fewer significant digits than fit in the type's significand (mantissa) capacity.
It would be more useful to classify values in that reduced-precision range as subnormal. leastNormalMagnitude would then change to be (UInt128.max / 10 + 1) * 1e-128, and isSubnormal, isNormal, and floatingPointClass would be updated consistently.
(If this notion of subnormals is not adopted and no value is to be subnormal, leastNormalMagnitude must still be changed so that it continues to be equal to leastNonzeroMagnitude.)
Canonicality and total ordering
Two existing APIs require either corrected semantics or deprecation: isCanonical and isTotallyOrdered(belowOrEqualTo:).
Currently, isCanonical is true unconditionally. That's difficult to reconcile with the fact that Decimal has compact and (possibly multiple) non-compact encodings of the same value. It's rather misleading for an API named isCanonical to be true for different encodings of the same value, even if it's not an IEEE format.
Meanwhile, isTotallyOrdered(belowOrEqualTo:) doesn't actually provide a true total ordering over the complete set of valid Decimal values. If the API is to be retained, its behavior must be defined for all valid Decimal values, and the treatment of noncanonical or malformed representations must also be considered. Otherwise, deprecation is preferable to an operation that promises semantics it doesn't provide.
Other deprecations
Parts of Decimal's public API surface were added to align with early drafts of Swift's floating-point protocols. The standard library subsequently changed those protocols, abandoning certain spellings, but Decimal continued to retain those abandoned forms.
Deprecate APIs with abandoned spellings that are redundant, misleading, or both:
quietNaNin favor ofnanisSignalingin favor ofisSignalingNaNleastFiniteMagnitudein favor of-Decimal.greatestFiniteMagnitude— the constant actually gives the most negative finite value, which is not a (non-negative) magnitudeisEqual(to:),isLess(than:), andisLessThanOrEqualTo(_:)in favor of==,<, and<=add,subtract,multiply(by:), anddivide(by:)in favor of arithmetic operators
Source compatibility
Arithmetic results may differ where an operation requires rounding, but (as described above) users couldn't rely on accurate rounding in many cases. Except in midpoint cases, any results that were correctly rounded remain so, and all legacy NSDecimal* APIs retain their existing explicit rounding modes.
Conversion results would change where the old implementation failed to choose the shortest or nearest representation (as the case may be) accurately. The corrected constants and changes to subnormal classification would change for code that explicitly observes the relevant properties. These observable changes replace behavior that was insufficiently specified and/or incorrect with behavior that users can reason about and rely on.
It's unlikely that the APIs proposed for deprecation are in wide use, but in any case deprecation is source-compatible and directs users to more idiomatic spellings that are by now longstanding.
Implications on adoption
In that this proposal introduces no new features, implications on adoption are limited to the source compatibility concerns detailed above.
Future directions
API additions to expose additional facilities directly on the Decimal type, some not currently available at all and some only via legacy NSDecimal* APIs, can be the subject of future proposals. This might include, for example, arithmetic with explicit rounding mode and scale parameters, conversions to and from (U)Int128, a rounded(_:scale:) API, or additional rounding modes.
Alternatives considered
A broad alternative is to stick with .plain rounding as default, leave conversion behavior unspecified, and to maintain current constants and duplicative APIs, all on compatibility grounds. That is unattractive for Decimal because much of the behavior addressed here was never a coherent semantic contract: rounding was not implemented consistently, binary floating-point conversion behavior arose from implementation limitations, constants and floating-point classification APIs were inaccurate, and public members were retained from protocol designs subsequently abandoned.