Variables and Types in Rust

About This Module

This module covers Rust's type system and variable handling, including immutability by default, variable shadowing, numeric types, boolean operations, characters, and strings. Understanding these fundamentals is essential for all Rust programming.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. Why might immutable variables by default be beneficial for programming?
  2. What is the difference between variable shadowing and mutability?
  3. How do strongly typed languages like Rust prevent certain classes of bugs?
  4. What are the trade-offs between different integer sizes?
  5. Why might string handling be more complex than it initially appears?

Learning Objectives

By the end of this module, you should be able to:

  • Understand Rust's immutability-by-default principle
  • Use mutable variables when necessary
  • Apply variable shadowing appropriately
  • Choose appropriate numeric types for different use cases
  • Work with boolean and bitwise operations
  • Handle characters and strings properly in Rust
  • Understand type conversion and casting in Rust

Variables are by default immutable!

Take a look at the following code.

Note: we'll use a red border to indicate that the code is expected to fail compilation.

#![allow(unused)]
fn main() {
let x = 3;
x = x + 1; // <== error here
}

Run it and you should get the following error.

   Compiling playground v0.0.1 (/playground)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:1
  |
3 | let x = 3;
  |     - first assignment to `x`
4 | x = x + 1; // <== error here
  | ^^^^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
3 | let mut x = 3;
  |     +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `playground` (bin "playground") due to 1 previous error

The Rust compiler errors are quite helpful!

Use mut to make them mutable

#![allow(unused)]
fn main() {
// mutable variable
let mut x = 3;
x = x + 1;
println!("x = {}", x);
}

Assigning a different type to a mutable variable

What happens if you try to assign a different type to a mutable variable?

#![allow(unused)]
fn main() {
// mutable variable
let mut x = 3;
x = x + 1;
println!("x = {}", x);
x = 9.5;   // what happens here??
println!("x = {}", x);
}

Again, the Rust compiler error message is quite helpful!

Variable Shadowing

You can create a new variable with the same name as a previous variable!

fn main() {
let solution = "4";  // This is a string

// Create a new variable with same name and convert string to integer
let solution : i32 = solution.parse()
                     .expect("Not a number!");

// Create a third variable with the same name!
let solution = solution * (solution - 1) / 2;
println!("solution = {}",solution);

// Create a fourth variable with the same name!
let solution = "This is a string";
println!("solution = {}", solution);
}

In this example, you can't get back to the original variable, although it stays in memory until it goes of out scope.

Question: why would you want to do this?

Question: Can you use mut and avoid variable shadowing? Try it above.

Variable Shadowing and Scopes

Rust automatically deallocates variables when they go out of scope, such as when a program ends.

You can also use a block (bounded by {}) to limit the scope of a variable.

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

{ // start of block scope

    let x = 2;  // shadows outer x
    println!("{}", x); // prints `2`

} // end of block scope

println!("{}", x);    // prints `1` again — outer `x` visible
}

Basic Types: unsigned integers

unsigned integers: u8, u16, u32, u64, u128

  • usize is the default unsigned integer size for your architecture

The number, e.g. 8, represents the number of bits in the type and the maximum value.

  • So unsigned integers range from to .
Unsigned IntegerUnsigned 8 bit binary
000000000
100000001
200000010
300000011

Here's how you convert from binary to decimal.

Basic Types: unsigned integers - min and max values

Rust lets us print the minimum and maximum values of each type.

#![allow(unused)]
fn main() {
println!("U8 min is {} max is {}", u8::MIN, u8::MAX);
println!("U16 min is {} max is {}", u16::MIN, u16::MAX);
println!("U32 min is {} max is {}", u32::MIN, u32::MAX);
println!("U64 min is {} max is {}", u64::MIN, u64::MAX);
println!("U128 min is {} max is {}", u128::MIN, u128::MAX);
println!("USIZE min is {} max is {}", usize::MIN, usize::MAX);
}

Verify u8::MAX on your own.

Question: What is the usize on your machine?

Basic Types: signed integers

Similarly, there are these signed integer types.

signed integers: i8, i16, i32 (default), i64, i128,

isize is the default signed integer size for your architecture

  • from to

Unsigned integers - min and max values

#![allow(unused)]
fn main() {
println!("I8 min is {} max is {}", i8::MIN, i8::MAX);
println!("I16 min is {} max is {}", i16::MIN, i16::MAX);
println!("I32 min is {} max is {}", i32::MIN, i32::MAX);
println!("I64 min is {} max is {}", i64::MIN, i64::MAX);
println!("I128 min is {} max is {}", i128::MIN, i128::MAX);
println!("ISIZE min is {} max is {}", isize::MIN, isize::MAX);
}

Signed integer representation

Signed integers are stored in two's complement format.

  • if the number is positive, the first bit is 0
  • if the number is negative, the first bit is 1
Signed IntegerSigned 8 bit binary
000000000
100000001
200000010
300000011
-111111111
-211111110
-311111101

Here's how you convert from binary to decimal.

If the first bit is 0, the number is positive. If the first bit is 1, the number is negative.

To convert a negative number to decimal:

  • take the sign of the first bit,
  • flip all the bits and add 1 (only for negative numbers!)

Exercise: Try that for -1

Converting between signed and unsigned integers

If you need to convert, use the as operator:

#![allow(unused)]
fn main() {
let x: i8 = -1;
let y: u8 = x as u8;
println!("{}", y);
}

Question: Can you explain the answer?

Why do we need ginormous i128 and u128?

They are useful for cryptography.

Don't use datatype sizes larger than you need.

Larger than architecture default generally takes more time.

i64 math operations might be twice as slow as i32 math.

Number literals

Rust lets us write number literals in a few different ways.

Number literalsExample
Decimal (base 10)98_222
Hex (base 16)0xff
Octal (base 8)0o77
Binary (base 2)0b1111_0000
Byte (u8 only)b'A'
#![allow(unused)]
fn main() {
let s1 = 2_55_i32;
let s2 = 0xff;
let s3 = 0o3_77;
let s4 = 0b1111_1111;

// print in decimal format
println!("{} {} {} {}", s1, s2, s3, s4);

// print in different bases
println!("{} 0x{:X} 0o{:o} 0b{:b}", s1, s2, s3, s4);
}

Be careful with math on ints

fn main() {
let x : i16 = 13;
let y : i32 = -17;

// won't work without the conversion
println!("{}", x * y);   // will not work
//println!("{}", (x as i32)* y); // this will work
}

Basic Types: floats

There are two kinds: f32 and f64

What do these mean?

  • This is the number of bits used in each type
  • more complicated representation than ints (see wikipedia)
  • There is talk about adding f128 to the language but it is not as useful as u128/i128.
fn main() {
let x = 4.0;
println!("x is of type {}", std::any::type_name_of_val(&x));

let z = 1.25;
println!("z is of type {}", std::any::type_name_of_val(&z));

println!("{:.1}", x * z);
}

Exercise: Try changing the type of x to f32 and see what happens: let x:f32 = 4.0;

Floats gotchas

Be careful with mixing f32 and f64 types.

You can't mix them without converting.

fn main() {
let x:f32 = 4.0;
println!("x is of type {}", std::any::type_name_of_val(&x));

let z:f64 = 1.25;
println!("z is of type {}", std::any::type_name_of_val(&z));


println!("{:.1}", x * z);

//println!("{:.1}", (x as f64) * z); // this will work
}

Floats: min and max values

Rust lets us print the minimum and maximum values of each type.

#![allow(unused)]
fn main() {
println!("F32 min is {} max is {}", f32::MIN, f32::MAX);
println!("F32 min is {:e} max is {:e}\n", f32::MIN, f32::MAX);
println!("F64 min is {:e} max is {:e}", f64::MIN, f64::MAX);
}

Exercise -- Integers and Floats

Create a program that:

  1. creates a u8 variable n with value 77
  2. creates an f32 variable x with value 1.25
  3. prints both numbers
  4. multiplies them and puts the results in an f64 variable result
  5. prints the result

Example output:

77
1.25
77 * 1.25 = 96.25

Get your code working here (or in your own editors) and then paste the result in Gradescope.

fn main() {

}

More Basic Types

Let's look at:

  • Booleans
  • Characters
  • Strings

Logical operators and bool

  • bool uses one byte of memory

Question: Why is bool one byte when all we need is one bit?

We can do logical operations on booleans.

#![allow(unused)]
fn main() {
let x = true;
println!("x uses {} bits", std::mem::size_of_val(&x) * 8);

let y: bool = false;
println!("y uses {} bits\n", std::mem::size_of_val(&y) * 8);

println!("{}", x && y); // logical and
println!("{}", x || y); // logical or
println!("{}", !y);    // logical not
}

Bitwise operators

There are also bitwise operators that look similar to logical operators but work on integers:

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

println!("{x:04b} & {y:04b} = {:04b}", x & y); // bitwise and
println!("{x:04b} | {y:04b} = {:04b}", x | y); // bitwise or
println!("!{y:04b} = {:04b} or {0}", !y); // bitwise not
}

Bitwise 'not' and signed integers

#![allow(unused)]
fn main() {
let y = 7;

println!("!{y:04b} = {:04b} or {0}", !y); // bitwise not
}

What's going on with that last line?

y is I32, so let's display all 32 bits.

#![allow(unused)]
fn main() {
let y = 7;
println!("{:032b}", y);
}

So when we do !y we get the bitwise negation of y.

#![allow(unused)]
fn main() {
let y = 7;
println!("{:032b}", !y);
}

It's still interpreted as a signed integer.

#![allow(unused)]
fn main() {
let y = 7;
println!("{}", !y);
}

Bitwise Operators on Booleans?

It's a little sloppy but it works.

#![allow(unused)]
fn main() {
let x = true;
println!("x is of type {}", std::any::type_name_of_val(&x));
println!("x uses {} bits", std::mem::size_of_val(&x) * 8);

let y: bool = false;
println!("y uses {} bits\n", std::mem::size_of_val(&y) * 8);

// x and (not y)
println!("{}", x & y);  // bitwise and
println!("{}", x | y);  // bitwise or
println!("{}", x ^ y);  // bitwise xor
}

Exercise -- Bitwise Operators on Integers

Create a program that:

  1. Creates an unsigned int x with value 12 and a signed int y with value -5
  2. Prints both numbers in binary format (use {:08b} for 8-bit display)
  3. Performs bitwise AND (&) and prints the result in binary
  4. Performs bitwise OR (|) and prints the result in binary
  5. Performs bitwise NOT (!) on both numbers and prints the results

Example output:

12: 00001100
-5: 11111011
12 & -5: 00001000
12 | -5: 11111101
!12: 11110011
!-5: 00000100
fn main() {

}

Characters

Note that on Mac, you can insert an emoji by typing Control-Command-Space and then typing the emoji name, e.g. 😜.

On Windows, you can insert an emoji by typing Windows-Key + . or Windows-Key + ; and then typing the emoji name, e.g. 😜.

#![allow(unused)]
fn main() {
let x: char = 'a';
println!("x is of type {}", std::any::type_name_of_val(&x));
println!("x uses {} bits", std::mem::size_of_val(&x) * 8);

let y = '🚦';
println!("y is of type {}", std::any::type_name_of_val(&y));
println!("y uses {} bits", std::mem::size_of_val(&y) * 8);

let z = '🦕';
println!("z is of type {}", std::any::type_name_of_val(&z));
println!("z uses {} bits", std::mem::size_of_val(&z) * 8);

println!("{} {} {}", x, y, z);
}

Strings and String Slices (&str)

In Rust, strings are not primitive types, but rather complex types built on top of other types.

String slices are immutable references to string data.

  • String is a growable, heap-allocated data structure

  • &str is an immutable reference to a string slice

  • String is a wrapper around Vec<u8> (More on Vec later)

  • &str is a wrapper around &[u8]

  • string slice defined via double quotes (not so basic actually!)

String and string slice examples

fn main() {
    let s1 = "Hello! How are you, 🦕?";  // type is immutable borrowed reference to a string slice: `&str`
    let s2 : &str = "Καλημέρα από την Βοστώνη και την DS210";  // here we make the type explicit
    
    println!("{}", s1);
    println!("{}\n", s2);
}

String and string slice examples

We have to explicitly convert a string slice to a string.

fn main() {

    // This doesn't work.  You can't do String = &str
    let s3: String = "Does this work?"; // <== error here
    
    let s3: String = "Does this work?".to_string();
    println!("{}", s3);
}

Comment out the error lines and run the code to see what happens.

String and string slice examples

We can't index directly into a string slice, because it is a complex data structure.

Different characters can take up different numbers of bytes in UTF-8.

fn main() {
    let s4: String = String::from("How about this?");
    println!("{}\n", s4);

    let s5: &str = &s3;
    println!("str reference to a String reference: {}\n", s5);
    
    // This won't work.  You can't index directly into a string slice. Why???
    println!("{}", s1[3]); // <== error here
    println!("{}", s2[3]); // <== error here

    // But you can index this way.
    println!("4th character of s1: {}", s1.chars().nth(3).unwrap());
    println!("4th character of s2: {}", s2.chars().nth(3).unwrap());
    println!("3rd character of s4: {}", s4.chars().nth(2).unwrap());
}

Comment out the error lines and run the code to see what happens.

Exercise -- String Slices

Create a program that:

  1. Creates a string slice containing your name
  2. Converts it to a String
  3. Gets the third character of your name using the .chars().nth() method
  4. Prints both the full name and the third character

Example output if your name is "Alice":

Alice
i
fn main() {


}

Recap

  • Variables are by default immutable
  • Use mut to make them mutable
  • Variable shadowing is a way to reuse the same name for a new variable
  • Booleans are one byte of memory
  • Bitwise operators work on integers
  • Characters are four bytes of memory
  • Strings are complex data structures
  • String slices are immutable references to string data