§ 10. Ввод/вывод

Введение

01 Изучив, как ядро видит наши программы, а также то, как создавать дочерние процессы, перейдем к наиболее часто используемым системным вызовам — ввод/вывод.

Блокирующий ввод/вывод в Linux

02 Ниже представлена программа Hello world, написанная с помощью системных вызовов. Сначала мы открываем файл с помощью функции open, затем записываем в файл массив символов с помощью функции write и, наконец, закрываем файл функцией close. Эти системные вызовы используются для реализации ввода/вывода в C, С++ и всех других языках. Разберемся с ними по порядку.

int fd = open("myfile", O_CREAT|O_WRONLY|O_TRUNC, 0644); // открыть файл
const char msg[] = "hello\n";
write(fd, msg, sizeof(msg)); // записать в файл
close(fd); // закрыть файл
Запись в файл.

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, который по коду ошибки выведет ее словесное описание.

int fd = open("myfile", O_CREAT|O_WRONLY|O_TRUNC, 0644);
if (fd == -1) {
  throw std::system_error(errno, std::generic_category());
}
const char msg[] = "hello\n";
ssize_t nwritten = write(fd, msg, sizeof(msg));
if (nwritten == -1) {
  throw std::system_error(errno, std::generic_category());
}
if (close(fd) == -1) {
  throw std::system_error(errno, std::generic_category());
}
Запись в файл с обработкой ошибок.

08 Проверка возвращаемого значения очень быстро делает код трудночитаемым. Чтобы исправить ситуацию, обычно используют макрос или функцию. Макрос вставляет вызов функции прямо внутрь условного перехода.

template <class T>
inline T check(T ret) {
  if (ret == T(-1)) {
    throw std::system_error(errno, std::generic_category());
  }
  return ret;
}

auto fd = check(open("myfile", O_CREAT|O_WRONLY|O_TRUNC, 0644));
const char msg[] = "hello\n";
auto nwritten = check(write(fd, msg, sizeof(msg)));
check(close(fd));
Запись в файл с проверкой ошибок с помощью функции check.

Rvalue-ссылки в С++

09 Одним из элементов синтаксиса C++ являются ссылки. Когда вы создаете ссылку на объект, то этот объект не копируется, а получает новое имя в программе. Ссылка может быть константной или неконстантной, константные ссылки не позволяют изменять объект. Преимущество ссылки перед указателем состоит в невозможности создать ссылку на нулевой объект, потому что для этого пришлось бы разыменовать нулевой указатель. Тем не менее, после создания ссылки объект можно удалить, после чего ссылка станет ссылаться на несуществующий объект. Такую ссылку называют повисшей (dangling reference) и основным способом борьбы с ними являются умные указатели, которые мы рассмотрим чуть позже.

X obj;                   // lvalue-объект
X& ref1 = obj;           // lvalue-ссылка
const X& ref2 = obj;     // константная lvalue-ссылка

X func();                // прототип функции
X& ref3 = func();        // ошибка: безымянный объект нельзя изменять с помощью lvalue-ссылки
const X& ref4 = func();  // ок
Особенности lvalue-ссылок.

10 Для компилятора C++ программа состоит из выражений. Выражения делятся на те, что можно использовать слева от оператора присваивания (lvalue), и те, что можно использовать справа от него (rvalue). В С++ слева от знака присваивания может стоять либо константная, либо неконстантная ссылка, а справа от знака присваивания может стоять только константная ссылка. Это было сделано, для того чтобы программист по ошибке не начал изменять временные объекты. Любой безымянный объект является rvalue. Ссылка на lvalue (rvalue) и объект lvalue (rvalue) являются разными понятиями, не следует их путать.

ТерминОпределениеПоложение относительно знака =Является ли lvalue-объектом?
lvalue-объектименованный объекти слева, и справада
rvalue-объектбезымянный объекттолько справанет
lvalue-ссылкановое имя для именованного объектаи слева, и справада
rvalue-ссылкановое имя для безымянного объектаи слева, и справада
Определения, связанные со ссылками.
X x, y, z;   // ок
x = y;       // ок: x и y являются lvalue-объектами
x*y = z;     // ошибка: результат операции x*y является rvalue-объектом и может быть только справа от знака =
z = x*y;     // ок
Особенности lvalue-объектов и rvalue-объектов.

11 В С++11 появились ссылки на rvalue, то есть ссылки на безымянные объекты. Таким образом мы можем присвоить имя объекту, который до этого имени не имел, и проводить операции с этим объектом. Сама rvalue-ссылка является lvalue-объектом.

X func();                // прототип функции
X&& ref3 = func();       // ок безымянный объект можно изменять с помощью rvalue-ссылки
const X&& ref4 = func(); // ок, но пользы в константной rvalue-ссылке нет
Особенности rvalue-ссылок.

12 Подведем предварительный итог. Мы познакомились с левыми и правыми значениями (lvalue и rvalue) и ссылками на них. rvalue-ссылка имеет другой синтаксис и другие внутриязыковые правила использования. Автоматически такая ссылка получается при возврате из функции безымянных объектов или из выражений, построенных на вызовах операторов этих объектов.

13 Для rvalue-ссылок в языке определяются вспомогательные операции. Одна из таких операций — это std::move (перемещение). Эта операция представляет собой преобразование lvalue-ссылки в rvalue-ссылку путем обыкновенного приведения типов. Это помогает избавиться от копирования объектов в тех местах программы, где более эффективно перемещение.

14 Вторая операция — это std::forward (пересылка). Эта операция сохраняет тип объекта (rvalue или lvalue), на который указывает ссылка. Она необходима для сохранения типа ссылки, поскольку rvalue-ссылка является lvalue-объектом. Обе операции реализованы на языке C++.

15 Эти нововведения оптимизировали уже существующие программы без переписывания их исходного кода. Как же написать свой класс, так чтобы он получил максимум пользы от rvalue-ссылок?

Использование rvalue-ссылок для управления ресурсами

16 Рассмотрим применение rvalue-ссылок в классах, которые являются обертками для ресурсов. В качестве ресурса мы выберем память, а класс будет представлять собой динамический массив.

class float_array {
private:
  size_t _size = 0;
  float* _data = nullptr;
public:
  float_array() {}
  float_array(size_t size): _size(size), _data(new float[size]) {}
  ~float_array() { delete[] _data; }

  float* data() { return _data; }
  size_t size() const { return _size; }
};
Класс float_array.

17 Наиболее важными методами класса-обертки являются конструктор и деструктор: в конструкторе мы выделяем память (захватываем ресурс), а в деструкторе освобождаем память (освобождаем ресурс). Оба этих метода вызываются автоматически, и, если деструктор освобождает ресурс, захваченный в конструкторе, то у нас никогда не будет утечек памяти.

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

class float_array {
...
  // конструктор копирования
  float_array(const float_array& rhs): _size(rhs._size), _data(new float[rhs._size]) {
    for (size_t i=0; i<_size; ++i) {
      _data[i] = rhs._data[i];
    }
  }
  // оператор копирования
  float_array& operator=(const float_array& rhs) {
    float_array tmp(rhs);
    swap(tmp);
    return *this;
  }
  // обмен
  void swap(float_array& rhs) {
    std::swap(_data, rhs._data);
    std::swap(_size, rhs._size);
  }
...
};
Методы класса float_array, которые нужны для копирования объектов этого класса.

19 Теперь добавим в класс возможно перемещения объектов. Для этого нам также понадобится конструктор и оператор перемещения. Мы реализуем конструктор перемещения путем захвата ресурсов из переданного нам объекта и замены их на нулевые ресурсы. Оператор перемещения реализуется через конструктор, как и раньше. В результате действия оператора перемещения во временном объекте оказываются старые значения всех полей класса, а деструктор временного объекта обсвобождает эти ресурсы.

class float_array {
...
  // конструктор перемещения
  float_array(float_array&& rhs):
  _size(rhs._size), _data(rhs._data) { // захватываем ресурсы
    rhs._size = 0; // обнуляем исходные ресурсы
    rhs._data = nullptr;
  }
  // конструктор перемещения (альтернативный)
  // float_array(float_array&& rhs) {
  //   swap(rhs);
  // }
  // оператор перемещения
  float_array& operator=(float_array&& rhs) {
    float_array tmp(std::move(rhs));
    swap(tmp);
    return *this;
  }
...
};
Методы класса float_array, которые нужны для перемещения объектов этого класса.

20 Показанные выше конструкторы и операторы являются классическим подходом при написании классов-оберток для ресурсов. В C++ это наиболее часто встречающийся вид классов. Наиболее часто используемый ресурс — это оперативная память, но таким же образом можно создавать классы для файловых дескрипторов, для памяти видеокарты и для других ресурсов, не имеющих физического представления.

template <class T>
void swap(T& a, T& b) {
  T tmp = move(a);
  a = move(b);
  b = move(tmp);
}
Реализация std::swap.

Задания

Ввод/вывод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.

Видео

Запись лекции 06.11.2021.