Pull to refresh

Работа с бинарными файлами в стиле STL

Reading time 6 min
Views 30K
Я хотел бы рассказать о решении одной задачи, возникшей в процессе обучения старших школьников и младшекурсников программированию. Естественно, пишу я об этом, потому что считаю, что этот опыт может быть интересен более широкой аудитории.

Постановка задачи


Работа с бинарными файлами — традиционная тема при обучении программированию — по меньшей мере, в России. Не последнюю роль здесь играет широкое распространение в российских школах языка программирования Pascal, который имеет встроенную поддержку работы с так называемыми типизированными файлами (типы file of integer, file of real и т. п.). В некоторых случаях (при углублённом изучении программирования у школьников или на младших курсах университета), когда начинается изучение языка C++, возникает желание решать задачи на обработку «файлов типа T» уже в новом окружении данного языка. И тут возникает вопрос, какие средства при этом использовать.

К сожалению, для работы с бинарными файлами в языке C++ предусмотрены только низкоуровневые средства — методы read/write стандартных типов потоков istream/ostream. Кроме других очевидных недостатков этот факт не даёт использовать в полную силу программирование «в стиле STL» (то есть, в первую очередь, часть стандартной библиотеки C++, связанную с алгоритмами и итераторами).

Итак, задача состоит в том, чтобы обеспечить работу с бинарными файлами, хранящими последовательность значений типа T, как с последовательностью STL (vector<T> и т. п.). Для T подразумеваются значения базовых типов, а также, так называемые POD-типы (везде дальше можно думать только о базовых типах, если вы не знакомы с понятием POD).

Возможные решения


ios_base::binary: fail #0

Если вы никогда не встречались с подобной задачей (что было бы странно!), возможно, вы вспомните что-то про флаг ios_base::binary, но те, кто встречался, хорошо знает, что данное средство практически никак не поможет в решении. Стандарт языка предельно краток на тему того, какого эффекта можно ожидать от указания данного флага при открытии потока, но в сети можно встретить пояснение, что он просто отключает платформенно-зависимые трансляции символов перехода на новые строки и, возможно, ещё некоторых символов, что не имеет прямого отношения к нашей задаче.

Статический полиморфизм: fail #1

Вероятно, люди, знающие стандартную библиотеку чуть глубже, чем на базовом уровне, помнят, что стандартные типы для файловых потоков ofstream/ifstream являются синонимами для явных инстанций шаблонов basic_ofstream/basic_ifstream. Эти шаблоны имеют два аналогичных типовых параметра: тип символов потока (назовём этот параметр Ch) и тип характеристик типа символов — по умолчанию это std::char_traits<Ch>. Для ofstream/ifstream в качестве Ch взят тип char.

Здесь немедленно возникает мысль попробовать инстанцировать эти шаблоны с тем типом T, который мы хотим читать из бинарного файла, указав его в качестве значения шаблонного параметра Ch. Простейший код, пытающийся читать из потока такого типа значения типа int падает с исключением времени выполнения bad_cast. Возможно, что-то можно было бы изменить, написав свою специализацию для char_traits<int> и передав её вторым параметром шаблону класса файлового потока, однако этот путь показался беспеперспективным (писать специализацию весьма обширного шаблона char_traits для каждого типа T…) и я не стал разбираться с ним далее.

ООП и динамический полиморфизм: fail #2

После первых неудач можно прийти к мысли, что получить нужное поведение «совсем бесплатно» из стандартных средств не получится и придётся написать кое-какой код. Попробуем решить эту задачу в парадигме ООП, то есть написав пару-другую своих классов. Классов потоков, естественно. Отнаследоваться у стандартных ofstream/ifstream, сохранив максимум определений из предков, и посмотреть, что получится. (В скобках замечу, что задача эта сама по себе не лишена смысла хотя бы ввиду того, что отмечена довольно высоким рейтингом сложности в списке упражнений из книги Б. Страуструпа — упр. 15, п. 21.10 в третьем и специальном изданиях книги «Язык программирования C++».)

С самого начала очевидной была необходимость перегрузки операций << и >> для своих классов потоков. Казалось, этого будет достаточно. Проблема возникла в следующем. Чтобы работать с потоком с помощью алгоритмов стандартной библиотеки, следует использовать итераторы ввода/вывода. По заявлениям авторов STL её средства все из себя обобщённые и я ожидал, что как только мой класс потока будет удовлетворять некоторым неявным требованиям библиотеки, она с радостью заработает с ним — статический полиморфизм… В частности, я ожидал, что стандартные итераторы параметризованы типом потока, с которым они работают. Не тут-то было! В определении шаблонов итераторов ввода/вывода жёстко зашиты стандартные типы basic_ofstream/basic_ifstream.

Надежда на спасение на этом пути остаётся, если обратить внимание на одну особенность реализации операций << и >>: для базовых типов они реализованы как функции-члены шаблонов классов потоков. Если бы они, кроме того, были объявлены виртуальными, то можно было бы положиться на динамический полиморфизм (стандартные итераторы хранили бы объекты моих потоков по ссылке на базовый класс) — получилось бы частичное решение исходной задачи, работавшее только для базовых типов (int, double и т. п.). Однако указанные функции-члены не являются виртуальными. Тут можно было бы порассуждать о логике устройства стандартной библиотеки или отсутствии таковой (например, известно, что для STL изначально не предполагалось использования всей мощи ООП, наследования и полиморфизма, но ведь потоковая библиотека построена на ООП…), однако перейдём к финальному решению.

Ad-hoc полиморфизм (перегрузка): win


В конце концов, всё, что требуется — вызывать специальные версии операций << и >>, которые бы прятали низкоуровневую работу с файлами посредством read/write. Достаточно предоставить свою перегрузку этих операций и позаботиться о том, чтобы вызывалась именно она. Достичь этого можно использованием специальных типов в аргументах. Манипулировать типами потоков у нас уже не получилось — остаётся придумать специальные типы для вводимого/выводимого. Здесь напрашивается использование того, что называется «обёртками».

К счастью, нам нет нужны писать новые классы обёрток для разных типов T: можно ограничиться одним шаблоном класса, хранящим поле параметра-типа T и умеющего преобразовываться к ссылке на это поле — константной и неконстантной. Конструктор с одним параметром типа T, который не объявлен как explicit, позволит неявно преобразовывать значения типа T к типу-обёртке. Конструктор без параметров — требование STL. Результирующий код приведён ниже.
#include <iostream>

using std::istream;
using std::ostream;

template<typename T>
class wrap {
    T t;

public:
    wrap() : t() {}

    wrap(T const & t) : t(t) {}

    operator T&() {
        return t;
    }

    operator T const &() const {
        return t;
    }
};

template<typename T>
istream & operator>>(istream & is, wrap<T> & wt) {
    is.read(reinterpret_cast<char *>(&static_cast<T &>(wt)), sizeof(T));
    return is;
}

template<typename T>
ostream & operator<<(ostream & os, wrap<T> const & wt) {
    os.write(
            reinterpret_cast<char const *>(&static_cast<T const &>(wt)),
            sizeof(T));
    return os;
}

Использование static_cast требует от компилятора вызвать определённую в теле шаблона класса операцию приведения типа для получения ссылки на информационное поле, а reinterpret_cast приводит адрес этого поля к указателю на char, готовя нас к низкоуровневой работе с read/write.

Вот пример, демонстрирующий использование обёртки. Он несёт на себе отпечаток тех идей, которые закладывались изначально, а именно, программирование в стиле STL.
#include <algorithm>
#include <fstream>
#include <functional>
#include <iostream>
#include <iterator>
#include <numeric>

#include <cassert>

int main() {
    int arr[] = {1, 1, 2, 3, 5, 8};

    // запись
    std::ofstream out("f.dat");
    std::copy(arr, arr + 6, std::ostream_iterator< wrap<int> >(out));
    out.close();

    // чтение: проверим, что содержимое файла совпадает с массивом
    std::ifstream in("f.dat");
    assert(
            std::inner_product(
                    std::istream_iterator< wrap<int> >(in),
                    std::istream_iterator< wrap<int> >(),
                    arr,
                    true,
                    std::equal_to<int>(),
                    std::logical_and<bool>())
    );
}

Заключение


Понятно, что в результате получился «абсолютный велосипед», который, наверное, писался многими программистами на C++, однако в сети или в каких-то известных библиотеках (например, Boost, в частности, Boost.Iostreams) подобного я не заметил.

Ещё я хотел бы отметить, что намерено оставил за скобками обсуждение актуальности поставленной задачи. Наверное, есть люди, не представляющие работу с файлами на более высоком, чем read/write, уровне. Возможно, найдутся те, кто скажет, что бинарные файлы это прошлое, что они жутко непереносимы или что-то похожее. Может быть, отчасти это так, однако само упражнение в решении такой задачи мне показалось интересным и познавательным.

За постановку задачи и обсуждение решения я искренне благодарю Виталия Николаевича Брагилевского.

UPD1: в комментариях спросили, почему вместо преобразований типов не написать обычные функции-члены get. Преобразования по существу используются в примерах наподобие следующего.
    std::ifstream in("f.dat");
    int arr2[6];
    std::copy(std::istream_iterator< wrap<int> >(in),
            std::istream_iterator< wrap<int> >(), arr2);
Tags:
Hubs:
+31
Comments 49
Comments Comments 49

Articles