JavaRush /Курси /Go SELF /bufio.Reader/Writer: буферизація та Flush()

bufio.Reader/Writer: буферизація та Flush()

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

1. Навіщо потрібна буферизація

Коли ви тільки починаєте писати програми, легко міркувати так: «Ну я ж просто записую текст у файл — що тут може бути дорогим?». Але I/O — річ із характером. Будь-яке реальне читання чи запис часто впирається в системні виклики, драйвери, файлову систему, кеші ОС і все таке, що точно не виконується «за одну наносекунду».

Буферизація — це ідея «не смикати нижній рівень надто часто». Замість того щоб робити 10 000 маленьких записів по 1–10 байтів, ми складаємо дані в пам’ять і передаємо їх униз більшими порціями. Те саме і з читанням: ми читаємо з файла чи сокета «оптом» у пам’ять, а вже потім віддаємо вашому коду ті шматки, які він просить.

Уявіть кур’єра: якщо він їздитиме за кожною скріпкою окремо, ви швидко розоритеся. Якщо ж забере цілу коробку за один раз — ви вже красень-логіст. bufio у Go — це якраз така «коробка зі скріпками» між вашим кодом і реальним джерелом або приймачем байтів.

2. bufio.Reader: читання поверх io.Reader

bufio.Reader — це обгортка над будь-яким io.Reader. Зовні він поводиться як звичайний читач, але всередині тримає буфер, куди попередньо зчитує дані з нижнього джерела. Це дає два практичні плюси: по-перше, ви менше навантажуєте нижчий рівень I/O, а по-друге, отримуєте зручні методи на кшталт читання до роздільника.

Як створити bufio.Reader і що він «обгортає»

Почнімо з найпростішого: bufio.NewReader(r) приймає будь-який io.Reader. Це може бути файл (*os.File), strings.NewReader, мережеве з’єднання — що завгодно. І це важливо: bufio — не «про файли», а «про потік байтів».

package main

import (
	"bufio"
	"fmt"
	"strings"
)

func main() {
	r := bufio.NewReader(strings.NewReader("hello\nworld\n"))
	fmt.Println(r.Size()) // наприклад: 4096 (розмір буфера за замовчуванням)
}

Тут ми не робимо нічого корисного, але бачимо головну ідею: у bufio.Reader є внутрішній буфер. Він не зобов’язаний збігатися з обсягом даних, які ви читаєте.

Читання до "\n": ReadString і останній рядок

У навчальних задачах (і в реальних CLI-утилітах) дуже часто потрібно читати текст послідовно. Так, можна читати байти й вручну шукати "\n", але це той випадок, коли ви або пишете власний парсер, або просто використовуєте bufio.Reader. Ми не настільки багаті на час.

ReadString('\n') читає дані до роздільника і повертає рядок. Важлива деталь: якщо "\n" знайдено, він зазвичай включається в результат. Це зручно, але інколи несподівано.

package main

import (
	"bufio"
	"fmt"
	"io"
	"strings"
)

func main() {
	r := bufio.NewReader(strings.NewReader("a\nb\nlast"))

	for {
		line, err := r.ReadString('\n')
		if len(line) > 0 {
			fmt.Printf("line=%q\n", line) // line="a\n", потім "b\n", потім "last"
		}
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("read error:", err)
			return
		}
	}
}

Ключовий момент тут не в ReadString, а в обробці кінця потоку: останній рядок може прийти без "\n". І тоді ReadString('\n') поверне шматок даних (наприклад, "last") і помилку io.EOF одночасно. Якщо ви спершу перевірите err, а потім вирішите, що «EOF — усе, виходимо», ви втратите останній рядок і будете сумувати.

Ця «подвійна» ситуація (len(line) > 0 і err == io.EOF) — класика потокової роботи та пряме продовження того, що ви робили з (n, err) у «сирому» Read.

ReadBytes: те саме, але в []byte

Іноді вам зручніше працювати з байтами, а не з рядками. Наприклад, якщо ви хочете потім викликати bytes.TrimSpace або обробляєте дані як «сирі» байти.

ReadBytes('\n') схожий на ReadString('\n'), але повертає []byte.

package main

import (
	"bufio"
	"fmt"
	"io"
	"strings"
)

func main() {
	r := bufio.NewReader(strings.NewReader("x\ny\n"))

	for {
		b, err := r.ReadBytes('\n')
		if len(b) > 0 {
			fmt.Printf("chunk=%q\n", b) // chunk="x\n", chunk="y\n"
		}
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("read error:", err)
			return
		}
	}
}

Сенс той самий: спочатку обробляємо те, що отримали, і лише потім вирішуємо, що робити з помилкою.

3. bufio.Writer: запис і Flush()

Якщо bufio.Reader робить читання зручнішим, то bufio.Writer робить запис і швидшим, і… небезпечнішим для новачка. Тому що з’являється новий крок: дані можуть «застрягти» в буфері й не потрапити у файл або на виведення, доки ви не зробите Flush().

І так: це саме той момент, коли програміст уперше в житті пише програму, а вона «іноді не записує останні рядки». Звучить як містика, а насправді — просто забули Flush().

Як працює bufio.Writer і де лежать дані до Flush()

bufio.NewWriter(w) приймає будь-який io.Writer (файл, os.Stdout, bytes.Buffer) і починає накопичувати дані у внутрішньому буфері. У якийсь момент буфер заповнюється — і bufio.Writer сам проштовхує частину даних далі. Але якщо ви записали небагато даних, вони можуть так і лишитися всередині, доки ви не скажете: «Усе, завершуємо, виштовхуй залишок».

Ця команда й називається Flush().

Щоб відчути це на практиці, зручно взяти bytes.Buffer як «нижній» записувач.

package main

import (
	"bufio"
	"bytes"
	"fmt"
)

func main() {
	var out bytes.Buffer
	bw := bufio.NewWriter(&out)

	_, _ = bw.WriteString("hello")
	fmt.Println("now:", out.String()) // now: "" (поки порожньо!)

	_ = bw.Flush()
	fmt.Println("after flush:", out.String()) // after flush: "hello"
}

Так, ми тут знехтували помилками WriteString, бо приклад маленький. У реальному коді так не робимо.

Flush() — частина протоколу запису

Дуже важливо виробити звичку: Flush() — це повноцінна I/O-операція. Вона може повернути помилку. Іноді саме на Flush() помилка й проявляється, бо до цього дані лежали в пам’яті й нікуди реально не записувалися.

Цікава деталь: bufio.Writer усередині поводиться як «скарбничка помилок»: під час запису він може запам’ятати першу помилку й далі виконувати операції як no-op, а підсумкову проблему ви побачите на Flush().

Звідси практичне правило: якщо ви використали bufio.Writer, Flush() має стати таким самим обов’язковим кроком, як закриття файла.

Close() файла і Flush() буфера — різні сутності

Дуже часта думка: «Ну я ж зробив defer f.Close(), значить усе закриється і запишеться». Так от: Close() закриває файл, але не зобов’язане автоматично виштовхнути те, що ви накопичили в bufio.Writer, бо буфер — це окремий об’єкт.

Правильна послідовність зазвичай така: спочатку Flush(), потім Close() (або defer Close(), але Flush() усе одно робимо явно).

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	f, err := os.Create("tasks.txt")
	if err != nil {
		fmt.Println("create:", err)
		return
	}
	defer f.Close()

	bw := bufio.NewWriter(f)
	if _, err := bw.WriteString("first line\n"); err != nil {
		fmt.Println("write:", err)
		return
	}
	if err := bw.Flush(); err != nil {
		fmt.Println("flush:", err)
		return
	}
}

Так, можна зробити defer bw.Flush(), але тут є тонкий момент: по-перше, потрібно коректно обробити помилку Flush() (а помилки у defer часто губляться, якщо не бути уважним). По-друге, інколи ви хочете явно бачити: ось тут запис точно завершено.

Карта в голові: bufio.Reader vs bufio.Writer

Щоб не плутатися у відчуттях, корисно тримати перед очима просту схему: де саме лежать дані і хто кого «смикає».

flowchart TD
    subgraph R["ЧИТАННЯ (Reader)"]
        A["ваш код"] --> B["bufio.Reader\n(буфер у памʼяті)"] --> C["нижній io.Reader\n(файл / мережа / рядок)"]
    end

    subgraph W["ЗАПИС (Writer)"]
        D["ваш код"] --> E["bufio.Writer\n(буфер у памʼяті)"] --> F["нижній io.Writer\n(файл / stdout / буфер)"]
        E -.-> G["Flush()"]
        G -.-> F
    end

Сенс у тому, що під час читання bufio.Reader попередньо підвантажує дані, а під час запису bufio.Writer передає їх далі лише тоді, коли ви просите або коли буфер заповнюється. Тому Flush() — центральний ритуал, який не можна пропускати.

І так, це саме той випадок, коли в програмуванні корисно мати ритуали. Як чистити зуби. Тільки Flush() іноді повертає помилку.

4. Міні‑застосунок: «таск‑лист у файлі»

Тепер давайте зберемо невеликий фрагмент коду, який схожий на частину реального застосунку: у нас є список задач, і ми хочемо зберегти його у файл, а потім прочитати назад. Ми не будуємо «ідеальну архітектуру» (це окрема тема), але пишемо досить акуратно, щоб код не виглядав одноразовим.

Уявімо формат зберігання максимально простим: один рядок = одна задача. Жодних JSON, CSV чи іншої мороки — просто текст.

Запис задач порядково через bufio.Writer

Почнімо з функції, яка приймає потік запису й записує туди задачі. Для простоти вважаємо, що кожна задача — це рядок.

package main

import (
	"bufio"
	"fmt"
	"io"
)

func WriteTasks(w io.Writer, tasks []string) error {
	bw := bufio.NewWriter(w)

	for _, t := range tasks {
		if _, err := bw.WriteString(t + "\n"); err != nil {
			return fmt.Errorf("write task: %w", err)
		}
	}

	if err := bw.Flush(); err != nil {
		return fmt.Errorf("flush tasks: %w", err)
	}
	return nil
}

Тут важливі одразу два моменти. Ми не друкуємо помилки всередині функції, а повертаємо їх угору — у стилі Go. І ми вважаємо Flush() частиною контракту: якщо буфер не дотиснуто, запис не завершено.

Читання задач порядково через bufio.Reader

Тепер прочитаємо файл назад. Використовуємо ReadString('\n'). При цьому акуратно обробляємо кінець файла й останній рядок без "\n".

package main

import (
	"bufio"
	"fmt"
	"io"
	"strings"
)

func ReadTasks(r io.Reader) ([]string, error) {
	br := bufio.NewReader(r)

	var tasks []string
	for {
		line, err := br.ReadString('\n')
		if len(line) > 0 {
			tasks = append(tasks, strings.TrimRight(line, "\n"))
		}

		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, fmt.Errorf("read task line: %w", err)
		}
	}
	return tasks, nil
}

Так, strings.TrimRight(line, "\n") виглядає трохи дивно (чому не TrimSpace?). Але це свідомо: ми прибираємо лише символ переведення рядка, а пробіли в задачі нехай лишаються. Раптом користувач справді хоче задачу "купити молоко " — люди різні.

Склеїмо разом: зберегти й прочитати

Тепер покажемо маленький main, який записує задачі у файл і відразу читає їх назад. Це не «продуктовий сценарій», а просто перевірка.

package main

import (
	"fmt"
	"os"
)

func main() {
	tasks := []string{"купити молоко", "вивчити Go", "поспати"}

	f, err := os.Create("tasks.txt")
	if err != nil {
		fmt.Println("create:", err)
		return
	}
	if err := WriteTasks(f, tasks); err != nil {
		fmt.Println("write tasks:", err)
		_ = f.Close()
		return
	}
	_ = f.Close()

	in, err := os.Open("tasks.txt")
	if err != nil {
		fmt.Println("open:", err)
		return
	}
	defer in.Close()

	got, err := ReadTasks(in)
	if err != nil {
		fmt.Println("read tasks:", err)
		return
	}
	fmt.Println("loaded:", got) // loaded: [купити молоко вивчити Go поспати]
}

Зверніть увагу на тонкий момент: ми закрили файл після запису, а потім відкрили його заново на читання. Це не суворо обов’язково, але в таких мініперевірках допомагає мислити ясно: запис завершили, файл закрили, потім читаємо знову.

5. Типові помилки під час роботи з bufio.Reader/Writer

Помилка №1: «Я записав, але у файлі порожньо / немає останнього рядка».
Це майже завжди забутий Flush(). bufio.Writer може зберігати дані в пам’яті й не віддавати їх нижньому потоку запису одразу. Якщо програма завершилася, а буфер не було скинуто, ви втрачаєте хвіст даних. Виправлення просте: якщо використовуєте bufio.Writer, наприкінці запису обов’язково викличте Flush() і перевірте, чи не повернулася помилка.

Помилка №2: плутати Close() і Flush() та сподіватися, що «воно само».
Close() закриває ресурс (наприклад, файл), але bufio.Writer — окрема обгортка зі своїм внутрішнім станом. Спершу потрібно завершити роботу буфера (зазвичай Flush()), а вже потім закрити файл. Якщо намагатися закрити файл, не скинувши буфер, ви можете отримати частково записаний результат або дивні ефекти.

Помилка №3: втрачати останній рядок під час читання через ReadString('\n').
Якщо файл не закінчується переводом рядка (це абсолютно нормально), то остання порція даних повернеться разом із io.EOF. Якщо ваш код побудований за схемою «якщо помилка — виходимо», ви втрачаєте дані. Надійний патерн: спочатку обробляємо line (якщо вона не порожня), потім перевіряємо err, а io.EOF сприймаємо як штатне завершення.

Помилка №4: створювати bufio.Writer/bufio.Reader на кожну операцію.
Іноді новачки роблять обгортку всередині циклу: на кожній ітерації створюють новий bufio.NewWriter(f) і пишуть один рядок. Це майже повністю зводить буферизацію нанівець, бо ви постійно створюєте новий буфер і не отримуєте накопичення. Буферизатор зазвичай створюють на весь потік: один bufio.Writer на весь процес запису, один bufio.Reader на весь процес читання.

Помилка №5: ігнорувати помилки Flush() і вважати, що якщо WriteString не поскаржився, то все гаразд.
По-перше, Flush() — це I/O, і він може впасти. По-друге, сама модель роботи bufio.Writer передбачає накопичення та відкладену фіксацію помилок: він може поводитися як «скарбничка першої помилки», а назовні ви дізнаєтеся про проблему саме на Flush().

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