JavaRush /Курси /Go SELF /bufio.Scanner: обмеження розміру токена

bufio.Scanner: обмеження розміру токена

Go SELF
Рівень 40 , Лекція 2
Відкрита

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.

3. Обхідні шляхи та альтернативи

Підвищити ліміт через 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.

4. Приклад із застосунку: імпорт задач і довгі описи

Уявімо наш навчальний 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'). І це буде локальна заміна всередині однієї функції, а не переписування всієї програми.

5. Типові помилки під час роботи з 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.

1
Задача
Go SELF, 40 рівень, 2 лекція
Недоступна
Нумерований чат
Нумерований чат
1
Задача
Go SELF, 40 рівень, 2 лекція
Недоступна
Лічильник слів
Лічильник слів
1
Задача
Go SELF, 40 рівень, 2 лекція
Недоступна
Супердовгий рядок
Супердовгий рядок
1
Задача
Go SELF, 40 рівень, 2 лекція
Недоступна
Читання без Scanner
Читання без Scanner
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ