Reasons I Love Rust

This post is my first blog, and while I have some things I would love to post about on here, I’d figure I’d create this as a helper for beginners, sense Rust can be pretty tricky for those just getting into it (especially for someone’s first language.) I expect you to know some of Rust’s basic syntax and keywords; if you don’t know this, this post may not prove too helpful.

I was gifted or lucky in the fact that I was familiar with C++. C++ is another language with a difficult learning curve if you aren’t comfortable with memory management or consider it alien. When I started, I programmed in Java, so coming from Java to a language like C++ was quite tricky; some of the many reasons are listed as such:

  1. C++11 (the one I used when I first started) fully expected you to handle every aspect of the memory. What made it worse was even though smart pointers did exist, other developers didn’t use them much at the time, nor were they taught to newcomers introduced to the language.
  2. Templates were complicated to understand. There could honestly be an entire post dedicated to this one thing. Even though templates on the surface level are easy, templates become incredibly complex and complicated once you start chowing down on them. From making sure the generic inherits a specific type to whether the type meets a certain boundary, it can be hard to grasp it. Not to mention the insane paragraph-long compiler errors they can throw (though I have heard they’ve been doing better with that in the recent compiler updates.)
  3. Using anything other than MSVC on Windows becomes a huge hassle. I always have a thing for performance and making sure all my code is as little bloated as possible. Sadly, the MSVC compiler is notorious for bloating the assembly to an unreal degree. GCC and Clang make neat and small code, yet MSVC will create so much more backend simply printing “Hello, World!”. However, installing GCC pre-compiled can be a massive project, especially finding them up-to-date with the current GCC version. Clang is much easier, but I’ve seen it has occasional issues and requires more preparation to prepare certain things. And in the Rust aspect, GCC tends to work better overall for certain crates over Clang.
  4. Doing simple projects takes a lot of thought and planning, depending on what you do. With C++20 being out, it came with an all-new filesystem handling, which is excellent! The last time I have built a program in C++, it was a directory lister, and to make it work well without a library attached, I had to use WIndows’s backend API, which is highly outdated and hard to use. It took almost two days just to set up everything needed, with a large chunk of that solely being dedicated to learning the Windows backend. Don’t even get me started on the bit masking to figure out the file’s properties!
  5. Most but not least, using different libraries is insanely fussy and challenging. We all know this one to some degree, whether you worked in C++ for a day or did full-blown projects in it. We all know the pain of learning about libraries, understanding the difference between static and dynamic libraries, and then spending the next two hours arguing with the compiler on linking the library. Visual Studio (thankfully) has built-in properties to handle many of this, like many other IDEs like CLion, Code::Block, and others. But a significant thing I noticed is that if one thing is slightly off, it will cause the compiler to lose its mind, same with the IDE completely. I could get a program to compile fine, but the IDE wouldn’t see the library’s headers; I would fix that, and then the linker would argue, fix that, and then you have a DLL problem, fix that, and then something else breaks. Compiler errors were a constant cycle, and no matter how much I taught myself on it, it never ceased to get easier.

I could continue to go one about C++ and its frustrations, but you get the idea in the end. I love C++ and its history, but what I’m getting at is C++ is a complex language, and so is Rust. Every low-level language will be hard to some degree once you start entering memory management. C, C++, and Rust are no different in this regard, but Rust has specific changes that make it more difficult. So now that I’m done with talking about C++ let’s enter the world of Rust.

Rust is a functional programming language. What this means is Rust doesn’t utilize Object Oriented designs (at least some of it.) Rust instead uses traits much more similar to something like C. In C++, you have classes, interfaces, and abstract classes for OO (Object Oriented) programming; Rust has structs, enums, and traits.

Borrow Checker

Rust has many neat features that help with many things, but in general, Rust’s main focus out of everything is Memory Safety. Its goal is to have concise, simple, and easy-to-read code that is entirely memory-safe. Does it achieve this? In my opinion, it does with flying colors. You can make memory leaks in Rust, but most of the time, I find you can only do that when you are thoroughly looking to do it.

How does Rust do memory safety if it is low-level, even compared to C in style and performance? Simple. The compiler that Rust uses has a tool built-in called the Borrow Checker. The borrow checker is a nifty tool that sifts through your code and determines whether or not there is something unsafe in your code. If it raises a flag, the compiler fails the program, whether or not it is syntactically correct.

Here's an example:

fn main() {
    let mut x = 1;
    let y = &mut x;
    let z = &mut x;
    *y = 11;
    *z = 10;
}

Let’s break down this program. So we create a variable called x, we then make y and z which references x. If you don’t know, &mut is a mutable reference, making the reference able to modify the original variable (x.) We dereference y and set its value to 11, so x is now 11. We then do the same thing with z, setting x to 10.

Now this program... it’s completely useless and does nothing, but syntactically, it is excellent; it should compile fine, right? No, the borrow checker will jump in and throw this error:

error[E0499]: cannot borrow `x` as mutable more than once at a time
 --> <source>:4:13
  |
3 |     let y = &mut x;
  |             ------ first mutable borrow occurs here
4 |     let z = &mut x;
  |             ^^^^^^ second mutable borrow occurs here
5 |     *y = 11;
  |     ------- first borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0499`.
Compiler returned: 1

What does this error mean? What is it saying? I thought the code was correct?

Well... technically, it is, but it isn’t. The borrow checker is raising a flag here because it notices you are borrowing a variable as mutable twice; the reason it does this is that the first borrow could possibly mutate x and invalidate it when z goes to try and modify it. This is a painfully simple way to trigger this error, and in most cases, you’ll end up triggering it when working with structs since it’s much more likely to encounter mutability issues. A straightforward fix to this situation is to remove z and modify solely through y as such:

pub fn main() {
    let mut x = 1;
    let y = &mut x;
    *y = 11;
    *y = 10;
}

This is the easiest fix, you would only need a single reference for all of it in a real case. There are other, more unsafe ways of getting around the borrow checker, but I won’t be showing those, as the borrow checker’s goal is to ensure safety, and soon, it’ll become your best friend.

I found the borrow checker not only helped me write concise and safe code, but it changed my way of thinking. Instead of coding and thinking of myself as the CPU, stepping through each part of my program, and reading each instruction given to me, I now view myself as the memory, seeing what memory is created and how that memory should be handled. You’ll find approaching Rust like this will work a lot easier for you. Since Rust requires some form of insurance, you, the programmer, are taking care of the memory. If you don’t, the borrow checker will fight you every step of the way.

Weirdly, the relationship you’ll have with the borrow checker is similar to a marriage; it requires both sides to work together for the whole thing to work as a whole. If one is putting in all the work when the other isn’t, arguments will break out. In this case, however, the borrow checker is always putting in 110%; it’s up to you to do the same.

For instance, with what I said earlier on how I view myself as the memory, I will now write my version of this same program:

pub fn main() {
    // Since this is a useless program, there is no need to use more memory.
    let mut x: i8 = 1;
    let y = &mut x;
    *y += 10;
    *y -= 1;
}

So, in this version, there are a few differences and even a comment. Let’s read the comment: Since this is a useless program, there no need to use more memory.

If you saw it in the last example of the code, I never specified a type. By default, the compiler will give the type to the variable. Since it knows this variable isn’t a float because it has no decimal, it sets the type to i32. The compiler gives the variable 32 bits of data to use, giving the max value of the variable give or take 2,147,483,647 (at least in the assembly code of the program.)

Since this program is useless and we are doing small and basic number calculations, we don’t need something that high. As a matter of fact, we need something tiny. i8 is the lowest bit variable you can pick for an integer; its max value is 255 with its minimum negative being -255. So I’d rather have only 8 bits on the memory and use that rather than overcompensating with 32 bits.

Something else I did was change how the value is altered. Instead of saying *y = 11, I said *y += 10, which is the equivalent of *y = *y + 10, and then I followed it by removing 1, which makes the final value 10. I could even be more straightforward and just say *y = 10 sense that would be the final result after the math is done.

But, I'll simplify it even more:

pub fn main() {
    // Since this is a useless program, there is no need to use more memory.
    let x: i8 = 10;
}

This is the most optimized form of the program. It simply sets x to 10. A useless program, I know. But! We will make it print the number so it can at least display its glory!

pub fn main() {
    // Since this is a useless program, there is no need to use more memory.
    let x: i8 = 10;
    println!("{}", x);
}

Now it will print the value of x to the world! Hm? Don’t you know what println! is? Oh, I never talked about that.

Macro

Rust has a secret weapon compared to a lot of other languages. Rust, in my opinion, has one of the strongest and most well-done macro syntaxes compared to many other languages. C and C++ were moderately complex and could do basic functions, but macros in Rust can do so much more. It’s the same setup as others, but more.

During compiling, Rust processes macros in compile-time, meaning that macros technically don’t even exist as functions but rather instructions to the compiler on what to put there. So when we call println!, it actually isn’t even there in the code; it is replaced with actual Rust code that does the function we want, which is printing to the terminal, before compiling to IR and then ASM. We use a macro because doing this by hand with methods and structs would be confusing, and it also has platform-dependent code on it. Printing to a console in Windows is a lot different than printing to a console in Linux, the same with macOS. So this macro is actually doing something smart. Depending on the OS you’re on, the code it generates will be different! I didn’t read up on this too deep, so I am making assumptions, but I’m pretty certain this is what the macro is doing under the hood.

So what about {}, or the fact we’re passing through the variable x? Well, this is similar to C# in a way. What happens is the function is given a string, this string can be anything from “All hail the glorious {}!” to just ” {}” like what we used above. What happens is the string is looked through, and if the string contains {}, it knows that it’s being given a variable. That’s why we also pass through x in the macro call. It’s because {} will be replaced with the value of x, which is 10. You can read up much more about it and clearly understand how it works through the official Rust documentation on formatting.

On the topic of documentation, documentation for Rust is your best friend. Use it as often as you can, even for myself, it has made life so much easier than never having used it. You can see the entire documentation here.

On top of that, you should also read the Rust handbook, which does a much better job explaining many things than anything I could do. Rust is community-driven and extremely friendly, so utilize all the tools given and don’t fear asking questions if you’re confused, I was for a while, but the community helped a ton without feeling full of themselves, or being... elitists. The Rust handbook can be read for free here.

Now, onto the next topic!

Type System

Do you like Typescript? Do you like not having to specify the type of a variable all the time? Well, Rust has you covered! Sort of...

Rust was kind of unique when I discovered it's type system for the first time. I found you could statically type every single variable, specifying exactly what it was, or you could ignore it and let the compiler handle it for you. Here's an example:

pub fn get_chars() -> Vec<u8> {
    let mut vec: Vec<u8> = Vec::new();
    vec.push(29);
    vec.push(45);
    vec.push(61);
    vec.push(64);
    vec.push(54);
    vec
}

pub fn main() {
    let x: Vec<u8> = get_chars();
}

Here, we have a vector that gets characters. These characters are created in the get_chars() function, each variable is created with a specified type, but as shown earlier, we don’t need to do that, it’s entirely optional, to some degree.

pub fn get_chars() -> Vec<u8> {
    let mut vec = Vec::new();
    vec.push(29);
    vec.push(45);
    vec.push(61);
    vec.push(64);
    vec.push(54);
    vec
}

pub fn main() {
    let x = get_chars();
}

This code here is just as valid as the last bit of code, and the reason for that is because the compiler is smart enough to pick up on clues. For instance, the return type of the function is Vec<u8>, we know the variable vec is a Vec struct, and it’s pushing numbers that fit in the range of u8, so the compiler knows that vec is a Vec<u8>, it’s heavily implied in the code itself. Because the function returns this Vec as well, it knows x is also a Vec<u8> since it takes the return and owns it.

Before the borrow checker was updated, it used to be where you could use no types to a limit. `Vec’s would throw errors all the time if you didn’t specify the type because the compiler at the time couldn’t guarantee what type it was. Nowadays, it’s much easier and generally more comfortable. I myself only use types when I feel the performance is necessary (very case-specific), but generally, you only need to specify types in select situations when it’s warranted. And IDEs for Rust comes with a feature that shows the type for the variable without you needing to type it, showing you what the compiler will see before it is even compiled.

Structs

Structs are fun; they are the pillar to Rust and what makes it work, and in some ways, better than C++ or C# in how they handle classes. What if I wanted to make a Vector2 for a 2d game? It’s easy with structs and very straightforward.

pub struct Vector2 {
    x: f32,
    y: f32
}

impl Vector2 {
    pub fn new(x: f32, y: f32) -> Self {
        Vector2 {
            x,
            y
        }
    }
}

pub fn main() {
    let x = Vector2::new(0.0, 1.0);
}

Structs, as shown here, are basically classes. You have variables and can even use impl to have function specifically for them! Why are we using :: to call new though? This is because new isn’t fully a method for an already created Vector2 struct. Instead, it’s kind of like a static function, something you call when you want to make a new Vector2. For a method, we can do something simple:

pub struct Vector2 {
    x: f32,
    y: f32
}

impl Vector2 {
    pub fn new(x: f32, y: f32) -> Self {
        Vector2 {
            x,
            y
        }
    }

    pub fn mulf(&mut self, scale: f32) {
        self.x *= scale;
        self.y *= scale;
    }
}

pub fn main() {
    let mut x = Vector2::new(0.0, 1.0);
    // x = Vector2 [x: 0.0, y: 2.0]
    x.mulf(2.0);
}

We made a method called mulf, which multiplies the Vector2 according to scale. Simple enough, right? I won’t go into huge detail on the specifics of methods, but whenever a function has its first parameter with &self, self or &mut self, it is a function that is tied to a created and owned struct. Since x owns a created Vector2, it can call mulf to modify its properties.

Custom Operators

What if we wanted to do something like:

pub fn main() {
    let mut x = Vector2::new(0.0, 1.0);
    // x = Vector2 [x: 0.0, y: 2.0]
    x.mulf(2.0);

    let y = Vector2::new(10.0, 11.0);
    // x = Vector2 [x: 10.0, y: 12.0]
    x = x + y;
}

Add a vector to another vector, basic addition! Well, this can’t work...

error[E0369]: cannot add `Vector2` to `Vector2`
  --> <source>:26:11
   |
26 |     x = x + y;
   |         - ^ - Vector2
   |         |
   |         Vector2
   |
   = note: an implementation of `std::ops::Add` might be missing for `Vector2`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0369`.
Compiler returned: 1

It seems the compiler is angry. How do we fix this?

Well, remember how we used impl to give function to Vector2? We can impl to also implement traits; we’ll talk more on traits in a minute, but let’s implement the Add operator and make this work!

// This should be at the top of the file
use std::ops::Add;

impl Add for Vector2 {
    type Output = Vector2;

    fn add(self, rhs: Self) -> Self::Output {
        Vector2 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

pub fn main() {
    let mut x = Vector2::new(0.0, 1.0);
    // x = Vector2 [x: 0.0, y: 2.0]
    x.mulf(2.0);

    let y = Vector2::new(10.0, 11.0);
    x = x + y;
}

There is a lot to unpackage here. So, we implemented a trait called Add. This trait uses the type Output which is Vector2, and has the function add. This functions code will later be used whenever we need to use + in any mathematical operations involving Vector2! This is incredibly useful for things like Vector2, Vector3, and even Matrix4x4; 3d mathematics rely on these things for everything to run correctly, it would be a real headache having to call add, sub, mul, etc. whenever we’d want to do basic math to these things.

Traits

Traits are something I love but equally find confusing. They are simple, pretty much interfaces like you would see in Java, yet, they carry a little more to them that make them unique. Let’s say we want a normalization function to normalize the Vector2 to determine the direction of the Vector2.

pub trait Normalize {
    type Output;

    fn norm(&mut self);
}

The first thing we do is declare the trait for usage not only in Vector2 and possibly Vector3 if we make that. The first thing we do is name it Normalize; much like Add, we need to name it to know what the trait is meant to do. We see type Output again, but it equals nothing. This is because we will set the type it will equal once we implement. The last thing is simply the function tied to the trait, the one we’ll call to normalize the Vector2. Now we implement it!

pub fn mag(&self) -> f32 {
    (self.x.powi(2) + self.y.powi(2)).sqrt()
}

We need to calculate the magnitude/length of the vector, so we have to make a function inside of Vector2 to do this. Unless you haven’t practiced your vector math, I shouldn’t have to explain what this code does.

pub fn divf(&mut self, n: f32) {
    self.x /= n;
    self.y /= n;
}

We also have to make another function like mulf but for dividing rather than multiplying.

impl Normalize for Vector2 {
    type Output = Vector2;

    fn norm(&mut self) {
        let mag = self.mag();
        self.divf(mag);
    }
}

After doing all of that, we finally implement Normalize. Normalizing is straightforward. You get the magnitude of the vector and then divide the vector by its length, basically shrinking it. Traits allow me to implement this equation in Vector2, but Vector3 as well if I program that struct. It’s instrumental, just like interfaces in Java, and it requires no more code than Java would to pull that off! Amazing.

Conclusion

This post isn’t really meant to teach or give tips, but rather to highlight things I enjoy about a language I love to program in; if you did learn something, I’d love to know. But so far, this is as much as I can give without writing another 2,000+ words explaining more. I hope you enjoyed reading and learning a bit with me. Rust is an enjoyable experience if you give it a try.