Skip to content
academia.sh

Lesson 04 / 12

`unsafe` Rust

That an unsafe block does not close off the compiler's entire checking, only the checking that covers a few specific operations, is shown by running code: within the same block, a raw pointer read stays unchecked while a slice query is still checked.

Published Updated

In this lesson

  • Shows by running code within the same block that an unsafe block does not close off the compiler's checking wholesale, only for a few specific operations
  • Proves by comparison that a raw pointer read, an unchecked access, and a contracted function call all produce the same result as their safe counterpart
  • Explains, by distinguishing the two, that a delegated guarantee stays invisible in the result when used correctly, and whose responsibility a misuse falls to
Contents

The previous topic showed that no matter how different the body a macro produces looks, it still passes through type checking like ordinary source: the compiler checked a generated function exactly as strictly as a hand-written one. This topic asks the same question from the opposite direction: is there a form where the compiler does not perform this checking at all, where it stops checking and leaves the matter to the writer? The answer is the unsafe keyword — but this lesson’s finding is that the keyword does not open as wide a door as it appears to.

The Borrow Checker lesson, from the Ownership and the Memory Model course, established how the compiler tests ownership and borrowing rules, which forms it rejects. This lesson does not show that checker closing — it shows the opposite: that it does not close, only that the obstacle in front of a few operations is lifted. The Standard Library and Streams course asked who gives the guarantee for a behavior: the interface, the implementation, or the caller — the parties to a contract. What this lesson asks is different: where the compiler’s own checking stops, without looking at either party to that contract.

This gap exists for a reason: the rules the borrow checker tests do not cover every correct program that could be built. A structure such as a circular buffer, a node pool, or a driver that accesses a hardware register directly can carry an ownership pattern more complex than the borrow checker can follow — the checker may reject that pattern even when it is correct, because its testing rule comes not from the pattern itself but from the class of patterns the checker can follow. unsafe is the escape valve that closes this gap: the writer can say “this is correct” for a reason the checker cannot test. The standard library’s own growable array and slice types are built the same way — while they present a safe interface to code that looks from the outside, their own bodies use the same raw pointer operations. This lesson does not look inside those bodies; it only measures how the same permission works in an ordinary program.

The unsafe Block’s Limited Permission

Constructing a raw pointer does not require unsafe. The form &value as *const i32 is an ordinary expression, it compiles, and it skips no checking — what is constructed is only an address, not yet read. Borrow rules are not in play on this line either, because raw pointers are not among the types the borrow checker tracks; no bookkeeping such as ownership, lifetime, or uniqueness is kept for them.

The restriction begins when there is an attempt to read the pointer. The compiler cannot know that a genuinely valid value sits where a raw pointer points — the pointer might be null, the memory it belongs to might have been returned, or it might just carry a made-up address. Because the compiler cannot distinguish among these three possibilities, it requires the read to sit inside an unsafe block: the code compiles, but the declaration “there is a contract here and I am meeting it” belongs to the writer. The same restriction applies to calling a function whose body is marked unsafe — the function’s name says the caller must satisfy a precondition, but the compiler does not test that condition.

This permission list is short and fixed by the language itself: the compiler reads which operations require unsafe from the source, not from the caller’s intent. The run below places three operations that do not require unsafe side by side: constructing a raw pointer, shifting it in a way defined against overflow, and comparing two pointers with each other. Because none of the three reads the value, they break nothing even if they operate on an invalid address — what would break something is attempting to access the data at that address.

// allowed.rs — which pointer operations do not require unsafe
fn main() {
    let v = vec![10, 20, 30, 40];
    let pointer = v.as_ptr();                       // construct
    let shifted = pointer.wrapping_add(1);           // shift, defined against overflow
    let equals_self = pointer == pointer;
    let equals_shifted = pointer == shifted;

    println!("equals self: {}", equals_self);
    println!("equals shifted: {}", equals_shifted);
}
equals self: true
equals shifted: false

What these three operations share is that their results, on their own, expose no memory: knowing whether an address equals itself differs from knowing the value at that address. LL1. The wrapping_add call here was chosen deliberately — the language’s pointer method carrying this name returns a defined value even when the shift overflows, and for that reason does not demand unsafe; by contrast, the add method that performs the same shift asks the caller, as a precondition, that the result stay inside the same allocated memory block or one byte past it, and because the compiler does not test this precondition, calling it requires unsafe. The two methods do the same arithmetic; what differs between them is what is guaranteed in the case of overflow.

By contrast, reading or writing a raw pointer and calling a function whose body is marked unsafe both require an unsafe block — because both operations rest on an assumption the compiler cannot test (that the address is valid, that the function’s precondition is met). The next two sections show this distinction by running code within the same block.

Checking That Continues and Checking That Stops, in the Same Block

An unsafe block does not affect every line written inside it to the same degree. The run below places two different operations inside the same block: one a direct read from a raw pointer, the other a bounds-checked query on a slice. Both stand under the same unsafe keyword, but only one is exempt from checking.

LL2. A four-element integer slice is used, and the queried index (the tenth item) lies outside this slice’s bounds.

// block_a.rs — checking that continues and checking that stops, in the same unsafe block
fn main() {
    let v = vec![10, 20, 30, 40];
    let pointer = v.as_ptr();

    // Two operations in the same block: one reads through a raw pointer (unchecked),
    // the other makes a safe slice query (v.get) (still checked).
    let (first_raw, tenth) = unsafe { (*pointer, v.get(10)) };

    println!("raw read: {}", first_raw);
    println!("tenth item: {:?}", tenth);
}
raw read: 10
tenth item: None

Both lines ran inside the same unsafe block, but they took different paths. *pointer read the raw pointer directly — there is no bounds check here at all, whatever the address points to is what gets read. v.get(10), by contrast, passed through the slice’s own method; this method’s body is not marked unsafe, and the bounds check inside it kept running, which is why, asked for something past the fourth element, it returned None instead of crashing. LL3. Here the unsafe block only lifted the obstacle in front of the *pointer line; the v.get call was already a safe method, and its entering an unsafe block did not change the checking inside its own body.

This observation is the lesson’s central finding: unsafe is not a key, it does not unlock all of the compiler’s locks at once. The block grants permission only for the specific operations written inside it — everything outside those operations, even inside the block, stays subject to its own rules. The borrow checker is still in play here too: v’s ownership, pointer’s having been derived from it, and both their scope rules — none of this was suspended because of the unsafe block. The only thing suspended was the bounds checking on the raw pointer read itself.

The run above deliberately showed this by placing the safe call inside the unsafe block; in ordinary code there is no such requirement, v.get(10) does not require an unsafe block on its own. Common practice is to keep an unsafe block limited to only the lines that genuinely need it — the wider a block grows, the harder it becomes to tell afterward which line rests on which contract. Writing two operations in the same block in this lesson is meant to make concrete why that practice exists: even when a block is kept wide, where checking continues and where it stops can be read from the source, but that readability is not automatic — it requires tracing line by line.

Three Delegated Operations

Like the *pointer read in the previous section, the lines where checking is delegated do not stand alone — each of them can be placed side by side with a safe counterpart that does the same job. The run below compares three separate delegated operations with their safe counterparts: a read through raw pointer arithmetic, the slice’s unchecked access method, and a call to an unsafe function that carries a contract.

Though these three forms do the same job, where each is used differs. Raw pointer arithmetic is used when a memory block needs to be walked independently of Rust’s own slice type — for instance, when reading a memory region returned from a foreign function, a topic taken up in the next lesson. get_unchecked is used on a slice when the validity of the index has already been proven somewhere else (by a loop bound, by a search result). A contracted unsafe function is used to package these two forms together and present an interface where it is enough for the caller to read only the signature — the contract is still there, but its body can move to a different file.

LL4. The slice being compared is again [10, 20, 30, 40]; the raw pointer starts at the second index and is shifted two positions further, the unchecked access goes straight to the second index.

// block_b.rs — three delegated operations and their safe counterparts
unsafe fn get_second_item(v: &[i32]) -> i32 {
    // Contract: the caller guarantees that v carries at least two items.
    *v.get_unchecked(1)
}

fn main() {
    let v = vec![10, 20, 30, 40];
    let pointer = v.as_ptr();

    let raw = unsafe { *pointer.add(2) };
    let unchecked = unsafe { *v.get_unchecked(1) };
    let contracted = unsafe { get_second_item(&v) };

    println!("raw pointer read: {} (safe counterpart {})", raw, v[2]);
    println!("unchecked access: {} (safe counterpart {})", unchecked, v[1]);
    println!("contract-delegated function: {} (safe counterpart {})", contracted, v[1]);
}
raw pointer read: 30 (safe counterpart 30)
unchecked access: 20 (safe counterpart 20)
contract-delegated function: 20 (safe counterpart 20)

All three lines’ results are identical to the bounds-checked access done with []. This is not a coincidence: the pointer advanced with add(2), the get_unchecked(1) call, and the call inside get_second_item all read the slice’s memory layout correctly — only who guarantees that correctness changes. In safe [] access, the guarantee is given by the slice’s own method, and it stops with a panic at run time if the index is out of bounds. In get_unchecked and raw pointer arithmetic, nobody gives that guarantee; choosing the correct index is the caller’s obligation.

The get_second_item function exists to show a form that makes this obligation visible in the source: the unsafe word in front of its signature says the body expects a precondition to be met before it is called. This is a form the language itself recognizes — the function’s name and signature do not hide the unchecked access inside its body from the caller’s eye, they mark it instead. Marking a function unsafe also does not automatically make every line inside its body unchecked: within the body too, only the lines that genuinely require unsafe (here, the get_unchecked call) carry that status, the rest is ordinary checked code. LL5. The party that calls the function as unsafe has to verify for itself that v carries at least two elements; in this run, because the slice carried four elements, the contract was met and the result matched its safe counterpart.

There is a reason these three methods exist in the language: safe [] access performs one bounds-checking step on every call — it tests whether the index is smaller than the slice’s length, then reads. get_unchecked skips that step; the only thing it reads is an index the caller has already verified somewhere else. This lesson does not count that step’s cost, because the run is limited to a single access and duration is not measured anywhere in this curriculum; what matters is that which check was skipped can be read from the source — the name get_unchecked says directly, without hiding it, that the bounds check was skipped.

The Limiting Measurement

What these three operations share is that, used correctly, they leave no trace in the result. In the run above, the raw read, the unchecked access, and the contracted call all gave the same number as their safe counterparts — an observer looking only at the output cannot tell which line was unsafe. The difference is not in the result, but in whose responsibility it is what happens when they are used incorrectly.

Misuse is not run in this lesson, because a read that spills past the bounds is undefined behaviour, and its result is specific to this machine, to this run — the next run might not reproduce the same error, it might silently read a wrong value, or it might lead to a crash. Describing it in prose is enough: a call to pointer.add(100) would try to read an address that goes far past the four-element slice. The compiler does not reject this call — because rejecting it would require knowing what the address points to, and it does not know. In the same way, if get_second_item were called with a slice of fewer than two elements, the contract would be violated, and the compiler would not notice this either; the signature only assumes the caller has kept a promise, it does not test the promise.

This clarifies what unsafe checking does: the compiler no longer asks “is this correct”, it only asks “is this a form the source has permitted.” Measuring where the boundary stops is a separate matter from running what lies beyond it — this lesson does only the first of those.

This distinction also explains why unsafe functions need a documentation convention. Adding a documentation line above a function like get_second_item that spells out which precondition is expected is not something the language requires — but it is a common writing habit, because the unsafe word in the signature does not say by itself “which” precondition, only that there is one. The function in this run carries that documentation as a single-line comment; the Documentation lesson, later, will show that a documentation line’s own correctness is likewise not checked on its own, and falls into a separate class (nobody checks it). What matters here is that unsafe itself does not require this documentation, only that the language leaves a visible mark in the source: without the unsafe word this function cannot be called at all, but the word’s presence does not show that the caller has actually passed a correct slice.

Summary

  • An unsafe block does not close off the compiler’s entire checking; it only excludes a few specific operations (raw pointer read/write, calling an unsafe function) from scope — a safe call inside the same block (v.get) stayed in the still holds class
  • A read through raw pointer arithmetic, a slice’s unchecked access method, and a call to a contracted unsafe function — all three are operations where checking is handed to the writer
  • All three delegated operations, used correctly, gave a result identical to their safe counterpart; the difference is not in the result but in the responsibility for misuse
  • The borrow checker keeps working inside an unsafe block too — it is not a checker that closes, only a few operations that open
  • The signature of a function marked unsafe is a form that makes the precondition inside its body visible to the caller; the compiler does not test this precondition, it only forces its existence to be declared

Next Step

In this lesson, the delegated guarantee always stayed inside the same program — both the raw pointer and the unchecked access were reading Rust’s own memory layout; the compiler always knew which type the pointer’s target had and the slice’s real length, it had only stopped testing that knowledge at the moment of reading. The next lesson carries the same delegation one step further: calling a function, written in another language, whose body the compiler never sees at all. There, the contract is not only a precondition but a signature — the compiler now compiles by trusting only the declaration written, without ever looking at the other side’s body.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close