1. Зачем вообще нужен bufio.Scanner и что такое токен
Когда вы впервые видите Scanner, он кажется слишком удобным, почти подозрительным: цикл выглядит чисто, ошибок «на каждой итерации» нет, всё читается как обычный перебор коллекции. И в этом смысл: Scanner — это «токенизатор поверх потока». Он берёт io.Reader, сам дочитывает данные порциями и отдаёт вам токены: строки, слова, руны или что угодно ещё, если вы зададите свою логику разбиения.
В терминологии Scanner токен — это «кусок данных, который вы считаете атомарным для обработки». По умолчанию токеном считается строка (разделение по \n). Можно переключиться на слова, руны и т. п. Но важно помнить: токен должен целиком поместиться во внутренний буфер. Если не помещается — сканирование останавливается с ошибкой. И это центральная тема лекции.
Каноничный шаблон: Scan() в цикле и Err() после цикла
Если бы в Go вручали медаль «за самый узнаваемый цикл», то Scanner точно вошёл бы в финал. И этот шаблон не просто «так принято». Он отражает устройство API: Scan() возвращает bool, а ошибка достаётся отдельным вызовом Err() уже после завершения цикла.
Такой подход — осознанный дизайн: основной поток управления («пока есть токены — обрабатываем») не замусоривается проверками ошибок на каждом шаге.
Пример, который читает строки из любого io.Reader:
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
sc := bufio.NewScanner(strings.NewReader("a\nb\nc\n"))
for sc.Scan() {
fmt.Println("token:", sc.Text()) // token: a ...
}
fmt.Println("err:", sc.Err()) // err: <nil>
}
Документация формулирует это так: Scan() возвращает false, когда токены закончились из‑за EOF или ошибки; после этого Err() вернёт ошибку (кроме io.EOF, который превращается в nil).
Важно привыкнуть к дисциплине: цикл закончился — проверь Err(). Даже если вам кажется, что «читаем же из строки, какие там ошибки».
2. Ограничение по размеру токена и ошибка token too long
Почему ограничение вообще существует
Scanner умеет читать строки, слова и т. п., но он не готов хранить в памяти токен бесконечного размера. Поэтому в bufio есть константа MaxScanTokenSize = 64 * 1024 (то есть 64 KiB), которая используется как стандартный максимум для буфера токена.
Это означает практическую вещь: если вы читаете строки (режим по умолчанию), и вам попалась строка длиннее ~64 KiB, то Scanner остановится. Причём это может случиться «внезапно» на 10 000-й строке, если именно там кто-то вставил огромный JSON в одну линию или просто «случайно положил лог без переносов».
Почему так сделано — довольно прагматично. Если бы Scanner автоматически раздувал буфер «до победного», то один злой (или просто неаккуратный) вход мог бы заставить программу выделить гигантскую память. В CLI‑утилитах это легко превращается в «положили программу одним файлом». Так что ограничение — это, по сути, ремень безопасности.
Как выглядит ошибка «токен слишком длинный»
Ошибки у Scanner тоже довольно честные: если токен не влезает, вы получите bufio.ErrTooLong, который в пакете определён как "bufio.Scanner: token too long".
Давайте специально воспроизведём ситуацию. В реальной жизни вы так, конечно, не делаете (хотя иногда лог-файлы стараются):
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
longLine := strings.Repeat("x", 70*1024) // 70 KiB
sc := bufio.NewScanner(strings.NewReader(longLine))
ok := sc.Scan()
fmt.Println("scan ok:", ok) // scan ok: false
fmt.Println("err:", sc.Err()) // err: bufio.Scanner: token too long
}
Почему ok стал false? Потому что Scan() говорит: «токен получить не могу», и завершает сканирование. А почему не паника? Потому что это не ошибка программиста, а особенность входных данных, её нужно уметь обработать.
Кстати, документация подчёркивает ещё один неприятный нюанс: когда сканирование останавливается из‑за ошибки (или из‑за слишком большого токена), Reader внутри мог «уехать» вперёд по входу довольно далеко. То есть это не тот инструмент, где вы гарантированно можете «чуть-чуть откатиться и перечитать». Если нужна такая точность — берите bufio.Reader.
4. Обходные пути и альтернативы
Увеличить лимит через Scanner.Buffer
Самый частый способ «починить» проблему — сказать Scanner: «окей, я ожидал длинные токены, вот тебе больший лимит». Для этого есть метод Buffer(buf []byte, max int). Он задаёт стартовый буфер и максимальный размер, до которого Scanner имеет право раздуваться.
Есть два правила, которые стоит запомнить.
Первое: Buffer(...) надо вызывать до начала сканирования. Если вызвать после первого Scan(), будет паника — документация говорит об этом явно.
Второе: максимальный размер токена должен быть не больше большего из max и cap(buf); иначе Scanner не сможет гарантировать буферизацию.
Практический пример: разрешим строки до 1 MiB.
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
longLine := strings.Repeat("x", 200*1024) // 200 KiB
sc := bufio.NewScanner(strings.NewReader(longLine))
sc.Buffer(make([]byte, 1024), 1024*1024) // старт 1 KiB, максимум 1 MiB
fmt.Println(sc.Scan()) // true
fmt.Println(len(sc.Text())) // 204800
}
Эта настройка делает вашу программу устойчивее к «случайно длинным строкам». Но не превращайте её в «ставим 1 гигабайт, чтобы наверняка». Если вход реально может быть гигантским, лучше сменить подход чтения, а не пытаться проглотить «одну строку размером с фильм».
Поменять стратегию разбиения
Иногда проблема не в том, что вход огромный, а в том, что вы выбрали неправильную единицу обработки. Если вы читаете «строками», а строка может быть огромной, возможно, вам вообще не нужна строка как атомарный объект. Может быть, вам важны слова, или отдельные байты, или руны.
В bufio есть готовые split‑функции: ScanLines, ScanWords, ScanRunes, ScanBytes. Документация, например, уточняет, что ScanLines возвращает строки без \n, а последняя строка может вернуться и без завершающего перевода строки.
С точки зрения «лимита токена» это работает так: если вы переключились на ScanWords, а у вас нет слов длиной >64 KiB, то вы внезапно перестаёте упираться в ограничение. Но если у вас есть «одно слово на 500 KiB» (например, base64 без пробелов), то проблема останется.
Мини‑пример со словами:
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
sc := bufio.NewScanner(strings.NewReader("go is fun"))
sc.Split(bufio.ScanWords)
for sc.Scan() {
fmt.Println(sc.Text()) // go / is / fun
}
fmt.Println("err:", sc.Err()) // err: <nil>
}
Это не «серебряная пуля», но иногда правильная токенизация решает проблему лучше, чем увеличение буфера.
Перейти на bufio.Reader, если нужен контроль
Есть ситуации, где Scanner просто не ваш инструмент. И это нормально: в Go вообще много «простых инструментов», которые честно говорят: «я удобен, но не универсален». Документация bufio прямо рекомендует: если вам нужны большие токены, больше контроля над ошибками или последовательные сканы одного Reader, используйте bufio.Reader вместо Scanner.
Почему bufio.Reader лучше в таких случаях? Потому что он позволяет читать поток «до разделителя» (ReadString, ReadBytes) и вы сами управляете тем, что делать, если строка длинная. Да, код получается чуть более «ручной», зато у вас меньше сюрпризов.
Мини‑пример: читаем «строки» через ReadString('\n'). Здесь важно помнить, что при конце файла можно получить кусок строки и io.EOF одновременно — и это не «ошибка», а нормальный финал.
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func main() {
r := bufio.NewReader(strings.NewReader("a\nb\nlast"))
for {
s, err := r.ReadString('\n')
if len(s) > 0 {
fmt.Printf("line: %q\n", s)
}
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read error:", err)
return
}
}
}
Этот подход особенно хорош, когда «строки» могут быть большими, но вы готовы обрабатывать их по мере чтения, не полагаясь на внутренние лимиты Scanner.
5. Пример из приложения: импорт задач и длинные описания
Представим наше учебное CLI‑приложение для задач (условно назовём его tasker). До этого момента мы уже научились открывать файлы и читать/писать их, а теперь хотим сделать импорт задач из простого текстового формата: одна задача — одна строка. Казалось бы, идеальный кейс для Scanner.
Но у пользователя может быть задача вида «вставить сюда спецификацию на 120 тысяч символов» — и эта одна строка внезапно ломает импорт. Поэтому мы сразу встроим защиту: увеличим лимит токена до разумного значения.
Сделаем функцию, которая принимает io.Reader (а не имя файла). Это хорошая привычка: мы отделяем «где взять данные» от «как их разобрать». Такой стиль позволяет позже читать хоть из файла, хоть из stdin, хоть из строки.
package main
import (
"bufio"
"fmt"
"io"
)
type Task struct {
ID int
Title string
}
func ReadTasks(r io.Reader) ([]Task, error) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024), 1024*1024) // до 1 MiB на строку
var tasks []Task
id := 1
for sc.Scan() {
tasks = append(tasks, Task{ID: id, Title: sc.Text()})
id++
}
return tasks, sc.Err()
}
Обратите внимание на две вещи.
Во-первых, мы возвращаем sc.Err() как ошибку функции. Это именно тот контракт, который стоит держать: никакой печати внутри, никаких fmt.Println("ой"). Просто возвращаем ошибку наверх.
Во-вторых, мы не забыли, что Err() проверяется после цикла. Это не «особое мнение автора», это базовая механика Scanner: ошибка хранится внутри и извлекается отдельно.
Теперь пример использования (без файлов — просто чтобы увидеть поведение):
package main
import (
"fmt"
"strings"
)
func main() {
input := "buy milk\nwrite code\n"
tasks, err := ReadTasks(strings.NewReader(input))
fmt.Println("err:", err) // err: <nil>
fmt.Println("count:", len(tasks)) // count: 2
}
Если в будущем вы решите, что «строки могут быть очень длинные, а память жалко», у вас уже есть план Б: переписать ReadTasks на bufio.Reader.ReadString('\n'). И это будет локальная замена внутри одной функции, а не переписывание всей программы.
6. Типичные ошибки при работе с bufio.Scanner и большими токенами
Ошибка №1: не проверять scanner.Err() после цикла.
Самая коварная вещь здесь в том, что Scanner «делает вид, что всё хорошо», пока вы не спросите «а точно?». Scan() просто вернёт false, и если вы не вызвали Err(), вы можете принять «ошибка чтения» за «файл закончился». Правильный шаблон — цикл for Scan() и затем обязательный Err().
Ошибка №2: увеличивать лимит, но вызывать Buffer() слишком поздно.
Buffer() нельзя вызывать «когда уже стало плохо». Метод должен быть вызван до начала сканирования, иначе будет паника. Это не придирка: внутреннее состояние сканера уже использует буфер, и менять правила в процессе — значит ломать инварианты.
Ошибка №3: лечить любой ввод установкой max = миллиард.
Да, так можно «победить» ошибку token too long. Но вы одновременно разрешаете входу заставить программу выделить очень много памяти. Обычно правильнее поставить разумный лимит (например, 1–10 MiB, в зависимости от задачи) и при превышении честно вернуть ошибку bufio.ErrTooLong. Эта ошибка существует именно для того, чтобы вы могли её различать.
Ошибка №4: пытаться использовать Scanner там, где нужен контроль над чтением.
Если вам нужно читать гигантские строки, делать «много проходов» по одному Reader или очень точно управлять тем, где остановилось чтение, Scanner может оказаться неудобным выбором. Документация прямо говорит, что для больших токенов и большего контроля стоит предпочесть bufio.Reader.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ