如何编写使用临时容器的范围管道?

77

我有一个带有以下签名的第三方函数:

std::vector<T> f(T t);

我还有一个现有的可能是无限范围 (range-v3排序) 的T类型命名为src。我想创建一个流水线,将f映射到该范围的所有元素,并将所有向量展平为具有所有元素的单个范围。
本能地,我会写如下代码:
 auto rng = src | view::transform(f) | view::join;

然而,这种方法之前是行不通的,因为我们无法创建临时容器的视图。 更新:此问题已被修补,由此提交进行了修复。range-v3如何支持这样的范围管道?

非常抱歉回复晚了,我忘记了这个问题的存在。 - Casey
1
下面的hack其实是不必要的。我有自己的ranges库,可以解决这个问题。如果在组合表达式树时,源容器作为左值传递,则存储引用。如果源容器作为右值传递,则将其移动到表达式树中。表达式树的所有节点都支持适当的移动语义,只要您在没有真正复制的情况下构建表达式树,您得到的就是源容器的移动。我最初遇到了range-v3所具有的完全相同的问题,但它是可以解决的。 - bradgonesurfing
1
一些单元测试展示了左值(lvalues)和右值(rvalues)被正确处理。不幸的是,由于它是专有代码库的一部分,我无法分享更多信息 :( https://gist.github.com/bradphelan/0bb9397ea7b49f45122908b1a9da061f - bradgonesurfing
1
实际上我来这里是寻找一个略微不同的问题的答案,我误认为它是这个问题。 在我的情况下,我在管道的开头有一个临时变量:for (int i : std::vector({1,2,3}) | mytransform([](int i) { return i+1; })) { std::cout << i << std::endl; }。 我的mytransform函数会因为std::vector过早被删除(基于范围的for循环的右手边的临时变量的生命周期会延长到循环结束,但其他任何临时变量都不会被延长)而产生垃圾或崩溃。@bradgonesurfing上面的评论解决了这个问题。 - Don Hatch
非显而易见的注意事项:在移动情况下,被移动的 src 容器应该放入 shared_ptr 中,以便所有视图的副本共享源,并且复制视图是 O(1)。 - Don Hatch
显示剩余2条评论
6个回答

20

看起来 range-v3库现在有测试用例展示了如何正确地完成此操作。需要将views::cache1运算符添加到管道中:

auto rng = views::iota(0,4)
        | views::transform([](int i) {return std::string(i, char('a'+i));})
        | views::cache1
        | views::join('-');
check_equal(rng, {'-','b','-','c','c','-','d','d','d'});
CPP_assert(input_range<decltype(rng)>);
CPP_assert(!range<const decltype(rng)>);
CPP_assert(!forward_range<decltype(rng)>);
CPP_assert(!common_range<decltype(rng)>);

那么对于原帖中的问题,解决方案是编写:

auto rng = src | views::transform(f) | views::cache1 | views::join;

7
是的,这是今天的正确答案。(我是range-v3的作者。)如果有人想知道,views::cache1会将范围中最近生成的元素缓存在视图对象本身中。解引用迭代器将返回对缓存对象的引用。这给它一个稳定的地址,所以views::join可以在不使其悬空的情况下对其进行迭代。 - Eric Niebler
5
有没有任何免费的方法可以得到这种行为?这似乎有点可疑。也许可以使用一种特质/概念来增强范围(range),以表明它们是否是纯净的,即遍历两次会得到相同的结果。然后下游组合器可以决定是否缓存。 - bradgonesurfing

15

range-v3禁止在临时容器上使用视图,以帮助我们避免创建悬空迭代器。您的示例恰好说明了为什么在视图组合中需要此规则:

auto rng = src | view::transform(f) | view::join;

如果view::join存储由f返回的临时向量的beginend迭代器,那么它们将在使用之前失效。
“这很好,Casey,但为什么range-v3视图不像这样内部存储临时范围?”
因为性能。就像STL算法的性能取决于迭代器操作为O(1)的要求一样,视图组合的性能取决于视图操作为O(1)的要求。如果视图在你背后将临时范围存储在内部容器中,则视图操作的复杂性 - 因此组合的复杂性 - 将变得不可预测。
“好吧,既然我理解了所有这些精美的设计,我该怎么让它工作?!?”
由于视图组合不会为您存储临时范围,因此您需要自己将其转储到某种存储中,例如:
#include <iostream>
#include <vector>
#include <range/v3/range_for.hpp>
#include <range/v3/utility/functional.hpp>
#include <range/v3/view/iota.hpp>
#include <range/v3/view/join.hpp>
#include <range/v3/view/transform.hpp>

using T = int;

std::vector<T> f(T t) { return std::vector<T>(2, t); }

int main() {
    std::vector<T> buffer;
    auto store = [&buffer](std::vector<T> data) -> std::vector<T>& {
        return buffer = std::move(data);
    };

    auto rng = ranges::view::ints
        | ranges::view::transform(ranges::compose(store, f))
        | ranges::view::join;

    unsigned count = 0;
    RANGES_FOR(auto&& i, rng) {
        if (count) std::cout << ' ';
        else std::cout << '\n';
        count = (count + 1) % 8;
        std::cout << i << ',';
    }
}

请注意,这种方法的正确性取决于 view::join 是一个输入范围,因此是单遍的。
“这不是新手友好的。哎呀,甚至不是专家友好的。为什么 range-v3 中没有对‘临时存储物化™’提供支持?”
因为我们还没有开始处理它 - 欢迎提交补丁 ;)

3
现在的答案是使用 views::cache1,正如用户 bradgonesurfing 在下面的回答中所示。 - Eric Niebler

12

我怀疑它无法这样做。没有任何一个view具有存储临时对象的机制 - 这明确违反了来自文档中视图的概念:

视图是一种轻量级包装器,以某种自定义方式呈现底层元素序列的视图,而不对其进行变异或复制。视图创建和复制都很便宜,并具有非拥有引用语义。

因此,为了使那个join起作用并在表达式结束后仍然存在,必须在某个地方保存这些临时对象。那个东西可能是一个action。这将起作用 (演示):

auto rng = src | view::transform(f) | action::join;

除了src无限大的情况,即使对于有限的src来说,它可能会增加太多开销,你也不想使用。

你可能需要复制/重写view::join,以使用一些微调版本的view::all(这里需要查看),而不是要求一个左值容器(并返回一个迭代器对到其中),允许存储内部的右值容器(并返回一个迭代器对到存储的版本)。但是,那需要复制几百行代码,所以似乎相当不令人满意,即使它能正常工作。


3
range 库现在有一个干净的解决方案来解决这类问题:views::cache1。请参见 bradgonesurfing 的答案。 - Eric Niebler

6

编辑

显然,下面的代码违反了视图不能拥有它们所引用数据的规则。(但我不知道像这样写是否严格禁止。)

我使用 ranges::view_facade 创建自定义视图。它保存由 f 返回的向量(一次一个),将其更改为范围。这使得可以在这些范围的范围上使用 view::join。当然,我们不能随机或双向访问元素(但 view::join 本身会将范围降级为输入范围),也不能对它们进行赋值。

我从 Eric Niebler 的存储库中复制了 struct MyRange,稍作修改。

#include <iostream>
#include <range/v3/all.hpp>

using namespace ranges;

std::vector<int> f(int i) {
    return std::vector<int>(static_cast<size_t>(i), i);
}

template<typename T>
struct MyRange: ranges::view_facade<MyRange<T>> {
private:
    friend struct ranges::range_access;
    std::vector<T> data;
    struct cursor {
    private:
        typename std::vector<T>::const_iterator iter;
    public:
        cursor() = default;
        cursor(typename std::vector<T>::const_iterator it) : iter(it) {}
        T const & get() const { return *iter; }
        bool equal(cursor const &that) const { return iter == that.iter; }
        void next() { ++iter; }
        // Don't need those for an InputRange:
        // void prev() { --iter; }
        // std::ptrdiff_t distance_to(cursor const &that) const { return that.iter - iter; }
        // void advance(std::ptrdiff_t n) { iter += n; }
    };
    cursor begin_cursor() const { return {data.begin()}; }
    cursor   end_cursor() const { return {data.end()}; }
public:
    MyRange() = default;
    explicit MyRange(const std::vector<T>& v) : data(v) {}
    explicit MyRange(std::vector<T>&& v) noexcept : data (std::move(v)) {}
};

template <typename T>
MyRange<T> to_MyRange(std::vector<T> && v) {
    return MyRange<T>(std::forward<std::vector<T>>(v));
}


int main() {
    auto src = view::ints(1);        // infinite list

    auto rng = src | view::transform(f) | view::transform(to_MyRange<int>) | view::join;

    for_each(rng | view::take(42), [](int i) {
        std::cout << i << ' ';
    });
}

// Output:
// 1 2 2 3 3 3 4 4 4 4 5 5 5 5 5 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 9 9 9 9 9 9 

使用gcc 5.3.0编译。


3
你的类型声称满足“视图(View)”概念,因此可以编译。然而,由于它没有O(1)复制,所以这种类型不符合“视图”概念的复杂性要求,因此它实际上不是一个“视图”。在实践中,这意味着人们会错误地推断包含“MyRange”的管道的性能。 - Eric Niebler
@ptrj,fwiw,作为 Range-v3 的新用户,我很快就遇到了这个限制。在这方面,你在 Fluent C++ 上的帖子非常有趣。 - Enlico
@Enlico 我不是Fluent C++文章的作者。我们只是碰巧有一个类似的想法或“hack”。 - ptrj
奇怪,@ptrj,FluentC++的帖子恰好链接到了Eric Niebler上面的评论。真是巧合! - Enlico

4
这里的问题当然是视图的整个概念——一种非存储、分层懒惰求值器。为了遵守此合同,视图必须传递范围元素的引用,通常可以处理rvalue和lvalue引用。
不幸的是,在这种特定情况下,view::transform 只能提供一个 rvalue 引用,因为你的函数 f(T t) 返回一个容器,而view::join 希望一个 lvalue,因为它试图将视图(view::all)绑定到内部容器。
可能的解决方案都会在管道中引入某种形式的临时存储。以下是我想出的选项:
  • Create a version of view::all that can internally store a container passed by an rvalue reference (As suggested by Barry). From my point of view, this violates the "non-storing view" conception and also requires some painful template coding so I would suggest against this option.
  • Use a temporary container for the whole intermediate state after the view::transform step. Can be done either by hand:

    auto rng1 = src | view::transform(f)
    vector<vector<T>> temp = rng1;
    auto rng = temp | view::join;
    

    Or using action::join. This would result in "premature evaluation", will not work with infinite src, will waste some memory, and overall has a completely different semantics from your original intention, so that is hardly a solution at all, but at least it complies with view class contracts.

  • Wrap a temporary storage around the function you pass into view::transform. The simpliest example is

    const std::vector<T>& f_store(const T& t)
    {
      static std::vector<T> temp;
      temp = f(t);
      return temp;
    }
    

    and then pass f_store to the view::transform. As f_store returns an lvalue reference, view::join will not complain now.

    This of course is somewhat of a hack and will only work if you then streamline the whole range into some sink, like an output container. I believe it will withstand some straightforward transformations, like view::replace or more view::transforms, but anything more complex can try to access this temp storage in non-straightforward order.

    In that case other types of storage can be used, e.g. std::map will fix that problem and will still allow infinite src and lazy evaluation at the expense of some memory:

    const std::vector<T>& fc(const T& t)
    {
        static std::map<T, vector<T>> smap;
        smap[t] = f(t);
        return smap[t];
    }
    

    If your f function is stateless, this std::map can also be used to potentially save some calls. This approach can possibly be improved further if there is a way to guarantee that an element will no longer be required and remove it from the std::map to conserve memory. This however depends on further steps of the pipeline and the evaluation.

由于这3种解决方案几乎涵盖了在 view::transformview::join 之间引入临时存储的所有位置,因此我认为这些是您的所有选项。我建议选择第三种方案,因为它将使您保持整体语义完整,并且实现起来非常简单。

创建一个可以绑定到容器的右值引用的view::all版本。这基本上可以防止f返回的临时对象在整个表达式返回期间过期。但是这是错误的。如果它是整个表达式,那么临时对象的生命周期仅绑定到整个表达式。即auto&& x = f();扩展了f的结果的生命周期,但是auto&& x = g(f());不会,并且由g来确保它的生命周期与其返回值一样长。 - R. Martinho Fernandes
@R.MartinhoFernandes 对,我的意思是实际上将f()返回的任何值存储在这个“rvalue view”中,就像其他答案建议的那样。我想“绑定”这个词不太合适。然而,我认为无法避免使用临时变量,因为沿着线路堆叠的view允许(并且view::join将)尝试多次访问这些元素。 - Ap31
实际上,我认为整个问题的根源在于 view::join 违反了这种函数式行为,因为它通过存储左值引用来实现 - 适当的函数式实现只需在需要时每次调用底层视图即可。在这种情况下,这将导致额外的 f() 调用,从函数式的角度来看这是完全可以接受的,但在现实中,函数会消耗 CPU 并且有时具有状态。但是,是的,这个“修复”的 view::join 将是 rvalue-friendly 的,这将是一个 - 适当的 - 解决方案,我想。 - Ap31

4

更新

range-v3现在有一个名为views::cache1的视图,它将最近的元素缓存在视图对象本身中,并返回对该对象的引用。正如用户@bradgonesurfing在他的答案中指出的那样,这是今天清晰高效地解决此问题的方法。

下面是旧的、过时的答案,仅作历史趣味保留。


这是另一种不需要太多花哨技巧的解决方案。它的代价是每次调用f都需要调用std::make_shared。但是,在f中分配和填充容器,因此可能这是一个可以接受的代价。

#include <range/v3/core.hpp>
#include <range/v3/view/iota.hpp>
#include <range/v3/view/transform.hpp>
#include <range/v3/view/join.hpp>
#include <vector>
#include <iostream>
#include <memory>

std::vector<int> f(int i) {
    return std::vector<int>(3u, i);
}

template <class Container>
struct shared_view : ranges::view_interface<shared_view<Container>> {
private:
    std::shared_ptr<Container const> ptr_;
public:
    shared_view() = default;
    explicit shared_view(Container &&c)
    : ptr_(std::make_shared<Container const>(std::move(c)))
    {}
    ranges::range_iterator_t<Container const> begin() const {
        return ranges::begin(*ptr_);
    }
    ranges::range_iterator_t<Container const> end() const {
        return ranges::end(*ptr_);
    }
};

struct make_shared_view_fn {
    template <class Container,
        CONCEPT_REQUIRES_(ranges::BoundedRange<Container>())>
    shared_view<std::decay_t<Container>> operator()(Container &&c) const {
        return shared_view<std::decay_t<Container>>{std::forward<Container>(c)};
    }
};

constexpr make_shared_view_fn make_shared_view{};

int main() {
    using namespace ranges;
    auto rng = view::ints | view::transform(compose(make_shared_view, f)) | view::join;
    RANGES_FOR( int i, rng ) {
        std::cout << i << '\n';
    }
}

你认为将 shared_view 的构造函数从 Container const& 中移除,以防止未来的更改将 O(1) 构造变成 O(N) 构造,这个想法怎么样? - Casey
完成。但目前还不能保证移动容器的时间复杂度为O(1)。 - Eric Niebler
2
这个怎么样?auto rng = src | views::transform(f) | views::cache1 | views::join; - horseyguy
1
是的,这就是你今天要做的。当初提出这个问题时,view::cache1并不存在。 - Eric Niebler
这是一个很好的答案!但我不明白为什么要这样做,直到我看了其他答案,然后自己得出了相同的结论。如果我理解正确,导致这里的路径是:(1)从ptrj的答案开始,它很好地解决了将临时对象的生命周期延长到需要它的地方的问题,但违反了视图合同。(2)Eric对该答案的评论解释了违反的内容:这样的视图将没有O(1)复制方法。(3)意识到,如果所有副本都通过shared_ptr共享数据,则它将具有O(1)复制方法。这就是这个答案。 - Don Hatch
转念一想,现在我不太确定了。你使用shared_ptrs的方式与我在(3)中想的不同。 - Don Hatch

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接