🦫 Самоучитель по Go для начинающих. Часть 14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os

В этой статье рассмотрим основные методы ввода-вывода из пакета io, изучим механизм буферизации и его применение в Go, а также разберем, как работать с файлами с помощью пакета os.

Ввод-вывод. Пакет io

Одним из самых фундаментальных пакетов стандартной библиотеки является io, который предоставляет базовые интерфейсы для операций ввода-вывода. Основное его назначение заключается в обертке функций и методов различных пакетов в общедоступные интерфейсы, абстрагирующие функциональность.

Давайте изучим основные инструменты пакета io для выполнения трех базовых операций: чтения, записи и копирования. Стоит уделить особое внимание сущностям из этих пунктов, так как они используются во многих других библиотеках.

Чтение данных

Чтение данных производится с помощью интерфейса io.Reader, содержащего единственный метод Read, который принимает слайс байт, а возвращает количество записанных байт и ошибку в случае некорректного завершения:

type Reader interface {
    Read(p []byte) (n int, err error)
}

При использовании Reader стоит учитывать следующее:

  1. После завершения потока данных Reader возвращает ошибку io.EOF (End Of File), которую следует обработать отдельно.
  2. Reader не гарантирует полное заполнение буфера.
  3. Даже если метод Read возвращает n < len(p) байт, он может использовать весь слайс p во время вызова.

Если заранее известен размер данных для чтения, предпочтительнее использовать функцию io.ReadFull, которая проверяет заполнение буфера перед возвратом значения и в случае различия размера данных и размера буфера выдает ошибку io.ErrUnexpectedEOF:

func ReadFull(r Reader, buf []byte) (n int, err error)

Запись данных

Для записи данных используется интерфейс io.Writer, визуально похожий на io.Reader:

type Writer interface {
	Write(p []byte) (n int, err error)
}

Writer представляет собой обертку для метода Write, который записывает len(p) байт из слайса p в указанный поток данных и возвращает два значения: количество записанных байт n (0 <= n <= len(p)) и возникшую ошибку, которая привела к досрочной остановке записи.

При работе с Writer следует придерживаться следующих правил:

  1. Write должен возвращать ненулевое значение ошибки, если было прочитано n < len(p) байт.
  2. Write не должен изменять данные слайса, даже временно.
  3. Реализации интерфейса Writer не должны сохранять источник данных p.
👨‍💻 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

Копирование данных

Копирование данных из источника src в dts до появления io.EOF или произвольной ошибки производится с помощью функции io.Copy. Она возвращает число скопированных байт и ошибку в случае её возникновения, а при корректном завершении предполагается, что err == nil:

func Copy(dst Writer, src Reader) (written int64, err error)

Для копирования данных с размером больше заданного числа байт следует использовать функцию io.CopyN:

func CopyN(dst Writer, src Reader, n int64) (written int64, err error)

Она копирует не больше n байт из src в dst и возвращает количество скопированных байт и ошибку в случае её возникновения. Стоит учитывать, что число записанных байт (written) равняется параметру n только в том случае, если err равняется nil.

Проиллюстрируем применение интерфейса Reader и работу функции Copy в коде:

r := strings.NewReader("данные для чтения")

if _, err := io.Copy(os.Stdout, r); err != nil {
	log.Fatal(err)
}

В первой строке переменной r с помощью функции strings.NewReader присваивается указатель на структуру Reader пакета strings, которая реализует io.Reader и еще несколько других интерфейсов:

func NewReader(s string) *Reader { return &Reader{s, 0, -1} }

type Reader struct {
	s        string
	i        int64
	prevRune int
}

Далее вызывается функция io.Copy, принимающая в качестве аргументов os.Stdout (стандартный поток вывода) и переменную r интерфейса Reader. В результате выполнения кода из объекта r в стандартный поток вывода будет скопирована строка: данные для чтения.

Буферизованный ввод-вывод в Go. Пакет bufio

Скорость операций ввода-вывода значительно повышается, когда данные накапливаются в специальном буфере для их последующего чтения или записи. Такой подход называется буферизацией. Он позволяет сократить количество системных вызовов и улучшить производительность программы.

В Go для буферизации операций ввода-вывода используется встроенный пакет bufio. Он оборачивает io.Reader или io.Writer, создавая новый объект Reader или Writer, соответственно, который также реализует интерфейс, но обеспечивает буферизацию и некоторые улучшения ввода-вывода.

Пакет bufio содержит три основных типа: Reader, Writer и Scanner. Давайте рассмотрим каждый из них подробнее.

bufio.Reader

Тип bufio.Reader реализует буферизацию для объекта io.Reader и создается с помощью одной из функций:

  • func NewReader(rd io.Reader) *Reader – Reader с буфером стандартного размера (4096 байт).
  • func NewReaderSize(rd io.Reader, size int) *Reader – Reader с буфером явно задаваемого размера.

Совместно с этими функциями часто используется метод ReadString, считывающий данные до первого появления разделителя delim и возвращающий строку длиной до delim включительно:

func (b *Reader) ReadString(delim byte) (string, error)

В качестве примера напишем код считывания данных из стандартного ввода до первого разделителя, которым будет выступать точка с запятой:

r := bufio.NewReader(os.Stdin)
str, err := r.ReadString(';')
if err != nil {
	log.Fatal(err)
}
fmt.Printf("%s\\\\n", strings.TrimSpace(str))
// Ввод: abc;def;
// Вывод: abc;

bufio.Writer

Тип bufio.Writer предоставляет буферизацию для объекта io.Writer и может быть создан одной из следующих функций:

  • func NewWriter(w io.Writer) *Writer – Writer с буфером стандартного размера (4096 байт).
  • func NewReaderSize(rd io.Reader, size int) *Reader – Writer с буфером явно задаваемого размера.

После окончания записи необходимо вызвать метод Writer.Flush для перенаправления данных в базовый интерфейс io.Writer.

Пакет bufio предусматривает три отдельных метода для записи данных типов string, byte и rune:

  • func (b *Writer) WriteString(s string) (int, error) – записывает строку и возвращает количество записанных байт. Если оно меньше длины строки, метод вернет ошибку.
  • func (b *Writer) WriteByte(c byte) error – записывает один байт, возвращает ошибку в случае её возникновения.

Применим изученные функции и методы в программе, которая считывает строковую переменную со стандартного потока os.Stdin и записывает её в поток os.Stdout:

w := bufio.NewWriter(os.Stdout)
r := bufio.NewReader(os.Stdin)

name, err := r.ReadString('\\\\n') // считывание до первого переноса строки
if err != nil {
	log.Fatal(err)
}
w.WriteString("Привет, " + name) // запись строки в объект w
w.Flush()
// Ввод: Гоша
// Вывод: Привет, Гоша

bufio.Scanner

Тип bufio.Scanner предоставляет интерфейс для построчного чтения данных. Чаще всего он используется в связке с методом Scanner.Scan, который итерируется по токенам источника данных, определяемым типом SplitFunc:

func (s *Scanner) Scan() booltype SplitFunc func(data []byte, atEOF bool) (advance int, token []byte, err error)

Метод Scanner.Split устанавливает конкретную функцию разбиения (тип SplitFunc) для объекта Scanner. Она может использоваться для сканирования файла по байтам, рунам, строкам и словам, разделенным пробелами. Допускается задание пользовательских функций разбиения:

func (s *Scanner) Split(split SplitFunc)

Применим функции построчного чтения в программе, которая считывает очередную порцию данных и сразу же выводит её на экран:

scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
	fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
	fmt.Fprintln(os.Stderr, "чтение os.Stdin:", err)
}

При запуске кода и ввода сообщения в консоль получим его же на строке ниже:

Echo message 1
Echo message 1
Echo message 2
Echo message 2
📖 Книги для Go разработчиков
Больше полезных книг вы найдете на нашем телеграм-канале «Книги для Go разработчиков»

Работа с файлами. Пакет os

В этом пункте изучим основные методы для работы с файлами в программах на Go.

Открытие и закрытие файла

os.OpenFile

Наиболее общей функцией для открытия файла является os.OpenFile со следующей сигнатурой:

func OpenFile(name string, flag int, perm FileMode) (*File, error)

Она открывает файл с указанным названием name и специальным флагом, указывающим атрибуты открытия файла. Полный список флагов будет приведен в отдельном пункте.

Если файл не существовал до открытия, и в качестве аргумента os.OpenFile передан флаг O_CREATE, то файл будет создан с атрибутом perm типа FileMode, который задает уровень доступа к файловой системе в виде числа uint32. Например, FileMode со значением 0666 означает, что файл доступен для чтения и записи.

В случае успешного завершения os.OpenFile возвращает объект типа *File, иначе – ошибку типа *PathError.

os.Open

Оберткой над os.OpenFile является функция os.Open, которая открывает файл только для чтения, на что указывает флаг O_RDONLY:

func Open(name string) (*File, error) {
	return OpenFile(name, O_RDONLY, 0)
}

Типичный код открытия файла в Go выглядит следующим образом:

file, err := os.Open("filename.txt")
if err != nil {
	log.Fatal(err)
}
defer file.Close()

Обратите внимание: открытый файл должен всегда закрываться по завершении работы программы с помощью метода Close. Обычно он вызывается отложено, с использованием defer, как это показано в примере выше.

Флаги

Флаги указывают параметры открытия файла, такие как доступ (чтение, запись или все вместе), поведение (открытие, сокращение и т. д.), режим работы (асинхронный, добавление и т. д.). Стоит иметь в виду, что в каждой конкретной операционной системе могут быть реализованы не все флаги. Для Linux, к примеру, список флагов можно посмотреть в man page.

Пакет os предоставляет константы, оборачивающие основные флаги операционной системы:

const (
	O_RDONLY int = syscall.O_RDONLY // открыть файл только для чтения.
	O_WRONLY int = syscall.O_WRONLY // открыть файл только для записи.
	O_RDWR int = syscall.O_RDWR // открыть файл только для чтения и записи.

	O_APPEND int = syscall.O_APPEND // добавлять данные в файл при записи.
	O_CREATE int = syscall.O_CREAT // создать новый файл, если его не существует.
	O_EXCL int = syscall.O_EXCL // используется с O_CREATE, открытие завершится с ошибкой, если файл существует
	O_SYNC int = syscall.O_SYNC // открыть для синхронного ввода-вывода
	O_TRUNC int = syscall.O_TRUNC // обрезать при открытии файл, доступный для записи
)

Создание, переименование и удаление файла

Для создания, удаления и переименования файла используются функции os.Create, os.Remove и os.Rename соответственно.

  • os.Create создает или вырезает файл с указанным именем. Если он уже существует, то будет вырезан, иначе – создан с атрибутом 0666 (чтение и запись). В случае успешного завершения функция вернет объект *File с файловым дескриптором O_RDWR, готовый к использованию для ввода-вывода. Если возникнет ошибка, она будет иметь тип *PathError:
func Create(name string) (*File, error) {
	return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666)
}
  • os.Remove с сигнатурой func Remove(name string) error удаляет файл с указанным названием или возвращает ошибку типа *PathError.
  • os.Rename с сигнатурой func Rename(oldpath, newpath string) error переименовывает (перемещает) файл из oldpath в newpath. Если объект с именем newpath уже существует и не является директорией, функция заменит этот объект на новый.

Чтение файла

os.ReadFile и io.ReadAll

Если заранее известно название файла, для его чтения можно использовать функцию os.ReadFile. Она считывает все содержимое и в случае успешного завершения возвращает err == nil, но не err == EOF.

Альтернативой для os.ReadFile является функция io.ReadAll, считывающая данные из объекта io.Reader до первой ошибки или EOF.

Здесь и далее в демонстрационном коде будет использоваться единственный файл с именем filename.txt. Его содержимое будет меняться в зависимости от используемых функций и методов. Изначально в нем содержатся три строки, которые мы прочитаем с помощью функции os.ReadFile и выведем на экран:

data, err := os.ReadFile("filename.txt")
if err != nil {
	log.Fatal(err)
}
fmt.Println(string(data))

Код для функции io.ReadAll аналогичен написанному выше, требуется лишь предварительно открыть необходимый файл и передать его в качестве аргумента в io.ReadAll.

Содержимое файла filename.txt, выведенное на экран:

filename.txt
a
bc
def

Так как две рассматриваемые функции прочитывают файл целиком, их не стоит использовать для открытия больших источников данных, иначе это может привести к переполнению доступной памяти, зависанию программы и другим неприятным последствиям.

bufio.NewScanner

При работе с файлами большого размера следует считывать данные построчно. Этого можно достичь с помощью ранее изученного сканера из пакета bufio:

file, err := os.Open("filename.txt")
if err != nil {
	log.Fatal(err)
}
defer file.Close()

scanner := bufio.NewScanner(file) // создание сканера
for scanner.Scan() {              // считываем очередную строку
	fmt.Println(scanner.Text())   // выводим считанную строку
}
if err := scanner.Err(); err != nil {
	log.Fatal(err)
}

Вывод будет аналогичен предыдущему пункту.

Метод Read

Метод Read считывает фиксированное число байт, сохраняет их в слайсе []byte и возвращает количество прочитанных байт и встреченную ошибку. При достижении конца файла Read вернет два значения: 0, io.EOF:

file, err := os.Open("filename.txt")
if err != nil {
	fmt.Println(err)
}
defer file.Close()

content := make([]byte, 8)
_, err = file.Read(content)
if err != nil {
	fmt.Println(err)
}
fmt.Println(string(content))

Вывод будет аналогичен предыдущим двум пунктам.

Запись в файл

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

os.WriteFile

Функция os.WriteFile при необходимости создает файл с заданным атрибутом и записывает туда данные из переданного слайса байт. Если файл существовал, то os.WriteFile обрежет его перед записью, не меняя разрешения:

// Атрибут 0644 означает доступ к чтению и записи
data := []byte("message from WriteFile")
err := os.WriteFile("filename.txt", data, 0644)
if err != nil {
	log.Fatal(err)
}

В результате файл filename.txt перезапишется и будет содержать единственную строку: "message from WriteFile".

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

Метод Write

Метод Write записывает фиксированное число байт из слайса []byte и возвращает количество успешно записанных байт и встреченную ошибку. Если это количество меньше длины слайса, вернется ошибка с ненулевым значением:

file, err := os.Create("filename.txt")
if err != nil {
	log.Fatal(err)
}
defer file.Close()

data := []byte("message from Write")
_, err = file.Write(data)
if err != nil {
	log.Fatal(err)
}

Содержимое файла filename.txt после выполнения кода:

filename.txt
message from Write

Обратите внимание: метод Write требует, чтобы файл был заранее открыт или создан с доступом на запись, иначе вернется ошибка bad file descriptor.

Метод WriteString

Записи строки в файл производится с помощью метода WriteString, который является оберткой для Write:

func (f *File) WriteString(s string) (n int, err error) {
	b := unsafe.Slice(unsafe.StringData(s), len(s))
	return f.Write(b)
}

Метод WriteString применяется в коде аналогично методу Write, но вместо слайса байт передается строка.

Добавление данных в файл

Как вы могли заметить, предыдущие инструменты полностью перезаписывали содержимое файла, что может быть недопустимым в некоторых ситуациях. Для добавления некоторых данных сразу после имеющихся следует указать флаг os.O_APPEND при открытии файла:

file, err := os.OpenFile("filename.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
	log.Fatal(err)
}
defer file.Close()

data := "\\nadded line\\n"
_, err = file.WriteString(data)
if err != nil {
	log.Fatal(err)
}

Теперь файл filename.txt содержит следующие данные:

filename.txt
message from Write
added line

Какой пакет использовать?

Как вы могли убедиться, язык Go предоставляет широкие возможности для обработки данных и взаимодействия с файловой системой. В этой статье мы изучили лишь основные инструменты из трех базовых пакетов: io, bufio и os.

Какой же пакет использовать для каждой конкретной задачи? Универсальных правил здесь нет, но можно принять во внимание следующие рекомендации:

  • Для выполнения базовых операций над небольшими файлами допускается использовать инструменты из пакета io, которые под капотом являются абстракциями сущностей пакета os.
  • Для обеспечения полноценного доступа к файловой системе следует воспользоваться составляющими пакета os. Это позволит получить контроль над сущностями ОС и избежать непредвиденных ошибок.
  • При работе с большим объемом данных, который может быть разделен на отдельные фрагменты, стоит отдать предпочтение пакету bufio, так как он предоставляет возможность считывать и записывать содержимое поэтапно.

Отметим, что помимо io, bufio и os в стандартной библиотеке Go есть множество других пакетов для обработки данных и манипуляций с файловой системой, заточенных под конкретные задачи: encoding/csv для работы с csv файлами, encoding/json для обработки json, path для управления путями и другие. Большая часть из них задействует рассмотренные в этой статье примитивы из io, bufio и os, поэтому изучение дополнительных пакетов не составит у читателей особого труда.

Для знакомства с обработкой JSON рекомендуем прочитать статью "Эффективная работа с JSON в Go".

Задачи

Для отработки изученного материала будет полезно решить две несложные задачи.

Поиск слов

Напишите программу для подсчета количества слов в стандартном потоке ввода и последующего их вывода на экран с символом переноса строки. Ввод-вывод осуществляйте с использованием пакета bufio.

Пример входных данных

Go clear. Go fast. Go simple.

Выходные данные для примера

Количество слов: 6
Go
clear.
Go
fast.
Go
simple.

Подсказка

Подсказка: примените функции и методы из пакета bufio, для разбиения на слова используйте функцию bufio.ScanWords.

Решение

var words []string

scanner := bufio.NewScanner(os.Stdin)
scanner.Split(bufio.ScanWords)

for scanner.Scan() {
	words = append(words, scanner.Text())
}
if err := scanner.Err(); err != nil {
	log.Fatal(err)
}

fmt.Printf("Количество слов: %d\\\\n", len(words))

w := bufio.NewWriter(os.Stdout)
for _, word := range words {
	w.WriteString(word + "\\\\n")
}
w.Flush()

Сумма заказа

Напишите программу для подсчета общей стоимости заказа. Данные о купленных товарах находятся в файле order.txt в формате "товар-цена". Размер файла может быть довольно большим, поэтому не стоит считывать его целиком.

Пример данных в файле order.txt

яблоки-4
груши-9
бананы-1

Выходные данные для примера

Общая сумма всех заказов: 14

Решение

func orderCost(filename string) (int, error) {
	file, err := os.Open(filename)
	if err != nil {
		return 0, err
	}
	defer file.Close() // не забываем закрыть файл

	var sum int
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		parts := strings.Split(line, "-") // разбиение строки по символу -
		if len(parts) != 2 {
			log.Fatal("Неверный формат строки:", line)
		}
		// получение стоимости товара из строки
		productCost, err := strconv.Atoi(strings.TrimSpace(parts[1]))
		if err != nil {
			log.Fatal("Ошибка при чтении суммы товара:", err)
		}
		sum += productCost
	}
	if err := scanner.Err(); err != nil {
		return 0, err
	}
	return sum, nil
}

func main() {
	sum, err := orderCost("order.txt")
	if err != nil {
		log.Fatal("Ошибка при чтении заказов:", err)
		return
	}
	fmt.Println("Общая сумма всех заказов:", sum)
}

Заключение

В этой части самоучителя мы научились эффективно обрабатывать данные и взаимодействовать с операционной системой при помощи трех базовых пакетов: io, bufio и os.

В следующей статье цикла погрузимся в изучение конкурентности в языке Go, разберемся с горутинами, каналами и примитивами синхронизации.

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики
  13. Работа с датой и временем. Пакет time
  14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
  15. Конкурентность. Горутины. Каналы
  16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты
  17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
29 января 2017

Изучаем алгоритмы: полезные книги, веб-сайты, онлайн-курсы и видеоматериалы

В этой подборке представлен список книг, веб-сайтов и онлайн-курсов, дающих...