Published on

Chapter 3: Common Programming Concepts

15 min read

Authors
banner

Chapter 3: Common Programming Concepts

Variables and Mutability in Rust 🦀

When you first start learning Rust, one of the most striking differences from other programming languages is how it handles variables. Coming from languages like Python or JavaScript where you can freely change any variable's value, Rust's approach might seem restrictive. But this apparent strictness is actually one of Rust's greatest strengths, preventing entire categories of bugs before your code ever runs.

The Immutable Default

In Rust, variables are immutable by default. This means once you bind a value to a name, you cannot change that value. Let's see this in action by creating a new project:

cargo new variables
cd variables

Now, let's write some code that demonstrates this behavior:

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

When you run this code with cargo run, Rust immediately catches the problem:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

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

This error isn't just the compiler being difficult—it's protecting you from bugs. Imagine a large program where one part of your code assumes a configuration value never changes, while another part modifies it. This could lead to subtle bugs that are incredibly hard to track down. Rust eliminates this possibility entirely.

Explicit Mutability

When you do need to change a variable's value, Rust requires you to be explicit about it using the mut keyword:

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

Now the program compiles and runs successfully:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

The mut keyword serves as a clear signal to anyone reading your code: "This variable is intended to change." This explicit declaration makes your code more readable and helps prevent accidental modifications.

Constants

While immutable variables can sometimes be changed (through shadowing, which we'll discuss), constants are truly unchangeable values. They differ from variables in several important ways:

const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;

fn main() {
    println!("Three hours contains {} seconds", THREE_HOURS_IN_SECONDS);
}

Constants have unique characteristics:

  • Always immutable: You cannot use mut with constants
  • Type annotation required: You must explicitly specify the type
  • Global scope allowed: Constants can be declared anywhere, including globally
  • Compile-time evaluation: They can only be set to expressions that can be computed at compile time

The naming convention for constants is ALL_UPPERCASE with underscores between words. This makes them easily identifiable and conveys their permanent nature. Constants are perfect for values like configuration limits, mathematical constants, or any value that represents a fundamental property of your system.

Variable Shadowing

Rust allows you to declare a new variable with the same name as a previous variable, a feature called shadowing. The new variable "shadows" the previous one:

fn main() {
    let x = 5;
    let x = x + 1;
    
    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }
    
    println!("The value of x is: {x}");
}

This program produces:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

Here's what happens step by step:

  1. First x is bound to 5
  2. A new x shadows the first, with value 6 (5 + 1)
  3. Inside the inner scope, another x shadows with value 12 (6 * 2)
  4. When the scope ends, we return to the outer x with value 6

Shadowing differs from mutability in two important ways. First, you get compile-time protection—you must use let to create a new binding. Second, and perhaps more importantly, shadowing allows you to change the type of a value while reusing the same name:

let spaces = "   ";
let spaces = spaces.len();

The first spaces is a string, the second is a number. This is particularly useful when transforming data from one form to another, like parsing user input from a string to a number.

If you tried to do the same thing with a mutable variable, you'd get an error:

let mut spaces = "   ";
spaces = spaces.len(); // This won't compile!
$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

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

The Philosophy Behind Design

These features—immutability by default, explicit mutability, constants, and shadowing—all serve Rust's core mission of preventing bugs while maintaining performance. By making you think carefully about when and how data changes, Rust eliminates entire categories of errors that plague other systems programming languages.

When you encounter Rust's compiler errors, remember they're not obstacles but helpful guides. Each error prevents a potential runtime bug, making your programs more reliable and secure. The habits you develop working with Rust's variable system will make you a more thoughtful programmer in any language.

As you continue your Rust journey, these fundamental concepts will support more advanced features like ownership and borrowing. The explicit thinking about data mutability you're developing now will serve as the foundation for understanding Rust's unique approach to memory safety.

==> : Data Types

Rust's type system is where the magic happens. Every value in Rust has a specific type that tells the compiler what kind of data it's working with and how much memory it occupies. This isn't just bureaucracy—it's the foundation of Rust's memory safety guarantees and zero-cost abstractions.

Rust is a statically typed language, meaning all variable types must be known at compile time. However, the compiler can often infer types based on the value and how it's used, creating a perfect balance between safety and ergonomics.

Scalar Types: The Building Blocks

Scalar types represent single values. Rust has four primary scalar types that form the backbone of all data manipulation.

Integers: Precision by Design

Rust gives you granular control over integer types, letting you choose exactly the right size for your data. Each integer type specifies both the number of bits it uses and whether it's signed or unsigned:

LengthSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize
fn main() {
    let small: i8 = 127;           // 8-bit signed (-128 to 127)
    let big: i64 = 9_223_372_036_854_775_807; // 64-bit signed
    let unsigned: u32 = 4_294_967_295;        // 32-bit unsigned
    
    // Rust infers i32 by default
    let default_int = 42;
    
    // Number literals with type suffixes
    let hex = 0xff_u8;      // 255 as u8
    let octal = 0o77_i32;   // 63 as i32
    let binary = 0b1111_0000_u8; // 240 as u8
    let decimal = 98_222_i32; // Underscores for readability
    
    // Architecture-dependent sizes
    let ptr_sized: isize = 100;  // Same size as a pointer
    let index: usize = 42;       // Commonly used for array indexing
}

The _ separators make large numbers readable, and Rust's default i32 strikes the perfect balance between performance and range for most use cases. The isize and usize types depend on the architecture your program is running on: 64 bits on a 64-bit architecture and 32 bits on a 32-bit architecture.

Integer Overflow: In debug mode, Rust panics on integer overflow. In release mode, Rust performs two's complement wrapping. You can explicitly handle overflow with methods like wrapping_add, checked_add, overflowing_add, and saturating_add.

Floating-Point: IEEE 754 Precision

Rust provides two floating-point types following the IEEE-754 standard. The f32 type is a single-precision float, and f64 is a double-precision float:

fn main() {
    let pi: f64 = 3.141592653589793;  // Double precision (default)
    let e: f32 = 2.718281828;         // Single precision
    
    // Mathematical operations
    let sum = 5.5 + 10.2;             // f64 by default
    let difference = 95.5 - 4.3;
    let product = 4.0 * 30.0;
    let quotient = 56.7 / 32.2;
    let remainder = 43.0 % 5.0;
    
    // Floating-point methods
    let negative = -42.5_f64;
    let absolute = negative.abs();    // 42.5
    let rounded = pi.round();         // 3.0
    let ceiling = 2.1_f32.ceil();     // 3.0
    let floor = 2.9_f32.floor();      // 2.0
    
    // Special values
    let infinity = f64::INFINITY;
    let neg_infinity = f64::NEG_INFINITY;
    let not_a_number = f64::NAN;
}

Rust defaults to f64 because modern processors make it nearly as fast as f32 while providing much better precision. All floating-point types are signed and can represent positive values, negative values, and special values like infinity and NaN (Not a Number).

Boolean: Truth in Simplicity

The boolean type is elegantly simple but crucial for control flow. Rust's bool type has only two possible values: true and false, and it's exactly one byte in size:

fn main() {
    let is_rust_awesome = true;
    let is_learning_hard: bool = false;
    
    // Booleans shine in conditional logic
    if is_rust_awesome && !is_learning_hard {
        println!("Rust is approachable and powerful!");
    }
    
    // Boolean operations
    let logical_and = true && false;    // false
    let logical_or = true || false;     // true
    let logical_not = !true;            // false
    
    // Comparison operators return booleans
    let greater = 5 > 3;                // true
    let equal = 42 == 42;               // true
    let not_equal = 10 != 20;           // true
}

Character: Unicode by Default

Rust's char type represents Unicode scalar values, making internationalization seamless:

fn main() {
    let letter = 'z';
    let emoji = '😻';
    let chinese = '中';
    
    // Each char is 4 bytes, supporting full Unicode
    println!("Char size: {} bytes", std::mem::size_of::<char>());
}

Compound Types: Grouping Data Elegantly

Compound types combine multiple values into a single type, enabling sophisticated data structures.

Tuples: Heterogeneous Collections

Tuples group values of different types into a single compound type:

fn main() {
    let coordinates: (i32, i32, i32) = (10, 20, 30);
    let mixed_data = ("Rust", 2024, true, 3.14);
    
    // Destructuring tuples
    let (x, y, z) = coordinates;
    println!("Position: x={}, y={}, z={}", x, y, z);
    
    // Accessing by index
    let language = mixed_data.0;
    let year = mixed_data.1;
    
    // The unit tuple - Rust's way of representing "nothing"
    let unit: () = ();
}

The empty tuple () is special—it's called the unit type and represents expressions that don't return a meaningful value.

Arrays: Fixed-Size Homogeneous Collections

Arrays in Rust are fixed-size collections of the same type, allocated on the stack:

fn main() {
    // Explicit type annotation
    let months: [&str; 12] = [
        "January", "February", "March", "April",
        "May", "June", "July", "August",
        "September", "October", "November", "December"
    ];
    
    // Type inferred from initialization
    let fibonacci = [1, 1, 2, 3, 5, 8, 13];
    
    // Initialize with repeated values
    let zeros = [0; 5]; // [0, 0, 0, 0, 0]
    
    // Accessing elements
    let first_month = months[0];
    let third_fib = fibonacci[2];
    
    println!("Array length: {}", months.len());
}

Type Safety in Action

Rust's type system prevents entire categories of bugs at compile time:

fn main() {
    let number = 42;
    let text = "Hello";
    
    // This won't compile - type mismatch
    // let result = number + text; // Error!
    
    // Rust forces explicit conversion
    let number_as_string = number.to_string();
    let combined = number_as_string + text;
    
    // Array bounds are checked at runtime
    let arr = [1, 2, 3, 4, 5];
    let index = 10;
    // This will panic at runtime, but won't corrupt memory
    // let element = arr[index]; // Panic: index out of bounds
}

The Power of Inference

Rust's type inference is sophisticated enough to deduce types in most situations:

fn main() {
    let mut numbers = Vec::new(); // Type unknown yet
    numbers.push(42);             // Now Rust knows it's Vec<i32>
    
    let collected: Vec<i32> = (0..10).collect(); // Explicit when needed
    
    // Inference works with complex expressions
    let result = numbers.iter().map(|x| x * 2).collect::<Vec<i32>>();
}

Rust's type system isn't just about preventing bugs—it's about expressing intent clearly and enabling the compiler to generate optimal code. By understanding these fundamental types, you're building the mental model needed to leverage Rust's unique approach to systems programming, where safety and performance aren't mutually exclusive choices.

The beauty lies in how these simple building blocks combine to create complex, safe, and efficient programs. Every type decision you make is a step toward more reliable software.

© 2025 President-XD