golang

Маленькое инженерное чудо: почему я переписал ввод-вывод для контестов на Go

  • понедельник, 13 апреля 2026 г. в 00:00:33
https://habr.com/ru/articles/1022492/

Python берут за скорость реализации. C++ - за производительность и контроль над памятью.

А Go? Go выбирают те, кто любит Go. Я один из них. Долгое время я использовал связку bufio.Scanner + ScanWords + strconv.Atoi. Но стоит в задаче смешать числа, строки или посимвольный ввод - начинаются “танцы с бубном”. В какой-то момент мне надоело, и я написал contestio. Решения оказались простыми. То чувство, когда: “Чёрт возьми! Почему мне это не пришло в голову раньше!?”

Мотивация: хочется удобно и быстро, а не выбирать

  • fmt.Fscan - удобен, но аллоцирует на каждом чихе.

  • bufio.Scanner - ноль аллокаций, но жёстко привязан к одному режиму разбиения. Смешанный ввод - боль.

  • bufio.Reader - быстр и гибок, но каждый раз писать циклы - тоска.

Хотелось собрать всё лучшее: скорость ручного разбора, удобство fmt и гибкость bufio.Reader. И чтобы zero-allocation, но с душой.

Получилось шесть простых решений. Каждое по отдельности - ерунда, а вместе - маленькое чудо.


Чудо первое: bufio.Reader - наше всё

Вместо того, чтобы оборачивать Scanner, я стал работать напрямую с буфером bufio.Reader. Функция _nextToken возвращает срез байт, указывающий прямо на внутренний буфер. Никакого копирования, никаких аллокаций.

Почему круто? Все методы bufio.Reader остаются доступными. Хочешь прочитать строку через ReadString - пожалуйста. Хочешь подсмотреть Peek - без проблем. Высокоуровневые ScanInt/ScanWord живут рядом с низкоуровневыми методами. Никакого “режима сканера” - просто читай как хочешь.

var age int
var name string

ScanInt(br, &age)               // через contestio
name, _ = br.ReadString('\n')   // стандартный метод bufio.Reader
name = strings.TrimSpace(name)

Чудо второе: один парсер для всех целых типов

Раньше для int8, uint16, int64 я использовал strconv.Atoi (возвращает int, не подходит для uint64), либо ParseInt/ParseUint с проверкой диапазона. Кода становилось многовато.

Теперь - один дженерик _parseInt[T Int]:

func _parseInt[T Int](token []byte) (T, error) {
    unsigned := ^T(0) >= 0   // true для беззнаковых
    // ... обработка знака, ручной цикл до 20 цифр, fallback на ParseUint
    // ... проверка диапазонов
}

Что делает компилятор? Генерирует отдельную реализацию для каждого типа T. Для беззнаковых - выкидывает код обработки знака. Для int8 - встраивает константу absMin = 1<<7. Все лишние проверки исчезают.

Когда я смотрел на сгенерированный ассемблер - улыбался. Одна функция для всех типов - универсальность без потерь!


Чудо третье: вывод который не тревожит GC

Если мы читаем прямо из буфера, то логично и писать прямо в буфер. Но есть маленькая деталь: мы не знаем длину числа заранее, а сбрасывать неполный буфер (не кратный кластеру) совесть не позволяет.

Решение - прикрепить к bufio.Writer маленький массив scratch [32]byte. Если в буфере осталось места меньше, чем длина scratch, пишем временно в scratch, а потом одним вызовом отправляем в буфер.

func _writeAppendFunc(bw *Writer, appendVal func([]byte, T) []byte, v T) error {
    var buf []byte
    if bw.Available() < len(bw.scratch) {
        buf = bw.scratch[:0]
    } else {
        buf = bw.AvailableBuffer()
    }
    buf = appendVal(buf, v)
    _, err := bw.Write(buf)
    return err
}

И вуаля - ровно ноль аллокаций на вывод. Мелочь, а приятно.


Чудо четвёртое: рефлексия, которая не тормозит

fmt.Fscan/fmt.Fprint принимают любые типы через any, но внутри используют тяжелую рефлексию (и аллокации). У нас типов немного: целые, вещественные, строки. Поэтому основное API - типизированные функции: ScanInt[T], ScanFloat[T], ScanWord[T].

Но если хочется удобства, есть ScanAny(br, &product, &price, &amount), под тегом any. В этом режиме мы строим таблицы функций по reflect.Kind. При вызове ScanAny (или PrintAny) определяет тип для каждого аргумента и вызывает нужный парсер или принтер.

var _parseAnyTab = []_parseAnyFunc{
    reflect.Int:     _parseIntToAny[int],
    reflect.Int8:    _parseIntToAny[int8],
    // ...
    reflect.Float32: _parseFloatToAny[float32],
    reflect.String:  _parseWordToAny[string],
}

Для получения указателя на данные можно использовать или reflect.ValueOf(x).UnsafePointer() (чуть медленнее) или напрямую брать из eface (под тегом unsafe,для смелых).

Основное правило zero‑allocation: передавай указатели, объявляй переменные до входа в цикл и переиспользуй их.

var x int
for i := 0; i < N; i++ {
    x = data[i]
    PrintAny(bw, op, &x)   // без аллокаций
}

Рефлексия здесь - лёгкий диспетчер, а не тяжёлый сериализатор. Просто, элегантно и неожиданно быстро!


Чудо пятое: тег must - меньше кода, тот же контроль

В контестах ввод гарантированно корректен. Но проверить себя всё равно нужно. Писать if err != nil { panic(err) } после каждого вызова - утомительно.

Решение: оборачиваем каждый вызов в приватную функцию _must:

func _scanSlice[T any](br *Reader, parse _parseFunc[T], a []T) (int, error) {
    return _must(_scanSliceCommon(br, parse, a))
}

В файле must.go (с тегом //go:build must) _must паникует на любой ошибке, кроме io.EOF. В nomust.go (без тега) - просто возвращает значения. Компилятор первую заинлайнит, вторую выкинет, как “мёртвый” код.

go run -tags=must main.go   # паникует на ошибках
go run main.go              # возвращает ошибки

Контроль остался, кода - меньше. И никакой магии - просто условная компиляция.


Чудо шестое: contestio-inline. Зависимости? Не, не слышали

Многие проверяющие платформы (Яндекс.Контест, Codeforces) запрещают внешние пакеты. Ручное копирование парсеров в каждое решение - путь к ошибкам и тоске.

Утилита contestio-inline анализирует AST файла решения, находит использованные имена из contestio, строит граф зависимостей внутри библиотеки и встраивает только нужные объявления прямо в main.go. После этого файл становится самодостаточным.

contestio-inline main.go          # встроил код
contestio-inline -clear main.go   # вернул как было

Утилита проверяет компиляцию (go build), делает бэкап main.go~ и поддерживает теги сборки (-tags=must).

Важное условие: необходим точечный импорт (import . "github.com/aaa2ppp/contestio"). Это сделано, чтобы не трогать оригинальный код решения и для краткости.


Цифры (просто чтобы было)

Система: Intel Core i3-7100, Windows, Go 1.24.2. Чтение/запись 1M чисел, буфер 4 КБ.

Чтение целых чисел

Время (мс)

Аллокации

fmt.Fscan

473

1 005 000

Scanner + ScanWords + Atoi

106

0

contestio: scanSlice

62

0

Вывод целых чисел

Время (мс)

Аллокации

fmt.Fprintf

109

965 000

strconv.AppendInt

37

2 600

contestio: printSlice

39

0


Вместо заключения

contestio - не попытка обогнать и перегнать. Это инженерная отдушина. Набор простых, изящных решений, которые сложились в цельную картину.

Я люблю Go и ненавижу аллокации. Я считаю, что IO должен быть быстрым. Я старался сделать просто и удобно. Да, я написал contestio ради удовольствия. Как в отпуск съездил. Солнце, море, вино, 5* и всё включено! :)

Попробуй:

go get github.com/aaa2ppp/contestio@latest
go install github.com/aaa2ppp/contestio/cmd/contestio-inline@latest
contestio-inline main.go

Загляни в код
Больше примеров

Нашёл баг? Есть идея? Issues открыты.
Инженерные решения лучше всего оттачиваются в диалоге, а не в вакууме.