Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

Test MDX File V15

C++ Concepts Crossword

Twenty C++ terms to recall and fill in. Tick each clue once you have addressed it.

🧩

Click a cell and type to fill the highlighted word. Click the same cell again (or press Enter) to switch between the across and down word passing through it. Clicking a clue jumps to its place in the grid. The checkbox in front of each clue is your own record of progress; Check grid highlights any incorrect letters without giving the answer away.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0/80 cells

Across

Down

Rust Concepts Crossword

Twenty Rust terms to recall and fill in. Tick each clue once you have addressed it.

🧩

Click a cell and type to fill the highlighted word. Click the same cell again (or press Enter) to switch between the across and down word passing through it. Clicking a clue jumps to its place in the grid. The checkbox in front of each clue is your own record of progress; Check grid highlights any incorrect letters without giving the answer away.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0/76 cells

Across

Down

Edge Programming Crossword

Twenty edge-programming terms to recall and fill in. Tick each clue once you have addressed it.

🧩

Click a cell and type to fill the highlighted word. Click the same cell again (or press Enter) to switch between the across and down word passing through it. Clicking a clue jumps to its place in the grid. The checkbox in front of each clue is your own record of progress; Check grid highlights any incorrect letters without giving the answer away.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0/76 cells

Across

Down

Data Races
0/3 challenges

Data Race Lab: Be the Scheduler

123First attempt per challenge counts.

The program under test (C++)

g++ compiles this without complaint
int counter = 0;              // shared, unprotected

void worker() {
    counter = counter + 1;    // read -> add -> write
}

int main() {
    std::thread a(worker);
    std::thread b(worker);
    a.join();
    b.join();
    // counter == ?           // 1 or 2: the scheduler decides
}

The line counter = counter + 1 looks like one operation but is three: read, add, write. The scheduler can pause a thread between any of them, and the compiler will not warn you. Below, you are the scheduler.

Challenge 1: be a hostile scheduler

Schedule the six micro-steps so the final counter is 1. Two increments ran, one vanished. Hint: what happens if both threads read before either writes? Target final counter: 1

counter (shared)0
A reg
B reg
AReadAddWrite
BReadAddWrite

No steps yet. You are the scheduler: choose which thread runs next.

step 0 / 6
Vectors & Growth

Vector Growth & Invalidation Lab

One machine, two languages

std::vector<int> v;     // ptr = nullptr, size = 0, capacity = 0
v.push_back(10);        // reallocates whenever size == capacity
int* p = &v[0];         // valid only until the NEXT reallocation

A std::vector<int> is three words on the stack: a pointer to a heap block, a length and a capacity. Switching languages resets the lab because the growth policies genuinely differ: this C++ implementation grows 1, 2, 4, 8, 16 while Rust's Vec<i32> starts at 4.

Stack
ptrnullptr
size0
capacity0
reallocations: 0 · elements copied: 0
Heap

ptr is nullptr: an empty vector costs no heap memory at all.

Fresh vector: nothing on the heap yet. Try a push.

The invalidation experiment

Take a pointer to the first element, then push until the vector grows. The pointer is not updated when the block moves: it silently dangles.

Discussion:

Doubling capacity is what makes push amortised O(1): the copies get rarer exactly as they get bigger, so the total copying work stays proportional to the final length (watch the "elements copied" counter as you push to 16). The price of moving blocks is that every reallocation invalidates every pointer, reference and iterator into the vector. C++ states this as a documented rule the programmer must remember, and breaking it is silent undefined behaviour. Rust encodes the same rule in the type system: a live borrow freezes the vector, so the code that would dangle is rejected before it runs. In both languages the practical fix is the same: when you know the size in advance, call reserve once and no growth (and therefore no invalidation) can occur.

Result & Option
0/3 challenges

Result & Option Lab: Failure as a Value

The pipeline you are configuring (Rust)

fn run(s: &str) -> Result<i32, &'static str> {
    let n = s.parse::<i32>().unwrap();
    let q = 100i32.checked_div(n).unwrap();
    Ok(q)
}

run("4");

Two things can go wrong: the parse can fail (a Result with a reason) and the division can be impossible (an Option, because absence needs no reason). Neither failure is an exception: both arrive as ordinary values your code must do something with.

Input
On Err
On None

Take the value; PANIC if it is an Err. Then: take the value; PANIC if it is None.

0 / 10

Press Step to watch the value (or the failure) travel through the pipeline.

Meanwhile in C++, with input "4"

int n = std::stoi(s);   // can throw: the signature does not say so
int q = 100 / n;        // n == 0 is undefined behaviour, not an exception

std::stoi("4") returns 4, then 100 / 4 yields 25. The happy path looks identical in both languages; the unhappy paths are where they part company.

Predict the outcome

For each configuration, predict what happens before you run it. Your first answer counts; after submitting you can load the configuration into the pipeline above and watch it play out.

Challenge 1run("abc") with .unwrap() then .ok_or(..)?
Challenge 2run("0") with ? then .ok_or(..)?
Challenge 3run("abc") with .unwrap_or(1) then .unwrap()

Discussion:

Rust splits "something went wrong" into two types with different meanings: Option<T> for absence (no reason needed) and Result<T, E> for failure with a reason. Because both appear in the function signature, a caller can see every failure mode at a glance, and the compiler will not let one be ignored silently. The ? operator keeps the happy path readable while still propagating every failure honestly, which is what exceptions promise without the visibility. C++ has been converging on this design: std::optional (C++17) mirrors Option, though dereferencing an empty one is still undefined behaviour, and std::expected (C++23) mirrors Result. The habit to build is the same in both languages: reserve unwrap (and unchecked access) for cases you can prove cannot fail, and let everything else travel as a value.

Bytes & Endianness

Endianness Lab: Where Bytes Live

One value, four addresses

uint32_t v = 0x12345678;
uint8_t* p = (uint8_t*)&v;   // peek at v one byte at a time
p[0];                        // which byte lives at the LOWEST address?

A uint32_t occupies four consecutive addresses, and the hardware must choose which end of the number goes first. Little-endian machines (x86, nearly all ARM and RISC-V) put the least significant byte at the lowest address; big-endian machines (network protocols, some MIPS and PowerPC) put the most significant byte there. Same value, same addresses, opposite order.

Value
The value0x12MSB345678LSB= 305,419,896
Machine
Memoryaddresses increase

Click a memory cell to read it through p[i], then toggle the machine and watch the coloured bytes swap ends.

The network byte order trap

Protocols define multi-byte fields in big-endian network order. Your little-endian host stores 0x12345678 as LSB-first bytes, so copying its memory straight onto the wire sends the field backwards. Toggle the conversion and watch what a conformant receiver decodes.

no conversion: the memory image goes out as-is
Wire78563412first byte sent last

Receiver decodes (big-endian, as the protocol specifies): 0x78563412 = 2,018,915,346

The bytes arrived backwards, so the receiver assembled a completely different number. Worse, two little-endian hosts that BOTH skip the conversion will appear to work, and the bug only surfaces when a different machine joins the conversation.

Two languages, one machine

// C++
uint32_t v = 0x12345678;
uint8_t* p = (uint8_t*)&v;
p[0];              // depends on the machine
uint32_t w = htonl(v);   // to network order
// C++20:
// std::endian::native
//   == std::endian::little
// Rust
let v: u32 = 0x12345678;
let le = v.to_le_bytes();  // explicit
let be = v.to_be_bytes();  // explicit
let w = u32::from_be_bytes(buf);
// decode network data the same way
// on EVERY machine

Discussion:

Endianness is invisible right up until data crosses a boundary: a network socket, a file written on one machine and read on another, a sensor register on an embedded bus, or a cast that peeks at bytes the way p[0] does. Inside a single program, arithmetic and the shifts and masks from the Bitwise lab work identically on both kinds of machine, because operators see the value, not its storage. The portable habit is to name the byte order at every boundary: in C and C++ that means the htonl/ntohl family and, since C++20, checking std::endian::native; in Rust the conversion is built into the integer types themselves, and code written with to_be_bytes/from_be_bytes cannot accidentally depend on the host. On the edge devices this book targets, both worlds meet constantly: the ARM core is little-endian while the network it speaks to is big-endian, so this conversion is not a corner case but a daily routine.

Deadlock & Lock Ordering

Rust Deadlock Lab

🏦

Two locks, two threads, one frozen program

A bank service shares two resources, each behind its own lock: Arc<Mutex<Account>> and Arc<Mutex<Vec<String>>> for the audit log. Thread A locks accounts then audit_log. Thread B locks them in the opposite order. Each thread is correct on its own; the bug only exists between them. You are the OS scheduler: choose which thread runs next and try to freeze the program.

This program compiles cleanly. A deadlock is memory-safe, so rustc has nothing to complain about. The borrow checker prevents data races, not deadlocks.

Lock order

Hint for the fatal schedule: step A once, then B once, then try to continue with either thread.

Thread Aready
thread::spawn(move || {    let mut acc = accounts.lock().unwrap();    let mut log = audit_log.lock().unwrap();    acc.balance -= 50; log.push("A: debit 50");}); // scope ends: guards dropped, locks released
Thread Bready
thread::spawn(move || {    let mut log = audit_log.lock().unwrap();    let mut acc = accounts.lock().unwrap();    acc.balance += 30; log.push("B: credit 30");}); // scope ends: guards dropped, locks released

You are the scheduler: who runs next?

scheduler decisions: 0
A holds: nothingB holds: nothingbalance: 100audit entries: 0

Wait-for graph

🔒 accounts🔒 audit_logAB
held bywaiting fora cycle in this graph is a deadlock

The four Coffman conditions

Deadlock needs all four at once. Every classic fix works by breaking exactly one of them.

Mutual exclusion

always present

A Mutex allows exactly one holder at a time. That is its whole job.

Hold and wait

not currently

A thread keeps the lock it has while waiting for another one.

No preemption

always present

Nothing can forcibly take a lock away; guards release only when dropped.

Circular wait

no cycle

A chain of waits that loops back on itself: the cycle in the graph.

Breaking the cycle: three practical fixes

1 · A global lock order

breaks circular wait

Decide one order for the whole program (say, accounts before audit_log) and never acquire against it. No cycle can form. This is the fix the "same order" mode demonstrates, and the standard discipline in large codebases.

2 · One coarser lock

breaks hold and wait

Put both resources behind a single Mutex<Bank>. A thread takes one lock or none, so it can never hold one while waiting for another. Simple and correct, at the cost of less concurrency.

3 · try_lock and back off

breaks hold and wait, voluntarily

try_lock() returns an Err instead of blocking. If the second lock is busy, release the first, pause, and retry. Harder to get right (it can livelock under contention), so prefer fixes 1 or 2.

How C++ compares

// C++17: locks both, in a safe order,
// using a deadlock-avoidance algorithm
std::scoped_lock guard(accounts_mtx,
                       audit_mtx);
// ... both locks held here ...
// released together at end of scope

C++ has exactly the same problem: two std::mutex objects locked in opposite orders deadlock just as silently. But the C++ standard library ships a tool Rust's does not: std::scoped_lock (and the older std::lock) acquires multiple mutexes atomically with a built-in avoidance algorithm. In Rust there is no standard multi-lock helper, so the ordering discipline from fix 1 matters even more. Neither compiler catches the bug; the difference is only in the tools each library offers.

Discussion

Rust's headline guarantee is freedom from data races, and it is real: the compiler will not let two threads mutate the same data unsynchronised. But a deadlock involves no bad memory access at all, so it sits entirely outside the type system. The frozen program you built here is, by Rust's definition, perfectly safe. That is why experienced engineers treat lock acquisition order as an architectural decision, written down and enforced in review, rather than something each function decides locally. One global order, held everywhere, and the cycle you watched form in the wait-for graph becomes impossible, in Rust and in C++ alike.