Chapter 5: Common Programming Concepts
This chapter introduces fundamental programming concepts shared by most languages, illustrating how they function in Rust and drawing comparisons with C where relevant. We will cover keywords, identifiers, expressions and statements, core data types (including scalar types, tuples, and arrays), variables (focusing on mutability, constants, and statics), operators, numeric literals, arithmetic overflow behavior, performance aspects of numeric types, and comments.
While many concepts will feel familiar to C programmers, Rust’s handling of types, mutability, and expressions often introduces stricter rules for enhanced safety and clarity. We defer detailed discussion of control flow (like if and loops) and functions until after covering memory management, as these constructs frequently interact with Rust’s ownership model. Similarly, Rust’s struct and powerful enum types, along with standard library collections like vectors and strings, will be detailed in dedicated later chapters.
5.1 Keywords
Keywords are predefined, reserved words with special meanings in the Rust language. They form the building blocks of syntax and cannot be used as identifiers (like variable or function names) unless escaped using the raw identifier syntax (r#keyword). Many Rust keywords overlap with C/C++, but Rust adds several unique ones to support features like ownership, borrowing, pattern matching, and concurrency.
5.1.1 Raw Identifiers
Occasionally, you might need to use an identifier that conflicts with a Rust keyword. This often happens when interfacing with C libraries or using older Rust code (crates) written before a word became a keyword in a newer Rust edition.
To resolve this, Rust provides raw identifiers: prefix the identifier with r#. This tells the compiler to treat the following word strictly as an identifier, ignoring its keyword status.
For example, if a C library exports a function named try (a reserved keyword in Rust), you would call it as r#try() in your Rust code. Similarly, if Rust introduces a new keyword like gen (as in the 2024 edition) that was used as a function or variable name in an older crate you depend on, you can use r#gen to refer to the item from the old crate.
fn main() {
// 'match' is a keyword, used for pattern matching.
// To use it as a variable name, we need `r#`.
let r#match = "Keyword used as identifier";
println!("{}", r#match);
// 'type' is also a keyword.
struct Example {
r#type: i32, // Use raw identifier for field name
}
let instance = Example { r#type: 1 };
println!("Field value: {}", instance.r#type);
// 'example' is NOT a keyword. Using r# is allowed but unnecessary.
// Both 'example' and 'r#example' refer to the same identifier.
let example = 5;
let r#example = 10; // This shadows the previous 'example'.
println!("Example: {}", example); // Prints 10
// Note: Inside format strings like println!, use the identifier *without* r#.
// println!("{}", r#match); // This would be a compile error.
}
While you can use r# with non-keywords, it’s generally only needed for actual keyword conflicts or, rarely, for future-proofing if you suspect an identifier might become a keyword later.
5.1.2 Keyword Categories
Rust classifies keywords into three groups:
- Strict Keywords: Actively used by the language and always reserved.
- Reserved Keywords: Reserved for potential future language features; currently unused but cannot be identifiers.
- Weak Keywords: Have special meaning only in specific syntactic contexts; can be used as identifiers elsewhere.
5.1.3 Strict Keywords
These keywords have defined meanings and cannot be used as identifiers without r#.
| Keyword | Description | C/C++ Equivalent (Approximate) |
|---|---|---|
as | Type casting, renaming imports (use path::item as new_name;) | (type)value, static_cast |
async | Marks a function or block as asynchronous | C++20 co_await context |
await | Pauses execution until an async operation completes | C++20 co_await |
break | Exits a loop or block prematurely | break |
const | Declares compile-time constants | const |
continue | Skips the current loop iteration | continue |
crate | Refers to the current crate root | None |
dyn | Used with trait objects for dynamic dispatch | Virtual functions (indirectly) |
else | The alternative branch for an if or if let expression | else |
enum | Declares an enumeration (sum type) | enum |
extern | Links to external code (FFI), specifies ABI | extern "C" |
false | Boolean literal false | false (C++), 0 (C) |
fn | Declares a function | Function definition syntax |
for | Loops over an iterator | for, range-based for (C++) |
gen | Reserved (Rust 2024+, experimental generators) | C++20 coroutines |
if | Conditional expression | if |
impl | Implements methods or traits for a type | Class methods (C++), None (C) |
in | Part of for loop syntax (for item in iterator) | Range-based for (C++) |
let | Introduces a variable declaration | Declaration syntax (no direct keyword) |
loop | Creates an unconditional, infinite loop | while(1), for(;;) |
match | Pattern matching expression | switch (less powerful) |
mod | Declares a module | Namespaces (C++), None (C) |
move | Forces capture-by-value in closures | Lambda captures (C++) |
mut | Marks a variable or reference as mutable | No direct C equivalent (const is inverse) |
pub | Makes an item public (visible outside its module) | public: (C++ classes) |
ref | Binds by reference within a pattern | & in patterns (C++) |
return | Returns a value from a function early | return |
Self | Refers to the implementing type within impl or trait blocks | Current class type (C++) |
self | Refers to the instance in methods (&self, &mut self, self) | this pointer (C++) |
static | Defines static items (global lifetime) or static lifetimes | static |
struct | Declares a structure (product type) | struct |
super | Refers to the parent module | .. in paths (conceptual) |
trait | Declares a trait (shared interface/behavior) | Abstract base class (C++), Interface (conceptual) |
true | Boolean literal true | true (C++), non-zero (C) |
type | Defines a type alias or associated type in traits | typedef, using (C++) |
unsafe | Marks a block or function with relaxed safety checks | C code is implicitly unsafe |
use | Imports items into the current scope | #include, using namespace |
where | Specifies constraints on generic types | requires (C++20 Concepts) |
while | Loops based on a condition | while |
5.1.4 Reserved Keywords (For Future Use)
These are currently unused but reserved for potential future syntax. Avoid using them as identifiers.
| Reserved Keyword | Potential Use Area | C/C++ Equivalent (Possible) |
|---|---|---|
abstract | Abstract types/methods | virtual ... = 0; (C++) |
become | Tail calls? | None |
box | Custom heap pointers | std::unique_ptr (concept) |
do | do-while loop? | do |
final | Prevent overriding | final (C++) |
macro | Alternative macro system? | #define (concept) |
override | Explicit method override | override (C++) |
priv | Private visibility? | private: (C++) |
try | Error handling syntax | try (C++) |
typeof | Type introspection? | typeof (GNU C), decltype (C++) |
unsized | Dynamically sized types | None |
virtual | Virtual dispatch | virtual (C++) |
yield | Generators/coroutines | co_yield (C++20) |
5.1.5 Weak Keywords
These words have special meaning only in specific contexts. Outside these contexts, they can be used as identifiers without r#.
union: Special meaning when defining aunion {}type, otherwise usable as an identifier.'static: Special meaning as a specific lifetime annotation, otherwise usable (though rare due to the leading').- Contextual Keywords (Examples): Words like
defaultcan have meaning within specificimplblocks but might be usable elsewhere.macro_rulesis primarily seen as the introducer for declarative macros.
5.1.6 Comparison with C/C++
While C programmers will recognize keywords like if, else, while, for, struct, enum, const, and static, Rust introduces many new ones. Keywords like let, mut, match, mod, crate, use, impl, trait, async, await, and unsafe reflect Rust’s different approaches to variable declaration, mutability control, pattern matching, modularity, interfaces, asynchronous programming, and safety boundaries. The ownership system itself doesn’t have dedicated keywords but relies on how let, mut, fn signatures, and lifetimes interact.
5.2 Identifiers and Allowed Characters
Identifiers are names given to entities like variables, functions, types, modules, etc. In Rust:
- Allowed Characters: Identifiers must start with a Unicode character belonging to the XID_Start category or an underscore (
_). Subsequent characters can be from XID_Start, XID_Continue, or_.- XID_Start includes most letters from scripts around the world (Latin, Greek, Cyrillic, Han, etc.).
- XID_Continue includes XID_Start characters plus digits, underscores, and various combining marks.
- This means identifiers like
привет,数据,my_variable,_internal, andisValidare valid.
- Restrictions:
- Standard ASCII digits (
0-9) cannot be the first character (unless using raw identifiers, e.g.,r#1st_variable, which is highly discouraged). - Keywords cannot be used as identifiers unless escaped with
r#. - Spaces, punctuation (like
!,?,.,-), and symbols (like#,@,$) are generally not allowed within identifiers.
- Standard ASCII digits (
- Encoding: Identifiers must be valid UTF-8.
- Length: No explicit length limit, but overly long identifiers harm readability.
Naming Conventions (Style, Not Enforced by Compiler):
snake_case: Used for variable names, function names, module names (e.g.,let user_count = 5;,fn calculate_mean() {},mod network_utils {}).UpperCamelCase: Used for type names (structs, enums, traits) and enum variants (e.g.,struct UserAccount {},enum Status { Connected, Disconnected },trait Serializable {}).SCREAMING_SNAKE_CASE: Used for constants and statics (e.g.,const MAX_CONNECTIONS: u32 = 100;,static DEFAULT_PORT: u16 = 8080;).
These conventions enhance readability and are strongly recommended.
5.3 Expressions and Statements
Rust makes a clearer distinction between expressions and statements than C/C++.
5.3.1 Expressions
An expression evaluates to a value. Most code constructs in Rust are expressions, including:
- Literals (
5,true,"hello") - Arithmetic (
x + y) - Function calls (
calculate(a, b)) - Comparisons (
a > b) - Block expressions (
{ let temp = x * 2; temp + 1 }) - Control flow constructs like
if,match, andloop(thoughloopitself often doesn’t evaluate to a useful value unless broken with one).
// These are all expressions:
5
x + 1
is_valid(data)
if condition { value1 } else { value2 }
{ // This whole block is an expression
let intermediate = compute();
intermediate * 10 // The block evaluates to this value
}
Critically, an expression by itself is not usually valid Rust code. It needs to be part of a statement (like an assignment or a function call) or used where a value is expected (like the right side of = or a function argument).
5.3.2 Statements
A statement performs an action but does not evaluate to a useful value.
Common statement types:
- Declaration Statements: Introduce items like variables, functions, structs, etc.
let x = 5;(Variable declaration statement)fn my_func() {}(Function definition statement)struct Point { x: i32, y: i32 }(Struct definition statement)
- Expression Statements: An expression followed by a semicolon. This is used when you care only about the side effect of the expression (like calling a function that modifies state or performs I/O) and want to discard its return value. The semicolon effectively discards the value of the preceding expression.
do_something();(Callsdo_something, discards its return value)x + 1;(Calculatesx + 1, discards the result - usually pointless unless+is overloaded with side effects)
Key Difference from C/C++: Assignment (=) is a statement in Rust, not an expression. It does not evaluate to the assigned value. This prevents code like x = y = 5; (which works in C) and avoids potential bugs related to assignment within conditional expressions (if (x = 0)).
#![allow(unused)]
fn main() {
fn do_something() -> i32 { 0 }
let mut x = 0;
let y = 10; // Declaration statement
x = y + 5; // Assignment statement (the expr. y + 5 is evaluated, then assigned to x)
do_something(); // Expression statement (calls function, discards result)
}
5.3.3 Block Expressions
In Rust, a code block enclosed in curly braces { ... } is an expression that itself evaluates to a value.
- A block evaluates to the value of its final expression.
- If the block is empty, or if its final construct is a statement (which includes expressions followed by a semicolon, or other statement types like
letbindings or item declarations), the block evaluates to the unit type(). This behavior is distinct from C, where code blocks do not typically yield a value directly.
fn main() {
let y = {
let x = 3;
x + 1 // No semicolon: the block evaluates to x + 1 (which is 4)
};
println!("y = {}", y); // Prints: y = 4
let z = {
let x = 3;
x + 1; // Semicolon: the value is discarded, block evaluates to ()
};
println!("z = {:?}", z); // Prints: z = ()
let w = { }; // Empty block evaluates to ()
println!("w = {:?}", w); // Prints: w = ()
}
This feature is powerful, allowing if, match, and even simple blocks to be used directly in assignments or function arguments. Be mindful of the final semicolon; omitting or adding it changes the block’s resulting value and type.
5.3.4 Line Structure
Rust is free-form regarding whitespace and line breaks. Statements are terminated by semicolons, not newlines.
#![allow(unused)]
fn main() {
// Valid, spans multiple lines
let sum = 10 + 20 +
30 + 40;
// Valid, multiple statements on one line (discouraged for readability)
let a = 1; let b = 2; println!("Sum: {}", a + b);
}
5.4 Data Types
Rust is statically typed, meaning the type of every variable must be known at compile time. It is also strongly typed, generally preventing implicit type conversions between unrelated types (e.g., integer to float requires an explicit as cast). This catches many errors early.
Rust’s data types fall into several categories. Here we cover scalar and basic compound types.
5.4.1 Scalar Types
Scalar types represent single values.
- Integers: Fixed-size signed (
i8,i16,i32,i64,i128) and unsigned (u8,u16,u32,u64,u128) types. The number indicates the bit width. The default integer type (if unspecified and inferrable) isi32. - Pointer-Sized Integers: Signed
isizeand unsignedusize. Their size matches the target architecture’s pointer width (e.g., 32 bits on 32-bit targets, 64 bits on 64-bit targets).usizeis crucial for indexing arrays and collections, representing memory sizes, and pointer arithmetic. - Floating-Point Numbers:
f32(single-precision) andf64(double-precision), adhering to the IEEE 754 standard. The default isf64, as modern CPUs often handle it as fast as or faster thanf32, and it offers higher precision. - Booleans:
bool, with possible valuestrueandfalse. Takes up 1 byte in memory typically. - Characters:
char, representing a single Unicode scalar value (fromU+0000toU+D7FFandU+E000toU+10FFFF). Note that acharis 4 bytes in size, unlike C’scharwhich is usually 1 byte and often represents ASCII or extended ASCII.
Scalar Type Summary Table:
| Rust Type | Size (bits) | Range / Representation | C Equivalent (<stdint.h>) | Notes |
|---|---|---|---|---|
i8 | 8 | -128 to 127 | int8_t | Signed 8-bit |
u8 | 8 | 0 to 255 | uint8_t | Unsigned 8-bit (often used for byte data) |
i16 | 16 | -32,768 to 32,767 | int16_t | Signed 16-bit |
u16 | 16 | 0 to 65,535 | uint16_t | Unsigned 16-bit |
i32 | 32 | -2,147,483,648 to 2,147,483,647 | int32_t | Default integer type |
u32 | 32 | 0 to 4,294,967,295 | uint32_t | Unsigned 32-bit |
i64 | 64 | Approx. -9.2e18 to 9.2e18 | int64_t | Signed 64-bit |
u64 | 64 | 0 to approx. 1.8e19 | uint64_t | Unsigned 64-bit |
i128 | 128 | Approx. -1.7e38 to 1.7e38 | __int128_t (compiler ext.) | Signed 128-bit |
u128 | 128 | 0 to approx. 3.4e38 | __uint128_t (compiler ext.) | Unsigned 128-bit |
isize | Arch-dependent (32/64) | Arch-dependent | intptr_t | Signed pointer-sized integer |
usize | Arch-dependent (32/64) | Arch-dependent | uintptr_t, size_t | Unsigned pointer-sized, used for indexing |
f32 | 32 (IEEE 754) | Single-precision float | float | |
f64 | 64 (IEEE 754) | Double-precision float | double | Default float type |
bool | 8 (usually) | true or false | _Bool / bool (<stdbool.h>) | Boolean value |
char | 32 | Unicode Scalar Value (U+0000..U+10FFFF, excl. surrogates) | wchar_t (varies), char32_t (C++) | Represents a Unicode character (4 bytes) |
5.4.2 Compound Types
Compound types group multiple values into one type. Rust has two primitive compound types: tuples and arrays.
Tuple
A tuple is an ordered, fixed-size collection of values where each element can have a different type. Tuples are useful for grouping related data without the formality of defining a struct.
- Syntax: Types are written
(T1, T2, ..., Tn), and values are(v1, v2, ..., vn). - Fixed Size: The number of elements is fixed at compile time.
- Heterogeneous: Elements can have different types.
- Access: Use a period (
.) followed by a zero-based literal numeric index (e.g.,tup.0,tup.1). This index must be known at compile time (it cannot be a variable). Attempting to access a non-existent index results in a compile-time error.
fn main() {
// A tuple with an i32, f64, and u8
let tup: (i32, f64, u8) = (500, 6.4, 1);
// Access elements using period and index (0-based)
let five_hundred = tup.0;
let six_point_four = tup.1;
let one = tup.2;
println!("Tuple elements: {}, {}, {}", five_hundred, six_point_four, one);
// Tuple elements must be accessed with literal indices (0, 1, 2, ...).
// You cannot use a variable index like tup[i] or tup.variable_index.
// const IDX: usize = 1;
// let element = tup.IDX; // Compile Error
// Tuples can be mutable if declared with 'mut'
let mut mutable_tup = (10, "hello");
mutable_tup.0 = 20; // OK
println!("Mutable tuple: {:?}", mutable_tup);
// Destructuring: Extract values into separate variables
let (x, y, z) = tup; // Assigns tup.0 to x, tup.1 to y, tup.2 to z
println!("Destructured: x={}, y={}, z={}", x, y, z);
}
- Unit Type
(): An empty tuple()is called the “unit type”. It represents the absence of a meaningful value. Functions that don’t explicitly return anything implicitly return(). Statements also evaluate to(). - Singleton Tuple: A tuple with one element requires a trailing comma to distinguish it from a parenthesized expression:
(50,)is a tuple,(50)is just the integer 50.
Accessing tuple fields by index (e.g., tup.0) is extremely efficient. The compiler calculates the exact memory offset at compile time, resulting in a direct memory access with no runtime overhead, similar in performance to accessing struct fields in C.
Tuples are good for returning multiple values from a function or when you need a simple, anonymous grouping of data. For more complex data with meaningful field names, use a struct.
Array
An array is a fixed-size collection where every element must have the same type. Arrays are stored contiguously in memory on the stack (unless part of a heap-allocated structure).
- Syntax: Type is
[T; N]whereTis the element type andNis the compile-time constant length. Value is[v1, v2, ..., vN]. - Fixed Size: Length
Nmust be known at compile time and cannot change. - Homogeneous: All elements must be of type
T. - Initialization:
- List all elements:
let a: [i32; 3] = [1, 2, 3]; - Initialize all elements to the same value:
let b = [0; 5];// Creates[0, 0, 0, 0, 0]
- List all elements:
- Access: Use square brackets
[]with ausizeindex. Access is bounds-checked at runtime; out-of-bounds access causes a panic.
fn main() {
// Array of 5 integers
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
// Type and length can often be inferred
let inferred_numbers = [10, 20, 30]; // Inferred as [i32; 3]
// Initialize with a default value
let zeros = [0u8; 10]; // Array of 10 bytes, all zero
// Access elements (0-based index, must be usize)
let first = numbers[0];
let third = numbers[2];
println!("First: {}, Third: {}", first, third);
// Index must be usize
let idx: usize = 1;
println!("Element at index {}: {}", idx, numbers[idx]);
// let invalid_idx: i32 = 1;
// println!("{}", numbers[invalid_idx]); // Compile Error: index must be usize
// Bounds checking (this would panic if uncommented)
// println!("Out of bounds: {}", numbers[10]);
// Arrays can be mutable
let mut mutable_array = [1, 1, 1];
mutable_array[1] = 2;
println!("Mutable array: {:?}", mutable_array);
// Get length
println!("Length of numbers: {}", numbers.len()); // 5
}
- Memory: Arrays are typically stack-allocated (if declared locally) and provide efficient, cache-friendly access due to contiguous storage.
CopyTrait: If the element typeTimplements theCopytrait (like primitive numbers,bool,char), then the array type[T; N]also implementsCopy.
Array element access (array[index]) using a runtime variable index is typically very fast. It involves a simple calculation to find the element’s memory address (base + index * size). Crucially, safe Rust precedes this access with a runtime bounds check (index < array.len()) to ensure memory safety, preventing buffer overflows common in C. While this check adds a minimal runtime overhead compared to C’s unchecked access, it provides a vital safety guarantee.
However, if the index is a compile-time constant (e.g., array[2] or an index defined via const), the compiler performs a static bounds check. An out-of-bounds constant index will result in a compilation error, preventing any runtime check. In such cases, the access compiles down to a direct memory operation with a known offset, making it as efficient as accessing a tuple or struct field.
Use arrays when you know the exact number of elements at compile time and need a simple, fixed-size sequence. For dynamically sized collections, use Vec<T> (vector) from the standard library (covered later).
Multidimensional Arrays
You can create multidimensional arrays in Rust by nesting array declarations. For example, a 2x3 matrix (2 rows, 3 columns) can be represented as an array of 2 elements, where each element is an array of 3 integers:
fn main() {
let matrix: [[i32; 3]; 2] = [ // Type: array of 2 elements, each [i32; 3]
[1, 2, 3], // Row 0: An array of 3 i32s
[4, 5, 6], // Row 1: An array of 3 i32s
];
// Accessing element at row 1, column 2 (0-based index)
let element = matrix[1][2]; // Accesses the value 6
println!("Element at [1][2]: {}", element);
// You can also modify elements if the matrix is mutable
let mut mutable_matrix = matrix;
// Copies the original matrix (since [i32; 3] and [[i32; 3]; 2] are Copy)
mutable_matrix[0][1] = 20; // Change element at row 0, column 1 to 20
println!("Modified matrix[0][1]: {}", mutable_matrix[0][1]); // Prints 20
println!("Original matrix[0][1]: {}", matrix[0][1]);
// Prints 2 (original is unchanged)
}
This demonstrates creating an array of arrays. Accessing elements uses chained indexing (matrix[row][column]), and standard bounds checking applies at each level.
5.4.3 References
As introduced in Chapter 2, Rust provides references—safe, managed pointers that allow indirect access to data stored elsewhere in memory. Much like pointers in C, references contain the memory address of a value, enabling one level of indirection.
References in Rust come in two forms: immutable and mutable. They make it possible to temporarily access data without taking ownership or creating a copy, which is particularly efficient when passing values to functions.
To create a reference, Rust uses the & symbol for immutable access and &mut for mutable access. The dereferencing operator * can be used to access the value behind a reference, though Rust often applies dereferencing automatically when needed. In principle, it’s possible to create references to references (e.g., &&value), introducing multiple levels of indirection, but this is seldom required in practice.
Rust also supports raw pointers, which can be used within unsafe blocks for low-level operations that are not checked by the compiler.
Chapter 6 will explore references more thoroughly as part of the discussion on Ownership, Borrowing, and Memory Management.
The following example demonstrates a function that takes a mutable reference to a fixed-size array and squares each element in place:
fn square_elements(arr: &mut [i32; 5]) {
for i in 0..arr.len() {
arr[i] *= arr[i];
}
}
fn main() {
let mut numbers = [1, 2, 3, 4, 5];
square_elements(&mut numbers);
println!("{:?}", numbers); // [1, 4, 9, 16, 25]
}
The function modifies the original array by working directly on its elements through a mutable reference. This avoids the overhead of copying data into and out of the function.
5.4.4 Stack vs. Heap Allocation (Brief Overview)
By default, local variables holding scalar types, tuples, and arrays are allocated on the stack. Stack allocation is very fast because it involves just adjusting a pointer. The size of stack-allocated data must be known at compile time.
Data whose size might change or is not known until runtime (like the contents of a Vec<T> or String) is typically allocated on the heap. Heap allocation is more flexible but involves more overhead (finding free space, bookkeeping).
We will explore stack, heap, ownership, and borrowing—concepts central to Rust’s memory management—in detail in later chapters. For now, understand that primitive types like those discussed here are usually stack-allocated when used as local variables.
5.4.5 A Note on Sub-Range Types
Coming from languages like Ada, Pascal, or Nim, you might be familiar with defining integer types restricted to a specific sub-range, such as type Month = 1..12;. Rust does not have direct, built-in syntax for creating such custom integer types where the range constraint is automatically enforced by the type system on all assignments and operations. This generally aligns with Rust’s philosophy of providing powerful, composable building blocks (like structs and enums) rather than adding numerous specialized types to the language core.
When you need to ensure a number consistently stays within a specific range in Rust, idiomatic approaches include:
- The Newtype Pattern: This involves defining a simple struct that wraps a primitive integer (e.g.,
struct Month(u8);). You then implement associated functions (likeMonth::new(value: u8)) that perform validation upon creation, typically returning anOption<Month>orResult<Month, Error>. This ensures that if you have a value of typeMonth, its internal value is guaranteed to be within the valid range (e.g., 1-12). We will explore this useful pattern in more detail in the chapter on structs. - Enums: For small, fixed sets of discrete values (like days of the week or specific error codes), defining an
enumis often the clearest and safest approach, providing strong compile-time guarantees. - Runtime Assertions: In internal functions or performance-sensitive code where the overhead of the Newtype pattern isn’t desired, you might use a standard integer type and add checks using
assert!ordebug_assert!to validate the range at critical points.
Interestingly, while Rust lacks general integer sub-range types, the language and standard library do heavily utilize the concept of value restriction – particularly non-nullness or non-zero-ness – to enhance safety and enable crucial optimizations:
- References & Box: Rust’s references (
&T,&mut T) and the smart pointerBox<T>are guaranteed by the type system (in safe code) to never be null. NonNullandNonZero: The standard library provides explicit types likestd::ptr::NonNull<T>(for raw pointers) and thestd::num::NonZero{Integer}family (e.g.,NonZeroU8,NonZeroIsize, stable since Rust 1.79). These types encapsulate a value that is guaranteed not to be zero (or null). This guarantee allows for significant memory layout optimizations; for example,Option<NonZeroU8>takes up only 1 byte of memory, the same asu8, because the “None” variant can safely reuse the zero representation.
So, while you won’t find a direct equivalent to type Day = 1..31;, Rust provides patterns to achieve similar guarantees and leverages specific range restrictions (like non-zero) where they offer substantial benefits.
5.5 Variables and Mutability
Variables associate names with data stored in memory.
5.5.1 Declaring Variables
Use the let keyword to declare a variable and initialize it.
#![allow(unused)]
fn main() {
let message = "Hello"; // Declare 'message', initialize it with "Hello"
let count = 10; // Declare 'count', initialize it with 10
}
A Note on Terminology: “Binding”
You will frequently encounter the term “binding” in Rust literature (e.g., “variable binding,” “let binds a value to a name”). This term emphasizes that
letcreates an association between a name and a value or memory location.While accurate, especially when discussing immutability, shadowing, or references, the term “binding” might feel slightly abstract for simple cases like
let x: i32 = 5;if you’re used to C’s model where the variablexis the memory location holding5. In such simple cases, thinking ofletas declaring a variable and initializing it with a value is perfectly valid and perhaps more direct.This chapter will often use simpler terms like “declare,” “initialize,” “assign,” or “holds a value” for basic variable operations, while reserving “binding” for contexts like immutability or shadowing where it adds clarity. Be aware that other Rust resources heavily use “binding” in all contexts.
5.5.2 Immutability by Default
By default, variables declared with let are immutable. Once initialized, their value cannot be changed.
fn main() {
let x = 5;
println!("The value of x is: {}", x);
// x = 6; // Compile Error: cannot assign twice to immutable variable `x`
}
This design choice encourages safer code by preventing accidental modifications and making program state easier to reason about, especially important for concurrency. We refer to let x = 5; as creating an immutable binding.
5.5.3 Mutable Variables
To allow a variable’s value to be changed after initialization, declare it using let mut.
fn main() {
let mut y = 10;
println!("The initial value of y is: {}", y);
y = 11; // OK, because y was declared as mutable
println!("The new value of y is: {}", y);
}
Use mut deliberately when you need to change a variable’s value. Prefer immutability when possible.
5.5.4 Type Annotations and Inference
Rust’s compiler features powerful type inference. It can usually determine the variable’s type automatically based on the initial value and how the variable is used later.
#![allow(unused)]
fn main() {
let inferred_integer = 42; // Inferred as i32 (default integer type)
let inferred_float = 2.718; // Inferred as f64 (default float type)
}
However, you can (and sometimes must) provide an explicit type annotation using a colon (:) after the variable name.
#![allow(unused)]
fn main() {
let explicit_float: f64 = 3.14; // Explicitly typed as f64
let count: u32 = 0; // Explicitly typed as u32
// Annotation needed when type isn't clear from initializer or context
let guess: u32 = "42".parse().expect("Not a number!");
// Annotation needed if declared without immediate initialization
let later_initialized: i32;
later_initialized = 100; // OK now
}
Annotations are required when the compiler cannot uniquely determine the variable’s type from its initialization and usage context (a common example is with functions like parse() which can return different types based on the annotation).
5.5.5 Uninitialized Variables
Rust guarantees, through compile-time checks, that you cannot use a variable before it has been definitely initialized on all possible code paths.
fn main() {
let x: i32; // Declared but not initialized
let condition = true;
if condition {
x = 1; // Initialized on this path
} else {
// If we comment out the line below, the compiler will complain
// because 'x' might not be initialized before the println!.
x = 2; // Initialized on this path too
}
// OK: The compiler knows 'x' is guaranteed to be initialized by this point.
println!("The value of x is: {}", x);
// let y: i32;
// println!("{}", y); // Compile Error: use of possibly uninitialized variable `y`
}
This check eliminates a common source of bugs found in C/C++ related to reading uninitialized memory. Note that compound types like tuples, arrays, and structs must generally be fully initialized at once; partial initialization is usually not permitted for safe Rust code.
5.5.6 Constants
Constants represent values that are fixed for the entire program execution and are known at compile time. They are declared using the const keyword.
- Must have an explicit type annotation.
- Must be initialized with a constant expression – a value the compiler can determine without running the code (e.g., literals, simple arithmetic on other constants).
- Conventionally named using
SCREAMING_SNAKE_CASE. - Can be declared in any scope, including the global scope.
- Are effectively inlined by the compiler wherever they are used. They don’t necessarily occupy a specific memory address at runtime.
const SECONDS_IN_MINUTE: u32 = 60;
const MAX_USERS: usize = 1000;
fn main() {
let total_seconds = 5 * SECONDS_IN_MINUTE;
println!("Five minutes is {} seconds.", total_seconds);
let user_ids = [0u32; MAX_USERS]; // Use const for array size
println!("Max users allowed: {}", MAX_USERS);
println!("User ID array size: {}", user_ids.len());
}
Use const for values that are truly fixed, program-wide parameters or mathematical constants.
5.5.7 Static Variables
In Rust, global variables—that is, variables declared outside of any function—are called static variables. Like constants, static variables (static) represent values that exist for the entire duration of the program ('static lifetime). However, unlike const, they occupy a fixed, single memory address. Any access to a static variable involves reading from or writing to this specific memory location, similar to how global variables function in C.
Static variables:
- Must have an explicit type annotation, just like
constitems. - Immutable statics (
static) must be initialized with a constant expression, similar toconst. - Mutable statics (
static mut) exist but are inherently unsafe due to the risk of data races in concurrent programs. Any access or modification of astatic mutrequires anunsafeblock. Their use is strongly discouraged in favor of safe concurrency primitives such asMutex,RwLock, or atomic types likeAtomicU32. - Are conventionally named using
SCREAMING_SNAKE_CASE.
Important Note on Rust Edition 2024: References to static mut
With Rust Edition 2024, the compiler now defaults to disallowing shared or mutable references to static mut variables, even within unsafe blocks. This change stems from the static_mut_refs lint being set to deny by default. The rationale is that taking such references leads to immediate undefined behavior, even if the reference is never actually used. This occurs because maintaining Rust’s strict mutability XOR aliasing rule (either one mutable reference or many shared references, but not both) becomes exceptionally difficult with global mutable state, particularly in multithreaded or reentrant contexts.
Consider the following examples, which will now result in a compilation error in Rust Edition 2024:
#![allow(unused)]
fn main() {
static mut X: i32 = 23;
static mut Y: i32 = 24;
unsafe {
let y = &X; // ERROR: shared reference to mutable static
let ref x = X; // ERROR: shared reference to mutable static
let (x, y) = (&X, &Y); // ERROR: shared reference to mutable static
}
static mut NUMS: &[u8; 3] = &[0, 1, 2];
unsafe {
println!("{NUMS:?}"); // ERROR: shared reference to mutable static
let n = NUMS.len(); // ERROR: shared reference to mutable static
}
}
This error also applies to implicit references, such as those created when printing static mut variables or calling methods on them, as shown by the println! and len() examples above.
Workarounds for static mut References
For situations where a reference to a static mut variable is genuinely necessary—for instance, in specific embedded programming scenarios where the overhead of Mutex or atomics might be unacceptable—there are two primary workarounds:
-
Using Raw Pointers: You can obtain a raw pointer to the
static mutvariable. Raw pointers in Rust do not come with the same aliasing guarantees as references and require manual dereferencing within anunsafeblock.static mut GLOBAL_COUNTER: u32 = 0; fn main() { unsafe { // Obtain a raw mutable pointer let ptr: *mut u32 = &raw mut GLOBAL_COUNTER; *ptr += 1; // Dereference the raw pointer to modify // Obtain a raw constant pointer to read let const_ptr: *const u32 = &raw const GLOBAL_COUNTER; println!("COUNTER (via raw pointer): {}", *const_ptr); } }As illustrated, a raw pointer can be created from a
static mutitem using&raw mutor&raw constand then dereferenced to access the value. This explicitly signals to the compiler and other developers that you are operating outside of Rust’s usual reference safety guarantees. -
Allowing the Lint: You can explicitly disable the
static_mut_refslint for specific code sections or for the entire crate. This approach should be used with extreme caution, as it bypasses a critical safety check.To allow the lint for the entire crate, add the following attribute at the top of your
main.rsorlib.rsfile:#![allow(static_mut_refs)] static mut REQUEST_COUNTER: u32 = 0; fn main() { unsafe { // This will now compile due to the #![allow] attribute let ref_to_counter = &REQUEST_COUNTER; println!("Requests processed (unsafe with allowed lint): {}", ref_to_counter); } }While suppressing the error, this method does not eliminate the underlying undefined behavior. Therefore, it is only advisable when you have a profound understanding of memory safety and aliasing in your specific use case.
Here’s an example illustrating the proper use of static and safer alternatives to static mut:
// Immutable static: lives for the program duration at a fixed address.
static APP_VERSION: &str = "1.0.2";
// Mutable static: requires unsafe to access.
// As of Rust 2024, taking references to this is disallowed by default.
static mut REQUEST_COUNTER: u32 = 0;
fn main() {
println!("Running version: {}", APP_VERSION);
// Accessing/modifying static mut requires an unsafe block.
// In Rust 2024, taking a direct reference like `&REQUEST_COUNTER`
// would now result in a compilation error by default.
unsafe {
// Direct modification is still allowed within unsafe
REQUEST_COUNTER += 1;
// Not allowed in Rust 2024:
// println!("Requests processed (unsafe direct access): {}", REQUEST_COUNTER);
// Example of accessing via raw pointer (to bypass Rust 2024 reference lint)
let raw_ptr_counter: *const u32 = &raw const REQUEST_COUNTER;
println!("Requests processed (unsafe via raw pointer): {}", *raw_ptr_counter);
}
increment_safe_counter(); // Prefer safe alternatives
}
// A safer way to handle global mutable state using atomics
use std::sync::atomic::{AtomicU32, Ordering};
static SAFE_COUNTER: AtomicU32 = AtomicU32::new(0);
fn increment_safe_counter() {
// Atomically increment the counter
SAFE_COUNTER.fetch_add(1, Ordering::Relaxed);
println!("Requests processed (safe using atomics): {}",
SAFE_COUNTER.load(Ordering::Relaxed));
}
Alternatives to mutable global variables are discussed later in Chapter 19, “Smart Pointers,” and the use of raw pointers is discussed in detail in Chapter 25, where we cover unsafe language extensions.
const vs. static
- Use
constwhen the value can be computed at compile time and you want it inlined directly into the code. These are similar to C macros for constants but include type checking. - Use
staticwhen you need a single, persistent memory location for a value throughout the program’s lifetime, much like a C global variable. Only usestatic mutwithinunsafeblocks and with extreme caution, preferably replacing it with safe concurrency patterns likestd::sync::Mutexorstd::sync::atomictypes. As of Rust 2024, taking references tostatic mutis generally disallowed to prevent undefined behavior.
5.5.8 Shadowing
Rust allows you to declare a new variable with the same name as a previously declared variable within the same or an inner scope. This is called shadowing. The new variable declaration creates a new binding, making the previous variable inaccessible by that name from that point forward (or temporarily, within an inner scope).
fn main() {
let x = 5;
println!("x = {}", x); // Prints 5
// Shadow x by creating a new variable also named x
let x = x + 1; // This 'x' is a new variable, initialized using the old 'x'
println!("Shadowed x = {}", x); // Prints 6
{
// Shadow x again in an inner scope
let x = x * 2; // This is yet another 'x', local to this block
println!("Inner shadowed x = {}", x); // Prints 12
} // Inner scope ends, its 'x' binding disappears
// We are back to the 'x' from the outer scope (the one holding 6)
println!("Outer x after scope = {}", x); // Prints 6
// Shadowing is often used to transform a value while reusing its name,
// potentially even changing the type.
let spaces = " "; // 'spaces' holds a &str (string slice)
let spaces = spaces.len(); // The name 'spaces' is re-bound to a usize value
println!("Number of spaces: {}", spaces); // Prints 3
}
Shadowing differs significantly from marking a variable mut. Mutating (let mut y = 5; y = 6;) changes the value within the same variable’s memory location, without changing its type. Shadowing (let x = 5; let x = x + 1;) creates a completely new variable (potentially with a different type) that happens to reuse the same name, making the old variable inaccessible by that name afterwards.
5.5.9 Scope and Lifetimes
A variable is valid (or “in scope”) from the point it’s declared until the end of the block {} in which it was declared. When a variable goes out of scope, Rust automatically calls any necessary cleanup code for that variable (this is part of the ownership and RAII system, detailed later).
fn main() { // Outer scope starts
let outer_var = 1;
{ // Inner scope starts
let inner_var = 2;
println!("Inside inner scope: outer={}, inner={}", outer_var, inner_var);
} // Inner scope ends, 'inner_var' goes out of scope and is cleaned up
// println!("Outside inner scope: inner={}", inner_var);
// Compile Error: `inner_var` not found in this scope
println!("Back in outer scope: outer={}", outer_var);
} // Outer scope ends, 'outer_var' goes out of scope and is cleaned up
5.5.10 Declaring Multiple Variables (Destructuring)
While C allows int a, b;, Rust typically uses one let statement per variable. However, Rust supports destructuring assignment using patterns, which is often used with tuples or structs to initialize multiple variables at once.
fn main() {
let (x, y) = (5, 10); // Destructure the tuple (5, 10)
// This binds x to 5 and y to 10
println!("x={}, y={}", x, y);
}
We will see more advanced uses of patterns and destructuring later.
5.6 Operators
Rust supports most standard operators familiar from C/C++.
- Arithmetic:
+(add),-(subtract),*(multiply),/(divide),%(remainder/modulo). - Comparison:
==(equal),!=(not equal),<(less than),>(greater than),<=(less than or equal),>=(greater than or equal). These return abool. - Logical:
&&(logical AND, short-circuiting),||(logical OR, short-circuiting),!(logical NOT). Operate onboolvalues. - Bitwise:
&(bitwise AND),|(bitwise OR),^(bitwise XOR),!(bitwise NOT - unary, only for integers),<<(left shift),>>(right shift). Operate on integer types. Right shifts on signed integers perform sign extension; on unsigned integers, they shift in zeros. - Assignment:
=(simple assignment). - Compound Assignment:
+=,-=,*=,/=,%=,&=,|=,^=,<<=,>>=. Combines an operation with assignment (e.g.,x += 1is equivalent tox = x + 1). - Unary:
-(negation for numbers),!(logical NOT forbool, bitwise NOT for integers),&(borrow/reference),*(dereference). - Type Casting:
as(e.g.,let float_val = integer_val as f64;). Explicit casting is often required between numeric types. - Grouping:
()changes evaluation order. - Access:
.(member access for structs/tuples),[](index access for arrays/slices/vectors).
Key Differences/Notes for C Programmers:
- No Increment/Decrement Operators: Rust does not have
++or--. Usex += 1orx -= 1instead. This avoids ambiguities present in C regarding pre/post increment/decrement return values and side effects within expressions. - Strict Type Matching: Binary operators (like
+,*,&,==) generally require operands of the exact same type. Implicit numeric promotions like in C (e.g.,int + float) do not happen. You must explicitly cast usingas.#![allow(unused)] fn main() { let a: i32 = 10; let b: u8 = 5; // let c = a + b; // Compile Error: mismatched types i32 and u8 let c = a + (b as i32); // OK: b is explicitly cast to i32 println!("c = {}", c); } - No Ternary Operator: Rust does not have C’s
condition ? value_if_true : value_if_false. Use anifexpression instead, which is more readable and less prone to precedence errors:#![allow(unused)] fn main() { let condition = true; let result = if condition { 5 } else { 10 }; println!("Result = {}", result); } - Operator Overloading: You cannot create new custom operators, but you can overload existing operators (like
+,-,*,==) for your own custom types (structs, enums) by implementing corresponding traits from thestd::opsmodule (e.g.,Add,Sub,Mul,PartialEq). This allows operators to work intuitively with user-defined types like vectors or complex numbers.
In addition to the operators mentioned earlier, Rust uses & to create references or to specify that a type is a reference, and * to dereference a reference in order to access the original value it points to.
Operator Precedence: Largely follows C/C++ conventions (e.g., * and / before + and -, comparisons before logical operators). Use parentheses () to clarify or force a specific evaluation order when in doubt – clarity is usually preferred over relying on subtle precedence rules.
5.7 Numeric Literals
Numeric literals allow you to specify fixed numeric values directly in your source code.
-
Integer Literals:
- Default to
i32if the type cannot be inferred otherwise from context. - Can use underscores
_as visual separators for readability (e.g.,1_000_000). These are ignored by the compiler. - Can have type suffixes to specify the exact integer type:
10u8,20i32,30usize. - Supports different bases using prefixes:
- Decimal:
98_222(no prefix) - Hexadecimal:
0xff(prefix0x) - Octal:
0o77(prefix0o) - Binary:
0b1111_0000(prefix0b)
- Decimal:
- Byte literals represent single bytes (
u8) using ASCII values:b'A'(theu8value 65).
- Default to
-
Floating-Point Literals:
- Default to
f64(double precision). - Can use underscores:
1_234.567_890. - Requires a digit before a decimal point (
0.5, not.5). - A trailing decimal point is allowed (
1., equivalent to1.0). - Can use exponent notation (
eorE):1.23e4(1.23 * 10^4),0.5E-2(0.5 * 10^-2). - To specify
f32(single precision) when the type cannot be inferred from context, use thef32suffix:2.0f32.
- Default to
fn main() {
let decimal = 100_000; // i32 by default
let hex = 0xEADBEEF; // i32 by default
let octal = 0o77; // i32 by default
let binary = 0b1101_0101; // i32 by default
let byte = b'X'; // u8 (value 88)
let float_def = 3.14; // f64 by default
let float_f32 = 2.718f32; // f32 explicit suffix
let float_exp = 6.022e23; // f64
println!("Dec: {}, Hex: {}, Oct: {}, Bin: {}, Byte: {}",
decimal, hex, octal, binary, byte);
println!("f64: {}, f32: {}, Exp: {}", float_def, float_f32, float_exp);
// Type inference example:
let values: [f32; 3] = [1.0, 2.0, 3.0]; //Lit. are known to be f32 from array type
let sum = values[0] + 0.5;//0.5 here must be f32 due to context, suffix not needed
println!("Sum (f32): {}", sum);
let value_f64 = 1.0; // f64
// let mixed_sum = values[0] + value_f64; // Error: mismatched types f32 and f64
}
If the compiler cannot unambiguously determine the required numeric type from the context (e.g., assigning to an untyped variable, or initial parsing), you must provide either a type suffix on the literal or a type annotation on the variable.
5.8 Overflow in Arithmetic Operations
Integer overflow occurs when an arithmetic operation results in a value outside the representable range for its type. C/C++ behavior for signed overflow is often undefined, leading to subtle bugs and security vulnerabilities. Rust provides well-defined, safer behavior.
- Debug Builds: By default, when compiling in debug mode (
cargo build), Rust inserts runtime checks for integer overflow. If an operation (like+,-,*) overflows, the program will panic (terminate with an error message). This helps catch potential overflow errors during development and testing. - Release Builds: By default, when compiling in release mode (
cargo build --release), these runtime checks are disabled for performance. Instead, integer operations that overflow will perform two’s complement wrapping. For example, for au8(range 0-255),255 + 1wraps to0, and0 - 1wraps to255.
// Example (behavior depends on build mode: debug vs release)
fn main() {
let max_u8: u8 = 255;
// This line's behavior changes:
// - Debug: Panics with "attempt to add with overflow"
// - Release: Wraps around, result becomes 0
let result = max_u8 + 1;
println!("Result: {}", result); // Only runs in release mode without panic
}
This difference means code relying on wrapping behavior might panic unexpectedly in debug builds, while code assuming panics won’t happen might produce incorrect results due to wrapping in release builds.
5.8.1 Explicit Overflow Handling
To ensure consistent and predictable behavior regardless of build mode, Rust provides methods on integer types for explicit overflow control:
- Wrapping: Methods like
wrapping_add,wrapping_sub,wrapping_mul, etc., always perform two’s complement wrapping, in both debug and release builds.#![allow(unused)] fn main() { let x: u8 = 250; let y = x.wrapping_add(10); // Always wraps: 250+10 -> 260 -> 4 (mod 256). y is 4. } - Checked: Methods like
checked_add,checked_sub, etc., perform the operation and return anOption<T>. It’sSome(result)if the operation succeeds without overflow, andNoneif overflow occurs. This allows you to detect and handle overflow explicitly.#![allow(unused)] fn main() { let x: u8 = 250; let sum1 = x.checked_add(5); // Some(255) let sum2 = x.checked_add(10); // None (because 250 + 10 > 255) if let Some(value) = sum2 { println!("Checked sum succeeded: {}", value); } else { println!("Checked sum overflowed!"); // This branch is taken } } - Saturating: Methods like
saturating_add,saturating_sub, etc., perform the operation, but if overflow occurs, the result is clamped (“saturated”) at the numeric type’s minimum or maximum value.#![allow(unused)] fn main() { let x: u8 = 250; let sum = x.saturating_add(10); // Clamps at u8::MAX (255). sum is 255. let y: i8 = -120; let diff = y.saturating_sub(20); // Clamps at i8::MIN (-128). diff is -128. } - Overflowing: Methods like
overflowing_add,overflowing_sub, etc., perform the operation using wrapping semantics and return a tuple(result, did_overflow).resultcontains the wrapped value, anddid_overflowis aboolindicating whether wrapping occurred.#![allow(unused)] fn main() { let x: u8 = 250; let (sum, overflowed) = x.overflowing_add(10); // sum=4 (wrapped), overfl. is true println!("Overflowing sum: {}, Overflowed: {}", sum, overflowed); }
Choose the method that best reflects the intended logic for calculations that might exceed the type’s bounds. Relying on the default build-mode-dependent behavior is often risky.
5.8.2 Floating-Point Overflow
Floating-point types (f32, f64) adhere to the IEEE 754 standard for arithmetic and do not panic or wrap on overflow. Instead, operations exceeding representable limits produce special values:
- Infinity:
f64::INFINITY(orf32::INFINITY) for positive infinity,f64::NEG_INFINITY(orf32::NEG_INFINITY) for negative infinity. This typically results from dividing by zero or calculations producing results of enormous magnitude. - NaN (Not a Number):
f64::NAN(orf32::NAN). This indicates an undefined or unrepresentable result, such as0.0 / 0.0, the square root of a negative number, or arithmetic involvingNaNitself.
fn main() {
let x = 1.0f64 / 0.0; // Positive Infinity
let y = -1.0f64 / 0.0; // Negative Infinity
let z = 0.0f64 / 0.0; // NaN
println!("x = {}, y = {}, z = {}", x, y, z);
// Use methods to check for these special values
println!("x is infinite: {}", x.is_infinite()); // true
println!("x is finite: {}", x.is_finite()); // false
println!("y is infinite: {}", y.is_infinite()); // true
println!("z is NaN: {}", z.is_nan()); // true
// Crucial NaN comparison behavior: NaN is not equal to anything, including itself!
println!("z == z: {}", z == z); // false! Use is_nan() instead.
}
Code involving floating-point arithmetic should be prepared to handle Infinity and especially NaN. Remember that direct equality checks (==) with NaN always return false; use the .is_nan() method instead.
5.9 Performance Considerations for Numeric Types
Different numeric types offer trade-offs between memory usage, value range, and computational performance.
i32/u32: Often the “sweet spot” for general-purpose integer arithmetic. They perform well on both 32-bit and 64-bit architectures.i32is the default integer type for good reason.i64/u64: Highly efficient on 64-bit CPUs, offering a much larger range than 32-bit types. They might incur a slight performance cost on 32-bit CPUs for operations that aren’t natively supported. Necessary when values might exceed the approx. +/- 2 billion range ofi32.i128/u128: Provide a very large range but are not natively supported by most current hardware. Arithmetic operations are typically emulated by the compiler using multiple lower-level instructions, making them significantly slower than 64-bit (or even 32-bit) operations. Use only when the extremely large range is strictly required.f64: The default floating-point type. Modern 64-bit CPUs often have dedicated hardware for double-precision floating-point math, makingf64operations as fast as, or sometimes even faster than,f32operations, while offering significantly higher precision.f32: Primarily useful when memory usage is a major concern (e.g., large arrays of floats in graphics, simulations, or machine learning) or when interacting with hardware or external libraries specifically requiring single precision (e.g., GPU programming APIs). Performance relative tof64varies by CPU.- Smaller Types (
i8/u8,i16/u16): Can significantly reduce memory consumption, especially in large arrays or data structures, potentially improving cache locality and performance. However, CPUs often perform arithmetic most efficiently on their native register size (typically 32 or 64 bits). Operations involving smaller types might require extra instructions for loading, sign-extension (for signed types), or zero-extension (for unsigned types) before the actual arithmetic, which can sometimes negate the memory savings in terms of speed. The impact is highly context-dependent. isize/usize: Designed to match the architecture’s pointer size. Use these primarily for indexing into collections (arrays, vectors, slices), representing memory sizes, and pointer arithmetic. Avoid using them for general numeric calculations unless directly related to memory addressing or collection capacity/indices, as their size varies between architectures (32 vs 64 bits), which could affect portability if used for non-memory-related logic.
General Advice: Begin with the defaults (i32, f64). Choose other types based on specific requirements: range needs (i64, u64, i128), memory constraints (i8, u16, f32), or indexing/memory size representation (usize). If performance is critical, profile your code rather than making assumptions about the speed of different types. Be mindful that explicit as casts between numeric types, while necessary for type safety, are not entirely free and represent computations that take some amount of time.
5.10 Comments in Rust
Comments are annotations within the source code ignored by the compiler but essential for human understanding. They should explain the why behind code, document assumptions, or clarify complex sections.
5.10.1 Regular Comments
Used for explanatory notes within function bodies or alongside specific lines of code.
- Single-line comments: Start with
//and extend to the end of the line. Ideal for brief notes.// Calculate the average of the two values let average = (value1 + value2) / 2.0; // Use floating-point division - Multi-line comments (Block comments): Start with
/*and end with*/. They can span multiple lines and are useful for longer explanations or temporarily disabling blocks of code. Rust supports nested block comments.#![allow(unused)] fn main() { /* This function processes user input. It first validates the format, then updates the internal state. TODO: Add better error handling for malformed input. /* Nested comment example: Temporarily disable logging println!("Processing input: {}", input); */ */ fn process_input(input: &str) { // ... function body ... } }
5.10.2 Documentation Comments
Special comments processed by the rustdoc tool to automatically generate HTML documentation for your crate (library or application). They use Markdown syntax internally.
- Outer doc comments (
///or/** ... */): Document the item that immediately follows them (e.g., a function, struct, enum, trait, module). This is the most common form, used for documenting public APIs.#![allow(unused)] fn main() { /// Represents a geometric point in 2D space. pub struct Point { /// The x-coordinate value. pub x: f64, /// The y-coordinate value. pub y: f64, } /** * Calculates the distance between two points. * * Uses the Pythagorean theorem. * * # Arguments * * * `p1` - The first point. * * `p2` - The second point. * * # Examples * * ``` * let point1 = Point { x: 0.0, y: 0.0 }; * let point2 = Point { x: 3.0, y: 4.0 }; * assert_eq!(calculate_distance(&point1, &point2), 5.0); * ``` */ pub fn calculate_distance(p1: &Point, p2: &Point) -> f64 { ((p1.x - p2.x).powi(2) + (p1.y - p2.y).powi(2)).sqrt() } } - Inner doc comments (
//!or/*! ... */): Document the item that contains them – typically the module or the crate itself. These are usually placed at the very beginning of the file (lib.rsormain.rsfor the crate documentation,mod.rsor the module’s file for module documentation).#![allow(unused)] fn main() { // In lib.rs or main.rs //! # Geometry Utilities Crate //! //! This crate provides basic types and functions for working with //! 2D geometry, such as points and distance calculations. // In utils/mod.rs /*! Internal utility functions module. Not part of the public API. */ }
Guidelines:
- Focus comments on explaining intent, assumptions, non-obvious logic, or usage guidelines, rather than simply restating what the code does.
- Keep comments accurate and up-to-date as the code evolves. Stale comments can be worse than no comments.
- Use documentation comments generously for all public API items in libraries. Include examples (
```blocks) to demonstrate usage clearly. This is crucial for making your library usable by others.
5.11 Summary
This chapter covered the foundational building blocks common to many programming languages, as implemented in Rust, highlighting key differences from C:
- Keywords: Reserved words defining Rust’s syntax, including raw identifiers (
r#) for conflicts. - Identifiers: Naming rules (Unicode-based) and conventions (
snake_case,UpperCamelCase). - Expressions vs. Statements: Expressions evaluate to a value; statements perform actions and end with
;. Block expressions ({}) are a key feature. Assignment is a statement. - Data Types:
- Scalar: Integers (
i32,u8,usize, etc.), floats (f64,f32), booleans (bool), characters (char- 4 bytes Unicode). - Compound: Tuples (fixed-size, heterogeneous
(T1, T2)), Arrays (fixed-size, homogeneous[T; N]).
- Scalar: Integers (
- Variables: Declared with
let, immutable by default, made mutable withmut. Rust enforces initialization before use. The term “binding” is common but can be thought of as declaration/initialization for simple cases. - Constants (
const): Compile-time values, inlined, no fixed address. - Statics (
static): Program lifetime, fixed memory address,static mutrequiresunsafeand is discouraged. - Shadowing: Re-declaring a variable name with
let, creating a new variable. - Operators: Familiar arithmetic, comparison, logical, bitwise operators. No
++/--, no ternary?:, requires strict type matching (useasfor casts). - Numeric Literals: Syntax for integers (various bases, suffixes,
_separators), floats (suffixes,_, exponents), byte literals (b'A'). - Overflow: Well-defined behavior: debug builds panic, release builds wrap (integers). Explicit handling methods (
checked_,wrapping_, etc.) available for consistent control. Floats useInfinity/NaN. - Performance: Considerations for different numeric types (
i32/f64often good defaults). - Comments: Regular (
//,/* */) and documentation (///,//!) comments for explanation andrustdocgeneration.
These concepts provide a necessary base for writing Rust programs. While some aspects resemble C, Rust’s emphasis on explicitness (like type casting and overflow handling), static guarantees (like initialization checks), and default immutability contribute significantly to its safety and reliability. The next chapters will delve into Rust’s unique ownership and borrowing system, showing how it interacts with functions, control flow, and data structures to provide memory safety without a garbage collector.