My first day checking out Rust
This weekend I decided I would learn a bit of Rust and try to understand the hype around it! Like learning most programming languages, I started out writing a "Hello World!" program.
I found this example online:
The fn keyword is used to indicate the declaration of a function, followed by the function name and its arguments in parenthesis. The function body is enclosed in brackets. What stood out to me initially was the ! after the println function. What is it?
The ! indicates a macro. A macro is a meta-programming tool for code generation. In C/C++ you may see a macro like the #define statement here:
One of the big differences between macros in C/C++ and Rust is that C/C++ macros are evaluated at the pre-processor stage of the compiler vs. in Rust they are evaluated as part of the language in the AST. One way to visualize this difference is by seeing the output of this program in C/C++ vs. in Rust:
The C/C++ version return value of CUBE(1+2) is 7 and the Rust version is 27. C++ under the hood is doing 1+2*1+2*1+2 which is 1+2+2+2 = 7 with the correct order of operations. Because in Rust macros are an extension of the language the macro is converted to an AST before being evaluated, the expression 1+2 is evaluated before being executed.
This got me wondering: what is the utility of function-like macros when functions have great properties like type checking? One example of when you would want to use macros is for helpful debug messages like the following:
Another big benefit of macros is that they can get file name and line numbers at compile time instead of looking them up at runtime.
The strings in file!() and line!() are evaluated at compile-time instead of runtime which most other languages do. I wanted to prove that to myself so I searched for a way to get macro-expanded version of the Rust file. This turned out to be pretty straight forward with rustc --pretty expanded -Z unstable-options <filename>.rs. I additionally needed to use the nightly Rust compiler since the stable compiler doesn't support -Z unstable-options.
The output of the macro-expanded code looked like this:
Woah, cool! As expected, the filename string and line number are already there! Great, now that I've satisfied my curiosities about the "Hello World!" program we wrote, I want to understand the hype around Rust.
The first thing that's mentioned about Rust is its zero-cost abstractions. This is a property C++ boasts about as well. From Bjarne Stroustrup (original C++ developer) a zero-cost abstraction is
What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.
Once again, I needed to prove this to myself. I took a very basic example of a potential Rust compiler optimization to evaluate arithmetic expressions.
Checking the assembly output of the program with rustc --emit asm -C opt-level=3 <filename>.rs, I found that the Rust compiler did indeed convert 5 * 1000 into 5000.
Neat, but this is what I'd expect from any basic compiler! Let's try a more complicated example:
Woah, the Rust compiler calculated the entire expression even though it was more complicated! You can notice this by looking at the movl $10000 -4(%rbp) instruction which is the value of the second argument in println!. I'm curious if this is still possible if the data was on the heap and mutable by another thread. That brings me to another benefit of Rust that developers rave about: memory safety and detection of concurrency bugs at compile time.
Rust makes it very difficult to have a memory leak. Challenge accepted! I'm going to try to cause a basic memory leak:
Box::new allocates memory on the heap in Rust. Then, we implement the Drop trait which will be run whenever the memory address is free'd. If there really are no memory leaks in Rust and it is not a garbage collected language, _m1 should be free'd before test() returns.
This piece of code simply prints out:
Cool, as expected _m1 is freed before the test function is returned! So Rust does free memory that can't be referenced anymore. I tested some more programs and found that even if a reference is returned, if it is not used, then Rust will free it. As expected, if a reference is returned and used, it is not freed. I've kind of proved that Rust does what it claims to do, but I'm not going to dive into what mechanism makes it possible.
No Concurrency Bugs
Finally, Rust is also free from concurrency bugs. It'll check for this at compile time. I want to verify that this is the case. The mechanism used to make this possible is similar to the one used for memory safety. I won't talk about it here but its pretty neat! Here is the program I started out with:
Pretty simple, in C++ something similar would compile and sometimes return 0 and sometimes return 1. Lets see what happens in Rust.
What just happened!? There were two errors:
- Closure may outlive the current function, but it borrows i, which is owned by the current function
- Cannot borrow i as immutable because it is also borrowed as mutable
The first error refers to the fact that the variable i is "owned" by main and therefore can't be accessed by this other thread. Memory is free'd once its "owner" has terminated. In this case, its possible that the thread running main exits and frees up i and then the spawned thread attempts to access it resulting in a segfault. That's pretty cool!
The second error refers to i being borrowed as immutable inside the println while its also being used borrowed as mutable in the newly spawned thread. Rust doesn't allow any other references to memory while there exists a mutable reference. This is how Rust prevents reading racy data.
We can fix the first error by moving ownership of i to the spawned thread with the move keyword.
Now we get a new error:
This error is similar to the second error we saw previously. Because we used the move keyword and transferred ownership to the spawned thread, the main thread can no longer access it! Now we need a way to transfer ownership of i back to the main thread. We can do that like this:
Now, we return i in the spawned thread which transfers ownership back to the thread that it joins with. In this case, that's the main thread. Once ownership is transferred back to the main thread, the main thread can read and modify the i variable.
What a fun day learning Rust! I'm excited to understand the ownership and borrowing mechanism that makes all this possible. If you want to learn more, here are some great resources: