Safety and Efficiency with Rust

Tooling and Rust Basics


Learning Objectives

  • You know the basic syntax of Rust.
  • You know of the basic data types and know how they are used.
  • You can write simple programs that have variables and functions.

Setup and Tooling

The recommended way to install Rust on any system is using rustup. Rustup installs the Rust compiler (rustc) and Cargo (cargo), which is the Rust package manager and build tool. Follow the instructions on the rustup website to install Rust and Cargo on your computer.

To use Rust sensibly in VSCode, use the rust-analyzer VSCode extension.

Hello Rust!

Even though you might already be tired of “Hello, World!”, let’s still start with it. The following program shows a simple Rust program that prints out the text “Hello, Rust!”.

Loading Exercise...

Similar to many other languages, Rust has an obligatory main function, which serves as a starting point for the code. Printing is done with the macro println! that prints text with an added newline character \n at the end — the exclamation mark ! in the name indicates that it is a macro and not a function.

String literals may span over multiple lines.

Loading Exercise...

Comments in code are done with // for single-line comments and /* and */ for block comments. There is also a triple slash, ///, for documentation comments.


Documentation comments

Rust provides a built-in mechanism for writing semantic documentation comments. These come in different flavors, but the most common is the triple-slash /// doc-comment.

Documentation comments can be used to write, for example, Markdown-formatted text in the source code to document a function’s usage. The documentation comment-generated text is what we see when hovering over a function in most IDEs (e.g., VS Code). You can try this out by hovering your mouse over a function call for a function that you’ve written doc-comments for.

Similar to Java’s Javadoc, the markdown documentation can be converted to a website using the rustdoc tool or cargo doc. Try to run cargo doc --open in the terminal in your Rust project directory to see the generated documentation pages for your project, for instance with the above example code in main.rs.

Variables and Mutability

Variables in Rust are defined using the let keyword. When you run the below program, you’ll see warnings from the compiler as the variables are not used.

Printing variable values

Variables cannot be directly given as arguments to the println! macro. Instead, we need to use string interpolation with curly braces {} to insert the variable into the format string. The following example shows how to print the values of the variables x and y as a part of a string, also demonstrating using empty curly braces that will get injected by the extra argument given to the print macro.

Loading Exercise...

Mutability

Variables in Rust are immutable by default, meaning their values cannot be changed after assignment. This design choice enhances code safety and reliability. For example, the following code does not compile because it attempts to modify an immutable variable:

To define a variable as mutable, add the mut keyword immediately after the let keyword when defining the variable. In the following example, the variable x is mutable, allowing its value to be changed. The compiler, however, provides a warning since the initial value 3 assigned to x is never read.

Code editors such as VSCode can spot these issues before you run the Rust compiler. This is a gentle reminder to test out and write Rust code on your own computer as well.

Loading Exercise...

Data Types

Every variable and value in Rust has a data type, such as the 32-bit integer i32 or String, the growable sequence of characters. Rust is statically typed, meaning the type of every variable must be known at compile time.

Knowing each type during compile time enables Rust to enforce stricter safety rules and provides IDEs, such as VS Code, the ability to offer useful information and auto-suggestions based on the data types you’re working with. We don’t need to write the types explicitly often because of type inference.

The type of a value determines which operations can be performed on it, and many data types come with multiple useful out-of-the-box methods. For example, the string slice &str has methods like to_lowercase and replace, while f32 has methods like round, abs, and max that can simplify programming tasks.

We will not go through an exhaustive list of Rust’s types, but look at a short overview and introduce new types as needed. For those interested, an exhaustive list of Rust’s types can be viewed in the types section of Rust’s documentation.

Loading Exercise...

Explicit data types

Until now, we’ve let the compiler determine the data types of our variables through type inference. This works most of the time, but in some cases, variable types must be explicitly defined. For instance, a floating point literal such as 1.3 can refer to either 32-bit or 64-bit floating-point value. Consequently, the method round cannot be used on arbitrary floating-point values and requires explicit typing for the given floating point value.

You can explicitly define a variable’s type by typing a colon (:) and the type after the variable name when declaring it.

For single-value numeric types, you can also define the type by adding a type suffix to the value itself.

Primitive and custom types

Rust has multiple kinds of data types. Primitives, such as the 64-bit floating point f64 or 32-bit integer i32 are built into the language. Structs and enums are custom data types built on top of the primitive types. The most important primitive types can be examined in the rust-by-example tutorial, which includes, for instance, the different integer and floating-point types in Rust.

Floats and integers are also called scalar (i.e., single value) types because they are single values and cannot be directly separated into multiple values. Besides scalars, Rust primitives also include compound types, such as tuples and arrays, which are usually composed of multiple values.

Here is a short list of primitive types in Rust:

  • Scalars: integers, floats, booleans, and characters
  • Compound types: tuples, arrays and slices

Scalar types usually have a lowercase name (e.g. char), while structs and enums have capitalized names by convention (e.g. the String struct) — String is a growable and modifiable string unlike the string slice &str, which is a fixed value and cannot change.

let a_char: char = 'a';
let an_i32: i32 = 0;

// Compound types
let a_tuple: (i32, f64, u8) = (500, 6.4, 1);
let an_array: [i32; 5] = [1, 2, 3, 4, 5];

// Custom types
// Struct (String is a struct)
let a_string: String = String::from("Hello, world!");

// Enum (Option is an enum)
let an_enum: Option<i32> = Some(5);

Integers and floating points

Rust integer types come in two flavors: signed and unsigned. Signed types can store both positive and negative numbers, while unsigned types can only store positive numbers. They also come in various sizes, from 8-bit to 128-bit, allowing programmers to choose the size that best fits the application. This flexibility helps optimize memory usage—for instance, there’s no need to allocate 128 bits of memory for a variable that will only hold values between 0 and 255.

The following table lists the different integer types in Rust and their sizes.

LengthSignedUnsigned
8 biti8u8
16 biti16u16
32 biti32u32
64 biti64u64
128 biti128u128
architecture dependentisizeusize

The arch types isize and usize depend on the kind of computer your program is running on: 64 bits on a 64-bit architecture and 32 bits on a 32-bit architecture.

We won’t need to worry about too much which type to use when defining integer variables though. We can use the default type i32 for most cases and optimize or increase the variable size if necessary. For instance, it’s good to use one of the unsigned variants when we know that the value should never be negative.

As always when developing programs, we should remember the advice by computer scientist Donald Knuth: “premature optimization is the root of all evil”.

Floating point types come only in two sizes, 32-bit for a smaller memory footprint and 64-bit for better precision. There are no unsigned floats.

LengthType
32 bitf32
64 bitf64

The default type for floats in Rust is f64. This is because using them on modern CPUs is roughly as fast as using f32 and memory is cheap nowadays.

Loading Exercise...

Numerical computation

Basic calculation in Rust is done using familiar symbols and rules from math. 1 + 2 becomes 3, 3 * 3 becomes 9 and parentheses define calculation order.

In Rust, unlike languages like JavaScript and Python with automatic typecasting, dividing two integers results in an integer with the quotient truncated.

And dividing an integer by zero causes panicking — in Rust terminology, panicking means a program crashing during runtime due to an unrecoverable error.

In the above example, we used an empty string’s length to trick the compiler (to prevent it from inferring that the value of zero is 0). Otherwise the compiler could spot the divide-by-zero error already during compile-time. Try to change zero to be the integer literal 0 to see this in action.

Dividing floating-point numbers is safer since a float can be infinite or NaN (not a number).

We can perform arithmetic operations only on values of the same type. Trying to add an integer to a float will cause a compile-time error.

Likewise, different types of integers cannot be added together.

When we want to compute values of different types together, we need to explicitly convert the values to the same type.

Casting values as other types

Type conversions in Rust are mostly explicit. There are many built-in type coercions that every Rust programmer encounters at some point, but we do not need to worry about them yet.

Using the as keyword, we can cast numerical values into other primitive types.

Similarly to calculations, we need to be careful when casting values. Casting a float to an integer truncates the value, and casting 128 to an i8 results in -128 since 127 is the largest value of i8. That is, casting to an integer wraps the value around if it is too large.

Some casts are not possible and will cause a compiler error. For example, you cannot cast a u32 to a char directly; only u8 can be cast to char in Rust.

The as casts only work for primitives, and the behavior cannot be overridden for custom types. Custom types need to implement methods to convert between types, like &str’s to_string to convert it to a String.

An exhaustive list of possible casts can be found in the Rust Reference.

Functions

Functions start with the fn keyword, which is followed by the name of the function, parentheses with optional parameters, and a body enclosed in curly braces {}. The following example outlines two functions: main and say_my_name, where the main function calls the say_my_name function.

In Rust, similar to Gleam, function names are written using snake_case.

Returning from functions

Returning a value from a function can be done using the return keyword, or by simply leaving out the semicolon from the last expression. The latter method works because in Rust, the return value from a function is the value the function call expression evaluates to (unless we use an explicit return).

The type of the return value — the return type — must be written in the function declaration using an arrow ->, unless the function returns the unit value (), which is used when there is no other meaningful value that could be returned.


Loading Exercise...

The unit type

The absence of data is denoted by the unit type () which is the default return type from functions. The only element of the unit type is also called unit (): (). If an expression ends in a semicolon, it evaluates to a unit.

Function parameters

Function parameters are listed in the parentheses of the function declaration. All parameters must have their types explicitly defined. The following example demonstrates a function simon_says that takes a string slice as a parameter.

Function parameters are immutable by default. You can observe this by trying to modify the value of the argument age in the function print_age below. When you try to modify the value, you’ll get a compile error.

To define a mutable parameter, you need to add the mut keyword before the parameter name. The following example demonstrates a function increment that takes a mutable integer as a parameter.

Below, we use an expression-oriented way to convert miles into kilometers.

Note that the main function doesn’t need a specified return type because it returns a value of the unit type ().

Loading Exercise...

Variable scope

Variables are only accessible in the scope they are defined in. This is called lexical (or static) scoping. Each function has its own scope, and variables defined within the function are accessible only inside that function.

// this is the global scope
fn main() {
    // this is in the scope of the main function
}

fn another_function() {
    // this is in the scope of another function
}

Variables defined with let cannot be defined in the global scope — only within functions or blocks. However, constant variables — defined with const — can be defined in the global scope. When defining constant variables, their data type must always be explicitly defined.

Scopes form a hierarchy where outer scopes cannot see variables from the inner subscopes. To demonstrate this, we can create extra scopes by writing block expressions with {}.


Block expressions

Block expressions, like other expressions, have a type and evaluate to a value. To “return” a value from a block expression, you can leave the last expression within the block without a semicolon, similar to functions.

Note that we need to add the semicolon at the end of the let binding, just like with the simpler let statements like let value = 3;.

Block expressions is a powerful feature in Rust that allows for neat syntax. The same rules for block expressions apply regardless of where the block is, whether in a conditional expression, a loop, or any other block.

On a side note but related to scopes, like Gleam, Rust supports shadowing. Shadowing allows reusing the same variable name for another variable. Shadowing a variable from the outer scope inside a subscope does not change the variable.

Scopes play a major role in Rust’s ownership system and borrowing, which we will look at next. Right after completing a quick quiz.

Loading Exercise...