1个不稳定版本
0.1.0 | 2024年5月15日 |
---|
#621 in 异步
75KB
842 行
cocoro
crate 展示了与 coroutine_trait
功能保护的 std::ops::Coroutine
不同的Rust协程方法。
协程是一个状态机,可以逐个消耗“输入”值,并且每次“产出”一个值或“返回”一个最终值。协程可以任意多次产出,但只能返回一次。
在这个crate中,核心协程特性看起来像这样
pub trait SuspendedVisitor<Y, R, I, N>
where
N: Coro<Y, R, I>,
{
type Out;
fn on_yield(self, y: Y, next: N) -> Self::Out;
fn on_return(self, r: R) -> Self::Out;
}
pub trait Suspended<Y, R, I> {
type Next: Coro<Y, R, I>;
fn visit<X>(
self,
visitor: impl SuspendedVisitor<Y, R, I, Self::Next, Out = X>,
) -> X;
}
pub trait Coro<Y, R, I = ()>: Sized {
type Next: Coro<Y, R, I>;
type Suspend: Suspended<Y, R, I, Next = Self::Next>;
fn resume(self, input: I) -> Self::Suspend;
}
注意以下与 std::ops::Coroutine
的区别
resume
方法以值的形式接受self
,而不是以固定独占引用的形式。- "产出"和"返回"的类型是泛型参数,而不是关联类型。
resume
方法返回一个包装协程状态的Suspend
类型,该类型为“访问者”提供一个可以再次恢复的协程句柄。
Suspended
特性可以被视为枚举的抽象。实际上,许多协程实现将使用crate提供的以下枚举
pub enum Suspend<Y, R, N> {
Yield(Y, N),
Return(R),
}
Yield
和 Return
变体被导入到crate的根命名空间中,因此它们可以在不使用 Suspend::
前缀的情况下使用。
此外,Coro
特性提供了一些默认的组合器,这些组合器对于使用 Iterator
的人来说应该很熟悉,例如
map_yield
使用FnMut
转换产出的值map_return
使用FnOnce
转换返回值flatten
将返回另一个协程的协程简化为单个协程
此crate不尝试使用花哨的宏或代码转换,让您能够像编写过程函数一样编写协程,就像coroutine_trait
功能所做的那样。相反,它设计为函数式风格,其中生成的元素和返回的元素通过组合子管道进行转换,强调类型安全。
示例
一个基本的计数器
这是一个产生连续整数且永不返回的协程
use cocoro::{Coro, Suspended, Yielded};
struct Counter(i32);
impl Coro<i32, (), ()> for Counter {
type Next = Self;
type Suspend = Yielded<i32, Self>;
fn resume(self, _: ()) -> Self::Suspend {
Yielded(self.0, Counter(self.0 + 1))
}
}
let mut counter = Counter(0);
for _ in 0..10 {
let Yielded(n, next) = counter.resume(());
println!("{}", n);
counter = next;
}
请注意,Counter
结构是不可变的,并且通过构造一个新的Counter
实例从resume
方法返回下一个状态。
因为Next
关联类型是Self
,所以我们能够就地修改counter
变量。然而,在下一个示例中,我们将看到一个产生具有与其自身不同类型的状态的协程。
此外,我们可以无可争议地匹配Yielded
结构,因为协程的Suspend
关联类型在编译时是已知的,并且对用户是透明的。更普遍地说,当一个协程的Suspend
关联类型在编译时未知(例如,因为它没有由函数参数上的界限或由协程来源的函数的返回类型上的impl
返回类型约束)时,Suspended
特质提供了一个visit
方法,该方法可以转换为Suspend
枚举以进行模式匹配,或直接使用SuspendedVisitor
进行访问。
更常见的是使用辅助函数和组合子来创建协程,而不是直接实现Coro
特质。
使用yield_with
函数,我们可以使用闭包完成与上面相同的事情
use cocoro::{yield_with, Coro, Void, Yield};
let mut i = 0;
let _: Option<Void> = yield_with(|()| {
i += 1;
i
})
.take(10)
.for_each(|n| {
println!("{}", n);
});
静态大小的倒计时
use cocoro::{Coro, Returned, Suspend, Suspended, Void, Yielded};
struct Three;
struct Two;
struct One;
#[derive(Debug, PartialEq, Eq)]
struct Blastoff;
impl Coro<i32, Blastoff, ()> for Three {
type Next = Two;
type Suspend = Yielded<i32, Self::Next>;
fn resume(self, _: ()) -> Self::Suspend {
Yielded(3, Two)
}
}
impl Coro<i32, Blastoff, ()> for Two {
type Next = One;
type Suspend = Yielded<i32, Self::Next>;
fn resume(self, _: ()) -> Self::Suspend {
Yielded(2, One)
}
}
impl Coro<i32, Blastoff, ()> for One {
type Next = Blastoff;
type Suspend = Yielded<i32, Self::Next>;
fn resume(self, _: ()) -> Self::Suspend {
Yielded(1, Blastoff)
}
}
impl Coro<i32, Blastoff, ()> for Blastoff {
type Next = Void;
type Suspend = Returned<Blastoff>;
fn resume(self, _: ()) -> Self::Suspend {
Returned(Blastoff)
}
}
let countdown = Three;
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Returned(blastoff) = countdown.resume(());
println!("{:?}!", blastoff);
这展示了如何使用Next
关联类型将不同类型的协程链接在一起,只要它们都具有相同的输入和输出类型(Y
、R
和I
)。
Suspended
上的as_yield
和as_return
辅助方法提供了方便地获取产生值或返回值Option
注意的另一件事:可以使用Void
协程作为Next
类型来静态地表示协程不会再次产生。因为Void
类型是不可实例化的,所以不可能从其Next
类型为Void
的协程中产生。
可以使用闭包编写相同的示例
use cocoro::{from_fn, Coro, Returned, Suspended, Void, Yielded};
#[derive(Debug, PartialEq, Eq)]
struct Blastoff;
#[rustfmt::skip]
let countdown = from_fn(|_| {
Yielded(3, from_fn(|_| {
Yielded(2, from_fn(|_| {
Yielded(1, from_fn(|_| {
Returned(Blastoff) })) })) }))
});
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Returned(blastoff) = countdown.resume(());
println!("{:?}!", blastoff);
这更加紧凑,但按照rustfmt想要的格式化方式看起来可能非常难以接近。尽管如此,这个例子表明您可以使用普通闭包定义具有静态、类型安全状态机的协程,并证明在编译时通过无可争议的模式匹配保留了这个状态信息。
通常情况下,您会在from_fn
协程中使用枚举Suspend
,而不是Yielded
或Returned
结构体,特别是在协程使用运行时信息来决定是产生还是返回时。
理论
这个crate的主要动机是展示Rust类型系统如何提供编译时构建的正确性保证,即标准库协程或其他状态机(如Iterator
和Future
)通过合同暗示但无法在编译时强制执行:它们在返回后永远不会再次产生。大多数设计选择都源于此,包括最终强调函数式组合子,这对于添加从过程gen
块中无法获得的缺失表达能力非常有帮助。gen
块是Pin
的原因,如果在cocoro
协程中使用,将强制执行一个resume
方法来可变借用(固定)引用,而不是通过值接受self
,这是“可能消费”协程的唯一方法,在产生时将其传回调用者,在返回时丢弃它。
因为cocoro
协程是用组合子和手动impl实现的,而不是用gen
块或变换代码的宏(如宏)这样的语法糖,所以该特性被设计成可以与其他特性互操作,并使用函数式编程模式,使它们与过程式对应的表达式一样丰富。
cocoro
协程是对产生类型和返回类型的函子。组合子map_yield
和map_return
对应于将协程作为函子进行理论上的map
操作。
协程也是输入类型的反变函子,因此您可以使用一个函数contramap_input
进行contramap_input
,该函数接受不同的输入类型并返回原始输入类型,并获得一个使用contramap
函数的输入类型作为其输入类型的新的协程。
组合子flatten
对应于返回类型上的单子上的join
操作。有了它和map_return
,可以实现组合子flat_map
,对应于单子上的bind
操作。
为了完成单子公理,使用return
操作与JustReturn
包装结构体实现,这是一个可以接受任何作为输入的协程,永远不会产生,并始终返回它所构建的值的协程。这两个结构体和flat_map
组合子共同遵守返回类型上的函子的单子定律。
在生成的类型上没有针对函子实现的单子,但可以将 just_yield
结构体视为对生成的类型上的 应用函子 的 pure
操作。同时,zip()
组合子是一个应用函子 lifta2
函数可以派生的操作。
将协程的操作形式化为成熟的函数式编程概念,是表明 cocoro
协程在理论上合理,并且可以用功能程序员熟悉的各种方式进行使用的一种方式。这包括熟悉具有类似特性的其他类型,如 Iterator
和 Result
的 Rust 程序员。
常见问题解答
为什么叫 cocoro
呢?
我更多的是想到一个双关语:“coro”是“coroutine”的缩写,而“co”作为前缀表示“一起”或“与”,以表明其互补性和可能对 Rust 标准库协程的从属关系。作为双关语的另一层,"cocoro" 听起来像“kokoro”,这是日语中“心”的意思,附带所有与思维、精神和核心相关的联想。
我还隐约地指向了“co-”作为数学双关语前缀的想法,特别是在范畴论的情况下。尽管协程在严格意义上不是常规的范畴对偶,但人们可以构想“co-routine”作为“routine”的对偶,因此“co-coroutine”是某种……常规的东西。
但这个名字恰好可以在 crates.io 上使用。
为什么 resume()
方法消耗 self
?
在 std::ops::Coroutine
特性中,resume
方法以 Pin<&mut Self>
作为接收器类型。这样做是为了能够编写 gen
块,其中语言会从过程代码中合成状态机。这些状态机可能包括指向块内其他局部变量的局部变量,使得在创建了引用之后,类型是自我引用的不可移动的。为了强制执行这一点,生成器需要一个 Pin<&mut Self>
来确保生成器在内存中“固定”,并且不能被移动。对于更知名且更稳定的 Future
特性也是如此,可以使用 async
块构建自我引用的实现。
cocoro
库不需要支持自我引用的类型,因为它不试图描述 gen
块的状态机。相反,协程是手工编写的或由非挂起组合子和闭包组成的。
好的,但为什么不用 &mut self
作为 resume()
的接收器呢?
这是一个实验,尝试在类型系统中表达在类型合同(如 Iterator
、Future
,以及是的,std::ops::Coroutine
)中隐含的东西。合同规定,一旦你到达迭代器/未来/coroutine的“末尾”(即它返回 None
/Ready
/Complete
),就不能再次调用 next()
/poll()
/resume()
。一些实用函数,如 Iterator::fuse()
,试图添加运行时安全层,以更具体地定义违反合同时会发生什么。
但是 cocoro
选择了一种不同的方法:在编译时强制执行此合同。方法 resume()
消耗 self
并返回一个只能通过 Yield
获取协程的 Suspended
枚举。这使得在 Return
之后无法再次调用 resume()
,因为 Return
变体不包含要恢复的协程,并且调用 resume()
已消耗了协程。
与 std::ops::Coroutine
相比,性能如何?
cocoro
协程被设计得尽可能轻量。但 std::ops::Coroutine
协程也是如此,而且是由那些在编写高性能 Rust 代码方面更加投入和经验丰富的人所写。
cocoro
协程是“无栈”的,并且避免分配,就像 std::ops::Coroutine
协程一样。cocoro
协程的一个潜在好处是,标准库协程被转换为可能表示为 Rust 枚举的状态机(见不稳定手册),这意味着状态机的每个迭代都可能包含一个分支。另一方面,cocoro
协程支持静态上已知将确定性推进到特定状态的协程,正如在 just_yield()
和 just_return()
函数中看到的那样。这样的协程消除了这些点的分支,这可能导致代码更快。
然而,当 cocoro
协程恢复和暂停时,它们会在其状态表示的数据周围移动,这与必须固定在内存中的标准库协程相反。这意味着在 cocoro
协程生成的代码中可能有复制操作,而在标准库协程生成的代码中没有。我通常预计编译器会优化这些复制操作,但我还没有进行任何基准测试来确认这一点。
许多基本的 cocoro
组合子是尾递归的,可以被编译器优化为循环,但Rust并不保证在一般情况下进行尾调用优化。
为什么返回一个关联类型而不是一个 Suspend
枚举呢?
这是允许 cocoro
协程选择静态确定性状态机的秘密配方。协程的 Next
关联类型是协程的下一个状态类型,如果每个协程都必须返回一个枚举,编译器将不得不在匹配结果时在该枚举的标记上插入一个检查。
特别是,这允许 just_yield()
协程返回一个 Yielded
结构体而不是 Suspend
枚举的 Yield
变体,并且 just_return()
协程返回一个 Returned
结构体而不是 Suspend
枚举的 Return
变体。这三种类型 Suspend
、Yielded
和 Return
都实现了 Suspended
特性,所以它们都是根据运行时是否已知的分支返回协程的有效选项。
为什么产生和返回类型是泛型类型参数?
在Rust中,一个特质的“输入”类型是泛型类型,“输出”类型是关联类型,如 FnOnce
特质所示。这种特质可以泛型化输入类型,即对任意许多可能的输入类型实现特质是重要的。这就是允许 Fn
系列特质与引用类型一起工作,这些实际上是泛型化省略了生命周期参数的高阶类型。
特质 Coro
不仅泛型化输入类型,还泛型化产生和返回类型。这是为了允许实现 Coro
的类型为任意许多输入、产生和返回类型实现特质。例如,忽略其输入的协程可以泛型化所有 I
输入类型,永不产生的协程可以泛型化所有 Y
产生类型,永不返回的协程可以泛型化所有 R
返回类型。
当您使用 just_yield()
创建协程时,可以将该协程注入任何接受任何返回类型协程的函数,或从使用 impl Coro
返回类型语法的函数返回它。
这意味着在某些地方需要提供类型注解,如果没有 Yield
和 Return
关联类型,则不需要。您会在示例中经常看到 .yields::<Void>()
和 .returns::<Void>()
,以提供单个函数体内代码段所需的类型注解。但在实际代码中,您很可能会通过具有 impl Coro
边界(指定预期的产生、返回和输入类型)的函数返回或消耗协程,这些边界引导类型检查器从无限多种实现中选择一个特定的 Coro
特性实现。
依赖关系
约45KB