Variables

Dear Computer

Chapter 10: Everything Revisited

Variables

Variables in Rust are declared using a let statement. Unlike C and Java, the type is placed after the variable name:

Rust
let lucky: u32 = 62;
let distance: f64 = 13.1;
let is_overdue: bool = true;
let outcome: char = 'w';

In C and Java, redeclaring a variable in the same block is a semantic error. For example, this C program fails to compile:

C
int lucky = 62;
int lucky = 7;    // uh oh

But Rust accepts multiple declarations in the same block. A later declaration shadows any earlier declaration:

Rust
let lucky: u32 = 62;
let lucky: u32 = 7;
let lucky: f64 = 7.0;
println("{}", lucky);    // prints 7.0

Later declarations can even use different types. The earlier declaration goes out of scope just as the later declaration comes in.

As soon as we start naming data, we encounter many language design questions. What types are available? May types be implicit? Can variables change over time? Are global variables supported? Let's explore how Rust answers these questions.

Primitive Types

Most of Rust's integer primitive types explicitly state their number of bits. The explicit unsigned integer types are u8, u16, u32, u64, and u128. The explicit signed integer types are i8, i16, i32, i64, and i128. In C, the size of an int depends on the machine on which the code is running. If a file format or a network protocol expects a 4-byte integer, we should avoid int if we want portable code and instead use types like uint32_t. Rust makes these fixed-width types the norm—except when indexing.

If an integer is going to be used as an index into a collection in Rust's standard library, then its type will likely be usize. This type is the host machine's favored integer size. On a 64-bit machine, it will be 64 bits. On a 32-bit machine, it will be 32 bits.

The floating point types are f32 and f64. Modern CPUs process double-precision numbers nearly as fast as single-precision, so f64 is commonly used.

The Rust compiler rejects implicit coercions from one type to another. The assignment to floating-point y in this code fails because x is an integer:

Rust
let x: u32 = 13;
let y: f64 = x;         // implicit coercion; compile error
let z: f64 = x as f64;  // explicit cast; compiles

The assignment to z succeeds because it explicitly casts the integer using the as casting operator.

The boolean type is bool. A bool value can be cast to integers 0 or 1 using as. Unlike C, the inverse is prohibited: an integer cannot be cast to a bool either implicitly or explicitly.

Type Inference

If we leave off the type of a variable declaration, the compiler attempts to infer the type from the expression on the right-hand side or some later initialization. These implicit declaractions are equivalent to the earlier explicit ones:

Rust
let lucky = 62;
let distance = 13.1;
let is_overdue = true;
let outcome = 'w';

Sometimes we want to know what type is being inferred. In Haskell, we used :t in GHCI to query the type of an expression. Rust doesn't currently have a REPL like GHCI. However, we can get the type of an expression using the type_name_of_val function:

Rust
use std::any::type_name_of_val;

fn main() {
    let distance = 13.1;
    println!("{}", type_name_of_val(&distance));  // f64
}

The use statement imports the function from its module std::any so it can be called without qualification.

Mutability

By default, names cannot be reassigned to other values. This code is illegal:

Rust
let halflife = 831.7;
halflife /= 2.0;       // compile error

If we wish to reassign a variable, we must explicitly opt in to mutability with the mut modifier:

Rust
let mut halflife = 831.7;
halflife /= 2.0;

Earlier we saw how const in JavaScript and final in Java lock down only the variable name from being reassigned, while a non-primitive value to which the name is bound may still be mutated. In Rust, neither the name nor the value may change. This code fails not because of a reassignment but because it calls a mutating method:

Rust
let groceries = Vec::new();
groceries.push("za'atar");    // compile error

Qualifying groceries with mut makes the push call legal.

Const

Rust also has a const qualifier for declaring constants. Surely const is unnecessary if variables are immutable by default? But const has different semantics than it does in other languages. A const must satisfy two semantic constraints:

The first constraint makes a const variable like any variable that lacks a mut qualifier. The second constraint means that we can't assign a value that relies on dynamic data like user input or a value returned from a non-pure function. This program attempts to generate a const random byte:

Rust
use rand::prelude::random;

fn main() {
    const MAGIC_BYTE: u8 = random::<u8>();  // compile error
}

But it fails. The random function is not pure. This assignment does compile:

Rust
fn main() {
    const MAGIC_BYTE: u8 = 255 / 3 + 7;
}

The value is statically deterministic, which is very far from random.

A constant's declaration must have an explicit type. Its identifier should be written in SCREAMING_SNAKE_CASE. This practice is more than a convention. If we don't use capitals, the Rust compiler issues a warning.

Globals

Mutable global data has a track record of not being safe. Functions that rely on global data are impure and difficult to test. Concurrent accessors may put the data in an inconsistent state. There's no clear order in which global data from different compilation units is initialized, which can introduce non-determinism into our program. Nobody likes non-determinism.

Safety is a major concern of the Rust developers. Therefore, they have outlawed global variables, both mutable and immutable. We cannot use let mut or let to declare a variable at a global scope. However, we may declare global const data.

← PrintControl Flow →