如何检查一个序列中的元素是否需要移动?

3

有一个类似的问题:check if elements of a range can be moved?

我认为其中的答案并不是一个好的解决方案。实际上,它需要对所有容器进行部分特化。


我尝试过,但我不确定仅检查operator*()是否足够。

// RangeType

using IteratorType = std::iterator_t<RangeType>;
using Type = decltype(*(std::declval<IteratorType>()));

constexpr bool canMove = std::is_rvalue_reference_v<Type>;

更新

这个问题可以分成两部分:

  1. STL 中的算法,比如 std::copy/std::uninitialized_copy,在接收右值元素时,是否可以避免不必要的深拷贝?
  2. 当接收到一个 右值范围 时,如何检查它是像 std::ranges::subrange 这样的 范围适配器,还是像 std::vector 这样持有其元素所有权的容器?
template <typename InRange, typename OutRange>
void func(InRange&& inRange, OutRange&& outRange) {
    using std::begin;
    using std::end;
    std::copy(begin(inRange), end(inRange), begin(outRange));
    // Q1: if `*begin(inRange)` returns a r-value,
    //     would move-assignment of element be called instead of a deep copy?
}

std::vector<int> vi;
std::list<int> li;
/* ... */
func(std::move(vi), li2);
// Q2: Would elements be shallow copy from vi?
// And if not, how could I implement just limited count of overloads, without overload for every containers?
// (define a concept (C++20) to describe those who take ownership of its elements)

正如@Nicol Bolas、@eerorika和@Davis Herring所指出的那样,Q1并不是问题,这也不是我困惑的原因。(但我确实认为API很混乱,std::assign/std::uninitialized_construct可能是更理想的名称)

@alfC对我的问题(Q2)做了很好的回答,并提供了一个清晰的观点。(范围移动语义)


总之,对于大多数当前容器(特别是那些来自STL的容器),(以及每个范围适配器...),部分特化/重载函数是唯一的解决方案,例如:

template <typename Range>
void func(Range&& range) { /*...*/ }

template <typename T>
void func(std::vector<T>&& movableRange) {
    auto movedRange = std::ranges::subrange{
        std::make_move_iterator(movableRange.begin()),
        std::make_move_iterator(movableRange.end())
    };

    func(movedRange);
}

// and also for `std::list`, `std::array`, etc...

2
回答这个问题的主要观点是你不需要问这个问题。如果用户给你一个可移动的迭代器范围,那么你自然会从中移动。责任在于用户提供适当的范围。那么...你为什么觉得需要问这个问题呢? - Nicol Bolas
1
没有需要检查的内容。如果迭代器是移动迭代器,那么*it将是一个xvalue。这就是该类型的全部意义:如果it是移动迭代器,则T t = *it;执行移动 - Nicol Bolas
1
@zjyhjqs:在C++中,没有所谓的“r-value type”。有“r-value reference type”,以及r-value表达式的分类。 - eerorika
1
@zjyhjqs 不管怎样,r-value引用并不意味着您可以移动它。您可以将r-value引用引用到非可移动类型上。并且您可以通过将l-value转换为r-value引用来从l-value移动。这就是我所说的“检查某些东西是否可移动和检查某些东西是否是rvalue引用是完全不同的两件事情”的意思。 - eerorika
1
如果您想要有条件地移动,那么您可能正在寻找std::forward吗? - eerorika
显示剩余19条评论
3个回答

3
我理解你的观点。我确实认为这是一个真正的问题。
我的答案是,社区必须确切地同意移动嵌套对象(例如容器)的含义。在任何情况下,这需要容器实现者的合作。而对于标准容器来说,则需要良好的规范。
我悲观地认为,标准容器无法被改变以“泛化”“移动”的含义,但这不能阻止新的用户定义容器利用移动惯用语。问题在于据我所知,没有人深入研究过这个问题。
目前,std::move似乎意味着“浅层移动”(一级移动顶部的“值类型”)。也就是说,你可以移动整个东西,但不一定是单个部分。这反过来使得尝试“std::move”非拥有范围或提供指针/迭代器稳定性的范围毫无意义。
一些库,例如与std::ranges相关的库,仅拒绝r-value引用范围,我认为这只是推卸责任。
假设您有一个容器Bagstd::move(bag)[0]std::move(bag).begin()应该返回什么?这实际上取决于容器的实现决定返回什么。
很难想到一般的数据结构,但如果数据结构很简单(例如动态数组),为了与结构体struct保持一致(std::move(s).field),std::move(bag)[0]应该与std::move(bag[0])相同,然而标准已经强烈反对我:https://en.cppreference.com/w/cpp/container/vector/operator_at 而且现在可能为时已晚。
同样适用于std::move(bag).begin(),根据我的逻辑,它应该返回一个move_iterator(或类似的东西)。
事情变得更糟的是,std::array<T, N> 的工作方式符合我的期望(std::move(arr[0])等同于std::move(arr)[0])。但是,std::move(arr).begin()只是一个简单的指针,所以它失去了“转发/移动”的信息!这真是一团糟。
因此,是的,回答您的问题,您可以检查using Type = decltype(*std::forward<Bag>(bag).begin());是否为r-value,但往往不会实现为r-value。也就是说,你必须希望最好的并相信.begin*以非常特定的方式实现。
通过某种方式检查范围本身的类别,你会处于更好的状态。也就是说,目前你只能靠自己:如果你知道bag绑定到一个r-value,并且类型在概念上是一个“拥有”值,那么你目前必须使用std::make_move_iterator来完成操作。
我目前正在尝试使用自定义容器进行大量实验。https://gitlab.com/correaa/boost-multi 但是,通过尝试允许此操作,我破坏了标准容器对于移动的预期行为。而且,一旦进入非所有权范围,就必须手动使迭代器可移动。
我发现区分顶层移动(std::move)和元素移动(例如bag.mbegin()bag.moved().begin())在经验上是有用的。否则,我会过度重载std::move,这应该是最后的选择,如果有的话。
换句话说,在......
template<class MyRange>
void f(MyRange&& r) {
   std::copy(std::forward<MyRange>(r).begin(), ..., ...);
}

“r”绑定到一个r-value并不意味着元素可以移动,因为“MyRange”可能只是一个“刚刚”生成的更大容器的非拥有视图。因此,通常需要外部机制来检测“MyRange”是否拥有值,而不仅仅是像您提出的那样检测“*std::forward(r).begin()” 的“值类别”。我猜想,使用范围(ranges),我们可以希望在将来通过某种适配器一样的东西“std::ranges::moved_range”或使用三参数“std::move”来指示深层次的移动。

我尝试定义std::move(Bag)[0]std::move(Bag).begin()的行为。请看我的回答,希望能得到一些建议。 - zjyhjqs

0

这个问题的答案是:不可能。至少对于当前的STL容器而言是如此。


假设我们能为容器要求添加一些限制呢?

添加一个静态常量isContainer,并创建一个RangeTraits。这可能会很有效,但不是我想要的优雅解决方案。

受@alfC启发,我正在考虑r-value容器本身的适当行为,这可能有助于制定概念(C++20)。

实际上有一种区分容器和范围适配器之间差异的方法,尽管由于当前实现中的缺陷而无法检测到,但不是语法设计的问题。


首先,元素的生命周期不能超过其容器,并且与范围适配器无关。
这意味着从一个右值容器中检索元素的地址(通过迭代器或引用)是错误的行为。

在11年后的时代,有一件事往往被忽视,ref-qualifier

许多现有成员函数,例如std::vector::swap,应标记为左值限定符:

auto getVec() -> std::vector<int>;

//
std::vector<int> vi1;
//getVec().swap(vi1); // pre-11 grammar, should be deprecated now
vi1 = getVec(); // move-assignment since C++11

由于兼容性的原因,然而它并没有被采用。(像std::array和std::forward_list这样的新构建的东西中普遍应用的 ref-qualifier 更加令人困惑。)

例如,按照我们的预期实现下标运算符非常容易:

template <typename T>
class MyArray {
    T* _items;
    size_t _size;
    /* ... */

public:
    T& operator [](size_t index) & {
        return _items[index];
    }
    const T& operator [](size_t index) const& {
        return _items[index];
    }

    T operator [](size_t index) && {
        // not return by `T&&` !!!
        return std::move(_items[index]);
    }

    // or use `deducing this` since C++23
};

好的,那么 std::move(container)[index] 将返回与 std::move(container[index]) 相同的结果(不完全相同,可能会增加额外的移动操作开销),这在我们尝试转发容器时非常方便。

但是,beginend 呢?


template <typename T>
class MyArray {
    T* _items;
    size_t _size;
    /* ... */

    class iterator;
    class const_iterator;
    using move_iterator = std::move_iterator<iterator>;

public:
    iterator begin() & { /*...*/ }
    const_iterator begin() const& { /*...*/ }

    // may works well with x-value, but pr-value?
    move_iterator begin() && {
        return std::make_move_iterator(begin());
    }

    // or more directly, using ADL
};

这么简单,就像这样吗?

不是!迭代器在容器销毁后会失效。因此从临时 (pr-value) 中解引用迭代器是未定义的行为!!

auto getVec() -> std::vector<int>;

///
auto it = getVec().begin(); // Noooo
auto item = *it; // undefined behaviour

由于程序员无法识别对象是pr值还是x值(两者都会推导成T),从r值容器中检索迭代器应该被禁止
如果我们可以调节容器的行为,Container显式删除从r-value容器获取迭代器的函数,那么就有可能检测出来。
这里有一个简单的演示: https://godbolt.org/z/4zeMG745f

从我的角度来看,禁止这种明显错误的行为可能并不会对良好实现的旧项目造成编译失败等破坏性影响。

实际上,每个容器只需要进行一些修改,并为范围访问工具添加适当的约束或重载,例如std::begin/std::ranges::begin


我同意目标和方法,但在技术和哲学上有一些不同。这是我在自己的实验中所做的。首先,我有T&& operator[](size_t i) && {return std::move(operator[](i));}。这样更一致,并且可以节省移动操作。另外,我会像你最初提出的那样保留.begin(),因为它与(我的)op[]更一致,而且你仍然可以在一行中使用begin和end。copy(move(v).begin(), move(v).end(), dest)。如果我们认为语义上begin和end都不能浅移动v,那么这种方式更加一致。 - alfC
@alfC 关键点是_从右值容器中保存元素的地址是危险的_。当客户端注意到一个返回引用类型T&&的函数时,他们可能倾向于使用通用引用auto&&/const auto&,这将导致当容器是像getVec()这样的临时对象时出现悬空指针问题。迭代器也是如此。 - zjyhjqs
好的,我们在这个问题上有根本性的分歧。问题在于试图保存即将失效的某个东西的地址,如果你只是即时使用它是可以的。并且,在我的观点中,引用也是同样的情况。迭代器和引用总是可能失效,你无法防止这种情况发生。我提出的建议是明确表明什么即将失效。 - alfC
在我看来,如果实用程序实现得好,客户端就不需要从即将过期的容器中获取迭代器。就像copy(std::move(v), dest)copy(MovedRange{v.begin() + 2, v.end() - 3}, dest)一样可以。那些实现**copy**的人在接收到r-value时可以根据足够的信息进行适当的优化。 - zjyhjqs
在理想的世界中,是的,但是某人仍然必须实现对过期容器的操作,为此他/她将使用迭代器(或具有相同问题的范围)。而且,在现实中,无论是否过期,迭代器(/范围)仍然是容器和算法之间的粘合剂。看看我的库,我按照我提出的方式实现了operator[]begin/end,这允许这段代码:https://gitlab.com/correaa/boost-multi/-/blob/master/test/element_access.cpp#L338-363 。库的目标不是保护用户免受不良行为的影响,而是允许他们做强大的事情。 - alfC
如果您愿意,让我们在聊天或此处的问题中继续:https://gitlab.com/correaa/boost-multi/-/issues - alfC

0
如果问题是使用std::move还是std::copy(或ranges::等效物),答案很简单:始终使用copy。如果给定的范围具有rvalue元素(即,它的ranges::range_reference_t是任一种rvalue),您将无论如何从中移动(只要目标支持移动赋值)。 move是一种方便的方式,用于当您拥有该范围并且决定从其元素中移动时。

如果您可以检测到源绑定到r-value,最好使用某种显式移动(例如template<class DestContainerNonVector> void transfer_to(std::vector<std::string>&& v, DestContainerNonVector& dest) { assert(v.size() == dest.size()); std::move(v.begin(), v.end(), dest.begin());})。您说总是这样做,但在最后做出了例外。这是OP想要通用处理的情况。关键在于,如果您不知道源范围或容器,则很难将其泛化。 - alfC
@alfC:这不是一个异常,因为您拥有的范围不是需要检查的未知量:换句话说,在transfer_to中没有检测。如果作为一种便利,您想要自动从作为rvalue 容器的范围参数的元素中移动,那就是另外一个问题了。 - Davis Herring

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