Маленькое инженерное чудо: почему я переписал ввод-вывод для контестов на Go
- понедельник, 13 апреля 2026 г. в 00:00:33
Python берут за скорость реализации. C++ - за производительность и контроль над памятью.
А Go? Go выбирают те, кто любит Go. Я один из них. Долгое время я использовал связку bufio.Scanner + ScanWords + strconv.Atoi. Но стоит в задаче смешать числа, строки или посимвольный ввод - начинаются “танцы с бубном”. В какой-то момент мне надоело, и я написал contestio. Решения оказались простыми. То чувство, когда: “Чёрт возьми! Почему мне это не пришло в голову раньше!?”
fmt.Fscan - удобен, но аллоцирует на каждом чихе.
bufio.Scanner - ноль аллокаций, но жёстко привязан к одному режиму разбиения. Смешанный ввод - боль.
bufio.Reader - быстр и гибок, но каждый раз писать циклы - тоска.
Хотелось собрать всё лучшее: скорость ручного разбора, удобство fmt и гибкость bufio.Reader. И чтобы zero-allocation, но с душой.
Получилось шесть простых решений. Каждое по отдельности - ерунда, а вместе - маленькое чудо.
Вместо того, чтобы оборачивать 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. Все лишние проверки исчезают.
Когда я смотрел на сгенерированный ассемблер - улыбался. Одна функция для всех типов - универсальность без потерь!
Если мы читаем прямо из буфера, то логично и писать прямо в буфер. Но есть маленькая деталь: мы не знаем длину числа заранее, а сбрасывать неполный буфер (не кратный кластеру) совесть не позволяет.
Решение - прикрепить к 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) // без аллокаций }
Рефлексия здесь - лёгкий диспетчер, а не тяжёлый сериализатор. Просто, элегантно и неожиданно быстро!
В контестах ввод гарантированно корректен. Но проверить себя всё равно нужно. Писать 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 # возвращает ошибки
Контроль остался, кода - меньше. И никакой магии - просто условная компиляция.
Многие проверяющие платформы (Яндекс.Контест, 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 КБ.
Чтение целых чисел | Время (мс) | Аллокации |
|---|---|---|
| 473 | 1 005 000 |
| 106 | 0 |
contestio: scanSlice | 62 | 0 |
Вывод целых чисел | Время (мс) | Аллокации |
|---|---|---|
| 109 | 965 000 |
| 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 открыты.
Инженерные решения лучше всего оттачиваются в диалоге, а не в вакууме.