2.2 Проблемы при работе с вещественными числами

В предыдущем разделе мы рассматривали внутреннее нюансы устройства 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.299999999999999988897769753748434595763683319091796875

package 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” и др.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *