3个版本 (破坏性更新)
0.3.0 | 2020年1月31日 |
---|---|
0.2.0 | 2020年1月4日 |
0.1.0 | 2020年1月2日 |
#1169 in Rust模式
57KB
1K SLoC
haskell_bits
Haskell的各种类型类和概念在Rust中的实现。 (crates.io链接)
概述
目前这个库是Rust中Functor/Applicative/Monad层次的实现。它是一个非常正在进行中的项目,我现在发布它主要是作为一个征求意见,我希望能与大家讨论我的设计选择,在走得太远之前。
在这个领域已经有一些工作,例如
那么,这个库有什么不同之处呢?!
我认为这个库独特之处在于可以在Monads,Applicatives,Functors等上定义泛型函数。
上面提到的库似乎为各种类型定义了Functor,Applicative,Monad,所以fmap
,bind
等在这些类型上工作,但似乎没有一种方法可以编写一个适用于所有类型定义的泛型函数,例如Monad。
我认为这是一个非常重要的问题。这些类型类如Haskell中的Monad的伟大之处在于,如果你有m
个不同的Monads和n
个定义在Monads上的函数,你现在就有了实质上m*n
个函数定义。创建一个新的Monad,你就得到了一个巨大的基础设施。创建一个新的Monad函数,它就可以在由其他人已经定义的100多个Monadic类型上工作。
这是Monads和相关概念,以及更广泛的Haskell,之所以如此强大的关键之一,因为你可以以乘法数量的方式组合事物,如此之多,以至于许多问题只是将现有的代码块组合在一起,类型系统确保你可以安全地这样做,而且几乎总是正确。这在生产力方面是一个巨大的优势,特别是在可靠性方面。
示例
这是一个简单的Haskell函数
monadic_pair :: Monad m => m a -> m a -> m (a, a)
monadic_pair x y = do
x_val <- x
y_val <- y
pure (x_val, y_val)
这是在Rust中使用这个库的等效函数
fn monadic_pair<TCon, T, TArg>(x: &TArg, y: &TArg) -> <TCon as WithTypeArg<(T, T)>>::Type
where
TCon: Monad + WithTypeArg<T> + WithTypeArg<(T, T)>,
TArg: TypeApp<TCon, T>,
T: Clone,
{
mdo! {
x_val =<< x;
y_val =<< y;
ret<TCon> (x_val.clone(), y_val.clone());
}
}
然后我们可以将其应用于向量
let v1: Vec<u32> = vec![1, 2, 3];
let v2: Vec<u32> = vec![4, 5, 6];
let v_result = monadic_pair(&v1, &v2);
assert_eq!(
v_result,
vec![
(1, 4),
(1, 5),
(1, 6),
(2, 4),
(2, 5),
(2, 6),
(3, 4),
(3, 5),
(3, 6)
]
);
或选项
let o1: Option<u32> = Some(1);
let o2: Option<u32> = Some(2);
let o3: Option<u32> = None;
let o1_result = monadic_pair(&o1, &o2);
let o2_result = monadic_pair(&o1, &o3);
assert_eq!(o1_result, Some((1, 2)));
assert_eq!(o2_result, None);
所有结果都是预期的。
我们甚至可以进行标准的Applicative风格函数应用
let _applicative_option_result = (|x| move |y| x + y).lmap(o1).lap(o2);
assert_eq!(Some(3), _applicative_option_result);
但我会稍后提到一些注意事项。
它是如何工作的呢?
Rust 没有高阶类型,至少我们没有可以抽象的类型。我的意思是,虽然 Rust 允许使用一些泛型 T
的 Vec<T>
,但 Rust 不允许 T<u32>
。它允许 Vec<u32>
,但我们不能像 u32
一样对 Vec
进行抽象。
这带来了一些特定的后果。比如说,我们想在 Rust 中编写一个 trait,封装对结构体所有部分应用函数的想法。我们可能会尝试这样做:
trait Mappable {
map<TIn, TOut>(f: impl Func<TIn, TOut>, x: Self<TIn>) -> Self<TOut>;
}
impl Mappable for Vec {
...
}
impl Mappable for Option {
...
}
// etc...
一个小问题
但是我们不能这样做。Rust 会拒绝编译这段代码,因为 Vec
、Option
(以及隐式地 Self
)不是类型。虽然 Option<u32>
是,但 Option
不是。
所以我们需要做一些技巧
pub trait WithTypeArg<T>
{
type Type;
}
然后
pub struct VecTypeCon;
impl<T> WithTypeArg<T> for VecTypeCon {
type Type = Vec<T>;
}
我们可以这样定义 Mappable
pub trait Mappable {
fn map<TIn, TOut>(
f: impl Fn(TIn) -> TOut,
x: <Self as WithTypeArg<TIn>>::Type,
) -> <Self as WithTypeArg<TOut>>::Type
}
这是为 Option
提供的一个简单实现
impl Mappable for OptionTypeCon {
fn lmap<TIn, TOut>(
f: impl Fn(TIn) -> TOut,
x: <OptionTypeCon as WithTypeArg<TIn>>::Type,
) -> <OptionTypeCon as WithTypeArg<TOut>>::Type {
Option::map(x, f) // Option itself already has a specific "map" function
}
}
以及为 Vec
提供的实现
impl Mappable for VecTypeCon {
fn map<TIn, TOut>(
f: impl Fn(TIn) -> TOut,
x: <VecTypeCon as WithTypeArg<TIn>>::Type,
) -> <VecTypeCon as WithTypeArg<TOut>>::Type {
let size = x.capacity();
let mut v: Vec<TOut> = Vec::with_capacity(size);
for e in x {
v.push(f(e));
}
v
}
}
到目前为止,这已经很好了,但有一个小问题,我们解决了。
注意,这个库的主要动机是能够以泛化的方式组合这些函数。
好,让我们做一个愚蠢的函数,它只是连续进行两次映射(这个函数很愚蠢,因为它可以通过合并函数来更有效地执行,但我们还是这么做吧)
fn map2<TCon, TIn, TMid, TOut>(
f: impl Fn(TIn) -> TMid,
g: impl Fn(TMid) -> TOut,
x: <TCon as WithTypeArg<TIn>>::Type,
) -> <TCon as WithTypeArg<TOut>>::Type
{
map(g, map(f, x))
}
不幸的是,这个函数不起作用,尽管它看起来类型是正确的。我认为这是因为 Rust 除非类型完全解析,否则不会对类型进行匹配,而 <TCon as WithTypeArg<TIn>>::Type
不能解析,因为我们不知道 TCon
或 TIn
是什么。因此,我们不能将其传递给 map
,即使它需要一个 <TCon as WithTypeArg<TIn>>::Type
(完全相同的东西)作为参数,因为我认为 Rust 想在检查它们是否相等之前解析它们。
所以我们调整了 WithTypeArg
的定义
pub trait WithTypeArg<T> {
type Type: TypeApp<Self, T>;
}
然后编写这个新的 trait TypeApp
pub trait TypeApp<TCon, T>
where
TCon: WithTypeArg<T>,
Self: Is<Type = <TCon as WithTypeArg<T>>::Type>,
{
}
是的,注意这些 trait 互相引用。重要的是 Is
trait。这需要一些思考,我建议查看我的 is_type
库的源代码。但基本上,这允许一个人在 trait 和实际类型之间强制建立一对一的对应关系。
所以你可以随心所欲地在它们之间来回转换。
然后我们可以按照以下方式重新定义 map
,替换掉任何 TCon
的参数(但不是返回类型)
<作为作为 具有类型参数<T>>::类型
with
实现 类型应用<TCon, T>
pub trait Mappable {
fn map<TIn, TOut>(
f: impl Fn(TIn) -> TOut,
x: impl TypeApp<TCon, TIn>,
) -> <Self as WithTypeArg<TOut>>::Type
}
和类似地更改 map2
fn map2<TCon, TIn, TMid, TOut>(
f: impl Fn(TIn) -> TMid,
g: impl Fn(TMid) -> TOut,
x: impl TypeApp<TCon, TIn>,
) -> <TCon as WithTypeArg<TOut>>::Type
{
map(g, map(f, x))
}
我们就成功了。
似乎由于某种原因没有将 <TCon as WithTypeArg<T>>::Type
传递给类型为 <TCon as WithTypeArg<T>>::Type
的参数,但它会将 <TCon as WithTypeArg<T>>::Type
传递给 TypeApp<TCon, T>
的特质参数。
请注意,在实现中,这意味着我们必须将所有 x
的出现替换为 x.into_val()
,其中 into_val
是特质 Is
的一部分(在 TypeApp
的 where 子句中)。
基本上,这是这种方法的核心。我们只是对 Applicative 和 Monad 做类似的事情,但下面我会详细介绍遇到的一些细节和问题。
上面的类称为 Mappable
,而实际上我在上面详细说明的它是库中称为 LinearFunctor
的,我现在也会谈谈这个。
库的结构。
目前,库中有 7 个主要特质
Functor
Lift
Applicative
Monad
LinearFunctor
LinearApplicative
LinearMonad
Functor、Applicative 和 Monad基本上是从 Haskell 复制的,除了 Applicative
的 pure
函数被拆分为特质 Lift
。所以 Lift
和 Applicative
扩展 Functor
,而 Monad
扩展了 Lift
和 Applicative
。
LinearFunctor/
LinearApplicative/
LinearMonad
是 Functor/
Applicative/
Monad
的 "按值" 版本。这些类型类消耗它们的参数。这可以是一个效率提升,因为它们不需要复制它们的参数,这也意味着它们可以定义在不是 Cloneable
的类型上。
"正常" 类型类(即通过引用处理数据)的标准前缀是 f
(在 Functor 中)或没有前缀(否则),"按值" 版本(即 "线性")以 l
为前缀。例如,fmap
是用于 Functor 的,而 lmap
是用于 LinearFunctor 的。
注意,在 Linear
类中,LinearFunctor
稍微特别一些。Functor
扩展 LinearFunctor
,也就是说,每个 Functor
都是 LinearFunctor
(但反之亦然)。这是因为如果定义了 Functor
,就可以通过使用引用来定义 LinearFunctor
。
《LinearApplicative》和《LinearMonad》都接收函数参数作为《FnOnce》类型的参数。这在《LinearApplicative》的情况下尤其明显,以便进行链式调用。运行产生的闭包两次可能需要在某个时刻显式调用 .clone()
。这意味着《LinearApplicative》和《LinearMonad》可以定义在更少类型的集合中,通常是不“相乘”的类型。例如,《Vec》是《LinearFunctor》类型,但不是《LinearApplicative》或《LinearMonad》类型,而《Option》则是所有这些。
此外,所有特性函数都有普通顶级函数来调用它们,以及经常有其他特性函数来调用它们,尽管这些特性函数只是为了允许使用 .
语法。我现在将深入探讨这些选择的理由。
技术细节(尤其是针对特性的实现者)
以下以《LinearFunctor》特性为例。您之前已经见过类似的代码,但这是实际的代码
// Implement this trait for LinearFunctor
pub trait LinearFunctor {
fn lmap<TIn, TOut>(
f: impl Fn(TIn) -> TOut,
x: <Self as WithTypeArg<TIn>>::Type,
) -> <Self as WithTypeArg<TOut>>::Type
where
Self: WithTypeArg<TIn> + WithTypeArg<TOut>;
}
// Call this for lmap(f, x) syntax
pub fn lmap<TCon, TIn, TOut>(
f: impl Fn(TIn) -> TOut,
x: impl TypeApp<TCon, TIn>,
) -> <TCon as WithTypeArg<TOut>>::Type
where
TCon: LinearFunctor + WithTypeArg<TIn> + WithTypeArg<TOut> + ?Sized,
{
<TCon as LinearFunctor>::lmap(f, x.into_val())
}
// This is for x.lmapop(f) syntax
pub trait LMapExt {
fn lmap<TCon, TIn, TOut>(self, x: impl TypeApp<TCon, TIn> + Sized) -> <TCon as WithTypeArg<TOut>>::Type
where
Self: Fn(TIn) -> TOut + Sized,
TCon: LinearFunctor + WithTypeArg<TIn> + WithTypeArg<TOut>
{
lmap(self, x)
}
}
impl<T> LMapExt for T {}
这里有三个函数,一个只是定义,第二个是实现,它调用第一个,第三个调用第二个。
第一个是您实际要实现的。请注意,这里的参数是实际类型。这里没有 x: impl TypeApp<TCon, TIn>
。这意味着,如上所述,这个函数在类型推断方面表现不佳。
因此,这里的第二个函数,顶级函数,接受 TypeApp
特性作为参数,这意味着它与类型推断更好地协同工作。但在这里,我们需要担心从特性类型转换到“真实”类型,因此我们调用来自 is_type
库的 into_val()
来完成此操作。
第三个函数只是一个特性,因此如果我们想使用 .
语法,我们可以使用它。当我们要链式调用操作符时,这非常有用,例如 f.lmap(x).lap(y)
。
请注意,只有在《Functor》的情况下,函数 map
才能与值和引用参数一起工作,并根据参数是值还是引用调用 fmap
或 lmap
。
Do 语法
宏 !mdo
允许以“do-notation”形式编写。这段代码在很大程度上是从 rust-mdo (有轻微修改)中盗用的。
Do 语法目前仅与 Monad 函数的引用版本定义,所以您会看到许多地方有 &
和 Clone::clone(...)
。有时,使用 Clone::clone
比使用 .clone()
更适合类型推断。我相信这是因为后者对值和引用都有效。
问题
从这种做法中会得到一些小问题,使得某些情况下与等效的Haskell代码相比,事情变得稍微混乱一些。我将在稍后的部分讨论可以添加到Rust中的特性,以使事情变得更加美好。
到处都是WithTypeArg
约束
当编写泛型函数,这些函数是针对Functors、Applicatives或Monads时,必须在各个地方使用约束WithTypeArg
。确实,上面的函数map2
实际上不起作用,需要这样编写:
fn lmap2<TIn, TMid, TOut, TCon>(
f: impl Fn(TIn) -> TMid,
g: impl Fn(TMid) -> TOut,
x: impl TypeApp<TCon, TIn>,
) -> <TCon as WithTypeArg<TOut>>::Type
where
TCon: LinearFunctor + WithTypeArg<TIn> + WithTypeArg<TMid> + WithTypeArg<TOut>
{
lmap(g, lmap(f, x))
}
注意所有的约束WithTypeArg<TIn> + WithTypeArg<TMid> + WithTypeArg<TOut>
。必须为函数中存在的每个类型参数应用都添加此约束。例如,在上面的lmap2
中,即使它既不是输入也不是输出参数,仅仅因为它是最内层map调用的结果,我们也需要WithTypeArg<TMid>
。
闭包类型不能命名
在稳定的Rust中,闭包类型不能命名,而且(据我所知)也不能定义实现Fn
的类型(我认为这需要扩展)。这在尝试定义某些函数时会出现。例如,考虑Applicative
的定义:
pub trait Applicative: Functor + Lift {
fn ap<TIn, TOut, TFunc>(
f: &<Self as WithTypeArg<TFunc>>::Type,
x: &<Self as WithTypeArg<TIn>>::Type,
) -> <Self as WithTypeArg<TOut>>::Type
where
Self: WithTypeArg<TFunc> + WithTypeArg<TIn> + WithTypeArg<TOut>,
TFunc: Fn(&TIn) -> TOut,
{
<Self as Applicative>::lift2(|y1: &TFunc, y2: &TIn| y1(y2), f, x)
}
fn lift2<TIn1, TIn2, TOut, TFunc>(
f: TFunc,
x1: &<Self as WithTypeArg<TIn1>>::Type,
x2: &<Self as WithTypeArg<TIn2>>::Type,
) -> <Self as WithTypeArg<TOut>>::Type
where
Self: WithTypeArg<TIn1> + WithTypeArg<TIn2> + WithTypeArg<TOut>,
TFunc: Fn(&TIn1, &TIn2) -> TOut;
}
我们用lift2
来定义ap
,但最好也用另一种方式定义它,这样实现者可以选择实现哪一种。简而言之,以下身份如下所示:
lift2(f, x, y) = f.fmap(x).ap(y)
但在Rust中,没有自动柯里化,我们需要像这样的东西:
lift2(f, x, y) = (|x| |y| f(x,y)).fmap(x).ap(y)
所以在这种情况下,fmap
的输出是一个函数Applicative。最终ap
应用,这变成只是值,但中间步骤是一个函数。
但这个函数没有类型我们可以命名。如上所述,Rust希望为我们的函数的每个参数都添加一个WithTypeArg
约束,但我们不能为这个函数编写类型,因为它没有类型名。
这个问题的一个实际问题是,你无法在函数内部创建比如M
一些类型推断的麻烦
注意实际上定义了两个lift()
函数(lift
只是pure
,但pure
曾经是一个关键字,所以我避免了它)。
// lift(x)
pub fn lift<TCon, T>(x: T) -> <TCon as WithTypeArg<T>>::Type
where
<TCon as WithTypeArg<T>>::Type : TypeApp<TCon, T>,
TCon: Lift + WithTypeArg<T>,
{
<TCon as Lift>::lift::<T>(x)
}
// lift_c(x)
pub fn lift_c<TCon, T, U>(x: U::Param) -> U
where
T : Is<Type = U::Param>,
U : TypeApp<TCon, T>,
<TCon as WithTypeArg<T>>::Type : TypeApp<TCon, T>,
TCon: Lift + WithTypeArg<T>,
{
Is::from_val(lift::<TCon, T>(Is::from_val(x)))
}
它们都做同样的事情,第二个实际上只是调用了第一个,但它们对类型推断的影响是不同的。
第一个不允许推断反向进行。例如,如果你这样做:
x: Option<u32> = lift(5);
编译器将无法解决这个问题,因为5
可以是许多不同的数值类型。
但如果你这样做:
x: Option<u32> = lift_c(5);
它将能够从结果类型推断出5
是u32
。
问题是lift_c
在泛型函数中表现不佳,比如所有Monads的泛型函数。
所以把lift_c
看作是“具体提升”,意思是我们有一个具体类型。
与bind
和bind_c
有类似的划分。
因此,也存在两个宏,即 !mdo
和 !mdo_c
,其中 !mdo
应用于泛型代码,而 !mdo_c
可用于已知具体类型时,因为在这种情况下可能进行更好的推理。
以下是一个例子
let o1: Option<u32> = lift_c(5);
let o2: Option<u32> = lift_c(7);
let _do_result: Option<(u32, u32)> = mdo_c! {
x =<< &o1;
y =<< &o2;
ret (Clone::clone(x), Clone::clone(y));
};
注意,在这里,对于宏返回值的类型构造器不需要明确指定。
但在 monadic_pair
fn monadic_pair<TCon, T, TArg>(x: &TArg, y: &TArg) -> <TCon as WithTypeArg<(T, T)>>::Type
where
TCon: Monad + WithTypeArg<T> + WithTypeArg<(T, T)>,
TArg: TypeApp<TCon, T>,
T: Clone,
{
mdo! {
x_val =<< x;
y_val =<< y;
ret<TCon> (x_val.clone(), y_val.clone());
}
}
中,我们必须显式指定 do 块的结果的类型构造器,即 TCon
。不幸的是,Rust 无法推断这一点。
可添加到 Rust 中以使事情变得更好的特性
总的来说,尽管在某些地方看起来有点混乱,但对于客户端代码来说,还不是太糟糕。泛型函数在 monad 上定义起来有点混乱,但如果您主要使用这些函数对特定的具体 monad 进行操作,代码实际上相当简洁。但这里有一些可以帮到的事情
高阶类型(HKT)
实现高阶类型及其语法将是理想的,因为我们就不必通过将“黑客技术”放入特质来实现这一点,并且如果这样做,类型推断也可能更好。但如果没有这样做,还有一些特性会有很大的帮助
在 where 子句中使用 Forall
如果我们能定义如下
trait TypeCon<TCon> where TCon : forall U. WithTypeArg<U>
这将解决 WithTypeArg
约束无处不在 的问题。它也会解决 闭包类型无法命名 的问题,因为不再需要显式命名此类临时类型。
泛型关联类型(GAT)
这是一个正在开发中的特性,可能有助于类似“forall”选项,但该特性的特定部分尚未在 nightly 中实现,也没有特定的时间表,根据 这个问题的回复。
未来的工作
有一些明显的事情要做,首先是实现除了 Option
和 Vec
以及 Result
之外的其他类型的 Functor/Applicative/Monad,Result
是明显的下一个选择,以及标准 Rust 库中的其他事物。
IO
也是一个可能的选择,我认为实现 Haskell 的 Parsec
可能会允许展示一些关于这个特性的强大功能的良好示例。
此外,一些更有用的 monadic 函数,如 mapM
,需要 traversable 和 foldable,因此这些是显而易见的特质来实现的下一个。
从哪里开始挖掘代码。
在 lib.rs
中的 test()
函数中,有大量的测试代码,这可能是开始的地方,因为那里有很多使用示例。
欢迎讨论和评论!
这个的全部目的就是为了展开讨论,尤其是关于当前设计核心方面的讨论,所以如果您有任何建议,请随时在 GitHub 上打开一个 issue。或者甚至可以打开一个 PR,如果这些更改更具体或者只想添加一些函数、特质或实现。