3个版本 (破坏性更新)

0.3.0 2020年1月31日
0.2.0 2020年1月4日
0.1.0 2020年1月2日

#1169 in Rust模式

MIT/Apache

57KB
1K SLoC

haskell_bits

Haskell的各种类型类和概念在Rust中的实现。 (crates.io链接)

概述

目前这个库是Rust中Functor/Applicative/Monad层次的实现。它是一个非常正在进行中的项目,我现在发布它主要是作为一个征求意见,我希望能与大家讨论我的设计选择,在走得太远之前。

在这个领域已经有一些工作,例如

那么,这个库有什么不同之处呢?!

我认为这个库独特之处在于可以在Monads,Applicatives,Functors等上定义泛型函数。

上面提到的库似乎为各种类型定义了Functor,Applicative,Monad,所以fmapbind等在这些类型上工作,但似乎没有一种方法可以编写一个适用于所有类型定义的泛型函数,例如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 允许使用一些泛型 TVec<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 会拒绝编译这段代码,因为 VecOption(以及隐式地 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 不能解析,因为我们不知道 TConTIn 是什么。因此,我们不能将其传递给 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 复制的,除了 Applicativepure 函数被拆分为特质 Lift。所以 LiftApplicative 扩展 Functor,而 Monad 扩展了 LiftApplicative

LinearFunctor/LinearApplicative/LinearMonadFunctor/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 才能与值和引用参数一起工作,并根据参数是值还是引用调用 fmaplmap

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,其中F是某个函数。你必须始终传入它。

一些类型推断的麻烦

注意实际上定义了两个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);

它将能够从结果类型推断出5u32

问题是lift_c在泛型函数中表现不佳,比如所有Monads的泛型函数。

所以把lift_c看作是“具体提升”,意思是我们有一个具体类型。

bindbind_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 中实现,也没有特定的时间表,根据 这个问题的回复

未来的工作

有一些明显的事情要做,首先是实现除了 OptionVec 以及 Result 之外的其他类型的 Functor/Applicative/Monad,Result 是明显的下一个选择,以及标准 Rust 库中的其他事物。

IO 也是一个可能的选择,我认为实现 Haskell 的 Parsec 可能会允许展示一些关于这个特性的强大功能的良好示例。

此外,一些更有用的 monadic 函数,如 mapM,需要 traversablefoldable,因此这些是显而易见的特质来实现的下一个。

从哪里开始挖掘代码。

lib.rs 中的 test() 函数中,有大量的测试代码,这可能是开始的地方,因为那里有很多使用示例。

欢迎讨论和评论!

这个的全部目的就是为了展开讨论,尤其是关于当前设计核心方面的讨论,所以如果您有任何建议,请随时在 GitHub 上打开一个 issue。或者甚至可以打开一个 PR,如果这些更改更具体或者只想添加一些函数、特质或实现。

依赖项