JavaRush /Курсы /Go SELF /io.Copy / io.CopyN — когда использовать

io.Copy / io.CopyN — когда использовать

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

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) экономит время и нервы, особенно когда программа работает с несколькими файлами.

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