I ramdomly stumbled upon this Pull Request where the author is adding Option to the OCaml standard library. Pretty confused why this only happened in 2018, but maybe I am reading this wrongly.

I in this PR you can see how OCaml’s expressivity shines. Here’s a snippet of the implementation (more or less):

type 'a t = 'a option = None | Some of 'a

let none = None
let some v = Some v
let value o ~default = match o with Some v -> v | None -> default
let get = function Some v -> v | None -> invalid_arg "option is None"
let bind o f = match o with None -> None | Some v -> f v
let join = function Some (Some _ as o) -> o | _ -> None
let map f o = match o with None -> None | Some v -> Some (f v)
let fold ~none ~some = function Some v -> some v | None -> none
let iter f = function Some v -> f v | None -> ()
let is_none = function None -> true | Some _ -> false
let is_some = function None -> false | Some _ -> true
let to_result ~none = function None -> Error none | Some v -> Ok v

I thought about writing it in Rust to see how it would look like, what can I learn along the way and how much more verbose it gets.

Type definition

type 'a t = 'a option = None | Some of 'a
pub enum Maybe<T> {
    Just(T),
    Nothing,
}

Not too bad, very close.

Constructors

let none = None
let some v = Some v
impl<T> Maybe<T> {
    pub fn nothing() -> Self {
        Self::Nothing
    }

    pub fn just(value: T) -> Self {
        Self::Just(value)
    }
}

Ok, somewhat different, but not too bad. A few reasons:

  • You could move nothing and just outside of the impl block but your would not gain too much.
  • The main difference is really due to OCaml not having to type annotate as long as the compiler can figure it out. In Rust the functions and their arguments just have to be annotated with the return types. This is what adds the extra verbosity.
  • Misc: OCaml has the .mli file, Rust doesn’t. Any .ml file is already a module, just like rust, so you get the same things implicitly. One difference is that in OCaml the type t parametrizes the entire module. You can’t get the same in Rust.

Predicates

let is_none = function None -> true | Some _ -> false
let is_some = function None -> false | Some _ -> true

Rust attempt 1:

pub fn is_none(self) -> bool {
    match self {
        Self::Nothing => true,
        _ => false,
    }
}
pub fn is_some(self) -> bool {
    match self {
        Self::Something(_) => true,
        _ => false,
    }
}

Both readable, difference in whitespace really.

Rust attempt 2:

pub fn is_none(self) -> bool {
    matches!(self, Maybe::Nothing)
}

pub fn is_some(self) -> bool {
    matches!(self, Maybe::Just(_))
}

Damn. That’s much better, although it’s a semihack. More about it at the end.

Map and Bind

let map f o = match o with None -> None | Some v -> Some (f v)
let bind o f = match o with None -> None | Some v -> f v
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Maybe<U> {
    match self {
        Self::Just(something) => Maybe::Just(f(something)),
        Self::Nothing => Maybe::<U>::Nothing,
    }
}

pub fn bind<U, F: FnOnce(T) -> Maybe<U>>(self, f: F) -> Maybe<U> {
    match self {
        Self::Just(something) => f(something),
        Self::Nothing => Maybe::<U>::Nothing,
    }
}

At first sight, not that bad, just whitespace. However, looking at the implementation you can actually see that the map and bind are templated by the function type. You can’t just define:

pub fn map<U>(self, f: FnOnce(T) -> U) -> Maybe<U>;

Because the compiler yells at you:

pub fn map<U>(self, f: FnOnce(T) -> U) -> Maybe<U> {
                    ^ doesn't have a size known at compile-time

And this is because FnOnce is not a type, but a trait. It’s not so intuitive, but after some research it’s pretty obvious. You have to change into:

pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Maybe<U>;

I believe this and the first option are very similar, with subtle differences, if not the same. They are both resolved at compile time. Another option would be:

pub fn map<U>(self, f: dyn FnOnce(T) -> U) -> Maybe<U>;

Which will turn this into run time dynamic dispatch.

Quite a few nuances to traits and to passing functions as parameters in Rust. You can optimize for running time, compilation size, but this comes at a cost of complexity.

From all of this, and some previous experience, a few lessons that I learned:

  • Rust is not functional (this has been said before)
  • Lifetimes and borrowing are a blessing and a curse. A unique feature, by all means, but not a cure for everything
  • Rust doesn’t work for everything. I don’t want to struggle to compile something as easy as passing a function as parameter. The struggle is worth it if you really need all the benefits that Rust offers compared to, say, OCaml. And yes, there are loads of benefits, especially regarding the availability of open souces libraries, not even going into domain specifics where functional is better than imperative. It may be worth it, although I would prefer if I can just write my functions like: let map o f = .. and that’s it.

In Rust bind is called and_then.

A test:

#[test]
fn test_bind() {
    use Maybe::*;

    let two_multiplied_by = |x: i32| x * 2;
    let two_divided_by = |x| if x == 0 { Nothing } else { Just(2 / x) };
    let two_added_to = |x: i32| x + 2;

    let res = Just(0)
        .bind(two_divided_by)
        .bind(two_divided_by)
        .bind(two_divided_by)
        .map(two_multiplied_by)
        .map(two_multiplied_by)
        .map(two_added_to)
        .map(two_multiplied_by)
        .map(two_added_to);

    assert_eq!(res, Nothing);
}

Closing thoughts

It’s pretty cool to see some abstractions (re)discovered across time, languages and problems and how they are (similarly) implemented in different languages. As a proof it’s just the fact that both Rust and OCaml are using map, bind/and_then, that matches! is a thing, that the Rust ? operator is also a thing and people actually use them heavily.

More interestingly, it points to the fact that there are some models/patterns of writing code are universal, that are somewhat domain-agnostic and that if you learn them you will define better APIs, write more extensible code and you bank on the fact that these lessons have already been learned before.

Other two examples are Option and Result. Sure, both examples of the same thing, sum types, but the reality is that among all sum types, these two are used the most. I really miss them in C++/Java (old java) where the ways to indicate the something fails is through return codes, or exceptions. Just the fact that you can’t indicate that something can succeed or fail but through a whole new mechanism shows how a language can get in your way.

Keep coding!