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

Rust: Borrowed Views and Useful Interfaces

Connect lifetimes, typed offsets, From and Into, Display, derived traits, generic capabilities, and validated builders.

Lesson 3.4
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.4Key ideas, reference and questions

A one-page summary of “Rust: Borrowed Views and Useful Interfaces” you can keep beside your notes. Plain Markdown.

Lesson 3.4 — Rust: Borrowed Views and Useful Interfaces: cheatsheet

  • Remember: Use types to distinguish roles, traits to name capabilities, and builders to gather choices. Each serves a different purpose.

Quick reference

  • Return a borrowed view: &'a [u8] — View cannot outlive its input owner.
  • Distinguish integer roles: struct Rva(u32); — A new type, not a renamed integer.
  • Generate diagnostics/equality: #[derive(Debug, PartialEq, Eq)] — Implements supported capabilities from fields.
  • Permit cheap implicit copies: Copy plus Clone — Appropriate only when copying does not duplicate ownership duties.
  • Convert infallibly: From<T> / .into() — Destination type defines the conversion.
  • Convert with validation: TryFrom<T> / .try_into() — Caller handles a possible failure.
  • Write a user-facing label: impl Display — Used by {} and .to_string().
  • Ask for a capability: &impl GoldReader — Borrows a concrete trait implementation.
  • Gather optional settings: Builder::default().choice(...).build() — Validate before producing the ready object.

Review

  • Q: A returned slice points into the caller’s buffer. What does a matching lifetime annotation express? A: The returned borrow is tied to the input borrow.
  • Q: A function creates a temporary local array and returns a reference to it. Can naming a longer lifetime fix the problem? A: No; the local storage still ends when the call returns.
Download cheatsheet (.md)

The previous lesson used checked slices and iterator queries. Those functions also made promises about access: some borrowed an existing collection, while others returned a newly owned result. Here we name those relationships and give functions interfaces that say which kinds of values they can accept.

Section titled “A lifetime describes how borrows are related”

Suppose a function returns the first count bytes of its input. The returned slice points into the caller’s buffer; it does not own a copy.

fn prefix<'a>(bytes: &'a [u8], count: usize) -> Option<&'a [u8]> {
bytes.get(..count)
}
fn main() {
let buffer = [10_u8, 20, 30, 40];
if let Some(view) = prefix(&buffer, 2) {
println!("{view:?}");
}
}

'a is a lifetime parameter. The repeated name says the returned borrow is tied to the input borrow. It does not keep the buffer alive longer. The compiler rejects using the view after the buffer is gone or changing the borrowed elements while that shared view is still in use. Here the view ends after its print, and the caller retains the buffer.

prefix(&buffer, count) lends a viewThe caller owns four bytes. A prefix that fits comes back as the address of buffer[0] plus a length; a count past the end gives None.

The caller’s buffer and what prefix returns

buffer: [u8; 4], owned by the caller

bytes 0 to 1 are in the view
10index 0
20index 1
30index 2
40index 3

prefix(&buffer, 2) returns

Result
Some(&[10, 20])
What the view holds
address of buffer[0]
length 2

get(..2) fits inside the 4 bytes, so prefix returns a view of 2 bytes. The view stores where they start and how many there are; no byte is copied.

With one borrowed input, Rust can infer this relationship. The shorter signature fn prefix(bytes: &[u8], count: usize) -> Option<&[u8]> expresses the same borrowing relationship. Explicit names become useful when several borrowed inputs make the relationship ambiguous, or when a struct keeps a borrow. Lifetimes describe validity, not a number of seconds.

A returned view stays tied to its owner. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

fn prefix<'a>(bytes: &'a [u8], count: usize) -> Option<&'a [u8]> {
    bytes.get(..count)
}
let buffer = [10_u8, 20, 30, 40];
let view = prefix(&buffer, count);

A valid view can be used only while its owner is alive and the borrow permits it. The compiler checks this relationship.

Final values: owner = buffer in caller; count = 2; view = Some(&[10, 20]); copied_bytes = 0; lifetime = view cannot outlive buffer.

Read every step of the lesson’s example
  1. Step 1, line 4. The caller owns buffer. No lifetime annotation extends how long it lives.

    owner = buffer in caller; count = 2.

    Memory: +0 = 0A; +1 = 14; +2 = 1E; +3 = 28.

  2. Step 2, line 5. The function receives a temporary shared borrow, plus the requested count.

    owner = buffer in caller; count = 2.

    Memory: +0 = 0A; +1 = 14; +2 = 1E; +3 = 28.

  3. Step 3, line 2. get lends a view into the same allocation. It does not copy the elements.

    owner = buffer in caller; count = 2; view = Some(&[10, 20]); copied_bytes = 0.

    Memory: +0 = 0A; +1 = 14; +2 = 1E; +3 = 28.

  4. Step 4, line 5. A valid view can be used only while its owner is alive and the borrow permits it. The compiler checks this relationship.

    owner = buffer in caller; count = 2; view = Some(&[10, 20]); copied_bytes = 0; lifetime = view cannot outlive buffer.

    Memory: +0 = 0A; +1 = 14; +2 = 1E; +3 = 28.

The Primer’s Runner record supplies the receiver’s fields:

struct Runner {
name: String,
stamina: u32,
health: u32,
max_health: u32,
}

This is record context for the method below, not a second record definition to paste into the complete Mira/Sol program. Replace the Primer’s brief forwarding method with this direct-check alternative; keep one try_dash definition.

An impl block attaches behavior to Runner. This alternative method performs the same guarded subtraction as the Primer’s dash function:

impl Runner {
fn try_dash(&mut self, cost: u32) -> bool {
if self.stamina < cost {
return false;
}
self.stamina = self.stamina - cost;
return true;
}
}

self means the runner receiving the method call. &mut self gives the method temporary editable access to that runner, like the earlier &mut Runner parameter. Place this alternative impl block below the two-runner program, replacing the forwarding method, and use runner.try_dash(4). The result stays Mira 8 and Sol 3. The method organizes the code differently; it keeps the same stamina check and subtraction. In Rust, a struct holds fields and an impl gives it methods; class inheritance is not required.

Give numbers different types when they mean different things

Section titled “Give numbers different types when they mean different things”

An RVA is an offset within a loaded module. A virtual address identifies a location in a process. Both can be represented with integers, but interchanging them produces a different question. A newtype wraps an existing value in a distinct type:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Rva(u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct VirtualAddress(usize);
fn resolve(base: VirtualAddress, offset: Rva) -> Option<VirtualAddress> {
base.0.checked_add(offset.0 as usize).map(VirtualAddress)
}

Rva(512) and VirtualAddress(512) have different types even though their numbers agree. resolve cannot receive them in the opposite order. .0 reads a tuple struct’s first field. The check rejects overflow; a real reader must still validate the address against the intended process and memory region.

Handles benefit from the same distinction. A ProcessHandle and a ModuleHandle should not become interchangeable just because both are machine-sized values. An owned process handle must also close exactly once, so it must not derive Copy: a numeric copy would duplicate the cleanup duty. Handle ownership develops that wrapper and its Drop implementation. A borrowed handle view has a different duty from an owned handle.

Let derive generate ordinary trait implementations

Section titled “Let derive generate ordinary trait implementations”

#[derive(...)] asks the compiler to generate the listed capabilities from the fields. Debug supports diagnostic {:?} output; PartialEq supports equality comparisons; Eq marks equality without exceptional cases such as floating-point NaN. Clone provides explicit .clone(); Copy permits an implicit value copy and requires Clone.

For Rva, copying its plain integer is harmless. For String, an explicit clone allocates a separate owned string, so String does not implement Copy. Derived capabilities are not permission checks or address validation. If a field cannot implement a requested trait, the derive fails to compile.

Convert with From and Into; format with Display

Section titled “Convert with From and Into; format with Display”

From describes an infallible conversion into your type. Implementing From<u32> for Rva also makes u32 implement Into<Rva>.

impl From<u32> for Rva {
fn from(value: u32) -> Self { Self(value) }
}
impl std::fmt::Display for Rva {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "RVA 0x{:X}", self.0)
}
}
fn main() {
let offset: Rva = 512_u32.into();
println!("{offset}"); // RVA 0x200
}

The destination annotation chooses what .into() constructs. This conversion simply names a number’s role; it does not check a module’s size. Use TryFrom and a Result when a conversion may fail.

Display controls user-facing {} formatting and .to_string(). Debug answers a different question: what should a diagnostic view of this value look like? You implement Display yourself because the best label cannot be inferred from a struct’s fields. write! writes into the supplied formatter and reports a formatting error if its destination fails; it does not print directly to the terminal.

Give one number a type and a readable spelling. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Rva(u32);
impl From<u32> for Rva { fn from(n: u32) -> Self { Self(n) } }
impl std::fmt::Display for Rva {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "RVA 0x{:X}", self.0)
    }
}
let offset: Rva = raw.into();
let label = offset.to_string();

to_string uses Display. The number has a clearer role, but its validity still depends on the target module.

Final values: raw = 512; offset = Rva(512); traits = Debug, Clone, Copy, PartialEq, Eq; label = RVA 0x200; validated = false.

Read every step of the lesson’s example
  1. Step 1, line 2. Rva is a distinct type, so a function taking Rva cannot accidentally receive a different newtype.

    raw = 512; offset = not constructed.

  2. Step 2, line 1. derive generates the named trait implementations from the field. Copy fits this plain numeric wrapper; it would be wrong for an owned handle.

    raw = 512; offset = not constructed; traits = Debug, Clone, Copy, PartialEq, Eq.

  3. Step 3, line 9. Implementing From<u32> also supplies Into<Rva>. The destination annotation chooses the conversion.

    raw = 512; offset = Rva(512); traits = Debug, Clone, Copy, PartialEq, Eq.

  4. Step 4, line 6. Display writes an intended user-facing label into the formatter.

    raw = 512; offset = Rva(512); traits = Debug, Clone, Copy, PartialEq, Eq; label = RVA 0x200.

  5. Step 5, line 10. to_string uses Display. The number has a clearer role, but its validity still depends on the target module.

    raw = 512; offset = Rva(512); traits = Debug, Clone, Copy, PartialEq, Eq; label = RVA 0x200; validated = false.

A trait names behavior that several types can provide. A fixture can supply invented gold values; another implementation can obtain a checked snapshot. A caller that only needs the gold does not need to know which storage system provided it.

trait GoldReader {
fn gold(&self) -> Option<u32>;
}
struct Fixture(u32);
impl GoldReader for Fixture {
fn gold(&self) -> Option<u32> { Some(self.0) }
}
struct Missing;
impl GoldReader for Missing {
fn gold(&self) -> Option<u32> { None }
}
fn affordable(reader: &impl GoldReader, price: u32) -> bool {
reader.gold().is_some_and(|gold| gold >= price)
}

The &self method borrows its implementation for reading. In this argument position, impl GoldReader means “one concrete type that implements this trait.” Each call can use a different concrete type; the function’s body uses the promised method. A longer spelling uses a generic parameter: fn affordable<R: GoldReader>(reader: &R, price: u32). Both express the same capability requirement here.

For Fixture(20) and price 12, the answer is true. For Missing, is_some_and returns false without calling its closure. A trait makes the interface reusable; it does not ensure that every implementation returns accurate data or has permission to read a process.

One capability lets a function use different readers. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

trait GoldReader { fn gold(&self) -> Option<u32>; }
struct Fixture(u32);
impl GoldReader for Fixture {
    fn gold(&self) -> Option<u32> { Some(self.0) }
}
fn affordable(reader: &impl GoldReader, price: u32) -> bool {
    reader.gold().is_some_and(|gold| gold >= price)
}

Both readers use the same function. The trait does not grant operating-system access or turn missing data into a valid value.

Final values: reader = fixture; price = 12; access = shared borrow; gold = Some(20); affordable = true.

Read every step of the lesson’s example
  1. Step 1, line 1. The trait defines the capability, not where the data comes from.

    reader = fixture; price = 12.

  2. Step 2, line 6. The argument borrows one concrete implementation. impl Trait here asks for any type with this capability.

    reader = fixture; price = 12; access = shared borrow.

  3. Step 3, line 7. This implementation returns Some(20). The closure compares its number with the price.

    reader = fixture; price = 12; access = shared borrow; gold = Some(20); affordable = true.

  4. Step 4, line 8. Both readers use the same function. The trait does not grant operating-system access or turn missing data into a valid value.

    reader = fixture; price = 12; access = shared borrow; gold = Some(20); affordable = true.

A builder stores construction choices, then checks them before producing the finished object. It is useful when a tool has several optional settings. A simple object with two obvious required fields usually needs only a constructor.

#[derive(Debug)]
struct Sampler { limit: usize, label: String }
struct SamplerBuilder { limit: usize, label: String }
impl Default for SamplerBuilder {
fn default() -> Self { Self { limit: 1, label: "sample".into() } }
}
impl SamplerBuilder {
fn limit(mut self, value: usize) -> Self { self.limit = value; self }
fn label(mut self, value: impl Into<String>) -> Self {
self.label = value.into();
self
}
fn build(self) -> Result<Sampler, &'static str> {
if self.limit == 0 { return Err("limit must be positive"); }
Ok(Sampler { limit: self.limit, label: self.label })
}
}

Each setting method takes ownership of the draft and returns the updated draft. That enables the chain below. impl Into<String> accepts a string literal or an owned String and stores an owned label. build validates the limit once; the object is not ready before it succeeds.

let sampler = SamplerBuilder::default().limit(3).label("gold").build();

The builder itself does not start a worker or read memory. It only constructs a validated configuration. A zero limit returns an error that the caller can handle.

A builder accumulates choices before validating. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let draft = SamplerBuilder::default();
let draft = draft.limit(limit).label("gold");
let sampler = draft.build();

build checks the limit once and returns a ready sampler.

Final values: limit = 3; label = gold; validated = true; result = Ok(Sampler { limit: 3, label: gold }).

Read every step of the lesson’s example
  1. Step 1, line 1. Default creates an ordinary draft with a limit of one and a label of sample.

    limit = 1; label = sample; validated = false.

  2. Step 2, line 2. Each consuming method returns the updated draft so the next method can use it.

    limit = 3; label = gold; validated = false.

  3. Step 3, line 3. build checks the limit once and returns a ready sampler.

    limit = 3; label = gold; validated = true; result = Ok(Sampler { limit: 3, label: gold }).

Need Rust expression Meaning
Return a borrowed view &'a [u8] View cannot outlive its input owner.
Distinguish integer roles struct Rva(u32); A new type, not a renamed integer.
Generate diagnostics/equality #[derive(Debug, PartialEq, Eq)] Implements supported capabilities from fields.
Permit cheap implicit copies Copy plus Clone Appropriate only when copying does not duplicate ownership duties.
Convert infallibly From<T> / .into() Destination type defines the conversion.
Convert with validation TryFrom<T> / .try_into() Caller handles a possible failure.
Write a user-facing label impl Display Used by {} and .to_string().
Ask for a capability &impl GoldReader Borrows a concrete trait implementation.
Gather optional settings Builder::default().choice(...).build() Validate before producing the ready object.

See the official From, Display, and lifetime references for details. Next, cleanup and error layers connect an owned resource to the scope responsible for releasing it.

Knowledge check

Reason about the concept

A returned slice points into the caller’s buffer. What does a matching lifetime annotation express?

Finished Lesson 3.4?

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.