如何编写使用临时容器的范围管道?
Posted
技术标签:
【中文标题】如何编写使用临时容器的范围管道?【英文标题】:How do I write a range pipeline that uses temporary containers? 【发布时间】:2021-05-22 19:48:15 【问题描述】:我有一个带有这个签名的第三方函数:
std::vector<T> f(T t);
我还有一个名为 src
的 T
的现有潜在无限范围 (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 << i << std::endl;
。我的 mytransform 函数出现垃圾或崩溃,因为 std::vector
被过早删除(基于范围的循环的临时 rhs 的生命周期被延长到循环结束,但任何其他临时时间都没有得到延长:-( ) @bradgonesurfing 上面的 cmets 出色地解决了这个问题。
非显而易见的警告:在移动的情况下,移动的 src 容器应该进入一个 shared_ptr 以便视图的所有副本共享源并且复制视图是 O(1)。
【参考方案1】:
我怀疑它不能。 view
s 没有任何机制可以在任何地方存储临时文件 - 这明显违反了 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::transform
s,但任何更复杂的东西都可以尝试以非直接顺序访问这个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::transform
和 view::join
之间引入临时存储的所有地方,我认为这些都是您可以选择的。我建议使用#3,因为它可以让您保持整体语义完整并且实现起来非常简单。
【讨论】:
“创建一个 view::all 版本,它可以绑定到容器的右值引用。这基本上可以防止 f 返回的临时对象在整个表达式返回的生命周期内过期。”这是错误的。如果它是整个表达式,则临时的生命周期仅绑定到整个表达式。 IE。auto&& x = f();
延长了 f
的结果的生命周期,但 auto&& 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
返回的临时向量的begin
和end
迭代器,它们将在被使用之前失效。
“这很好,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&
中删除 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
可以在它没有悬空的情况下迭代它。
有没有办法免费获得这种行为?它看起来确实很臭。也许范围可以增加一个特征/概念,说明它们是否是纯的,即:迭代它们两次会得到相同的结果。然后下游组合器可以决定是否缓存。以上是关于如何编写使用临时容器的范围管道?的主要内容,如果未能解决你的问题,请参考以下文章
如何在 Linux 平台上创建用于用 C++ 编写的临时文件?
如何在具有多个数据帧列输入的 sklearn 管道中编写转换器