Variables

This chapter has a few concepts to cover:

  • Creating variables
  • Giving a variable a type
  • Primitive types

Creating Variables

In Rust, you can create a variable much like you would in other languages:

#![allow(unused)]
fn main() {
let x = 10;
}

The base syntax is straightforward: give it a name, give it a value. This creates an immutable variable. If you try to change it after this, you'll get an error:

#![allow(unused)]
fn main() {
let x = 10;
x = 11;
}

If you do this in a file and try to compile it, you'll get a pretty helpful error:

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:3:5
  |
2 |     let x = 10;
  |         -
  |         |
  |         first assignment to `x`
  |         help: consider making this binding mutable: `mut x`
3 |     x = 11;
  |     ^^^^^^ cannot assign twice to immutable variable

For more information about this error, try `rustc --explain E0384`.

To fix this error, we learn another keyword: mut. This keyword is used to create mutable variables (or references).

If we run this program instead, it will compile:

#![allow(unused)]
fn main() {
let mut x = 10;
x = 11;
}

A note on mut

The relation between the mut keyword and the concept of mutability is apparent in its name and in its usage: it allows you to mutate things.

However, this hides another way of thinking about it which is very helpful in the context of references later on. How do you achieve mutability? By enforcing exclusive access: when there is one reference to a mutable variable, there can be no others (but you can use that one multiple times). So a mut reference is also an exclusive reference. (We'll talk about references later on.)

More on that later in the ownership and references section!


Type Annotations

Rust is a statically typed language. Everything is given a type at compile time. This does not mean, however, that we need to write types for everything! Like many modern languages, it employs type inference to figure out what types things are if you don't say. That's how Rust was able to compile our code up above without any types on it.

If you use a tool like rust-analyzer, you can see inline type hints. If you use that, you'll be able to see which types are inferred for a given variable, which is invaluable.

Sometimes, though, you just have to write the types down yourself! The compiler can't always figure out what you meant, and it can make it more clear to other programmers and the compiler what you intended—so if the inferred type doesn't match what you intended, now that's caught at compile time rather than run time.

The syntax for type annotations will feel somewhat familiar if you're used to Python or TypeScript, which have similar syntax. Let's say you're declaring a string and a 32-bit unsigned integer. You could write:

#![allow(unused)]
fn main() {
let name: &str = "Captain Blackbeard";
let age: u32 = 35;
}

In a variable initializer, the type annotation is the : <type> portion. There are a few other places you'll see type annotations, like in functions and closures and structs. We'll cover those when we get there, but they're all of this form with a colon and a type.


Primitive Types

Rust has a bunch of primitive types to help you express what you want to write! The primitives are well documented in the Rust docs, which you can look to for more details.

  • Unsigned integers: u8, u16, u32, u64, u128, and usize (8-bit through 128-bit numbers, respectively) These can be written with bare literals like 123, or you can append on the integer type, like 123u8. usize is a pointer-sized unsigned integer; on 64-bit systems, this is often a 64-bit integer, and it's the type you use to index into collections.
  • Signed integers: i8, i16, i32, i64, i128, and isize. These can be written with bare literals like 456, or you can append on the integer type, like 456i32. isize is a pointer-sized signed integer, which is handy for representing the difference between two indexes.
  • Floating point numbers: f32 and f64. These can be written with bare literals like 1.2, or they can be written 1.2f32 or 1.2f64.
  • Characters: char is a 4-byte Unicode character. These can be written with single quotes around them, like 'a'.
  • Booleans: bool is true or false.
  • Strings: str is the primitive type for a string. String handling is complicated in Rust1, but this is the primitive type, which is a UTF-8 encoded string (or "string slice"). It's usually seen in the reference form, &str, but more on references later. Since strings are UTF-8 encoded, characters have variable-width encoding, which complicates accessing characters at specific indices.
  • Unit: () is the "unit type". This can be written with a literal (), and it basically means... nothing. It's an empty, 0-element tuple, and it's the equivalent of void in other languages.

There are also a couple of other types worth mentioning here which aren't technically primitives, but are formed from them.

  • Tuples: (u8, bool) is the type of a two-tuple of an unsigned 8-bit integer and a boolean. You can make a tuple from as many elements as you want, practically. A tuple value is of the form (123, true), which would have the previous type.
  • Arrays: an array in Rust is a fixed-size list of values, with the size as part of its type. The array [0, 1, 2, 3, 4] may have the type [u8; 5], for example.

Casting Between Types

Sometimes you have a primitive of one type, but you want it in another type! This happens often with numbers, where you'll have (say) an 8-bit integer but need to use a 64-bit integer for something. Rust does not do any implicit casting for you. You must explicitly say that you want it to happen, which helps prevent overflow errors.

Casting with the as keyword is straightforward. You take the variable, and say as <type> for what you want to cast it into. If it can't be done, the compiler will tell you! You do have to be careful to ensure that if you cast to a smaller size value, that you won't overflow anything. The behavior you get is well-defined but may be unexpected.

Here is an examples of a cast:

#![allow(unused)]
fn main() {
let x: i64 = -13;
let y: u8 = 10;
let z = x + (y as i64);
}

In this example, we had to cast y so that we could add it to x. If we didn't, we'd get a compiler error. Try modifying this to remove the cast and see what error you get. Also try casting x to a u8 and see what you get instead.


Exercises:

  1. From memory, try to recall the different primitive types in Rust, and write them down. How many did you get right? How many did you miss?
  2. Write a program that creates three integers of different types and multiplies them together. Print the result.

1

Or rather, Rust forces you to acknowledge how complicated strings really are. They're among the more confusing bits coming in, I find.