В предыдущем разделе мы рассматривали внутреннее нюансы устройства float и лишь косвенно затронули проблемы, которые могут возникать при работе с подобными числами.
Обратите внимание, что статья является базовым обзором на ограничения float типов в Go.
Если в какой-то момент вам покажется, что float ,практически бесполезный тип, то это не так.
Типы данных float32 и float64 отличный выбор для: научных вычислений, графиков, приближённых вычисленияй, и другим задачам, где малые отклонения не будут являться критичными.
Go по умолчанию определяет число с плавающей точкой как тип float64.
a := 0.1
fmt.Printf("%T\n", a) // float64Напомним, что под капотом находится стандарт IEEE 754, который определяет, что числа с плавающей точкой должны храниться в двоичной форме: мантисса + порядок. Это приводит к тому, что мы не можем точно представить точную запись для многих дробных числа.
Например, дробь 1/3 не имеет конечного представление в десятичной системе, мы получим 0.(3)₁₀ (3 в периоде)
А в бинарной системе счисление десятичная дробь 0.1 не будет иметь конечного представления:
0.1₁₀ = 0.0001100110011001100110011…₂ = 0.(00011)₂
Все описанное выше приводит к неожиданным результатам.
Потеря точности и округление
Рассмотрим следующую программу:
package main
import "fmt"
func main() {
a := 0.1
b := 0.2
c := 0.3
fmt.Println(a+b == c) // false
fmt.Printf("a: %.17g \n", a) // 0.10000000000000001
fmt.Printf("b: %.17g \n", b) // 0.20000000000000001
fmt.Printf("c: %.17g \n", c) // 0.29999999999999999
fmt.Printf("a + b: %.17g \n", a+b) // 0.30000000000000004
}
Если вы удивлены выводом, то давайте разберемся, подробнее, что происходит.
- Объяснение
- Вывод
- Код
Так как float не может точно хранить все десятичные дроби. Округление происходит при каждой операции. Таким образом мы получаем результаты выше.
Внимательный читатель заметит разницу между инициализацией дробей 0.1, 0.2 и 0.3.
Это происходит по той причине различных двоичных приближений этих дробей. Важно отметить, что каждое из этих чисел не имеет точное двоичное приближение.
0.1
float64: 0.10000000000000001
hex: 0x3fb999999999999a
fmt(%.17g): 0.10000000000000001
exact decimal: 0.1000000000000000055511151231257827021181583404541015625
0.2
float64: 0.20000000000000001
hex: 0x3fc999999999999a
fmt(%.17g): 0.20000000000000001
exact decimal: 0.200000000000000011102230246251565404236316680908203125
0.3
float64: 0.29999999999999999
hex: 0x3fd3333333333333
fmt(%.17g): 0.29999999999999999
exact decimal: 0.299999999999999988897769753748434595763683319091796875
0.1 + 0.2
float64: 0.30000000000000004
hex: 0x3fd3333333333334
fmt(%.17g): 0.30000000000000004
exact decimal: 0.3000000000000000444089209850062616169452667236328125
sum = a + b
float64: 0.29999999999999999
hex: 0x3fd3333333333333
fmt(%.17g): 0.29999999999999999
exact decimal: 0.299999999999999988897769753748434595763683319091796875package main
import (
"fmt"
"math"
"math/big"
)
func dumpFloat64(label string, f float64) {
// 1. hex-представление (как в IEEE 754)
bits := math.Float64bits(f)
hex := fmt.Sprintf("0x%016x", bits)
// 2. точное десятичное значение через big.Float
// 256 бит точности — достаточно, чтобы отобразить все цифры
bf := new(big.Float).SetPrec(256).SetFloat64(f)
dec := bf.Text('f', -1) // вывод всех значащих цифр
// 3. формат Go "%.17g"
g := fmt.Sprintf("%.17g", f)
fmt.Printf("%s\n", label)
fmt.Printf(" float64: %.17g\n", f)
fmt.Printf(" hex: %s\n", hex)
fmt.Printf(" fmt(%%.17g): %s\n", g)
fmt.Printf(" exact decimal: %s\n", dec)
fmt.Println()
}
func main() {
a, b, c := 0.1, 0.2, 0.3
values := []struct {
label string
val float64
}{
{"0.1", a},
{"0.2", b},
{"0.3", c},
}
for _, v := range values {
dumpFloat64(v.label, v.val)
}
// отдельно продемонстрируем 0.1 + 0.2
dumpFloat64("0.1 + 0.2", a+b)
// отдельно продемонстрируем sum
sum := 0.1 + 0.2
dumpFloat64("sum", sum)
}
В примере выше, обратите внимание, что, что в Go 0.1 и 0.2 — это untyped floating-point constants, это не float32 и не float64, а константы с произвольной точностью.
Что это обозначает на практике?
// В данном случае компилятор выполняет операцию на произвольной точности и только после этого преобразовывает к float64
sum := 0.1 + 0.2 // arbitrary-precision константы → float64
// Если мы сделаем так, то компилятор Go сначала приведет дроби к float64 и только после этого сделает сложение
a := 0.1
b := 0.2
sum := a + b // арифметика float64Если вы не до конца понимаете, что происходит в этих примерах, не переживайте, более подробно мы рассмотрим эти нюансы в практических задачах далее.
Что делать, чтобы избежать этих проблем?
Для решения проблемы потери точности и округления числа применяются следующие решения:
“math/big”
- Использовать библиотеку “math/big”, и типы big.Float или
big.Rat(рациональные числа) — подробности работы с этой библиотеками и типами данных мы рассмотрим в отдельных материалах.
Использовать целые числа (Fixed-point)
Плюсы:
- Абсолютно точное сложение
Минусы:
- Точность вычислений поддерживается в ручном режиме
package main
import "fmt"
func main() {
// Деньги в копейках
priceCents := 12345 // 123.45 рубля
taxCents := 6789 // 67.89 рубля
totalCents := priceCents + taxCents
fmt.Printf("Total: %d копеек = %.2f рубля\n", totalCents, float64(totalCents)/100) // Total: 19134 копеек = 191.34 рубля
}Использовать округление
Плюсы:
- Простое решение
Минусы:
- Является маскировкой симптомов, а не полноценным решением
package main
import (
"fmt"
"math"
)
func main() {
x := 0.1 + 0.2
// Округление до 2 знаков после запятой
rounded := math.Round(x*100) / 100
fmt.Println("Rounded:", rounded) // 0.3
}Использовать epsilon
Плюсы:
- Простое решение
Минусы:
- Является маскировкой симптомов, а не полноценным решением
package main
import (
"fmt"
"math"
)
func main() {
x := 0.1 + 0.2
// Проверка с epsilon
const eps = 1e-9
if math.Abs(x-0.3) < eps {
fmt.Println("x ≈ 0.3")
}
}Избегать прямого сравнения float
Плюсы:
- Простое решение
- Позволяет получать более предсказуемые сравнения в if условиях
Минусы:
- Является маскировкой симптомов, а не полноценным решением
package main
import (
"fmt"
"math"
)
func almostEqual(a, b float64) bool {
const eps = 1e-9
return math.Abs(a-b) < eps
}
func main() {
x := 0.1 + 0.2
y := 0.3
if almostEqual(x, y) {
fmt.Println("x ≈ y")
} else {
fmt.Println("x != y")
}
}
Подведем итоги:
| Подход | Ограничения |
| Сравнение float | Никогда не делайте a == b с float. Используйте ε‑сравнение |
| Fixed-point | Хороший подход для финансов: храните деньги в целых единицах (центах, копейках), чтобы избежать ошибок округления. |
| big.Float, big.Rat | Позволяет точные вычисления, но математические операции занимают в разы больше времени. |
| float64 | Отлично подходит для «приближённых вычислений». При этом важно понимать ограничения и использовать эпсилон подход при сравнении значений. |
В статье мы рассмотрели фундаментальные проблемы работы с вещественными числами в Go. Далее мы детальнее рассмотрим более сложные и интересные темы: стабильное суммирование, компенсацию погрешности, нюансы при больших числах, денормалы, “ulp”, ошибки при преобразовании, “catastrophic cancellation” и др.
Leave a Reply