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

Posted

技术标签:

【中文标题】如何编写使用临时容器的范围管道?【英文标题】:How do I write a range pipeline that uses temporary containers? 【发布时间】:2021-05-22 19:48:15 【问题描述】:

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

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

我还有一个名为 srcT 的现有潜在无限范围 (of the range-v3 sort)。我想创建一个管道,将f 映射到该范围内的所有元素,并将所有向量扁平化到一个包含所有元素的范围内。

本能地,我会写以下内容。

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

但是,这行不通,因为我们无法创建临时容器的视图。

range-v3 如何支持这样的 range 管道?

【问题讨论】:

很抱歉回答得太晚了,我忘记了这个问题的存在。 下面的 hack 应该没有必要。我有自己的范围库来解决这个问题。如果在编写表达式树时源容器作为左值传递,则存储一个引用。如果源容器作为右值传递,则将其移动到表达式树中。表达式树的所有节点都支持正确的移动语义,因此只要您在没有真实副本的情况下构建表达式树,您得到的只是源容器的移动。我一开始遇到了与 range-v3 完全相同的问题,但它是可以解决的。 一些单元测试表明左值和右值被正确处理。不幸的是,我不能分享更多,因为它是专有代码库的一部分:(gist.github.com/bradphelan/0bb9397ea7b49f45122908b1a9da061f 我实际上是来这里寻找一个稍微不同的问题的答案,我把它误认为是这个问题。就我而言,我在管道的开头有一个临时的:for (int i : std::vector(1,2,3) | mytransform([](int i) return i+1; )) std::cout &lt;&lt; i &lt;&lt; std::endl; 。我的 mytransform 函数出现垃圾或崩溃,因为 std::vector 被过早删除(基于范围的循环的临时 rhs 的生命周期被延长到循环结束,但任何其他临时时间都没有得到延长:-( ) @bradgonesurfing 上面的 cmets 出色地解决了这个问题。 非显而易见的警告:在移动的情况下,移动的 src 容器应该进入一个 shared_ptr 以便视图的所有副本共享源并且复制视图是 O(1)。 【参考方案1】:

我怀疑它不能。 views 没有任何机制可以在任何地方存储临时文件 - 这明显违反了 docs 的视图概念:

视图是一种轻量级的包装器,它以某种自定义方式呈现底层元素序列的视图,而无需更改或复制它。视图的创建和复制成本很低,并且具有非拥有引用语义。

因此,为了使 join 能够发挥作用并比表达式更长寿,必须在某个地方保留这些临时变量。那东西可能是action。这会起作用(demo):

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

除了src 显然不是无限的,即使是有限的src 也可能会增加太多开销,让您无论如何都不想使用。

您可能不得不复制/重写view::join 来代替使用view::all 的一些巧妙修改的版本(需要here),而不是需要一个左值容器(并返回一个迭代器对),允许将在内部存储的右值容器(并将迭代器对返回到该存储版本中)。但那是几百行代码的复制量,所以看起来很不令人满意,即使这样可行。

【讨论】:

范围库现在为此类问题提供了一个干净的解决方案:views::cache1。请参阅 bradgonesurfing 的答案。【参考方案2】:

已编辑

显然,下面的代码违反了视图不能拥有它们引用的数据的规则。 (不过不知道是不是严禁写这样的东西。)

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

我从 Eric Niebler 的 repository 中复制了 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 编译。

【讨论】:

您的类型声称满足View 概念,因此可以编译。但是,由于它没有 O(1) 副本,所以这种类型不符合 View 概念的复杂度要求,因此它不是 /actually/ 一个 View。在实践中,这意味着人们会错误地推断包含 MyRange 的管道的性能。 @ptrj,fwiw,作为 Range-v3 的新用户,I've stumbled against this limitation pretty soon。 Your post on Fluent C++ 在这方面很有意思。 @Enlico 我不是 Fluen C++ 帖子的作者。我们只是碰巧有一个类似的想法或“黑客”。 奇怪,@ptrj,FluentC++ 帖子的链接与 Eric Niebler 上面的评论完全一致。多么巧合!【参考方案3】:

这里的问题当然是视图的整个概念——一个非存储的分层惰性求值器。为了跟上这个约定,视图必须传递对范围元素的引用,并且通常它们可以处理右值和左值引用。

不幸的是,在这种特定情况下,view::transform 只能提供一个右值引用,因为您的函数f(T t) 按值返回一个容器,而view::join 在尝试将视图 (view::all) 绑定到内部容器时需要一个左值.

可能的解决方案都会在管道的某处引入某种临时存储。以下是我想出的选项:

创建一个view::all 的版本,它可以在内部存储由右值引用传递的容器(如Barry 所建议的那样)。在我看来,这违反了 “非存储视图”的概念,也需要一些痛苦的模板 编码,所以我建议不要使用此选项。

view::transform 步骤之后为整个中间状态使用一个临时容器。可以手动完成:

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

或使用action::join。这将导致“过早评估”,不适用于无限src,会浪费一些内存,并且总体上与您的初衷具有完全不同的语义,因此这根本不是一个解决方案,但至少它符合查看课程合同。

围绕传递给view::transform 的函数包装一个临时存储空间。最简单的例子是

const std::vector<T>& f_store(const T& t)

  static std::vector<T> temp;
  temp = f(t);
  return temp;

然后将f_store 传递给view::transform。由于f_store 返回一个左值引用,view::join 现在不会抱怨了。

这当然有点小技巧,只有当您将整个范围精简到某个接收器(例如输出容器)时才会起作用。我相信它可以承受一些简单的转换,例如view::replace 或更多view::transforms,但任何更复杂的东西都可以尝试以非直接顺序访问这个temp 存储。

在这种情况下,可以使用其他类型的存储,例如std::map 将解决该问题,并且仍然允许无限 src 和惰性求值,但会牺牲一些内存:

const std::vector<T>& fc(const T& t)

    static std::map<T, vector<T>> smap;
    smap[t] = f(t);
    return smap[t];

如果你的f 函数是无状态的,这个std::map 也可以用来潜在地保存一些调用。如果有办法保证不再需要某个元素并将其从std::map 中删除以节省内存,则此方法可能会进一步改进。然而,这取决于管道和评估的进一步步骤。

由于这 3 个解决方案几乎涵盖了在 view::transformview::join 之间引入临时存储的所有地方,我认为这些都是您可以选择的。我建议使用#3,因为它可以让您保持整体语义完整并且实现起来非常简单。

【讨论】:

“创建一个 view::all 版本,它可以绑定到容器的右值引用。这基本上可以防止 f 返回的临时对象在整个表达式返回的生命周期内过期。”这是错误的。如果它是整个表达式,则临时的生命周期仅绑定到整个表达式。 IE。 auto&amp;&amp; x = f(); 延长了 f 的结果的生命周期,但 auto&amp;&amp; x = g(f()); 没有,这取决于 g 来确保它与返回值一样长。 @R.MartinhoFernandes 对,我的意思是实际存储 f() 在此“右值视图”中返回的任何值,正如其他答案所建议的那样。我想“绑定”是一个错误的词。但是,我认为暂时无法逃脱,因为允许(并且view::join 将)多次尝试访问这些元素。 实际上我认为这整个问题源于view::join通过存储左值引用违反了这种功能性行为 - 正确的功能实现只会在每次需要时调用底层视图.在这种情况下,这将导致额外的 f() 调用,这对于功能性 pov 来说是完全可以的,但实际上函数当然会消耗 CPU 并且有时具有状态。但是,是的,这个“固定”view::join 将是右值友好的,我猜这将是一个合适的解决方案【参考方案4】:

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 中没有对“临时存储实现™”的某种支持?”

因为我们还没有解决它 - 欢迎使用补丁 ;)

【讨论】:

现在的答案是使用views::cache1,正如用户 bradgonesurfing 在下面的答案中所示。【参考方案5】:

更新

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';
    

【讨论】:

对于从 Container const&amp; 中删除 shared_view 的构造函数以防止未来的变化默默地将 O(1) 构造变成 O(N) 构造,您有什么看法? 完成。但目前不能保证移动容器是 O(1)。 这个怎么样? auto rng = src | views::transform(f) | views::cache1 | views::join; 是的,这就是你今天的做法。首次提出此问题时,view::cache1 不存在。 这是一个很好的答案!但是我不明白您为什么要这样做,直到我阅读了其他答案然后自己得出了相同的结论。如果我理解正确,这里的路径是:(1)从ptrj的答案开始,这很好地解决了将临时的寿命延长到需要的地方的问题,但违反了视图合同。 (2) Eric 对该答案的评论解释了违反了什么:这样的视图不会有 O(1) 复制方法。 (3) 意识到它有一个 O(1) 复制方法,如果它的所有副本通过 shared_ptr 共享数据。这就是答案。【参考方案6】:

看起来像there are now test cases in the range-v3 library 显示了如何正确做到这一点。需要在管道中添加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)>);

所以 OP 问题的解决方案是写

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

【讨论】:

是的,这是今天的正确答案。 (我是 range-v3 的作者。)对于任何想知道的人,views::cache1 缓存了视图对象本身中最近生成的范围元素。取消引用迭代器会返回对缓存对象的引用。这给了它一个稳定的地址,所以views::join 可以在它没有悬空的情况下迭代它。 有没有办法免费获得这种行为?它看起来确实很臭。也许范围可以增加一个特征/概念,说明它们是否是纯的,即:迭代它们两次会得到相同的结果。然后下游组合器可以决定是否缓存。

以上是关于如何编写使用临时容器的范围管道?的主要内容,如果未能解决你的问题,请参考以下文章

尝试编写命名管道时如何修复“Broken Pipe”错误?

如何在 Linux 平台上创建用于用 C++ 编写的临时文件?

如何在具有多个数据帧列输入的 sklearn 管道中编写转换器

在golang中,如何编写一个为下一个阶段引入延迟的管道阶段?

ifstream 管道和多个(顺序)编写器

如何编写一个简单的依赖注入容器