Skip to content
Lesson 3.3Typed Memory, Ownership, and Object Layouts

Rust: Collections, Bounds, and Bytes

Read iterator chains, overlapping windows, complete records, explicit byte conversion, integer limits, and C layout.

Lesson 3.3
Read or listenPrint chapter or book
About 24 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 3.3Key ideas, reference and questions

A one-page summary of “Rust: Collections, Bounds, and Bytes” you can keep beside your notes. Plain Markdown.

Lesson 3.3 — Rust: Collections, Bounds, and Bytes: cheatsheet

  • Remember: A memory reader and a packet parser both receive sequences of bytes. Before using those bytes, they need to check the available length, decide how to group elements, and choose the numeric interpretation.
  • Also useful: Picture a vector as numbered lockers, a hash map as a coat check where a ticket finds your coat, and a queue as a line at a shop.

Quick reference

  • Keep selected items: .filter(predicate) — Predicate borrows its item.
  • Transform each item: .map(function) — Runs as the iterator is consumed.
  • Accumulate an answer: .fold(start, combine) — Arithmetic must fit or use an explicit policy.
  • Compare neighbors: .windows(n) — n must be positive; windows overlap.
  • Decode fixed records: .chunks_exact(n) — n must be positive; inspect the remainder.
  • Convert an exact field: from_le_bytes / to_le_bytes — Follow the format's byte order and width.
  • Reject overflow: checked_* — Caller handles None.
  • Cycle through bit patterns: wrapping_* — Wraparound must be intended.
  • Stop at the type limit: saturating_* — The game limit may be smaller.
  • Match C layout: #[repr(C)] — Target alignment and padding still apply.

Review

  • Q: An iterator chain filters and transforms items. Nothing consumes the chain yet. What is normally happening? A: Operations are described; item processing is deferred.
  • Q: A query keeps values matching a condition, transforms them, and combines them into one answer. Which sequence fits? A: Filter, then map, then fold over the kept values.
Download cheatsheet (.md)

A memory reader and a packet parser both receive sequences of bytes. Before using those bytes, they need to check the available length, decide how to group elements, and choose the numeric interpretation. Ordinary Rust collections let us learn those decisions in a small local example. The previous lesson explains the Option and Result values returned when a check fails.

The guarded purchase in Lesson 1.4 used two Player records, Ada and Bo. A fixed array was enough when we knew there were exactly two. What if players can join, we need one player’s score by name, or we must handle pending actions in arrival order? Those are different questions, and different collections make each one natural:

Collection Picture it as Good first use Important limit
Array, [T; N] numbered slots with a fixed count two known players, three prices its count does not grow
Vector, Vec<T> numbered slots that can grow players joining or a history of health samples removing from the middle shifts later items
Hash map, HashMap<K, V> a key pointing to its value find Ada’s gold by her name or ID a key may be missing; order is not the promise
Queue, VecDeque<T> a line of waiting items handle actions in arrival order it is for the next item, not fast arbitrary lookup
Grid rows and columns map tiles at (row, column) boundaries must be checked
Graph places and their connections waypoints and reachable paths you must store and inspect the connections

T stands for the kind of item stored. A vector of u32 values, for example, is written Vec<u32>. K and V stand for a map’s key and value types. A queue can be implemented with Rust’s VecDeque, which lets us add at the back and take from the front.

The array we used has a fixed number of slots. A vector keeps numbered slots but can grow, which fits a history of gold samples:

let mut recent_gold = vec![100, 75];
recent_gold.push(65);

A hash map answers “what value belongs to this key?” A queue answers “what item arrived next?” The following Rust fragments use all three, including a lookup or queue removal with no answer.

A grid can be a vector of rows, so tiles[row][column] reaches a tile. A graph stores which waypoints connect; Lesson 4.8 uses one to search for a route. These are arrangements of data, not magic algorithms. Choose by the operation you need: next by position, look up by key, take the oldest, or visit neighbors. Lesson 3.10 shows how their layouts can be recognized in a running game.

yes
no
yes
no
yes
no
What do I need to find?
a numbered position?
array or vector
a named key?
hash map
the next waiting item?
queue
a tile or connected neighbor? grid or graph

Different questions call for different collections. A vector keeps a growable sequence of samples. A hash map finds a value by a key. A queue keeps actions in arrival order. These fragments reuse the Primer’s Mira stamina values, separately from its complete two-runner program. The use line makes the two standard library collection types available by their short names:

use std::collections::{HashMap, VecDeque};
let mut recent_stamina = vec![12, 8];
recent_stamina.push(6);
let second_sample = recent_stamina[1];
println!("Second sample: {second_sample}");
let mut stamina_by_name = HashMap::new();
stamina_by_name.insert("Mira", 8);
if let Some(amount) = stamina_by_name.get("Mira") {
println!("Mira has {amount} stamina");
}
let mut pending = VecDeque::new();
pending.push_back("dash");
pending.push_back("rest");
if let Some(action) = pending.pop_front() {
println!("Next action: {action}");
}

vec! starts a growable vector with two samples, push adds a third, and [1] selects the second sample because positions start at zero. The hash map associates the key "Mira" with 8, so get("Mira") can find the amount without scanning a list of names. The queue adds actions at the back and takes the oldest from the front; after the first pop, "rest" is still waiting.

Both the map lookup and the queue removal may fail to produce an item: the name might be absent or the queue empty. Their results therefore use Some(value) or None. Lesson 3.2 develops that possible-value shape with checked subtraction and fallible operations. The grid/graph distinction above connects these choices to map tiles and paths.

An iterator yields a sequence of items on demand. filter keeps items that satisfy a predicate, map transforms each kept item, and fold combines items into one running answer. The closures describe those operations; a consuming operation such as fold drives the sequence.

fn doubled_total(budget: u32) -> u32 {
let prices = [3_u32, 8, 12];
prices.into_iter()
.filter(|cost| *cost <= budget)
.map(|cost| cost * 2)
.fold(0, |sum, cost| sum + cost)
}

For a budget of 8, filter keeps 3 and 8, map produces 6 and 16, and fold produces 22. This is a query, not a purchase: each price is checked against the same unchanged budget. It does not ask whether buying all the items together would be affordable.

into_iter consumes this array and yields its u32 values. filter passes a reference to its predicate so an item can still be yielded after the check; *cost reads the borrowed number. map then receives each kept number by value. If you need to borrow the collection instead, start with .iter(); for copyable numbers, .iter().copied() yields values while keeping the original collection.

Keep affordable costs, transform them, then accumulate. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let prices = [3_u32, 8, 12];
let total = prices.into_iter()
    .filter(|cost| *cost <= budget)
    .map(|cost| cost * 2)
    .fold(0, |sum, cost| sum + cost);

No items remain. The total is the fold accumulator, including zero when no price passed.

Final values: budget = 8; sum = 22; prices = [3, 8, 12]; cost = 12; kept = false; doubled = 16; total = 22.

Read every step of the lesson’s example
  1. Step 1, line 1. The owned array contains three prices. This query does not make purchases or reduce the budget.

    budget = 8; sum = 0; prices = [3, 8, 12].

  2. Step 2, line 2. into_iter yields each u32 by value. The adaptors are lazy until fold asks for values.

    budget = 8; sum = 0; prices = [3, 8, 12].

  3. Step 3, line 3. filter borrows 3 to test it: keep.

    budget = 8; sum = 0; prices = [3, 8, 12]; cost = 3; kept = true.

  4. Step 4, line 4. map receives the kept number by value and doubles it.

    budget = 8; sum = 0; prices = [3, 8, 12]; cost = 3; kept = true; doubled = 6.

  5. Step 5, line 5. fold adds it to the accumulator and requests the next item.

    budget = 8; sum = 6; prices = [3, 8, 12]; cost = 3; kept = true; doubled = 6.

  6. Step 6, line 3. filter borrows 8 to test it: keep.

    budget = 8; sum = 6; prices = [3, 8, 12]; cost = 8; kept = true; doubled = 6.

  7. Step 7, line 4. map receives the kept number by value and doubles it.

    budget = 8; sum = 6; prices = [3, 8, 12]; cost = 8; kept = true; doubled = 16.

  8. Step 8, line 5. fold adds it to the accumulator and requests the next item.

    budget = 8; sum = 22; prices = [3, 8, 12]; cost = 8; kept = true; doubled = 16.

  9. Step 9, line 3. filter borrows 12 to test it: skip.

    budget = 8; sum = 22; prices = [3, 8, 12]; cost = 12; kept = false; doubled = 16.

  10. Step 10, line 5. No items remain. The total is the fold accumulator, including zero when no price passed.

    budget = 8; sum = 22; prices = [3, 8, 12]; cost = 12; kept = false; doubled = 16; total = 22.

A second query counts runners with at least 4 stamina. Unlike the doubled total above, its result is how many items pass the predicate:

let stamina_samples = [8_u32, 3, 6];
let ready = stamina_samples
.iter()
.copied()
.filter(|stamina| *stamina >= 4)
.count();
println!("{ready} runners can dash"); // 2

.iter().copied() keeps the original array while yielding cheap u32 copies. The filter predicate still borrows each candidate, so *stamina reads its number. count consumes the kept values. The equivalent loop is:

let stamina_samples = [8_u32, 3, 6];
let mut ready = 0;
for stamina in stamina_samples {
if stamina >= 4 {
ready += 1;
}
}
// ready is 2

Both forms produce 2. Choose the form that makes the question and each value transformation clearest.

Suppose three position samples are 2, 6, and 10. We want each change between adjacent samples. windows(2) yields borrowed slices [2, 6] and [6, 10]. They overlap because the middle sample belongs to both neighbor pairs.

fn movement_deltas(positions: &[i32]) -> Vec<i32> {
positions.windows(2)
.map(|pair| pair[1] - pair[0])
.collect()
}

Every yielded window has exactly two elements, so its two indexes are valid. The example’s small coordinates also keep the subtraction in i32 range; arbitrary external coordinates need a checked subtraction or a wider type. collect consumes the iterator into a new Vec<i32>. A slice shorter than the window size yields no windows. A zero window size would panic, so validate a reader-supplied size before calling it.

Overlapping windows keep neighboring samples together. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let positions = [2_i32, middle, 10];
let deltas: Vec<i32> = positions.windows(2)
    .map(|pair| pair[1] - pair[0])
    .collect();

collect consumes the iterator into a new owned Vec. The original positions remain unchanged.

Final values: positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4; deltas = [4, 4].

Read every step of the lesson’s example
  1. Step 1, line 1. The array owns three samples. Each two-element window borrows consecutive elements.

    positions = [2, 6, 10]; middle = 6.

  2. Step 2, line 2. The next window overlaps the previous one by one sample.

    positions = [2, 6, 10]; middle = 6; pair = [2, 6].

  3. Step 3, line 3. A window has exactly two elements, so these two indexes are in bounds.

    positions = [2, 6, 10]; middle = 6; pair = [2, 6]; delta = 4.

  4. Step 4, line 2. The next window overlaps the previous one by one sample.

    positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4.

  5. Step 5, line 3. A window has exactly two elements, so these two indexes are in bounds.

    positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4.

  6. Step 6, line 4. collect consumes the iterator into a new owned Vec. The original positions remain unchanged.

    positions = [2, 6, 10]; middle = 6; pair = [6, 10]; delta = 4; deltas = [4, 4].

chunks_exact(2) divides a buffer into separate two-byte groups. Unlike windows, the groups do not overlap. A final single byte does not form a complete record. Rust exposes it through remainder() so the parser can decide whether to reject it or buffer it for later.

fn decode_records(bytes: &[u8]) -> Result<Vec<u16>, &'static str> {
let records = bytes.chunks_exact(2);
if !records.remainder().is_empty() {
return Err("partial record");
}
Ok(records
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect())
}

The buffer [4, 0, 9, 0] decodes as two little-endian numbers, 4 and 9. Appending 255 makes this complete-packet parser return an error. For a stream being received in pieces, keeping that byte for the next read may be appropriate; Lesson 9.1 explains that distinction. This function fixes the group size at two. A zero size would panic.

Fixed records leave a remainder that must be handled. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let records = bytes.chunks_exact(2);
let tail = records.remainder();
if !tail.is_empty() { return Err("partial record"); }
let totals: Vec<u16> = records
    .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
    .collect();

The complete packet yields two values.

Final values: length = 4; record_size = 2; tail = []; record = 9; totals = [4, 9].

Read every step of the lesson’s example
  1. Step 1, line 1. Non-overlapping chunks contain exactly two bytes each. A zero chunk size would panic; this example fixes it at two.

    length = 4; record_size = 2.

    Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.

  2. Step 2, line 2. remainder borrows the short tail; the record iterator has not consumed anything yet.

    length = 4; record_size = 2; tail = [].

    Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.

  3. Step 3, line 4. The tail is empty, so the complete record iterator can be consumed.

    length = 4; record_size = 2; tail = [].

    Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.

  4. Step 4, line 5. Each two-byte pair decodes using an explicit little-endian order.

    length = 4; record_size = 2; tail = []; record = 4.

    Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.

  5. Step 5, line 5. The next pair becomes 9. Its bytes did not overlap the first pair.

    length = 4; record_size = 2; tail = []; record = 9.

    Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.

  6. Step 6, line 6. The complete packet yields two values.

    length = 4; record_size = 2; tail = []; record = 9; totals = [4, 9].

    Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.

Check slice bounds before decoding a number

Section titled “Check slice bounds before decoding a number”

A slice borrows a contiguous sequence and includes its length. bytes[3] assumes index 3 exists; it panics if that assumption is false. bytes.get(3) instead returns Some(&byte) or None. The same checked operation accepts a range: get(..4) asks for the first four bytes.

fn read_gold(bytes: &[u8]) -> Option<u32> {
let raw: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
Some(u32::from_le_bytes(raw))
}

The range check establishes the length. try_into produces an exact [u8; 4] array from that slice; .ok() turns its Result into an Option, and ? exits if it is missing. from_le_bytes interprets [0x2C, 0x01, 0, 0] as 300. 300_u32.to_le_bytes() reverses that conversion and produces the same four bytes. Use from_be_bytes and to_be_bytes when the format says the high byte comes first.

Predict first

Decimal 100 is 0x64, so 100_u32.to_le_bytes() gives the four bytes 64 00 00 00, lowest byte first.

A reader passes those same four bytes to u32::from_be_bytes. What number does it get?

Answer: 0x64000000, which is 1,677,721,600. from_be_bytes treats the byte at +0 as the highest byte, so 0x64 lands in the top byte, which is worth 256 × 256 × 256 = 16,777,216: 100 × 16,777,216 = 1,677,721,600. The bytes did not change; only the reading order did.

Write a u32 low byte first, then read it backto_le_bytes puts the lowest byte at offset +0. Read the same four bytes with from_be_bytes and the number changes.

Number, bytes in memory, number again

100.to_le_bytes()

100 = 0x00000064, high byte to low byte
00
00
00
64
In memory, offset +0 first
64+0
00+1
00+2
00+3

u32::from_le_bytes

+0 is the lowest byte
0x00000064 = 100the same number

100 is 0x00000064. to_le_bytes stores 64 00 00 00, and from_le_bytes reads them back as 100.

Reading the first four bytes leaves any later bytes unused. A format that requires exactly four bytes must also check the total length. Byte conversion interprets a number; it does not decide whether that number is a valid health amount, length, or address.

A bounded slice becomes a number, then bytes again. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

fn read_gold(bytes: &[u8]) -> Option<u32> {
    let raw: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
    Some(u32::from_le_bytes(raw))
}
let encoded = gold.to_le_bytes();

to_le_bytes chooses the same explicit order for writing. It does not depend on the host CPU.

Final values: length = 4; gold = 300; raw = [2C, 01, 00, 00]; decoded = Some(300); encoded = 2C 01 00 00.

Read every step of the lesson’s example
  1. Step 1, line 1. A shared slice lends bytes and their length; it does not transfer their ownership.

    length = 4; gold = 300.

    Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.

  2. Step 2, line 2. The checked slice has exactly four bytes; try_into copies them into a four-byte array.

    length = 4; gold = 300; raw = [2C, 01, 00, 00].

    Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.

  3. Step 3, line 3. Little-endian conversion makes the low byte first. The number is 300.

    length = 4; gold = 300; raw = [2C, 01, 00, 00]; decoded = Some(300).

    Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.

  4. Step 4, line 5. to_le_bytes chooses the same explicit order for writing. It does not depend on the host CPU.

    length = 4; gold = 300; raw = [2C, 01, 00, 00]; decoded = Some(300); encoded = 2C 01 00 00.

    Memory: +0 = 2C; +1 = 01; +2 = 00; +3 = 00.

An unsigned eight-bit value, u8, holds 0 through 255. The mathematical sum 250 + 10 is 260 and does not fit. Rust supplies explicit arithmetic policies so the programmer chooses the intended behavior:

let count = 250_u8;
let checked = count.checked_add(10); // None
let wrapping = count.wrapping_add(10); // 4
let saturating = count.saturating_add(10); // 255

checked_* reports that an answer cannot be represented. wrapping_* keeps the low bits, making this sum 4 after passing 255. saturating_* stops at the type’s limit. Use checked arithmetic for a size or balance that must be rejected if it overflows; wrapping for an intentionally cyclic counter; saturating for a bounded meter.

Plain integer overflow may panic when overflow checks are enabled or wrap when disabled. Explicit methods make the behavior independent of that build setting. Saturation is to the type limit: a game with maximum health 100 still needs a separate clamp to 100, even if health is a u8.

Choose what an overflowing counter means. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let count: u8 = start;
let checked = count.checked_add(gain);
let wrapping = count.wrapping_add(gain);
let saturating = count.saturating_add(gain);

saturating_add clamps at the largest value. Use this for a meter that must stop at its limit.

Final values: count = 250; gain = 10; mathematical = 260; checked = None; wrapping = 4; saturating = 255.

Read every step of the lesson’s example
  1. Step 1, line 1. The mathematical sum may be wider than the destination type.

    count = 250; gain = 10; mathematical = 260.

  2. Step 2, line 2. The sum does not fit; checked_add gives None and lets the caller reject it.

    count = 250; gain = 10; mathematical = 260; checked = None.

  3. Step 3, line 3. wrapping_add keeps the low eight bits. Use this when the counter is intentionally cyclic.

    count = 250; gain = 10; mathematical = 260; checked = None; wrapping = 4.

  4. Step 4, line 4. saturating_add clamps at the largest value. Use this for a meter that must stop at its limit.

    count = 250; gain = 10; mathematical = 260; checked = None; wrapping = 4; saturating = 255.

Separate in-memory layout from a file format

Section titled “Separate in-memory layout from a file format”

Rust’s ordinary struct representation does not promise a C field layout. #[repr(C)] requests the target platform’s C ordering and alignment:

#[repr(C)]
struct Record {
tag: u8,
gold: u32,
zone: u16,
}

On the Windows x64 target used in this book, tag is at offset 0, gold at offset 4, and zone at offset 8. Three padding bytes align gold; two tail padding bytes make the full size 12. The same fields written consecutively into a file would occupy only 7 bytes. Padding is part of the type’s memory layout, not an implied serialized field.

let gold_offset = std::mem::offset_of!(Record, gold);
let record_size = std::mem::size_of::<Record>();
let record_alignment = std::mem::align_of::<Record>();

These operations measure the compiled type on the current target. repr(C) does not select byte order, prove that remote bytes contain valid field values, or make every C++ class compatible with this struct. Decode external file or packet fields explicitly rather than copying the raw bytes of a Rust struct, including its padding.

C layout includes alignment padding. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

#[repr(C)]
struct Record { tag: u8, gold: u32, zone: u16 }
let record = Record { tag: 1, gold, zone: 2 };
let offset = std::mem::offset_of!(Record, gold);
let size = std::mem::size_of::<Record>();

Two tail padding bytes round the total up to a multiple of the struct alignment.

Final values: target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2; gold_offset = 4; size = 12.

Read every step of the lesson’s example
  1. Step 1, line 1. repr(C) chooses C field ordering and alignment for the target platform. It is not a disk-file format.

    target = Windows x64; gold = 300.

  2. Step 2, line 2. tag begins at zero; gold needs four-byte alignment, so three bytes of padding precede it.

    target = Windows x64; gold = 300; alignment = 4.

    Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.

  3. Step 3, line 3. The value changes; the type layout does not.

    target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2.

    Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.

  4. Step 4, line 4. offset_of! reports gold at offset four for this target layout.

    target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2; gold_offset = 4.

    Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.

  5. Step 5, line 5. Two tail padding bytes round the total up to a multiple of the struct alignment.

    target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2; gold_offset = 4; size = 12.

    Memory: +0 = tag; +1..3 = padding; +4..7 = gold 300; +8..9 = zone; +10..11 = padding.

Need Expression Boundary to check
Keep selected items .filter(predicate) Predicate borrows its item.
Transform each item .map(function) Runs as the iterator is consumed.
Accumulate an answer .fold(start, combine) Arithmetic must fit or use an explicit policy.
Compare neighbors .windows(n) n must be positive; windows overlap.
Decode fixed records .chunks_exact(n) n must be positive; inspect the remainder.
Borrow checked elements .get(index_or_range) Returns None outside the bounds.
Convert an exact field from_le_bytes / to_le_bytes Follow the format’s byte order and width.
Reject overflow checked_* Caller handles None.
Cycle through bit patterns wrapping_* Wraparound must be intended.
Stop at the type limit saturating_* The game limit may be smaller.
Match C layout #[repr(C)] Target alignment and padding still apply.

The Iterator reference, slice reference, and type-layout reference give the complete definitions. Next, borrows and interfaces explain how functions share this data without losing its owner.

Knowledge check

Reason about the concept

An iterator chain filters and transforms items. Nothing consumes the chain yet. What is normally happening?

Finished Lesson 3.3?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 3: 11 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.