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);
}
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 let
s 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 justVec<T>
as output. Rust in this case determines the overload ofcollect()
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.
-
As programmers, we already have enough tools at our disposal to shoot ourselves into the foot; macros just add to that danger. ↩
-
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. ↩
-
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. ↩