A Whirlwind Tour of Rust's Core Concepts
This article will take you from zero to Rust in under 10 minutes. Instead of focusing on just a few concepts, we will go through numerous Rust snippets to explain the meaning behind their keywords and symbols. This will be a fast-paced guide. After you have installed Rust from rustup.rs, you are ready to begin.
Understanding Variables and Basic Types
let
introduces a variable binding. This can be written as a single line. i32
is a signed 32-bit integer. You can specify the variable's type explicitly after a colon, which is called a type annotation.
let x: i32 = 1;
Behind the scenes, languages such as Python hide the implementation from you and quietly promote the length of the integer at runtime when needed. This is clever but inefficient when you know how big your numbers are going to be. Rust, of course, has crates that provide this dynamic behavior if you need it.
Note on Integer Types:
How do you choose which integer type to use in Rust? In general, if you just need to represent some number, use an i32
. 32 bits has a wide enough range that it's relatively unlikely to overflow, and signed numbers have fewer surprises than unsigned numbers. i32
is equivalent to a long
in C or an int
in Java. If you use a numeric literal and don't explicitly annotate the type, Rust will default to i32
.
If you declare a name and initialize it later, the compiler will prevent you from using it before it's initialized. While this sounds obvious, C crashes at runtime if you attempt to do this.
The underscore (_
) is a special name, or rather a lack of a name. It basically means to throw away something and not warn about it not being used. This pattern of strict defaults with escape hatches to not be overly annoying is one you will see a lot with Rust.
let _ = 42; // I don't care about this value
Working with Tuples
Rust has tuples, which you can think of as fixed-length collections of values of different types. Note that the Rust compiler can nearly always infer the types that you are using, and only rarely do you need to clarify ambiguous cases. This helps prevent errors where you might think you're working with one type of variable when it's actually another.
Tuples can be destructured when doing an assignment, which means they're broken down into their individual fields. This is especially useful when a function returns a tuple.
let pair = ('a', 17);
pair.0; // 'a'
pair.1; // 17
// When destructuring, you can ignore parts of it
let (first, _) = pair;
When destructuring a tuple, an underscore can be used to throw away part of it. The semicolon marks the end of a statement; unlike in other languages, semicolons are not just mandatory whitespace.
Functions, Blocks, and Expressions
In Rust, like in Lisp, nearly everything is an expression. fn
declares a function. Below, we have a void function (a function that returns nothing) and a function that returns an integer. The arrow (->
) indicates a function's return type.
fn a_void_function() {
// This function returns nothing
}
fn a_function_that_returns_an_integer() -> i32 {
5 // The return value
}
A pair of brackets declares a block, which has its own scope, sort of like an immediately-invoked function in JavaScript. This interior variable x
only lives as long as the block does and does not modify the external x
.
Blocks are also expressions, which means they evaluate to a value. If you've written Lisp or Ruby, this should start to feel very familiar. Inside a block, there can be multiple statements. We call the final expression of a block the "tail," which is what the whole block will evaluate to. These are equivalent:
// Example 1
let x = {
let y = 1;
y + 1
}; // x is 2
// Example 2
let x = 2;
This is why omitting the semicolon at the end of a function is the same as returning a value. if
conditionals are also expressions. A match
is also an expression, not a statement.
Accessing Values and Namespaces
Dots (.
) are typically used to access fields of a value or call a method on a value, just like in most C-style languages. The double colon (::
) is similar but operates on namespaces. This distinction provides great clarity between using a property or a namespace.
In the following example, std
is a crate (a library), cmp
is a module (a source file), and min
is a function.
std::cmp::min(a, b);
The use
directive can bring names from other namespaces into scope. Rust has strict scoping rules; if you don't see it in your source code, it's not available. Types are namespaces too, and methods can be called as regular functions. str
is a primitive type, but many non-primitive types are also in scope by default.
The Power of Structs and Methods
Structs are the backbone of Rust's excellent, rich type system. Think of structs as lightweight new types encapsulating the valid states of your system.
Match arms are patterns. A match
has to be exhaustive—at least one arm needs to match—and an underscore can be used as a catch-all pattern. In addition to primitive integer matches, you can also match deeply nested data and destructure it for ease of use.
You can declare methods on your own types. Here, we're adding an is_positive
method to our new Number
struct, and then we can use our new methods like usual.
struct Number {
value: i32,
}
impl Number {
fn is_positive(&self) -> bool {
self.value > 0
}
}
Variable bindings are immutable by default, which means their interior can't be mutated, and they also cannot be reassigned. Those of you coming from functional languages like Haskell will be very pleased to see immutability by default, rather than in other C-like languages which typically have immutability added on later through keywords like const
. While Rust isn't strictly a functional language, it is clear that the language design has balanced practicality with purity from the functional world.
Leveraging Generics
Functions can be generic. They can have multiple type parameters, which can then be used in the function's declaration and its body instead of concrete types. Think of them like a template string. Note that in this case, both a
and b
must be of the same type T
.
The standard library type Vec
, which is a heap-allocated array, is generic. v1
is a vector of integers, and v2
is a vector of booleans. Behind the scenes, vectors use an array and swap it out for a larger array at runtime when it reaches full capacity.
Speaking of Vec
, it comes with a macro that gives us more or less Vec
literals. v1
is a vector of integers (i32
), as usual, and v2
is a vector of booleans. All of these invoke a macro. You can recognize them by the bang (!
) at the end of the name. In fact, println!
is a macro.
Robust Error Handling
panic!
is also a macro. It violently stops execution with an error message and the file name and line number of the error. Some methods also panic. For example, the Option
type can contain something or it can contain nothing. If unwrap
is called on it and it contains nothing, it panics.
Option
is not a struct; it's an enum with two variants. Note the generic type parameter: an Option::Some
can be of any type. Enum's variants can be used in patterns.
enum Option<T> {
None,
Some(T),
}
Result
is also an enum. It can either contain something or an error. It also panics when unwrapped and containing an error. Functions that can fail typically return a Result
. For instance, when creating a UTF-8 string from bytes, not all bytes represent a valid string.
In the first example, we can see that s1
is the Ok
variant of the Result
enum, but s2
is the Err
variant. This pattern of errors as values keeps us in the functional world, where other languages would have exceptions which break us out.
let s1 = std::str::from_utf8(&[240, 159, 146, 150]); // Ok("💖")
let s2 = std::str::from_utf8(&[195, 40]); // Err(...)
If you want to panic in case of failure, you can unwrap
. Or you can use expect
for a custom error message. It's called expect
because it telegraphs to people reading both the code and the errors what you were expecting when you unwrapped the result.
You can also match
and handle the error, or use if let
to safely destructure the inner value if it is Ok
. Alternatively, you can bubble up the error, returning it to the calling function, which then handles it.
This pattern of unwrapping the value inside a Result
if it's Ok
or returning it if it's an Err
is so common that Rust has dedicated syntax to do it. The question mark operator (?
) at the end of a line does the exact same thing as a larger match
statement. This is the normal Rust error pattern in application code where you're trying to just write the happy path, though the previous options are available to you when you need them.
Powerful and Lazy Iterators
One of the most powerful features in Rust is its iterator system. Consider an iterator that represents all natural numbers from one to infinity. This is possible to store in RAM because iterators are computed lazily on demand.
let all_numbers = 1..; // Represents 1, 2, 3, ...
This iterator notation is called a range. The most basic iterators are ranges; they can be open at the bottom or top, or you can specify both ends exactly. Computation only happens when the iterator is called.
Anything that is iterable can be used in a for
loop. We've just seen a range being used, but it also works with a Vec
, a slice, or an actual iterator. Note that string literals also have a .bytes()
iterator if you want the raw bytes. Rust's char
type is a Unicode scalar value that is always a valid character.
You can use an iterator in a for
loop even if the iterator items are filtered, mapped, and flattened. This fluent interface pattern is found everywhere in Rust.
A New Way of Thinking
Instead of classes and mutating shared data, modeling your program state as structs and then writing functions to move between these valid states makes invalid states unrepresentable.
Writing Rust is a very different experience from reading it. On one hand, it's more difficult; you're not just reading a solution to a problem, you're actively solving it. But on the other hand, the Rust compiler helps out a lot. The compiler always has very good error messages and insightful suggestions. And when there's a hint missing, the compiler team is not afraid to add it.
Join the 10xdev Community
Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.