Skip to content

Commit

Permalink
feat: add conversion to float32 and float64
Browse files Browse the repository at this point in the history
The only uses case that can overflow is when a float64 is stored in a float32
  • Loading branch information
ccoVeille committed Dec 14, 2024
1 parent cbcae8f commit 992666e
Show file tree
Hide file tree
Showing 5 changed files with 335 additions and 1 deletion.
8 changes: 8 additions & 0 deletions asserters.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ func sameSign[T1, T2 Number](a T1, b T2) bool {
func getUpperBoundary(value any) any {
var upper any = math.Inf(1)
switch value.(type) {
case float64:
upper = float64(math.MaxFloat64)
case float32:
upper = float32(math.MaxFloat32)

Check warning on line 19 in asserters.go

View check run for this annotation

Codecov / codecov/patch

asserters.go#L16-L19

Added lines #L16 - L19 were not covered by tests
case int8:
upper = int8(math.MaxInt8)
case int16:
Expand Down Expand Up @@ -41,6 +45,10 @@ func getUpperBoundary(value any) any {
func getLowerBoundary(value any) any {
var lower any = math.Inf(-1)
switch value.(type) {
case float64:
lower = float64(-math.MaxFloat64)
case float32:
lower = float32(-math.MaxFloat32)

Check warning on line 51 in asserters.go

View check run for this annotation

Codecov / codecov/patch

asserters.go#L48-L51

Added lines #L48 - L51 were not covered by tests
case int64:
lower = int64(math.MinInt64)
case int32:
Expand Down
48 changes: 48 additions & 0 deletions asserters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,51 @@ func assertUintError[in safecast.Number](t *testing.T, tests []caseUint[in]) {
})
}
}

type caseFloat32[in safecast.Number] struct {
name string
input in
want float32
}

func assertFloat32OK[in safecast.Number](t *testing.T, tests []caseFloat32[in]) {
t.Helper()

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := safecast.ToFloat32(tt.input)
assertNoError(t, err)
assertEqual(t, tt.want, got)
})
}
}

func assertFloat32Error[in safecast.Number](t *testing.T, tests []caseFloat32[in]) {
t.Helper()

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := safecast.ToFloat32(tt.input)
requireErrorIs(t, err, safecast.ErrConversionIssue)
assertEqual(t, tt.want, got)
})
}
}

type caseFloat64[in safecast.Number] struct {
name string
input in
want float64
}

func assertFloat64OK[in safecast.Number](t *testing.T, tests []caseFloat64[in]) {
t.Helper()

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := safecast.ToFloat64(tt.input)
assertNoError(t, err)
assertEqual(t, tt.want, got)
})
}
}
55 changes: 54 additions & 1 deletion conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,41 @@ import (
func convertFromNumber[NumOut Number, NumIn Number](orig NumIn) (converted NumOut, err error) {
converted = NumOut(orig)

// floats could be compared directly
switch any(converted).(type) {
case float64:
// float64 cannot overflow, so we don't have to worry about it
return converted, nil
case float32:
origFloat64, isFloat64 := any(orig).(float64)
if !isFloat64 {
// only float64 can overflow float32
// everything else can be safely converted
return converted, nil
}

// check boundary
if math.Abs(origFloat64) < math.MaxFloat32 {
// the value is within float32 range, there is no overflow
return converted, nil
}

// TODO: check for numbers close to math.MaxFloat32

boundary := float32(math.MaxFloat32)
errBoundary := ErrExceedMaximumValue
if negative(orig) {
boundary = -boundary
errBoundary = ErrExceedMinimumValue
}

return 0, Error{
value: orig,
err: errBoundary,
boundary: boundary,
}
}

errBoundary := ErrExceedMaximumValue
boundary := getUpperBoundary(converted)
if negative(orig) {
Expand All @@ -27,6 +62,9 @@ func convertFromNumber[NumOut Number, NumIn Number](orig NumIn) (converted NumOu
}
}

// convert back to the original type
cast := NumIn(converted)
// and compare
base := orig
switch f := any(orig).(type) {
case float64:
Expand All @@ -35,7 +73,8 @@ func convertFromNumber[NumOut Number, NumIn Number](orig NumIn) (converted NumOu
base = NumIn(math.Trunc(float64(f)))
}

if NumIn(converted) == base {
// exact match
if cast == base {
return converted, nil
}

Expand Down Expand Up @@ -115,3 +154,17 @@ func ToInt64[T Number](i T) (int64, error) {
func ToUint64[T Number](i T) (uint64, error) {
return convertFromNumber[uint64](i)
}

// ToFloat32 attempts to convert any [Number] value to a float32.
// If the conversion results in a value outside the range of a float32,
// an [ErrConversionIssue] error is returned.
func ToFloat32[T Number](i T) (float32, error) {
return convertFromNumber[float32](i)
}

// ToFloat64 attempts to convert any [Number] value to a float64.
// If the conversion results in a value outside the range of a float64,
// an [ErrConversionIssue] error is returned.
func ToFloat64[T Number](i T) (float64, error) {
return convertFromNumber[float64](i)
}
197 changes: 197 additions & 0 deletions conversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1280,3 +1280,200 @@ func TestToUint(t *testing.T) {
})
})
}

func TestToFloat32(t *testing.T) {
t.Run("from int", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[int]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int8", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[int8]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int16", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[int16]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int32", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[int32]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int64", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[int64]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[uint]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint8", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[uint8]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint16", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[uint16]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint32", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[uint32]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint64", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[uint64]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from float32", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[float32]{
{name: "zero", input: 0.0, want: 0.0},
{name: "rounded value", input: 1.1, want: 1.1},
{name: "negative within range", input: -100.9, want: -100.9},
{name: "positive within range", input: 100.9, want: 100.9},
})
})

t.Run("from float64", func(t *testing.T) {
assertFloat32OK(t, []caseFloat32[float64]{
{name: "zero", input: 0.0, want: 0.0},
{name: "negative zero", input: math.Copysign(0, -1), want: -0},
{name: "almost zero", input: math.SmallestNonzeroFloat32, want: 1e-45},
{name: "almost negative zero", input: -math.SmallestNonzeroFloat32, want: -1e-45},
{name: "negative within range", input: -100.9, want: -100.9},
{name: "positive within range", input: 100.9, want: 100.9},
{name: "with imprecision due to conversion", input: 2.67428e+28, want: 2.67428e+28},
})

assertFloat32Error(t, []caseFloat32[float64]{
{name: "out of range max float32", input: math.MaxFloat32 * 1.02}, // because of float imprecision, we have to exceed the min int64 to trigger the error
{name: "out of range min float32", input: -math.MaxFloat32 * 1.02}, // because of float imprecision, we have to exceed the min int64 to trigger the error
})
})
}

func TestToFloat64(t *testing.T) {
t.Run("from int", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[int]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int8", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[int8]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int16", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[int16]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int32", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[int32]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from int64", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[int64]{
{name: "zero", input: 0, want: 0.0},
{name: "negative within range", input: -100, want: -100.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[uint]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint8", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[uint8]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint16", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[uint16]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint32", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[uint32]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from uint64", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[uint64]{
{name: "zero", input: 0, want: 0.0},
{name: "positive within range", input: 100, want: 100.0},
})
})

t.Run("from float32", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[float64]{
{name: "zero", input: 0.0, want: 0.0},
{name: "rounded value", input: 1.1, want: 1.1},
{name: "negative within range", input: -100.9, want: -100.9},
{name: "positive within range", input: 100.9, want: 100.9},
})
})

t.Run("from float64", func(t *testing.T) {
assertFloat64OK(t, []caseFloat64[float64]{
{name: "zero", input: 0.0, want: 0.0},
{name: "negative within range", input: -100.9, want: -100.9},
{name: "positive within range", input: 100.0, want: 100.0},
})
})
}
28 changes: 28 additions & 0 deletions examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,31 @@ func ExampleToUint() {
// 42 <nil>
// 0 conversion issue: -1 (int8) is less than 0 (uint): minimum value for this type exceeded
}

func ExampleToFloat32() {
a := int8(42)
i, err := safecast.ToFloat32(a)
fmt.Println(i, err)

b := math.MaxFloat64
i, err = safecast.ToFloat32(b)
fmt.Println(i, err)

// Output:
// 42 <nil>
// 0 conversion issue: 1.7976931348623157e+308 (float64) is greater than 3.4028235e+38 (float32): maximum value for this type exceeded
}

func ExampleToFloat64() {
a := int8(42)
i, err := safecast.ToFloat64(a)
fmt.Println(i, err)

b := math.MaxFloat64
i, err = safecast.ToFloat64(b)
fmt.Println(i, err)

// Output:
// 42 <nil>
// 1.7976931348623157e+308 <nil>
}

0 comments on commit 992666e

Please sign in to comment.