为什么我的程序在使用生成器时会出现可变生命周期问题?

Why does my program have variable lifetime issues when using generators?

如果您不熟悉生成器,问题末尾会提供一些背景知识。

我使用 VS2015 Update 2 及其 <experimental/generator> header 编写了一个简单的生成器,其中一个版本采用迭代器,另一个采用范围并委托给迭代器版本。但是,我的程序没有正确生成我想要的,有时会崩溃。

这是一个崩溃的示例:

#include <experimental/generator>
#include <iostream>
#include <string>

namespace stdx = std::experimental;

template<typename It>
stdx::generator<char> lazy(It first, It last) {
    while (first != last) {
        co_yield *first++;
    }
}

stdx::generator<char> lazy(const std::string &str) {
    return lazy(str.begin(), str.end());
}

int main() {
    for (auto c : lazy("abc")) {
        std::cout << c;
    }
}

Visual Studio 产生以下调试错误:

string iterator not incrementable

但是,如果我为字符串提取一个变量,它工作正常,打印 abc:

int main() {
    std::string abc = "abc";
    for (auto c : lazy(abc)) {
        std::cout << c;
    }
}

如果我改为更改范围版本以按值而不是按引用获取字符串,它仍然会崩溃并出现相同的调试错误:

stdx::generator<char> lazy(std::string str)
…
for (auto c : lazy("abc")) {

这是怎么回事?我该如何编写我的生成器来避免这个问题?

快速生成器说明

不知道什么是生成器的背景知识:

生成器允许您编写一个 return 是一系列值的函数,但是将其编写为 return 一个值给调用者,让调用者有机会使用值,然后在调用者需要下一个值时从中断处继续。

这使得编写一个无限循环成为可能,该循环一次产生一个值,而不是永远 运行。如果调用者向生成器请求十个值,生成器函数将部分执行十次然后停止。

因为值是按需(懒惰地)生成的,所以可以将 return 值视为有关如何获取值的说明,而不是整个值容器。这使得将结果组合成高效的管道成为可能。

C++ 有几个包含生成器的提案。 Microsoft 已在 Visual Studio 2015 年针对使用 /await 选项的项目实施了他们的提案。

从第一个例子开始,回想一下基于范围的 for 循环等价于:

{
    auto && __range = lazy("abc"); 
    for (auto __begin = __range.begin(), __end = __range.end(); __begin != __end; ++__begin) { 
        auto c = *__begin; 
        std::cout << c; 
    } 
} 

常量引用参数

现在考虑 lazy 中的 const std::string &str 引用的临时字符串的生命周期。如果我错了,请纠正我,但我不相信规则允许临时函数与函数参数一样长。更改范围 lazy 的主体以遍历 strco_yield 值不起作用,这暗示缺乏生命周期延长。但是,即使它可以和参数一样长,这仍然行不通。

考虑这里实际发生的情况:

  1. lazy("abc") 被调用。它调用 lazy(str.begin(), str.end()) 为其 return 值。
  2. 因为迭代器 lazy 使用 co_yield,它的主体在其 returned 生成器被迭代之前不会开始执行。
  3. 对范围 lazy 的调用完成。参数str被销毁
  4. __range的声明结束,它的值为生成器return到迭代器lazy生成值。临时字符串被破坏。
  5. 发生迭代,迭代器 lazy 对已销毁的对象使用迭代器。

通过在循环上方声明 abc,被迭代的字符串在迭代期间处于活动状态。因此,该示例适用于此更改。

参数值

接下来,让我们看一下取值str的例子:

  1. lazy("abc") 被调用。创建了一个 std::string 并且 lazy 收到了它自己的那个字符串的副本。
  2. 范围 lazy 调用迭代器 lazy 和 return 生成器。和以前一样,迭代器 lazy 的主体还没有开始执行。
  3. 对范围 lazy 的调用完成。参数str被销毁
  4. __range的声明结束。
  5. 发生迭代,迭代器 lazy 对销毁的 str 参数使用迭代器。

如您所见,按值获取参数不会影响结果。

解决方案

没有迭代器

第一个解决方案是从迭代器继续前进。使用适当的范围实用程序,可以轻松地将范围转换为更小的范围。

stdx::generator<char> lazy(std::string str) {
    for (char c : str) {
        co_yield c;
    }
}

如果调用者希望使用子字符串,他们可以使用任意数量的实用程序。也许有一个生成器函数 substr 懒惰地生成子字符串。更一般地说,take 函数将延迟生成前 N 个值,而 skip 函数将延迟丢弃前 N 个值。

但是,该函数还必须更改为接受 stdx::generator<char> 才能处理这些结果。为了涵盖所有内容,您的生成器可以获取任何它可以迭代的内容,并按值获取它:

template<typename Chars>
stdx::generator<char> lazy(Chars chars) {
    for (auto c : chars) {
        co_yield c;
    }
}

当然,当函数实际对序列中的元素执行某些操作时,这更有用。

优化

但是,如果给定一个移动成本高的对象(例如,一个大的 std::array),这可能会效率低下。在这种情况下,最好通过引用获取对象。我们必须相信调用者认为左值与迭代一样长。但是临时文件是一个很大的问题,所以我们可以禁用它们:

template<typename Chars>
stdx::generator<char> lazy(const Chars& chars) {
    for (auto c : chars) {
        co_yield c;
    }
}

template<typename Chars>
stdx::generator<char> lazy(const Chars&&) = delete;

有关已删除重载的说明,请参阅 this CppCon video

保证参数生命周期

现在如果迭代器很重要并且您必须有这个迭代器版本怎么办?通过简单的更改,范围 lazy 将按计划工作:

stdx::generator<char> lazy(std::string str) {
    for (auto c : lazy(str.begin(), str.end())) {
        co_yield c;
    }
}

通过 使用 co_yield 获取参数,被迭代的参数肯定会在迭代中存在。成功!

我们对非迭代器解决方案所做的相同调整也适用于此。如果调用者在其生命周期内受到信任,则可以对参数进行泛化并通过引用获取左值。

但是,有一个警告。生成器有一些开销。从函数返回然后返回并从它停止的地方继续需要类似于状态机的东西。通过在这里使用 co_yield,我们也将这个函数变成了一个生成器,带来了额外的开销。现在编译器可能足够聪明来优化它。我不能说,编译器肯定会随着时间的推移对这些事情变得更聪明,但这是需要注意的事情。

解决这个问题的一种方法是让两个版本做同样的工作而不依赖另一个。这将需要更多的代码。

卸载问题

也许最简单的解决方案是将生成器作为参数。由于 lazy 在这一点上变得多余,让我们为 char 实现 take:

// Copying a generator is cheap! (Should just be a pointer)
stdx::generator<char> take(stdx::generator<char> chars, int n) {
    auto current = chars.begin();
    for (int i = 0; i < n && current != chars.end(); ++i) {
        co_yield *current++;
    }
}

通过这样做,我们已经接受了早期生成器函数生成的任何内容,并且我们让调用者弄清楚他们想要如何从实际容器转到 stdx::generator。这样做的一种方法就是如上所述的 lazy 函数,按值获取容器并在每个元素上使用 co_yield

这种方法可能是最流行的。在 C# 中,生成器函数使用 IEnumerable<T>,而不是任何容器。尽管不是以 IEnumerable<T> 开头,但它的扩展方法让用户可以 container.GeneratorFunction(),使其无缝衔接。在 Java 8 中,生成器函数采用 Stream<T> 并且容器提供 .stream() 方法作为桥梁。此 lazy 函数等同于 Java 的 stream 方法。

字符串在迭代器之前过期。

临时字符串的生命周期未按范围延长。这与链接范围适配器的问题相同。

为了解决这个问题,我使我的范围适配器要么是基于范围的,要么是使用可选的资源存储器使其成为基于迭代器的。

struct nothing_t{};

template<typename It, class Storage=nothing_t>
stdx::generator<char> lazy(It first, It last, Storage s={}) {
  while (first != last) {
    co_yield *first++;
  }
}

现在你的基于范围的看起来像:

stdx::generator<char> lazy(std::string str) {
  auto store=std::make_unique<std::string>(std::move(str));
  auto b=store->begin(),e=store->end();
  return lazy(b,e,std::move(store));
}

但真正基于远程才是正确的选择。

template<class It, class Storage=nothing_t>
struct range_t{
  It begin() const{return b;};
  It end() const{return e;};
  It b,e;
  Storage s;
};

template<class It>
struct range_t<It,nothing_t>{
  It begin() const{return b;};
  It end() const{return e;};
  It b,e;
  range_t(It s, It f, nothing_t={}):
    b(s),e(f)
  {}
};
template<class It,class Storage=nothing_t>
range_t<It,std::decay_t<Storage>> range(It b, It e,Storage&& s={}){
  return {b,e,std::forward<Storage>(s)};
}
template<class T, sts::size_t N>
range_t<T*,std::array<T,N>> range(T* b, T* e, T(&arr)[N]){
  // todo
}

现在惰性迭代器创建一个范围并转发到一个参数版本。

基本上范围是比迭代器更好的基本单位。

当然,当您拥有 range_t 时,除了类型擦除目的之外,生成器的大部分光彩都会消失。

template<class R>
auto lazy(R r)->stdx::generator<std::decay_t<decltype(*std::begin(r))>>
{
  for(auto&& x:r)
    co_yield decltype(x)(x);
}
template<class It>
auto lazy(It b, It e){
  return lazy(range(b,e));
}

短小精悍,最大限度地减少了类型擦除层。