Introduction
Chances are that by the time you're reading this, you have heard of the Rust programming language. Its popularity is steadily growing and it's been voted the most loved programming language on stackoverflow every single year since it's first public release in 2015. But why is that? Supposedly there are several reasons, but in my opinion, the most important one is that Rust makes it easy (or at least easier than other languages) to write high quality code.
In this book, I'm going to try and explain what exactly Rust does to aid you in writing better code. There are many features, most aren't even unique to Rust, but it's the integration and combination in a single language which unlocks their full potential. Maybe this can be done even better than Rust did, but in my opinion, no language has done it so far. What I'm not going to explain are basic programming concepts, so you should ideally have some experience in another system programming language, but any other programming language should get you there most of the way, too.
Of course, the book is going to be colored by my experiences with other languages. There was some JS that I don't like personally, some C++ that was a mess and some C# which was okay, but also had some weird quirks. Some of the features of Rust even got adopted by at least one of these languages in recent years.
The book is written in a way that later chapters may build on top of information delivered in earlier chapters, so reading it top to bottom should provide the best experience. I will also try to avoid showing features that are going to be explained later. In critical cases, I may provide a link to an earlier chapter introducing a concept for you to read up on, if you want.
License
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
The type system
This chapter is pretty big, because it contains a lot of basics you may encounter further along the article. Rust may have a few interesting types not found in languages you know, and those may have some interesting properties. As such, it pays to learn about them before diving into the more advanced topics.
Rust is a statically typed language. That means that any element, be it variable, struct, field or other, has a single type which can't change during its lifetime. Now that is nothing special by itself, but programming languages are mostly divided into either group of statically or dynamically typed. Typically, statically typed languages map much closer to what is happening inside the hardware, since there's much less abstraction involved. This comes with advantages such as speed, runtime size and correctness, but forces you to think about your implementation more and results in more code for the same task. In my opinion, that is a trade-off one should always make, except maybe for prototyping.
But Rust goes one step further than most other languages and uses types as the general core feature most other features are built upon. You will see what I mean over the course of this article, but for now, let us take a look into the type system itself.
Primitive types
Firstly, there are the usual integer and floating point types. Instead of languages like C++, where you have to know how big an int
is (usually 32 bits) and use modifiers for additional types (an unsigned integer with a size of 128 bits is unsigned long long
), Rust directly incorporates those things inside the type names. This makes things clearer and is also useful for knowing how exactly a value is going to behave.
Of course, there's also a boolean type.
let unsigned_byte: u8 = 255;
let signed_byte: i8 = -128;
let unsigned_short: u16 = 65535;
let signed_short: i16 = -32767;
let unsigned_int: u32 = 4_294_967_295;
let signed_int: i32 = -2_147_483_648;
let unsigned_long: u64 = 18_446_744_073_709_551_615;
let signed_long: i64 = -9_223_372_036_854_775_808;
let unsigned_long_long: u128 = 340_282_366_920_938_463_463_374_607_431_768_211_455;
let signed_long_long: i128 = -170_141_183_460_469_231_731_687_303_715_884_105_728;
let float: f32 = 3.141_592_74;
let double: f64 = 3.141_592_653_589_793_1;
let boolean: bool = false;
Additionally, there are the usize
and isize
integer types. These correspond to the native size of the CPU. Additionally, usize
is used to index arrays and memory, but more on that later.
Now there is the char
type. People that know C++ might assume it to be an 8-bit integer type and try to use it as byte storage. In Rust, the tool for that particular job is u8
, as chars are 32 bits wide. That is because Rust actually natively supports unicode!
But isn't 32 bits for every single character pretty wasteful? It is, but chars are only really used when processing them one by one. There is a special type for strings, but more on that later.
References, arrays and the like
References
Rust has two types of references: "Normal" references that are managed by the compiler, and pointers that need to be managed by the programmer. Typically, you'll only see pointers around unsafe
code, as that is needed to work with them. In normal application development, you should rarely see unsafe code, if ever. However, it might make things easier (or possible at all) when going really low-level, like implementing fast, basic containers or doing embedded computing. Regular references are a major component of Rust's ownership system, but more on that in its own chapter.
Arrays
Like in other system programming languages, arrays in Rust have a static size. This greatly limits their use cases, but comes with its own advantages. Firstly, it allows for much better optimization, secondly, it can also convey intent when reading others' code. As you might have guessed, arrays with dynamic size are handled by the standard library. However, this is not the end of the story: Enter slices.
Slices
Technically, slices are pointers into arrays that also define the length. Conceptually, slices are views into an array (or a part of it), with no regard for it being statically or dynamically sized. They look like an array without a length parameter.
This brings about some guarantees: A slice points to one single block of continuous memory. As such, nothing can exist between elements and performance is very high when working with them.
As for creating a slice, you basically index an array with a range. A range can be open (grabbing the whole array), half-open or closed. A range is also including its lower index, but excluding its upper one, unless you add a =
after the range operator, yielding ..=
.
With these three elements, it's time for some more example code.
#![allow(unused)] fn main() { let answer: usize = 42; // Reference to the variable let my_reference: &usize = &answer; println!("{}", answer); println!("{}", *my_reference); println!(); let my_array: [usize; 8] = [42; 8]; // Copying initializer let other_array: [usize; 8] = [0, 1, 2, 3, 4, 5, 6, 7]; for number in my_array { print!("{} ", number); } println!(); println!(); for number in other_array { print!("{} ", number); } println!(); println!(); // Extra prints are hidden to make the code clearer // Can you guess all outputs? let element: usize = my_array[3]; println!("{}", element); // 42 let element_reference: &usize = &my_array[3]; println!("{}", element_reference); // 42 let array_reference: &[usize; 8] = &my_array; // 42 42 42 42 42 42 42 42 for number in array_reference { print!("{} ", number); } println!(); let whole_array_slice: &[usize] = &other_array[..]; // 0 1 2 3 4 5 6 7 for number in whole_array_slice { print!("{} ", number); } println!(); let element_from_slice: usize = whole_array_slice[3]; println!("{}", element_from_slice); // 3 let another_whole_array: &[usize] = &other_array[0..8]; // 0 1 2 3 4 5 6 7 for number in another_whole_array { print!("{} ", number); } println!(); let array_front: &[usize] = &other_array[..=4]; // 0 1 2 3 4 for number in array_front { print!("{} ", number); } println!(); let array_back: &[usize] = &other_array[4..]; // 4 5 6 7 for number in array_back { print!("{} ", number); } println!(); // Attention, it's relative to the slice, not the array! let element_from_back: usize = array_back[1]; println!("{}", element_from_back); // 5 }
One thing to note here: Rust checks indices for being out of bounds, so instead or accessing invalid memory, the program will panic. For arrays and constant indices, Rust will even check the bounds at compile time. The amount of errors this prevents cannot be overstated.
You can skip these checks in highly optimized code if you really want to, but then you are responsible for it never accessing invalid memory, so of course that's unsafe
. For regular code, correctness far outweighs the potential performance gain of not checking bounds.
Coming back to chars, I promised to explain how strings are handled efficiently, even though a single char
is 32 bits already. There's a special slice called str
, using the UTF-8 codec to only save as many bytes as needed.
Naively indexing into it could lead to errors very fast since you can hit a byte in the middle of a symbol, so there are safe methods providing access to single chars, as well as converting the slice into a u8
slice for more general data processing.
Using str
slices is fine for constant strings, much like const char *
in C++, but as slices only provide a view into data and don't own it, you will work just as much with the owning String
struct from the standard library in most cases.
An upcoming chapter will deal with strings in even more detail.
Composite data types
You won't get too far without defining composite types to strap data together (unless you like pain). I suppose most programmers are used to classes, most of them may also know enums. Rust also has those, but with some twists.
Tuples
Tuples are the most basic composite data type. They don't have names and consist of fields of independent types, only discerned by their position. They are useful if you want to bundle data in a single context, since tuples are defined in-place. Most notable, they allow you to return multiple values from functions.
let tuple: (usize, &str, usize, &str) = (42, "answer", 69, "fun");
Structs
Structs in Rust refer to all named composite types with a single variant. They come in three forms:
- No fields at all, so they can't carry data. They can be used for polymorphism or as parameters for generics, but more on that in their respective chapters. They are zero-sized, so they don't occupy any memory.
- Unnamed fields, only identifiable by their position. Field access works exactly like with a tuple, only that structs provide a name for the type. A struct used to wrap another value is typically implemented by using a single unnamed field.
- Named fields. This behaves like structs or classes in the languages you probably already know.
struct NoFields;
struct UnnamedFields(usize, bool);
struct NamedFields {
some_field: usize,
another_field: bool
}
Enums
As it is the case with structs, enums are also much more flexible than their equivalents in many other languages. They essentially behave like structs with multiple variants, but more on that in its own chapter.
Special types
Unit
A tuple with zero elements is called unit type, which acts as a no-type similar to void
in C++.
Without returning a value, a function will return an unit type. On the other side, a function with no return signature will default to the unit type, as well.
// All valid and equivalent
fn one_function() {}
fn other_function() -> () {
let unused: usize = 0;
}
fn another_function() {
return ();
}
Never
At the date of writing, this type can only be used explicitly in the nightly version of Rust. It denotes the return type of a function that will just never return. The prime example of this is the panic!
macro. In a code block that panics, it doesn't make sense to make up some garbage data, only to satisfy the function signature. Thus, returning a value after encountering a never
value is optional and doesn't have any effect.
fn can_panic(will_panic: bool) -> bool {
if will_panic {
// Returning after here wouldn't be reachable and thus doesn't make sense
panic!();
} else {
return false;
}
}
Attaching code to types
If you want to add behaviour or other associated code to your types, you can do so by writing an impl
block on that type. Such a block can contain associated constants, types and functions. There can also be multiple impl
blocks for the same type, for example in different files.
Methods that are called on an object instance are written as a regular function, but take some form of self
as their first parameter, meaning they need an object instance to be called. In contrast, functions without a self
parameter can only be called on the type, not on an instance.
Object methods can also be called like a normal associated function, but need an object instance for their first parameter in that case, similar to how C++ handles things.
Additionally to the self
keyword, which translates to this
in many other languages, there is also a Self
keyword with a capitalized S. This doesn't refer to an object instance, but rather to the type which is implemented in the current impl
block. It can be used instead of the explicit type to make the relation more clear.
For example, a constructor typically is a stock standard function that does not use a self
parameter, but returns a Self
object.
In contrast, self
can't be replaced by anything else and fields or methods of the objects can only be accessed explicitly through it. For people who don't have all fields and method names in their head, this makes it very easy to identify those elements. It also helps to discern them from external constants or functions and local variables.
struct Rectangle { width: usize, height: usize, } impl Rectangle { const MAX_HEIGHT: usize = 42; // Notice the capitalized 'Self' as type const EXAMPLE: Self = Self { width: 2, height: 1, }; // Default constructor, no special case in the language pub fn new() -> Self { return Self::EXAMPLE; } pub fn associated_function() -> usize { return Self::MAX_HEIGHT; } // Non-capitalized 'self' as object instance pub fn object_method(self) -> usize { return self.width; } } fn example() { let object: Rectangle = Rectangle::new(); let another_object: Rectangle = Rectangle::new(); println!("{}", Rectangle::associated_function()); println!("{}", object.object_method()); // Calling a method as associated function works, too! println!("{}", Rectangle::object_method(another_object)); } fn main() { example(); }
Now traits make implementations much more flexible still, but more on that, you guessed it, in its own chapter.
Type inference
Now many of those examples don't look too nice, do they? For instance, why does Rust use this weird syntax for defining the type, why not just put it in front of the variable name, like C++ does? Also, most of that code looks pretty verbose, some types also look kind of complicated, do I really need to write all those? You don't, actually!
Of course, constants, function definitions and such still need to be typed. Apart from that, the compiler is able to infer the type of variables to a pretty large degree. Let's take the example code from the slice section from before and remove the unneeded type definitions.
#![allow(unused)] fn main() { let answer = 42; // Defaults to i32 // Reference to the variable let my_reference = &answer; println!("{}", answer); println!("{}", *my_reference); println!(); // Enough to infer type in all dependent variables // Technically isn't needed because i32 works fine here let my_array: [usize; 8] = [42; 8]; let other_array = [0, 1, 2, 3, 4, 5, 6, 7]; for number in my_array { print!("{} ", number); } println!(); println!(); for number in other_array { print!("{} ", number); } println!(); println!(); // Extra prints are hidden to make the code clearer let element = my_array[3]; println!("{}", element); // 42 let element_reference = &my_array[3]; println!("{}", element_reference); // 42 let array_reference = &my_array; // 42 42 42 42 42 42 42 42 for number in array_reference { print!("{} ", number); } println!(); let whole_array_slice = &other_array[..]; // 0 1 2 3 4 5 6 7 for number in whole_array_slice { print!("{} ", number); } println!(); // Sets everything to do with other_array to use usize // Also could still just use the i32 default let element_from_slice: usize = whole_array_slice[3]; println!("{}", element_from_slice); // 3 let another_whole_array = &other_array[0..8]; // 0 1 2 3 4 5 6 7 for number in another_whole_array { print!("{} ", number); } println!(); let array_front = &other_array[..=4]; // 0 1 2 3 4 for number in array_front { print!("{} ", number); } println!(); let array_back = &other_array[4..]; // 4 5 6 7 for number in array_back { print!("{} ", number); } println!(); let element_from_back = array_back[1]; println!("{}", element_from_back); // 5 }
This leads to small functions not having any type definition inside the function body, because everything can be inferred by the return type or the parameter types.
Literal types are mostly inferred by the type of the element they are assigned to. However, you can also provide a typed literal and let it infer its type into variables, instead.
let typed_variable: usize = 42;
let inferred_variable = 42_usize;
Conversions
Rust is very strict on needing explicit conversions that can make a functional difference, but provides ample support for automatic dereferencing. You can't even add two integers of different types, you need an explicit conversion. This actually has a good reason: It makes sure the programmer knows exactly how the integers are added. For example, an 8-bit addition overflows much sooner than a 16-bit addition. Two integers do integer division in most other languages, even when assigned to a floating point target. Granted, you don't need to worry about those in most cases, but in edge cases, this is a significant feature for promoting correctness.
Arrays and slices are exclusively indexed by values of the usize
type. The reason is that ultimately, usize
covers the entire accessible memory pool while not hindering performance by being bigger than the hardware natively supports. Also, a memory address obviously can't be negative.
Primitive types can be converted to each other with the as
keyword. For things like interpreting the bits of a floating point value as an integer, methods are provided. For composite types, there are two traits handling conversion from one type to another, From
and Into
, but more on that, well, you know the drill by now.
#![allow(unused)] fn main() { let array = [0; 64]; let small_number: i16 = 42; let big_number: u64 = 69; let boolean = true; println!("{}", array[small_number as usize]); println!("{}", big_number + small_number as u64); println!("{}", boolean as usize); }
There are two other traits, Defer
and DeferMut
, that enable structs (manually) and most reference types (automatically) to transparently present the element they wrap around/refer to. This doesn't work if you need the value of an inner field, but it works for methods and fields on the inner one.
A good example would be the Box
struct which gives access to its inner value, String
which gives access to all the str
methods, or Vec
which also gives access to all slice
methods of the same element type.
struct MyStruct { pub data: usize } fn example() { let mut random_string = String::from("This sHouLd aLl bE lOweR cAsE!"); let mut value = 69; let my_struct = MyStruct { data: 42 }; let variable_reference = &mut value; let struct_reference = &my_struct; // Needs manual dereferencing *variable_reference += 1; // Calling str method on a String random_string.make_ascii_lowercase(); println!("{}", random_string); // Special case because the Display trait // is also implemented on references println!("{}", variable_reference); // Does not need manual dereferencing println!("{}", struct_reference.data); } fn main() { example(); }
Last but not least, the ?
operator can be used to escalate errors through a function. It can implicitly convert an error into a more general trait object and even box it as needed. However, as you probably already guessed, more in its own chapter.
Unicode
It may be understandable for really old languages having the problem of no or bad unicode support, and I think most modern ones did solve it. But it seems that in many cases, there is still lots trouble with handling text that isn't encoded as ASCII (or some other older standard) all over the world. Of course it is a solved problem in Rust, so I wanted to include it. So, here's how Rust solves that problem and why it has an additional primitive type for strings, even though they technically are collections.
If you skipped trough the book, you might have missed the general properties of the char
and str
types, so have some links to the char
explanation and the str
explanation.
There's a lot of functionality to correctly interface with the provided types. You can get a single character when indexing into a str
, but you have to be careful: Indexing into the middle of an UTF-8 character will result in a panic. The type provides a few safer alternative ways, for example using an iterator over every UTF-8 character, yielding char
s.
If you still want to directly index it, you can check the safety of doing so by using the is_char_boundary
method for an index.
You can convert both the char
and str
primitives to a few different formats. If you don't want to print a string, but rather just transport it, you can just use byte arrays. Since char
isn't used for this purpose as in C(++), those use the u8
type.
You can also encode from and decode to UTF-16. In this case, a "wide char" buffer is used, which translates to u16
in Rust.
When encoding from raw data, you can choose to either use a conversion method that returns a Result
depending on if the data has a valid format (see the error handling chapter), or a lossy function which replaces all invalid characters with a special one.
You still might occasionally see it online when conversion between encoding formats failed somewhere: It's the "�" unicode character.
While we're pretty much completely in unicode land in Rust, there might still be some things you want to do the old ASCII way. For example, converting to upper or lower case only reliably works with ASCII characters, else you would need tons and tons of conversion rules, which is out of scope for the core and standard libraries. Also, if you know that your input is ASCII-formatted, there's a few more things you can do. The Rust types provide a wealth of methods for those purposes:
- Check if a string is ASCII-formatted with
is_ascii
. - Convert a string to upper and lower case with
make_ascii_lowercase
and its opposite. - Case-insensitive comparison between strings using
eq_ignore_ascii_case
- Special ASCII-variants for various checks, for example if a character is alphanumeric.
The scope of things
When your project gets larger than a couple of code lines, you obviously have to think about architecture if you want to keep your code easy to maintain and extend. Most importantly, you want to protect the code and data in one part of your project from being misused by another part. There's more to it than just architecture, but it does play a big role. The software architecture can make or break a big project.
Evaluation of expressions
Expressions with a scope (curly brackets) evaluate to the last expression inside it. That includes simple scopes, functions, but also if
and match
statements.
This also explains the meaning of the semicolon as the statement terminator. It is used to terminate assignments, but also converts expressions into statements, converting the evaluated type to the unit
type. There is one exception though, as the never
type isn't converted by the terminator.
Now why would you want it to work that way? Well firstly, it's still relatively easy to find the return expression without the return
keyword, as it's always the last expression inside a scope and therefore is at the bottom of it.
On the upside, it heavily promotes clear control flow, as this only works when the control flow actually returns at the bottom. When you encounter a return
keyword, it's a signal to the programmer that it's an early return, so you should exercise more awareness and caution.
Speaking of which, the return
statement is special in that it evaluates into the never
type inside a function, so anything coming after it will not be executed and have no effect on the program.
I did say that if
and match
statements have this behaviour, too. They evaluate to the last statement in the taken arm. To enable this, they need each arm to evaluate to the same type, of course. An if
statement needs an else
block, so there's always a return value.
Funnily enough, because a whole block evaluates to a single value, they can be used anywhere where expressions are allowed. This includes field and variable assignments.
Now of course, we want to know how that looks in practice. Have at it:
// Return the result of the if statement
fn example1(condition: bool) -> usize {
if condition {
42
} else {
69
}
}
fn example2(condition: bool) -> usize {
let return_value = if condition {
42
} else {
69
};
// Just return the variable
return_value
}
Modules
As the main unit of organization, a module can contain definitions of almost any kind, as well as other modules. It's also a way to gate access to items from the outside, but more on that later. One very important thing is how that plays together with having multiple source files. Each file in itself is a module already!
You add a submodule by writing a mod
statement inside the module you want to add it to. It comes on two flavors, either by providing a block after the identifier and defining the module in-place in the same file, or by terminating the statement after the identifier and therefore signaling Rust that you want to import a source file as the submodule.
When importing another file as a submodule, there's a strict convention to follow: The identifier of the submodule corresponds to the file name of it, excluding the .rs
file extension. Additionally, it has to be inside a folder named like the identifier of your current module, with the folder being in the same directory as your current one.
For example, consider this directory structure:
src
outer_module
inner_module.rs
outer_module.rs
main.rs
The file main.rs
is a special file, it maps to the crate root if your crate is an application. For libraries, that file is called lib.rs
.
Now the module of the outer_module.rs
file maps to crate::outer_module
, while the module of inner_module.rs
maps to crate::outer_module::inner_module
.
There is one exception to the file structure rules. If you want a module containing submodules to look a bit tidier, instead of having a code file and a folder of the same name in the same directory, you can define your module in a mod.rs
file inside the module folder. It will get its identifier from the folder name. It looks like this:
src
outer_module
inner_module.rs
mod.rs
main.rs
This maps to the exact same logical module tree as the file structure above. Just be warned that if you do this, you might find yourself with a bunch of open files named mod.rs
at one point.
To access an item inside a module (if you are allowed to access it, more on that in a bit), you can always refer to it by its full logical path, like you have just seen. You can also use use
statements to bring a module, or items inside it, into scope.
Of course, this can create name conflicts, which would trigger a compiler error, so you can use the as
keyword to import an item with an alias name.
Idiomatic Rust code mostly only imports modules, so you can use the items by referring to them through the module they are contained in. This avoids identifier paths getting too long, but you can still clearly see the module it comes from.
Now for another example, consider this as the code in main.rs
:
// Needed to put module into module tree
mod outer_module;
// We're going to assume a 'SomeStruct' struct without fields
// inside 'inner_module'
// Full absolute path
use crate::outer_module::inner_module::SomeStruct as AbsoluteStruct;
// Relative path, could also use 'self' in front
use outer_module::inner_module::SomeStruct as RelativeStruct;
// Full path to module containing a struct
use crate::outer_module::inner_module;
fn main() {
let full = crate::outer_module::inner_module::SomeStruct;
let absolute = AbsoluteStruct;
let relative = RelativeStruct;
let idiomatic = inner_module::SomeStruct;
}
Now this example only works if inner_module
and SomeStruct
are declared public. More on that now.
Private by default
When you define a module, constant, type and so on, they are going to be private by default. As an effect, if you want code at some place outside of the module to use the code you are currently working on, you have to explicitly allow it. This alone can prevent heaps of messy code that doesn't scale well and will eventually fall apart.
For structs, the same rule applies with fields. Code outside of the module can't access private fields directly, only through public functions on the object. This is standard object oriented programming procedure. When all fields are public (or you have access to them anyway), you can create a new object of a struct through simply assigning all of its fields. Sometimes, you want to use structs to simply group data together. In this case, making all fields public is absolutely fine.
// You might have seen this in the first chapter already! struct MyStruct { pub data: usize } fn example() { let my_object = MyStruct { data: 42 }; println!("{}", my_object.data); } fn main() { example(); }
The Rust compiler also enforces a consistent privacy model, so if, for example, you would try to return an object of a private struct in a public function, you would receive a compiler error saying that the public function leaks a private type.
By now, you might have noticed that I explicitly wrote "outside of the module" a few times. This is because code in the same module, as well as in submodules, actually has private access. This is because Rust applies the same encapsulation rules as with code blocks: An inner scope, such as an if-block, has access to the variables of the scope it's defined in, but not the other way around. Declaring items public is the obvious exception to that rule, as it enables the export of items out of their scope.
This behaviour has a few interesting effects:
- Factoring out code into submodules becomes fairly easy, just encapsulate a part of your module, define an interface to access it from the outside, and you're done. The new module still has full access to the non-encapsulated parts. This enables fairly organic growth.
- Extending a module with something that needs private access becomes trivial. Most importantly, this enables unit testing in a separate submodule instead of relying on special functionality like
friend
classes in C++.
General project structure
Large software "monoliths" currently have a kind of bad reputation. Microservices are said to be the salvation from the unmanageable complexity that is the monolith. But what exactly are microservices doing better? They do have their advantages, for example:
- Independent scalability for different parts/services
- A crashing service crashes in isolation, leaving the others intact
- Ability to update parts independently
However, the main reason they are used is: They enforce interfaces between different services, meaning parts of the project. But they come with a number of disadvantages:
- Although the individual scope is smaller, you need more projects for the same work.
- You have to maintain network interfaces between your services.
- Moving a task over network multiple times incurs a higher latency for the user.
- The same amount of works needs more computational resources, sometimes significantly so.
- Since you operate a distributed system, you need advanced protocols for resistance agains parts failing and so on.
All in all, for the price of providing explicit boundaries, micro services introduce a significant amount of complexity, the very same thing that led to the bad reputation of monoliths in the first place. So in the common case, it is a no-solution.
So what to do instead? Think about your project structure!
A good project structure can grow organically, without sinking into chaos. Consider this: When any file grows too large, split it up into submodules. The original module then contains two kinds of code, common code to provide to the submodules, and interface code to tie them back together into one entity. The interface to other modules doesn't need to change at all. In fact, it also shouldn't. Don't directly link into anything other than the top module from outside!
With this, you have a two-way dependency: The submodules depend on the common code of their parent, while the interface code of the parent depends on the submodules. The interface code can also depend on the common code, but never the other way around, as that would create a cyclic dependency.
Please take note that different submodules of the same layer also should not directly depend on each other. If there is loose coupling, try to interface it through the higher level module tying them together. If there is tight coupling, it should not be in different submodules, anyway.
Code that is only loosely coupled to the main functionality of your project, such as generic collections, should be put into its own subtree and essentially treated like a seperate library. This code is also a prime candidate for moving into an actual separate crate.
Re-exports
When structuring your code in deeply nested modules, using different parts can become quite tedious, since you may have many different, pretty long logical paths to the code you want. Also, you sometimes might want to offer a different interface than the actual code structure provides. The prime example is factoring out code into submodules, where the access path seen from the outside changes. To that end, you can re-export an item back into your current module by just making a public use statement.
Let's assume the directory structure from the module example. If we want to make SomeStruct
available directly from the outer_module
, we just have to add a statement to that module:
// Doesn't need to be public if you don't want direct access
mod inner_module;
// Re-export
pub use inner_module::SomeStruct;
We can now refer to it directly from the outer module. The main file can use it like this:
mod outer_module;
fn example2() {
let re_exported = outer_module::SomeStruct;
}
Integrated documentation
Writing good documentation for your code is an integral part of software development in my opinion. It clashes a bit with the currently popular stance of good code documenting itself, and while it is true that good code can tell you what is happening, and also many times how it does things, it almost never tells you why it does things in a certain way. Also, I sure do hope those people also mean not documenting functions, constants or modules, because yes, you at least do want documentation for your API.
While many projects still document by writing documentation completely separate from the code it's documenting (or not at all), the current state of the art is putting documentation directly next to the referenced code. The reason is simple: Documentation away from the documented code has almost no chance of a developer looking at it while changing things, therefore being very likely to get outdated very quickly. When you have your documentation next to your code, it usally has some special annotation so some framework can grab and compile it into a more ergonomic representation. This is so that people wanting to read it don't have to dive into the actual code. Rust basically does the same, but with minimal annotation, some extra features and as part of the official Rust toolchain.
Normal comments in Rust are done just like in C, with //
being a one-line comment and /*
introducing a multi-line comment that gets terminated using */
.
If you want to comment the next item below for your documentation, just use ///
or /**
to introduce your comment. The terminator for multi-line comments stays the same.
Multiple documentation comments above the same item will be concatenated and will display as a single one inside your documentation.
Instead of the next item, you can also document the item enclosing your comment.
This is particularly useful with modules, since they usually have their own file and you probably want to have your documentation inside it, instead of it being where the module is linked into your crate.
To do so, use the //!
or /*!
syntax.
No matter which method you use, you should be aware that the text before the first empty line inside a documentation comment is used as the shorthand description you can see in lists and search results. Everything after that empty line is the full text and only visible from the actual page of the item.
Rust documentation comments to have a few tricks up their sleeve. Most importantly, they support markdown syntax
for formatting.
That means you can have headers, subheaders, tables, integrated images (you should host those somewhere else though), hyperlinks and much more.
Also, you can put example code blocks inside them, enabling full syntax highlighting in your documentation.
When the signature of your item references another item, it will automatically contain a link to the latter, even through crate dependencies.
In addition, when linking to items from inside of your comment, you can use the standard Rust path syntax to refer to them.
Finally, since documentation is an integral part of Rust, when uploading your crates to crates.io
, the documentation of each crate will automatically be compiled and uploaded to docs.rs
, with dependencies linked to their own online documentation and with a documentation link added to your uploaded crate.
To finish this chapter, let's have a look how a documented module could look like:
//! An example module.
//!
//! This module dellivers an example of how documentation inside the
//! module code could look like. It contains a [struct](self::MyStruct)
//! and a few functions.
/// An example struct without any fields.
///
/// This struct demonstrates the documentation of a type. It is used
/// as a parameter for [`answer`](self::answer).
pub struct MyStruct;
/// An example function.
pub fn answer(_example: &MyStruct) {}
Integrated tests
Good testing practice is something most companies and hobby programmers still don't really apply. The reason is pretty obvious: To save the work it would take. In a private context it's fine in the majority of cases, you mostly just don't have that high requirements for the correctness of your program. You might even use a dynamically typed and/or script language for that reason. On the other hand, companies often handle it that way out of shortsightedness. The extra work is "wasted" in that it directly contributes zero bucks to the operating income. Their value is invisible, right up to the point where something got changed and the software suddenly breaks, at which point tests can either tell what exactly broke, or more importantly, can prevent breakage at all.
Back to the "amount of work" thing, setting up testing can be really cumbersome in some languages. I recently helped a friend setting tests up in C#, we needed multiple hours because of a version difference since Visual Studio 2019 installed .NET at version 5.0, whereas the test framework used version 4.8. Using a testing framework and setting everything up for testing can be quite an amount of work and complicate a project even further, so having an easy way to test things is quite valuable in my opinion.
In Rust, testing is as easy as it gets. Simply write a function without parameters or return value, annotate it with #[test]
and you have created a unit test! It can be tested by the default toolchain supplied with Rust in practically all cases by using the command cargo test
.
It is recommended to put unit tests for a module into a submodule. As you might remember from the scope chapter, the module has access to private items of the module containing it. Also, you can annotate the module with #[cfg(test)]
, so it only gets compiled into your crate when you're testing.
struct MyStruct {
private_field: usize,
}
#[cfg(test)]
mod tests {
#[test]
fn a_test() {
// We have access to private items!
let value = MyStruct {
private_field: 42
};
assert_eq!(value.private_field, 42);
}
}
Now this was nice, but that was only about unit tests, what about integration tests? For that, you can create a tests
folder right next to the src
folder. Cargo will automatically interpret each program file inside the tests
folder as an integration test module and output its tests in a separate listing when executing them.
Those tests will see your crate as just as external as any dependency you're using inside it, so you only have access to your public API. Also, in order to have common code between integration test modules, you can create a subfolder inside the tests
directory and put a mod.rs
program file inside it.
This way, the file doesn't sit directly inside the tests
folder and thus isn't interpreted as an integration test module itself.
Instead, each integration test module will see it as a submodule of itself. Your project directory structure then may look like this in total:
src
some_module
inner_module.rs
some_module.rs
lib.rs
test
common
mod.rs
some_use_case.rs
another_use_case.rs
So we had unit tests and integration tests already, but there is another interesting thing about tests not mentioned yet. Remember that you can put example code inside your documentation? Well, this example code gets tested too! This is extremely valuable, since it not only can detect your normal crate code breaking, but also your documentation's. As it doesn't directly contribute to the crates's functionality, it is way easier to forget updating it when changing something inside the crate.
Immutability by default
I had a bit of trouble with that term in the beginning. Immutable basically means non-changeable, in other words, read-only. It's not precisely the same, but it's close enough for understanding. The general advantage of having read-only values is pretty clear: You can trust a block of code to not change a variable. From the other side, you can't randomly change values that shouldn't.
In my eyes, making this behaviour the default is a pretty smart thing, because programmers rarely put in the work to declare something read-only, even if it is. On the other hand, making something mutable explicitly tells you to expect the value to change.
For this, Rust uses the mut
keyword in front of the identifier. If you use it without mutating the value down the road, the compile will issue a warning. For immutable variables, Rust is extremely flexible with when to assign it and can even check multiple paths for exactly one assignment.
#![allow(unused)] fn main() { let answer; answer = 42; // works answer = 69; // doesn't work let fun; let having_fun = true; // MUST have an else block, otherwise not all paths // would provide exactly one assignment if having_fun { fun = 69; } else { fun = 0; } }
You may ask yourself: What do I need immutable variables for? What can I even do with them? Apart from actually writing algorithms (and not even always there), you'll find that you barely need mutable variables. In many cases, you either only need them as a function parameter, or you change the context by assigning the value to a new variable with a better fitting name.
Now that is all well and good, but the really interesting part starts with objects and functions. The mutability rule also applies to function parameters. If a function wants a mutable reference as a parameter, it's an explicit sign for you that the function will change the element.
// 'normal' function fn add_two_value(start: usize) -> usize { start + 2 } // function with a mutable reference fn add_two_reference(value: &mut usize) { *value += 2; } fn example() { let immutable = 42; let mut mutable = 69; let value_result = add_two_value(immutable); add_two_reference(&mut mutable); println!("{}", value_result); println!("{}", mutable); } fn main() { example(); }
For immutable objects, you can only call methods that don't need the object to be mutable. For example, getters work on them, while setters don't. Although mutability is part of the variable type, you can use mutable types in place for immutable types, too (but obviously not the other way around). You can use this to put mutable variables in an immutable context.
#![allow(unused)] fn main() { // immutable reference fn extract_len(my_vec: &Vec<usize>) -> usize { my_vec.push(42); // doesn't work my_vec.len() // works } fn example() { let mut my_vec = vec![0, 1, 2, 3]; // immutable context let len = extract_len(&my_vec); } }
There is one workaround for these rules that I see a bit critically. Rust supports shadowing, meaning defining a new variable with the same name as an existing one. This is no problem if you do it inside a new code block, but in the same block, you can actually use it to work around the immutability rule and change the value found under the identifier.
#![allow(unused)] fn main() { let having_fun = true; let answer = 1; let fun = 69; // This can trip you up let answer = 42; if having_fun { // Has no effect outside of this block let fun = 9001; } println!("{}", answer); println!("{}", fun); }
Patterns
You might already have seen the use of the let
keyword for declaring variables. Why use that instead of something like var
? Or the type of the variable?
Well, the type is already out because of type inference, as seen in its own chapter. The var
does imply declaring a variable, but let
does a bit more in Rust. It binds a pattern!
Irrefutable patterns
Irrefutable patterns work just like you'd expect from a variable declaration, but they not only allow single variables, but deconstruction from composite structures, too.
// Definition for below struct MyStruct { some_field: usize, another_field: usize } fn example() { // Simple variable let fun = 69; println!("{}", fun); println!(); // Tuple let tuple = (42, 69); let (first_element, second_element) = tuple; println!("{}", first_element); println!("{}", second_element); println!(); let demo_value = MyStruct { some_field: 42, another_field: 69 }; // Deconstructing a struct let MyStruct { some_field: into_variable, another_field: into_another_variable } = demo_value; println!("{}", into_variable); println!("{}", into_another_variable); } fn main() { example(); }
Now that is pretty convenient already, but this would force you to define a variable for each element inside the structure you want to deconstruct. Luckily, Rust patterns have several ways to make it more convenient if you don't need all those values.
// Definition for below struct MyStruct { some_field: usize, other_field: usize, another_field: usize } fn example() { // Tuple not capturing the middle element let (answer, _, leet) = (42, 69, 1337); println!("{}", answer); println!("{}", leet); println!(); let demo_value = MyStruct { some_field: 42, other_field: 69, another_field: 1337 }; // Struct not capturing one field and using shorthand on another let MyStruct { some_field: struct_answer, other_field, // capture into variable of the same name another_field: _ // do not capture this } = demo_value; println!("{}", struct_answer); println!("{}", other_field); println!(); // Struct only capturing a part of the fields let MyStruct { another_field, .. // don't capture other fields } = demo_value; println!("{}", another_field); } fn main() { example(); }
One question you might ask yourself is "So okay, these are patterns, but why are they called irrefutable? This means there also must be refutable ones, right?". And you are correct, there are refutable patterns!
Refutable patterns
Refutable patterns don't only provide variable names to bind data to, but also constraints to match against. They can be used as guard into a code block (then called arm) and bound variables are only available inside that block, living in its scope. There is a catch, however: The constraints have to be static, since the equivalent code is generated at compile time. Also, using variable identifiers in pattern constraints would make it extremely easy to confuse with variable binding.
Matching against refutable patterns is extremely flexible otherwise.
You can match refutable patterns either by using if let
for one arm, with an optional else
if the constraint isn't fulfilled (no variables will be bound in this case), or a match
statement for checking multiple arms.
You can also combine multiple patterns to check the same arm for each, though only variable names present in every of those patterns will be bound. If you want to bind a value to a variable while also using a constraint on it, you can do so using the @
symbol.
That was all very dry, wasn't it? Let's see how these patterns look in some example code.
struct Person { pub name: &'static str, pub age: usize, } fn example() { let henry = Person { name: "Henry", age: 42 }; if let Person { name, age: age @ 20..=67 } = henry { println!("{} is probably a working adult at age {}.", name, age); } else { println!("The person is most likely not a working adult."); } } fn example2() { let point = (42, 69); match point { (0, 0) => { println!("The point is on the origin of the coordinate system.") } (x, y @ 0) | (x @ 0, y) => { println!("The point is on one of the axes at [{}, {}].", x, y); } // irrefutable pattern as default case (x, y) => { println!("The point is at [{}, {}]", x, y); } } } fn main() { example(); example2(); }
You may be able to imagine the amount of possibilities on how to structure your control flow with refutable patterns. However, some instances can still look a little weird, especially when looking back at the example code just now. Although some examples with "normal" patterns can already look concise, like the coordinate example, the real power of pattern matching only becomes apparent when combined with enums.
Enums
I'm not gonna explain the general concept of enums, since you should already know that if you have a bit of programming experience. Rusts enums are special in the sense that they represent algebraic data types, like they exist in F#, OCaml, or Haskell.
This basically means that the variants of an enum can contain individual data fields, applying the same rules as for a struct, the only difference being that you are forced to check for a variant before using its fields. This makes enums extremely flexible and opens up of a couple of interesting possibilities.
For example, you can replace polymorphism with an enum in many cases, if you want to. You have to define all variants locally, so there is way more coupling, but the data is saved in-place under the hood, instead of requiring a reference to somewhere else. Since there is less indirection and more data locality in memory, this can improve performance. You also don't need a common interface or trait for the variants, so you gain additional flexibility, but more on that later.
Another example is a multi-typed argument. You can have some data available in different formats and instead of having a function for each type, you can make a function that accepts both types and handles them inside. The possibilities are nearly endless and we will revisit enums a fair amount of times in this article.
Of course, we can't conclude the chapter without showing some example code, so let's take a look at how the example that was just given can be implemented:
fn deserialize_binary(binary_data: Vec<u8>) -> Save { println!("{}", binary_data[0]); Save }
fn parse_text(text_data: String) -> Save { println!("{}", text_data); Save }
// Just for the function signature
struct Save;
enum SaveData {
Binary(Vec<u8>),
PlainText(String),
}
// Generates the enum
// Note that enum variants aren't independent types
fn generate_default_save() -> SaveData {
SaveData::PlainText(
String::from("BEGIN DATA\nEND DATA")
)
}
// Basically just wraps handling for different types
// in a single function
fn load_save(save_data: SaveData) -> Save {
match save_data {
SaveData::Binary(binary_data) => {
deserialize_binary(binary_data)
}
SaveData::PlainText(text_data) => {
parse_text(text_data)
}
}
}
Exhaustive Matching
This chapter covers a small, specific thing about match
statements. It warrants its own chapter anyways because it can have a big effect of the correctness of code using these statements.
Now what does 'exhaustive matching' mean? It means that every possible value that can put inside the statement must at least match one arm. The reason is that programmers sometimes tend to forget cases, leading to errors. With the extensive pattern matching of Rust, this problem would be magnified compared to other languages.
If you want to match all cases that don't need special treatment, you can use a irrefutable pattern for default handling.
If it doesn't need any handling at all, you can use the default pattern _
, which essentially is an irrefutable pattern that matches the entire value and doesn't bind anything.
But even if your default arm is empty, if you don't match all cases, it needs to exist. It's still a signal to the programmer that some variants exist that don't need handling there.
It can also be a convenience feature. Imagine having some enum and matching it in different places. Now if you don't use default matching, adding another variant to an enum will throw you one error for each match statement using it. As the error messages tell you where exactly it is, you can use them to find every single match statement and add the new variant to it. This can greatly reduce the amount or errors you produce in this case.
Although I don't think there's a great need to provide an example here, I would feel bad writing such a short chapter without any code, so here you go:
#![allow(unused)] fn main() { let value: usize = 42; // Missing 10..99! // Will not compile match value { 0..=10 => {} 99.. => {} } // Default handler, compiles match value { 1 => {} 2 => {} 3 => {} rest => {} } }
Traits
First off, Rust doesn't have all properties you would expect from a language that supports the object oriented paradigm, it doesn't have inheritance, to be exact. That's not tragic though, as many people nowadays prefer composition over inheritance, anyways. That basically means that instead of extending a base type with new data, you get a similar result by using your "base" type as a field inside your new one.
So types can't share data, but they can share behaviour. That is where traits come in. Although not entirely identical, most languages implement a similar concept as interfaces or abstract classes. They define an interface which types can implement and which code can use in an abstract manner. They get extremely flexible and powerful when combined with generics, but that's the topic of the next chapter. For now, we'll concentrate on basic things like polymorphism.
The extent of traits in Rust
Traits are a major and integral feature of Rust. Instead of hard-coding basic behaviour of types into the language itself, it uses the syntax to call trait methods in the vast majority of cases. Many of them also rely on generics, but as I'm not going into detail on any of the traits for now, just take a few for example:
- Adding two values is implemented in the
Add
trait, subtracting in theSub
trait, and so on. - Comparing two values for equality is implemented in the
PartialEq
andEq
traits. - Checking the ordering (greater-than etc) is done in the
PartialOrd
andOrd
traits. - The copy-constructor is implemented through the
Clone
trait. - Printing is done with the
Display
andDebug
traits. Additional formatting has extra traits, such asUpperHex
. - Converting between types uses the
From
andInto
traits. - Automatic dereferencing to inner types uses the
Deref
andDerefMut
traits.
This not only simplifies the language and puts some separation between syntax and functionality, but also allows you to integrate your custom types incredibly well with built in types, since the written code calls the exact same interfaces on them.
Additionally to explicit traits like this, there are also so-called auto traits that don't add functionality, but tell the language how to handle a type. Some more examples:
Sized
tells the language that the size of the type is known at compile time.Send
notes a type that is safe to send to another thread,Sync
means it's safe to share a reference to the object with other threads.Unpin
types can change their position in memory even after being pinned.- The
Copy
trait doesn't implement anything itself, but signals that a type doesn't need a deep copy for cloning.
Polymorphism
As Rust is pretty low level for a high level language, it pays to understand how polymorphism works under the hood. This is what this chapter is for.
Each type that implements a trait also implements a table with pointers to each implemented function from that trait, in a defined order.
This is called a virtual table and normally transparent to the programmer, but you could use this knowledge to implement polymorphism manually, if you wish so. Programmers of C-style languages may know the virtual
keyword, this is where its name comes from.
Now, a reference to a trait object actually includes two pointers: One to the object data and one to the virtual table of the object type. To call a method on a trait object, the program will take the offset of a trait function, calculated by the compiler from the defined order, and apply it on the virtual table pointer, yielding a pointer (directed at the virtual table) to a pointer of the trait function implementation of that type. It then jumps there and executes the function. As said, this is completely transparent to the programmer, but this is how polymorphism works under the hood.
Trait objects
References
Back to our usage of Rust, instead of using a concrete type for a reference, you can use the dyn
keyword, followed by the trait you want as interface. It stands for dynamic dispatch
, the mechanism described just before.
But however great Rust's type inference normally works, it is (currently) not able to detect if you want to have a trait object reference, so you'll have to define the type manually in this case.
use std::fmt::Display; fn example() { let answer = 42; let message = "Hello, world!"; // Anything that implements the Display trait let mut trait_object: &dyn Display = &answer; println!("{}", trait_object); trait_object = &message; println!("{}", trait_object); } fn main() { example(); }
Boxed objects
The dyn
keyword does only work on references, not on actual objects. That is because of a property very attentive readers might already have noticed way back in the references subchapter, when we only used references on slices, not the slices itself.
Slices share the same behaviour as trait objects, and that is not by coincidence as they both do not implement the Sized
trait.
This trait doesn't do anything by itself, but it's a marker, signaling that the size of a type is known at compile time. As this is needed for translating code to any register- and most stack operations, you can't even assign a non-sized type to a variable.
References fix that, as they represent a pointer to memory and are always usize
internally, no matter the type of the data they point to.
But of course, we want to have owned trait objects too, else there is little sense for polymorphism, isn't it?
Apart from the alternative of using enums, of course there is a way of owning trait objects (and also slices in the process), it's the Box
container.
The container basically is a owned reference to data on the heap, similar to C++'s unique_ptr
, for example. Since allocation is handled at runtime, it doesn't matter if the type inside
doesn't implement the Sized
trait, whereas the Box
itself only saves the reference, so it's Sized
itself.
As Box
implements the Defref
trait, you can call methods on it just like you would on the trait object itself.
However, the type inference rule still applies, so if you want your Box to carry a trait object, you have to define the type manually.
use std::fmt::Display; fn example() { // Notice how the object itself isn't a reference anymore! let my_vec: Vec<Box<dyn Display>> = vec![Box::new(42), Box::new("Hello, world!")]; for trait_box in my_vec { println!("{}", trait_box); } // Just so you see it, this works too! // Actually doesn't need the type definition since it can be inferred // from the return value of the called method let boxed_slice: Box<[usize]> = vec![0, 1, 2, 3].into_boxed_slice(); } fn main() { example(); }
Trait extension
If you want to use functions from another trait when implementing your own, you can require the other trait to be implemented for any type trying to implement yours. This can theoretically be seen as a form of inheritance, as by extending the already defined interface, you create a subtrait for the one you're requiring, therefore making that a supertrait.
You can also use a new trait to combine different traits together. This can be used to circumvent the limit of one non-auto trait for dyn
types. This limit stems from the fact that each trait has its own virtual table when implemented on a type, and a dynamic type only has one virtual table pointer.
The combined trait will have a virtual table that contains the function pointers of every supertrait.
Let us take a look at the head of the standard library error trait definition:
use std::fmt::{ Debug, Display };
pub trait Error: Debug + Display {
...
}
It requires both printing traits, meaning both have to be implemented by a type that implements the Error
trait, but also that the functionality of both is available.
A function receiving an error object will probably want to either print the error to a user if the user can fix it, or to some log (or inside a panic) otherwise.
In the latter case, a programmer seeing the error or receiving a report will probably want the additional information provided by Debug
printing, while a user could be confused by it and would prefer the more user-friendly Display
version.
Generics
Generics are a first class feature in Rust. Generics alone can provide flexible structure, together with traits, they can provide incredibly flexible functionality. But as an introduction, we will focus on the structure first.
Structure and usage
Of course, we have already used some generic structures, namely Vec
and Box
.
Type inference made it possible that we only needed to declare the type of the variable. The type of the respective new
function, or rather the entire container, was inferred.
Variants of generic elements (types as well as functions) are accessed like a subelement using the so-called turbofish operator ::<>
.
Note that Vec
and Box
actually take two generic parameters, the second is the memory allocator. Rust allows you to set default types for any number of the rightmost parameters, so you only need to assign them when you want a type beside the default.
To my knowledge, this is the only place where the language itself does allow default values at all. Default values of types are handled by the Default
trait.
// Generic type, normal function
let my_box = Box::<usize>::new(42);
// Generic function
let fun = std::cmp::max::<usize>(42, 69);
Generics are very well integrated into the language. To make a type or function generic, just define at least one generic parameter in angel brackets behind the name and that's all, you're already good to go. These parameters are then usable inside the type or function.
struct SomeWrapper<T> {
inner_data: T,
}
fn swap_order<T, U>(input: (T, U)) -> (U, T) {
let (first, second) = input;
(second, first)
}
For implementing types, you should be aware that Rust does not have specialisation of generic types. That means you can't overwrite anything provided by a generic implementation through a specialised one. This falls in line with Rust not providing function overloading in general. I guess this is because firstly, you can't have functions with the same name doing different things and secondly, that could be considered some form of compile time reflection. Rust generally tries to avoid reflection and I agree with this stance. During runtime, reflection relies on information a compiled program shouldn't have since that's a pretty considerable overhead. In source code, it makes code opaque and in the case of function overloading, it's easy to mix up different versions while having them doing different things.
But while you can't overwrite generic functions with specialised ones, you can extend your generic type with specialised impl
blocks.
#![allow(unused)] fn main() { // Consider the previous code block struct SomeWrapper<T> { inner_data: T, } // Generic impl impl<T> SomeWrapper<T> { pub fn reference_to_inner(&self) -> &T { &self.inner_data } } // Specialised impl // This type will have both methods impl SomeWrapper<u64> { pub fn as_bytes(&self) -> [u8; 8] { self.inner_data.to_ne_bytes() } // Compiler error, redefining this function is not allowed! pub fn reference_to_inner(&self) -> &u64 { &self.inner_data } } }
Trait bounds
Generics aren't very ergonomic in most other languages, if they exist at all. The reason is that generics usually rely on duck typing, meaning you use any function on your generic type and just hope that the type used for instantiating happens to have a function with matching name and signature. That makes it difficult to reason about the code and obviously, auto completion etc only barely works, if at all. This leads to programmers avoiding generic code in most cases, to the point of arguing that "almost no code can be shared between types". That might actually be right for other languages, but in Rust, generics are a core feature that isn't just used extensively, but also way more ergonomic. Enter the happy land that is trait bounds!
When using generics, Rust doesn't allow you to make any assumptions about the type's properties, so you can either only use standard type operations like moving an object around or referencing it, or you can constrain the type with trait bounds and get access to the properties of that trait in return. This works exactly the same way as trait extension does.
With multiple generic parameters, as well as multiple trait bounds per parameter, the signature of your type or function will become quite long and difficult to parse for humans.
Because of this, Rust provides an alternative syntax where instead of declaring the trait bounds directly at the generic parameter declaration, you can put the where
keyword, followed by the trait bounds, directly before opening the block.
trait FirstTrait {
fn first_method(&self);
}
trait SecondTrait {
fn second_method(&self);
}
// Direct bounds
fn example<T: FirstTrait + SecondTrait>(value: T) {
value.first_method();
value.second_method();
}
// Bounds with 'where'
fn example2<T>(value: T) -> bool where T: FirstTrait + SecondTrait {
value.first_method();
value.second_method();
true
}
Trait bounds work just the same when generically implementing a type as trait extensions. As you can have multiple impl
blocks for the same type, you can actually give them different trait bounds, optionally implementing functions depending on the type you use to instantiate it.
Scaling the capability of a type by the capability of its generic parameter is one of the many demonstrations on how flexible the Rust traits and generics really are.
As an alternative of declaring generic parameters before using them, Rust also provides the impl
syntax. By that, I do not mean the impl
blocks we've seen before, but a keyword for function parameters (doesn't work on types) that lets you use trait bounds instead of types for parameters and/or return type.
Any of those bounds represent a generic type that's independent from any other trait bound, even if that's of the same type. But as generics aren't dynamic types and get instantiated at compile time, every code path inside such a function still has to return the same type, even if the signature is generic.
use std::fmt::Display;
// The return value gets reduced to just its bound,
// so this will return an essentially useless type!
fn print_and_clone(print_object: impl Display, clone_object: &impl Clone)
-> impl Clone {
println!("{}", print_object);
clone_object.clone()
}
Generic traits
Just as types and functions, traits can also have generic types. As different instantiations of a type or function are considered as two separate entities, it's also the same with traits.
For example, the Add
trait uses a generic parameter for the right-hand side of the operator, which is useful if you have a type that represents an amount of change for another one, like Duration
for Instant
in the standard library.
use std::ops::Add;
struct MyInteger;
// Rhs (right-hand side) defaults to Self
impl Add for MyInteger {
type Output = MyInteger;
fn add(self, rhs: Self) -> Self::Output {
MyInteger
}
}
// Integrated conversion, please don't do this in real code!!
impl Add<MyInteger> for usize {
type Output = usize;
fn add(self, rhs: MyInteger) -> Self::Output {
self + 1
}
}
Blanket implementations
Now for a very fun part, you can implement traits on generic types!
Implementing a trait on a generic type will make its elements available to every type that fits the trait bounds. You may be asking yourself: Why in the world would I want that, that sounds like a recipe for disaster! But actually, it's not so bad.
Firstly, you have to import the trait into the module you want to use a blanket implementation in, so "accidental" use can't happen. Secondly, there's the orphan rule:
If you implement a trait on a type, either the trait or the type (or both) has to originate from the crate the impl
statement is in. That means you can't attach foreign traits to foreign types, so accidentally changing functionality in foreign code also is something that can't happen.
With that in mind, we will look at two examples from the standard library: ToString
and Into
.
ToString
expresses the contents of an object as a new String
. This is automatically implemented for any object that implements Display
. You might know where this is going: The implementation creates a new String
object and just prints into it.
This works because print!
works like just a specific form of write!
that always prints to stdout.
write!
can print to any object that implements the Write
trait, which String
does, coincidentally. It uses a shortcut internally, but you could implement a working version yourself with this knowledge.
Into
is one of two type conversion traits in the standard library. The interesting bit is that it gets automatically implemented for any type whose target type implements From
with it.
Just have a look at the original library source code:
impl<T, U> Into<U> for T
where
U: From<T>,
{
fn into(self) -> U {
U::from(self)
}
}
Null handling
After two big chapters of explanation how features work in Rust, we will now return to solving common programming problems. One of these is handling absent values.
Many languages support this, some like JavaScript may even go the extra mile and support two different types of absent values, null
and undefined
. Having that feature is nice, but handling absent values is often pretty horrible and leads to errors extremely fast.
If you are using programs written in C# or Java, chances are that you have seen a bunch of NullPointerException
s or NullReferenceException
s already.
Opening the browser console before loading a website is also notorious for throwing a bunch of errors for non-existing values. So how can we improve on that?
Conventionally, we would have to null-check every value that can be null before using it. That isn't viable in languages where everything can be null, so we mostly just assume that nobody is going to throw absent values our way. This works most of the time, but once in a while, either some function returns an absent value, or we want to put absent values into functions ourselves, to convey some extra intention. The biggest problem here is that in the vast majority of languages, the possibility of an absent value is implicit and doesn't appear anywhere in a function signature, or at all, really. Thus, there is no guarantee in the interface that those values are handled properly.
Languages like Kotlin are the exception to this rule. In Kotlin, if a value can be absent, it is noted as a nullable type in the function signature. However, there's still the issue of handling absent values when they are encountered. We will go through a few approaches found in common languages.
Lower level languages like C don't have an inherent concept of absent values, because they work on flat memory which can't be absent. They express absent values through pointers to a special null address. You can null-check for it pointing to that address, but it isn't mandatory. When you try to use the value of such a null pointer, the thread just straight up crashes. This is absolutely horrible for program usefulness and reliability, but at least ensures memory safety because you don't work on arbitrary memory. Except if you forget to set your pointer to that special address, then you do access arbitrary memory and only the machine gods know what will happen. Unsafe pointers in Rust actually function in the same way, so there is a reason why they are unsafe and normal programs should never touch them.
Then there are languages like JavaScript, which work on a bunch of tasks called through events. Again, null-checks are existent, but not mandatory. When you try to use an absent value, the task stumbling upon it is abandoned. When you have a real-time application that suddenly just stops, this is why. If you have a button that doesn't do anything even though there's code for it, this is why. Again, absolutely horrible for usefulness and reliability, but compared to lower level languages, memory safety is always ensured, so nothing arbitrary happens.
Going further, we approach languages like C# and Java, that actually have some form of handling for it. Again, non-mandatory null-checks. If you try to use an absent value, an exception is thrown. You can catch that exception and handle the error, but catching the exception isn't mandatory, either. If you don't catch the exception, the thread crashes. The ability to catch those errors makes this approach better already, but it just adds another safety net. If you forget to set that up, it's just as the same as the other approaches in terms of usefulness and reliability.
Now we get to Rust, which hasn't an inherent concept of absent values since it's a lower level language. Instead, it uses its powerful generics to provide an enum for the notion of absent values in the standard library.
pub enum Option<T> {
None,
Some(T),
}
As you might have read in its chapter, you can only use data inside an enum variant if you check for that variant. Thus, null-checking is mandatory in Rust. Because this is in the standard library, every function in the standard library that might return something uses this enum (except if it also returns something on an error case, but more on that later, so we have a wealth of examples to choose from. Let's just see what happens when we try to pop a value off a vector:
let mut vec = vec![1, 2, 3];
if let Some(value) = vec.pop() {
// Use the popped value
} else {
// Tried to pop off an empty vector!
}
Now of course, this also is very verbose, which you might not want for something like prototyping. Also, you may have guaranteed for a value to not be absent already (through Option::is_some
for example), so you want to just use it.
You can do that because the enum provides an unwrap
method.
This replaces the check and lets you directly use the value, but crashes the thread if the value is absent. Now you may ask yourself: But doesn't this make null-checking non-mandatory again? And you are absolutely right, it does. However, the difference is that not checking it is a conscious decision of the programmer and it's also very easy to see in the code.
So if you make an error and not check a value where you should do that, that error is very easy to find.
Error handling
Error handling is a very interesting topic in itself. Although it does receive attention, I think it still isn't getting as much as it deserves. It's my personal opinion that correctly handling when things go wrong is just as important as correctly implementing when things go right, at least if your goal is to write a well-behaved library or application. Rust uses the best method of error handling I've come across so far, and I've seen some languages also pick that method up over the years.
When I'm speaking of error handling in this chapter, I always mean handling of recoverable errors. Handling unrecoverable errors should be a solved problem at this point: Unwind the stack, drop everything that goes out of scope in the correct order. Beyond that, it's a matter of deallocating system resources while stack unwinding. That means it will release locks, file handlers, network sockets and so on.
To start off this chapter, let's take a look at different error handling methods over the years.
A bit of history
In the beginning, there was only arithmetic error handling. Programs were reasonably small, didn't have opaque dependencies and most importantly, they only took input data that was relied on to be correct. As there were no side effects, incorrect inputs would produce arbitrary outputs, and that was okay because the output wasn't usable in that case, anyway.
Going forward a few years, we had multi-user systems, multiple programs running on the same machine and we had (comparatively) higher level languages like C, combined with a library for a standard interface with the system. Complexity was several orders of magnitude greater, so there also were heaps of things that could go wrong. As a consequence, we started to use memory protection, so one location in a program can't arbitrarily corrupt another one, or even worse, other programs including the operating system. Also, we started to use early forms of error handling.
Try-Catch
This form of error handling evolved organically over the years, but the basic idea stayed the same:
In a block of code, encountering an error leads to the program runtime searching for a handler of that exception type outside of that code block. Modern implementations include stack unwinding, so when a function does not have a handler, it will throw the error itself to go further through the stack, until a handler is found. If no handler is found, the thread will crash.
Because you are leaving the structured control flow, the usually used keywords for this form of error handling are try
for the fallible block, throw
for an escalating exception and catch
for the handler of it.
Return values
Parallel to the development of exception handling using try
and catch
, C was developed. The language implemented error handling by either returning an error code from a failed function or setting a global error variable.
The error code are integers and mostly arbitrary, so you would have to look up some documentation to know which code corresponds to which error. But it doesn't end here.
There's quite some confusion on how to map errors to values in the case of a function return. In C, you can only return one data value, so errors have to map into the same space as valid values.
As the range of valid values is dependent on the function, the error range is also dependent on it. Additionally, programmers couldn't agree on a universal standard back then. As a consequence, an error is always 0
for some functions, for some it's -1
, for some it's a positive error code, for some it's a negative one, or it's some arbitrary special value.
This is very messy, and it's also easy to just forget to handle an error, especially in the case of checking a global variable.
Unix implemented the same mechanism on the program level and others followed, so nowadays your program can return an integer to signal success or failure.
It is almost universally agreed on that 0
is the success value, while you can mostly just throw and other number on an error because there isn't too much standardization there.
Callbacks
Then there's callbacks. I didn't find too much about the history of using callbacks for error handling, but as modern languages such as JS are the main ones using it, I will regard that as more modern. Using this mechanism, you give a function a parameter in form of a callback function. This function will be called in the appropriate case, in out case when encountering an error condition.
I believe the idea is that since error handling disrupts the flow of code handling the success case, callbacks can be used to move the error handling code somewhere else. However, this disregards the error case as being much less important than the success case, which it isn't in my opinion. Also, callbacks generally tend to obfuscate control flow.
Errors in functions
Now let us take a look at how Rust does error handling. First off, contrary to most other languages, recoverable errors in Rust are explicit. As with null handling, the standard library contains an enum for this exact purpose:
pub enum Result<T, E> {
Ok(T),
Err(E),
}
Code using a function returning this enum have to explicitly check for success to access the return value. Even when disregarding the value, the compiler will issue a warning if you do not check a result. In an error case, information about the error will be returned.
This can be used creatively, for example as done in the binary_search
function of slices.
On success, it returns the index of the found element, on failure, it returns the index where you could insert the element such that the slice stays sorted.
Handling errors using the Result
enum gives you the choice of ignoring the error and doing nothing, handling it, or escalating it to the caller. Of course, the latter only works if your function also returns a Result
with the appropriate type.
You can also use the unwrap
function to convert it into a non-recoverable error, but with the same caveats found in null handling.
While always explicitly handling errors is very verbose, even if you just want to escalate it, Rust actually provides some support in the language itself, as opposed to the standard library, by using the ?
operator.
Using this operator unwraps the return value in the success case, or returns the error to the calling function in an error case.
It also wraps your error in a Box
if needed, so you can use it to convert a concrete error to a boxed dynamic type. This enables you to escalate different errors from called functions.
use std::fs::File;
use std::io::Read;
fn read_number_from_file() -> Result<usize, Box<dyn std::error::Error>> {
// Can return a std::io::Error
let mut file = File::open("fun.txt")?;
let mut buffer = String::new();
// Can return a std::io::Error
file.read_to_string(&mut buffer)?;
// Can return a std::str::FromStr::Err
let number = buffer.parse::<usize>()?;
Ok(number)
}
Arithmetic errors
Now we know how to design, check for, and handle recoverable errors in functions, but there's still errors hiding in the form of hardware limitations. Of course, you can't save an infinite amount of numbers in a finite amount of memory. Also, you want calculation to be fast, so that is an additional limitation. Floating point numbers do have a bit of error handling embedded in the most-used standard, but integers mostly just go into undefined behaviour when an overflow occurs.
For reasons of ergonomics (and performance), Rust actually doesn't differ from this behaviour by default. However, since undefined behaviour is about the worst that can happen for the correctness of your program, an integer overflow will crash it when compiled in debug mode. Undefined behaviour mostly means wrap-around in most cases, since that is how most hardware implementations work. But as you should never expect a specific thing to happen during undefined behaviour, Rust provides functions with explicit overflow behaviour for all integer types and all operations that can overflow in the specific case. Those are:
- Checked methods that only return a value if overflow did not occur.
- Overflowing methods that return the wrapped result and also a boolean value signaling if overflow occurred.
- Saturating methods that clamp the result into the valid range.
- Wrapped methods that explicitly wrap around, giving programmers a chance to mark it as wanted behaviour.
Ownership
This is a novel feature of Rust, I haven't seen this in any other language so far. It's how Rust achieves memory safety without requiring either garbage collection or manual memory management. It achieves this by extensive compile time analysis of references, as well as implementing the general ownership concept.
But why would you actually want that? Well, because you get the advantages of both worlds. Manual memory management is cumbersome and error-prone. The biggest selling point of Rust for projects using lower level languages is memory safety, eliminating a whole class of errors, especially in terms of security. In addition to that, you retain the speed and real time properties of manual memory managed languages, features that garbage collected languages either can't provide or are severely lacking in.
Now that you know why this is such a big deal, let's see how it is achieved!
Concept
Every resource, be it an object, primitive or something else, is owned by its enclosing scope. The scope is responsible for cleaning the resource up if it is dropped (goes out of scope). This means that exactly one deallocation takes place, making common problems of manually managed languages, like double-free or forgetting to deallocate at all, pretty much impossible. However, this requires careful consideration on where to put data and how to make it available to another context from the programmer side, since just passing it around doesn't work in Rust in many cases. Let's look at an example:
struct MyStruct;
impl std::fmt::Display for MyStruct { .. }
fn consuming_function<T>(value: T) {
..
}
fn example() {
let primitive = 42;
let object = MyStruct;
consuming_function(primitive);
consuming_function(object);
// Fine
println!("{}", primitive);
// Compiler error!
println!("{}", object);
}
Now what happened here? It might be a bit unintuitive, but Rust handled the primitive and the struct object differently. This is caused by the Copy
trait that is implemented on the primitive, but not on our struct.
Implementing the trait causes Rust to use copy semantics for function parameters, you might know this as "call by value" or "pass by value".
On the other hand, not implementing it doesn't cause Rust to use reference semantics or "call by reference"/"pass by reference", since the language handles those explicitly.
Instead, it uses move semantics in that case.
So by calling consuming_function
, the ownership of the object variable is transferred into the function, making it no longer usable inside the function that owned it previously.
As the variable isn't returned by the function, it cleans the resource up at the end of its scope. If you use some unsafe manual memory management or have resources that need special cleanup, you can implement the Drop
trait for some custom cleanup code.
However, Drop
and Copy
are mutually exclusive since Copy
denotes a type with shallow copying and therefore no cleanup except for freeing the memory of the object itself.
Of course, we still want to be able to access data from multiple places, and that is where Borrowing comes into play.
Borrowing
Borrowing a value essentially works like references in other languages, but with the ownership concept applied to it. Using references as function parameters still transfers ownership to the function, but instead of cleaning up when its scope ends, it returns ownership to the caller, thus the name "borrow".
The ownership rule applies to object methods too, so if the signature shows self
instead of a borrow like &self
, the method will consume the object and it can't be used afterwards. This is used by the type conversion traits or pushing onto a vector, by example.
As borrows are part of the ownership system, there are some constraints applied to how borrows can be created and used:
- A resource can't be moved or consumed if borrows of it exist.
- There can only be at most one mutable borrow to a resource.
- Immutable borrows are only possible if there is no mutable borrow on the resource, but there can be any number of them.
As consequence, a resource can't be mutated while something holds an immutable reference to it. This can lead to a programming experience commonly called "fighting the borrow checker". You should exercise extra attention to these rules when programming in Rust, but we're also going through a few examples where you might run into problems.
First off, here is an example of why the rules with mutable and immutable borrows exist:
#![allow(unused)] fn main() { let mut vec = vec![42]; // Borrow into a vector means // borrowing the entire vector let reference = &vec[0]; // Compiler error, mutable borrow! vec.push(69); println!("{}", *reference); }
Why shouldn't you be allowed to add another element to the vector, when you're only holding a reference to a seemingly unrelated field of it? Well, because it's not actually independent! If you add an element to the vector, it might need to grow, triggering a reallocation. If this happens, all the elements are copied to the newly allocated memory, meaning their address in memory will change. And guess what, references are just fancy pointers, so they would end up pointing to invalid memory.
Now for a more lengthy example, this tries to replicate a common "fighting the borrow checker" moment one might have.
#![allow(unused)] fn main() { #[derive(Clone)] struct MyUnit { pub player_id: usize, pub life_points: isize, pub damage: isize, } fn setup() -> Vec<MyUnit> { let mut units = Vec::new(); for _ in 0..10 { units.push(MyUnit { player_id: 0, life_points: 100, damage: 1, }); units.push(MyUnit { player_id: 1, life_points: 100, damage: 1, }); } units } fn example(mut units: Vec<MyUnit>) { // Each shot hits all enemies for simplicity for my_unit in units.iter() { // This will not compile: trying to mutably borrow // the vector when we're already borrowing it in // the outer loop! for other_unit in units.iter_mut() { if other_unit.player_id != my_unit.player_id { other_unit.life_points -= my_unit.damage; } } } } }
The outer loop borrows the vector, while the inner loop tries to borrow the vector at the same time. Rust doesn't allow this. Now how would one go about fixing this problem?
The idiomatic way would be to change your structures. You could either split the vector per team, so you will borrow different elements. Another way would be to save shots into their own vector, rather than directly applying damage, then iterating through that using a second pass. Finally, you can use reference arrays per team, indexing into your main vector. However, this can reintroduce the same problem, since you want to select a team and then iterate through all other teams.
If you don't need your code to be idiomatic, you can always just use classic for-loops. This greatly reduces the scope needed for borrows, but is a bit less ergonomic to read/write and a bit easier to mess up.
struct MyUnit { pub player_id: usize, pub life_points: usize, pub damage: usize }
struct Shot {
pub player_id: usize,
pub damage: usize,
}
// Still room for improvement, but iterators
// are a topic for the next chapter!
fn example_idiomatic(mut units: Vec<MyUnit>) {
let mut shots = Vec::new();
for my_unit in units.iter() {
shots.push(Shot {
player_id: my_unit.player_id,
damage: my_unit.damage
});
}
while let Some(shot) = shots.pop() {
for my_unit in units.iter_mut() {
if my_unit.player_id != shot.player_id {
my_unit.life_points -= shot.damage;
}
}
}
}
// Classic loops, be careful you don't actually
// swap i and j around at the wrong occasions!
fn example_pragmatic(mut units: Vec<MyUnit>) {
for i in 0..units.len() {
for j in 0..units.len() {
if units[j].player_id != units[i].player_id {
units[j].life_points -= units[i].damage;
}
}
}
}
Lifetimes
Now you know about ownership and borrowing, but there's still more attached to it, namely lifetimes. Those can (and are) elided in almost all cases, so knowing about the semantics isn't strictly necessary when working with normal code. However, understanding lifetimes can still be useful, especially when working with more complicated stuff or multiple references to the same item.
Basically, as items can't be moved or mutated while a reference points to them, they also can't go out of scope and therefore be cleaned up. This means that references have to guarantee they don't outlive the data they're pointing to.
#![allow(unused)] fn main() { let reference; // new, smaller scope { let answer = 42; reference = &answer; // answer goes out of scope here and would leave // the reference pointing to invalid memory! } println!("{}", *reference); }
Lifetimes get assigned automatically as long as deducting the correct one is trivial. In more complicated cases, you can assign then yourself just like a generic parameter. Here's an example of that:
struct ObjectWithReference<'a> {
pub reference: &'a usize
}
The meaning of a lifetime parameter changes depending on where it shows up. As a generic parameter on a type, it means that an object of the type has some lifetime, assigned by the compiler and deducted from the scope it's in.
On reference fields inside a type, it means that the item the reference is pointing to must live at least as long as the assigned lifetime.
In the example, it means that a ObjectWithReference
object must not outlive whatever reference
is pointing to. Let's look at the usage of it.
#![allow(unused)] fn main() { struct ObjectWithReference<'a> { pub reference: &'a usize } let mut outer_value = 42; // okay, same scope, same lifetime let outer_reference = ObjectWithReference { reference: &mut outer_value }; let outliving_reference; // new, smaller scope { let mut inner_value = 69; // okay, value pointed to outlives the reference let inner_reference = ObjectWithReference { reference: &mut outer_value }; // NOT okay, outlives inner_value! outliving_reference = ObjectWithReference { reference: &mut inner_value }; } println!("{}", *outliving_reference.reference); }
Next, we're gonna look at functions with lifetimes. I'm taking the example straight out of the official Rust book.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
This is also using the provided lifetime with different meanings. On input parameters, it constraints the lifetime to the shortest lifetime of any input parameter using that lifetime. On an output parameter, it constraints the output to the lifetime. In total, it means that the output is not allowed to outlive any of the input parameters. I'm gonna take the usage bit from the book too, but I will annotate it a bit:
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn example() { let string1 = String::from("long string is long"); let result; // new, smaller scope { let string2 = String::from("xyz"); // references from String::as_str have the same lifetime // as the String object result = longest(string1.as_str(), string2.as_str()); // string2 goes out of scope here, but result musn't // outlive any of its inputs, so this will not compile! } println!("The longest string is {}", result); } }
As you can see, the rules don't care about runtime behaviour, since they are applied at compile time. At runtime, string2
would never be returned, therefore result
would never point to invalid memory.
To avoid excessive scoping and quite a bit of headaches, Rust has the ability to shorten the lifetime of a reference to its last usage. It means any of the examples in this lifetime subchapter will compile if you just delete the last print statement, since the lifetimes of any outliving reference can be shortened to its last usage, which is its assignment. I will also throw in another more explicit example:
#![allow(unused)] fn main() { let mut answer = 42; let reference = &answer; // last usage of the reference println!("{}", reference); // Is okay since the first reference can // be dropped before it let mutable_reference = &mut answer; *mutable_reference = 1; println!("{}", mutable_reference); }
One last thing that deserves a mention is the special 'static
lifetime. It's the lifetime of the program itself, so there are no constraints referencing items with that lifetime.
However, normal borrowing rules still apply, of course. It can be useful when working with threads or manually implementing shared ownership with unsafe code, but the most notable use of 'static
lifetimes are string literals:
let message: &'static str = "Hello, world!";
Closures
If you know about closures already, this chapter will hold no big surprises for you, they function about how you would expect. However, some Rust features rely heavily on them, so of course there's a chapter for them. If you don't know what closures are, they go by "lambdas" in some languages like C++.
In short, they are anonymous functions which can capture parts of the scope they are defined in. They are written as two pipes with their arguments in between, followed by an expression evaluating to data of their return type. Argument types and return type should be able to be inferred in almost all cases, but you can also make them explicit if you want to. Their main use is in customising behaviour of some functions, which we will see extensively shortly. Depending on what they capture from their environment, they implement one of three traits:
Fn
doesn't capture anything at all, or only immutable references. Regular functions implement this trait, too. Closures implementing this can be used where the following traits are expected, as well.FnMut
captures mutable references of the environment, so it can mutate them. Closures implementing this can be used anywhere whereFnOnce
is expected, too.FnOnce
fully captures and consumes parts of the environment, as it moves it into itself. As the name and the ownership rules imply, this type of closure can only be called once. Closures implementing this have amove
keyword in front of it.
One important use is in spawning a thread. As spawned threads start working immediately, they don't take functions with arguments (which would immediately need those arguments anyway), but instead a closure that can capture any items the thread needs.
#![allow(unused)] fn main() { let (sender, receiver) = std::sync::mpsc::channel(); // Closure captures sender and returns a boolean let thread = std::thread::spawn(move || { if let Ok(()) = sender.send(String::from("Hello world!")) { true } else { false } }); let success = thread.join().unwrap(); if success { println!("Message from thread: {}", receiver.recv().unwrap()); } }
The second important use case is when sorting or searching for items. When they don't have Ord
implemented, but contain a value that does and you want to use that, you can use the *_by_key
version of the respective function.
It accepts a closure that takes an item reference as its argument and returns one that implements the trait.
You can also define the entire comparison function yourself by using the *_by
versions of those functions. Of course, we're going to need a nice little example for that, don't we?
#![allow(unused)] fn main() { use std::cmp::Ordering; struct MyStruct { pub id: usize, pub some_data: usize, } // Let's just assume that we get some // data from somewhere else fn example(data: &mut [MyStruct]) { // Doesn't work, we don't have the // Ord trait implemented! data.sort(); // Assume we only want to sort by the ID data.sort_by_key(|item| item.id); // Assume we want to sort by ID first // and data second data.sort_by(|first, second| { match first.id.cmp(&second.id) { Ordering::Equal => first.some_data.cmp(&second.some_data), other => other } }); } }
The third important use case is iterators, which we will go into now.
Iterators
Iterators in Rust represent a venture into the functional programming paradigm. We've already used them before, it's what we go through in for-loops. They are objects which are used to iterate over collections in an abstract manner, so we're not dependent on the internal organisation of data when using a specific collection. But of course, we can go much further than that.
When trying to understand iterator methods, it needs a slight shift in your mental model compared to standard "procedural" loops. Java calls its equivalent the Stream API, and I think the name suits the mental model well. Instead of getting an item and then describing what we want to do to it, we can describe how the "stream" should manipulate any item that is coming through. Iterator methods provide a standardised structure for common patterns when iterating over items, and thus have a higher layer of abstraction. As a consequence, we need to define less structure (control flow and variables) ourselves, giving our code less space for errors to happen in. When learned, iterator methods are also easier to understand than parsing a whole loop and deducting what is going on. For me, especially selecting items bears a striking resemblance to designing SQL queries.
But as always, this was very theoretical and we want to see what it actually means. Taking the filter
method for example, if you have a loop with an if statement that wraps the entire loop body, you can just replace it with filter
like so:
// Example: Take a list of number, print even ones
fn example_procedural(values: &[usize]) {
for &value in values {
if value % 2 == 0 {
println!("{}", value);
}
}
}
fn example_functional(values: &[usize]) {
for &value in values
.into_iter()
.filter(|&&item| item % 2 == 0)
{
println!("{}", value);
}
}
Note how we used a closure to tell the iterator method what we want to filter for. Also note that the example code is a special case:
While we haven't called an iterator method on the first example function, it calls into_iter
implicitly, which gives us an iterator consuming the value.
As we're iterating over a reference (in form of a slice) and references implement the Copy
trait, it uses copy semantics and the slice is still usable after the loop. We're then iterating over references of the inner elements though, so you have to account for that.
Because the filter
closure again expects a reference to the item that we're iterating over, we get the weird &&
notation.
If you try to iterate over an owned Vec
instead, the vector will not be usable after the for-loop, but we will be iterating over the values itself, instead of references to it.
Since usize
has the copy trait, we can also transform the iterator from our example to use actual values instead, just like iterating over Vec
would do. You can replace the head of our second for-loop with this, without any change in behaviour:
let data = vec![0, 1, 2, 3];
let values = &data[..];
for value in values.into_iter().copied().filter(|&item| item % 2 == 0)
{}
Note that iterators are lazy, that means they won't do anything until they yield elements or are consumed. Because of this, you should never use an iterator method purely for side effects.
There are also methods consuming an iterator to produce a single value, which can be used instead of iterating over the items using a for-loop. We will use some of them now.
To try and manifest the required mental model, I will now try to write my approach to a problem a friend of mine tried to solve: Given an extremely long number as a string (we will use &str
), find the highest sum of 13 neighbouring digits.
First off, a string won't do for calculation, we need a collection of digits, so we want to convert the string into one. We look into the standard library documentation and find the str
method chars
, which returns us an iterator over each character.
We will then try and convert them into an integer.
For our intention, the map
method looks just right. The char
type also has a to_digit
method for conversion. Finally, we want to collect our result into a vector.
The collect
method can collect items into any type implementing FromIterator
, but type inference wouldn't work because of this, so we need to manually specify the collection we want.
After trying around a bit, we come up with this:
fn highest_sum_of_neighbouring_digits(input: &str) {
// the element type can be inferred
let digits: Vec<_> = input
// iterator over chars
.chars()
// converts each char to a u32
// can unwrap because we guarantee our string
// to be a decimal number
.map(|character| character.to_digit(10).unwrap())
// create a new vector with the resulting values
.collect();
}
Now we have our digits. For the second bit, we try to slice the requirement into multiple steps:
- We need all combinations of 13 neighbouring digits
- We need to calculate the sum for each combination
- We need to select the maximum of those sums
We consult the standard library again, and we find the windows
method on slices. Since a vector can also act like a slice, we can also use this method.
It returns an iterator over each possible collection of neighbouring values, completely fulfilling our first step.
The second step consists of condensing each combination down to the sum of each digit inside it. We are already iterating over them, so we can convert them using the map
method again.
As for how to calculate the sum, after consulting the standard library yet again, it provides us with the sum
method as a specialised way of handling this. It's very handy since it consumes the iterator and spits out a single value for further processing.
But at this point, we have to pay attention:
Since each item returned by the Windows
iterator consists of an entire window of 13 digits, we have to iterate over them inside the map
method, so we're having an iterator inside an iterator!
let digits: Vec<u32> = vec![];
// step 1 and 2
// needs an element type because the inner `sum` can't infer it
let sums: Vec<u32> = digits
// collections of neighbouring digits
.windows(13)
// map each collection to its sum
.map(|neighbouring_digits| {
// using copied because iterating over a slice
// yields references instead of values
neighbouring_digits.into_iter().copied().sum()
})
// create a new vector with the resulting values
.collect();
Now for the final step, finding the maximum of those sums. Of course, the standard library gives us just the right iterator method for it again: max
.
let sums: Vec<u32> = vec![0, 1, 2, 3];
// step 3
// can unwrap because we guarantee the string to contain
// at least one window
let highest_sum = sums.iter().copied().max().unwrap();
We now can finally put our function together! If we're especially fancy, we can even combine our last three steps into one, arriving at this final solution:
fn highest_sum_of_neighbouring_digits(input: &str) -> u32 {
let digits: Vec<_> = input
.chars()
.map(|character| character.to_digit(10).unwrap())
.collect();
// last expression inside the function, therefore
// evaluates to its return value
digits
.windows(13)
.map(|neighbouring_digits| {
neighbouring_digits.into_iter().copied().sum()
})
// directly looking for the maximum of the mapped sums
.max()
.unwrap()
}
Multithreading
Multithreading is a fairly recent topic. While the use of multiple processors has been around for almost as long as modern (transistor based) computers themselves, microcomputers only have started to provide real concurrency around the dawn of x86-64, so around the early 2000s. As a consequence, many (scripting) languages from the 90s didn't start out with parallel execution support, and because of its inherent complexity (and backwards compatibility issues), some of them don't support it to this day. They are still ways to achieve it, namely there exist Python runtimes supporting it, though they may have trouble with correctness, and modern JavaScript can use service workers.
Complexity is the keyword here, because it's incredibly hard to get parallel execution right. Most programming languages are incredibly unsafe in concurrent logic, memory safety, or both. Even Rust can't help with logic errors such as deadlocks, but it does make correct synchronisation much easier than seen on many other languages.
Shared memory
Starting off with the oldest and simplest method of shared memory, a disclaimer first: You shouldn't use shared memory unless you have a special reason for it. Other methods can be much more ergonomic and harder to mess up. That said, it is supported by the most languages, particulary lower level ones.
When sharing memory between threads, Rust places additional restrictions on how to access it, controlled by the Send
and Sync
traits on types.
Thanks to Rusts trait system, you can't use anything that would break synchronisation, because it is missing one or both of those traits. Let's look at the most important examples:
Rc
is a wrapper enabling shared ownership of data. Doesn't work across threads, there's another version calledArc
for it. It has slightly higher overhead due to the synchronisation.Cell
andRefCell
provide means to mutate things that are normally immutable, but aren't thread safe. Thread save alternatives areRwLock
andMutex
.
Thinking a bit about Rusts ownership model
in combination with the Sync
trait, we realise that we can immutably borrow something to another thread, but mutable borrows are out of the question. (Even immutable borrows would have lifetimes problems, but we will ignore them for now.)
Arc
functions the same way: Its content can be read from multiple threads, but there is no way to get a mutable reference to the inner value.
The reason is the same as for the general borrowing rules, but in addition, a value with mutable borrows could change at any time for the observing thread, between any two instructions.
This could cause all sorts of erroneous behaviour, so all the more reason to prohibit it. But then, how could we make changes to a value that can be observed by another thread?
The standard library delivers a few useful types for this use case. Atomic types
provide methods that combine the read and write step, so no thread can interfere. For this reason, those methods don't actually require the type to be mutable.
The other important method is to provide exclusivity at runtime, blocking other threads from reading a value that is currently borrowed mutably. This is exactly what RwLock
and Mutex
do.
To complete the overview of shared memory in Rust, an example:
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; let mut threads = Vec::new(); // Could also use an atomic value for pure counting, // but we want side effects! let counter: Arc<Mutex<usize>> = Arc::default(); for i in 0..20 { let thread_counter = counter.clone(); threads.push(std::thread::spawn(move || { // Only one thread can be inside here at a time if let Ok(mut counter) = thread_counter.lock() { println!("Thread {} counting!", i); *counter += 1; } })); } // Wait for all threads to finish for thread in threads { thread.join().unwrap(); } // 100 println!("Counted to {}!", *counter.lock().unwrap()); }
Message passing
If we want our code to be a bit more ergonomic, we can use message passing instead of shared memory. Conceptually, we have a pipe where we can use one thread to push data in, and then another thread to get the data out again on the other end.
The state of this pipe is abstracted away by the Rust standard library's Sender
or SyncSender
and Receiver
.
The "pipe" can buffer data if desired, so some amount of decoupling can be achieved. Otherwise, the access is blocking by default, so a thread will wait on the receiving end until a value comes through.
When one end of the pipe gets closed, the other end receives a signal, enabling appropriate handling.
#![allow(unused)] fn main() { let (task_sender, task_receiver) = std::sync::mpsc::channel(); let (result_sender, result_receiver) = std::sync::mpsc::channel(); let thread = std::thread::spawn(move || { let mut sum: usize = 0; // Receive until pipe disconnects while let Ok(number) = task_receiver.recv() { sum += number; result_sender.send(sum).unwrap(); } }); for i in 0..20 { task_sender.send(i).unwrap(); } // Signal to the other thread that there will be no more tasks std::mem::drop(task_sender); while let Ok(sum) = result_receiver.recv() { println!("Result: {}", sum); } // Thread should be finished thread.join().unwrap(); }
Iterators
The functional programming paradigm does not have quite as many problems with synchronisation because it solves the problem of mutability by encapsulating it. You can see this in the restrictions that iterators in Rust have: They borrow the whole collection while iterating, making it impossible to access otherwise. Also, it's not possible to access any other element than the one you're currently at while iterating. This means that if you don't access anything else that needs synchronisation while iterating, the iteration can inherently be done in parallel. This is how functional languages can introduce trivial concurrency.
Rust's type system allows the same for iterators, since they are essentially are a library feature. However, iterators in the standard library don't support concurrency, so you will have to resort to an external one. We're now taking a brief look on the rayon
crate since it's the de-facto standard for parallel iteration in Rust.
It also has some additional features, but parallel iteration is the core.
Rayon provides both a global thread pool and traits for parallel iteration. They have some additional restrictions, for example, closures of non-collecting iterator methods can only immutably borrow variables with Sync
from their context, moving non-Copy
variables is also not allowed.
If these conditions are fulfilled, you can simply call par_iter
on any collection implementing them to get a ParallelIterator
. You can then use it just like a normal iterator, but it will run in parallel if it makes sense to do so.
You might not be able to use all iterator functions you know from the standard library, but it captures the majority of use cases.
That's basically it, apart from a code example to finish it off:
use rayon::prelude::*; fn main() { let mut v: Vec<usize> = (0..65535).into_iter().collect(); // hidden call to deref_mut v.sort_unstable(); v.as_mut_slice().sort_unstable(); // parallel sorting v.as_parallel_slice_mut().sort_unstable(); let sum: usize = v.iter().map(|i| i + 2).sum(); // parallel execution let parallel_sum: usize = v.par_iter().map(|i| i + 2).sum(); println!("Sum: {}", sum); println!("Parallel sum: {}", parallel_sum); }
Async
Async programming enables you to extract the maximum concurrency out of your program by avoiding the overhead of spawning a thread for each asynchronous task, and also providing a few ergonomic primitives for building them.
But although it has some support in the language itself already, such as syntactic sugar in the form of the async
and await
keywords, the standard library doesn't provide a runtime for them, so we need to resort to external crates like tokio
for this.
In general, the runtime will execute lots of so called green threads that are extremely lightweight compared to regular ones. This means that you are able to spawn thousands of these threads without problem. They are scheduled cooperatively, so each thread won't yield to another one unless unless done explicitly. This happens at any call to await
.
Now we're going to take the example from the message passing chapter and adapt it to the async environment using the tokio
primitives:
async fn example() { let (task_sender, mut task_receiver) = tokio::sync::mpsc::channel(20); let (result_sender, mut result_receiver) = tokio::sync::mpsc::channel(20); let thread = tokio::spawn(async move { let mut sum: usize = 0; // Receive until pipe disconnects while let Some(number) = task_receiver.recv().await { sum += number; result_sender.send(sum).await.unwrap(); } }); for i in 0..20 { task_sender.send(i).await.unwrap(); } // Signal to the other thread that there will be no more tasks std::mem::drop(task_sender); while let Some(sum) = result_receiver.recv().await { println!("Result: {}", sum); } // Thread should be finished thread.await.unwrap(); } fn main() { // I personally prefer it explicitly since it's easier // to understand what the code does let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(example()); }
That example makes use of the runtime scheduler, but tokio
has a few more interesting functions:
Non-parallel primitives
All of these run task concurrently, but on the same thread, so they don't run in parallel:
join!
takes multipleasync
expressions and waits until all of them complete.try_join!
takes multipleasync
expressions that return a result and waits for all to complete, but short-circuits on the first error and returns it. When this happens, all expressions still running are abandoned.select!
takes multipleasync
expressions, matches the return values to a pattern each and abandons all other expressions when the first match is encountered. If no return value matches, an optionalelse
arm can be defined.
Async IO
While the API is mostly equivalent to its std
pendants (just async
), they provide better utilization of CPU resources. Doing IO with regular threads blocks them when the OS is busy serving the request, decreasing throughput.
You can mitigate this by spawning more threads, so one of them can run in the meantime, but that means more threads than CPU resources, potentially competing for the same resources and therefore decreasing throughput from that side. Since async tasks are scheduled cooperatively, their overhead is much lower and therefore enables throughput in these two cases.
Timers
The runtime also provides timing functionality. Combined with the scheduler, time-based functionality can be implemented quite efficiently.
Sleep
blocks until a specified amount of time has passed. Compared to the variant in the standard library, only the task is blocked, so the runtime can switch to another one without involving the operating system, incurring much lower overhead.Timeout
can be used to attach a timeout to a task. If it blocks after the specified amount of time has passed, the task is abandoned and an error is returned.Interval
blocks on a call to itstick
method until the last successful call was at least the specified duration ago.
To finish this chapter, we're going to look at a last example:
struct WorkState(usize);
fn save(state: tokio::sync::MutexGuard<WorkState>) {
// actual saving here
println!("{}", state.0);
}
async fn example() {
let state = std::sync::Arc::new(tokio::sync::Mutex::new(WorkState(42)));
// auto save
let auto_save_state = state.clone();
tokio::spawn(async move {
let mut timer = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
timer.tick().await;
save(auto_save_state.lock().await);
}
});
// the main loop goes here
println!("{}", state.lock().await.0);
}
fn main() {
// I personally prefer it explicitly since it's easier
// to understand what the code does
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(example());
}
Conclusion
So in total, what did we learn?
Firstly, Rust is not a one-size-fits-all language, as they probably can't exist in the first place. Reading the numerous examples provided throughout this article, you can see various trade-offs that Rust is taking in its design. It has various design goals, such as being fast, but beyond that, promoting code correctness is the most important property of the language in my personal opinion.
It comes with a wealth of features to achieve that:
- Types actually signaling their capabilities
- Native unicode support
- A coherent encapsulation system
- Explicit mutability
- Exhaustive matching
- Explicit handling of absent values
- Explicit and mandatory handling of fallible functions
- Extremely low barrier for writing documentation
- Extremely low barrier for testing
- Its unique ownership system for achieving memory safety
- Captured buffer overflows
- Higher abstraction primitives such as iterators
- Coherent multithread safety features enabling some amount of base confidence
Those features are combined with another wealth of ergonomic enhancements, the traits and generics systems in particular. Although some of them make Rust code more verbose in comparison, the combination of all those features makes for an extremely enjoyable software developing experience, so the top spot as "most loved programming language" is well deserved in my opinion.
That about covers all things that I wanted to. I do hope you can take some things away from this article, and maybe it also inspired you to give Rust a try.
If so, you can find everything you need on the official Rust website
.