What Rust Is All About: A Small Tour

In this post, we'll try to give you a taste of Rust's key features so you may get a feel for the language. We'll touch on the type system, some handy functional features, traits, and more!

Introduction#

Rust is a systems programming language that was conceived at Mozilla and is now developed by a larger community. Initially, its purpose was to help rewrite portions of Firefox's codebase (largely C/C++) to improve its safety and security. It has since enjoyed growing adoption in fields such as

  • systems programming,

  • embedded,

  • WebAssembly,

  • blockchain,

  • web servers,

  • networking, and

  • command-line utilities.

Rust is a statically typed language that emphasizes static code analysis, helpful compilation errors, convenient and comprehensive tooling, memory and concurrency safety, sheer performance, and abstractions that incur no (or little) runtime cost. Its memory model avoids the overhead of a garbage collector, but at the same time, the language's compiler enforces a set of rules that effectively prevent memory errors common in C/C++ applications.

What you will learn#

This is not a comprehensive "learn Rust in one hour" guide. It should, however, give you a little taste of what the language is about and help you decide if it's for you! We will guide you through some of the key concepts you'll inevitably stumble into in Rust code, including custom types, error handling, ownership, closures, higher-order functions, iterators, pattern matching, and traits.

Prerequisites#

This guide is intended for people with some prior high-level programming experience, though which particular language(s) you've previously worked with shouldn't overly matter.

If you'd like to give the snippets a run, the easiest way is to use the Rust Playground online. If you prefer to run things locally, the Getting Started chapter from the Book will help you get your environment and a "Hello, World" project set up!

Safety and Reliability#

Extensive Type system#

A comprehensive static type system means safer code and fewer manual tests that need to be written. The more information you can encode in the type system, the more Rust can check for free during compilation, allowing greater confidence in the correctness and reliability of your programs!

Rust has structs. Structs have fields.

Rust also has enums for when a thing can be either this or that. Enums have variants. An enum value can only be set to one variant.

Enum variants can hold data.

Both structs and enums can be generic and accept arbitrary concrete types inside. This is possible by declaring type variables in angle brackets (<>).

In the example above, the compiler will deduce that foo is of type Wrapper<f64>. Wrapper<T> is not a dynamic type! It's resolved during compilation.

Oh, and by the way, the let statement is how you declare a local variable. It accepts type annotations, but most of the time they're not necessary - Rust might be a disciplined and strongly-typed language, but it's fairly smart and able to infer types itself a lot of the time.

You can add methods to any type you define using impl blocks.

println! allows us to print stuff to the terminal. The {} token allows us to insert some dynamic values, in this case, self.x and self.y.

Error handling made simple#

Rust doesn't have anything like a try...catch statement. Instead, there is the Result<T, E> type, defined more or less like so:

Two types should be provided here. T is the type of the actual return value. E is the type of error data.

A function that may fail should return a Result - like the one below that attempts to sum a vector, but fails if it's empty.

The unit type () is often used to signify nothing is returned. In this case, we're basically saying we don't want to add any extra error information if the function fails - we just want to know that it failed. In "real" Rust code, it's generally a good idea to provide an error type that implements the std::error::Error trait, but that's outside of our scope right now.

If we want to use our my_sum function now, we cannot ignore the fact it might produce an error. The snippet below won't work.

This doesn't compile.

error[E0369]: cannot add {float} to std::result::Result<f64, ()>

The compiler tells us the result of summing is really a Result, and so cannot be added to a float. First, we need to unpack the value inside the Result and handle the potential error.

One verbose way to handle the error (by crashing the whole application) looks like this:

This match statement isn't some special construct for error-handling. It can be used for pattern-matching against any enum. We'll talk about it later. This is the power of Rust's approach to error-handling: there's no magic. You get to use the same control flow as you would for the rest of your code.

There's of course shorthand for the above:

Another strong point for Rust is that even if you don't use the Ok value (sometimes there is none), the compiler will still nudge you about having unchecked results. Forgetting about potential problems isn't easy.

No null values#

Unless we go into unsafe hackery, Rust doesn't have null values. Let's define a struct Foo.

Any initialized variable of the type Foo must hold a valid Foo with a valid f64 number inside. Thanks to that, if you write a function that accepts Foo, you don't have to worry about handling a potentially null value. The compiler will make sure only valid, initialized data gets passed to the function.

What if we want to express that a variable can hold nothing? This is one place where generic enums come in handy. The standard library defines this Option type:

If we wrap our Foo in an Option, we can express that we might have a Foo or nothing.

Ownership#

Every computer program has to manage the memory used for its data. When some data is no longer used, the memory it occupied should be freed. After it is freed, the program should never attempt to access that data again, or face bugs.

Most modern languages use garbage collectors. Thanks to those, this de-allocation and proper use are not something the programmer needs to be concerned with. This, however, introduces some performance overhead.

Unlike most high-level languages, Rust doesn't have a garbage collector. Instead, there's the model of ownership and borrow checking.

Ownership is a key concept in Rust. The idea is that, at any time, every bit of data in memory has exactly one owner. When that owner goes out of scope, the data is tossed out.

It is also possible to create references to owned data. These do not affect how long the data lives, but for your program to compile, the compiler has to ascertain that the references aren't used after the owner goes out of scope and the data is no more.

If you try to compile the above snippet, you'll get this:

error[E0597]: foo does not live long enough

Try to comment out the last println! statement and run the snippet then!

By default, a reference is immutable. To create a mutable reference, you have to add the mut keyword like so:

The first mut keyword allows mutation to the owned data at all. The &mut foo creates a mutable reference, which is then stored in foo_ref. *foo_ref dereferences the reference to get access to the underlying data and modify it through assignment.

You can have many immutable references, but you can only have one mutable reference. While you hold a mutable reference, you cannot use the owner or have any other (mutable or not) reference. This is to prevent data races and mistakes.

All illegal use of references causes compilation errors and forces you to fix your code before it ever has a chance to see production!

Modern programming techniques#

One amazing thing about Rust is it brings some high-level abstractions inspired by cutting edge languages to the world of systems programming.

Closures and higher-order functions#

Rust supports closures - anonymous functions capable of dynamically capturing the variables from the encompassing scope. These can be treated like any other value and assigned to variables. Those variables can then be called the same way regular functions can.

Notice that num_printer prints two numbers, but they're passed through different mechanisms. The x argument has to be provided when calling num_printer, just like with named function. num_to_capture is implicitly captured by num_printer when the function is defined.

This becomes much more interesting when combined with higher-order functions - functions that accept other functions as arguments and/or return a function. Say a third-party library defines a function called perform_calculations and allows you to extend the behavior of it by providing a callback. This is your chance to get partial results printed for the user currently logged in.

Iterators and functional niceties#

Iterators are a major feature of Rust. They can be very convenient.

Ranges of numbers are iterators. Iterators can be iterated on in a for loop.

A borrowing iterator can be created for any collection, e.g. an array.

It's possible to turn your own custom types into iterators by implementing the Iterator trait, too. An example can be found here!

There's one more way to execute something on every element of an iterator.

What if we want to reverse an array, add an m to every element, and then collect it into a vector of strings?

What if we want to get the product of numbers from 1 to 5 (not inclusive)? fold lets us apply a function to the first and second elements, then to the result and the third element, and so on.

Hopefully, this gives you a taste of the power of iterators. They're not just for looping over them; they're a convenient and powerful tool for transforming data. The best way to learn about all those methods is to browse the Iterator documentation. That's also where you can learn to implement custom iterators - all you need to do is implement the trait on your own type.

Pattern matching#

Rust allows pattern matching and all the de-structuring goodness associated with it. An underscore (_) is used as a catch-all - it matches anything.

Tuples can be de-structured while we're pattern-matching against them. Underscores can also be used inside those patterns. Here's an implementation of Fizz buzz:

Enums can be neatly de-structured too. Instead of matching specific values inside of structures, they can be captured in a variable.

Traits#

Traits essentially declare some functionality that concrete types may provide. Traits can be compared to interfaces in other languages or (more accurately) to typeclasses in Haskell. They are Rust's solution to the problem of code reuse. They can pretty much act as abstract types, too.

One important property that traits have and interfaces in other languages do not is that traits can be implemented for foreign types - even built-in primitives. It's entirely possible to extend f64 with your own, custom trait.

One example of a useful trait is the Iterator one. It's fairly straightforward to create a custom iterator. The one below returns positive integers in order, ad infinitum.

After we have one defined, we can use it like any other iterator, including all of those convenience functions mentioned earlier. They are provided "for free" for anything that implements the Iterator trait.

Operator overloading with traits#

Since many operators are either implemented using traits or defer to a trait when faced with custom types, operator overloading is possible by implementing those traits. It's usually a reasonably straightforward endeavor. Here's how addition can be implemented.

#[derive(Debug)] is how we call a derive macro. Don't worry about it for now. All you need to know is that in this case, it enables us to print a Foo value to the terminal by constructing a debug representation of it.

Trait bounds#

Trait bounds are what make generic programming powerful. Previously, we mentioned type variables. It's possible to declare those for functions, too.

There's not much we can safely do with a value of an unknown type, though. What if we knew something more about it? What if we knew what traits it implements?

Here we define the function print_it generically for any value that implements the Display trait and can therefore be printed as a user-friendly string. Thanks to the trait bound, we know we can safely print whatever type we get.

It's important to stress that this isn't dynamic - the concrete types are inferred during compilation. When you call print_it(123.3), the compiler (rather than the runtime) will check that f64 implements the Display trait, and then essentially create a function from the generic print_it definition specifically for the f64 type.

What does it matter if this check is performed during compilation or runtime? If we ensure the correctness of types we pass around during compilation, type errors never have a chance to make it to production. It's much easier to rely on this kind of mechanism than to write extensive tests for all the problems we may create.

There's some preferable syntax sugar for the generic print_it function. It helps us avoid thinking about type variables. Life is usually simpler that way!

Conclusion#

Whew! Hopefully, this helped you get a feel for Rust and shown it as the clever language it is. We've taken a glimpse of the type system, error handling, safety, functional patterns, pattern matching, and traits.

Again, this tour isn't comprehensive; we've only scratched the surface here. The path to understanding the language and writing idiomatic code is much longer. Every journey has to start somewhere, though. We can only hope this was a good start, and that it got you hungry for more.

Next steps#

Want to learn Rust properly? "The Book" is widely considered the best course; we cannot recommend it enough. You will learn everything we've mentioned here and more.

Some alternatives: