为什么将 std::move 和 std::list 与自定义视图类型一起使用会导致无限递归?

Why does this usage of std::move and std::list with a custom view type cause an infinite recursion?

提问人:neuroevolutus 提问时间:11/4/2023 最后编辑:LoSneuroevolutus 更新时间:11/11/2023 访问量:161

问:

我正在阅读 Rainer Grimm 的书 C++20:获取有关定义自己的视图类型的详细信息 §5.1.7.2,当时我决定修改提供的代码示例,看看它是否适用于 .代码最终如下所示:std::list

#include <concepts>
#include <iostream>
#include <list>
#include <ranges>
#include <vector>

template <std::ranges::input_range Range>
  requires std::ranges::view<Range>
class ContainerView : public std::ranges::view_interface<ContainerView<Range>> {
  private:
  std::ranges::iterator_t<Range> begin_ {};
  std::ranges::sentinel_t<Range> end_ {};
  Range range_ {};

  public:
  constexpr ContainerView() = default;

  constexpr ContainerView(Range r)
      : begin_(std::begin(r))
      , end_(std::end(r))
      , range_(std::move(r))
  {
  }

  constexpr auto begin() const { return begin_; }
  constexpr auto end() const { return end_; }
};

int main()
{
  std::list my_list { 1, 2, 3 };
  auto my_container_view { ContainerView(std::views::all(my_list)) };

  for (auto const& c : my_container_view)
    std::cout << c << '\n';
}

该程序使用 x86-64 clang++ v17.0.1 和标志成功编译,并且在运行时,它会打印数字 1、2 和 3,并按预期正常退出。-std=c++20 -fsanitize=undefined -fsanitize=address -Wall -Wextra -Werror

然而,出于好奇,当我将函数更改为在传递到时将其包装在调用中时,程序似乎陷入了无限递归中,重复打印列表的元素。mainmy_liststd::movestd::views::all

int main()
{
  std::list my_list { 1, 2, 3 };
  auto my_container_view { ContainerView(std::views::all(std::move(my_list))) };

  for (auto const& c : my_container_view)
    std::cout << c << '\n';
}

我发现奇怪的是,当我简单地交换 for a 并保持代码的其余部分不变时,程序会打印每个元素一次并优雅地退出:std::liststd::vector

int main()
{
  std::vector my_vec { 1, 2, 3 };
  auto my_container_view { ContainerView(std::views::all(std::move(my_vec))) };

  for (auto const& c : my_container_view)
    std::cout << c << '\n';
}

当我删除类型的间接并直接遍历时,我也没有收到异常行为:ContainerViewstd::list

int main()
{
  std::list my_list { 1, 2, 3 };

  for (auto const& c : std::move(my_list))
    std::cout << c << '\n';
}

鉴于代码的每个变体都会编译并且不会产生编译时约束错误,那么在使用自定义视图类型并结合使用自定义视图类型时,是否违反了语义约束,从而导致明显的未定义行为?std::movestd::list

C++ STL C++20 移动语义 std-ranges

评论

3赞 273K 11/4/2023
移动范围会使迭代器失效。你得到UB。 - 访问尚未构建的范围。如果你没有收到编译器警告,那就很奇怪了。begin_ { std::begin(range_) }; end_ { std::end(range_) };
0赞 neuroevolutus 11/4/2023
如果我误解了什么,请原谅我,但我认为,鉴于类字段是按其声明的顺序初始化的,因此初始化应该只发生在初始化 和 之后,因此是有效的。编辑:没关系,非常感谢您的评论。我现在明白你的意思了。但是,即使我将代码更改为具有并简单地默认初始化,我似乎也会遇到相同的问题行为。range_begin_end_begin_end_
0赞 neuroevolutus 11/4/2023
@273K,我已根据您指出的问题将代码更新为 default-initialize。begin_end_
0赞 ShadowRanger 11/4/2023
@273K:据推测,他们可以将声明顺序(在类和初始值设定项中)更改为 put 和 之后。初始值设定项确实有定义的顺序,不是吗?begin_end_range_
0赞 273K 11/4/2023
@ShadowRanger 是的,你是对的,更改声明顺序将修复直接成员初始化。静止导致 UB,它将迭代器存储到移动的对象中。这已经是一个自我回答的成功故事。ContainerView(Range r)

答:

2赞 neuroevolutus 11/4/2023 #1

按照 @273K 的建议,确保在成员初始值设定项列表中的 into 移动后,在构造函数主体中初始化 and 可以消除未定义的行为。begin_end_Rangerrange_

#include <concepts>
#include <iostream>
#include <list>
#include <ranges>
#include <vector>

template <std::ranges::input_range Range>
  requires std::ranges::view<Range>
class ContainerView : public std::ranges::view_interface<ContainerView<Range>> {
  private:
  std::ranges::iterator_t<Range> begin_ {};
  std::ranges::sentinel_t<Range> end_ {};
  Range range_ {};

  public:
  constexpr ContainerView() = default;

  constexpr ContainerView(Range r)
      : range_(std::move(r))
  {
    begin_ = std::begin(range_);
    end_ = std::end(range_);
  }

  constexpr auto begin() const { return begin_; }
  constexpr auto end() const { return end_; }
};

int main()
{
  std::list my_list { 1, 2, 3 };
  auto my_container_view { ContainerView(std::views::all(std::move(my_list))) };

  for (auto const& c : my_container_view)
    std::cout << c << '\n';
}
4赞 T.C. 11/5/2023 #2

std::list需要将 sentinel 节点用于其终端迭代器(它需要支持 )。有两种方法可以做到这一点:--

  • 嵌入在列表本身中的哨兵节点。libstdc++ 和 libc++ 可以做到这一点。
  • 动态分配的 Sentinel 节点。MSVC 执行此操作。

在第一种情况下,当您移动列表时,元素的迭代器将成为新列表的迭代器,但结束迭代器仍指向原始列表。因此,在您的代码中,如果 was not 为空,则在移动后指向第一个元素,但仍指向即将销毁的元素。因此,当循环尝试递增直到达到......好吧,它永远不会这样做,你会得到一个无限循环。(哨兵节点的下一个指针指向 很方便,这就是为什么它看起来像是多次迭代列表的原因。rbegin_range_end_rbegin_end_*begin()

这仅在移动列表时才有问题,因为这是对左值的引用,因此在这种情况下,一切都在处理同一个对象。views::alllist

旁注:如果包含的类可以被复制或移动,那么同时存储迭代器(或哨兵)和它指向的范围是非常棘手的。默认的成员复制/移动将不起作用 - 您需要对迭代器执行等效的非传播缓存操作。

评论

0赞 neuroevolutus 11/5/2023
只是为了澄清我的理解,在将 移入 时会出现问题,因为它会隐式创建等价于 a 的东西,而如果没有移动,它会隐式创建 ,对吗?std::liststd::views::allstd::ranges::owning_viewstd::ranges::ref_view