So I tried Rust for the first time.

Martin Häusler - Jun 4 - - Dev Community

In my professional life, I'm at home on the Java Virtual Machine. I started out in Java over 10 years ago, but since around 4 years, I'm programming almost entirely in Kotlin. And while Kotlin serves a lot of different use cases and compilation targets, I've already felt like I need a "native tool" in my tool belt. I can deal with C if I have to, but it feels very dated at this point. And I heard all the buzz about Rust being the most loved programming language out there, so I thought I might take a look and see for myself.

Here's how that went.

Installation

The installation process was super smooth on my linux machine. I've installed rustup and let cargo init do its job; project setup done. I started my Visual Studio Code, got a few plugins, and was hacking away within minutes. I especially love the fact that cargo is also a built-in dependency management tool. Nice!

Advent of Code

Whenever I try a new language, the first thing I throw it at is usually the Advent of Code. It is a set of language-agnostic problems which start out very easy and gradually get more difficult; perfect for testing a new language!

I picked the "Day 1" example of 2023. We're given a textual input file; each line contains digits and characters. We need to find the first and the last digit in each row, convert them into a number (e.g. 8 and 3 turn into 83) and sum them up. After a little bit of tinkering, I ended up with this:

static INPUT: &'static str = include_str!("inputs/1.txt");

fn main() {
    let mut sum = 0;
    for line in INPUT.lines() {
        let numbers: Vec<u32> = line
            .chars()
            .filter(|c| c.is_numeric())
            .map(|c| c.to_digit(10).unwrap())
            .collect();
        let first = numbers.first().unwrap();
        let last = numbers.last().unwrap();
        sum += first * 10 + last;
    }
    println!("{}", sum);
}
Enter fullscreen mode Exit fullscreen mode

This ended up producing the correct result. This may not be idiomatic rust (I have yet to figure out what that is for myself) but it gets the job done. I have a lot of things to say about this little program.

include_str!

We're already starting out with a macro on the first line. I'm REALLY not a fan of macros1, but that's just how Rust rolls I guess. If we look beyond the problems of macros overall, the include_str! macro is really nice. It includes the text of an input file as a string in the program, and the compiler verifies that the file path is correct and the file exists. This should raise some eyebrows: this macro is doing more than just producing regular Rust code, it talks to the compiler. This pattern of macros opening sneaky paths to compiler intrinsics is going to repeat on our journey. It allows the compiler to provide better error messages, but macros are also really good at hiding what code is actually being executed. At the very least, in Rust all macros are clearly demarcated with a trailing ! in their name so you know where the "magic" is2.

Give me a lifetime

Right in our first line we're also hit with the expression &'static. There are really two things going on here:

  • & means that the type that comes next (str) is actually a "borrowed" reference. We will dig into this later, for now know that a borrowed thing is immutable.

  • 'static is a "lifetime specifier". All of these start with a single quote ('). Rust is the only language I've ever seen that uses single quotes in a non-pair-wise fashion. And good lord is it ugly! Looking past the syntax, 'static is a special lifetime that means "lives for the entire duration of the program", which makes sense for a constant like this.

main

The main function declaration is nothing special, aside from the nice fact that you're not forced to accept parameters, nor are you forced to return an integer (looking at you, Java!). fn is fine as a keyword, but I still prefer Kotlin's fun.

let mut

Variables are declared with let, types can optionally be annoated after the name with : SomeType. Fairly standard, all fine. The keyword mut allows us to mutate the variable after we've defined it; by default, all lets are immutable. Kotlin's var and val approach is a little more elegant in that regard, but it's fine.

Semicolon!! ;

Gods why. That's all I have to say. Why does the darn thing refuse to die? Chalk up another language with mandatory semicolons, they just keep coming.

for

Next is our standard "foreach" loop. A noteworthy detail is that there are no round braces ( ... ), which is also true for if statements. Takes a little time to get used to, but works for me. lines() splits the text into individual lines for us.

Iterators

Next we see chars() which gives us an iterator over the characters in the current line. Rust actually doesn't differentiate between a lazy Sequence and a lazy Iterator, like Kotlin does. So we have all the functional good stuff directly on the Iterator. We filter for the numeric characters, we map them to integers, we collect the result in a Vec<u32> (which is roughly comparable to a List<Int> in kotlin).

Lambdas

The lambda syntax in rust is... iffy at best, and a monstrosity at worst. The examples in the filter and map calls are very basic ones, things can get much worse. Rust actually has lambdas with different semantics (kind of like kotlin's callsInPlace contract). Sure, these things are there to aid the borrow checker, but I really miss my it from kotlin. filter { it.isDigit() } is hard to beat in terms of readability.

Type Inference... sometimes.

Much like the Kotlin compiler, the Rust compiler is usually pretty clever at figuring out the types of local variables and such so you don't have to type them all the time. Except that my let numbers absolutely required a manual type annotation for some reason. It's not like collect() could produce anything else in this example, so I don't really get why.

EDIT: Esfo pointed out in the comments (thanks!) that collect() can be used to produce more than just Vec<T> as output. Rust in this case determines the overload of collect() based on the expected return type - which is wild! I'm not sure if I like it or not.

Unwrap all the things!

You may have noticed the calls to unwrap(). The method unwrap() is defined on Rust's Result<T, E> type, which can be either an Ok<T> (success) or an Err<E> (error). unwrap() on Ok simply returns the value, unwrap() on Err causes a panic and terminates the whole program with an error message. And no, there is no try{ ... } catch { ... } in Rust. Once your program panics, there's no turning back. So unwrap() shouldn't be used in production code. For this simple toy example, I think it's good enough. Later I learned that if you're inside a method that returns a Result, you can also use ? to propagate the error immediately.3

print it!

Rust wouldn't be Rust if println was straight forward, because it really isn't. Right off the bat we see that println! has an exclamation mark, so it's a macro. And oh boy does it do a lot of magic. Rust doesn't really have string interpolation, so the standard way to print something is println!("foo {} bar", thing) which will first print foo, then the thing, then bar. That is, provided that thing is printable, but that's another story. You could also write println!("foo {thing} bar") which almost looks like string interpolation, but it's really just a feature of the println! macro. It breaks down quickly, for example when you try to access a field inside the curly brackets. This won't compile: println!("foo {thing.field} bar"). So while it may seem simple at first glance, it really isn't and it does a whole lot of magic. Kotlin string templates are not without issues (especially if you want a literal $ character in the string) but they are more universal because they allow for arbitrary logic in their placeholders.

The Pros and Cons

At this point, it's time for a first verdict.

  • Pro: Tooling is good
  • Pro: Compiles natively
  • Pro: Memory safe without manual memory management or garbage collector
  • Pro: small executables, excellent runtime performance
  • Pro: Feels "modern"

  • Con: Syntax. Good lord the syntax.

  • Con: Macros. No need to explain that.

  • Con: Error handling, or rather the absence thereof

  • Con: Enormously steep learning curve with borrowing and lifetimes

  • Con: Type inference sometimes works, sometimes doesn't

... and that's just for the first example. I've since performed more extensive experiments with the language, and I'll report about these in another article.

I can see why system level programmers enjoy Rust. It's certainly a big step up from C. But on a grand scale, compared to the effectiveness and productivity of Kotlin, I can't yet see where Rust's popularity is really coming from.


  1. As programmers, we already have enough tools at our disposal to shoot ourselves into the foot; macros just add to that danger. 

  2. In C, that's not the case. You can make a macro look like a totally normal function call, but it does arbitrary other things when compiled. 

  3. The Go language should take notes here. if(err != nil) return err is only funny so many times before it gets really old really fast. 

. . . . . . . . . . . . . . . . . . . . . .