7.5 Language Fundamentals

In the previous section we reviewed Rust’s approach to variables, shadowing, ownership, borrowing and lifetimes. These concepts are summarised in Table 1. that are built upon in this section so that we are ready to write basic programs.
Table 1: The key differences in variable declaration and mutability between Rust and C/C++.
| Concept | Rust Syntax/Behaviour | C/C++ Equivalent/Behaviour |
|---|---|---|
| Default Mutability | Immutable by default (let x = 5;). Compile-time error on re-assignment. | Mutable by default (int x = 5;). |
| Explicit Mutability | let mut x = 5; (Allows re-assignment). | int x = 5; (No special keyword needed for mutability). |
| Immutability | let x = 5; (Default). | const int x = 5; (Requires const keyword). |
| Constants | const NAME: Type = VALUE; (Always immutable, compile-time evaluable). | const int NAME = VALUE; (Similar, but const in C++ can be runtime-initialised). |
| Shadowing | let x = 5; let x = x + 1; (Creates a new variable, can change type). | No direct equivalent; requires a new variable name or nested scope. |
Rust’s Fundamental Data Types
Section titled “Rust’s Fundamental Data Types”Rust provides a comprehensive set of built-in data types, categorised into scalar and compound types. These types are designed with memory efficiency and safety in mind, often with explicit size specifications that benefit low-level programming and computer architectures.
Scalar Types
Section titled “Scalar Types”Scalar types represent a single value that cannot be broken down into smaller components.
Integers (i8, u8, i32, u32, isize, usize, etc.): Signed vs. Unsigned, Fixed vs. Variable Size
Rust offers a rich array of integer types, distinguished by their “signedness” and bit-width. Types prefixed with i denote signed integers (e.g., i32 for a 32-bit signed integer), capable of storing both positive and negative values using two’s complement representation. Types prefixed with u denote unsigned integers (e.g., u32 for a 32-bit unsigned integer), which can only store non-negative values.
Fixed-size integer types are available in 8, 16, 32, 64, and 128 bits, providing precise control over memory footprint and range. Additionally, Rust includes isize and unsigned usize, which are variable-size integer types whose size matches the pointer size of the underlying machine architecture (e.g., 32-bit on a 32-bit system, 64-bit on a 64-bit system). usize is particularly common for indexing collections due to its architecture-dependent size, ensuring compatibility with memory addresses.
By default, if an integer literal’s type cannot be inferred, Rust defaults to i32. These types are directly comparable to C/C++ integer types such as int, short, long, long long, and their unsigned counterparts. A key difference is that Rust’s explicit bit-width types (e.g., i32) offer guaranteed portability and size across different platforms, unlike C/C++‘s int, whose size can vary depending on the compiler and architecture.
fn main() { let _signed_int: i32 = -42; // added leading underscore because not reused let unsigned_int: u64 = 1_000_000; // underscores for readability let _index: usize = 5; // used for indexing (align to native pointer size)
// Alternate numeric bases let bin: u8 = 0b1010_1101; // binary for 173 let oct: u16 = 0o755; // octal for 493 (common in file permissions) let hex: u32 = 0xFF_AA_00; // hexadecimal for 16,755,200
println!("Decimal: {}", unsigned_int); println!("Binary: {bin}"); println!("Octal: {oct}"); println!("Hex: {hex}");}This code gives the output:
Decimal: 1000000Binary: 173Octal: 493Hex: 16755200Floating-Point Types (f32, f64) Rust provides two floating-point types: f32 for single-precision (32-bit) and f64 for double-precision (64-bit) floating-point numbers. Both adhere to the IEEE-754 standard for floating-point arithmetic (binary32 and binary64). Similar to integers, f64 is the default type inferred for floating-point literals if no explicit type is provided. These types are directly analogous to C/C++‘s float and double types.
let pi: f32 = 3.14159;let e: f64 = 2.718281828;Booleans (bool) The bool type in Rust represents a Boolean value, which can be either true or false. This type is identical in concept and usage to the bool type in C/C++.
let is_active: bool = true;Characters (char): Unicode Nature Rust’s char type is designed to represent a single Unicode Scalar Value. This means a char in Rust is four bytes in size, enabling it to represent a vast range of characters, including emojis, beyond the typical single-byte ASCII characters found in C/C++‘s char type. Character literals are defined using single quotes.
let letter_a: char = 'a';let happy_emoji: char = '😊';In C/C++, char is typically one byte and often represents ASCII or extended ASCII characters. Handling full Unicode character sets usually necessitates the use of external libraries, which can add complexity.
Rust strictly enforces type safety. Integer arithmetic and floating-point arithmetic behave differently, so Rust does not automatically convert between them. Requiring explicit casts:
- Prevents silent precision loss
- Avoids surprising behaviour in calculations
- Makes your intent clear in code
This strictness leads to safer and more predictable numerical operations compared to languages that automatically mix types (like Python or JavaScript). For example:
fn main() { let x: i32 = 10; // An integer value let y: f32 = 4.0; // A floating-point value
// Rust is strict: you cannot directly divide an integer by a float let result = x / y; // This will not compile
// You must explicitly convert one type so both types match let result = x as f32 / y; // Convert x to f32, now both sides are floats
println!("Result: {}", result); // Output: 2.5}It is a good time now to revisit mutability with a short Live Code example that allows you to create variables and see the impact of setting the value to be mutable.
🎬Code Demo: Rust Mutability
Section titled “🎬Code Demo: Rust Mutability”Rust Mutability Lab
Toggle mut on the binding and on the reference. To mutate through r, you need both.
Compound Types
Section titled “Compound Types”Compound types group multiple values into a single type.
Tuples: Fixed-size, Heterogeneous Collections
Tuples in Rust are ordered lists of fixed size that can hold values of different types. Elements within a tuple can be accessed by zero-based indexing (e.g., tuple.0, tuple.1) or through a destructuring let statement, which allows for simultaneous assignment of multiple bindings by matching a pattern on the left-hand side. Tuples in Rust are conceptually similar to std::tuple in C++, but they are a primitive type in Rust, integrated directly into the language’s core. In theory you can create a tuple with as many elements as memory allows.
let person_data = ("Alice", 30, true); // Type: (&str, i32, bool)let (name, age, is_student) = person_data; // Destructuringprintln!("Name: {}, Age: {}", name, person_data.1);Arrays: Fixed-size, Homogeneous Collections
Arrays are fixed-size collections where all elements must be of the same type. The size N of an array is a compile-time constant. A critical safety feature in Rust is that array access is bounds-checked at runtime, preventing common out-of-bounds access errors that are prevalent in C-style arrays. Rust arrays are similar to C-style arrays (e.g., int arr5;) or std::array in C++. Rust’s runtime bounds checking provides a significant safety advantage over raw C arrays, where out-of-bounds access leads to undefined behaviour.
let numbers: [i32; 5] = [1, 2, 3, 4, 5];let first_element = numbers; // Access element// let out_of_bounds = numbers[6]; // This would panic at runtimeSlices (&): Safe Views into Collections
A slice is a reference to a contiguous sequence of elements within another data structure, such as an array or a Vec. Slices provide a “view” into data without taking ownership or copying the underlying data. They possess a defined length and can be either mutable (&mut) or immutable (&). Conceptually, slices are similar to pointers used in conjunction with a length (e.g., T* data, size_t len) or std::span (introduced in C++20). However, Rust’s borrow checker ensures the validity of slices throughout their lifetime, a guarantee not inherently provided by raw pointers or std::span in C++.
let data = [10, 20, 30, 40, 50]; // Define an array of integerslet all_data: &[i32] = &data[..]; // Slice of the whole arraylet middle_part: &[i32] = &data[1..4]; // Slice from index 1 (inc) to 4 (ex): [20, 30, 40]Strings: String (Owned) vs. &str (Borrowed Slice)
Rust distinguishes between two primary string types:
String: This is a growable, mutable, owned, UTF-8 encoded string type. It allocates its data on the heap, making it suitable for dynamic string manipulation.&str(string slice): This is an immutable, fixed-length view into aStringor a string literal (which typically resides in the binary’s read-only data section). The String type in Rust is analogous to C++‘sstd::string, which is also heap-allocated, owned, and growable.
let owned_string = String::from("hello world"); // Heap-allocated, ownedlet string_literal: &str = "hello literal"; // Static string literallet borrowed_slice: &str = &owned_string[6..]; // Slice of an owned StringType Inference: When Rust Helps You Out!
Rust is a statically typed language, meaning that all types are determined at compile time. However, developers are not always required to explicitly write out type annotations. The compiler can frequently infer the type of a variable based on the value assigned to it and how that value is subsequently used within the code.
let inferred_int = 42; // Inferred as i32 by defaultlet inferred_float = 3.14; // Inferred as f64 by defaultThis feature is similar to C++11’s auto keyword, which also allows for type inference. However, Rust’s inference is often more pervasive and generally less prone to unexpected type deductions in complex scenarios, contributing to its overall type safety and ease of use.
Table 2: This table provides an overview of Rust’s primitive data types, their typical sizes, and their conceptual equivalents in C/C++.
| Type | Description | Size (typical) | C/C++ Equivalent/Note | Example |
|---|---|---|---|---|
| bool | Boolean (true/false) | 1 byte | bool | let flag = true; |
| char | Unicode Scalar Value | 4 bytes | wchar_t or multi-byte handling | let c = '😊'; |
| i8 | Signed integer | 1 byte | signed char | let x: i8 = -10; |
| u8 | Unsigned integer | 1 byte | unsigned char | let x: u8 = 200; |
| i16 | Signed integer | 2 bytes | short | let x: i16 = 1000; |
| u16 | Unsigned integer | 2 bytes | unsigned short | let x: u16 = 50000; |
| i32 | Signed integer | 4 bytes | int (often) | let x: i32 = -12345; |
| u32 | Unsigned integer | 4 bytes | unsigned int (often) | let x: u32 = 123456; |
| i64 | Signed integer | 8 bytes | long long | let x: i64 = 1_000_000_000; |
| u64 | Unsigned integer | 8 bytes | unsigned long long | let x: u64 = 2_000_000_000; |
| i128 | Signed integer | 16 bytes | No direct standard equivalent | let x: i128 = -1; |
| u128 | Unsigned integer | 16 bytes | No direct standard equivalent | let x: u128 = 1; |
| isize | Signed pointer-sized integer | Varies (e.g., 4 or 8 bytes) | ptrdiff_t | let x: isize = -1; |
| usize | Unsigned pointer-sized integer | Varies (e.g., 4 or 8 bytes) | size_t | let len: usize = 10; |
| f32 | Single-precision float | 4 bytes | float | let pi: f32 = 3.14; |
| f64 | Double-precision float | 8 bytes | double | let e: f64 = 2.718; |
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match Fundamental Types
Explicit Type Casting
Array Indexing and Safety
Tuple Destructuring
Functions: Building Blocks of Rust Code
Section titled “Functions: Building Blocks of Rust Code”Functions are fundamental organisational units in Rust, encapsulating reusable blocks of code. Their design reflects Rust’s emphasis on clarity, type safety, and predictable behaviour.
Defining Functions (fn)
Section titled “Defining Functions (fn)”Functions in Rust are declared using the fn keyword, followed by the function name, a list of parameters enclosed in parentheses, and a body defined within curly braces. By convention, Rust code adheres to snake_case for function names. This syntax is largely similar to function definitions in C/C++.
fn greet_student() { println!("Hello EEN1097 Student, from Rust!");}Parameters and Arguments: Explicit Types Required In Rust function signatures, it is mandatory to declare the type of each parameter. This explicit type annotation is a deliberate design choice that assists the compiler in precise type inference and facilitates the generation of more informative error messages. This requirement is similar to C/C++, where parameter types must also be explicitly declared in function prototypes and definitions.
fn add_numbers(a: i32, b: i32) { println!("Sum: {}", a + b);}Return Values: Implicit Expressions and Explicit return
Section titled “Return Values: Implicit Expressions and Explicit return”Functions in Rust can return values, with the return type explicitly declared after a thin arrow (-> which is a - followed by a >) in the function signature. A distinctive feature of Rust is its expression-oriented nature: the return value of a function is implicitly the value of the final expression in its body. When an expression is intended as the return value, it should not be terminated with a semicolon. While implicit returns are common and idiomatic, the return keyword can also be used for early returns or to enhance explicit clarity, much like in C/C++.
Rust Example (Implicit return using an expression):
// Rust - Implicit return (expression)fn multiply(a: i32, b: i32) -> i32 { a * b // No semicolon, this is an expression and the return value}Rust Example (Explicit return using a statement):
// Rust - Explicit return (statement)fn subtract(a: i32, b: i32) -> i32 { return a - b; // Uses return keyword}In C/C++, the return keyword is always explicit to specify the return value. The last statement in a function does not implicitly become the return value.
// C++ - Explicit returnint multiply(int a, int b) { return a * b;}Statements vs. Expressions: A Core Rust Distinction
Section titled “Statements vs. Expressions: A Core Rust Distinction”Rust makes a clear distinction between statements and expressions, a concept central to understanding its function bodies and return semantics.
- Statements are instructions that perform an action but do not yield a value. Examples include
letbindings (e.g.,let y = 6;) and function definitions themselves. - Expressions evaluate to a resultant value. Common examples include mathematical operations (e.g.,
6 + 7evaluates to13), function calls, macro calls, and new scope blocks enclosed in curly braces ({}).
A critical rule in Rust is that adding a semicolon to the end of an expression transforms it into a statement. This means it will not return a value. If such a statement is the final line in a function declared to return a value, it will result in a “mismatched types” compile-time error, as statements implicitly evaluate to (), the unit type (representing no value, which is similar to the spirit of void in C++). For example:
fn main() { let x = 5; // This expression evaluates to 5 let mut y = 5; // Also evaluates to 5
let z = (y = 10); // Assignment is a statement, evaluates to () println!("z = {:?}", z);}Will give the output:
z = ()Comments: Documenting Your Code
Section titled “Comments: Documenting Your Code”Effective documentation is crucial for code maintainability and collaboration. Rust provides robust, built-in mechanisms for commenting and generating documentation directly from source code.
Single-line Comments (//)
The most common and idiomatic style for short, single-line comments in Rust begins with two forward slashes (//) and extends to the end of the line. This syntax is identical to single-line comments in C++.
Block Comments (/*... */)
For comments spanning multiple lines, Rust supports block comments enclosed within /*... */ delimiters. A useful feature of Rust’s block comments is their ability to be nested, which is particularly convenient for temporarily disabling or commenting out large sections of code during development or debugging. This is identical to multi-line comments in C/C++, however, not all compilers permit nesting.
Documentation Comments (///, //!)
Rust provides full support for generating HTML documentation directly from source code using the rustdoc tool. This integration encourages developers to document their code comprehensively and keep it up-to-date.
///: These comments are used for item-level documentation and are placed directly above the item they describe (e.g., functions, structs, enums).//!: These comments are used for module-level documentation and are placed at the top of a file (to document the current module) or within a module declaration (for sub-modules).
Documentation comments support Markdown syntax, allowing for rich formatting, and can include specific sections such as # Examples, # Panics, # Errors, and # Safety to provide detailed usage information and behavioural caveats. For example in a module,
/// Adds two integers together.////// # Examples/// ```/// let sum = my_module::add(5, 3);/// assert_eq!(sum, 8);/// ```pub fn add(a: i32, b: i32) -> i32 { a + b}
//! # My Awesome Module//! This module provides utility functions for various tasks.This results in documentation output that looks like the following, but in the form of a professional-grade webpage:
Show Example Output:
add
pub fn add(a: i32, b: i32) -> i32
Adds two integers together.
Examples
let sum = my_module::add(5, 3);assert_eq!(sum, 8);As described previously, C/C++ typically relies on external tools like Doxygen for generating documentation from source code. While Doxygen uses specific formatting conventions within comments, the integration is not as inherent to the language syntax itself as it is in Rust.
The deep integration of documentation is a strong language feature in Rust, where special syntax for documentation comments is parsed by rustdoc to generate HTML documentation (similar to javadoc with Java), significantly improving code discoverability and usability. This approach encourages developers to write detailed, up-to-date documentation directly alongside their code, reducing the friction associated with maintaining separate documentation files or relying solely on external tools.
For complex systems, particularly in areas like edge programming where components might be developed by different teams or deployed in diverse environments, high-quality, accessible documentation is important for maintainability, developer onboarding, and collaboration. This fosters a culture of self-documenting code, raising the bar for code quality by making documentation an integral part of the development process. The broader implication is that this design choice significantly enhances project sustainability and reduces the bus factor by ensuring knowledge is embedded within the codebase itself.
🧩Knowledge Check
Section titled “🧩Knowledge Check”Functions and Expressions
Implicit vs. Explicit Returns
Function Ordering and Signatures
Control Flow: Directing Program Execution
Section titled “Control Flow: Directing Program Execution”Control flow constructs dictate the order in which different parts of a program are executed. Rust provides a familiar yet enhanced set of constructs compared to C/C++, often leveraging its expression-oriented nature for greater flexibility and safety.
Conditional Execution: if/else
Section titled “Conditional Execution: if/else”In Rust, if conditions are strictly required to evaluate to a bool type. A notable feature of Rust is that if itself is an expression, meaning it can return a value that can then be assigned to a variable. When an if expression is used for assignment, all its branches (if, else if, else) must consistently return the same type to satisfy the compiler. Logical operators && (AND) and || (OR) are used to combine multiple conditions. For example,
fn main(){ let number = 7; let description = if number % 2 == 0 { "even" } else { "odd" }; println!("The number is {}", description); // Output: The number is odd}The basic syntax of if/else is similar to C/C++. However, the ability to use if as an expression for conditional assignment is a key distinction. C++‘s and Java’s ternary operator (condition? val1 : val2) is its closest equivalent for achieving conditional assignment in a single expression.
While Rust does not require parentheses around the condition (e.g., if my_number == 7 instead of if (my_number == 7)), the code that executes when the condition is met (or not met) must be enclosed within curly braces {} (unlike C++). These curly braces define a code block, which can also act as an expression that returns a value.
Looping Constructs: loop, while, and for
Section titled “Looping Constructs: loop, while, and for”Rust offers three primary looping constructs: loop, while, and for.
loop: Infinite Loops with break and continue, Returning Values
The loop keyword creates an infinite loop that continues executing its block of code indefinitely until explicitly halted by a break keyword. The continue keyword can be used to skip the current iteration and proceed to the next. A unique and powerful feature of Rust’s loop is its ability to return a value, which is specified after the break keyword. This construct is functionally similar to a while(true) loop in C/C++, but with the added capability of directly returning a value from the loop block. For example,
fn main(){ let mut counter = 0; let result = loop { counter += 1; if counter == 10 { break counter * 2; // Returns 20 } }; println!("Loop result: {}", result); // Output: Loop result: 20}while Loops
while loops execute a block of code repeatedly as long as a specified condition remains true. This construct is identical in behaviour and syntax to while loops in C/C++. For example,
fn main(){ let mut count = 3; while count!= 0 { // ! followed by = println!("{}!", count); count -= 1; } println!("LIFTOFF!!!");}for Loops with Iterators: Efficient Collection Traversal
The for loop is the most common and idiomatic way to iterate over collections in Rust. It operates with iterators, providing an efficient and safe mechanism for processing data without the need for manual index management. Rust’s for loops can iterate in several ways:
for element in collection.iter(): Iterates over immutable references to elements.for element in collection.iter_mut(): Iterates over mutable references to elements.for element in collection: Iterates by taking ownership of the elements (moving them). Ranges are frequently used with for loops, specified asstart..end(exclusive ofend) orstart..=end(inclusive ofend).
fn main(){ // Rust - Iterating over an array let a = [10, 20, 30, 40, 50]; for element in a.iter() { println!("The value is: {}", element); }
// Rust - Iterating over a range for number in 1..=3 { // The = means the range includes 3 println!("{}", number); }}Conceptually, Rust’s for loop with iterators is similar to C++11’s range-based for loop (e.g., for (auto& element : collection)). However, Rust’s iterators are a deeply integrated and powerful part of the language and standard library, often allowing more optimised and flexible data processing pipelines through methods like map, filter, and fold.
Pattern Matching with match: A Powerful Alternative to switch
Section titled “Pattern Matching with match: A Powerful Alternative to switch”The match expression in Rust is an expressive and safe control flow construct that compares a value against a series of patterns and executes code based on the first matching pattern. Each branch within a match expression is called an “arm,” and uses the => (fat arrow — i.e., = followed by >) to define the action for a given pattern.
A critical feature of match is its exhaustiveness: the compiler rigorously ensures that all possible cases for the matched value are handled. This compile-time guarantee prevents unhandled cases, which are a common source of bugs and undefined behaviour in C++ switch statements. The _ (underscore) pattern serves as a catch-all for any cases not explicitly matched. Like if expressions, match can also return a value, making it flexible for conditional assignments. Additionally, “match guards” allow for if conditions to be embedded within match arms, enabling more precise pattern matching.
Rust Example (Matching with enum):
enum Status { Connected, Disconnected(u32), // Holds a value Error(String),}
fn handle_status(s: Status) { match s { Status::Connected => println!("Status: Connected"), Status::Disconnected(code) => println!("Status: Disconnected code {}", code), Status::Error(msg) => println!("Status: Error - {}", msg), }}
fn main() { handle_status(Status::Connected); handle_status(Status::Disconnected(404)); handle_status(Status::Error(String::from("Network failed")));}This gives the output:
Status: ConnectedStatus: Disconnected code 404Status: Error - Network failedRust Example (Matching with a value):
fn main() { let number = 5; let text_representation = match number { 1 => "one", 2 => "two", _ => "other", // Catch-all for all other cases }; println!("Number is: {}", text_representation); // Output: Number is: other}The match expression is significantly more powerful and inherently safer than C++‘s switch statement. C++ switch statements are limited to integer types, lack exhaustive checking (requiring explicit break statements to prevent fall-through bugs), and offer far less flexibility in pattern matching. The exhaustiveness of Rust’s match expression, coupled with its support for complex patterns, including enum variants with associated data and match guards, is a cornerstone of Rust’s correctness and robustness.
Rust Macros: More Than Textual Replacement
Section titled “Rust Macros: More Than Textual Replacement”In your Rust journey so far, you have already used several macros, such as println!, vec!, and panic!. The clear indication of a macro in Rust is the exclamation mark (!) following its name. While they might look like functions, they are fundamentally different.
Declarative and Procedural Macros
Rust provides two main types of macros: declarative macros (often defined using macro_rules!) and procedural macros.
- Declarative macros allow you to write code that looks like a
matchexpression, but instead of values, it matches patterns of Rust code and expands it into more code. - Procedural macros are more advanced; they act like functions that take code as input, operate on it using the compiler’s own syntax tree, and output new code.
Rust Macros vs. C++ Preprocessor Macros
For a C++ developer, the word “macro” often brings to mind the #define preprocessor. However, Rust macros are significantly more sophisticated and safer:
- C++ macros perform simple textual substitution before the compiler even sees the code. This can lead to notorious bugs (e.g., operator precedence issues or multiple evaluations of an argument). Rust macros operate on the Abstract Syntax Tree (AST), meaning they understand the structure of the code they are generating and are parsed by the compiler itself.
- Rust macros are hygienic. In C++, a macro can accidentally capture a variable from the scope it is called in, leading to “name shadowing” bugs that are very hard to track down. Rust’s macro system tracks which names come from which scope, preventing these accidental collisions. In Rust, the exclamation mark
!is not a valid character for a function identifier (name). - Because the compiler is “aware” of macros, it can provide much better error messages. If you pass the wrong type or number of arguments to a macro like
println!, the compiler will give you a specific error at compile-time, rather than failing with a cryptic message after a textual expansion.
Common Declarative Macros You’ll Encounter
println!(...): Prints to standard output with rich formatting.println!("The value is {} and its square is {}", x, x * x);format!(...): Similar toprintln!, but returns aStringinstead of printing.let s = format!("Error code: {}", 404);vec![...]: A convenient shorthand for creating and initialising aVec<T>.let v = vec![1, 2, 3, 4, 5];panic!(...): Immediately terminates the current thread with an error message, useful for unrecoverable errors.panic!("Critical failure: database connection lost!");
All of theses macros are declarative. Procedural macros are more “magical” and often look different in your code, for example derive macros: #[derive(Debug, Serialize)] (placed above a struct) or attribute macros used in lager chapters, e.g., #[tokio::main].
By using macros, Rust provides powerful abstractions (like variable argument numbers in println!) without compromising on type safety or performance, as the code is expanded and optimised during the compilation process.
🧩Final Knowledge Check
Section titled “🧩Final Knowledge Check”Control Flow and Macros
Conditional Assignment
Returning from a Loop
Range Iteration
Pattern Matching Exhaustiveness
Macro Syntax
© 2026 Derek Molloy, Dublin City University. All rights reserved.