Apparently writing 6k+ word novels is not allowed on this forum, so I had to split this reply into 4 separate posts. You may want to read it on GitHub.
github.com/LiarPrincess/Oh-my-decimal is about 95% of what you need. Almost any design of DecimalFloatingPoint should be a subset of what is implemented in this repository. Not tested on Apple silicon.
Vocabulary (oh-my-decimal uses the same names in code):
- standard - IEEE 754 2008
- standard 2019 - IEEE 754 2019
- signed exponent - human readable exponent, for example in 1E+3 = 1000the exponent is+3. Inoh-my-decimalit is represented asInttype.
- biased exponent - encoded exponent stored in decimal. In oh-my-decimalit is represented asBIDtype.
- DecimalFloatingPointRoundingRule- similar to- FloatingPointRoundingRule, but without- awayFromZero- not required by IEEE 754, not enough test cases to guarantee correctness.
- DecimalStatus- IEEE 754 flags:- isInvalidOperation,- isDivisionByZero,- isOverflow,- isUnderflow, and (most importantly)- isInexact. Most of the methods have it as a last argument:- status: inout DecimalStatus.
How this post works:
- each section starts with oh-my-decimalcode followed by discussion. All of the examples can be run usingoh-my-decimal.
- I will mix discussion about the protocol with my remarks to @mgriebling code from github.com/mgriebling/DecimalNumbers. It may get a little messy, but is it still a discussion about the implementation, so it is not off-topic. Note that their repository does not have a LICENSE file, so if they complain I will have to remove those parts.
- I am used to C# System.Decimal, so you may see some influences.
- oh-my-decimal contains DecimalStatus(container for IEEE flags). Judging by the design of theFloatingPointprotocol Swift will not need this. This changes A LOT.
Protocols
FloatingPoint
You can, but you do not get a lot from it.
We all know what the final Decimal type in Swift will conform to FloatingPoint, so the discussion here is a little bit pointless, but:
There are not a lot of functions that could work on Decimal/BinaryFloatingPoint (or Int if we talk about AdditiveArithmetic/Numeric) and be correct on all of them. Writing code that works on just Decimal is difficult, and as soon as you introduce multiplication (for example price * quantity) you almost always want to specialize to Decimal. Decimals have their own homogenous arithmetic, so you tend to use it exclusively instead of being generic.
In fact, in 99.9% of the cases you use only 1 Decimal type (most probably Decimal128), and specialize everything to it. You don't even use DecimalFloatingPoint, this protocol is more of a "decimal semantic" marker and a place where we declare common implementation (overloads with default rounding etc.). In binary floating point different types have different uses and tradeoffs: storage, speed, are we on CPU or GPU? For decimal you use 1 type for everything and never change it.
Not conforming to FloatingPoint would also allow you to support DecimalStatus.
As far as I know Foundation.Decimal does not conform to FloatingPoint. I'm not sure how many people complained about this fact.
I am not saying that the Decimal should not conform to FloatingPoint protocol. It can, but it is not nearly that useful.
AdditiveArithmetic, Numeric
Same story as FloatingPoint: you can, but you do not get a lot from it. You can be generic over DecimalFloatingPoint. But being generic over AdditiveArithmetic while using Decimal is a little bit weird.
ExpressibleByFloatLiteral
Swift converts to Float80/Double and then converts to a number.
This conversion may not be exact, so it is basically a random number generator.
If this gets fixed then: yes.
I see people proposing a new protocol for this, but can't we just use ExpressibleByFloatLiteral? We would need to add a new type to associatedtype FloatLiteralType: _ExpressibleByBuiltinFloatLiteral. This should be purely additive, because Float/Double/Float80 would use Float/Double/Float80 (same as currently - no changes).
The design of the new _ExpressibleByBuiltinFloatLiteral type is out of scope here, but you need to support compiler errors for isInexact and hex character sequences for (quote from standard):
5.4.3 Conversion operations for binary formats
Implementations shall provide the following formatOf conversion operations to and from all supported binary floating-point formats; these operations never propagate non-canonical floating-point results.
―formatOf-convertFromHexCharacter(hexCharacterSequence)
See 5.12 for details.
―hexCharacterSequence convertToHexCharacter(source, conversionSpecification)
See 5.12 for details. The conversionSpecification specifies the precision and formatting of the hexCharacterSequence result.
Or maybe that's not possible. I have not looked at this. Anyway, as long as the future ExpressibleByFloatLiteral can express Decimal then this discussion is completely orthogonal to the issue raised in this thread.
ExpressibleByIntegerLiteral
Oh-my-decimal has it.
Required by Numeric protocol, and as we know FloatingPoint requires Numeric.
Codable
Yes!
But how? We need to preserve sign/exponent/significant/cohort/payload/signaling bit etc. What do we do with non-canonical values?
Oh-my-decimal uses binary encoding (BID). We need to remember that receiver may not support parsing UInt128 - most of the languages stop at UInt64, the worst case would be if they tried to parse it as Double (ekhm… JavaScript). If we store each value as String then it should not be a problem -> they will fix it in post-processing. Why BID not DPD? It was easier for me.
| Decimal | Encoded positive | Encoded negative | 
|---|---|---|
| nan(0x123) | "2080375075" | "4227858723" | 
| snan(0x123) | "2113929507" | "4261413155" | 
| inf | "2013265920" | "4160749568" | 
| 0E-101 | "0" | "2147483648" | 
| 0E+0 | "847249408" | "2994733056" | 
| 0E+90 | "1602224128" | "3749707776" | 
| 1E-101 | "1" | "2147483649" | 
| 1000000E-101 | "1000000" | "2148483648" | 
| 9999999E+90 | "2012780159" | "4160263807" | 
We could also use unambiguous String representation (described below in "Decimal -> String" section), but:
- not canonical values will be encoded as 0- this matches the "shall not propagate non-canonical results" from the standard.
- parsing Decimalis slower thanInt- but probably fast enough that we can get away with it.
- different systems have different NaN payload encodings - not sure how much we care about them.
print(+Decimal64(nan: 0x123, signaling: false)) // nan(0x123)
print(-Decimal64(nan: 0x123, signaling: true)) // -snan(0x123)
print(+Decimal64.infinity) // inf
print(Decimal64.zero) // 0E+0
print(Decimal64.greatestFiniteMagnitude) // 9999999999999999E+369
print(Decimal64("123")!) // 123E+0
print(Decimal64("-123")!) // -123E+0
print(Decimal64("123E2")!) // 123E+2
print(Decimal64("12300E0")!) // 12300E+0, same value as above, different cohort
I'm not sure what is the correct answer for Swift. String representation is not a bad choice, even with all of its drawbacks.
@mgriebling I think you are missing Codable.
Sendable
Yes!
Fortunately this is not a problem, as long as UInt128 is also Sendable.
Note that using Decimals in Tasks/threads becomes more difficult if we store our rounding as a static property.
Though I would take static property over not having access to rounding at all - there is a quite popular Money type on github that has hard-coded nearestEven. THIS IS NOT LEGAL FOR EURO AND PLN (PLN = currency in Poland, obviously rounding mode is not connected to currency). I will not link to this repository.
Oh-my-decimal takes rounding as a method argument. This way we can just throw as many cores as we have at the problem. Hossam A. H. Fahmy test suite and oh-my-decimal-tests already take 10 min, and I don't even want to know how long they would take in serial execution.
You could also introduce context which stores the rounding method. Then every operation is done by context. Python allows you to do this. This is how oh-my-decimal-tests are written:
result = ctx_python.quantize(d, precision)
Another way is to store the rounding in thread local storage, but I would not recommend this. It makes for an awful programmer/user experience, and it breaks spectacularly with green threads.
I would say that having rounding as an argument is the safest choice. But this is how oh-my-decimal works, so I am biased. Official Swift Decimal may have some different needs. Or maybe we do not need rounding at all? Just quantize(to:rounding:status:) and round(_:status:), and that's it. I have seen such implementations.
@mgriebling I think you are missing Sendable.
Strideable
Oh-my-decimal does not conform to this protocol.
What is the distance between greatestFiniteMagnitude and leastNormalMagnitude? You can make it work, but I'm not sure if there is a clear use-case for this. I can't think of any.
Double has this, so we will probably also need it.
Random
Kind of weird for floating point. Apart from a few specific input ranges it would not do what user wants:
- simple random between 0 and 10 would be skewed towards smaller numbers because more of them are representable - tons of possible negative exponents.
- if we generated truly random (infinitely precise) value and rounded then bigger numbers would be more common - they have bigger ulp.
Oh-my-decimal does not have it.
Double does, and so should future Decimal type.
Properties
Static properties
static var nan: Self { get }
static var signalingNaN: Self { get }
static var infinity: Self { get }
static var pi: Self { get }
static var zero: Self { get }
static var leastNonzeroMagnitude: Self { get }
static var leastNormalMagnitude: Self { get }
static var greatestFiniteMagnitude: Self { get }
// Default on protocol:
static var radix: Int { 10 }
- 0- which cohort/sign/exponent? In Oh-my-decimal it is- 0E+0.
- leastNormalMagnitude- which cohort? In Oh-my-decimal:- Decimal64.leastNormalMagnitude = 1000000000000000E-398- all 'precision' digits filled = lowest exponent.
- greatestFiniteMagnitude- interestingly this one has to use- pack. You can't just use- Self.maxDecimalDigitsand- Self.maxExponentbecause- Decimal128does not need the- 11in combination bits (though this is an implementation detail).
- pishould be rounded- .towardZero.
For nerds we can have:
static var is754version1985: Bool { return false }
static var is754version2008: Bool { return true }
Oh-my-decimal defines this on DecimalFloatingPoint protocol - we know that all of our implementations share the same values. Swift should do it on each Decimal type separately (or just skip it). We can do this because Decimal is implemented in software. Idk if Swift guarantees that binary floating point operations conform to a particular version of the standard.
As for the @mgriebling implementation:
// mgriebling code
// https://github.com/mgriebling/DecimalNumbers/tree/main
public struct Decimal32 : Codable, Hashable {
  public static var greatestFiniteMagnitude: Self {
    Self(bid:ID(expBitPattern:ID.maxEncodedExponent, sigBitPattern:ID.largestNumber))
  }
  public static var leastNormalMagnitude: Self {
    Self(bid:ID(expBitPattern:ID.minEncodedExponent, sigBitPattern:ID.largestNumber))
  }
  public static var leastNonzeroMagnitude: Self {
    Self(bid: ID(expBitPattern: ID.minEncodedExponent, sigBitPattern: 1))
  }
  public static var pi: Self {
    Self(bid: ID(expBitPattern: ID.exponentBias-ID.maximumDigits+1, sigBitPattern: 3_141593))
  }
}
- 
leastNonzeroMagnitudeis basically1, so you don't have topack:- sign = 0 -> positive
- exponent = 0 -> 0 - Self.exponentBias
- significand = 1
 
- 
leastNormalMagnitudeis equalb^Emin = 10^Emin, try this:func test_xxx() { let lnm = Decimal32.leastNormalMagnitude let down = lnm.nextDown print(lnm, lnm.isNormal) // 9.999999e-95 true print(down, down.isNormal) // 9.999998e-95 true }We were leastNormalMagnitudeand we went toward0-> the 2nd line should befalse. You can use binary search to find the correctleastNormalMagnitude(it will converge quite fast) and then just encode it directly.
- 
pi- you can use this as a unit test (rounding has to be:.towardZero!):let string = "3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117067982148086513282306647" let expected = T(string, rounding: .towardZero, status: &status) XCTAssertEqual(…)
Properties
var isZero: Bool { get }
var isFinite: Bool { get }
var isInfinite: Bool { get }
var isCanonical: Bool { get }
var isNormal: Bool { get }
var isSubnormal: Bool { get }
var isNaN: Bool { get }
var isSignalingNaN: Bool { get }
var sign: FloatingPointSign { get }
var magnitude: Self { get }
// Default on protocol:
var floatingPointClass: FloatingPointClassification
- magnitude- if the input is non-canonical then the result should be canonical or not? Oh-my-decimal returns non-canonical (it just clears the bit).
Binary/decimal significand encoding
associatedtype BitPattern: UnsignedInteger & FixedWidthInteger
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Binary integer decimal.
var binaryEncoding: BitPattern { get }
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Densely packed decimal.
var decimalEncoding: BitPattern { get }
init(binaryEncoding: BitPattern)
init(decimalEncoding: BitPattern)
On Double we have:
let d = Double(bitPattern: UInt64)
let bitPattern: UInt64 = d.bitPattern
Oh-my-decimal uses the names defined by the standard. Though those names are only for the significand part, not the whole decimal bit pattern (emphasis mine):
3.5 Decimal interchange format encodings
c) For finite numbers, r is…
- If the implementation uses the decimal encoding for the significand…
- Alternatively, if the implementation uses the binary encoding for the significand, then…
(And also later in '5.5.2 Decimal re-encoding operations'.)
In Swift convention it would be more like: binaryEncodedBitPattern with the documentation pointing that the binary part is only for the significand.
In init oh-my-decimal will accept non-canonical values. They will be "corrected" on read, for example add function will read non-canonical as 0.
The use-cases for oh-my-decimal are:
- database does not support the given decimal format -> binaryEncoding->binary(16)
- function is not available on oh-my-decimal -> binaryEncoding-> Intel lib ->init(binaryEncoding:)
If we want to mirror BinaryFloatingPoint protocol:
protocol BinaryFloatingPoint {
  associatedtype RawSignificand: UnsignedInteger
  associatedtype RawExponent: UnsignedInteger
  var exponentBitPattern: RawExponent { get }
  var significandBitPattern: RawSignificand { get }
  init(sign: FloatingPointSign,
       exponentBitPattern: RawExponent,
       significandBitPattern: RawSignificand)
}
- exponentBitPatternis always the same.
- significandBitPatterndepends on binary/decimal encoding.
I will let the others do the name bike-shedding (maybe binaryEncodedSignificandBitPattern would be better, but it is worse for code completion):
associatedtype RawSignificand: UnsignedInteger
associatedtype RawExponent: UnsignedInteger
var exponentBitPattern: RawExponent { get }
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Binary integer decimal.
var significandBinaryEncodedBitPattern: RawSignificand { get }
/// IEEE-754: 5.5.2 Decimal re-encoding operations.
/// Densely packed decimal.
var significandDecimalEncodedBitPattern: RawSignificand { get }
init(sign: FloatingPointSign,
     exponentBitPattern: RawExponent,
     significandBinaryBitPattern: RawSignificand)
init(sign: FloatingPointSign,
     exponentBitPattern: RawExponent,
     significandDecimalBitPattern: RawSignificand)
Oh-my-decimal is on the @taylorswift side of the whole binaryEncoding debate. Though I was thinking about SQL (because that is what I'm used to when dealing with decimals), not databases with BSON backed.
From my experience 95+% of business operations contain either database read or write:
- 
if your database supports IEEE 754 (@taylorswift asked about BSON which supports Decimal128 using BID): // Read // oh-my-decimal naming let raw: UInt128 = row.column[2] let decimal = Decimal128(binaryEncoding: raw) // Write var row: DatabaseRow = … row.column[2] = decimal.binaryEncodingYou don't have to do anything! The decimal package already supports the conversion in both directions. You can't beat 0 lines of code, and we know that more lines of code = more bugs. 
- 
if you database does not support IEEE 754: - use the decimal type supported by your database. For example MSSQL + C# = System.Decimal. This has nothing to do with the Decimaltype that we are discussing, because here you are forced to use the decimal type defined by your database driver.
- store Decimalusingbinarycolumn type -> goes back to the "your database supports IEEE 754" point.
 
- use the decimal type supported by your database. For example MSSQL + C# = System.Decimal. This has nothing to do with the 
Btw. if we are worrying about the next Float80 situation then this field does not need to be on the protocol, it can be on the type, remember that most of the time you use only 1 Decimal type in the whole app. We do not have UInt80 (for Float80), but we have UInt32, UInt64 and UInt128. For the Decimal128 in the beginning it would be UInt128 from the decimal package - maybe under some different name (Decimal128.BitPattern?) and without all of the Int operations (you do not need zeroBitCount etc.). At some point before the final release it would switch to UInt128 from stdlib (if we ever get one).
This is exactly what Double does - in addition to exponentBitPattern and significandBitPattern required by BinaryFloatingPoint we have:
let d = Double(bitPattern: UInt64)
let bitPattern: UInt64 = d.bitPattern
// For Float80 it is not available:
Float80.nan.bitPattern (trigger code completion)
-> exponentBitPattern
-> significandBitPattern
Obviously if you want then you can add associatedtype BitPattern: UnsignedInteger (with/without FixedWidthInteger) to DecimalFloatingPoint protocol, just like oh-my-decimal. Though @scanon already addressed this:
It does not, because FixedWidthInteger has all sorts of protocol requirements beyond being a bag of bits that can be used to build a decimal. There's no need to be able to divide bit patterns of decimal numbers, or count their leading zeros.
Are those things easier to implement than decimal arithmetic? Yes. Does requiring that they be implemented impose some additional burden on the author of a decimal type? Also yes.
I'm 100% against:
if the sign, raw exponent, and raw significand are each encodable and decodable (as they would be since they'd be of primitive types as per your link), then so too would be any type that's an aggregate of those three properties. In the simplest implementation, then, you would encode and decode a floating-point value in that manner.
(…)
This would also have the desirable (or, even essential) effect of allowing you to store bit patterns in any IEEE interchange format independent of what types are supported by the platform.
Storing Decimal as a separate sign, exponent, and significand is not space efficient. Also: why?
Combining the "sign, raw exponent, and raw significand" into a 128-bit pattern (most space efficient encoding) is way too complicated to put into the user-land. You need to know the right shifts, handle non canonical values, NaN payloads. On top of that the significand becomes extremely messy if you do not have UInt128.
I don't fully understand the 2nd paragraph. An example would be very helpful, it is very vague to the point that it says nothing.
- 
are we talking about the next Float80situation? The whole#if (arch(i386) || arch(x86_64)) && !os(Windows) && !os(Android)? Is there any practical example forDecimal?
- 
are we talking about situation similar to what @taylorswift described? I believe that this is a quite common scenario -> 2 people (me + @taylorswift) had the same problem and we came to the same conclusion. In this case you can just convert using init:// We have value in Decimal64 let d64: Decimal64 = … // Our platform/database only supports Decimal128 let d128 = Decimal128(d64) // Always exact because 128 > 64 let bitPattern = d128.binaryEncoding // Or some other nameIf you want to manually change the decimal format then you have to re-bias the exponent, which is non-trivial because of overflows. Don't even think about writing converter to a smaller format outside of the Decimalpackage - way too complicated.
Double already has bitPattern property, so there is a precedence. It is just a matter of having UInt128. I would argue that there is a use case for having an access to the whole bitPattern of a Decimal type. It does not have to be on the DecimalFloatingPoint protocol, can be on type.


 They also have lots of hardcoded numbers and obscure look-up tables, many of which have been replaced.
 They also have lots of hardcoded numbers and obscure look-up tables, many of which have been replaced.