Core Concepts: Variables, Data Types, and Operators

Core Concepts: Variables, Data Types, and Operators

Now that your Rust environment is set up, let’s dive into the fundamental building blocks of any programming language: variables, data types, and operators. Understanding these concepts is crucial for writing any meaningful Rust program.

Variables and Mutability

In Rust, variables are used to store data. By default, variables are immutable, meaning once a value is bound to a variable, it cannot be changed. This promotes safer code by making it harder to introduce unexpected side effects.

Declaring Variables

You declare a variable using the let keyword:

fn main() {
    let x = 5; // x is immutable
    println!("The value of x is: {}", x);
    // x = 6; // This would result in a compile-time error!
}

If you try to reassign x, the Rust compiler will give you an error:

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

The error message is helpful! It even suggests a solution if you do intend to change the value.

Mutability with mut

To make a variable mutable (changeable), you add the mut keyword before the variable name:

fn main() {
    let mut x = 5; // x is now mutable
    println!("The value of x is: {}", x);
    x = 6; // This is perfectly fine
    println!("The new value of x is: {}", x);
}

Shadowing

Rust allows you to declare a new variable with the same name as a previous variable. This is called shadowing. When you shadow a variable, the new variable “shadows” or “hides” the old one, and it can even have a different data type.

fn main() {
    let x = 5; // x is an integer

    let x = x + 1; // x is shadowed by a new x, calculated from the old x

    {
        let x = x * 2; // Inner x shadows the outer x
        println!("The value of x in the inner scope is: {}", x); // Output: 12
    }

    println!("The value of x in the outer scope is: {}", x); // Output: 6
}

Shadowing is different from mut because we’re creating a new variable each time. This is useful when you want to transform a value but keep the original variable name for conceptual clarity, while ensuring the original value is no longer accessible.

Data Types

Every value in Rust has a data type. Rust is a statically typed language, meaning it must know the types of all variables at compile time. However, the compiler can often infer the type based on the value you assign, so you don’t always have to write it explicitly.

Rust provides two main categories of data types:

  • Scalar Types: Represent a single value.
  • Compound Types: Group multiple values into one type.

Scalar Types

Scalar types represent a single value. Rust has four primary scalar types: integers, floating-point numbers, Booleans, and characters.

Integers

Integers are whole numbers. Rust’s integer types are signed (can be positive or negative) and unsigned (only positive) and come in various sizes:

LengthSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize
  • isize and usize depend on the architecture of the computer your program is running on (e.g., 64 bits on a 64-bit system). They are primarily used for indexing collections.
  • The default integer type is i32.

Integer Literals:

fn main() {
    let decimal = 98_222; // underscores improve readability
    let hex = 0xff;
    let octal = 0o77;
    let binary = 0b1111_0000;
    let byte = b'A'; // u8 only

    println!("Decimal: {}", decimal);
    println!("Hex: {}", hex);
    println!("Octal: {}", octal);
    println!("Binary: {}", binary);
    println!("Byte: {}", byte); // Prints the ASCII value
}

Integer Overflow: When an integer type exceeds its maximum value (or goes below its minimum), it “overflows.” In debug builds, Rust will panic (crash) on integer overflow. In release builds (with optimizations), Rust performs “two’s complement wrapping,” meaning the value wraps around to the minimum value (e.g., u8::MAX + 1 becomes 0).

For this reason, it’s best to handle potential overflows explicitly using methods like checked_add, saturating_add, overflowing_add, or wrapping_add if wrapping behavior is desired.

fn main() {
    let a: u8 = 255;
    let b: u8 = 1;

    // debug build: will panic!
    // release build: wraps to 0
    // let c = a + b;
    // println!("a + b = {}", c);

    // Explicitly handle overflow
    if let Some(sum) = a.checked_add(b) {
        println!("Checked sum: {}", sum);
    } else {
        println!("Addition overflowed!"); // This will be printed
    }

    let saturating_sum = a.saturating_add(b);
    println!("Saturating sum: {}", saturating_sum); // Output: 255 (clamps to max)

    let (wrapping_sum, did_overflow) = a.overflowing_add(b);
    println!("Wrapping sum: {}, did overflow: {}", wrapping_sum, did_overflow); // Output: 0, true

    let wrapping_sum_simple = a.wrapping_add(b);
    println!("Simple wrapping sum: {}", wrapping_sum_simple); // Output: 0
}

Floating-Point Numbers

Rust has two floating-point types:

  • f32: Single-precision float
  • f64: Double-precision float (default)
fn main() {
    let x = 2.0; // f64
    let y: f32 = 3.0; // f32
    println!("Float x: {}", x);
    println!("Float y: {}", y);
}

Booleans

Booleans represent truth values and can be either true or false.

fn main() {
    let t = true;
    let f: bool = false; // with explicit type annotation
    println!("Boolean t: {}", t);
    println!("Boolean f: {}", f);
}

Characters

Rust’s char type represents a single Unicode scalar value. This means it can represent a lot more than just ASCII characters.

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let heart_eyed_cat = '😻';
    println!("Char c: {}", c);
    println!("Char z: {}", z);
    println!("Char heart_eyed_cat: {}", heart_eyed_cat);
}

Note that char literals use single quotes, while string literals use double quotes.

Compound Types

Compound types can group multiple values into one type. Rust has two primitive compound types: tuples and arrays.

Tuples

A tuple is a general-purpose way of grouping together a number of values with a variety of types into one compound type. Tuples have a fixed length: once declared, they cannot grow or shrink in size.

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
    // Accessing tuple elements:
    let (x, y, z) = tup; // Destructuring
    println!("The value of y is: {}", y); // Output: 6.4

    let five_hundred = tup.0; // Access by index
    let six_point_four = tup.1;
    println!("The first element is: {}", five_hundred); // Output: 500
}

An empty tuple, (), is known as the “unit type” and its value is the “unit value.” It is often used when a function doesn’t return any meaningful value.

Arrays

An array is a collection of values of the same type. Arrays also have a fixed length.

fn main() {
    let a = [1, 2, 3, 4, 5]; // Array of 5 i32 integers
    let b: [i32; 5] = [1, 2, 3, 4, 5]; // Explicit type: array of 5 i32
    let c = [3; 5]; // An array of 5 elements, all initialized to 3. Equivalent to [3, 3, 3, 3, 3]

    // Accessing array elements:
    println!("First element of a: {}", a[0]); // Output: 1
    println!("Third element of c: {}", c[2]); // Output: 3

    // Trying to access an out-of-bounds element will cause a runtime panic
    // let index = 10;
    // let element = a[index]; // This will panic at runtime!
    // println!("Element at index {}: {}", index, element);
}

While arrays are useful, in many cases, you might want to use a Vec (vector), which is a growable list type provided by the standard library. We’ll cover vectors later.

Operators

Rust supports common arithmetic, comparison, logical, and bitwise operators.

Arithmetic Operators

Standard mathematical operations:

  • +: addition
  • -: subtraction
  • *: multiplication
  • /: division
  • %: remainder (modulo)
fn main() {
    // Addition
    let sum = 5 + 10;
    println!("Sum: {}", sum); // 15

    // Subtraction
    let difference = 95.5 - 4.3;
    println!("Difference: {}", difference); // 91.2

    // Multiplication
    let product = 4 * 30;
    println!("Product: {}", product); // 120

    // Division
    let quotient = 56.0 / 3.0;
    println!("Quotient: {}", quotient); // 18.666...
    let truncated = -5 / 3; // Integer division truncates towards zero
    println!("Truncated: {}", truncated); // -1

    // Remainder
    let remainder = 43 % 5;
    println!("Remainder: {}", remainder); // 3
}

Comparison Operators

Used to compare values, resulting in a bool:

  • ==: equals
  • !=: not equals
  • >: greater than
  • <: less than
  • >=: greater than or equal to
  • <=: less than or equal to
fn main() {
    let a = 10;
    let b = 5;

    println!("a == b: {}", a == b); // false
    println!("a != b: {}", a != b); // true
    println!("a > b: {}", a > b);   // true
}

Logical Operators

Combine boolean expressions:

  • &&: logical AND
  • ||: logical OR
  • !: logical NOT
fn main() {
    let is_sunny = true;
    let is_warm = false;

    println!("Is it good weather? {}", is_sunny && !is_warm); // false (true AND true is true, but !is_warm is true)
    println!("Can we go out? {}", is_sunny || is_warm);      // true
}

Bitwise Operators (for integers)

Operate on individual bits of integer types:

  • &: bitwise AND
  • |: bitwise OR
  • ^: bitwise XOR
  • <<: left shift
  • >>: right shift
fn main() {
    let x = 0b1010; // 10 in decimal
    let y = 0b1100; // 12 in decimal

    println!("x & y: {:04b}", x & y); // 0b1000 (8)
    println!("x | y: {:04b}", x | y); // 0b1110 (14)
    println!("x ^ y: {:04b}", x ^ y); // 0b0110 (6)
    println!("x << 1: {:04b}", x << 1); // 0b10100 (20)
    println!("y >> 2: {:04b}", y >> 2); // 0b0011 (3)
}

The {:04b} format specifier prints the number in binary, padded with leading zeros to a width of 4.

Exercises / Mini-Challenges

Reinforce your understanding with these hands-on challenges!

Exercise 2.1: Calculate Area and Perimeter

Write a Rust program that calculates the area and perimeter of a rectangle. Define two mutable integer variables, width and height, assign them values, and then print the calculated area and perimeter.

Instructions:

  1. Initialize width to 10 and height to 5.
  2. Calculate area = width * height.
  3. Calculate perimeter = 2 * (width + height).
  4. Print both results in a user-friendly format.
  5. After printing, change width to 12 and height to 6, then recalculate and print the new area and perimeter.
// Solution Hint:
/*
fn main() {
    let mut width = 10;
    let mut height = 5;

    // Calculate and print initial values

    // Reassign and calculate again
    width = 12;
    height = 6;
    // Calculate and print new values
}
*/

Exercise 2.2: Type Conversion and Shadowing

You are given a value representing the number of seconds, but you need to display it in hours, minutes, and remaining seconds. Use shadowing and type conversion where necessary.

Instructions:

  1. Start with a variable total_seconds of type u32 initialized to 3675.
  2. Shadow total_seconds to calculate the number of hours and store it in a new variable hours.
  3. Shadow total_seconds again to find the remaining seconds after removing hours.
  4. Shadow total_seconds one more time to calculate the number of minutes from the remaining seconds.
  5. The final total_seconds will be the remaining seconds after removing minutes.
  6. Print the result in the format: HH hours, MM minutes, SS seconds.

Example Output (for 3675 seconds): 1 hours, 1 minutes, 15 seconds

// Solution Hint:
/*
fn main() {
    let total_seconds: u32 = 3675;

    let hours = total_seconds / 3600; // 3600 seconds in an hour
    let total_seconds = total_seconds % 3600; // Remaining seconds after extracting hours

    let minutes = total_seconds / 60;
    let total_seconds = total_seconds % 60; // Remaining seconds after extracting minutes

    // Print the results
}
*/

Exercise 2.3: Array Manipulation and Access

Create an array of your top 3 favorite fruits. Then, try to swap the first and last elements.

Instructions:

  1. Declare an array named fruits of type [&str; 3] (an array of 3 string slices) and initialize it with your favorite fruits (e.g., ["Apple", "Banana", "Cherry"]).
  2. Print the initial array.
  3. Implement logic to swap the first and last elements of the array. Hint: You’ll need a temporary variable.
  4. Print the array after the swap.
  5. Attempt to access an element at an index that is clearly out of bounds (e.g., fruits[5]) and observe the compiler’s behavior or runtime panic. Comment out the problematic line after observing.
// Solution Hint:
/*
fn main() {
    let mut fruits = ["Apple", "Banana", "Cherry"];
    println!("Initial fruits: {:?}", fruits);

    let temp = fruits[0];
    fruits[0] = fruits[2];
    fruits[2] = temp;

    println!("Fruits after swap: {:?}", fruits);

    // This line will cause a runtime panic if uncommented:
    // let out_of_bounds_element = fruits[5];
    // println!("Out of bounds element: {}", out_of_bounds_element);
}
*/