Вечная проблема: «0» слева или «сдвиг вправо»?
Вы когда-нибудь задумывались, почему в SQL-запросах номера заказов выглядят как 00042, а в логах — как 23:05:07? Всё просто: числа, дополненные нулями, выравниваются по ширине и не ломают сортировку. Но в Go, в отличие от Python или JavaScript, нет встроенного метода zfill. И тут начинается самое интересное.
На Stack Overflow вопрос «How to pad a number with zeros when printing?» набрал тысячи просмотров и десятки ответов. Люди ищут не только решение, но и быстрое решение. В этой статье мы разберём все способы дополнить число нулями в Go, сравним их производительность и выберем лучший для вашего проекта.
Почему это важно для Go-разработчика?
Go — язык для системного программирования, где каждая наносекунда на счету. Если вы пишете высоконагруженный сервис, неправильное форматирование строк может стать узким местом. Особенно если вы форматируете тысячи чисел в секунду.
- Логирование: временные метки, ID запросов, коды ошибок.
- Генерация отчётов: CSV, JSON, таблицы.
- Сетевые протоколы: фиксированная длина полей.
- Игры и графика: выравнивание текста в консоли.
Игнорирование производительности может привести к тому, что ваш сервис будет тратить 10% времени на форматирование чисел. Не верите? Давайте проверим.
Способ №1: Классика через fmt.Sprintf
Самый очевидный и часто рекомендуемый способ — использовать fmt.Sprintf с форматным спецификатором %0Xd, где X — общая ширина числа вместе с нулями.
package main
import "fmt"
func main() {
num := 42
padded := fmt.Sprintf("%05d", num)
fmt.Println(padded) // Вывод: 00042
}
Здесь %05d означает: «выведи число как десятичное целое, заполни слева нулями до ширины 5 символов». Если число уже длиннее (например, 123456), то нули не добавляются — ширина игнорируется.
Плюсы:
- Просто и читаемо.
- Поддерживает отрицательные числа (знак будет слева от нулей).
- Работает с разными типами:
%05dдля int,%05.2fдля float.
Минусы:
- Использует рефлексию внутри
fmt— медленно. - Создаёт лишнюю строку и interface{}.
- Не подходит для горячих путей.
Способ №2: Быстрый strconv.FormatInt с ручным дополнением
Если производительность критична, используйте пакет strconv. Он работает напрямую с байтами, без рефлексии.
package main
import (
"fmt"
"strconv"
"strings"
)
func padNumber(num int64, width int) string {
s := strconv.FormatInt(num, 10)
if len(s) >= width {
return s
}
return strings.Repeat("0", width-len(s)) + s
}
func main() {
fmt.Println(padNumber(42, 5)) // 00042
}
Плюсы:
- В 2-3 раза быстрее
fmt.Sprintf. - Нет рефлексии.
- Полный контроль над логикой.
Минусы:
- Кода больше.
- Нужно обрабатывать отрицательные числа отдельно (знак слева от нулей).
strings.Repeatсоздаёт новую строку — можно оптимизировать дальше.
Способ №3: Максимальная производительность через strings.Builder
Для тех, кто хочет выжать все соки, есть способ без лишних аллокаций — использовать strings.Builder и заранее выделять буфер.
package main
import (
"fmt"
"strconv"
"strings"
)
func padNumberFast(num int64, width int) string {
s := strconv.FormatInt(num, 10)
if len(s) >= width {
return s
}
var builder strings.Builder
builder.Grow(width)
for i := 0; i < width-len(s); i++ {
builder.WriteByte('0')
}
builder.WriteString(s)
return builder.String()
}
func main() {
fmt.Println(padNumberFast(42, 5)) // 00042
}
Но можно пойти ещё дальше — полностью отказаться от strconv и написать свою функцию конвертации числа в строку с дополнением нулями. Это даст прирост ещё на 10-20%, но код станет сложнее.
Совет: Если вам нужно форматировать много чисел в одном месте, используйте []byte и работайте с буфером напрямую.
Бенчмарки: кто быстрее?
Давайте напишем простой бенчмарк и сравним три подхода.
package main
import (
"fmt"
"strconv"
"strings"
"testing"
)
func padSprintf(num int64, width int) string {
return fmt.Sprintf("%05d", num)
}
func padStrconv(num int64, width int) string {
s := strconv.FormatInt(num, 10)
if len(s) >= width {
return s
}
return strings.Repeat("0", width-len(s)) + s
}
func padBuilder(num int64, width int) string {
s := strconv.FormatInt(num, 10)
if len(s) >= width {
return s
}
var builder strings.Builder
builder.Grow(width)
for i := 0; i < width-len(s); i++ {
builder.WriteByte('0')
}
builder.WriteString(s)
return builder.String()
}
func BenchmarkPadSprintf(b *testing.B) {
for i := 0; i < b.N; i++ {
padSprintf(42, 5)
}
}
func BenchmarkPadStrconv(b *testing.B) {
for i := 0; i < b.N; i++ {
padStrconv(42, 5)
}
}
func BenchmarkPadBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
padBuilder(42, 5)
}
}
Результаты (на моём ноутбуке):
fmt.Sprintf— ~150 нс/операцияstrconv + Repeat— ~80 нс/операцияstrings.Builder— ~70 нс/операция
Разница в два раза между первым и последним. Для 100 000 операций это 8 миллисекунд против 15. Не критично для одного вызова, но в цикле — заметно.
Особые случаи: отрицательные числа, float, hex
Форматирование с нулями для отрицательных чисел требует аккуратности. Например, fmt.Sprintf("%05d", -42) вернёт -0042