Skip to content

← Unsafe and Soundness step 22 of 24

Medium Primitives

Splitting borrows: implement `split_at_mut`

pub fn split_at_mut<T>(v: &mut [T], mid: usize) -> (&mut [T], &mut [T])
pub fn split_sums(v: Vec<i64>, cuts: Vec<usize>) -> Vec<i64>

Write split_at_mut from scratch. std’s version is banned — that is the whole exercise. Then use it to implement split_sums:

  1. drop every cut greater than v.len(), sort what is left, remove duplicates;
  2. those cuts partition v into segments — there is always one more segment than there are cuts, and segments may be empty;
  3. negate every odd-indexed segment, in place;
  4. return the sum of each segment, in order.

[1,2,3,4,5] with cuts = [] gives [15]. With cuts = [0] the first segment is empty and the second is the whole vector, negated: [0, -15]. With cuts = [5] it is [15, 0].

The starter’s split_at_mut contains two of the three classic bugs. Its split_sums is yours to write.

The shortest complete example of the whole track

Look at the signature again:

pub fn split_at_mut<T>(v: &mut [T], mid: usize) -> (&mut [T], &mut [T])

One exclusive reference goes in. Two come out, both with the same lifetime as the input. Every caller since 2015 has consumed that for free, and no caller has ever needed unsafe.

What makes it work is four things in sequence, and they are exactly the four things this track has been teaching:

  1. a safe signature that asserts a property the borrow checker cannot derive;
  2. an internal precondition checkassert!(mid <= len);
  3. a documented safety argument attached to each unsafe block;
  4. unsafe confined to two lines.

::: question The assert! is not defensive programming. What is it? It is the safety argument, expressed as code.

split_at_mut is a safe function — no unsafe at any call site — and by the rule from item 17.3 that is only allowed if there exists no input, reachable from safe code, for which calling it is undefined behaviour. Feed it mid = 9 on a three-element slice and, without the assert, it would build a slice reaching six elements past the end of the allocation. That is UB, from safe code, so the function would be unsound.

The assert removes the input from the domain. Every value of mid now either produces two valid slices or panics, and a panic is not undefined behaviour — it is a defined, orderly, recoverable outcome.

That trade is the single most useful pattern in this track: convert the unrepresentable precondition into a runtime check, and you convert an unsafe fn into a safe one. It costs one comparison. std makes that trade everywhere. :::

Why the borrow checker needs this at all

let x = &mut v[0];
let y = &mut v[1];   // E0499

You know from track 3 why that is rejected: v[i] desugars to a call to IndexMut::index_mut(&mut v, i), so at the type level both are loans of v itself, and the checker never looks inside a function body to reason about the arithmetic.

So the compiler did not get smarter when split_at_mut was written. Somebody wrote the proof down in a type. The disjointness lives in the signature, and the unsafe that discharges it lives in one place, once, forever.

That is the sense in which the borrow checker is conservative, not wrong. It rejects a program that is fine. The fix is never to argue with it; the fix is to hand it a signature that already contains the proof.

The three classic bugs

assert!(mid < len) instead of <=. An empty second half is perfectly legal — split_at_mut(v, v.len()) returns the whole slice and an empty one, and std guarantees it. The < version also panics on split_at_mut(&mut [], 0), which is the most ordinary call there is. Both the cuts = [len] and the empty-vector cases here hit it directly.

Building both halves from ptr without .add(mid). The two slices then overlap, so two live &mut alias the same elements. This passes a great many tests — the sums are wrong only when a segment is actually mutated — and it is undefined behaviour on every call.

len - mid underflowing. With the <= assert in place it cannot, which is another way of saying the assert is load-bearing rather than decorative.

::: question Two &mut [T] into the same allocation. Why is that not an instant aliasing violation? Because the aliasing rule is about overlapping places, not about allocations.

&mut uniqueness says: while this reference is live, nothing else may access the bytes it covers. Two mutable references into one buffer are perfectly fine as long as the byte ranges are disjoint — and ptr .. ptr + mid and ptr + mid .. ptr + len are disjoint by construction, given mid <= len.

This is exactly why .add(mid) is not an optimisation. Drop it and the ranges overlap, and now two live &mut cover the same bytes, which the model forbids outright — the optimiser is entitled to assume writes through one cannot be seen through the other.

Note also that from_raw_parts_mut produces slices with an unbounded lifetime — the caller chooses. Here the function signature chooses for you, by tying both outputs to the input’s lifetime. Get the signature wrong and you have handed out references that can outlive the slice. :::

Extending the idea

Once you have this, the rest of the family falls out. split_first_mut is split_at_mut(1) plus a length check. chunks_mut is split_at_mut in a loop, keeping the right half each time — which is exactly what split_sums does here. An iter_mut-style splitting iterator needs one more trick: either mem::take/mem::replace to move the remaining slice out of the iterator’s field, or a raw-pointer cursor with a PhantomData<&'a mut T> (item 17.19).

Note the loop shape in split_sums. rest is a &mut [i64]; passing it to split_at_mut moves it (a &mut is not Copy), and the returned right takes its place. That move is what makes the loop borrow-check: at no point do two names refer to the same mutable slice.

Lints

clippy::multiple_unsafe_ops_per_block would count from_raw_parts_mut(ptr.add(mid), n) as two operations and reject it. It is off for this problem — item 17.4 is the one that turns it on — but if you want the stricter discipline, hoist ptr.add(mid) into its own block with its own justification. clippy::mut_from_ref is deny-by-default and will catch any helper you write that takes &self and returns &mut.

What this grader cannot check

The overlapping-slices bug is undefined behaviour that produces correct output on any input where the two halves are not both written. Several cases here do write both halves, so it is likely to fail — but likelihood is the honest word. Miri detects this class immediately and cannot run in this harness.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…