如无必要,勿增实体。
曾经,iterator
必须支持拷贝。但是很多情况下这个要求甚至算不上锦上添花,而会直接限制
iterator
的使用场景。
p0902r0
把这个窘境总结了一下,提出了
move-only iterator。本文试着以笔者自己的语言去重复这篇提议。希望这样可以更好地理解它的来龙去脉。
一些背景
平时编程的时候,我们常常使用一些容器,比如
std::vector
和
std::map
来跟踪一组数据。尽管这两类容器的访问模式可以很不同,前者支持用下标进行随机的访问,而且用游标可以在里面前后游走,后者的也可以用游标前后移动,访问游标指向的
KV 对,但是它无法用下标访问。std::map
是一种关联型 (associative) 容器,用 key
作为索引才是存取它的正确手段。但是不管它们的访问模式如何,我们发明了
iterator 作为抽象的索引机制,能兼容这两种不同的访问模式。
它大约有下面几类功能
-
作为点查询 (point query) 的返回值,比如说
std::map::find()
的返回值。 当然,iterator 也需要能表示一个无效的返回值,说明找不到符合的元素。 -
用来表示一个区间,对于有序的容器,两个 iterator 中间的那些元素不正好也是这个有序容器的一个部分吗?
-
作为游标能在容器里面移动,访问其他容器的部分。
老马的实时菜单
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。很明显
InputIterator
是
Iterator
的特殊形式,它需要满足后者的所有要求。为了和 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
。
其中,std::incrementable
是之前“裹脚布”的标准定义:
有了这个标准的框架,特别是
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
如果程序员希望使用 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++
导致出错,提高了可维护性。