1. Потоковая модель: зачем читать и писать кусками
Если вы новичок, мозг естественно хочет «всё упростить»: прочитали файл целиком, получили []byte, дальше спокойно работаем. И на маленьких файлах это действительно прекрасно. Но мир, как обычно, портит всё: файлы бывают гигантские, ввод бывает бесконечным (например, поток из сети), а память не резиновая — даже если у вас 32 ГБ, они внезапно тоже могут закончиться.
Потоковая модель в Go — это идея: данные приходят и уходят кусками, и мы обязаны уметь работать с ними частями. Это не «оптимизация», а базовая техника. Даже когда вы читаете обычный файл, операционная система отдаёт данные не магически «всё сразу», а через системные вызовы, которые тоже работают порциями.
Представьте, что файл — это кран с водой, а ваш буфер []byte — стакан. Стакан можно наполнять многократно. Если пытаться сразу «перелить весь океан», возникают философские вопросы и ошибки выделения памяти.
io.Reader и io.Writer: два контракта, на которых держится I/O в Go
Перед тем как писать код, важно принять одну мысль: io.Reader и io.Writer — не «про файлы». Файл — это просто один из частных случаев. Эти интерфейсы описывают источник байтов и приёмник байтов. И это делает код гибким: один и тот же алгоритм может работать и с файлом, и со строкой, и с буфером, и с сетью.
Контракты выглядят так:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Самое важное здесь — не сигнатуры (их можно выучить), а смысл пары (n, err): n говорит, сколько байт реально обработано, а err — что пошло не так (или что данные закончились). В Go это прям «мини-протокол», и если вы его понимаете, вы перестаёте бояться I/O.
2. Контракт Read: как правильно читать (n, err)
Сейчас будет ключевой момент: Read(p) пытается заполнить ваш буфер p данными и возвращает, сколько байт реально положили. То есть после Read валидные данные — это только p[:n]. Остальная часть p — просто «старый мусор» (точнее, старые байты, которые там были до чтения).
Это место, где начинающие чаще всего ошибаются: они читают n, но потом печатают весь p, получая в выводе хвост от предыдущего чтения и задаваясь вопросом: «Go, ты чего?». Go ничего — это вы печатаете лишнее.
Ещё один важный факт: частичное чтение — это норма. n может быть меньше len(p) даже при err == nil. Например, данные пришли кусочком меньшего размера, или ОС решила отдать столько, сколько есть прямо сейчас.
И, конечно, io.EOF. В Go конец данных — это не «особый режим», а значение ошибки. При этом при реализации Read можно возвращать меньше байт, чем запросили, и EOF — нормальный сигнал завершения потока.
Таблица: как интерпретировать (n, err) в реальном коде
| n | err | Что это означает на практике |
|---|---|---|
|
|
Прочитали n байт, продолжаем |
|
|
Прочитали n байт и это последний кусок: обработать p[:n] и завершить цикл |
|
|
Данных больше нет, завершаем |
|
|
Редко, но возможно: обычно стоит просто продолжить (и не попасть в вечный цикл) |
|
|
Ошибка чтения, завершаем с ошибкой |
Главное правило: сначала обрабатываем n > 0, потом смотрим на ошибку. Иначе вы рискуете потерять последний кусок данных, когда пришло n > 0 вместе с io.EOF.
Базовый цикл чтения файла: читаем кусками и работаем с buf[:n]
Сейчас соберём каноничный шаблон. Он выглядит скучно, но это скука полезная: такой код переживёт вас, ваших внуков и пару смен ОС (ладно, это уже драматизация, но мысль ясна).
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open("tasks.txt")
if err != nil {
fmt.Println("open:", err)
return
}
defer f.Close()
buf := make([]byte, 4096)
for {
n, err := f.Read(buf)
if n > 0 {
fmt.Println("chunk bytes:", n) // например: chunk bytes: 4096
}
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read:", err)
return
}
}
}
Обратите внимание: мы пока ничего «умного» с данными не делаем, просто фиксируем механику. В реальном приложении вместо fmt.Println("chunk bytes:", n) будет обработка buf[:n]: подсчёт, поиск, парсинг, запись в другое место — что угодно.
Схема: правильный порядок проверок n и err
flowchart TD
A["Read(buf)"] --> B{n > 0?}
B -- да --> C["обработать buf[:n]"]
B -- нет --> D[ничего не обрабатывать]
C --> E{err == EOF?}
D --> E
E -- да --> F[break]
E -- нет --> G{err != nil?}
G -- да --> H[вернуть ошибку]
G -- нет --> A
3. Контракт Write: что такое short write и почему n нельзя игнорировать
После чтения хочется выдохнуть: «Окей, понял, n важно». И тут Go говорит: «Да-да. И в Write тоже». Потому что Write(p) тоже возвращает (n, err), и n тоже нельзя игнорировать.
Ситуация n < len(p) при err == nil называется short write («короткая запись»). Она встречается реже, чем partial read, но по контракту возможна. И если вы пишете надёжный код, вы обязаны либо корректно «дописать остаток», либо вернуть ошибку, если дописывать не умеете.
Иногда обсуждают приём «накапливать ошибку записи и дальше молча не писать», чтобы не размазывать if err != nil после каждого Write. Но здесь важнее механика: n нужно уважать.
Функция writeAll: дописываем, пока не запишем всё
package main
import "io"
func writeAll(w io.Writer, p []byte) error {
for len(p) > 0 {
n, err := w.Write(p)
if err != nil {
return err
}
p = p[n:]
}
return nil
}
Здесь весь смысл в строке p = p[n:]: мы «отрезаем» то, что уже записали, и пытаемся записать остаток. Это очень похожая логика на работу со слайсами, которую вы уже проходили: p[:n] — обработанная часть, p[n:] — хвост.
4. Практика: читаем tasks.txt потоково и считаем статистику
Чтобы лекция не была чистой теорией, давайте встроим это в наш учебный «таск-файл». Представим, что у нас есть файл tasks.txt, где каждая задача — отдельная строка. Пока мы не парсим задачи в структуры (это отдельная история), но уже можем сделать полезное: посчитать размер и количество строк, не читая файл целиком.
Подсчёт байт и строк через потоковое чтение
package main
import "io"
func countBytesAndLines(r io.Reader) (bytes int, lines int, err error) {
buf := make([]byte, 4096)
for {
n, readErr := r.Read(buf)
if n > 0 {
bytes += n
for _, b := range buf[:n] {
if b == '\n' {
lines++
}
}
}
if readErr == io.EOF {
return bytes, lines, nil
}
if readErr != nil {
return 0, 0, readErr
}
}
}
Заметьте, как красиво ложится «потоковый» стиль: мы не строим гигантскую строку, не делаем ReadFile, не просим память «потерпи ещё чуть-чуть». Мы читаем куски, в каждом куске считаем '\n', и всё.
Подключаем подсчёт к файлу
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("tasks.txt")
if err != nil {
fmt.Println("open:", err)
return
}
defer f.Close()
bytes, lines, err := countBytesAndLines(f)
if err != nil {
fmt.Println("count:", err)
return
}
fmt.Println("bytes =", bytes, "lines =", lines) // bytes = 123 lines = 7
}
Обратите внимание на важную архитектурную привычку, которую мы уже начинаем формировать: функция countBytesAndLines принимает io.Reader, а не *os.File. Это ещё не «большая архитектура», но уже правильное направление мышления: логика не должна зависеть от того, файл это или что-то другое.
5. Практика: показываем содержимое файла и не ломаемся на short write
Теперь сделаем вторую утилиту: вывести файл на экран (в stdout). Это то, что вы делали бы через os.ReadFile + fmt.Print(string(data)), но потоковый вариант более честный и хорошо тренирует контракт.
Копируем вручную: Read → writeAll
package main
import "io"
func dump(dst io.Writer, src io.Reader) error {
buf := make([]byte, 4096)
for {
n, readErr := src.Read(buf)
if n > 0 {
if err := writeAll(dst, buf[:n]); err != nil {
return err
}
}
if readErr == io.EOF {
return nil
}
if readErr != nil {
return readErr
}
}
}
Тут снова виден один и тот же «ритуал»: обработали n, затем разобрали err. Это ритм, который стоит довести до автоматизма.
Подключаем к файлу и os.Stdout
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("tasks.txt")
if err != nil {
fmt.Println("open:", err)
return
}
defer f.Close()
if err := dump(os.Stdout, f); err != nil {
fmt.Println("dump:", err)
return
}
}
Вот здесь случается маленькое «просветление»: файл — это io.Reader, Stdout — это io.Writer, а наша функция вообще не знает, что происходит. Она просто «перекачивает байты». И это одна из причин, почему Go-код для I/O часто получается очень переиспользуемым.
6. Типичные ошибки при работе с io.Reader/io.Writer
Ошибка №1: использовать весь буфер вместо buf[:n].
Самая частая проблема выглядит так: вы читаете n, _ := r.Read(buf), а потом печатаете string(buf) или считаете что-то по всему buf. В результате вы обрабатываете «хвост» от предыдущего чтения. Лечится просто: дисциплина «работаем только с buf[:n]».
Ошибка №2: сначала проверять err, а потом обрабатывать n.
Это классическая ловушка с io.EOF: Read имеет право вернуть n > 0 и одновременно err == io.EOF. В таком случае данные в buf[:n] — настоящие и их нельзя выбрасывать. Эту мысль полезно запомнить как мантру: «сначала данные, потом статус».
Ошибка №3: считать io.EOF «фатальной ошибкой» и печатать её как проблему.
io.EOF — это не «сломалось», а «данные закончились». В нормальном сценарии чтения файла EOF случится всегда. Если вы логируете EOF как ошибку, ваш лог будет выглядеть так, будто программа постоянно падает, хотя она просто дошла до конца файла. В примерах реализации чтения потоков EOF используется как штатный сигнал завершения.
Ошибка №4: игнорировать n у Write.
Да, обычно Write записывает всё. Но контракт говорит, что n важен. Если вы пишете код, который должен быть надёжным, лучше либо проверять n == len(p), либо использовать цикл дописывания (вроде writeAll). Это особенно полезно, когда вы пишете в «необычные» writer’ы (обёртки, сетевые соединения, собственные реализации).
Ошибка №5: делать бесконечный цикл на n == 0 и err == nil.
Такое бывает редко, но теоретически возможно: источник может временно не дать данных и не сообщить ошибку. Если ваш код устроен так, что он при этом ничего не меняет и просто крутится, вы получаете вечный цикл и нагрев ноутбука (а зимой это даже приятно, но всё равно баг). Практический способ не словить это — всегда иметь чёткую логику выхода (EOF/ошибка) и понимать, что «нулевое чтение без ошибки» — повод внимательно посмотреть на источник данных.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ