diff --git a/decimal-go.go b/decimal-go.go index 9958d690..08984f5b 100644 --- a/decimal-go.go +++ b/decimal-go.go @@ -2,6 +2,13 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build !go1.13 +// +build !go1.13 + +// This file is only built for Go versions older than 1.13. From Go 1.13 on, +// strconv's own shortest-float conversion is correct (golang/go#29491) and +// newFromFloat delegates to it instead. See newfromfloat_go113.go. + // Multiprecision decimal numbers. // For floating-point formatting only; not general purpose. // Only operations are assign and (binary) left/right shift. diff --git a/decimal.go b/decimal.go index ee554d4c..0f649a67 100644 --- a/decimal.go +++ b/decimal.go @@ -329,7 +329,7 @@ func NewFromFloat(value float64) Decimal { if value == 0 { return New(0, 0) } - return newFromFloat(value, math.Float64bits(value), &float64info) + return newFromFloat(value, 64) } // NewFromFloat32 converts a float32 to Decimal. @@ -346,55 +346,7 @@ func NewFromFloat32(value float32) Decimal { if value == 0 { return New(0, 0) } - // XOR is workaround for https://github.com/golang/go/issues/26285 - a := math.Float32bits(value) ^ 0x80808080 - return newFromFloat(float64(value), uint64(a)^0x80808080, &float32info) -} - -func newFromFloat(val float64, bits uint64, flt *floatInfo) Decimal { - if math.IsNaN(val) || math.IsInf(val, 0) { - panic(fmt.Sprintf("Cannot create a Decimal from %v", val)) - } - exp := int(bits>>flt.mantbits) & (1<>(flt.expbits+flt.mantbits) != 0 - - roundShortest(&d, mant, exp, flt) - // If less than 19 digits, we can do calculation in an int64. - if d.nd < 19 { - tmp := int64(0) - m := int64(1) - for i := d.nd - 1; i >= 0; i-- { - tmp += m * int64(d.d[i]-'0') - m *= 10 - } - if d.neg { - tmp *= -1 - } - return Decimal{value: big.NewInt(tmp), exp: int32(d.dp) - int32(d.nd)} - } - dValue := new(big.Int) - dValue, ok := dValue.SetString(string(d.d[:d.nd]), 10) - if ok { - return Decimal{value: dValue, exp: int32(d.dp) - int32(d.nd)} - } - - return NewFromFloatWithExponent(val, int32(d.dp)-int32(d.nd)) + return newFromFloat(float64(value), 32) } // NewFromFloatWithExponent converts a float64 to Decimal, with an arbitrary diff --git a/newfromfloat_go113.go b/newfromfloat_go113.go new file mode 100644 index 00000000..2d723eb8 --- /dev/null +++ b/newfromfloat_go113.go @@ -0,0 +1,70 @@ +//go:build go1.13 +// +build go1.13 + +package decimal + +import ( + "fmt" + "math" + "math/big" + "strconv" +) + +// newFromFloat converts val to a Decimal holding the shortest decimal digit +// string that round-trips back to val when parsed at the given bit size. +// +// strconv already computes exactly that string, so we ask it for the value in +// 'e' format with precision -1 and read the digits back out. The 'e' layout is +// [-]d[.ddd]e±dd, which is bounded (at most 24 bytes) and trivial to scan, +// unlike 'f' which can run to over 750 bytes for extreme exponents. +func newFromFloat(val float64, bitSize int) Decimal { + if math.IsNaN(val) || math.IsInf(val, 0) { + panic(fmt.Sprintf("Cannot create a Decimal from %v", val)) + } + + var buf [32]byte + b := strconv.AppendFloat(buf[:0], val, 'e', -1, bitSize) + + neg := b[0] == '-' + if neg { + b = b[1:] + } + + // Split the significand from the exponent. b always contains an 'e'. + var i int + for b[i] != 'e' { + i++ + } + digits, expDigits := b[:i], b[i+1:] + + // The shortest round-trip form has at most 17 significant digits for a + // float64 and 9 for a float32, so the significand always fits in an int64. + var mant int64 + nd := 0 + for _, c := range digits { + if c == '.' { + continue + } + mant = mant*10 + int64(c-'0') + nd++ + } + if neg { + mant = -mant + } + + esign := 1 + switch expDigits[0] { + case '-': + esign = -1 + expDigits = expDigits[1:] + case '+': + expDigits = expDigits[1:] + } + e := 0 + for _, c := range expDigits { + e = e*10 + int(c-'0') + } + + // digits is d.ddd, i.e. the significand scaled by 10^(nd-1). + return Decimal{value: big.NewInt(mant), exp: int32(esign*e - nd + 1)} +} diff --git a/newfromfloat_go113_test.go b/newfromfloat_go113_test.go new file mode 100644 index 00000000..e4923ac6 --- /dev/null +++ b/newfromfloat_go113_test.go @@ -0,0 +1,114 @@ +//go:build go1.13 +// +build go1.13 + +package decimal + +import ( + "math" + "math/rand" + "strconv" + "testing" +) + +// TestNewFromFloatShortestRoundTrip pins the contract of NewFromFloat and +// NewFromFloat32: the result is the shortest decimal that parses back to the +// original float. It covers the exponent and significand shapes a +// shortest-representation conversion has to get right, namely subnormals, +// three-digit exponents, single-digit significands and the range extremes. +// +// It is gated on Go 1.13 because before that strconv itself could return a +// shortest form that was not the nearest one (golang/go#29491), which would make +// the reference value below wrong rather than the conversion under test. +func TestNewFromFloatShortestRoundTrip(t *testing.T) { + f64 := []float64{ + 1, -1, 0.1, -0.1, 0.5, 2.5, 100, 123.456, + 1e15, 1e16, 1e17, 1e21, 1e22, 1e23, 9007199254740992, + 1e-100, 1e100, 1e-300, 1e300, + math.MaxFloat64, -math.MaxFloat64, + math.SmallestNonzeroFloat64, -math.SmallestNonzeroFloat64, + math.Pi, math.E, + } + // Every power of two, which sweeps the whole exponent range including the + // subnormals, plus both neighbours of every value collected so far. + for e := -1074; e <= 1023; e++ { + f64 = append(f64, math.Ldexp(1, e)) + } + for _, f := range append([]float64{}, f64...) { + f64 = append(f64, math.Nextafter(f, math.Inf(1)), math.Nextafter(f, math.Inf(-1))) + } + + for _, f := range f64 { + if f == 0 || math.IsInf(f, 0) { + continue + } + want, err := NewFromString(strconv.FormatFloat(f, 'f', -1, 64)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + got := NewFromFloat(f) + if !got.Equal(want) { + t.Errorf("NewFromFloat(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + if back, _ := got.Float64(); back != f { + t.Errorf("NewFromFloat(%v).Float64() = %v, does not round-trip", f, back) + } + } + + f32 := []float32{ + 1, -1, 0.1, -0.1, 100, 123.456, + math.MaxFloat32, -math.MaxFloat32, + math.SmallestNonzeroFloat32, -math.SmallestNonzeroFloat32, + } + for e := -149; e <= 127; e++ { + f32 = append(f32, float32(math.Ldexp(1, e))) + } + for _, f := range f32 { + if f == 0 { + continue + } + want, err := NewFromString(strconv.FormatFloat(float64(f), 'f', -1, 32)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + got := NewFromFloat32(f) + if !got.Equal(want) { + t.Errorf("NewFromFloat32(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + } +} + +// TestNewFromFloatRandomRoundTrip sweeps random bit patterns, which puts a +// meaningful share of subnormals and extreme exponents through the conversion. +func TestNewFromFloatRandomRoundTrip(t *testing.T) { + rng := rand.New(rand.NewSource(0xdead1337)) + for i := 0; i < 200000; i++ { + f := math.Float64frombits(rng.Uint64()) + if f == 0 || math.IsNaN(f) || math.IsInf(f, 0) { + continue + } + want, err := NewFromString(strconv.FormatFloat(f, 'f', -1, 64)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + if got := NewFromFloat(f); !got.Equal(want) { + t.Fatalf("NewFromFloat(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + } + for i := 0; i < 200000; i++ { + f := math.Float32frombits(rng.Uint32()) + if f == 0 || math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) { + continue + } + want, err := NewFromString(strconv.FormatFloat(float64(f), 'f', -1, 32)) + if err != nil { + t.Fatalf("NewFromString(%v): %v", f, err) + } + if got := NewFromFloat32(f); !got.Equal(want) { + t.Fatalf("NewFromFloat32(%v) = %s (%s, %d), want %s (%s, %d)", + f, got, got.value, got.exp, want, want.value, want.exp) + } + } +} diff --git a/newfromfloat_legacy.go b/newfromfloat_legacy.go new file mode 100644 index 00000000..eafca0f1 --- /dev/null +++ b/newfromfloat_legacy.go @@ -0,0 +1,76 @@ +//go:build !go1.13 +// +build !go1.13 + +package decimal + +import ( + "fmt" + "math" + "math/big" +) + +// newFromFloat converts val to a Decimal holding the shortest decimal digit +// string that round-trips back to val when parsed at the given bit size. +// +// This is the pre-Go 1.13 implementation: it computes the shortest form itself, +// using the vendored copy of strconv's multiprecision decimal (decimal-go.go and +// rounding.go), because strconv.FormatFloat could return a shortest form that is +// not the nearest one before Go 1.13 (golang/go#29491). +func newFromFloat(val float64, bitSize int) Decimal { + if math.IsNaN(val) || math.IsInf(val, 0) { + panic(fmt.Sprintf("Cannot create a Decimal from %v", val)) + } + + var bits uint64 + var flt *floatInfo + if bitSize == 32 { + // XOR is workaround for https://github.com/golang/go/issues/26285 + a := math.Float32bits(float32(val)) ^ 0x80808080 + bits = uint64(a) ^ 0x80808080 + flt = &float32info + } else { + bits = math.Float64bits(val) + flt = &float64info + } + + exp := int(bits>>flt.mantbits) & (1<>(flt.expbits+flt.mantbits) != 0 + + roundShortest(&d, mant, exp, flt) + // If less than 19 digits, we can do calculation in an int64. + if d.nd < 19 { + tmp := int64(0) + m := int64(1) + for i := d.nd - 1; i >= 0; i-- { + tmp += m * int64(d.d[i]-'0') + m *= 10 + } + if d.neg { + tmp *= -1 + } + return Decimal{value: big.NewInt(tmp), exp: int32(d.dp) - int32(d.nd)} + } + dValue := new(big.Int) + dValue, ok := dValue.SetString(string(d.d[:d.nd]), 10) + if ok { + return Decimal{value: dValue, exp: int32(d.dp) - int32(d.nd)} + } + + return NewFromFloatWithExponent(val, int32(d.dp)-int32(d.nd)) +} diff --git a/rounding.go b/rounding.go index d4b0cd00..427dbff0 100644 --- a/rounding.go +++ b/rounding.go @@ -2,6 +2,13 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build !go1.13 +// +build !go1.13 + +// This file is only built for Go versions older than 1.13. From Go 1.13 on, +// strconv's own shortest-float conversion is correct (golang/go#29491) and +// newFromFloat delegates to it instead. See newfromfloat_go113.go. + // Multiprecision decimal numbers. // For floating-point formatting only; not general purpose. // Only operations are assign and (binary) left/right shift.