C++更便捷的reader/writer的写法

在本文中,writer用于将某个变量写入到硬盘上,而reader则从硬盘上读取该变量的值;不考虑完整的序列化/反序列化特性和跨平台兼容性。

reader/writer的此番操作虽然可以被比较容易地实现,但是写法各不相同。较为常见的有遵循STL库流式操作(input/output stream)的写法:

// write x to the disk
writer w("dump.bin");
int x = 1;
w << x;

// read x from the disk
reader r("dump.bin");
int x;
r >> x;

然而,这其中存在两个问题
1)在上述的reader例子中,x需要被提前定义,显得不够优雅;更进一步的,假如x不能被默认初始化,或者初始化具有non-trivial的开销,就会造成书写或者性能上的问题;
2)这样的写法并不能很好地支持数组,特别是多维数组的存取。

本文介绍如下更为便捷的写法:

(写入单个变量或数组)

writer w("dump.bin");

// ------ write a single value ------
w('A');
double b = 1.0;
w(b);

// ------ write an array ------
std::vector v{
    std::vector{48,49,50,51},
    std::vector{97,98,99,100,101},
    std::vector{65,66}
};

w(v); // v is a multidimensional array
w(std::vector<int>(2'000'000'000));

(读取单个变量或数组)

reader r("dump.bin");

// ------ read a single value ------
char a = r();
auto b = (double)r();
// auto x = r(); // error; read an auto type is not allowed

// ------ read an array ------
std::vector<std::vector<int>> v = r({4,5,2}); // an array with three dimensions
std::vector<int> v2 = r(11); // a single-dimensional array

注意到上述reader的写法中,用户并不需要显式为roperator()调用指定返回值类型;相反的是,它能自动根据接收变量的类型来决定
我首先介绍这部分语法的实现,然后简单讲述对多维数组的支持。

reader调用不显式声明类型

不显式声明这件事,对于writer来说,相当的容易。对于一个writer,由于其是一个callable object,拥有小括号重载template<typename T> void operator(T x),我们可以轻易地得到x的类型T,并进行相应的写操作。但对于reader来说,对它的小括号调用是无参数的;如果声明成template<typename T> T operator()并在模板中显式填写返回值类型,由于C++的语法限制,并不能写成auto x = r<int>(),而是得写成auto x = r.operator()<int>(),变得又长又丑。更好的写法是,对x声明类型,而让r的返回值自动匹配,即int x = r()

我们让reader返回一个临时类型,称为transformer,并为其重载强制类型转换符template<typename T> transformer::operator T()。这样一来,int x = r()事实上就等价为如下语句

transformer t = r();
int x = t; // [ T = int ]

如此一来,x的类型信息便在transformer的类型转换重载中被获取了。

接下来的问题是,transformer既然是一个中间类型,我们并不想将它向用户公开;换而言之,我们要禁止用户通过auto t = r()的方式获取到具体的transformer对象。

一个简单的想法是,我们通过不公开其构造函数(声明为private/protected)来阻止用户构造;但这并不管用;考虑如下场景:

struct transformer{
template<typename T>
    operator T() const{...}

private:
    transformer(){}
    transformer(const transformer&) = delete;
    transformer(transformer&&) = delete;

    friend class reader;
};

struct reader{
    ...
    transformer operator(){
        return transformer();
    }
};

int main(){
    auto t = reader()(); // Fail to report errors
    return 0;
}

尽管看起来在main函数的第二行,用户调用了已被删除的transformer的移动构造函数,从而触发编译错误;但是,由于copy elision的缘故,auto t = reader()()被直接解释为在reader::operator()中对main::t的初始化,而无需调用移动构造函数。

解决方法是让reader::operator()返回一个引用transformer&,从而规避copy elision,触发构造函数。虽然用户依然可以使用auto &t = reader()()的写法来获得一个transformer的引用,但是由于该写法本来就是非法的(正常情况下无法写作auto &t= r()),故不对此额外处理。

在实际实现中,每个reader对象在其内部存储一份transformer变量,并在reader::operator()中返回其引用。为了减小拷贝开销,该transformer仅存储了其外部reader对象的this指针。transformer::operator T()会获取到类型T并转调用reader::read<T>来进行实际的文件读取和变量初始化操作。示意代码如下:

class mmap_reader
{
    class transformer_base{
        transformer_base(mmap_reader *r) : r(r), arg(nullptr){}

        transformer_base(const transformer_base&) = delete;
        transformer_base(transformer_base&&) = delete;
        transformer_base& operator=(const transformer_base&) = delete;
        transformer_base& operator=(transformer_base&&) = delete;

        friend mmap_reader;

    protected:
        mmap_reader *r;
        const void *arg;
    };

    template<typename A=void>
    class transformer : transformer_base{
        friend mmap_reader;
    public:
        template<typename T>
        operator T() const{
            if constexpr(std::is_void_v<A>)
                return this->r->read<T>();
            else
                return this->r->read<T>(*(const A*)(this->arg));
        }
    };

    transformer_base gen = this;
    ...

public:
    template<typename T>
    [[nodiscard]] T read() requires(std::is_trivially_copyable_v<T>) {...}

    template<std::ranges::range R, class U>
    [[nodiscard]] R read(const U &sizes) {...}

    template<std::ranges::range R, class U>
    [[nodiscard]] R read(const std::initializer_list<U> &sizes)
    {
        return read<R>(std::ranges::ref_view(sizes));
    }

    [[nodiscard]] transformer<>& operator()() // RETURN THE REF
    {
        gen.arg = nullptr;
        return static_cast<transformer<>&>(gen);
    }

    template<typename U>
    [[nodiscard]] transformer<U>& operator()(const U &sizes)
    {
        gen.arg = &sizes;
        return static_cast<transformer<U>&>(gen);
    }
    template<typename U>
    [[nodiscard]] auto operator()(const std::initializer_list<U> &sizes)
        -> transformer<std::initializer_list<U>>&
    {
        gen.arg = &sizes;
        return static_cast<
            transformer<std::initializer_list<U>>&
            >(gen);
    }

    ...
};

注意到为了支持对数组的读取,上述代码还增加了对reader::readreader::operator()的重载,以及在transformer中引入了额外的成员arg来暂存数组长度信息。

对多维数组的支持

对多维数组支持的实现难点主要体现在writer中,分别是1)如何获取传入数组的各维长度并计算各元素在文件中的偏移量;2)如何将元素并行写入buffer。出于篇幅考量,本文仅开第一部分;示意代码如下:

template<std::ranges::range R>
static auto calc_offset(const R &r) requires(
    std::is_trivially_copyable_v<std::ranges::range_value_t<R>> &&
    !std::ranges::range<std::ranges::range_value_t<R>>)
{
    return std::pair(
        util::delayed_seq(std::ranges::size(r)+1, [&](size_t i){
            return i * sizeof(std::ranges::range_value_t<R>);
        }),
        0
    );
}
template<std::ranges::range R>
static auto calc_offset(const R &r) requires
    std::ranges::range<std::ranges::range_value_t<R>>
{
    using cm = custom<typename lookup_custom_tag<R>::type>;
    using value_t = std::ranges::range_value_t<R>;
    using ret_t = decltype(calc_offset(std::declval<value_t>()));
    auto sub = util::init<typename cm::seq<ret_t>>(
        std::ranges::size(r),
        [&](size_t i){return calc_offset(r[i]);}
    );
    auto sizes = util::delayed_seq(sub.size(), [&](size_t i){
        return sub[i].first.back();
    });
    auto [offset,tot] = cm::scan(sizes);
    offset.push_back(tot);
    return std::pair(std::move(offset), std::move(sub));
}

我们逐维度递归并计算每一层元素的偏移量;递归的base case是对于当前层的元素类型R,判断R是否是满足is_trivially_copyable_v<range_value_t<R>> && !range<range_value_t<R>>;即它的成员并非range,意味着R已经是多维数组的最后一层,而且是trivially copyable,数据结构可以被安全地写入到文件里。
在上述代码中,calc_offset的第一个重载对应着base case,而第二个重载则是继续递归。之所以以重载的形式实现递归,是因为每一层递归的返回值类型都不相同,因此需要通过实例化不同的函数模板来实现。

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:《C++更便捷的reader/writer的写法》https://dappur.tech/blog/13/