---
title: "Файлы и ввод-вывод"
description: "Файлы в Go: os.ReadFile и os.WriteFile, контракты io.Reader и io.Writer, построчный bufio.Scanner, буферизация bufio.Writer и Flush, defer Close."
course: "Продвинутые возможности Go: от замыканий до рефлексии"
url: "https://golangmaster.ru/courses/go-advanced/lessons/20-fajly-io/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Файлы и ввод-вывод

*Курс «Продвинутые возможности Go: от замыканий до рефлексии», урок 20 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-advanced/lessons/20-fajly-io/*

«Чтение файлов go» — запрос, с которого начинается практика: до этого урока все программы курса жили в памяти и печатали в консоль, а теперь мы выйдем наружу — к диску, потокам и файлам. Двадцатый урок курса собирает в одну картину os.ReadFile и os.WriteFile — простой путь «файл целиком за один вызов», базовые os.Open и os.Create с defer Close, контракты io.Reader и io.Writer из десятого урока, которые становятся вдруг не теорией, а ежедневным инструментом, и пакет bufio: «bufio golang» — это про буферизацию, Scanner для построчного чтения и Writer, который копит байты и отдаёт их диску порциями. Мост из прошлого урока лежит на виду: заголовки срезов — это готовые буферы, строки из байтов — это содержимое файлов, а EOF — не катастрофа, а штатный сигнал конца потока.

### Чтение файлов в Go: от вызова к контракту

Пора вывести программу за пределы собственной памяти. До сих пор каждый пример курса начинался и заканчивался в оперативной памяти: срезы, карты, каналы — всё это исчезало вместе с процессом. Файл — первый внешний мир, с которым программа говорит напрямую, и чтение файлов в Go устроено так, что вы уже знаете половину языка, на котором этот разговор идёт. Запрос «чтение файлов go» на самом деле распадается на три навыка: выполнить один вызов целиком (os.ReadFile), работать потоком через контракт io.Reader и ускорить оба пути буферизацией bufio. Этим трём навыкам и посвящён урок.

Начнём с карты местности. В стандартной библиотеке за ввод-вывод отвечает связка пакетов: os даёт доступ к файловой системе — открыть, создать, прочитать целиком, записать целиком, спросить размер; io описывает два главных контракта — Reader и Writer — и сигналы вроде io.EOF; bufio оборачивает любой поток буфером, чтобы не дёргать диск (или сеть) на каждый байт. Эти три слоя лежат друг на друге: os.File умеет и Reader, и Writer, bufio принимает любого Reader и отдаёт тоже Reader, а функция вроде fmt.Fprintf согласна писать в ANY Writer — в файл, в сеть, в буфер в памяти. Поэтому «прочитать файл» и «прочитать HTTP-ответ» — это один и тот же цикл с разными источниками.

Договоримся о честности песочницы заранее, как в прошлом уроке. Резервный движок знает только fmt, strings, strconv, math, sort, errors и time — пакет os, bufio и io он не исполняет вовсе, и мы зафиксируем точные тексты отказов. Основной движок (yaegi) исполняет настоящие файловые операции: os.WriteFile, os.ReadFile, bufio.Scanner — файлы реально создаются в /tmp, и вывод раннеров детерминирован. А там, где файлы недоступны, мы построим Reader своими руками из строки — чистый Go без единого импорта из «файловой» тройки: контракт io.Reader — это несколько строк кода, и никто не мешает реализовать его на строке. Такой ручной Reader исполняется обоими движками и станет нашим полигоном.

### io.Reader и io.Writer: два метода, на которых стоит весь ввод-вывод

Вспомним контракт из [урока про интерфейсы](/courses/go-advanced/lessons/10-interfejsy/) — теперь он понадобится ежедневно. io.Reader — это один метод: `Read(p []byte) (n int, err error)`. Смысл подписи: потребитель вызывает Read и ПЕРЕДАЁТ свой буфер p — «вот память, заполняй»; Read кладёт туда сколько смог и возвращает n — сколько байт реально записал; err — ошибка, и её особая форма io.EOF означает «данные кончились, это не сбой». Ключевые правила контракта: n может быть меньше len(p) — и это норма, а не ошибка; буфер отдаёт потребитель, поэтому размер порции выбирает тот, кто читает; io.EOF возвращается только вместе с n = 0 — «прочитал что-то и заодно EOF» контракт запрещает. io.Writer — зеркало: `Write(p []byte) (n int, err error)` — «вот байты, возьми сколько сможешь и скажи».

Отсюда канонический цикл чтения, который вы будете писать всю карьеру: объявить буфер, вызывать Read в цикле, прибавлять n к счётчику, при err != nil — выйти, причём io.EOF — это выход ШТАТНЫЙ, а всё остальное — по-настоящему ошибка. Именно этот цикл мы собирали в десятом уроке поверх strings.NewReader; сегодня источник станет файлом, но цикл не изменится ни на букву — в этом и смысл контракта. Файл, сетевое соединение, HTTP-тело, gz-архив, криптографический поток — всё говорит на одной сигнатуре Read, и функция, написанная против io.Reader, работает со всеми источниками сразу.

Почему нельзя «просто прочитать всё сразу»? Можно — os.ReadFile так и делает, — но у потока может не быть конца или его размер может быть гигабайтом: сетевое соединение бесконечно, конвейер (pipe) отдаёт байты по мере появления. Контракт Read придуман именно для таких источников: вы никогда не просите больше, чем ваш буфер, и никогда не держите в памяти больше одной порции. А буфер — это срез байт, и вот где байтовое мышление прошлого урока включается на полную: make([]byte, 4096) — это заголовок на 24 байта плюс массив на четыре килобайта, и передача его в Read не копирует массив — Read заполняет ВАШУ память, а не свою.

### Ручной Reader на строке: контракт своими руками

Раннер двойной: этот код исполняют ОБА движка — здесь нет ни одного импорта из файловой тройки, только errors и fmt. Запустите и сверьте: три порции по 4, затем 0 и EOF, итого 12 байт — len("привет") в байтах UTF-8, по два на каждую букву. Эксперимент: сделайте буфер make([]byte, 5) — порции станут 5, 5, 2, EOF; сделайте 12 — вся строка за один вызов и EOF вторым. Поменяйте строку на "hi" — один вызов по 2 байта. Обратите внимание: строка "привет" весит 12 байт, хотя букв шесть, — это то же байтовое знание из прошлого урока, теперь оно отвечает за содержимое файла.

```go
package main

import (
        "errors"
        "fmt"
)

// sReader — Reader над строкой: тот же контракт, что у файлов.
type sReader struct {
        s   string
        pos int
}

// Read заполняет буфер потребителя и честно сообщает, сколько байт ушло.
func (r *sReader) Read(p []byte) (int, error) {
        if r.pos >= len(r.s) {
                return 0, errors.New("EOF")
        }
        n := 0
        for r.pos < len(r.s) && n < len(p) {
                p[n] = r.s[r.pos]
                r.pos++
                n++
        }
        return n, nil
}

func main() {
        r := &sReader{s: "привет"}
        buf := make([]byte, 4)
        total := 0
        for {
                n, err := r.Read(buf)
                total += n
                fmt.Println("n =", n, "err =", err)
                if err != nil {
                        break
                }
        }
        fmt.Println("байт прочитано:", total)
}
```

### Проверьте себя

**Вопрос:** Источник исчерпан: вы вызываете Read ещё раз. Что контракт io.Reader предписывает вернуть?

- ✅ (0, io.EOF): ноль байт и штатный сигнал конца потока, а не ошибку
- (0, nil): чтение пустого куска — обычное событие без ошибки
- (-1, io.EOF): отрицательное n — знак, что данных больше нет
- (len(p), io.EOF): буфер заполняется нулями до конца, а ошибка сообщает о конце

По контракту при исчерпании источника Read возвращает 0 и io.EOF — предобъявленную ошибку пакета io, означающую штатный конец данных. Смешивать io.EOF с настоящими ошибками нельзя: цикл чтения обязан различать «конец» (выходим спокойно) и «сбой» (передаём наверх). Запрет «n > 0 вместе с io.EOF» существует, чтобы потребителю не приходилось гадать, считать ли порцию из p при ошибке: EOF пришёл — значит, порции нет. Поэтому в цикле сначала используют n, и только потом смотрят на err.

### os.ReadFile и os.WriteFile: файл целиком одним вызовом

Когда файл небольшой и вам нужны все его байты сразу, ручной цикл не нужен — стандартная библиотека даёт две функции-однострочника. os.ReadFile(path) открывает файл, читает его целиком в память и возвращает пару `(data []byte, err error)`; os.WriteFile(path, data, perm) создаёт (или усекает!) файл и пишет байты целиком, perm — права доступа, обычно 0644: владелец может всё, остальные — только читать. Это «простой путь» урока: два вызова закрывают девять из десяти бытовых задач — прочитать конфиг, сохранить отчёт, перегнать небольшой файл в строку string(data) или обратно []byte(s).

Три детали, на которых спотыкаются. Первая: ReadFile возвращает []byte, а не string — файл с точки зрения Go это БАЙТЫ, и только вы решаете, интерпретировать ли их как текст UTF-8 через string(data); конверсия копирует содержимое, у файла в гигабайт это второй гигабайт памяти. Вторая: os.WriteFile ПЕРЕЗАПИСЫВАЕТ файл с нуля — если там было длинное содержимое, а вы пишете короткое, хвост не «допишется», а исчезнет: файл усекается до нуля перед записью. Дописать в конец (append) через WriteFile нельзя — для этого открывают файл через os.OpenFile с флагом os.O_APPEND, о чём мы поговорим в блоке про os.Open. Третья: err надо проверять на ОБА вызова — ReadFile честно вернёт пустой срез и ошибку, если файла нет, и ошибка эта — обычное значение, которое функция возвращает парой с данными, как мы учились в уроке про обёртывание ошибок.

Где проходит граница применимости простого пути? В памяти. os.ReadFile держит весь файл в памяти разом: лог на сто мегабайт — сто мегабайт, лог на десять гигабайт — уже проблема, и не только памяти: пока файл читается целиком, вы не обработали НИ ОДНОЙ строки, хотя они были готовы с самого начала. Для больших файлов и потоков без конца нужен цикл Read или bufio — об этом ниже. Правило выбора простое: знаете размер и он помещается в память — ReadFile; не знаете или не помещается — поток.

### Запись и чтение файла целиком

Раннер нативный: файловые операции исполняет только основной движок — резервный откажет с текстом «пакет \"os\" недоступен в песочнице», файлы здесь создаются по-настоящему, в /tmp. Запустите: обе ошибки — nil, в файле 42 байта (по два на каждую кириллическую букву, плюс пробелы и переводы строк), содержимое печатается как две строки. Эксперимент: допишите третий вызов os.WriteFile с []byte("коротко") и снова прочитайте — файл стал пятибайтовым: усечение перед записью стёрло старое содержимое. Уберите второй аргумент прав 0644 — код не соберётся: perm обязателен.

```go
package main

import (
        "fmt"
        "os"
)

func main() {
        path := "/tmp/goadv20-zapis.txt"
        // Записываем байты целиком: права 0644 — запись владельцу, чтение всем.
        err := os.WriteFile(path, []byte("строка один\nстрока два\n"), 0644)
        fmt.Println("ошибка записи:", err)
        // Читаем файл целиком одним вызовом: data — все байты файла.
        data, err := os.ReadFile(path)
        fmt.Println("ошибка чтения:", err, "байт:", len(data))
        fmt.Print(string(data))
}
```

### Что напечатает цикл чтения?

```go
package main

import (
        "errors"
        "fmt"
)

// box — Reader над строкой: тот же контракт, что у файлов.
type box struct {
        data string
        pos  int
}

func (b *box) Read(p []byte) (int, error) {
        if b.pos >= len(b.data) {
                return 0, errors.New("EOF")
        }
        n := 0
        for b.pos < len(b.data) && n < len(p) {
                p[n] = b.data[b.pos]
                b.pos++
                n++
        }
        return n, nil
}

func main() {
        r := &box{data: "привет"}
        buf := make([]byte, 5)
        for {
                n, err := r.Read(buf)
                fmt.Println(n, err)
                if err != nil {
                        break
                }
        }
}
```

**Что выведет программа?**

- ✅ 5 / 5 / 2 / 0 EOF: две полные порции по пять байт, хвост из двух, затем конец
- 4 / 4 / 4 / 0 EOF: Read всегда забирает по четыре байта
- 12 nil: одна порция — вся строка сразу, EOF не приходит
- 0 EOF сразу: буфер длиной пять «больше» строки, читать нечего

Строка «привет» — это 12 байт UTF-8, а не шесть «символов»: по два байта на кириллическую букву. Буфер длиной 5 забирает порции 5, 5 и 2, четвёртый вызов — 0 и EOF. Это predict исполняют оба движка: код не трогает os, bufio и io — Reader самодельный, EOF самодельный. Поменяйте размер буфера на 4 — получите порции 4, 4, 4 и EOF: размер порции выбирает потребитель, и содержимое от этого не меняется, только нарезка.

### os.Open, os.Create и defer Close: ручка на файл

os.ReadFile хорош, пока файл целиком помещается в память. Как только вы хотите читать по кусочку, писать по кусочку или дописывать в конец — нужна ручка на файл: *os.File. Две функции её выдают: os.Open(path) открывает файл ТОЛЬКО на чтение, os.Create(path) создаёт файл или усекает существующий в ноль и открывает на запись (права 0666 — уточняются маской umask процесса). Обе возвращают пару `(*os.File, error)`, и первая проверка после вызова — err != nil: файла может не существовать, прав может не хватить, путь может оказаться каталогом. Тонкость, которую стоит запомнить: *os.File — это не «файл на диске», а дескриптор открытого файла в вашей программе; сам файл лежит на месте, ручка — в памяти, и её, как всякий ресурс операционной системы, надо закрывать.

Закрытие в Go делают идиомой `defer f.Close()` сразу после успешного открытия: отложенный вызов сработает при выходе из функции — хоть по return, хоть по панике — и дескриптор вернётся системе. Почему это важно: число открытых дескрипторов на процесс ограничено, программа, теряющая ручки в цикле, упадёт с «too many open files» на тысячах итераций; к тому же незакрытый файл на запись может потерять последние байты, если ОС держала их в своих буферах. А вот обратное заблуждение опаснее: Close — это НЕ запись данных. Данные, застрявшие в буфере bufio.Writer, при Close не дописываются — сначала Flush, потом Close, и к этому мы вернёмся в блоке про буферизацию.

Третий член семейства — os.OpenFile(path, flag, perm) — общий случай: флаги os.O_RDONLY, os.O_WRONLY, os.O_RDWR, os.O_APPEND, os.O_CREATE комбинируются через вертикальную черту, и так делается, например, дозапись в лог: os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644). В уроке нам хватит Open и Create, но знать про OpenFile стоит хотя бы для того, чтобы читать чужой код: это он, а не WriteFile, обеспечивает append-семантику.

### bufio.Scanner: построчное чтение без ручных буферов

Чтение текстового файла построчно — самая частая операция над файлами вообще, и писать её через голый Read с ручным поиском переводов строки никто не хочет. Для этого в пакете bufio живёт Scanner: `sc := bufio.NewScanner(f)` принимает ЛЮБОГО Reader (файл — Reader, мы это уже знаем) и разбивает поток на «токены» — по умолчанию строки. Цикл выглядит как заклинание: `for sc.Scan() { fmt.Println(sc.Text()) }` — Scan подготавливает следующую строку и возвращает true, пока строки есть; Text() отдаёт её без завершающего перевода строки. У Scanner есть и Bytes() — те же байты без копирования в строку, — и Err() — ошибка, если цикл кончился НЕ из-за конца данных: Scan возвращает false и на EOF, и на сбое, различать их обязаны вы, вызывая Err после цикла.

Что здесь такого волшебного? Буферизация. Scanner читает из файла не по байту, а большими кусками (стартовый буфер — 4 килобайта, растёт до 64К), ищет '\n' в куске и отдаёт строки из своего внутреннего буфера — диск дёргается в тысячи раз реже. Это та же экономия, что у шин на материнской плате из прошлого урока: за один дорогой ход забираем много дешёвых байт. Но у буфера есть потолок: по умолчанию одна строка не может быть длиннее 64 килобайт (bufio.MaxScanTokenSize), и Scan молча вернёт false с ошибкой «token too long». Логи JSON-строкой на мегабайт Scanner проглотить не сможет, пока вы не увеличите потолок через sc.Buffer(make([]byte, 0, 1024*1024), 1024*1024) — известная грабля, о которой честно предупреждает документация.

Scanner — читатель, писателем служит bufio.Writer, но перед ним мы прогоним Scanner по настоящему файлу: откроем через os.Open, поставим defer Close и пройдём циклом. Обратите внимание на типы в цепочке: os.Open вернула *os.File, NewScanner приняла её как io.Reader — файл не знал, что его обернули, а Scanner не знает, что внутри файл: контракт в действии.

### Построчное чтение через Scanner

Раннер нативный: bufio и os в резервном движке недоступны — запускайте на основном. Запустите: три строки по счётчику, ошибка сканера — nil (цикл кончился ШТАТНЫМ концом данных). Эксперимент: добавьте в конец файла четвёртую строку с завершающим \n — вывод не изменится, потому что Text() отдаёт строку без перевода строки; сотрите последний символ \n у «гаммы» — тоже ничего: последняя строка без перевода строки всё равно валидна. Поменяйте источник: NewScanner примет любую ручку, которая умеет Read, — файл, сетевое соединение, строку через strings.NewReader; контракт делает их взаимозаменяемыми, и Scanner не заметит подмены.

```go
package main

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

func main() {
        path := "/tmp/goadv20-stroki.txt"
        // Готовим файл с тремя строками (последняя — без перевода строки).
        os.WriteFile(path, []byte("альфа\nбета\nгамма"), 0644)
        // Открываем на чтение: *os.File сам реализует контракт Reader.
        f, err := os.Open(path)
        if err != nil {
                fmt.Println("не открылся:", err)
                return
        }
        // Закрытие гарантировано при любом выходе из main.
        defer f.Close()
        sc := bufio.NewScanner(f)
        i := 0
        for sc.Scan() {
                i++
                fmt.Println(i, sc.Text())
        }
        fmt.Println("ошибка сканера:", sc.Err())
}
```

### bufio.Writer: буферизация и Flush — зачем дописывать руками

Чтение мы ускорили буфером, теперь запись. bufio.NewWriter(f) оборачивает любого Writer в буфер на 4096 байт по умолчанию: все Write и fmt.Fprintf кладут байты НЕ в файл, а в этот буфер в памяти; диск получает данные, когда буфер заполнился, — по 4 килобайта за ход. Экономия та же, что у Scanner, в обратную сторону: тысячи мелких записей складываются в несколько больших. Записываете по одному числу в цикле из десяти тысяч итераций? Без буфера — десять тысяч системных вызовов; с буфером — три. Это самый дешёвый прирост производительности в вводе-выводе, доступный одной строкой кода.

Но у буфера есть обратная сторона, и она — главная ловушка урока: байты в буфере — это ещё НЕ файл. Пока буфер не полон, файл про них не знает. Забыли дописать — данные потеряны при выходе из программы. Метод Flush() выталкивает всё накопленное в настоящий Writer — его вызывают перед завершением записи ВСЕГДА; правильная последовательность на выходе: Flush, потом Close. Проверить, сколько байт ещё сидит в буфере, можно через w.Buffered(), а убедиться, что всё вытолкнуто, — через w.Flush() и проверку его ошибки: Flush тоже возвращает err, и молча проглотить его — значит не заметить сбой записи на самом ответственном шаге.

Тонкость с размерами: bufio.NewWriterSize(f, 1024) делает буфер нужного размера, а если одиночная запись БОЛЬШЕ буфера, Writer не глотает её частями, а пишет напрямую мимо буфера — в файл. Поэтому буферизация ускоряет именно много мелких записей и почти не влияет на несколько больших. И держите в голове asymmetry: у Scanner ошибка достаётся через Err() ПОСЛЕ цикла, у Writer — из каждого вызова Write и из Flush; у обоих ошибка НЕ возвращается там, где вы интуитивно ждёте её увидеть.

### Буфер: до Flush и после

Раннер нативный: запускайте на основном движке — файлы здесь настоящие. Запустите: до Flush на диске 0 байт, хотя две строки уже «записаны»; после Flush — обе строки на месте. Это живая демонстрация ловушки урока: буфер — память, Flush — диск. Эксперимент: закомментируйте w.Flush() и перезапустите — файл останется пустым навсегда. Добавьте fmt.Println("в буфере байт:", w.Buffered()) после второй записи — там 52: каждая из двух строк весит 26 байт, кириллица по два байта на букву, плюс пробел и перевод строки.

```go
package main

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

func main() {
        path := "/tmp/goadv20-bufer.txt"
        f, _ := os.Create(path)
        w := bufio.NewWriter(f)
        // Эти две строки попадают в буфер в памяти, НЕ на диск.
        w.WriteString("первая строка\n")
        w.WriteString("вторая строка\n")
        // Читаем файл ДО Flush: байтов на диске ноль.
        before, _ := os.ReadFile(path)
        fmt.Println("до Flush байт:", len(before))
        // Выталкиваем буфер в файл — только теперь данные настоящие.
        w.Flush()
        f.Close()
        after, _ := os.ReadFile(path)
        fmt.Print("после Flush:\n")
        fmt.Print(string(after))
}
```

### Проверьте себя

**Вопрос:** Программа записала три строки через bufio.Writer и завершилась, не вызвав Flush. Что окажется на диске?

- ✅ Ничего: все три строки жили в буфере памяти и погибли вместе с программой
- Первые 4096 байт: всё, что успело вытечь из буфера до его заполнения
- Все три строки: Close сам вызывает Flush по умолчанию
- Последнюю строку: первые две ушли из буфера при переполнении

Буфер по умолчанию — 4096 байт; три короткие строки в него помещаются целиком и никуда с диска не попадают, а при завершении программы буфер — обычная память — исчезает. Close у *os.File закрывает дескриптор, но НЕ вытолкивает чужой bufio-буфер: Flush вызывается руками, и его ошибка проверяется. Если бы суммарная запись превысила 4096 байт, Writer записал бы полные порции в файл без всякого Flush (запись больше буфера идёт напрямую), и на диске лежали бы первые 4096 байт — но рассчитывать на это нельзя: дисциплина всегда одна — Flush до Close.

### Размер файла, os.Stat и выбор инструмента

Прежде чем читать файл, полезно знать, СКОЛЬКО его. os.Stat(path) возвращает структуру FileInfo с методами Size() (размер в байтах), IsDir(), ModTime() и именем; для несуществующего пути Stat вернёт ошибку — так в Go проверяют существование файла. Size помогает принять решение о стратегии: файл на сотню килобайт — os.ReadFile и не думать, файл на гигабайт — только потоки, файл в сети — размер заранее неизвестен вовсе, значит ReadFile по определению не подходит. Второе применение Stat — метаданные: время модификации для кешей, признак каталога при обходе дерева через os.ReadDir.

Соберём полную лестницу инструментов урока в один список выбора. Нужно все байты сразу и файл небольшой — os.ReadFile. Нужно записать всё сразу — os.WriteFile с правами 0644. Нужно читать поток по строкам — os.Open плюс bufio.Scanner (не забыть Err после цикла). Нужно писать много мелких кусочков — os.Create плюс bufio.NewWriter, в конце Flush и Close. Нужно дописывать в существующий файл — os.OpenFile с os.O_APPEND. Нужен сам размер или существование — os.Stat. Каждая ступенька стоит одной проверки ошибки дороже предыдущей, и именно эта лестница, а не отдельные функции, и есть ответ на запрос «чтение файлов go»: язык даёт вам и однострочник, и полный контроль, выбор — за размером задачи.

И последнее — про обработку ошибок, без которой ни одна ступенька не работает. Ошибки файлов говорят сами за себя: прочтите их текст — «open /tmp/x.txt: no such file or directory» содержит операцию, путь и причину; вам остаётся решить, что делать: завершиться с сообщением пользователю, пропустить файл в цикле или попробовать другой путь. Оборачивать ошибку контекстом полезно: fmt.Errorf("читаю конфиг %s: %w", path, err) сохранит причину для errors.Is и добавит, ЧТО вы делали — через полгода это спасёт час отладки. Единственная ошибка, которую НЕ надо считать провалом, — io.EOF из цикла чтения: это штатный сигнал конца, и смешивать её с настоящими сбоями — самая частая ошибка новичка в этом жанре.

> **Честно о песочнице: где файлы исполняются.** Резервный движок знает только fmt, strings, strconv, math, sort, errors и time — файловой тройки в нём нет: import "os" даёт отказ «пакет "os" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)», точно такой же текст — для "bufio" и "io". Более того, даже strings.NewReader там нет: «у пакета strings нет члена NewReader» — поэтому Reader-полигон урока собран руками из строки, и он исполняется обоими движками. Основной движок (yaegi) исполняет настоящие файловые операции: os.WriteFile, os.ReadFile, os.Open, os.Create, bufio.Scanner, bufio.Writer с Flush — файлы реально создаются и читаются в /tmp, раннеры урока нативны и детерминированы (мы прогнали каждый трижды). Запуск runner-ов с файлами на резервном движке бесполезен — переключитесь на основной.

### Допишите цикл чтения до EOF

Восстановите вручную канонический цикл: размер буфера и оператор выхода из цикла при ошибке. Ответы введите маленькими буквами, без пробелов.

```go
package main

import (
        "errors"
        "fmt"
)

// box — Reader над строкой: тот же контракт, что у файлов.
type box struct {
        data string
        pos  int
}

func (b *box) Read(p []byte) (int, error) {
        if b.pos >= len(b.data) {
                return 0, errors.New("EOF")
        }
        n := 0
        for b.pos < len(b.data) && n < len(p) {
                p[n] = b.data[b.pos]
                b.pos++
                n++
        }
        return n, nil
}

func main() {
        r := &box{data: "привет"}
        buf := make([]byte, ___)
        for {
                n, err := r.Read(buf)
                fmt.Println(n, err)
                if err != nil {
                        ___
                }
        }
}
```

Подсказка: Размер буфера должен делить 12 байт строки на порции по четыре; выход из for-цикла при любой ошибке — один оператор.

Правильные ответы в пропусках: `4`, `break`

### Соберите Reader и одно чтение

Соберите программу: тип-Reader над строкой, затем в main одно чтение в буфер и печать «сколько взяли, что взяли, какая ошибка». Две строки импорта — по одной на пакет.

Правильный порядок строк:

1. `package main`
2. `import "fmt"`
3. `import "errors"`
4. `type box struct {`
5. `data string`
6. `pos  int`
7. `}`
8. `func (b *box) Read(p []byte) (int, error) {`
9. `if b.pos >= len(b.data) {`
10. `return 0, errors.New("EOF")`
11. `}`
12. `n := 0`
13. `for b.pos < len(b.data) && n < len(p) {`
14. `p[n] = b.data[b.pos]`
15. `b.pos++`
16. `n++`
17. `}`
18. `return n, nil`
19. `}`
20. `func main() {`
21. `r := &box{data: "hi"}`
22. `buf := make([]byte, 8)`
23. `n, err := r.Read(buf)`
24. `fmt.Println(n, string(buf[:n]), err)`
25. `}`

### Шпаргалка: файлы и потоки из этого урока

| Инструмент | Что делает | Как выглядит | Осторожно |
| --- | --- | --- | --- |
| os.ReadFile | файл целиком в память, []byte | data, err := os.ReadFile(path) | гигабайтный файл — гигабайт памяти |
| os.WriteFile | записать []byte целиком | os.WriteFile(path, data, 0644) | усекает файл: старое содержимое стирается |
| os.Open | открыть на чтение, *os.File | f, err := os.Open(path) | сразу defer f.Close() после проверки err |
| os.Create | создать/усечь на запись | f, err := os.Create(path) | права 0666, существующий файл обнуляется |
| os.OpenFile | открыть с флагами | os.OpenFile(p, os.O_APPEND|os.O_CREATE, 0644) | это единственный честный append |
| os.Stat | размер и метаданные | st, err := os.Stat(path); st.Size() | ошибка = файла нет; проверка существования |
| io.Reader | контракт чтения потока | Read(p []byte) (n int, err error) | буфер даёт потребитель; n < len(p) — норма |
| io.Writer | контракт записи потока | Write(p []byte) (n int, err error) | n может быть меньше len(p) — проверяйте |
| io.EOF | штатный конец потока | if err == io.EOF { break } | не сбой; с n > 0 не приходит |
| bufio.Scanner | построчное чтение | for sc.Scan() { sc.Text() } | строка длиннее 64К — token too long; Err() после цикла |
| bufio.Writer | буферизованная запись | w := bufio.NewWriter(f) … w.Flush() | без Flush данные теряются; запись больше буфера идёт напрямую |
| defer f.Close() | гарантированное закрытие | defer f.Close() | Close не пишет данные: сначала Flush |

### Правила гигиены ввода-вывода

- Размер решает всё: файл, который помещается в память, — os.ReadFile; поток неизвестного или огромного размера — только io.Reader-цикл или bufio.
- Проверяйте err у КАЖДОГО вызова: ReadFile, WriteFile, Open, Create, Flush — каждая возвращает ошибку парой с результатом, и молча проглоченная ошибка превращается в пустой файл.
- defer f.Close() ставьте сразу после успешного os.Open/os.Create: дескрипторы — ограниченный ресурс, утечка проявляется как «too many open files» на тысячной итерации.
- Порядок завершения записи: w.Flush() с проверкой ошибки, потом f.Close() — Close не вытолкивает чужой буфер.
- io.EOF — не ошибка, а сигнал конца: в цикле чтения выходите по ней спокойно, наверх передавайте всё остальное.
- Для строк — bufio.Scanner (и не забудьте sc.Err() после цикла: false бывает и от сбоя), для байтов — Read с буфером, размер которого выбираете вы.
- os.WriteFile и os.Create усекают файл до нуля: дописывать в конец умеет только os.OpenFile с os.O_APPEND.
- Права 0644 — осмысленный дефолт для данных: запись владельцу, чтение всем; 0777 на файл — почти всегда баг.
- У длинных строк в Scanner есть потолок 64К (bufio.MaxScanTokenSize): «token too long» лечится sc.Buffer, а не отказом от Scanner.
- Буфер ускоряет МНОГО мелких записей: одна большая запись проскочит мимо буфера напрямую в файл — и это нормально.

### Что дальше: из байтов в данные

Соберём урок в одну карту. Контракты: io.Reader — Read(p []byte) (n int, err error) с буфером от потребителя и io.EOF как штатным концом, io.Writer — Write с честным n; любой файл, сеть и буфер говорят на этой паре сигнатур. Простой путь: os.ReadFile и os.WriteFile — файл целиком, но память платит за всё, а WriteFile усекает. Ручки: os.Open, os.Create, defer Close сразу после проверки ошибки, os.OpenFile для append. Буферизация: bufio.Scanner для строк (Scan/Text/Err, потолок 64К), bufio.Writer для записи (4096 по умолчанию, Flush обязателен, запись больше буфера идёт напрямую). Метаданные: os.Stat и Size для выбора стратегии. Мост из прошлого урока работает на каждом шаге: буфер — это срез с заголовком на 24 байта, строка — байты UTF-8, len считает именно их, а Reader на строке — двадцать строк чистого Go.

Двадцать первый урок — JSON: кодирование и декодирование. Файлы дали нам байты, но байты без формы — просто содержимое; encoding/json научит складывать структуры в текст и доставать их обратно: struct tags, Marshal, Unmarshal, вложенные структуры. Связка прямая: прочитали конфиг os.ReadFile — развернули байты в структуру Unmarshal'ом; собрали данные — Marshal и os.WriteFile. Байтовое мышление этого урока останется с вами: JSON — это те же байты UTF-8, просто с договорённостью о форме.

До встречи на [странице курса](/courses/go-advanced/). Если захочется перечитать основу контрактов — [урок про интерфейсы](/courses/go-advanced/lessons/10-interfejsy/): io.Reader/Writer мы разбирали именно там, а сегодня файлы превратили теорию в инструмент. Если захочется вернуться к байтам и заголовкам — [урок про unsafe и память](/courses/go-advanced/lessons/19-unsafe/): буфер, который вы передаёте в Read, — это тот самый срез с заголовком на 24 байта, чей вес мы измеряли Sizeof'ом.

### Карточки урока

- **Контракт io.Reader** — Один метод: Read(p []byte) (n int, err error). Буфер p даёт ПОТРЕБИТЕЛЬ — вы решаете, какими порциями читать. n — сколько байт реально легло (может быть меньше len(p) — это норма). err — ошибка; io.EOF означает штатный конец данных и приходит только с n = 0. Канонический цикл: Read в for, использовать n, выход при err != nil, io.EOF — спокойный выход, остальное — наверх. Источники-строки в тестах — strings.NewReader; файл, сеть, HTTP-тело говорят на той же сигнатуре.
- **os.ReadFile и os.WriteFile** — Простой путь «файл целиком». data, err := os.ReadFile(path) — все байты файла в памяти; os.WriteFile(path, data, 0644) — запись целиком с правами 0644. ReadFile возвращает []byte, а не string: файл — это байты, string(data) — отдельная конверсия с копированием. WriteFile УСЕКАЕТ файл перед записью — короткая запись стирает длинное содержимое; дописывание (append) — только os.OpenFile с os.O_APPEND. Для больших файлов и потоков не годится: весь размер — в памяти разом.
- **os.Open, os.Create и defer Close** — os.Open — ручка *os.File на чтение; os.Create — создать или усечь и открыть на запись (права 0666, уточняются umask). После получения ручки — сразу defer f.Close(): дескрипторы ограничены, утечка даёт «too many open files». Close НЕ записывает данные из bufio-буфера: сначала Flush, потом Close. Общий случай — os.OpenFile(path, flag, perm) с флагами O_RDONLY/O_WRONLY/O_RDWR/O_APPEND/O_CREATE, комбинируемыми через |.
- **bufio.Scanner: построчное чтение** — sc := bufio.NewScanner(f) оборачивает любого Reader; цикл «for sc.Scan() { sc.Text() }» отдаёт строки без '\n'. Scan читает большими кусками (стартовый буфер 4К) — диск дёргается реже в тысячи раз. Потолок по умолчанию — 64К на строку (bufio.MaxScanTokenSize): длиннее — false и «token too long», лечится sc.Buffer. После цикла обязателен sc.Err(): Scan возвращает false и на EOF, и на сбое — различать должны вы.
- **bufio.Writer и буферизация** — w := bufio.NewWriter(f) — буфер 4096 байт по умолчанию (NewWriterSize — свой размер); Write/Fprintf кладут байты в память, диск получает их порциями по заполнении буфера. Байты в буфере — ещё НЕ файл: без Flush() они умирают вместе с программой. Дисциплина: Flush с проверкой ошибки, затем Close. Запись больше буфера идёт напрямую в файл мимо буфера; Buffered() показывает, сколько байт ещё сидит в памяти. Ускоряет именно много мелких записей.
- **EOF и обработка ошибок ввода-вывода** — io.EOF — предобъявленная ошибка «данные кончились», штатный сигнал, а не сбой; в цикле чтения — break, наружу не передавать. Настоящие ошибки файлов говорят сами за себя: «open x.txt: no such file or directory» — операция, путь, причина; оборачивайте контекстом fmt.Errorf("читаю %s: %w", path, err) для errors.Is. Существование файла проверяют os.Stat по ошибке. В песочнице: фолбэк не знает os/bufio/io (даже strings.NewReader), натив исполняет файловые операции в /tmp; ручной Reader на строке исполняют оба движка.

## Коротко: что запомнить

- Ввод-вывод в Go стоит на двух контрактах: io.Reader — Read(p []byte) (n int, err error), io.Writer — Write(p []byte) (n int, err error); буфер отдаёт потребитель, n честно показывает, сколько байт обработано.
- io.EOF — штатный сигнал конца потока, а не ошибка: канонический цикл «Read → использовать n → при err != nil выйти», где EOF — спокойный выход, всё остальное — передача наверх.
- os.ReadFile и os.WriteFile — файл целиком одним вызовом: data, err := os.ReadFile(path); os.WriteFile(path, data, 0644); WriteFile усекает файл, дописывание — только os.OpenFile с os.O_APPEND.
- os.Open и os.Create дают ручку *os.File — дескриптор ограниченного ресурса: defer f.Close() сразу после проверки ошибки, иначе «too many open files» на тысячах итераций.
- Close не записывает данные: перед закрытием bufio.Writer обязателен Flush с проверкой ошибки — иначе буфер умирает вместе с программой.
- bufio.Scanner — построчное чтение: for sc.Scan() { sc.Text() }, буферизация кусками от 4К, потолок 64К на строку, sc.Err() после цикла различает конец и сбой.
- bufio.Writer — буферизованная запись: 4096 байт по умолчанию, запись больше буфера идёт напрямую в файл, ускоряет именно много мелких записей.
- os.Stat даёт размер и метаданные — по ним выбирают стратегию: небольшой файл — ReadFile, гигабайт или поток — только цикл Read/Scanner.
- Файл — это байты: ReadFile возвращает []byte, string(data) — отдельная конверсия с копированием; len считает байты UTF-8, «привет» — 12 байт.
- Песочница честна: фолбэк не знает os, bufio, io (и даже strings.NewReader), нативный движок исполняет настоящие файловые операции в /tmp; ручной Reader из двадцати строк чистого Go исполняют оба.

## Домашнее задание

Часть первая — ручной Reader: наберите sReader из раннера и прогоните строку "привет" с буферами 3, 4, 5 и 12; выпишите порции для каждого размера и объясните письменно, почему содержимое всегда одно, а нарезка разная. Часть вторая — файл целиком: напишите программу, которая os.WriteFile'ом создаёт файл с тремя вашими любимыми строками, os.ReadFile'ом читает его и печатает len(data) рядом с содержимым; затем запишите в тот же файл одно короткое слово и объясните в комментарии, почему len уменьшился. Часть третья — Scanner: создайте файл на пять строк (последняя без перевода строки), прочитайте его os.Open плюс bufio.Scanner с нумерацией, после цикла напечатайте sc.Err(); затем добавьте строку длиной в сто тысяч символов, получите «token too long» и вылечите его через sc.Buffer — размер потолка подберите сами. Часть четвёртая — буфер: os.Create, bufio.NewWriterSize(f, 32), запишите в цикле двадцать коротких строк, ПОСЛЕ цикла напечатайте w.Buffered(), прочитайте файл до Flush (объясните ноль), выполните Flush и прочитайте снова; в комментарии опишите порядок «Flush → Close» и что случится, если его нарушить. Комментарии в коде — по-русски.

## Вопросы и ответы по уроку

### Когда брать os.ReadFile, а когда os.Open с bufio?

Ориентир — размер и форма обработки. Файл целиком помещается в память и нужен весь сразу (конфиг, шаблон, небольшой JSON) — os.ReadFile: один вызов, минимум кода. Файл большой (сотни мегабайт и больше), поток без конца (сеть, pipe) или обрабатывать надо построчно на ходу — os.Open плюс bufio.Scanner для строк или цикл Read для байтов: память тратится на одну порцию, а не на весь файл. Середина — вопрос вкуса и профиля, но правило «знаю размер и он скромный — ReadFile» спасает от девяноста процентов проблем.

### Почему данные не записались, хотя Write прошёл без ошибки?

Почти наверняка вы пишете через bufio.Writer и забыли Flush. Байты лежали в буфере в памяти (по умолчанию 4096), файл про них не знал, и при завершении программы буфер исчез вместе с содержимым. Дисциплина: w.Flush() с проверкой ошибки, затем f.Close(). Учтите, что Close закрывает дескриптор, но НЕ вытолкивает чужой bufio-буфер, а записи, превышающие размер буфера, bufio пропускает напрямую в файл — поэтому иногда «забытый Flush» маскируется частично записанными данными.

### Что такое io.EOF и надо ли его обрабатывать как ошибку?

io.EOF — предобъявленная в пакете io ошибка-сигнал: «данных больше нет». Это штатный конец потока, а не сбой: в цикле чтения по err == io.EOF выходят спокойно, без логов и ретраев. Настоящие ошибки (нет файла, нет прав, обрыв сети) передаются наверх или обрабатываются по месту. Отдельное правило контракта: io.EOF приходит только с n = 0 — если Read вернул данные, ошибки при этом быть не может, поэтому сначала используйте n, потом проверяйте err.

### Работают ли файлы в песочнице самоучителя?

Частично, и это зафиксировано честно. Резервный движок знает только fmt, strings, strconv, math, sort, errors и time: import "os" даёт отказ «пакет "os" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)», то же — для "bufio" и "io"; даже strings.NewReader там отсутствует («у пакета strings нет члена NewReader»). Основной движок (yaegi) исполняет настоящие файловые операции: os.WriteFile, os.ReadFile, os.Open, os.Create, bufio.Scanner, bufio.Writer с Flush — файлы реально создаются и читаются в /tmp. Поэтому файловые раннеры урока нативные, а Reader-полигон собран руками из строки и исполняется обоими движками.
