Skip to content
portfolio/howtos/Learning Rust/

Chapter 02

Rust Fundamentals

Variables, types, strings, functions, control flow - the core language you need before writing any real code.

Variables, Mutability, and Shadowing

In Rust, variables are immutable by default. This is one of the first things that surprises people coming from other languages. You declare a variable with let:

let port = 8080;
port = 3000; // ERROR: cannot assign twice to immutable variable

If you need a variable to change, you opt in explicitly with mut:

let mut port = 8080;
port = 3000; // fine - port is mutable

Why immutable by default? It makes code easier to reason about. When you read a function and see let x = ..., you know x never changes. The compiler enforces it. mut is a signal that says "pay attention, this value will be modified."

Note
Rust also has const for compile-time constants: const MAX_CONNECTIONS: u32 = 100;. Unlike let, constants require a type annotation and must be set to a value that can be computed at compile time. They're useful for values that are truly fixed - like config limits or mathematical constants.

Shadowing lets you re-declare a variable with the same name. This creates a completely new variable - it's not mutation:

let input = "8080";        // input is a &str
let input = input.parse::<u16>().unwrap();  // input is now a u16

// The old string is gone. The new input is an integer.
println!("Port: {}", input);

Shadowing is useful when you want to transform a value and keep the same meaningful name. Without shadowing, you'd need awkward names like input_str and input_num. The compiler tracks types through each shadow, so there's no confusion.

Primitive Types

Rust has a small set of built-in types. Unlike languages where numbers are just "numbers," Rust makes you choose the size and signedness explicitly. This matters for a webserver - you need to know exactly how much memory you're using.

Integers

SignedUnsignedSize
i8u88-bit
i16u1616-bit
i32u3232-bit (default for integers)
i64u6464-bit
isizeusizePointer-sized (64-bit on modern systems)

If you don't annotate, integer literals default to i32. For our webserver, you'll mostly see u16 for ports, usize for indexing and lengths, and u8 for raw bytes.

let port: u16 = 8080;
let max_connections: u32 = 1000;
let byte: u8 = 255;

Floats, Booleans, Characters

let pi: f64 = 3.14159;     // f64 is the default float
let half: f32 = 0.5;       // f32 when you need less precision

let running: bool = true;
let verbose = false;        // type inferred as bool

let letter: char = 'A';    // char is 4 bytes - it holds any Unicode scalar
let crab: char = '🦀';     // yes, emoji are valid chars
Tip
Rust's char is not a single byte like in C. It's a Unicode scalar value, always 4 bytes wide. This means you can store any character from any language - or a crab emoji - in a single char.

Strings vs String Slices

Strings trip up every Rust beginner because Rust has two main string types. Understanding the difference early saves a lot of confusion:

  • String - an owned, heap-allocated, growable string. You can modify it, append to it, pass it around. You own the data.
  • &str - a string slice, a reference to a sequence of UTF-8 bytes stored somewhere else. It's read-only and borrowed. String literals like "hello" are &str.
// &str - a borrowed reference to string data
let greeting: &str = "Hello, world";

// String - owned, heap-allocated, growable
let mut response = String::from("HTTP/1.1 200 OK");
response.push_str("\r\nContent-Type: text/html");

// Converting between them
let owned: String = greeting.to_string();  // &str → String
let borrowed: &str = &response;            // String → &str (auto-deref)

Think of it like this: String is like owning a notebook - you can write in it, tear out pages, add pages. &str is like pointing at someone else's notebook and saying "read that part." You can look at it but not change it.

When writing functions, prefer taking &str as input - it accepts both String (via auto-deref) and string literals. Return String when your function builds a new string.

// This function accepts both String and &str as input
fn format_status_line(code: u16, reason: &str) -> String {
    format!("HTTP/1.1 {} {}", code, reason)
}

let line = format_status_line(200, "OK");
println!("{}", line); // HTTP/1.1 200 OK
Note
The format! macro works like println! but returns a String instead of printing. You'll use it constantly when building HTTP responses.

Functions, Expressions, and Statements

Functions are declared with fn. Unlike many languages, Rust requires you to annotate parameter types and return types - the compiler does not infer them:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

Notice there's no return keyword and no semicolon on the last line. That's because in Rust, the last expression in a block is the return value. This is a key distinction:

  • An expression produces a value: a + b, 5, if x { 1 } else { 2 }
  • A statement performs an action but doesn't return a value: let x = 5;, println!("hi");

The semicolon is what turns an expression into a statement. If you add a semicolon to the last line, it becomes a statement and the function returns () instead:

fn add(a: i32, b: i32) -> i32 {
    a + b   // expression - returned
}

fn add_broken(a: i32, b: i32) -> i32 {
    a + b;  // statement - returns (), compiler error!
}
Warning
This is the most common beginner mistake in Rust. If the compiler says "expected i32, found ()" - you probably have an extra semicolon on your return expression.

You can use return for early returns, and it's idiomatic to do so:

fn parse_port(input: &str) -> u16 {
    if input.is_empty() {
        return 8080; // early return with default
    }
    input.parse().unwrap_or(8080)
}

Because blocks are expressions, if/else can produce values too - Rust has no ternary operator because it doesn't need one:

let mode = if verbose { "debug" } else { "release" };

Control Flow

Rust has the control flow constructs you'd expect, with a few twists that make them more powerful.

if / else

Conditions don't use parentheses. The body is always a block with braces - no single-line ifs:

let status = 404;

if status == 200 {
    println!("OK");
} else if status == 404 {
    println!("Not Found");
} else {
    println!("Status: {}", status);
}

loop

loop creates an infinite loop. You break out of it with break. Unlike while true in other languages, loop can return a value:

let mut attempts = 0;

let result = loop {
    attempts += 1;
    if attempts == 3 {
        break "connected"; // loop evaluates to this value
    }
};

println!("{} after {} attempts", result, attempts);
// "connected after 3 attempts"

This pattern is useful for retry loops - something we'll use when our webserver tries to bind to a port.

while

while loops when you have a condition:

let mut countdown = 3;
while countdown > 0 {
    println!("{}...", countdown);
    countdown -= 1;
}
println!("Launch!");

for

for iterates over anything that implements the Iterator trait. You'll use it far more than while:

// Range: 0, 1, 2, 3, 4
for i in 0..5 {
    println!("Request #{}", i);
}

// Inclusive range: 1, 2, 3, 4, 5
for i in 1..=5 {
    println!("Attempt {}", i);
}

// Iterating over a collection
let methods = ["GET", "POST", "PUT", "DELETE"];
for method in methods {
    println!("Supports: {}", method);
}
Tip
Prefer for over while whenever possible. for with an iterator is idiomatic Rust, prevents off-by-one errors, and the compiler can optimize it better.

Comments and Documentation

Rust has three comment styles:

// Line comment - for explaining implementation details

/* Block comment - rarely used in practice,
   most Rustaceans prefer line comments */

/// Doc comment - generates HTML documentation.
/// These go before the item they document.
/// Supports **Markdown** formatting.
///
/// # Examples
///
/// ```
/// let port = parse_port("8080");
/// assert_eq!(port, 8080);
/// ```
fn parse_port(input: &str) -> u16 {
    input.parse().unwrap_or(8080)
}

Doc comments (///) are special - cargo doc compiles them into browsable HTML documentation. The # Examples section isn't just for show - Cargo can actually run those code examples as tests with cargo test. This means your documentation stays in sync with your code.

There's also //! for documenting the enclosing item (like a module or crate):

src/main.rs
//! A simple HTTP webserver built from scratch.
//!
//! This project is a learning exercise for Rust,
//! building up from raw TCP to a full-featured server.

fn main() {
    println!("Starting webserver...");
}
Note
Run cargo doc --open in your project to generate and browse the documentation. Even at this early stage, try it - seeing your doc comments rendered as HTML makes the convention click.

Putting It Together

Let's update our webserver's main.rs to use everything from this chapter. This is still just printing messages - we're not networking yet - but it exercises variables, types, functions, and control flow:

src/main.rs
/// Parse a port number from a string, falling back to a default.
fn parse_port(input: &str) -> u16 {
    if input.is_empty() {
        return 8080;
    }
    input.parse().unwrap_or(8080)
}

/// Build an HTTP status line from a status code.
fn status_line(code: u16) -> String {
    let reason = if code == 200 {
        "OK"
    } else if code == 404 {
        "Not Found"
    } else {
        "Unknown"
    };
    format!("HTTP/1.1 {} {}", code, reason)
}

fn main() {
    let port = parse_port("3000");
    let host = "127.0.0.1";

    println!("Starting webserver on {}:{}", host, port);

    let codes: [u16; 3] = [200, 404, 500];
    for code in codes {
        println!("  {} → {}", code, status_line(code));
    }
}

Run it with cargo run. You should see:

Starting webserver on 127.0.0.1:3000
  200 → HTTP/1.1 200 OK
  404 → HTTP/1.1 404 Not Found
  500 → HTTP/1.1 500 Unknown

Exercise

  1. Add a reason_phrase function that takes a u16 status code and returns an &str for common HTTP codes (200, 201, 204, 301, 400, 401, 403, 404, 500). Use a match if you want to peek ahead - or chain if/else if for now.
  2. Add doc comments (///) to your functions and run cargo doc --open to see the generated docs.
  3. Experiment with shadowing: declare a let port = "8080"; then shadow it with let port = port.parse::<u16>().unwrap(); and print the result.
  4. Try using a for loop with a range to print a countdown: "Server starting in 3... 2... 1..."

What's Next

You now know how to declare variables, choose types, write functions, and control program flow. These are the basic building blocks for everything that follows.

In the next chapter, we tackle ownership - the concept that makes Rust unique. It's the reason Rust can guarantee memory safety without a garbage collector, and it's the thing you need to understand before we start passing data between connections.