coyoneda 0.5.2

Functor composition via the Co-Yoneda Lemma

Crate coyoneda [] [src]

Functor composition via the Co-Yoneda Lemma

Functors in Rust

Let's implement functors in Rust!

Working around the lack of higher-kinded types, our trait for a functor will look something like this:

pub trait Param { type Param; }
pub trait ReParam<B>: Param { type Output: Param<Param=B>; }
pub trait Functor<'a, B>: ReParam<B> {
    fn fmap<F: Fn(Self::Param) -> B + 'a>(self, F) -> Self::Output;

This works great as long as we write functions that take specific types which are functors, but it is not possible to write a function operating on a generic functor type and using fmap more than once. For example, the following will not compile:

fn add_and_to_string<'a, F>(x: F) -> <F as ReParam<String>>::Output
   where F: Param<Param=i32> + Functor<'a, i32> + Functor<'a, String> {
   x.fmap(|n: i32| n + 1)
    .fmap(|n: i32| n.to_string())

While functors in general can be encoded to some extend in Rust's trait system, what we can't encode for a lack of higher-kinded types, is the fact that a functor Box maps a function between A and B to a function between Box<A> and Box<B>, not between Box<A> and Option<B>.

Especially when looking at functor composition, it is useful to be able to encode this fact, because it allows us to chain multiple calls to fmap, knowing that the result is also a functor, and can be fmap'ed further.

The Co-Yoneda Lemma

Let's define a data type called Coyoneda:

pub struct Coyoneda<'a, T: Param, B> {
    point: T,
    morph: Fn(T::Param) -> B + 'a

This datatype is a functor, which uses function composition to accumulate the mapping function, without changing the captured T. The implementation for Functor is trivial:

impl<'a, T: Param, B, C> Functor<'a, C> for Coyoneda<'a, T, B> {
    type Output = Coyoneda<'a, T, C>;
    fn fmap<F: Fn(B) -> C + 'a>(self, f: F) -> Coyoneda<'a, T, C> {
        let g = self.morph;
        Coyoneda{point: self.point, morph: move |x| f(g(x))}

The co-yoneda lemma states that for a covariant functor f, this Coyoneda f is naturally isomorphic to f. Practically speaking, this means that we can lift any f a into a Coyoneda f a, and given a function (a -> b) -> f b, we can retrieve back a f b from a Coyoneda f b.

Composing Coyoneda

Now here's the catch: Since we have a parameterized datatype that is isomorphic to any functor, we can lift functors into Coyoneda to compose them safely within Rust's type system!

For example, let's implement a function that is generic for any functor, by operating on our Coyoneda type:

fn add_and_to_string<T: Param>(y: Coyoneda<T, i32>) -> Coyoneda<T, String> {
   y.fmap(|n: i32| n + 1)
    .fmap(|n: i32| n.to_string())

Given we implemented a functor for Option, we can now do the following:

let y = add_and_to_string(From::from(Some(42)));
assert_eq!(y.unwrap(), Some("43".to_string()))

... or for Box:

let y = add_and_to_string(From::from(Box::new(42)));
assert_eq!(y.unwrap(), Box::new("43".to_string()))

... and for every other functor as well. Yay!