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 ofx
will be4
, since the last expression in the block,n + n
, produces the value4
and is implicitly returned. Remember, we did not put a semicolon;
aftern + 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 alet
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 as3.14
,1 + 2
,true && false
, etc., and cannot come from a function call evaluated at runtime (except forconst
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 usingpub
, 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 unlikelet
, a static variable must be declared with an explicit type, and its value must be a compile-time constant. However, unlikeconst
, 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 unlikelet
, 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 makesstatic
ideal for storing large values or data structures that don’t need to be inlined likeconst
and can be accessed via a fixed memory address. - Like
let
,static
is immutable by default but can be made mutable usingmut
. However, making astatic
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
andstatic
data defined at the file level can be collectively referred to as global variables, as they are globally accessible throughout the entire program. Whileconst
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