Assignment 4
Table of Contents
The following assignment is due Thursday 2/20 by 8:00 PM. You'll
need to submit one crate on Gradescope called assign4
. The
expectation is that you will put everything is src/main.rs
or
src/lib.rs
.1
The theme of this assignment is "hacking the trait system." Not all patterns here are canonically "Rustian" but they will require us to get creative with traits.
I've included some unit tests throughout the assignment. Rust makes it very easy to write tests, see the section in RPL on how to write tests for more information.
1. Monoids
Many data types have algebraic structure that can be captured by a shared interface. This is a common programming pattern in Haskell, and makes appearances in the Rust standard library. The goal of this problem is to generalize this idea.
All algebraic structures we'll consider in this problem deal with a binary operators. We'll abstract out this operator as part of a generic trait:
trait BinOp<T> { fn op(lhs: T, rhs: T) -> T; }
A semigroup is a set \(X\) together with a binary operation \(\circ : X \to X \to X\) which is associative.
It's possible to define a concatenation operation (mconcat
) for any
semigroup, which takes a starting value and an iterator, and folds the
operator over the values of the iterator:
trait Semigroup<O> where O: BinOp<Self>, Self: Sized { fn mconcat<I: Iterator<Item=Self>>(start: Self, iter: I) -> Self { let mut out = start; for item in iter { out = O::op(out, item) } out } }
This trait is generic over operators of a given type. For technical
reasons, we also require types on which we define semigroups are Sized
.
A monoid is a set \(X\) and an associative binary operator \(\circ : X \to X \to X\) and a designated unit element \(u \in X\).
The tasks for this problem:
- Define a monoid trait in analogy with the
Semigroup
trait, and so thatSemigroup
is a supertrait. - Implement these traits for
i32
andString
so that the tests below.
Part of this problem is reverse engineering. What kind of thing should
Add
be? What do you need to do in order to overload terms? I would
recommend looking over section on Advanced Traits in RPL for some
hints. Note that one of the upshots of this pattern is that we can
have multiple algebraic traits for a given type with different
operators, and we can have a uniform convention for referring to kinds
of operators across types.
#[cfg(test)] mod tests { use super::*; #[test] fn mconcat_semi_add_i32() { let expected = <i32 as Semigroup<Add>>::mconcat(10, vec![1, 2, 3, 4, 5].into_iter()); assert_eq!(expected, 25) } #[test] fn mconcat_semi_mul_i32() { let expected = <i32 as Semigroup<Mul>>::mconcat(10, vec![1, 2, 3, 4, 5].into_iter()); assert_eq!(expected, 1200) } #[test] fn mconcat_mon_add_i32() { let expected = <i32 as Monoid<Add>>::mconcat(vec![1, 2, 3, 4, 5].into_iter()); assert_eq!(expected, 15) } #[test] fn mconcat_mon_mul_i32() { let expected = <i32 as Monoid<Mul>>::mconcat(vec![1, 2, 3, 4, 5].into_iter()); assert_eq!(expected, 120) } #[test] fn mconcat_semi_str() { let s1 = String::from("abc"); let s2 = String::from("def"); let s3 = String::from("ghi"); let expected = <String as Semigroup<Add>>::mconcat(s1, vec![s2, s3].into_iter()); let actual = String::from("abcdefghi"); assert_eq!(expected, actual); } #[test] fn mconcat_mon_str() { let s1 = String::from("abc"); let s2 = String::from("def"); let s3 = String::from("ghi"); let expected = <String as Monoid<Add>>::mconcat(vec![s1, s2, s3].into_iter()); let actual = String::from("abcdefghi"); assert_eq!(expected, actual) } }
2. Functors
One of the deficits of traits as compared to Type classes is the inability to define traits on type constructors. This means it's very difficult to implement things like functors in Rust. That said, it's not impossible. In this problem we'll be hacking functors into Rust.
In rough terms, a functor is a type constructor (formally, a type of
kind \(\texttt{Type} \to \texttt{Type}\)) for which it's possible to
define a mapping function. We tend to think of functors as "things
which hold other things" like Option
or Vec
. The mapping
operation allows you to apply a function to the "inner" thing without
disturbing the structure of the "outer" thing. In Haskell, we define
the Functor
type class as:
class Functor (f :: Type -> Type) where fmap :: (a -> b) -> f a -> f b
Without getting into the Haskell of it all, the idea is that fmap
is
a higher-order function that takes a function and "lifts" it to a
function to the level of the "outer" thing. The benefit of this that
we can define functions against this interface, and derive code that
generalizes across a wide range of types.
Rust has a hard-coded implementation of map for Option
:
fn map<U, F>(self, f: F) -> Option<U> where F: FnOnce(T) -> U, { match self { Some(x) => Some(f(x)), None => None, } }
But there is no mechanism to generalize this in the Rust standard
library. Again, the problem is that we can't define a trait for the
type constructor Option
, only for type Option<T>
where T
is a
concrete type or a type parameterized in the implementation.
We're going to get around this with a collection of tricks using associated types. Our first issue is that we can't refer to the "type that a functor is holding" or "types which are the same up to what they're holding." We can get around this (in a circuitous way) by defining for every type a trait which keeps track of these things as associated types.
trait FunctorTypes { type Inner; type Outer<T>; } // Example for Option impl<T> FunctorTypes for Option<T> { type Inner = T; type Outer<S> = Option<S>; }
This is not terribly elegant, but its the kind of thing that could be
automated with a macro. Inner
will always match the type parameter
of the functor and Outer
will match the functor up to parameter.
Note that we're using a fancy trick here: associated types can be
generic.
We can then define our Functor
trait in terms of these associated types:
trait Functor: FunctorTypes { fn fmap<T>(self, f: impl Fn(Self::Inner) -> T) -> Self::Outer<T>; } // Example for Option impl<S> Functor for Option<S> { fn fmap<T>(self, f: impl Fn(S) -> T) -> Option<T> { match self { None => None, Some(x) => Some(f(x)), } } }
The implementation is identical to the above standard library
implementation (modulo the weaker assumption on the type of the
closure) but the types in the trait itself are wonky. We're using the
FunctorTypes
trait to glean the information we can't get directly
from the given types.
The tasks for this problem: Add a default implementation of the
function funzip
to Functor
. This function should take a functor
value with holding a pair and turn it into a pair of functor
values. The Rust standard library has a hard-coded version for
Option
:
fn unzip(self) -> (Option<T>, Option<U>) { match self { Some((a, b)) => (Some(a), Some(b)), None => (None, None), } }
A couple hints:
- Your implementation won't look like this, it will have
to use
fmap
onself
- You will need to add a trait bound requiring that the underlying functor value is clonable (you'll see when you try to implement the function).
- You'll also have to include a trait bound letting the compiler know
that the inner type of
Self
is a pair. Again, make sure to take a look at RPL on associated types to see how to specify an associated type.
Your implementation should be able to pass the following tests:
#[test] fn option_fmap() { let expected = Some(100).fmap(|x| x + 1); assert_eq!(expected, Some(101)); } #[test] fn option_funzip() { let expected = Some((vec![1, 2, 3], 4)).funzip(); assert_eq!(expected.0, Some(vec![1, 2, 3])); assert_eq!(expected.1, Some(4)) } #[test] fn option_functor_none() { let none : Option<(i32, i32)> = None; assert_eq!(none.fmap(|p| p.0 + p.1), None); assert_eq!(none.funzip(), (None, None)) }
3. Applicatives
Things can get a lot more complicated once we start generalizing past functors.
An applicative functor is a functor together with a function pure
that can "convert a thing into a held thing" and app
which can
"apply a held function to a held thing." This gives us the following trait:
trait Applicative: Functor { fn pure(x: Self::Inner) -> Self; fn app<T, U>(self, ax: Self::Outer<T>) -> Self::Outer<U> where T: Clone, Self::Inner: Fn(T) -> U; }
Again, there's some weird stuff going on with the types. We have a
trait bound which requires the inner type of Self
to be a closure,
so that self
is a "thing holding a function" and the input is a
"thing holding a T
" written with Outer
associated type.
The task for this problem: Implement the Applicative
trait for
Option
, in analogy with the implementation of Functor
.
- The
pure
function should take a value and wrap it in aSome
constructor. - The
app
function should apply the held function to the held value if both are notNone
.
Your implementation should pass the following tests:
#[test] fn option_pure() { assert_eq!(Option::pure(vec![1, 2, 3]), Some(vec![1, 2, 3])) } #[test] fn option_app() { let mut f = Some(|x| x + 1); assert_eq!(f.app(Some(10)), Some(11)); assert_eq!(f.app(None), None); f = None; assert_eq!(f.app(Some(10)), None); assert_eq!(f.app(None), None); }
Challenge (Optional): Give a default implementation of a method app2
which
can be applied when the inner value is a Curried binary function,
and which "runs" app
twice. It should pass the following test:
#[test] fn option_app2() { let f = Some(|x| move |y| x + y); assert_eq!(f.app2(Some(3), Some(4)), Some(7)); assert_eq!(f.app2(Some(3), None), None); assert_eq!(f.app2(None, Some(3)), None); assert_eq!(f.app2(None, None), None); }
Getting the trait bounds right for app2
is messy…
4. The Newtype Pattern
We can't define all these nice traits for vectors, but we can wrap vectors in a new structure:
#[derive(Clone, PartialEq, Debug)] struct VVec<T>(Vec<T>);
You can call the struct whatever you want. You access the inner vector using tuple accessor notation:
let v = VVec(vec![1, 2, 3]); assert_eq!(v.0[0], 1)
Working with these wrapped vectors requires some ugly boilerplate, but
at least now we can implement traits for VVec<T>
…
The task for this problem: Implement Semigroup
, Monoid
,
Functor
and Applicative
for VVec
.
Some hints:
- The concatenation operation on vectors is just vector concatenation.
- The mapping operation on vectors applies the given closure to every element of the vector.
- The pure operation creates a singleton.
- The
app
operation is a bit complicated: given a vector of closures and a vector of values, map each function onto the vector of values and concatenate the results.
Your implementation should pass the following tests:
#[test] fn mconcat_semi() { let v1 = VVec(vec![1, 2, 3]); let v2 = VVec(vec![4, 5, 6]); let v3 = vec![v1, v2]; let expected =<VVec<i32> as Semigroup<Add>>::mconcat(VVec(vec![0]), v3.into_iter()); let actual = VVec(vec![0, 1, 2, 3, 4, 5, 6]); assert_eq!(expected, actual); } #[test] fn mconcat_mon() { let v1 = VVec(vec![1, 2, 3]); let v2 = VVec(vec![4, 5, 6]); let v3 = vec![v1, v2]; let expected =<VVec<i32> as Monoid<Add>>::mconcat(v3.into_iter()); let actual = VVec(vec![1, 2, 3, 4, 5, 6]); assert_eq!(expected, actual); } #[test] fn fmap_vvec() { let v = VVec(vec![1, 2, 3]); let expected = v.fmap(|x| x + 1); assert_eq!(expected, VVec(vec![2, 3, 4])) } #[test] fn funzip_vvec() { let v = VVec(vec![(1, 2), (3, 4), (5, 6)]); let expected = v.funzip(); let v1 = VVec(vec![1, 3, 5]); let v2 = VVec(vec![2, 4, 6]); assert_eq!(expected, (v1, v2)) } #[test] fn pure_vvec() { assert_eq!(VVec::pure(1), VVec(vec![1])); } #[test] fn app_vvec() { let v1 = VVec(vec![|x| x + 1, |x| x + 2]); let v2 = VVec(vec![1, 3, 5]); assert_eq!(v1.app(v2).0, vec![2, 4, 6, 3, 5, 7]) }
Footnotes:
If you want to look into separating your solution into separate files, that's fine too.