golang

Float в Go: что должен понимать разработчик, чтобы не ловить странные баги

  • среда, 6 мая 2026 г. в 00:00:12
https://habr.com/ru/articles/1027040/

С типом float рано или поздно сталкивается почти любой разработчик. Сначала все выглядит просто. Есть float32, есть float64, можно хранить дробные числа, делить, умножать, считать проценты, средние значения, коэффициенты и что угодно еще. Кажется, что это просто «числа с точкой».

Но именно здесь у многих начинаются странные баги.

Почему 0.1 + 0.2 != 0.3? Почему после серии вычислений число внезапно становится 9.99999999997 вместо 10? Почему сравнение двух значений с float64 иногда работает, а иногда ломает логику? Почему в деньгах float почти всегда плохая идея? И почему даже корректная формула может давать нестабильный результат?

Проблема не в Go. Проблема в том, что float устроен не так, как его часто себе представляют. Это не «точное дробное число», а приближенное представление вещественных чисел в памяти компьютера.

В этой статье разберем, как устроен float в Go, где он полезен, где опасен, какие ошибки встречаются чаще всего и какие практики помогают не ловить неприятные сюрпризы в проде.

Почему float вообще существует

Если бы компьютеры умели хранить любые вещественные числа точно и без ограничений, отдельной темы бы не было. Но память конечна, а множество вещественных чисел бесконечно. Поэтому компьютер хранит не «настоящее число из математики», а его конечное приближение.

Для целых чисел это выглядит привычно. Например, int хранит целое значение в фиксированном числе битов. Для дробных значений нужен другой механизм, потому что числа могут быть очень маленькими, очень большими и содержать дробную часть.

Именно для этого используются числа с плавающей точкой, или floating point numbers.

Смысл в том, что число хранится примерно так:

sign × mantissa × base^exponent

В двоичной системе это позволяет представлять очень широкий диапазон значений. Именно поэтому float64 умеет хранить и довольно маленькие, и очень большие числа. Но за эту гибкость приходится платить точностью.

Какие float есть в Go

В Go есть два основных типа для чисел с плавающей точкой:

var a float32
var b float64

На практике почти всегда используется float64.

Потому что float64 дает заметно более высокую точность, а стоимость памяти в большинстве прикладных задач не настолько критична, чтобы ради нее массово переходить на float32.

Грубо говоря, float32 подходит там, где важнее экономия памяти или пропускная способность, например в графике, обработке больших массивов чисел, некоторых ML‑задачах или low‑level вычислениях. В обычной backend‑разработке, аналитике, сервисной логике и большинстве библиотечного кода почти всегда разумнее брать float64.

Почему 0.1 нельзя представить точно

Вот главная мысль, которую нужно усвоить раз и навсегда: многие десятичные дроби нельзя точно представить в двоичном виде.

Точно так же, как дробь 1/3 в десятичной системе выглядит как бесконечная периодическая запись 0.333333..., число 0.1 в двоичной системе тоже превращается в бесконечную дробь. А компьютер не может хранить бесконечную запись. Он хранит только конечное приближение.

Из‑за этого число 0.1 в памяти уже не является идеальным математическим 0.1. Это очень близкое значение, но не точное.

Поэтому такой код:

package main

import "fmt"

func main() {
	fmt.Println(0.1 + 0.2)
}

может вывести не строго 0.3, а что‑то вроде:

0.30000000000000004

В Go формат вывода часто делает результат визуально аккуратнее, но проблема никуда не исчезает. Она просто не всегда бросается в глаза.

Демонстрация главной проблемы

Посмотрим на простой пример:

package main

import "fmt"

func main() {
	fmt.Println(0.1 + 0.2 == 0.3)
}

Многие ожидают true, но на практике получают false.

Почему? Потому что левая часть и правая часть представлены в памяти как разные приближенные значения. Они очень близки, но не обязаны совпадать бит в бит.

Это одна из самых частых ошибок при работе с float: ожидание точного равенства там, где возможна только приблизительная близость.

Как устроен float64

Не обязательно учить стандарт IEEE 754 наизусть, но понимать основу полезно.

float64 использует 64 бита. Из них один бит уходит на знак, часть на порядок, часть на мантиссу. Мантисса отвечает за значащие цифры, порядок отвечает за масштаб числа.

Из этого следуют три практических вывода.

Первый вывод состоит в том, что float64 хранит число не абсолютно точно, а с ограниченной точностью.

Второй вывод в том, что точность зависит от масштаба. Чем больше число по модулю, тем грубее становится шаг между соседними представимыми значениями.

Третий вывод в том, что далеко не каждая арифметическая операция сохраняет ожидаемую человеческую точность.

Разработчику не обязательно помнить количество битов в каждом поле каждый день, но важно понимать сам принцип: float64 это компромисс между диапазоном и точностью.

Почему сравнение float через ==опасно

Сравнение через == для float не всегда ошибка, но почти всегда повод подумать еще раз.

Вот классический антипример:

func isEqual(a, b float64) bool {
	return a == b
}

Если значения получены в результате вычислений, такое сравнение может ломаться даже тогда, когда числа по смыслу одинаковы.

Гораздо безопаснее сравнивать числа с допуском:

package main

import (
	"fmt"
	"math"
)

func almostEqual(a, b, eps float64) bool {
	return math.Abs(a-b) < eps
}

func main() {
	fmt.Println(almostEqual(0.1+0.2, 0.3, 1e-9))
}

Здесь мы не требуем точного побитового совпадения. Мы проверяем, что разница между числами достаточно мала.

Это гораздо ближе к тому, как с float нужно работать в реальной жизни.

Что такое epsilon и почему он важен

epsilon или eps это небольшой допуск, с которым мы считаем числа практически равными.

Идея простая. Если два значения отличаются на крошечную величину, которая несущественна для нашей задачи, мы считаем их равными.

Но здесь есть тонкость. Нельзя выбрать один «магический» eps, который подойдет для всех задач.

Например, допуск 1e-9 может быть нормальным для геометрии в одних масштабах и совершенно неподходящим для финансовых расчетов или чисел порядка 1e12.

Поэтому хороший инженерский подход такой: допуск нужно выбирать в контексте предметной области.

Если вы работаете с расстояниями в метрах, это один масштаб. Если с процентами, другой. Если с научными вычислениями, третий.

Абсолютная и относительная погрешность

Когда мы сравниваем числа с float, полезно различать два подхода.

Абсолютная погрешность проверяет, что разница между числами по модулю меньше фиксированного порога:

math.Abs(a-b) < eps

Это хорошо работает, когда значения находятся примерно в одном масштабе и сам масштаб невелик.

Но если числа очень большие, фиксированный eps может быть слишком грубым или слишком строгим. Тогда полезнее относительная погрешность, когда мы сравниваем ошибку относительно величины самих чисел.

Пример более устойчивой проверки:

package main

import (
	"math"
)

func almostEqualRelative(a, b, eps float64) bool {
	diff := math.Abs(a - b)
	scale := math.Max(math.Abs(a), math.Abs(b))

	if scale == 0 {
		return diff < eps
	}

	return diff/scale < eps
}

Такой подход лучше учитывает масштаб значений.

На практике иногда используют смешанную стратегию: учитывать и абсолютную, и относительную погрешность.

Почему порядок операций может менять результат

В математике мы привыкли, что если выражение эквивалентно, то результат тот же. В вычислениях с float это не всегда так.

Посмотрим на пример:

package main

import "fmt"

func main() {
	a := 1e16
	b := 1.0
	c := -1e16

	fmt.Println((a + b) + c)
	fmt.Println(a + (b + c))
}

Результаты могут отличаться.

Почему? Потому что промежуточные значения округляются. Когда к очень большому числу прибавляется маленькое, маленькое может просто «потеряться» из‑за ограниченной точности. После этого дальнейшие вычисления уже идут с искаженным промежуточным результатом.

Это важная мысль: вычисления с float не всегда ассоциативны так, как в обычной математике.

Для разработчика отсюда следует практический вывод: при длинных цепочках вычислений и особенно при суммировании большого числа значений стоит думать о численной устойчивости.

Ошибка накопления при суммировании

Одна из самых частых практических проблем с float связана с накоплением ошибки.

Например, вы суммируете большое количество дробных значений:

package main

import "fmt"

func main() {
	var sum float64
	for i := 0; i < 1000000; i++ {
		sum += 0.1
	}
	fmt.Println(sum)
}

Интуитивно ожидается 100000.0, но реальный результат может немного отличаться.

Причина в том, что ошибка представления 0.1 повторяется снова и снова, а затем накапливается.

Если задача чувствительна к точности, такое суммирование стоит делать осторожнее.

Более устойчивое суммирование

Для некоторых задач полезно использовать более устойчивые алгоритмы суммирования, например алгоритм Кэхэна.

Пример:

package main

import "fmt"

func kahanSum(nums []float64) float64 {
	var sum float64
	var c float64

	for _, x := range nums {
		y := x - c
		t := sum + y
		c = (t - sum) - y
		sum = t
	}

	return sum
}

func main() {
	nums := make([]float64, 1000000)
	for i := range nums {
		nums[i] = 0.1
	}

	fmt.Println(kahanSum(nums))
}

Не во всех прикладных сервисах нужен именно такой уровень аккуратности, но важно знать, что «просто складывать в цикле» не всегда лучший вариант, если точность критична.

Почему float плохо подходит для денег

Это один из самых важных практических пунктов.

Деньги нельзя безопасно хранить в float, если вам нужна точность на уровне копеек, центов и бухгалтерской логики.

Почему? Потому что float хранит число приближенно. А в финансовой логике ошибка на одну копейку уже может быть проблемой. Особенно если операции массовые и ошибки накапливаются.

Плохой вариант:

type Account struct {
	Balance float64
}

Такой код может работать на игрушечных примерах, но в реальной финансовой логике быстро становится источником проблем.

Надежнее хранить деньги в минимальных целых единицах. Например, не рубли, а копейки:

type Account struct {
	BalanceKopecks int64
}

Тогда 1050 означает 10 рублей 50 копеек. Все операции становятся точными на уровне целых чисел.

Если предметная область сложнее и нужна высокая точность для десятичных расчетов, используют decimal‑библиотеки, а не float64.

Когда float использовать нормально

После всех предупреждений может показаться, что float это почти всегда зло. Это не так.

float64 абсолютно нормален там, где допустима небольшая погрешность и где модель данных сама по себе является непрерывной или приближенной.

Например, float подходит для:

расчетов в графике и геометрии, научных и инженерных вычислений, статистики, ML, работы с коэффициентами, вероятностями, средними значениями, физическими моделями, метриками, если небольшая погрешность допустима.

Если вы работаете с температурой, координатами, весами, скоростями, вероятностями или аналитическими коэффициентами, float64 обычно вполне уместен.

Ключевой вопрос всегда один: допустима ли в вашей задаче небольшая погрешность?

Если да, float64 нормален. Если нет, нужно искать другой тип данных.

float32 или float64

На практике ответ в большинстве случаев простой: берите float64, если у вас нет очень понятной причины брать float32.

Почему float64 обычно лучше:

у него выше точность, он безопаснее для большинства вычислений, он чаще используется в стандартных библиотеках и научных пакетах, он снижает риск неожиданных ошибок из‑за потери точности.

Когда может быть нужен float32:

когда важна экономия памяти, когда работаете с большими массивами чисел, когда данные приходят именно в таком формате, когда задача и так допускает заметную погрешность, когда есть требования от конкретного API или GPU‑пайплайна.

Но если вы не уверены, float64 почти всегда лучший выбор по умолчанию.

Особые значения: NaN, +Inf, ‑Inf

Еще одна тема, которую нельзя игнорировать при работе с float, это специальные значения.

В Go, как и в IEEE 754, существуют:

  • NaN — not a number

  • +Inf — положительная бесконечность

  • -Inf — отрицательная бесконечность

Они могут появляться в вычислениях, например:

package main

import (
	"fmt"
	"math"
)

func main() {
	fmt.Println(0.0 / 0.0)
	fmt.Println(1.0 / 0.0)
	fmt.Println(-1.0 / 0.0)

	fmt.Println(math.NaN())
	fmt.Println(math.Inf(1))
	fmt.Println(math.Inf(-1))
}

Практически это важно потому, что такие значения могут незаметно «протечь» через систему и испортить дальнейшие вычисления.

Особенно коварен NaN. У него есть необычное свойство: NaN != NaN.

То есть такой код:

x := math.NaN()
fmt.Println(x == x)

выведет false.

Поэтому для проверки нужно использовать math.IsNaN(x) и math.IsInf(x, sign).

Как NaN ломает логику

Представим, что у вас есть аналитический пайплайн, который вычисляет среднее значение. Где‑то в промежуточной формуле происходит деление на ноль, возникает NaN, а затем это значение идет дальше по системе.

На выходе вы можете получить:

  • сломанный график,

  • пустую метрику,

  • странную сортировку,

  • некорректный JSON,

  • падение на валидации,

  • странный ответ API.

Проблема в том, что NaN часто не вызывает немедленного падения. Он может жить внутри вычислений довольно долго.

Поэтому в критичных местах полезно явно проверять результаты:

func safeValue(x float64) error {
	if math.IsNaN(x) {
		return fmt.Errorf("value is NaN")
	}
	if math.IsInf(x, 0) {
		return fmt.Errorf("value is infinite")
	}
	return nil
}

Это особенно важно в аналитике, ML, численных расчетах и любых сервисах, где результат вычислений идет дальше в бизнес‑логику.

Форматирование float при выводе

Иногда проблема не в самих вычислениях, а в том, как число показывается пользователю.

Например:

price := 12.3456789
fmt.Println(price)
fmt.Printf("%.2f\n", price)

Первый вариант может показать длинную дробную часть, второй округлит до двух знаков после запятой.

Важно понимать, что форматирование не делает число «точнее». Оно только меняет способ отображения.

То есть fmt.Printf("%.2f", x) не лечит проблему точности, а просто красиво печатает результат.

Для UI и отчетов это нормально. Для логики вычислений нет.

Округление тоже не всегда тривиально

Многие думают, что округление с float это простая операция. На самом деле и тут бывают тонкости.

Пример:

package main

import (
	"fmt"
	"math"
)

func main() {
	x := 2.675
	fmt.Printf("%.2f\n", x)
	fmt.Println(math.Round(x*100) / 100)
}

Ожидания человека и реальный результат могут не совпасть, потому что исходное значение уже хранится как приближение.

То есть даже округление работает не с «идеальным 2.675», а с ближайшим представимым двоичным числом.

Если в системе важна десятичная точность по правилам бизнеса, одних float64 и Round может быть недостаточно.

Преобразования между int и float

Еще одна зона ошибок это преобразования между целыми и вещественными числами.

Например:

a := 5
b := 2
fmt.Println(float64(a) / float64(b))

Если забыть приведение типов и делить как int, получится 2, а не 2.5.

С другой стороны, обратное преобразование тоже требует осторожности:

x := 3.9
fmt.Println(int(x))

Результат будет 3, потому что преобразование к int просто отбрасывает дробную часть, а не округляет.

Это особенно важно в расчетах индексов, количестве страниц, процентных вычислениях и логике пагинации.

Когда float опасен в API и БД

Если сервис отдает или принимает значения с float, важно помнить, что разные системы могут сериализовать и десериализовать их чуть по‑разному.

Например, JSON нормально поддерживает числа, но дальше начинаются практические вопросы:

  • сколько знаков после запятой мы отдаем;

  • должен ли клиент видеть округленное значение;

  • нельзя ли потерять смысловую точность;

  • как фронтенд обработает число;

  • не попадет ли NaN или Inf в сериализацию.

Кстати, NaN и бесконечности в обычный JSON не укладываются как нормальные числовые значения. Это тоже нужно учитывать.

Если число идет наружу через API, полезно заранее решить, как именно вы его представляете: как float, как строку, как decimal, как целое число в минимальных единицах.

Пример хорошего практического подхода

Представим, что вы пишете сервис с аналитическими коэффициентами. Значения не финансовые, небольшая погрешность допустима, но вам важны предсказуемость и защита от мусора.

Тогда хорошая практика может выглядеть так:

  • внутри использовать float64,

  • после вычислений проверять NaN и Inf,

  • сравнивать значения через допуск,

  • на границе API форматировать результат осознанно,

  • не использовать ==, если число получено вычислением,

  • документировать допустимую погрешность.

Это уже выглядит как зрелый инженерный подход, а не как «ну это же просто число с точкой».

Полезные правила, которые стоит запомнить

Есть несколько правил, которые реально экономят время и нервы.

Первое правило: по умолчанию берите float64, а не float32.

Второе: не сравнивайте вычисленные float через ==, если вам нужна логическая близость, а не побитовое совпадение.

Третье: не храните деньги в float.

Четвертое: помните, что ошибка может накапливаться при большом числе операций.

Пятое: проверяйте NaN и Inf в важных вычислительных участках.

Шестое: не путайте красивый вывод числа и точность самого числа.

Седьмое: при выборе допуска думайте о масштабе задачи, а не используйте случайное 1e-9 везде подряд.

Что должен понимать Go‑разработчик про float

Если свести все к самому важному, то Go‑разработчику нужно понимать следующее.

float64 это приближенное представление вещественного числа, а не точное десятичное значение. Поэтому некоторые дроби нельзя представить без ошибки. Из‑за этого вычисления могут давать неожиданные на первый взгляд результаты, а сравнение через == часто оказывается ненадежным.

float64 отлично подходит для огромного числа инженерных задач, если предметная область допускает небольшую погрешность. Но для денег, бухгалтерии и других задач, где важна строгая десятичная точность, лучше использовать целые минимальные единицы или decimal‑подход.

Кроме того, разработчик должен помнить про NaN, бесконечности, накопление ошибки и влияние порядка операций на результат.

Этого уже достаточно, чтобы перестать воспринимать float как обычное число с точкой и начать использовать его осознанно.

Итог

float в Go это полезный инструмент, но только если понимать его ограничения. Большинство странных багов с ним возникают не потому, что тип плохой, а потому, что разработчик ожидает от него математической точности, которой у него нет по определению.

Если вы понимаете, что float64 хранит приближение, что сравнивать такие числа нужно осторожно, что деньги в нем хранить не стоит, а ошибки округления могут накапливаться, вы уже находитесь сильно выше среднего уровня практического понимания темы. И этого достаточно, чтобы многие баги перестали быть магией.