Найти тему
Библиотека программиста

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

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

← Часть 13

Работа с датой и временем. Пакет time

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

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

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

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

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

При использовании 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:

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 присваивается указатель на структуру Reader пакета strings, которая реализует io.Reader и еще несколько других интерфейсов:

Далее вызывается функция 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)

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

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:

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)

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

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

📖 Книги для 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:

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

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

Флаги

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

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

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

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

  • os.Create создает или вырезает файл с указанным именем. Если он уже существует, то будет вырезан, иначе – создан с атрибутом 0666 (чтение и запись). В случае успешного завершения функция вернет объект *File с файловым дескриптором O_RDWR, готовый к использованию для ввода-вывода. Если возникнет ошибка, она будет иметь тип *PathError:
  • 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 и выведем на экран:

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

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

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

bufio.NewScanner

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

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

Метод Read

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

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

Запись в файл

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

os.WriteFile

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

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

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

Метод Write

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

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

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

Метод WriteString

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

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

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

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

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

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

Как вы могли убедиться, язык 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.

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

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

Подсказка

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

Решение

Сумма заказа

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

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

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

Решение

Заключение

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

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

***

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

  1. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os