Skip to content

← Unsafe and Soundness step 8 of 24

Medium Primitives

`ptr::read`, `write`, `copy`, `copy_nonoverlapping`, `drop_in_place`

pub fn dedup_in_place(v: Vec<String>) -> Vec<String>

Reimplement Vec::dedup by hand, on the raw buffer. Remove consecutive duplicates — ["a","a","b","b","b","c"] becomes ["a","b","c"] — by walking a read index and a write index, taking each duplicate out of the buffer and dropping it, moving survivors down, and publishing the new length with set_len.

No retain, no dedup, no building a second vector. The elements are String, so ownership is real and mistakes cost something.

The starter has two bugs on one line. Clippy names the first.

Where the ownership model meets the memory model

This is the item where “a bitwise copy is not the same thing as a move” becomes a bug you can hold.

ptr::read(src) produces a bitwise copy of the value at src and hands it to you as an owned value. It does not touch the source. So if T: !Copy you now have two values that both believe they own the same heap allocation, and both will run their destructor. Exactly one of them must be forgotten, overwritten, or moved out of range before the other drops.

ptr::write(dst, val) stores val at dst without dropping whatever was there. That is precisely what you want for an uninitialised slot and catastrophic for an initialised one: the old String‘s heap buffer is simply forgotten. A leak, not a crash — which is worse, because nothing tells you.

ptr::copy(src, dst, n) is memmove. Overlap is allowed.

ptr::copy_nonoverlapping(src, dst, n) is memcpy. Overlap is undefined behaviour, and this is a nasty one: it works fine for small n and small overlaps and corrupts silently for larger ones, so it passes your unit tests and fails in production.

ptr::drop_in_place(p) runs T‘s destructor on a place you cannot move out of. It is what you want when the value must die where it lies.

Every count in this API is in ELEMENTS, not bytes. C’s memcpy counts bytes; Rust’s does not. Clippy’s size_of_in_element_count is deny-by-default precisely because transliterating C here multiplies your overrun by size_of::<T>().

::: question You ptr::read a String out of slot 5 and bind it to dup. What is the state of slot 5 now? It still contains a byte-identical String — pointer, length, capacity, all the same — and that copy is a lie.

Nothing was zeroed. Nothing was marked. The buffer looks exactly as it did. But ownership of the heap allocation now belongs to dup, and when dup goes out of scope the allocation is freed. If anything later treats slot 5 as a live String — reads it, drops it, includes it in a length — it is touching freed memory.

This is why the bookkeeping is the whole exercise. In safe Rust the compiler tracks which places are still owners and inserts drop flags for you. Here the compiler has stopped tracking, and len is the only record of who is alive. Getting len right, at the right moment, is the entire safety argument. :::

The std::ptr validity checklist

Every safety comment in this problem should discharge these five, from the std::ptr module docs. A pointer is valid for an access if it is:

  1. non-null (except for zero-sized accesses);
  2. dereferenceable — the whole size_of::<T>() byte range is inside one live allocation;
  3. properly aligned for T;
  4. pointing at a valid value of T (for reads);
  5. respecting the aliasing rules — no live &mut elsewhere to the same place, no live & if you are writing.

“Inside one allocation” is doing real work in item 3. A pointer that reaches from one heap block into an adjacent one is invalid even if both blocks are live and the arithmetic looks fine.

Writing this one

The shape is two indices. write is where the next survivor goes; read scans forward. Compare v[read] with the last survivor at write - 1. If they match, that element is a duplicate: ptr::read it out and drop it. If they differ, move it down to write and bump write.

Two decisions worth thinking about before you type.

Which copy routine? The source and destination indices satisfy write <= read, so when write != read the single-element ranges genuinely do not overlap and either routine would work. Use ptr::copy anyway. The reason is that a one-element copy_nonoverlapping here is a coincidence of this algorithm, and the moment somebody generalises the loop to move a run of k survivors it becomes a real overlap and real UB. Prefer the routine whose precondition you can state without appealing to the current loop bounds.

When does len change? Only at the end, with one set_len(write). In between, v.len() still says len, which means the vector is temporarily lying — slots you have read out are still counted. That is fine here because nothing in the loop can panic (comparing and dropping Strings does not), so no destructor ever observes the inconsistent state. Item 17.21 is about what happens when that assumption is false.

::: question The starter writes copy_nonoverlapping(p.add(read), p.add(write), size_of::<String>()). What does that actually do? It copies 24 Strings instead of one, off the end of the buffer, in both directions.

size_of::<String>() is 24 on a 64-bit target — a pointer, a length and a capacity. The count argument is in elements, so the call reads 24 String values starting at read and writes 24 starting at write. Almost all of those are past the end of the allocation.

Clippy catches this specific mistake by name:

error: found a count of bytes instead of a count of elements of `T`

and the lint, size_of_in_element_count, is deny-by-default, because there is no plausible correct program that looks like this. It exists because the mistake is so mechanical: everybody has written memcpy(dst, src, n * sizeof(T)) a thousand times, and the muscle memory transfers before the knowledge does. :::

What this grader cannot check

Two things, and they are opposite failures.

A leak is invisible. If you never drop the duplicates, every test here still passes. String‘s heap buffers just accumulate. Rust considers leaking safe — item 17.21 explains why that is a deliberate design decision rather than an oversight — so no lint and no test will tell you. Dropping the duplicates is required by the statement and enforced only by your own care.

A double free usually aborts, but not reliably. The 10 000-element cases are here to make heap corruption likely to surface, and the harness deliberately allocates two thousand strings after your function returns so that a damaged allocator has somewhere to fall over. That is a smoke detector, not a proof.

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

Loading visualization…