让我们以 Haskell 为灵感——它的核心是懒惰。
另外,让我们记住 C# 中的 Linq 如何以单子(呃 - 这里是单词 - 抱歉)的方式使用枚举器。
最后同样重要的是,让我们记住,协程应该为程序员提供什么。即计算步骤(例如生产者消费者)彼此解耦。
让我们试着想想协程与惰性求值的关系。
以上所有似乎都有某种关联。
接下来,让我们尝试提取我们个人对“懒惰”的定义。
一种解释是:我们希望以可组合的方式声明我们的计算,在执行它之前。我们用来组成完整解决方案的其中一些部分很可能会利用大量(有时是无限的)数据源,而我们的完整计算也会产生有限或无限的结果。
让我们来具体一些代码。我们需要一个例子!在这里,我选择了 fizzbuzz“问题”作为示例,只是因为它有一些不错的、懒惰的解决方案。
在 Haskell 中,它看起来像这样:
module FizzBuzz
( fb
)
where
fb n =
fmap merge fizzBuzzAndNumbers
where
fizz = cycle ["","","fizz"]
buzz = cycle ["","","","","buzz"]
fizzBuzz = zipWith (++) fizz buzz
fizzBuzzAndNumbers = zip [1..n] fizzBuzz
merge (x,s) = if length s == 0 then show x else s
Haskell 函数cycle 通过简单地永远重复有限列表中的值,从有限列表中创建一个无限列表(当然是懒惰的!)。在急切的编程风格中,编写类似的东西会敲响警钟(内存溢出,无限循环!)。但在懒惰的语言中并非如此。诀窍是,惰性列表不会立即计算。也许永远不会。通常只有后续代码需要它。
上面where 块中的第三行创建了另一个懒惰!列表,通过组合无限列表 fizz 和 buzz 通过单个两个元素配方“将来自任一输入列表的字符串元素连接成单个字符串”。同样,如果要立即对此进行评估,我们将不得不等待我们的计算机耗尽资源。
在第 4 行,我们使用无限惰性列表 fizzbuzz 创建有限惰性列表 [1..n] 的成员元组。结果还是偷懒。
即使在我们fb 函数的主体中,也不需要急于求成。整个函数返回一个包含解决方案的列表,它本身又是惰性的。您也可以将fb 50 的结果视为您可以(部分)稍后评估的计算。或者与其他东西结合,导致更大的(惰性)评估。
因此,为了开始使用我们的 C++ 版本的“fizzbuzz”,我们需要考虑如何将部分计算步骤组合成更大的计算位,每个计算都根据需要从之前的步骤中提取数据。
你可以在a gist of mine看到完整的故事。
下面是代码背后的基本思想:
借用 C# 和 Linq,我们“发明”了一个有状态的泛型类型 Enumerator,它拥有
- 部分计算的当前值
- 部分计算的状态(因此我们可以生成后续值)
- 工作函数,它产生下一个状态、下一个值和一个布尔值,它说明是否有更多数据或枚举是否已经结束。
为了能够通过.(点)的强大功能组合Enumerator<T,S>实例,该类还包含从Haskell类型类中借用的函数,例如Functor和Applicative。
枚举器的辅助函数始终采用以下形式:S -> std::tuple<bool,S,T 其中S 是表示状态的泛型类型变量,T 是表示值的泛型类型变量 - 计算步骤的结果。
所有这些已经在Enumerator 类定义的第一行中可见。
template <class T, class S>
class Enumerator
{
public:
typedef typename S State_t;
typedef typename T Value_t;
typedef std::function<
std::tuple<bool, State_t, Value_t>
(const State_t&
)
> Worker_t;
Enumerator(Worker_t worker, State_t s0)
: m_worker(worker)
, m_state(s0)
, m_value{}
{
}
// ...
};
所以,我们只需要创建一个特定的枚举器实例,我们需要创建一个工作函数,拥有初始状态并使用这两个参数创建一个 Enumerator 的实例。
这里有一个例子 - 函数 range(first,last) 创建一个有限范围的值。这对应于 Haskell 世界中的惰性列表。
template <class T>
Enumerator<T, T> range(const T& first, const T& last)
{
auto finiteRange =
[first, last](const T& state)
{
T v = state;
T s1 = (state < last) ? (state + 1) : state;
bool active = state != s1;
return std::make_tuple(active, s1, v);
};
return Enumerator<T,T>(finiteRange, first);
}
我们可以利用这个函数,例如:auto r1 = range(size_t{1},10); - 我们已经为自己创建了一个包含 10 个元素的惰性列表!
现在,我们的“哇”体验缺少的就是看看我们如何组成枚举数。
回到 Haskells cycle 函数,这有点酷。它在我们的 C++ 世界中会是什么样子?这里是:
template <class T, class S>
auto
cycle
( Enumerator<T, S> values
) -> Enumerator<T, S>
{
auto eternally =
[values](const S& state) -> std::tuple<bool, S, T>
{
auto[active, s1, v] = values.step(state);
if (active)
{
return std::make_tuple(active, s1, v);
}
else
{
return std::make_tuple(true, values.state(), v);
}
};
return Enumerator<T, S>(eternally, values.state());
}
它将一个枚举器作为输入并返回一个枚举器。本地 (lambda) 函数 eternally 只需在输入枚举用完值时将其重置为其起始值,瞧——我们有一个无限的、不断重复的列表版本作为参数:: auto foo = cycle(range(size_t{1},3)); 我们可以已经无耻地组成了我们懒惰的“计算”。
zip 是一个很好的例子,表明我们也可以从两个输入枚举器创建一个新的枚举器。生成的枚举器产生的值与输入枚举器中的较小者一样多(具有 2 个元素的元组,每个输入枚举器一个)。我已经在class Enumerator 内部实现了zip。下面是它的样子:
// member function of class Enumerator<S,T>
template <class T1, class S1>
auto
zip
( Enumerator<T1, S1> other
) -> Enumerator<std::tuple<T, T1>, std::tuple<S, S1> >
{
auto worker0 = this->m_worker;
auto worker1 = other.worker();
auto combine =
[worker0,worker1](std::tuple<S, S1> state) ->
std::tuple<bool, std::tuple<S, S1>, std::tuple<T, T1> >
{
auto[s0, s1] = state;
auto[active0, newS0, v0] = worker0(s0);
auto[active1, newS1, v1] = worker1(s1);
return std::make_tuple
( active0 && active1
, std::make_tuple(newS0, newS1)
, std::make_tuple(v0, v1)
);
};
return Enumerator<std::tuple<T, T1>, std::tuple<S, S1> >
( combine
, std::make_tuple(m_state, other.state())
);
}
请注意,“组合”最终也是如何组合两个源的状态和两个源的值的。
因为这篇文章已经是 TL;DR;对于许多人来说,这里...
总结
是的,惰性求值可以在 C++ 中实现。在这里,我借用了 haskell 的函数名和 C# 枚举器和 Linq 的范例。顺便说一句,pythons itertools 可能有相似之处。我认为他们采用了类似的方法。
我的实现(请参阅上面的要点链接)只是一个原型 - 不是生产代码,顺便说一句。所以我这边没有任何保证。不过,它可以很好地用作演示代码,以了解总体思路。
如果没有最终的 C++ 版本的 fizzbuz,这个答案会是什么,嗯?这里是:
std::string fizzbuzz(size_t n)
{
typedef std::vector<std::string> SVec;
// merge (x,s) = if length s == 0 then show x else s
auto merge =
[](const std::tuple<size_t, std::string> & value)
-> std::string
{
auto[x, s] = value;
if (s.length() > 0) return s;
else return std::to_string(x);
};
SVec fizzes{ "","","fizz" };
SVec buzzes{ "","","","","buzz" };
return
range(size_t{ 1 }, n)
.zip
( cycle(iterRange(fizzes.cbegin(), fizzes.cend()))
.zipWith
( std::function(concatStrings)
, cycle(iterRange(buzzes.cbegin(), buzzes.cend()))
)
)
.map<std::string>(merge)
.statefulFold<std::ostringstream&>
(
[](std::ostringstream& oss, const std::string& s)
{
if (0 == oss.tellp())
{
oss << s;
}
else
{
oss << "," << s;
}
}
, std::ostringstream()
)
.str();
}
而且...为了进一步说明问题 - 这里是 fizzbuzz 的一种变体,它向调用者返回一个“无限列表”:
typedef std::vector<std::string> SVec;
static const SVec fizzes{ "","","fizz" };
static const SVec buzzes{ "","","","","buzz" };
auto fizzbuzzInfinite() -> decltype(auto)
{
// merge (x,s) = if length s == 0 then show x else s
auto merge =
[](const std::tuple<size_t, std::string> & value)
-> std::string
{
auto[x, s] = value;
if (s.length() > 0) return s;
else return std::to_string(x);
};
auto result =
range(size_t{ 1 })
.zip
(cycle(iterRange(fizzes.cbegin(), fizzes.cend()))
.zipWith
(std::function(concatStrings)
, cycle(iterRange(buzzes.cbegin(), buzzes.cend()))
)
)
.map<std::string>(merge)
;
return result;
}
值得展示,因为您可以从中学习如何回避该函数的确切返回类型是什么的问题(因为它仅取决于函数的实现,即代码如何组合枚举器)。
它还表明,我们必须将向量 fizzes 和 buzzes 移到函数范围之外,以便它们最终在外部时仍然存在,惰性机制产生值。如果我们没有这样做,iterRange(..) 代码会将迭代器存储到早已不复存在的向量中。