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 КБ.
|
Чтение целых чисел |
Время (мс) |
Аллокации |
|---|---|---|
|
|
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@latestgo install github.com/aaa2ppp/contestio/cmd/contestio-inline@latestcontestio-inline main.go
Нашёл баг? Есть идея? Issues открыты.
Инженерные решения лучше всего оттачиваются в диалоге, а не в вакууме.
ссылка на оригинал статьи https://habr.com/ru/articles/1022492/