First thoughts on Rust vs OCaml

I'm about two weeks into Rust now, so this feels like a good time to write a critique, before I get Stockholm Syndrome'd.

My main motivation in learning Rust is that I have to maintain some of Dark's Rust code. There was a recent outage related to that code, and I had to learn on the fly, so better to actually know what I'm looking at.

I've also been dreaming of rewriting Dark in Rust for quite some time, largely due to frustrations with OCaml as well as some excellent marketing by the Rust community. I'm trying to evaluate whether this is a good idea, and if so, trying to figure out how to do it in a way that makes sense (which is to say, gradually).

By the way, Dark is platform for building backend apps; in practice it's a HTTP server and database (which comprise the "Dark platform") which connect to an interpreter for the Dark programming language, which you edit using the Dark editor. The interpreter is the core of the language, it runs in Kubernetes in Google Cloud, as well as compiled to JS in the browser. Today, almost everything is implemented in OCaml.

Initial impressions

So far I've spent a few weeks learning Rust, and I have to say, I like lots of things about Rust but I definitely don't love it yet.

The lens I use when talking about programming languages is Accidental Complexity, which is basically what Dark is all about (removing it, that is). So when I critique something, it's largely based on whatever bullshit hoops I feel it makes me jump through.

I'll be complaining about both OCaml and Rust below, so if you're a fan of either, prepare yourself. Remember, all programming languages suck, especially whichever one is your favorite.

What's to like about Rust (and dislike about OCaml)

There's a few things that Rust does very well, that OCaml really does not. I had hoped that OCaml would improve over the years, and for sure there's progress being made, if slow. But once you come face to face with a language that does it well, the difference is really stark!

Community

People say Rust is a fringe language, but as someone who's been coding in a truly fringe language (OCaml, and Clojure before that), Rust feels huge. There's tons and tons of ways to learn: an impressive tutorial (and lots of community tutorials as well), multiple books, StackOverflow questions, blog posts, videos. Not only that, but there's lots aimed at beginners; often it feels like OCaml blog posts are aimed at hardened professionals or academics. I recall finding it very very hard to get into, and especially hard to figure out how to write idiomatic OCaml, which I have not found with Rust at all.

Libraries

It's amazing - Rust has libraries for everything!! I've really been struggling with the dearth of libraries in OCaml. A year or two ago we were chatting to Google Cloud about deficiencies in Cloud SQL Postgres, and got told that Cloud SQL is only really there to tick boxes, we should move to Spanner instead. Except OCaml has no Spanner library, so we're stuck on Cloud SQL. Repeat this 50 times for 50 different vendors and services, and you start to get frustrated.

Tooling - building

The tooling is Rust is also fabulous. In particular, Cargo is probably the best build system I've ever used. The fact that they have integrated the package manager, the build system, and the compiler, and then it all just works? Magic!

By contrast, in OCaml you have opam, esy, dune, and OCaml itself all doing part of the job. They sorta kinda mostly work well together, until they don't. And as you wire them together, you really need to understand what each tool does, where it starts and stops, and how they integrate. Honestly, not fun at all.

The frontend is also confusing: there's the Bucklescript stuff, the ReasonML stuff, the OCaml compiler stuff, the Javascript stuff. But recently a nice improvement - they made it just one thing! So, some improvements there at least!

Tooling - editor

The editor tooling is also great in Rust. The Rust Language Server "just works" (at least in VS Code), whereas I've had a lot of trouble with setting up OCaml editor integration. I once had it finally working really well, and then I changed something in my OCaml config and have been unable to bring it back.

Tooling musing

I'm going to put my Dark biases to work here and speculate that all this amazingness is because Rust is mostly an integrated system: the same people are building all of these tools (if not the same exact humans, at least a group of people working towards the same goals). Whereas it doesn't feel that way in OCaml at all.

OCaml feels like a completely separate camps, where Facebook, Inria, Jane Street, and OCaml Labs, all sorta work on their own stuff and that few if any of them talk to each other. That's an outsider view (one I've voiced before) and possibly wrong, but if you folks do speak, maybe integrate a few of these projects and lower the cognitive overhead of learning all this stuff? Wouldn't it be great if there was a single tool as simple and powerful as Cargo for the entire OCaml/Reason ecosystem?

Macros

Macros in Rust are great! OCaml has PPXes, which are separate binaries that you build using the OCaml compiler toolkit. They have a very high barrier to entry, and I've never built one, and really struggled to even understand the ones I use. They also seem to be having a lot of trouble over the last few years.

Macros in Rust are built into the language. It feels a little like doing a regex over part of the syntax tree (with the same downsides, honestly), which is to say that they're pretty easy to get started and do quite powerful stuff with, even if they're not that powerful.

They do actually seem to lack some of the power of OCaml's PPXes. With OCaml's PPXes, I believe you can access the entirety of the compiler, with all of the upsides (do anything!) and downsides (compatibility between versions). However, with Rust I seem to have much less access - unless I'm mistaken I can't actually get it to parse type information for generics that I could then repurpose, which is frustrating.

They seem to be easier to write than Clojure's macros, though also less powerful. Given that Lisp/Clojure is entirely designed around treating code as data, and there's so little syntax, this makes sense. But still, pretty nice!

Aesthetics

Rust feels really well designed, which I expect is because they've aggressively pruned the parts of the language that don't make sense. So the Rust that I see today is supposably quite different from the Rust of 2010. Meanwhile, OCaml has a ton of stuff that they really should prune and have not.

Similarly, Rust the language is quite pretty, syntax-wise. Meanwhile, OCaml is so ugly that the community came up with a whole other syntax for it. This shouldn't be a big deal, but as someone responsible for getting folks up to speed on OCaml for a few years, this was not a fun experience.

OK, what sucks about Rust?

Apologies to the OCaml folks for being the victims of my frustrations so far; time to talk about Rust. First, some expectations: I've been working on Rust for two weeks, and a lot of that was doing the tutorial, worked examples, etc. But I do have a lot of experience with OCaml and Clojure, and also significant experience in C and C++. I've also been programming professionally for about 20 years, mostly in compilers and programming language implementations. So I feel Rust should be straightforward.

While we're talking about me, here's a fun anecdote about my first experience with Rust: In 2010, I started work at Mozilla. For my first day, they flew me to Whistler for their annual retreat, and I got to see Graydon Hoare present the thing he'd been working on for 4 years in secret: a new programming language called Rust! I excitedly bounded up to him to offer help: I know compilers, I love languages, I can help!! Unfortunately, though I tried I didn't get very far in helping, as I really couldn't get my head around the language that Rust was implemented in at the time, which was an esoteric academic language called "OCaml"!

Memory management

I was actually surprised at how little the actual memory management bothered me. I'm a big believer in garbage collection, and not having to think about memory, so I expected to hate this part of Rust, but it turns out it's kinda OK. You put everything in a Box::new (regular heap memory) or RC::new (reference counted memory) or Arc::new (reference counted memory suitable to be used concurrently in different threads), and then when they go out of scope they'll be cleaned up.

This actually seems kinda OK, especially when I compare it to writing C++ back in the day. I've only built the very small part of an interpreter so far, but I'm expecting that if I take care of scopes properly, the memory will just sort of manage itself? Not as good as garbage collection IMO, but not as bad as I'd heard.

Pattern matching

I was initially excited that Rust has pattern matching, since that's something every language should have (and maybe will soon). However, I pretty quickly learned to be less excited about this.

Let's quickly look at some interpreter code to see why.

type t =
  | EInteger of id * string
  | ELet of id * string * t * t
  | ELambda of id * (analysisID * string) list * t
  | EVariable of id * string
  | EFnCall of id * string * t list * sendToRail
[@@deriving show {with_path = false}, eq, ord, yojson {optional = true}

This is a cut down version of the original in OCaml.

#[derive(Debug)]
pub enum Expr_ {
  Let {
    var: String,
    rhs: Expr,
    body: Expr,
  },
  FnCall {
    name: FunctionDesc_,
    args: im::Vector<Expr>,
  },
  Lambda {
    params: im::Vector<String>,
    body: Expr,
  },
  Variable {
    name: String,
  },
  IntLiteral {
    val: i32,
  },
}

pub type Expr = Arc<Expr_>;
unsafe impl Send for Expr_ {}
unsafe impl Sync for Expr_ {}

So we can see the same type here in two different languages. In OCaml, you just specify the type. In Rust, you specify the type and also the garbage collection strategy - you can't just put an Expr in another Expr as you don't know the size of the Expr, and so need to wrap it in a Box, Rc or Arc. In C, you'd use a pointer, and really that's what we have here: both OCaml and Rust are effectively using a pointer to another Expr.

But what happens when we get to pattern matching? Well, here's how we do it in OCaml (full version here):

(function
 | state, [DList l; DBlock b] ->
     let f (dv : dval) : dval = Ast.execute_dblock ~state b [dv] in
        Dval.to_list (List.map ~f l)
 | args ->
     fail args

And in Rust? Here's how I'd like to do it:

    ("List", "map", 0), |args| match args {
        [ DList(list), DLambda(vars, body) ] =>
            DList(list.map(|val| execute_block(vars, body, [val])))
        args -> fail(args)
      })

However, I can't actually pattern match like this in Rust because I don't have DLists, I have Arc<Dlist>, and you can't pattern match through Arcs and Rcs and Boxes. (And also, I need to deal with ownership of basically everything here).

I eventually got this to work by doing:

match args.iter().map(|v| &(**v)).collect::<Vec<_>>().as_slice()

and then wrapped it in a macro to make it tolerable. Needless to say, this is quite frustrating.

Too many ways to do things

I remember when I first started Ruby; we were using JRuby in the early days of CircleCI, and also Mongo. Both of those were well supported, with many people using them. However, we seemed to be the first to ever use them together and I quickly put together this rule of thumb:

you can go off the beaten path once and be OK, but not twice.

In Rust, I'm seeing that there are many alternate paths, that aren't exactly off the beaten track, but that open multiple options and I'm starting to get worried. The most obvious ones being async/sync and Rc vs Arc.

I've already seen the seeds of Rc vs Arc. I'm using im, a library of immutable data structures (more on that later). I read that the idiomatic way to do composable errors in Rust is to use error-chain, which uses Arc. I was using im with Rc and that wasn't compatible with the Arcs in error-chain.

This seems to have come up before, as im actually has two libraries: im (which uses Arc) and im-rc (which uses Rc). The fact that there are two identical libraries with identical functionality, except that one uses Rc and the other uses Arc, seems to be a giant red flag that I feel is going to cause me a lot of trouble some day.

Similarly, it seems that sync and async Rust use entirely different standard libraries, which is also a big red flag. Do all flavors of im and Rc/Arc and sync/async play well together? No idea, but finding out is not an exciting prospect.

Immutable

Using im for data-structures is my 3rd "off the beaten track" thing in Rust. im is a library of immutable data structures, which is useful for - amongst other things - creating languages which are default immutable.

I initially thought Rust was immutable, but it is definitely not. While it has some amount of immutability built in, fundamentally you're acting on mutable values. It is super useful to have to ability to know that you can pass some to a function and it won't change, this is extremely wonderful.

However, when I compare this to the standard way to do things in immutable languages such as Clojure and OCaml, it has a big downside. Clojure and OCaml both use "purely functional data structures" which allow you to get new versions of data structures without destroying the old one.

For example, if I have a list and I want to do some operation on a slightly longer list, I can have a value that keeps the old list intact but also allows me to interact with the new slightly different list without a large performance cost. In Rust's default Vec, I'd have to do a clone of the old list for this sort of operation.

I find this super frustrating, and it feels like managing this is the cause of a lot of the boilerplate/accidental complexity in Rust.

A very common thing I'm doing is to create a new "scope" (or symbol table) in Dark, which is the old scope plus a few variables. In mutable languages, this is expensive in terms of performance, so language implementations often create a sort of stack of scopes to manage the performance implications. With immutability, you create a "copy" of the old scope for free, and then add your new values to it. When you're done, you just keep using the old scope, which hasn't actually changed.

So it's really quite frustrating to go from a language with immutability at its core, to one which has only some immutability. im handles some of the complexity, but I find myself constantly converting things to Vecs (as we saw above), which sorta defeats the purpose.

My point here is that I wish Rust was properly immutable, I guess. Hard to do in a systems language, but kinda frustrating.

Satisfying the compiler

I said I'd complain just a little about the type system (or "borrow checker" or whatever--are these the same thing or different? I'm not sure yet). Programming in Rust reminds me a lot of programming in C++: you add a const to one function, and then you have to follow that const around the entire codebase until you finally get to the place where you learn it actually can't be const, and so fuck you. Except in Rust this happens constantly. I'm hoping this will go away after I get used to it, but it's quite frustrating.

In OCaml, this experience is different: since basically everything is immutable you never get this, and you don't track your own pointers so everything is magic. Although I will say that very few times where something is actually wrong (usually the type inference does not infer using the same algorithm that a reasonable person would use), and the error messages in OCaml are very nearly useless.

Here's a nice metaphor: suppose you go to Chipotle every day. At Rust Chipotle, they have strict rules about the ingredients for your burrito. "White rice with medium salsa, sir? Absolutely not!". You see, medium salsa only goes with brown rice, and you also need to have beans or nothing works. Under no circumstances will they allow you to construct the burrito you think you want, no matter how much you think you want it.

Meanwhile, at OCaml Chipotle you can have whatever you like, and it always turns out awesome. But once a month you go for lunch and they'll refuse to make you a bowl, refuse to tell you why, and refuse to let you leave. And when you try to get help from a passerby after being trapped in the store, you realize there's nobody nearby who you can ask.

Macros

Macros were also in the "what's great about Rust" section. However, it feels like a code smell that I'm turning to macros so often. There's a ton of boiler plate, and I'm finding it hard to write reusable code using functions, what with all this ownership stuff. I expect I'll figure this out and won't need to lean on macros so much.

Interpreters & Compilers

Now to be fair, this might be a little bit of an unfair comparison. You see, OCaml is a language whose Raison D'etre is to build interpreters and compilers. I had heard that Rust is excellent at compilers too, so I was expecting it to be just as good. Maybe my brain is too warped by immutability - between Clojure and OCaml and Dark, I've barely actually coded in a mutable language in a decade.

Conclusion

Overall, Rust the language is frustrating but not terrible, while the tooling, community and ecosystem are truly excellent. Meanwhile, OCaml has poor tooling, poor ecosystem, and no community, while the language itself is truly excellent (except for the horrendous syntax, of course).

Since I've really hit a dead end with OCaml (multiple times even), I'm still hoping I'll get Rust Stockholm Syndrome. There doesn't really seem to be anywhere else to go.

Discuss on Twitter,  dev.to, Hacker News, or Medium.


You can sign up for Dark here, and check out our progress in these features in our contributor Slack or by watching our GitHub repo.