Skip to content

Commit

Permalink
cty: Define our number range more precisely
Browse files Browse the repository at this point in the history
Our documentation and implementation were previously pretty loose about
what _exactly_ the intended range of cty.Number is. Although internally
we store it as a *big.Float, most practical uses involve decimal string
representations of numbers, such as JSON serialization.

We'll now be fully explicit that we only expect to preserve precision for
the subset of numbers that are within the range of _both_ our in-memory
representation and of decimal string approximations of those values. This
was a practical constraint for many cases before anyway, but is now a
real documented constraint.

Along with this, the definition of "Equals" for numbers now honors the
definition by treating two numbers as equal only if they would have an
equal JSON serialization. cty can in principle be serialized in other
formats, including msgpack built-in, but JSON is the canonical
serialization in recognition of the fact that cty is typically used to
represent values destined for JSON-based network APIs.
  • Loading branch information
apparentlymart committed Nov 2, 2021
1 parent e5d3f15 commit f0fc9bc
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 9 deletions.
45 changes: 45 additions & 0 deletions cty/primitive_type.go
Expand Up @@ -52,6 +52,51 @@ func (t primitiveType) GoString() string {
}
}

// rawNumberEqual is our cty-specific definition of whether two big floats
// underlying cty.Number are "equal" for the purposes of the Value.Equals and
// Value.RawEquals methods.
//
// The built-in equality for big.Float is a direct comparison of the mantissa
// bits and the exponent, but that's too precise a check for cty because we
// routinely send numbers through decimal approximations and back and so
// we only promise to accurately represent the subset of binary floating point
// numbers that can be derived from a decimal string representation.
//
// In respect of the fact that cty only tries to preserve numbers that can
// reasonably be written in JSON documents, we use the string representation of
// a decimal approximation of the number as our comparison, relying on the
// big.Float type's heuristic for discarding extraneous mantissa bits that seem
// likely to only be there as a result of an earlier decimal-to-binary
// approximation during parsing, e.g. in ParseNumberVal.
func rawNumberEqual(a, b *big.Float) bool {
switch {
case (a == nil) != (b == nil):
return false
case a == nil: // b == nil too then, due to previous case
return true
default:
// This format and precision matches that used by cty/json.Marshal,
// and thus achieves our definition of "two numbers are equal if
// we'd use the same JSON serialization for both of them".
const format = 'f'
const prec = -1
aStr := a.Text(format, prec)
bStr := b.Text(format, prec)

// The one exception to our rule about equality-by-stringification is
// negative zero, because we want -0 to always be equal to +0.
const posZero = "0"
const negZero = "-0"
if aStr == negZero {
aStr = posZero
}
if bStr == negZero {
bStr = posZero
}
return aStr == bStr
}
}

// Number is the numeric type. Number values are arbitrary-precision
// decimal numbers, which can then be converted into Go's various numeric
// types only if they are in the appropriate range.
Expand Down
2 changes: 1 addition & 1 deletion cty/value_ops.go
Expand Up @@ -191,7 +191,7 @@ func (val Value) Equals(other Value) Value {

switch {
case ty == Number:
result = val.v.(*big.Float).Cmp(other.v.(*big.Float)) == 0
result = rawNumberEqual(val.v.(*big.Float), other.v.(*big.Float))
case ty == Bool:
result = val.v.(bool) == other.v.(bool)
case ty == String:
Expand Down
48 changes: 46 additions & 2 deletions cty/value_ops_test.go
Expand Up @@ -44,6 +44,51 @@ func TestValueEquals(t *testing.T) {
NumberIntVal(2),
BoolVal(true),
},
{
NumberIntVal(2),
NumberFloatVal(2.2),
BoolVal(false),
},
{
NumberFloatVal(2.0),
NumberFloatVal(2.2),
BoolVal(false),
},
{
MustParseNumberVal("0.0"),
MustParseNumberVal("-0.0"), // a statically-generated negative zero
BoolVal(true),
},
{
NumberFloatVal(0.0),
NumberFloatVal(0.0).Multiply(NumberIntVal(-1)), // a dynamically-generated negative zero
BoolVal(true),
},
{
MustParseNumberVal("3.14159265358979323846264338327950288419716939937510582097494459"),
MustParseNumberVal("3.14159265358979323846264338327950288419716939937510582097494459"),
BoolVal(true),
},
{
MustParseNumberVal("-3.14159265358979323846264338327950288419716939937510582097494459"),
MustParseNumberVal("-3.14159265358979323846264338327950288419716939937510582097494459"),
BoolVal(true),
},
{
MustParseNumberVal("3.14159265358979323846264338327950288419716939937510582097494459"),
MustParseNumberVal("-3.14159265358979323846264338327950288419716939937510582097494459"),
BoolVal(false),
},
{
MustParseNumberVal("1.2"),
NumberFloatVal(1.2),
BoolVal(true),
},
{
MustParseNumberVal("1.22222"),
NumberFloatVal(1.22222),
BoolVal(true),
},

// Strings
{
Expand Down Expand Up @@ -1812,11 +1857,10 @@ func TestValueMultiply(t *testing.T) {
NumberFloatVal(12345),
MustParseNumberVal("11941607769527758779715454277313298036253933804947715"),
},
//
{
NumberFloatVal(22337203685475.5),
NumberFloatVal(22337203685475.5),
MustParseNumberVal("498950668486420259929661100.25"),
MustParseNumberVal("498950668486420259929661100.2"),
},
}

Expand Down
33 changes: 27 additions & 6 deletions docs/types.md
Expand Up @@ -29,13 +29,24 @@ value.

### `cty.Number`

The number type represents arbitrary-precision floating point numbers.

Since numbers are arbitrary-precision, there is no need to worry about
integer overflow/underflow or loss of precision during arithmetic operations.
However, eventually a calling application will probably want to convert a
The number type represents what we'll clumsily call "JSON numbers".
Technically, this means the set of numbers that have a canonical decimal
representation in our JSON encoding _and_ that can be represented in memory
with 512 bits of binary floating point precision.

Since these numbers have high precision, there is little need to worry about
integer overflow/underflow or over-zealous rounding during arithmetic
operations. In particular, `cty.Number` can represent the full range of
`int64` with no loss. However, numbers _are_ still finite in memory and subject
to approximation in binary-to-decimal and decimal-to-binary conversions, and so
can't accurately represent _all_ real numbers.

Eventually a calling application will probably want to convert a
number to one of the Go numeric types, at which point its range will be
constrained to fit within that type, generating an error if it does not fit.
Because the number range is larger than all of the Go integer types, it's
always possible to convert a whole number to a Go integer without any loss,
as long as it value is within the required range.

The following additional operations are supported on numbers:

Expand All @@ -60,10 +71,17 @@ The following additional operations are supported on numbers:
`cty.Number` values can be constructed using several different factory
functions:

* `NumberVal` creates a number value from a `*big.Float`, from the `math/big` package.
* `ParseNumberVal` creates a number value by parsing a decimal representation
of it given as a string. This is the constructor that most properly
represents the full documented range of number values; the others below
care convenient for many cases, but have a more limited range.
* `NumberIntVal` creates a number value from a native `int64` value.
* `NumberUIntVal` creates a number value from a native `uint64` value.
* `NumberFloatVal` creates a number value from a native `float64` value.
* `NumberVal` creates a number value from a `*big.Float`, from the `math/big` package.
This can preserve arbitrary big floats without modification, but comes
at the risk of introducing precision inconsisistencies. Prefer the other
constructors for most uses.

The core API only allows extracting the value from a known number as a
`*big.Float` using the `AsBigFloat` method. However,
Expand All @@ -79,6 +97,9 @@ The following numbers are provided as package variables for convenience:
* `cty.NegativeInfinity` represents negative infinity as a number. All other
numbers are greater than this value.

Note that the two infinity values are always out of range for a conversion to
any Go primitive integer type.

### `cty.String`

The string type represents a sequence of unicode codepoints.
Expand Down

0 comments on commit f0fc9bc

Please sign in to comment.