§ 10. Ввод/вывод
Введение
01 Изучив, как ядро видит наши программы, а также то, как создавать дочерние процессы, перейдем к наиболее часто используемым системным вызовам — ввод/вывод.
Блокирующий ввод/вывод в Linux
02 Ниже представлена программа Hello world, написанная с помощью системных вызовов. Сначала мы открываем файл с помощью функции open
, затем записываем в файл массив символов с помощью функции write
и, наконец, закрываем файл функцией close
. Эти системные вызовы используются для реализации ввода/вывода в C, С++ и всех других языках. Разберемся с ними по порядку.
03 Функция open
является типичным системным вызовом. В ней есть аргументы, уникальные только для этой функции и есть аргумент, представляющий собой набор флагов. Наличие флагов позволяет добавлять функционал, не изменяя способ вызова функций, а значит, не нарушая обратную совместимость. Первый аргумент — имя файла, третий аргумент — права доступа. Первый флаг означает, что надо создать файл, если его нет, второй флаг означает, что мы открываем файл только для чтения, а третий флаг означает, что файл нужно усечь (обнулить), если он существует, и только потом начать запись.
Флаг | Описание |
---|---|
O_CREAT | создать файл, если его нет |
O_TRUNC | обнулить размер существующего файла при открытии |
O_WRONLY | открыть файл только для записи |
O_RDONLY | открыть файл только для чтения |
O_RDWR | открыть файл только для чтения и записи |
open
.04 Функция open
возвращает файловый дескриптор — номер специальной структуры ядра, используемой для любый операций ввода/вывода, будь то файлы, сетевые соединения или какое-то устройство, подключенное по USB. Файловый дескриптор — это один из важнейших объектов внутри ядра, находящийся наравне со страницами памяти. Незакрытые файловые дескрипторы приводят к утечкам памяти (памяти ядра!), что может оказаться более серьезным, чем утечка памяти приложения. Для их предотвращения нужно использовать классы-обертки, в конструкторе которых файл открывается, а деструкторе закрывается. Такие инструменты как valgrind
позволяют отследить незакрытые файловые дескрипторы (в отличие от санитайзеров, отслеживающих лишь утечки памяти, а не дескрипторов).
05 Функция write
записывает массив байт в системный буфер файлового дескриптора, откуда он впоследствии отправляется в файл или сетевой буфер, и возвращает количество записанных байт. По умолчанию операции записи является блокирующей, то есть при невозможности мгновенной записи в буфер (если ядро в данный момент выполняет другие операции записи/чтения) функция будет ждать на внутрисистемной блокировке завершения операции. Асинхронный (неблокирующий) ввод/вывод, в котором такой блокировки не происходит, будет рассмотрен на одной из следующих лекций.
06 Наконец, функция close
закрывает файловый дескриптор, отправляя содержимое внутрисистемных буферов в файл.
07 В коде, показанном выше, нет обработки ошибок. Это непростительно даже в такой простой программе, потому что любой системный вызов может завершиться ошибкой. При возникновении ошибки ее код записывается в глобальную переменную errno
. Если вы проигнорируете проверку на ошибку в одном из системных вызовов, то потом вам будет сложно понять, где же на самом деле произошла первая ошибка. Поэтому следует проверять на ошибку каждый системный вызов. Исключение составляют лишь вызовы, возвращающие поля структур ядра (например, номер процесса). Для обработки таких ошибок в C++ есть специальный класс std::system_error
, который по коду ошибки выведет ее словесное описание.
08 Проверка возвращаемого значения очень быстро делает код трудночитаемым. Чтобы исправить ситуацию, обычно используют макрос или функцию. Макрос вставляет вызов функции прямо внутрь условного перехода.
Rvalue-ссылки в С++
09 Одним из элементов синтаксиса C++ являются ссылки. Когда вы создаете ссылку на объект, то этот объект не копируется, а получает новое имя в программе. Ссылка может быть константной или неконстантной, константные ссылки не позволяют изменять объект. Преимущество ссылки перед указателем состоит в невозможности создать ссылку на нулевой объект, потому что для этого пришлось бы разыменовать нулевой указатель. Тем не менее, после создания ссылки объект можно удалить, после чего ссылка станет ссылаться на несуществующий объект. Такую ссылку называют повисшей
(dangling reference) и основным способом борьбы с ними являются умные указатели, которые мы рассмотрим чуть позже.
10 Для компилятора C++ программа состоит из выражений. Выражения делятся на те, что можно использовать слева от оператора присваивания (lvalue), и те, что можно использовать справа от него (rvalue). В С++ слева от знака присваивания может стоять либо константная, либо неконстантная ссылка, а справа от знака присваивания может стоять только константная ссылка. Это было сделано, для того чтобы программист по ошибке не начал изменять временные объекты. Любой безымянный объект является rvalue. Ссылка на lvalue (rvalue) и объект lvalue (rvalue) являются разными понятиями, не следует их путать.
Термин | Определение | Положение относительно знака = | Является ли lvalue-объектом? |
---|---|---|---|
lvalue-объект | именованный объект | и слева, и справа | да |
rvalue-объект | безымянный объект | только справа | нет |
lvalue-ссылка | новое имя для именованного объекта | и слева, и справа | да |
rvalue-ссылка | новое имя для безымянного объекта | и слева, и справа | да |
11 В С++11 появились ссылки на rvalue, то есть ссылки на безымянные объекты. Таким образом мы можем присвоить имя объекту, который до этого имени не имел, и проводить операции с этим объектом. Сама rvalue-ссылка является lvalue-объектом.
12 Подведем предварительный итог. Мы познакомились с левыми и правыми значениями (lvalue и rvalue) и ссылками на них. rvalue-ссылка имеет другой синтаксис и другие внутриязыковые правила использования. Автоматически такая ссылка получается при возврате из функции безымянных объектов или из выражений, построенных на вызовах операторов этих объектов.
13 Для rvalue-ссылок в языке определяются вспомогательные операции. Одна из таких операций — это std::move
(перемещение). Эта операция представляет собой преобразование lvalue-ссылки в rvalue-ссылку путем обыкновенного приведения типов. Это помогает избавиться от копирования объектов в тех местах программы, где более эффективно перемещение.
14 Вторая операция — это std::forward
(пересылка). Эта операция сохраняет тип объекта (rvalue или lvalue), на который указывает ссылка. Она необходима для сохранения типа ссылки, поскольку rvalue-ссылка является lvalue-объектом. Обе операции реализованы на языке C++.
- Все контейнеры и многие другие классы получили конструктор с rvalue-ссылкой.
- Методы вставки в контейнер были перегружены для rvalue-ссылок.
- Функция swap теперь реализована с помощью перемещения.
- При возвращении объекта из функции, он автоматически преобразуется в rvalue.
15 Эти нововведения оптимизировали уже существующие программы без переписывания их исходного кода. Как же написать свой класс, так чтобы он получил максимум пользы от rvalue-ссылок?
Использование rvalue-ссылок для управления ресурсами
16 Рассмотрим применение rvalue-ссылок в классах, которые являются обертками для ресурсов. В качестве ресурса мы выберем память, а класс будет представлять собой динамический массив.
17 Наиболее важными методами класса-обертки являются конструктор и деструктор: в конструкторе мы выделяем память (захватываем ресурс), а в деструкторе освобождаем память (освобождаем ресурс). Оба этих метода вызываются автоматически, и, если деструктор освобождает ресурс, захваченный в конструкторе, то у нас никогда не будет утечек памяти.
18 Теперь добавим в класс возможность копирования массива. Для этого нам понадобится конструктор копирования и оператор копирования. Мы реализуем конструктор копирования с помощью поэлементного копирования объектов в цикле, а оператор копирования реализуется через этот конструктор и вспомогательную функцию swap
, которая обменивает значения всех полей нашего класса.
19 Теперь добавим в класс возможно перемещения объектов. Для этого нам также понадобится конструктор и оператор перемещения. Мы реализуем конструктор перемещения путем захвата ресурсов из переданного нам объекта и замены их на нулевые ресурсы. Оператор перемещения реализуется через конструктор, как и раньше. В результате действия оператора перемещения во временном объекте оказываются старые значения всех полей класса, а деструктор временного объекта обсвобождает эти ресурсы.
20 Показанные выше конструкторы и операторы являются классическим подходом при написании классов-оберток для ресурсов. В C++ это наиболее часто встречающийся вид классов. Наиболее часто используемый ресурс — это оперативная память, но таким же образом можно создавать классы для файловых дескрипторов, для памяти видеокарты и для других ресурсов, не имеющих физического представления.
Задания
Ввод/вывод1 балл
21 Напишите класс-обертку File
, который в конструкторе открывает файл, а в деструкторе закрывает. Добавьте методы для записи в файл, чтения из файла. Проверьте выходное значение каждого системного вызова с помощью функции check
.
Перемещение1 балл
22 Добавьте в класс File
конструкторы и операторы перемещения. Проверьте, что файловые дескрипторы корректно закрываются с помощью команды valgrind --track-fds=yes ./a.out
.
Позиция в файле1 балл
23 Добавьте в класс File
метод для получения и изменения текущей позиции в файле относительно его начала. Для этого воспользуйтесь системным вызовом lseek
.
Копирование файловых дескрипторов2 балла
24 Добавьте в класс File
конструкторы и операторы копирования и перемещения. Файловый дескриптор можно скопировать функцией dup
и dup2
(в конструкторе и операторе копирования используются разные функции!). Файловый дескриптор со значением -1 можно считать нейтральным (как nullptr
для указателя). Проверьте, что файловые дескрипторы корректно закрываются с помощью команды valgrind --track-fds=yes ./a.out
.