В этой статье рассмотрим основные методы ввода-вывода из пакета io, изучим механизм буферизации и его применение в Go, а также разберем, как работать с файлами с помощью пакета os.
← Часть 13
Работа с датой и временем. Пакет time
Ввод-вывод. Пакет io
Одним из самых фундаментальных пакетов стандартной библиотеки является io, который предоставляет базовые интерфейсы для операций ввода-вывода. Основное его назначение заключается в обертке функций и методов различных пакетов в общедоступные интерфейсы, абстрагирующие функциональность.
Давайте изучим основные инструменты пакета io для выполнения трех базовых операций: чтения, записи и копирования. Стоит уделить особое внимание сущностям из этих пунктов, так как они используются во многих других библиотеках.
Чтение данных
Чтение данных производится с помощью интерфейса io.Reader, содержащего единственный метод Read, который принимает слайс байт, а возвращает количество записанных байт и ошибку в случае некорректного завершения:
При использовании Reader стоит учитывать следующее:
- После завершения потока данных Reader возвращает ошибку io.EOF (End Of File), которую следует обработать отдельно.
- Reader не гарантирует полное заполнение буфера.
- Даже если метод 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 следует придерживаться следующих правил:
- Write должен возвращать ненулевое значение ошибки, если было прочитано n < len(p) байт.
- Write не должен изменять данные слайса, даже временно.
- Реализации интерфейса 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, разберемся с горутинами, каналами и примитивами синхронизации.
***
Содержание самоучителя
- Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os