JavaRush /Курсы /Go SELF /io.Reader/io.Writer — потоковая модель для файлов

io.Reader/io.Writer — потоковая модель для файлов

Go SELF
40 уровень , 0 лекция
Открыта

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 Что это означает на практике
>0
nil
Прочитали n байт, продолжаем
>0
io.EOF
Прочитали n байт и это последний кусок: обработать p[:n] и завершить цикл
0
io.EOF
Данных больше нет, завершаем
0
nil
Редко, но возможно: обычно стоит просто продолжить (и не попасть в вечный цикл)
0
!= nil
Ошибка чтения, завершаем с ошибкой

Главное правило: сначала обрабатываем 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/ошибка) и понимать, что «нулевое чтение без ошибки» — повод внимательно посмотреть на источник данных.

1
Задача
Go SELF, 40 уровень, 0 лекция
Недоступна
Счётчик трафика
Счётчик трафика
1
Задача
Go SELF, 40 уровень, 0 лекция
Недоступна
Потоковое эхо
Потоковое эхо
1
Задача
Go SELF, 40 уровень, 0 лекция
Недоступна
Файловая статистика
Файловая статистика
1
Задача
Go SELF, 40 уровень, 0 лекция
Недоступна
Копия архива
Копия архива
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ