Variables and Data Mutability

In this lesson, we will learn how to declare variables in Rust, explore the concepts of data mutability and immutability, and understand their impact on memory safety and performance.

Declaring variables in Rust is simple and similar to other programming languages.

Naming Convention

We define a variable in Rust using the let keyword. Variable names should contain only alphanumeric characters, with optional underscores (_). In Rust, it is idiomatic to use the snake_case convention rather than camelCase. Variable names are case-sensitive, so attention must be paid to their exact spelling and capitalization.

// main.rs

fn main() {
    let my_data: i32 = 1;

    println!("my_data: {}", my_data);
}

In the program above, we have declared a variable my_data of type i32, which stores a signed 32-bit integer value. In Rust, we specify the type of a variable using the : <type> notation, which comes after the variable’s name. Then, we initialize the variable with the initial value 1. The ; (semicolon) marks the end of the statement. After that, we can refer to the variable’s value using its name.

We then print the value of my_data using the println! macro. The {} format specifier expect the data to be substituted in the string after the , in the order of their appearance. Since my_data is a number, Rust automatically converts it to a string under the hood so it can be substituted into the string.

$ cargo run
my_data: 1

Default Values

Unlike some programming languages, Rust doesn’t assign a default value to variables. For example, variables defined in JavaScript without an initial value get an undefined value, while in Go, a variable gets the zero value of its data type. In Rust, however, a variable must be initialized with a value before it can be used.

Some languages, like Java, use null to signal the absence of a value, while JavaScript, ever indecisive, uses both null and undefined. Go tries to smooth things over with a default value. Meanwhile, Rust stands tall and says, ‘No value? No problem, just initialize it or you’re not going anywhere!’

Data Immutability

Variables are immutable by default in Rust. Once defined, their value cannot be changed. Let’s explore what this means in practice.

fn main() {
    let my_data: i32 = 1;
    println!("[before] my_data: {}", my_data);

    my_data = 2;
    println!("[after] my_data: {}", my_data);
}

In the program above, we assigned a new value 2 to the variable my_data. Ideally, it should have just logged my_data: 2, but this program fails to compile with an error.

$ cargo run
   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
error[E0384]: cannot assign twice to immutable variable `my_data`
 --> src/main.rs:5:5
  |
2 |     let my_data: i32 = 1;
  |         ------- first assignment to `my_data`
...
5 |     my_data = 2;
  |     ^^^^^^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut my_data: i32 = 1;
  |         +++

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

Rust also provides us with detailed information about where the problem is in our code and how to fix it. From the error message, we can see that “cannot assign twice to immutable variable,” which means my_data = 2; was an illegal statement because the variable my_data is immutable. To fix this issue, Rust suggests we “consider making this binding mutable.” So, how do we make a variable mutable?

We use the mut keyword before the variable name to make it mutable. With this keyword, Rust understands that some part of the code may change the value of the variable, and that’s allowed.

fn main() {
    let mut my_data: i32 = 1;
    println!("[before] my_data: {}", my_data);

    my_data = 2;
    println!("[after] my_data: {}", my_data);
}

With this change, our program successfully compiles.

$ cargo run
[before] my_data: 1
[after] my_data: 2

Type inference

We will discuss Data Types in the next lesson since it’s a broad topic, but there’s an important concept we must understand regarding variable declarations. When we declare a variable with let, we don’t always need to specify the type, unlike with const and static. Rust can infer the type from the initial value.

By default, when we declare a variable with a numerical value such as 1 or 1328 without explicitly providing a type, Rust assigns it the i32 type. Similarly, for boolean values like true and false, Rust infers the type as bool, and for floating point numbers like 3.14 and -0.03, Rust assigns the type f64. This helps simplify code and reduces boilerplate.

fn main() {
    let a = 3; // The type is inferred here.

    let b: i32 = 4;

    println!("Sum: {}", a + b);
}

// $ cargo run
// Sum: 7

Though type inference in Rust is a great quality-of-life feature, it doesn’t always work in every situation. For example, if we are parsing a string into a number, we need to explicitly specify the type into which the string should be parsed. This is because Rust cannot infer the target type at compile time. It doesn’t know whether the string should be parsed into an integer, a floating-point number, or something else.

fn main() {
    let text = "3.14";
    let num = text.parse().unwrap();

    println!("Parsed: {}", num);
}

The above program does not compile and throws a compile-time error.

$ cargo run
   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
error[E0284]: type annotations needed
 --> src/main.rs:3:9
  |
3 |     let num = text.parse().unwrap();
  |         ^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `num` an explicit type
  |
3 |     let num: /* Type */ = text.parse().unwrap();
  |            ++++++++++++

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

The error also explains what we need to do with the message: “consider giving num an explicit type.”. With that change, our program compiles and gives us the expected result.

fn main() {
    let text = "3.14";
    let num: f64 = text.parse().unwrap();

    println!("Parsed: {}", num);
}

// $ cargo run
// Parsed: 3.14

Scopes

A variable in Rust is always valid within a certain scope. A variable defined within a function body cannot be accessed outside of it.

fn sum(a: i32, b: i32) -> i32 {
    let result = a + b;
    return result;
}

fn main() {
    let answer = sum(1, 2);
    println!("Answer: {}", answer);

    println!("result: {}", result);
}

In the program above, we defined a sum function that takes two i32 arguments and returns the value of result, a variable defined inside the function. When we call the sum function, it passes the values 1 and 2 as arguments, initializes the result variable with the value of the arithmetic expression a + b, and returns a copy of the value of result. Once the function returns, all variables declared inside the function body are dropped.

If we try to access the result variable from within the main function, we will get a compile-time error. This is because the result variable is not found in the scope of the main function since it only exists inside the sum function.

$ cargo run
   Compiling hello_world v0.1.0 (/Users/udayhiwarale/Projects/personal/runtimepanic-projects/rust/hello_world)
error[E0425]: cannot find value `result` in this scope
  --> src/main.rs:10:28
   |
10 |     println!("result: {}", result);
   |                            ^^^^^^ not found in this scope

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

We can also declare a custom scope using {} (curly brackets). This is called a block scope. Any variables declared inside this block are only valid within the block and are dropped when the block is exited.

fn main() {
    let a = 1;
    let b = 2;
    let mut answer: i32 = 0;

    {
        let result = a + b;
        answer = result;
    }

    // `result` is not accesible here

    println!("Answer: {}", answer);
}

// $ cargo run
// Answer: 3

In the program above, the variables a, b, and answer are accessible throughout the main function. Within a block scope, we define the variable result, which stores the sum of a and b. The variables a and b are accessible inside this block scope since they were defined in the parent scope. We then store a copy of the value of result in answer, after which the block scope ends. Once the block exits, result is no longer available because it only exists within the block scope.

A block scope can also act as an expression. If you assign this to a variable, the last expression in the block is implicitly returned as a value. For example, let x = { let n = 2; n + n }; — here, the value of x will be 4, since the last expression in the block, n + n, produces the value 4 and is implicitly returned. Remember, we did not put a semicolon ; after n + n, as doing so would turn it into a statement rather than an expression.

Constants

A constant is similar to an immutable let variable (_without mut_), whose value cannot be changed, but it is defined with the const keyword. Constants and variables share many similarities, but they have some key differences:

  • Unlike let, where the value is allocated in memory at runtime, the value of a constant is embedded directly into the binary at compile time. A constant always holds a fixed, immutable value that must be fully determined during compilation, whereas a let variable can hold a value that may change at runtime depending on logic or input. This is why a constant’s value must be a compile-time expression such as 3.14, 1 + 2, true && false, etc., and cannot come from a function call evaluated at runtime (except for const functions, which can be evaluated at compile time).
  • A constant must be defined with an explicit type, unlike let, where Rust can infer the type from the initial value. This is because constants are evaluated at compile time, and type inference may not always be reliable or suitable for this purpose.
  • Unlike let, constants can be declared at the file or module level, and they can also be made public using pub, allowing them to be shared across modules.
  • Constant names are usually written in all uppercase letters, with optional underscores (_) for separation.
const GLOBAL_CONST: u32 = 1;

fn main() {
    const LOCAL_CONST: i64 = 2;

    {
        const INNER_CONST: i64 = 3;
        println!("Inner: {}", INNER_CONST);
    }

    println!("Global: {}, Local: {}", GLOBAL_CONST, LOCAL_CONST);
}

In the program above, we have declared a few constants. The constant GLOBAL_CONST is declared at the file (or root) level, meaning it is accessible throughout the entire program. The constant LOCAL_CONST is declared within the main function, so it is scoped to that function and cannot be accessed outside of it. Similarly, the constant INNER_CONST is defined inside a block ({}) within the main function, and it is only accessible within that block.

$ cargo run
Inner: 3
Global: 1, Local: 2

Constant values are inlined

The value of a constant is inlined into the binary at compile time. Inlining is a compiler optimization process where, instead of referencing a constant by its name, the compiler directly replaces the constant’s name with its actual value in every place it’s used. This substitution occurs during compilation, which helps eliminate the need for memory allocation for the constant.

const PI: f64 = 3.14;

fn main() {
    let r = 2.0;
    let area = PI * r * r;

    println!("Area: {}", area);
}

In the program above, we defined a constant PI at the top of the file and used its value in the PI * r * r expression. Although it may seem like PI would refer to the value 3.14 stored in memory at runtime, the Rust compiler actually substitutes the constant PI directly in the expression during compilation. So, in the compiled binary, you’ll find 3.14 * r * r instead of PI * r * r.

This optimization does not affect the expected result but makes the program more efficient at runtime by avoiding a memory lookup. However, it comes with the consequence that PI doesn’t have a fixed memory address because its value is inlined.

$ cargo run
Area: 12.56

Static variables

Like let, there is also the static keyword to define a variable, but unlike let and more like const, it has some unique properties:

  • Like const, but unlike let, a static variable must be declared with an explicit type, and its value must be a compile-time constant. However, unlike const, a static variable is not inlined in the binary. Instead, it is stored at a fixed memory location, so every reference to a static variable points to the same memory address.
  • Like const, but unlike let, static variables can be defined at the file or module level and follow the same naming conventions (usually uppercase with underscores).
  • Once defined, the value of a static variable lives for the entire duration of the program (referred to as having a ‘static lifetime, which we will explore in a later lesson). This makes static ideal for storing large values or data structures that don’t need to be inlined like const and can be accessed via a fixed memory address.
  • Like let, static is immutable by default but can be made mutable using mut. However, making a static variable mutable is considered unsafe and should be avoided in most cases.
static PI: f64 = 3.14;

fn main() {
    let r = 2.0;
    let area = PI * r * r;

    println!("Area: {}", area);
}

// Area: 12.56

In the above program, unlike with const, the PI value is not inlined. At compile time, the compiler allocates a fixed memory address for this static variable, and all references to PI are replaced with this memory address. Effectively, the expression PI * r * r becomes <addr_of_PI> * r * r in the compiled binary. While this approach may not be as performant as inlining with const, it avoids the overhead of duplicating (inlining) large values by using a single fixed memory address, which can be more efficient for large or frequently used data.

Both const and static data defined at the file level can be collectively referred to as global variables, as they are globally accessible throughout the entire program. While const represents compile-time constants that are inlined wherever used, static refers to data stored at a fixed memory location for the duration of the program. Both are accessible globally, but differ in their handling of memory and mutability.

Unused variables

If you declare a variable but do not use it, Rust typically issues a warning about it.

fn main() {
    let a = 1;

    println!("Hello, world!");
}

In the program above, we declared a variable a but didn’t make use of it. If we try to run this, the program compiles, but with a warning, as shown below.

$ cargo run
   Compiling hello_world v0.1.0 (/Users/thatisuday/rust/hello_world)
warning: unused variable: `a`
 --> src/main.rs:2:9
  |
2 |     let a = 1;
  |         ^ help: if this is intentional, prefix it with an underscore: `_a`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: `hello_world` (bin "hello_world") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s
     Running `target/debug/hello_world`
Hello, world!

If we want to suppress this warning, we need to prefix the variable name with an underscore (_).

fn main() {
    let _a = 1;

    println!("Hello, world!");
}

// $ cargo run
// Hello, world!

In the code above, the variable _a is created unnecessarily. If the intention is to ignore the value entirely, you can assign it to _ instead of using _a. This prevents creating an actual variable and can be useful in scenarios where the value is not needed.

fn main() {
    let _ = 1;

    println!("Hello, world!");
}

// $ cargo run
// Hello, world!

Shadowing

One of my favorite features of Rust is shadowing. Often, we declare a variable, modify its value, and store it in another variable. However, coming up with names for these intermediate variables can be frustrating—especially when we don’t even use the original variable afterward.

Shadowing allows us to redeclare a variable with the same name within the same scope, effectively “replacing” the old variable. The new variable can even have a different type than the one it is shadowing.

fn main() {
    let fullname: &str = "John Doe";
    let fullname: String = fullname.to_uppercase();

    println!("Full Name: {}", fullname);
}

// $ cargo run
// Full Name: JOHN DOE

In the example above, we first declared a variable fullname and assigned it the value of a string literal, which is of type &str. Later, we want to convert this value to uppercase, but we don’t want to declare a new variable to store it. Instead, we want to reuse the fullname variable. While we could have made fullname mutable, the problem is that fullname.to_uppercase() returns a value of type String, which is different from &str (we will learn about this later). Rust doesn’t allow changing a variable’s type in this way when it’s mutable, but with shadowing, we can redeclare fullname with a new type (String) and assign it the uppercase version of the original value.

fn main() {
    let firstname = "John";
    let mut lastname = "Doe";

    {
        let firstname = "Jane";
        lastname = "Smith";
        println!("[inner] firstname: {}, lastname: {}", firstname, lastname);
    }

    println!("[outer] firstname: {}, lastname: {}", firstname, lastname);
}

In the example above, we declared the variables firstname and lastname with initial values of "John" and "Doe", respectively. The variable lastname is mutable, allowing its value to be updated later.

Inside the block scope, we shadow the firstname variable with a new value "Jane". Remember, when we shadow a variable, it doesn’t update the original variable; instead, it creates a new variable with the same name that exists only in that scope. However, we updated the value of the mutable lastname variable with "Smith", so when we log the value of lastname outside the block scope, the change is reflected, as the original lastname variable was modified.

$ cargo run
[inner] firstname: Jane, lastname: Smith
[outer] firstname: John, lastname: Smith
#rust #variables