1. Зачем нужен io.Copy
Когда вы только начинаете писать программы, очень хочется всё делать «вручную»: прочитали кусок, записали кусок, повторили. Это ощущается честно, как ручная коробка передач: кажется, что вы контролируете каждый байт. Но в I/O есть коварная деталь: «простой цикл» почти всегда обрастает проверками, обработкой частичных чтений, коротких записей и корректным завершением по EOF. И внезапно ваш «простой» код становится местом, где удобно поселить баг, который будет всплывать раз в тысячу запусков — то есть идеально, чтобы бесить вас именно на проде.
Посмотрим на максимально типичную ручную реализацию копирования из io.Reader в io.Writer. Она выглядит не страшно — пока вы не начнёте добавлять детали:
package main
import (
"io"
)
func CopyManual(dst io.Writer, src io.Reader) (int64, error) {
buf := make([]byte, 32*1024)
var total int64
for {
n, err := src.Read(buf)
if n > 0 {
w, werr := dst.Write(buf[:n])
total += int64(w)
if werr != nil {
return total, werr
}
if w != n {
return total, io.ErrShortWrite
}
}
if err == io.EOF {
return total, nil
}
if err != nil {
return total, err
}
}
}
Заметьте, сколько здесь «протокола»: буфер, buf[:n], проверка io.EOF, проверка w != n, отдельная ошибка записи. И это ещё хорошая версия. На практике новички часто забывают хотя бы одну из этих вещей.
И вот тут появляется io.Copy: это стандартная, хорошо протестированная реализация переноса байт из src в dst. Она делает примерно то же самое, но делает это за вас — и (важно!) делает это привычно для всей экосистемы Go.
Сигнатура и порядок аргументов
Когда вы впервые видите io.Copy, есть две классические эмоции: «О, красиво!» и «Подождите… а куда первым аргументом, а откуда вторым?». Это нормально: у всех в голове живёт травма от append(dst, src...), где порядок тоже важен.
Сигнатура такая:
func Copy(dst io.Writer, src io.Reader) (written int64, err error)
То есть сначала куда пишем, потом откуда читаем. Мнемоника простая: Copy(dst <- src). В отличие от вашей ручной реализации, здесь буфер обычно управляется внутри io.Copy.
Мини-пример на «безопасных» типах (без файлов), чтобы не отвлекаться на Close():
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("hello, copy")
var dst bytes.Buffer
written, err := io.Copy(&dst, src)
fmt.Println("written:", written) // written: 11
fmt.Println("err:", err) // err: <nil>
fmt.Println("dst:", dst.String()) // dst: hello, copy
}
Здесь три важных наблюдения.
- Во‑первых, written — это количество записанных байт, тип int64. Это не «количество итераций» и не «длина буфера», а фактический объём перенесённых данных.
- Во‑вторых, err — это ошибка, которая могла прийти как со стороны чтения, так и со стороны записи. io.Copy работает «между двумя мирами» и честно сообщает, если кто-то из этих миров начал капризничать.
- В‑третьих, io.Copy работает не только с файлами. Всё, что реализует io.Reader/io.Writer, подходит. Именно это делает код на Go таким «собираемым из кубиков».
EOF, частичные чтения и короткие записи
Внутри io.Copy происходит та же «механика протокола», которую вы уже учили: читаем кусок, пишем кусок, повторяем до конца. И здесь самое приятное: вам не нужно каждый раз вспоминать, в каком порядке обрабатывать n и err, и что делать с io.EOF. io.Copy умеет корректно дочитать поток до конца и завершиться без ошибки, когда данные закончились.
Если переложить это на схему, получается примерно так:
flowchart LR
S["src: io.Reader"] -->|"Read(p)"| C["io.Copy (цикл Read/Write)"]
C -->|"Write(p[:n])"| D["dst: io.Writer"]
C -->|"returns (written, err)"| R["caller"]
То есть io.Copy — это «правильный цикл», упакованный в стандартную библиотеку.
Ещё один полезный момент: у io.Copy есть оптимизации, но вам не нужно о них думать на уровне новичка. В некоторых случаях копирование может происходить более эффективно, чем «прочитал в буфер → записал из буфера», но снаружи контракт остаётся тем же. Ваш код остаётся простым, а стандартная библиотека старается сделать быстро.
Копирование файлов без утечек ресурсов
Теперь вернёмся к практике с файлами. В предыдущих днях вы уже открывали файлы через os.Open/os.Create и закрывали через defer Close(). Это важно, потому что при копировании файлов у нас сразу два ресурса: источник и приёмник. И если вы забудете закрыть хотя бы один — получите либо утечку файловых дескрипторов, либо незавершённую запись.
Классический пример — функция CopyFile. Она настолько каноничная, что её часто приводят в материалах по defer: идея в том, что defer помогает не забыть закрыть файлы даже при раннем return.
package main
import (
"io"
"os"
)
func CopyFile(dstName, srcName string) (int64, error) {
src, err := os.Open(srcName)
if err != nil {
return 0, err
}
defer src.Close()
dst, err := os.Create(dstName)
if err != nil {
return 0, err
}
defer dst.Close()
return io.Copy(dst, src)
}
Почему это лучше, чем «закрыть руками в конце»? Потому что если у вас os.Create упадёт с ошибкой, а src уже открыт, вы должны всё равно закрыть src. defer позволяет «привязать закрытие» сразу после успешного открытия, и это считается одной из базовых практик.
Контекст ошибок и частичный прогресс
Если io.Copy вернул ошибку, новичок часто делает так: «Ну вернул ошибку и вернул, что ещё надо». А надо, как правило, чуть больше: добавить контекст. Ошибка вида permission denied без контекста — как сообщение «всё плохо» без уточнения, где именно.
В Go принято добавлять контекст через fmt.Errorf. В этой лекции достаточно простого варианта без углубления: вы просто добавляете текст операции.
package main
import (
"fmt"
"io"
"os"
)
func BackupTasks(dstName, srcName string) (int64, error) {
src, err := os.Open(srcName)
if err != nil {
return 0, fmt.Errorf("open source: %v", err)
}
defer src.Close()
dst, err := os.Create(dstName)
if err != nil {
return 0, fmt.Errorf("create dest: %v", err)
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
return n, fmt.Errorf("copy data: %v", err)
}
return n, nil
}
Здесь важная деталь: я возвращаю n даже при ошибке копирования. Это честно: иногда полезно знать, что успело скопироваться, прежде чем что-то пошло не так. io.Copy как раз и даёт вам written, чтобы вы могли принять решение на уровне приложения: это «частичный успех» или «полный провал».
bufio.Writer: не забываем Flush()
Один из самых неприятных «полубагов» выглядит так: «Я всё скопировал, ошибок нет… а в файле пусто или не всё». Если вы пишете напрямую в файл (*os.File), то io.Copy пишет прямо туда. Но если вы обернули файл в bufio.Writer, то запись сначала попадает во внутренний буфер, и только потом уходит «вниз». В таком случае финализация — это Flush().
Это не случайная прихоть: bufio.Writer фактически реализует идею «копим мелкие записи, а ошибку проверяем в конце». Поэтому Flush() становится важной частью протокола, и ошибка может проявиться именно на Flush().
Мини-пример: копируем строковый источник в буферизированный вывод.
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("buffered copy!\n")
var raw bytes.Buffer
bw := bufio.NewWriter(&raw)
_, err := io.Copy(bw, src)
if err != nil {
fmt.Println("copy:", err)
return
}
if err := bw.Flush(); err != nil {
fmt.Println("flush:", err)
return
}
fmt.Print(raw.String()) // buffered copy!
}
Если убрать Flush(), вы получите ситуацию «данные вроде бы записали, но… как будто нет». И это логично: вы записали их в буфер bufio.Writer, а не в конечный raw.
2. io.CopyN: копируем ровно N байт
io.Copy копирует «до конца потока». Это идеальный вариант, когда вам нужно перенести весь файл, весь ввод, весь ответ и так далее. Но иногда требуется другое: «скопируй только первые N байт». Например, вы хотите сделать превью файла, прочитать заголовок, ограничить размер копируемых данных или просто реализовать «head» в миниатюре.
Для этого есть:
func CopyN(dst io.Writer, src io.Reader, n int64) (written int64, err error)
Поведение здесь важно понимать «по‑инженерному». io.CopyN пытается скопировать ровно n байт. Если данных меньше, чем n, то вы получите ошибку (часто это будет io.EOF), а written покажет, сколько реально удалось скопировать.
Мини-пример: копируем первые 4 байта.
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("abcdef")
var dst bytes.Buffer
n, err := io.CopyN(&dst, src, 4)
fmt.Println("written:", n) // written: 4
fmt.Println("err:", err) // err: <nil>
fmt.Println("dst:", dst.String()) // dst: abcd
}
Теперь пример, где источник короче, чем мы просим:
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("hi")
var dst bytes.Buffer
n, err := io.CopyN(&dst, src, 5)
fmt.Println("written:", n) // written: 2
fmt.Println("err:", err) // err: EOF
fmt.Println("dst:", dst.String()) // dst: hi
}
И вот здесь важная мысль: EOF в таком сценарии — не «ой, страшно», а корректный сигнал, что данных просто физически не хватило на заявленный объём. Для приложения это может быть нормальным исходом (например, «файл меньше 5 байт, окей, превью целиком»), а может быть ошибкой (например, «ожидали фиксированный заголовок формата, а его нет»). Решение принимает ваш код.
3. Откуда пришла ошибка: чтение или запись
Один тонкий момент: io.Copy возвращает одну ошибку, но источник у неё может быть разный. Иногда это «сломался диск/нет прав» на записи, иногда это «сломался источник» на чтении. На уровне новичка не нужно строить сложную классификацию, но очень полезно добавлять контекст операции: хотя бы «copy tasks.txt → tasks.bak».
Давайте сделаем два игрушечных типа: один ломается при чтении, другой — при записи. Это не для того, чтобы вы писали так в проде, а чтобы почувствовать механику.
package main
import (
"errors"
"io"
)
type failReader struct {
left int
}
func (r *failReader) Read(p []byte) (int, error) {
if r.left <= 0 {
return 0, errors.New("read failed")
}
p[0] = 'X'
r.left--
return 1, nil
}
type blackHoleWriter struct{}
func (blackHoleWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func CopyWithReadFailure() (int64, error) {
src := &failReader{left: 3}
dst := blackHoleWriter{}
return io.Copy(dst, src)
}
Если вы вызовете CopyWithReadFailure, копирование остановится на ошибке чтения, но written покажет, что часть данных успела уйти. И это ещё раз подчёркивает, почему возвращаемое число байт — не «для галочки».
4. Мини‑интеграция: backup и preview
Чтобы всё это не осталось абстрактной магией, давайте добавим две маленькие функции в наш «файловый» слой утилиты задач. Мы не строим сейчас полноценный CLI с командами и флагами (это отдельная большая тема), поэтому просто покажем, как выглядел бы код функций, которые потом можно вызвать из main.
Первая функция — резервная копия файла задач:
package main
import (
"fmt"
"io"
"os"
)
func BackupFile(dstPath, srcPath string) (int64, error) {
src, err := os.Open(srcPath)
if err != nil {
return 0, fmt.Errorf("open %s: %v", srcPath, err)
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return 0, fmt.Errorf("create %s: %v", dstPath, err)
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
return n, fmt.Errorf("copy %s -> %s: %v", srcPath, dstPath, err)
}
return n, nil
}
Вторая функция — «превью» первых N байт, например чтобы быстро посмотреть, что файл не пустой и примерно «про что он», не читая всё целиком:
package main
import (
"fmt"
"io"
"os"
)
func PrintPreview(path string, limit int64) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %v", path, err)
}
defer f.Close()
if _, err := io.CopyN(os.Stdout, f, limit); err != nil && err != io.EOF {
return fmt.Errorf("preview %s: %v", path, err)
}
fmt.Println() // перенос строки после превью
return nil
}
Обратите внимание на условие err != nil && err != io.EOF. В этом конкретном UX мы считаем EOF допустимым: если файл короче limit, мы всё равно показали всё, что было. Но если бы вы читали «строго 16 байт заголовка формата», то EOF был бы поводом сказать: «Файл слишком короткий, это не наш формат».
5. Типичные ошибки при работе с io.Copy / io.CopyN
Ошибка №1: перепутать dst и src.
Это происходит чаще, чем хочется признать, особенно когда имена переменных вроде a и b. В io.Copy(dst, src) первым идёт приёмник, вторым — источник. Если перепутать, компилятор не спасёт: оба интерфейса могут быть реализованы, и вы получите странное поведение или ошибку «не туда».
Ошибка №2: игнорировать written и считать, что “раз есть ошибка, значит ничего не скопировалось”.
На практике копирование может частично выполниться. Иногда это важно для логов и диагностики, иногда — для решения, надо ли удалять «битый» файл назначения. Поэтому written стоит хотя бы логически учитывать: это не украшение API.
Ошибка №3: копировать в bufio.Writer и забыть Flush().
Самый неприятный сценарий: io.Copy вернул nil, вы радостно закрыли файл — а часть данных осталась в буфере bufio.Writer, и на диске пусто. Дисциплина простая: если поверх writer’а стоит bufio.Writer, значит финализация — это Flush(), и именно там может проявиться ошибка.
Ошибка №4: воспринимать io.EOF как универсальную “фатальную ошибку”.
Для io.Copy конец потока обычно означает корректное завершение без ошибки. Для io.CopyN EOF означает «данных меньше, чем N», и это может быть либо нормой (превью), либо ошибкой (ожидали строгий размер). Если вы не различаете эти случаи, программа будет либо слишком паниковать, либо слишком молчаливо принимать поломанные данные.
Ошибка №5: не добавлять контекст к ошибке и потом страдать при отладке.
Ошибка permission denied сама по себе не говорит, где именно это случилось: при открытии источника, создании назначения или в процессе записи. Простое добавление текста операции (например, copy A -> B) экономит время и нервы, особенно когда программа работает с несколькими файлами.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ