如无必要,勿增实体。

曾经,iterator 必须支持拷贝。但是很多情况下这个要求甚至算不上锦上添花,而会直接限制 iterator 的使用场景。 p0902r0 把这个窘境总结了一下,提出了 move-only iterator。本文试着以笔者自己的语言去重复这篇提议。希望这样可以更好地理解它的来龙去脉。

一些背景

平时编程的时候,我们常常使用一些容器,比如 std::vectorstd::map 来跟踪一组数据。尽管这两类容器的访问模式可以很不同,前者支持用下标进行随机的访问,而且用游标可以在里面前后游走,后者的也可以用游标前后移动,访问游标指向的 KV 对,但是它无法用下标访问。std::map 是一种关联型 (associative) 容器,用 key 作为索引才是存取它的正确手段。但是不管它们的访问模式如何,我们发明了 iterator 作为抽象的索引机制,能兼容这两种不同的访问模式。

它大约有下面几类功能

  1. 作为点查询 (point query) 的返回值,比如说 std::map::find() 的返回值。 当然,iterator 也需要能表示一个无效的返回值,说明找不到符合的元素。

  2. 用来表示一个区间,对于有序的容器,两个 iterator 中间的那些元素不正好也是这个有序容器的一个部分吗?

  3. 作为游标能在容器里面移动,访问其他容器的部分。

老马的实时菜单

iterator 除了用来访问现成的容器里的数据,我们似乎也能用它来 存取 一些即时生成的数据呢?问题来源于生活,让我们还是从生活出发。老王来到一个新开的饭馆,也许他看错了店招:

不知所措的食客老王在面馆里面质问店里面的伙计:
啊,面馆里面竟然不卖面?那你们都有什么呢?
大义凛然的伙计(其实是掌柜老马):
我们有,普通泡馍,优质泡馍,纯羊肉泡馍,腊牛肉夹馍,羊杂汤。

老马作为店主,显然对小店提供的服务烂熟于心,他结合当前的剩余物资和食客可能的消费水平,对这张实时渲染生成的菜单进行了定制化,如果食客身着“锦衣”,那肯定也能负担“玉食”。这时候,老马的答案可能就是:

眉开眼笑的伙计(其实还是老马):
客官里面请!我们有,正黄旗金枪鱼,苏州龙虾,花毛一体盖浇饭,特优质泡馍,超纯羊肉泡馍,顶级羊杂汤。

为了更便于理解,两人的对话化为程序

future<> tour_in_casa_de_mars(Mars& mars, Wong& wong) {
  auto&& range = mars.menu(wong.appearance());
  auto end = range.end()
  auto begin = std::move(range).begin();
  if (auto dish = wong.pick_in_menu(begin, end); dish) {
    auto meal = co_await wong.put_order(*dish)
    co_await wong.consume(std::move(meal));
  }
  co_return;
}

class Wong {
  // ...
  template<class Iterator, class Sentinel)
  std::optional<Dish> pick_in_menu(Iterator first, Sentinel last) {
    for (; first != last; ++first) {
      if (want_to_try(*first)) {
        return *first;
      }
    }
    return {};
  }
};

其中,mars.menu() 返回的是一份神奇的可定制菜单

class CustomizedMenu {
  std::unique_ptr<Mars> mars;
  unsigned affordable;

public:
  CustomizedMenu(std::unique_ptr<Mars>&& mars, unsigned affordable)
    : mars{std::move(mars)}, affordable{affordable}
  {}
  class sentinel {};
  class iterator {
    std::unique_ptr<Mars> mars;
    unsigned affordable;
    std::optional<Dish> dish;
  public:
    using value_type = Dish;
    using reference_type = Dish&;
    using pointer_type = Dish*;
    using difference_type = std::ptrdiff_t;

    iterator(std::unique_ptr<Mars>&& mars, unsigned affordable)
      : mars{std::move(mars)}, affordable{affordable} {
      dish = mars.dish_with_price_higher_than(affordable / 2);
    }
    iterator(iterator&& rhs) noexcept = default;
    iterator& operator=(iterator const&) = delete;
    iterator& operator++() {
      if (!dish) {
        throw std::out_of_range();
      }
      dish = mars->dish_with_price_higher_than(dish->price);
      return *this;
    }
    friend bool operator==(iterator const& it, sentinel) noexcept {
      return !it.has_more();
    }
    reference_type operator*() const {
      assert(has_more());
      return *dish;
    }
  private:
    bool has_more() const noexcept {
      return dish && dish->price <= affordable;
    }
  };
  iterator begin() && {
    return {std::move(mars), affordable};
  }
  sentinel end() const noexcept {
    return {};
  }
};

这里面 Mars 代表老马的灵感,CustomizedMenu 是由灵感激发得到的菜单。其中, iterator 承担的功能和之前大相径庭:

  • iterator 只能往前走。因为菜单是即兴发挥的成果,老王是没法插话问老马,上面一个是啥,什么盖浇饭?老马回答不出来,但是如果你直接告诉他“花毛一体盖浇饭”,他一定会在你的耐心消失之前把它做出来。

  • iterator 无法复制。老马的灵感稍纵即逝,无法要求他从“苏州龙虾”开始再重复一遍菜谱。

  • iterator 是只读的。虽然老王也充满了创造力,在相熟的菜馆他或许能破例要求把“苏州龙虾”改成 更亲民的“扬州炒饭”,但是在老马这里行不通。

用 C++ 20 的话说,

  • 它是一个 std::input_iterator。即我们可以通过 dereference 它(即 std::indirectly_readable,从 iterator 读取数据。

  • 但是它不是 std::forward_iterator,因为这个 iterator 只能带我们走过一程, 之后就不能再用它了。如果老王的点菜算法需要多次遍历菜单,除非他自带速记功能, 否则的话很难在老马的面馆吃到东西了。所幸老王是个爽快人, Wong::pick_in_menu() 只需要遍历一遍菜单就可以得出结果。我们把这类 iterator 称作 “single-pass” iterator。 这种算法也就是 “single-pass” algorithm 了。

问题在于,在 C++20 之前,我们对这种 single-pass iterator 没有良好的定义,也缺乏支持。那时候的标准库过于粗线条,认为 iterator 必须支持拷贝。所以很可能 Wong::pick_in_menu() 是没办法使用 std::find_if() 来帮助老王选择他要的午饭的。

P1207 和 C++20

在 C++20 采纳的 p1207r4 里对 move-only iterator 做了深入的回顾,它同时主张:只支持 move 的 iterator 也能被划为 InputIterator,而且它进一步指出,很多标准库里面使用 InputIterator 的算法其实是 single-pass 的,它们的实现没有必要拷贝 iterator。很明显 InputIteratorIterator 的特殊形式,它需要满足后者的所有要求。为了和 C++20 的新式 "Iterator" 相区别,我们把之前的 "Iterator" 叫做 "LegacyIterator"。在 C++20 之前,C++ 标准要求它

  • CopyConstructible

  • CopyAssignable

  • Destructible

换成 C++20 concept,就是

template<class I>
concept __LegacyIterator =
  requires(I i) {
    {   *i } -> __Referenceable;
    {  ++i } -> std::same_as<I&>;
    { *i++ } -> __Referenceable;
  } && std::copyable<I>;

p1207r4看来,*i++std::copyable<I> 的要求就是束缚 iterator 发展的裹脚布。但是鉴于相当多的标准库实现是基于 "LegacyIterator" 实现的。它们的实现在不经意之间就使用了 iterator 的拷贝函数,更不用说大量的用户代码了,它们可能也自觉或者不自觉地依赖着 "LegacyIterator" 提供的“裹脚布”实现了自己的功能。所以为了确保新的标准库继续向后兼容, p1207r4 借 Ranges 的东风,仅仅要求新的 ranges 库能加入对应的 concept,类型,以及相应的支持,而不会波及 std 库。如果 std 里面的 single-pass 函数能去掉对 InputIterator 的拷贝调用,那肯定会锦上添花……

为了让那些真正的 multi-pass 算法有章可循、有法可依,C++20 为它们定义了 std::forward_iterator

Diagram

其中,std::incrementable 是之前“裹脚布”的标准定义:

Diagram

有了这个标准的框架,特别是 std::input_iterator 的标准化,我们就可以定义 ranges::input_range 了。虽然 ranges::input_range 只是个 concept。但是在它之上,我们可以定义一系列 views。它们都从底下的 input_range 取出元素,加以处理和判断,然后再生成新的 range。这些 view 都使用 single-pass 算法,自然也只需要 ranges::input_range 了:

  • std::ranges::views::filter

  • std::ranges::views::take_while

  • std::ranges::views::drop_while

  • std::ranges::views::transform

  • std::ranges::views::elements

所以在 p0902r0 之后,LWG 收到了一系列提议,它们都基于 move-only iterator,着眼于改进 ranges 对它的支持。比如 p1862r1p1456r1

如果程序员希望使用 C++20 开发类似的范型算法,也可以使用 ranges::input_range 或者更底层的 std::input_iterator。这样程序一方面能兼容各种 ranges::input_range 或者 std::input_iterator,可扩展性和维护性自然也更好。

刚才老王点菜的函数就可以重构一下,变成:

class Wong {
  // ...
  template<class Dishes>
  std::optional<Dish> pick_in_menu(Dishes&& dishes)
  requires std::ranges::input_range<Dishes> &&
           std::same_as<std::ranges::range_value_t<Dishes>, Dish> {
    auto result = std::ranges::find_if(
        std::move(dishes),
        [this](const Dish& dish) {
          return want_to_try(dish);
        });
    if (result != std::ranges::end(dishes)) {
      return *result;
    } else {
      return {};
    }
  }
};

和原来的版本相比,可能更啰嗦了一些。但是新版本更抽象,可读性更好一些,因为采用了 ranges 的 concept 和函数能对参数的类型进行合法性的检测,所以如果参数类型不符合要求,也能给出更有意义的错误信息。同时,因为避免了手工编写循环,可以避免因为某些类型的 iterator 不支持 i++ 导致出错,提高了可维护性。