1. Введение
Почти все новички (и некоторые опытные тоже, особенно по пятницам) путают две вещи: округление как математическую операцию и округление как формат вывода. Снаружи выглядит одинаково — “хочу 2 знака после точки” — но внутри это разные действия и разные последствия для программы.
Представьте, что число — это еда, а вывод — это тарелка. Вы можете нарезать еду (изменить число), а можете красиво разложить на тарелке (изменить только отображение). Вторая операция не меняет саму еду — только то, как вы её показываете.
В Go:
- форматирование через fmt.Printf("%.2f", x) не меняет x;
- математическое округление через math.Round(x) возвращает новое значение.
Это различие — главный смысл лекции.
Форматирование не меняет значение
Очень полезно один раз в жизни (желательно пораньше) увидеть глазами, что форматирование не меняет переменную. Иначе можно нечаянно построить логику программы на красивом выводе — а это примерно как строить дом по фотографии кирпича.
Сделаем простую проверку: выведем число “красиво”, а потом выведем его же с высокой точностью, чтобы увидеть, что оно не стало “короче”.
package main
import "fmt"
func main() {
x := 2.0 / 3.0
fmt.Printf("Pretty: %.2f\n", x) // Pretty: 0.67
fmt.Printf("Raw: %.17f\n", x) // Raw: 0.66666666666666663
fmt.Printf("Still x: %.17f\n", x) // Still x: 0.66666666666666663
}
Если от этого примера у вас появляется лёгкое чувство “меня обманули” — поздравляю, вы начали понимать float. Это не ошибка Go и не заговор математиков. В двоичной системе не все десятичные дроби представимы точно, поэтому появляются хвосты.
2. Форматирование float в fmt.Printf
Когда вы печатаете float64, по умолчанию Go старается вывести число “разумно”, но это не всегда то, что нужно человеку. Поэтому fmt.Printf даёт вам управление: сколько знаков после точки показывать, какой формат выбрать, как печатать число в отчёте, и так далее.
Что означает %.2f
Начнём с самого популярного: %f и его “красивая версия” %.2f.
- %f печатает число с плавающей точкой в десятичном виде.
- %.2f печатает ровно 2 знака после точки, округляя для вывода.
- %.0f печатает без дробной части (опять же округляя для вывода).
- %.3f — три знака после точки, и так далее.
Посмотрим на простом примере.
package main
import "fmt"
func main() {
x := 1.0 / 3.0
fmt.Printf("x as %%f = %f\n", x) // x as %f = 0.333333
fmt.Printf("x as %%.2f = %.2f\n", x) // x as %.2f = 0.33
fmt.Printf("x as %%.0f = %.0f\n", x) // x as %.0f = 0
}
Обратите внимание на две тонкости.
Первая: в строке формата %% — это способ вывести буквальный символ %. Иначе fmt будет думать, что вы снова начинаете форматирование.
Вторая: x не изменился. Он как был “одна треть” (примерно), так и остался. Мы просто показали его по-разному.
Другие полезные форматы: %.0f и %g
Когда начинаешь печатать числа, очень быстро появляется вопрос: “А всегда ли %.2f хорош?” Ответ — нет. Он хороший для денег и для “человеческих” отчётов, но иногда вам нужно другое.
Важно не выучить все спецификаторы наизусть (это путь к грусти), а понимать идею: формат выбирается под смысл вывода.
Небольшая таблица, которую полезно держать в голове:
| Формат | Идея | Типичный смысл |
|---|---|---|
|
“обычное” вещественное число | технический вывод, отладка |
|
фиксированная точность после точки | деньги, проценты, отчёты |
|
без дроби | “штук”, “человек”, “целых единиц”, но число всё ещё float |
|
“компактно”: либо обычный вид, либо экспонента | диагностика, когда масштабы разные |
|
значащие цифры (не “после точки”) | компактные отчёты, научные числа |
Пощупаем %g на коротком примере: он часто выглядит “умнее”, когда числа бывают и маленькие, и большие.
package main
import "fmt"
func main() {
a := 12345.6789
b := 0.000012345
fmt.Printf("a with %%f = %f\n", a) // a with %f = 12345.678900
fmt.Printf("a with %%g = %g\n", a) // a with %g = 12345.6789
fmt.Printf("b with %%g = %g\n", b) // b with %g = 1.2345e-05
fmt.Printf("a with %%.3g = %.3g\n", a) // a with %.3g = 1.23e+04
}
Если вы делаете “человеческий чек” — обычно лучше %.2f. Если вы печатаете диагностический вывод — %g иногда понятнее, потому что не заваливает вас лишними нулями.
Мини-приложение: калькулятор чаевых
Сейчас соберём небольшой кусочек в стиле нашего курса: программа читает ввод, что-то считает и печатает результат. Никакой архитектуры, никаких классов, только честный main и понятные переменные. Это важно: мы тренируем навык “я могу собрать полезную штуку из базовых деталей”.
Представим, что мы пишем мини-калькулятор чаевых:
- сумма счёта (вещественное число),
- процент чаевых (целое число, например 10 или 15),
- итоговая сумма к оплате.
Печатать будем как деньги: %.2f.
package main
import "fmt"
func main() {
var bill float64
var tipPercent int
fmt.Scan(&bill, &tipPercent)
tip := bill * float64(tipPercent) / 100
total := bill + tip
fmt.Printf("Tip: %.2f\n", tip) // Tip: 3.75 (пример)
fmt.Printf("Total: %.2f\n", total) // Total: 28.75 (пример)
}
Здесь мы используем сразу несколько вещей, которые “срастаются” в один навык.
Во-первых, fmt.Scan спокойно читает float64 и int, если ввод корректный.
Во-вторых, мы явно приводим tipPercent к float64, потому что иначе выражение bill * tipPercent не скомпилируется (типы-то разные, магии нет).
В-третьих, %.2f делает вывод таким, как привык видеть человек. И да, именно по этой причине %.2f так любят в примерах про деньги: не потому что “так принято”, а потому что это минимальный порядок.
3. Округление значения через пакет math
Иногда вам недостаточно “красивого вывода”. Например, вы хотите:
- посчитать сумму и дальше использовать округлённое значение в следующих вычислениях;
- сделать правило “округляем вверх” (например, “доставка всегда округляется до целых евро вверх”);
- отрезать дробную часть вниз и получить “целое количество упаковок”.
В таких случаях вам нужны функции из пакета math.
Round, Floor, Ceil
В базовом наборе три штуки закрывают большинство бытовых сценариев:
- math.Round(x) — округление к ближайшему целому;
- math.Floor(x) — округление вниз (к минус бесконечности);
- math.Ceil(x) — округление вверх (к плюс бесконечности).
Посмотрим на примере.
package main
import (
"fmt"
"math"
)
func main() {
fmt.Println(math.Round(2.5)) // 3
fmt.Println(math.Floor(2.9)) // 2
fmt.Println(math.Ceil(2.1)) // 3
}
Это уже изменение значения, точнее — получение нового значения. Переменная сама по себе не меняется (потому что мы её не меняли), но результат функции — это новое число, которое можно присваивать, складывать, сохранять.
Округление до двух знаков как значение: “масштабирование → Round → обратно”
Форматирование %.2f печатает две цифры после точки, но само значение не меняет. А если вы хотите именно значение с двумя знаками (например, сохранить “сумму после скидки” уже округлённой), то часто делают так:
- умножаем на 100,
- округляем до целого через math.Round,
- делим обратно на 100.
Пример:
package main
import (
"fmt"
"math"
)
func main() {
x := 12.3456
y := math.Round(x*100) / 100
fmt.Printf("x = %.10f\n", x) // x = 12.3456000000
fmt.Printf("y = %.10f\n", y) // y = 12.3500000000
}
Почему это работает? Потому что мы превращаем задачу “округлить до 2 знаков” в задачу “округлить до целого”, а это math.Round умеет напрямую.
Сразу важная ремарка: мы работаем с float, а значит “идеальной финансовой математики” тут нет. Но на нашем учебном уровне это нормальный и понятный инструмент: вы явно видите формулу и понимаете, что происходит.
4. Где делать округление в программе
Когда программа начинает считать что-то длиннее “два плюс два”, место округления становится важным. Если округлять слишком рано, можно накопить ошибку. Если не округлять вообще — пользователю будет больно смотреть на отчёт. Поэтому полезно мысленно разделять этапы.
flowchart TD
A[Ввод данных] --> B[Вычисления в float64]
B --> C{Нужно округлённое значение для дальнейших вычислений?}
C -->|нет| D["Форматируем при выводе fmt.Printf('%.2f')"]
C -->|да| E["Округляем значение< math.Round(x*100)/100"]
E --> D
Идея простая: если округление нужно только “чтобы было красиво” — делайте его в Printf. Если округление — часть бизнес-правила (пусть даже очень простого) — округляйте значение через math.
Кстати, философия “делайте код проще, чтобы не ошибаться” — это не только нудная мораль. В Go-сообществе это часто проговаривают именно как инженерный подход: меньше лишней сложности — меньше шансов “не заметить мелочь и потом неделю искать баг”.
5. Особенности float: пример 0.1 + 0.2
Этот пример уже стал мемом, но он полезен. Мы не будем уходить в глубокую теорию, просто закрепим практическую мысль: float — приближённый, а форматирование может скрыть хвосты… но хвосты всё равно существуют.
package main
import "fmt"
func main() {
x := 0.1 + 0.2
fmt.Printf("x = %.17f\n", x) // x = 0.30000000000000004
fmt.Printf("x = %.2f\n", x) // x = 0.30
}
Если вы делаете отчёт для человека, %.2f — это нормально и даже нужно. Если вы пишите логику вида if x == 0.3 { ... }, то вы, скорее всего, придёте к странным результатам (но стратегии сравнения с допуском — это отдельная дисциплина, и мы сейчас туда не лезем специально).
6. Типичные ошибки
Ошибка №1: “Я напечатал %.2f, значит число стало с двумя знаками”.
Это самая частая путаница. Форматирование — это только способ показать значение. Переменная не изменится, даже если вывод выглядит идеально. Если дальше по коду вы используете исходное x, то вы используете исходный x со всеми его “хвостами”. Если вам нужно именно округлённое значение, придётся вычислить его отдельно и сохранить в переменную.
Ошибка №2: попытка округлять через int(x) вместо math.Round.
Преобразование float64 в int отбрасывает дробную часть, то есть делает усечение в сторону нуля. Для 3.9 это будет 3, а для -3.9 это будет -3. Это не округление “по-человечески”, а совсем другая операция. В результате можно получить ошибки, которые выглядят как “иногда на копейку меньше”, а это типичная дорога к вечной печали.
Ошибка №3: печать денег через %v или %f без точности.
Технически программа работает, но UX — нет. Пользователь не обязан любить ваши шесть знаков после точки. Деньги и проценты почти всегда ожидаются в формате %.2f (или другом фиксированном), иначе вывод воспринимается как “сырые внутренности”, а не как результат.
Ошибка №4: “Сначала округлю каждую мелочь, потом сложу — так точнее”.
Интуитивно кажется логичным, но часто это даёт больше накопленной ошибки: округления “съедают” информацию. На базовом уровне полезное правило такое: если округление нужно только для вывода, округляйте в самом конце (через Printf). Если округление — часть правила, делайте его ровно в том месте, где правило должно вступить в силу, и не размазывайте округление по всему коду.
Ошибка №5: забыть импорт "math" и долго спорить с компилятором.
Компилятор в Go обычно ругается довольно честно: если вы пишете math.Round, а import "math" забыли, он не “догадается” за вас. Это раздражает примерно 20 секунд, зато потом вы привыкаете, что зависимости в коде видны явно. И да, это тоже часть той самой “простоты, которая уменьшает сюрпризы”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ