Skip to content

← Unsafe and Soundness step 3 of 24

Easy Primitives

unsafe {} vs unsafe fn: edition 2024 splits the two meanings

pub unsafe fn get_unchecked_sum(v: &[i64], idx: &[usize]) -> i64
pub fn checked_sum(v: Vec<i64>, idx: Vec<usize>) -> Option<i64>

This exercise has two functions. One does an operation with no checks and is marked unsafe; the other checks its inputs first and is safe to call.

get_unchecked_sum adds up v[i] for every i in idx, reading each element with slice::get_unchecked, which skips the bounds check. It is unsafe to call because an index that is out of range makes it read memory outside the slice. That is undefined behaviour (UB): the program has broken a rule the compiler relies on, and Rust no longer promises anything about what happens.

checked_sum is the safe wrapper: it validates the indices, then calls get_unchecked_sum. The exact contract:

  • v is any list of i64 values, possibly empty. idx is a list of non-negative indices, possibly empty, possibly with repeats.
  • Return None if idx is empty, or if any index is >= v.len(). One bad index anywhere rejects the whole call. When v is empty, every index is out of range.
  • Otherwise return Some(sum), where each index adds v[i] once per time it appears in idx. Use wrapping addition (i64::wrapping_add), so a sum that leaves the i64 range wraps around in two's complement instead of overflowing.
  • The harness prints {"value": n} for Some(n) and {"value": null} for None.

For example, with v = [10, 20, 30, 40], idx = [0, 2] gives Some(40), idx = [3, 3, 3] gives Some(120), and idx = [4] gives None because 4 is one past the last valid index.

The starter compiles, but the grade is compile, tests and cargo clippy -- -D warnings, and the starter produces several warnings. The first one is the point of the lesson: a warning with code E0133 inside get_unchecked_sum. (The others are the unused parameters of the unfinished checked_sum and a missing # Safety section, covered below.)

Two meanings of unsafe fn

Writing unsafe fn has always meant:

  1. The caller has obligations. Calling the function is an unsafe operation, so the caller must use an unsafe {} block and make sure the function's documented conditions hold. Otherwise the call may cause UB.

Before edition 2024 it also meant, by default:

  1. The whole body is an unsafe context. Inside an unsafe fn you could dereference raw pointers or call other unsafe functions without writing any unsafe {} block.

The second meaning makes review harder. In a long unsafe fn, nothing marks which lines perform unsafe operations, so a reader has to check every line to find the ones whose correctness depends on the caller's promise.

RFC 2585 added the lint unsafe_op_in_unsafe_fn, which flags an unsafe operation inside an unsafe fn that is not wrapped in its own unsafe {} block. In editions 2015 to 2021 the lint is allowed (silent) by default. In edition 2024 it is warn-by-default. With the lint in force, each unsafe operation in the body gets its own block, as it would in a safe function:

/// # Safety
///
/// `p` must be non-null, aligned, and point to an initialised `i32`.
pub unsafe fn f(p: *const i32) -> i32 {
    // SAFETY: the caller guarantees `p` is valid for reads.
    unsafe { *p }
}

Without the inner block, edition 2024 reports warning[E0133]: call to unsafe function ... is unsafe and requires unsafe block (for get_unchecked), with the note "an unsafe function restricts its caller, but its body is safe by default". It is a warning, not an error, so the code still builds. Here the grade runs Clippy with -D warnings, which turns every warning into an error, so the starter fails. (Outside this site you can get the same effect for just this lint with #![deny(unsafe_op_in_unsafe_fn)].)

Check your understanding If unsafe operations in the body of an unsafe fn now need their Show answer

own blocks, what does the unsafe on the function signature still mean? It means calling this function is an unsafe operation, so callers need their own unsafe {} block and their own justification.

It is a statement about the contract, not about the contents: "some inputs make calling me undefined behaviour; read my # Safety section and make sure you are not passing them."

When to mark a function unsafe

A useful rule of thumb:

Mark a function unsafe when there is some input, which safe code could pass, for which calling it causes undefined behaviour.

Containing unsafe code is not the test. slice::split_at_mut is implemented with unsafe code, but it is a safe function: it checks mid <= len first and panics otherwise. Every input either works or panics, and a panic is not UB.

(The rule of thumb has a known extension. A function can also be unsafe because misusing it breaks an invariant that other unsafe code relies on, even if the call itself does nothing wrong. Vec::set_len is the standard example: setting a length larger than the initialised part does not read anything, but later safe calls on the vector would.)

That is the shape you are building here. get_unchecked_sum is unsafe because passing idx = [99] with a 3-element v is UB. checked_sum does the same underlying operation but is safe, because it turns every bad input into None before the unsafe call happens. The unsafe function states the requirement; the safe function proves it is met.

The three Clippy lints in this exercise

clippy::missing_safety_doc is on by default. It warns about a public unsafe fn whose doc comment has no # Safety section. The section should say what the caller must guarantee (here: every element of idx is less than v.len()), not describe what the function does internally.

clippy::unnecessary_safety_doc is off by default; the starter turns it on with #![warn(...)] on its first line. It warns in the other direction: a safe public function that has a # Safety section. Do not give checked_sum one. Its callers have nothing to uphold, and a safety section that asks for nothing makes real ones easier to ignore.

clippy::undocumented_unsafe_blocks, also turned on by the starter, warns about an unsafe {} block with no // SAFETY: comment directly before it (on the line above the block or above the statement containing it). That includes the block inside checked_sum that calls get_unchecked_sum. The comment is where you explain why the # Safety requirement holds at this call. If you cannot write that explanation, the code probably has a bug.

Check your understanding checked_sum calls an unsafe fn. Why is checked_sum itself Show answer

not unsafe? Because no input to checked_sum can cause UB.

Give it any Vec<i64> and any Vec<usize>: out-of-range indices, an empty list, indices near usize::MAX. Each one either produces a sum or produces None. The unsafe call only runs after the guard has checked every index.

This is what a safe abstraction means: unsafe code whose precondition is enforced by a safe function around it, so safe callers cannot misuse it. Much of the standard library, including split_at_mut, is built this way.

This argument works because the guard and the unsafe call are in the same function, so no other code can run between them. The article unsafe code and module invariants covers what changes when the check and the unsafe code are only in the same module, and why the whole module then needs auditing.

What the tests cannot check

Passing tests does not prove that code has no undefined behaviour. A tool such as Miri, which interprets the program and reports many kinds of UB, needs a nightly toolchain and is not run by this grader.

Suppose you wrote the guard with > instead of >=. Then idx = [4] with a 4-element v gets past the guard, and get_unchecked reads the 8 bytes just after the end of the slice. The visible test for that input expects None, so it would most likely fail, but only because the guard returned the wrong variant. Nothing detects the out-of-bounds read itself. On a typical machine it returns whatever bytes happen to be there, and since it is UB the optimiser may assume it never happens, so the program can behave in other unexpected ways too. A test suite only catches UB like this by luck, so check the guard by reading it, not only by running it.