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

от автора

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 intvar name stringScanInt(br, &age)               // через contestioname, _ = br.ReadString('\n')   // стандартный метод bufio.Readername = 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 intfor 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@latestgo install github.com/aaa2ppp/contestio/cmd/contestio-inline@latestcontestio-inline main.go

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1022492/