Skip to content

← Collections, Text and First Iterators step 17 of 21

Medium Primitives

Cow: borrow until you have to own

Replace every ASCII control character in a string with '_' — and do not allocate when there is nothing to replace.

use std::borrow::Cow;

pub fn sanitize(s: &str) -> Cow<'_, str>

The harness reports two things per case: the resulting text, and whether you returned Cow::Borrowed or Cow::Owned. So “it did not allocate” is not a hope you have about your code — it is an asserted value in the test output.

"clean text"  ->  text "clean text",  borrowed true
"a\tb"        ->  text "a_b",         borrowed false
""            ->  text "",            borrowed true

char::is_ascii_control defines the character class: 0x000x1F and 0x7F. Tab, newline and carriage return are control characters. Non-ASCII letters are not.

The problem Cow solves

You have a function that usually returns its input unchanged and occasionally has to build something new. Sanitising, normalising, unescaping, path canonicalisation, trimming — the shape is everywhere. Two obvious signatures, both bad:

fn sanitize(s: &str) -> String   // always allocates, even for clean input
fn sanitize(s: &str) -> &str     // impossible: what do you return when you changed it?

The second one is not a style preference, it is a hard wall. A &str must point at memory that outlives the call, and a freshly built string is owned by the function. You met that as E0515 in the zero-copy parsing problem.

Cow — “clone on write” — is the type-level answer:

pub enum Cow<'a, B: ?Sized + ToOwned> {
    Borrowed(&'a B),
    Owned(<B as ToOwned>::Owned),
}

For B = str, that is exactly Borrowed(&'a str) or Owned(String). One discriminant, one branch. And crucially, the choice happens at runtime, per call — the caller gets zero copies on the common path and a correct answer on the uncommon one, from a single signature.

Using one

Cow<'a, B> implements Deref<Target = B>, so every read-only method of the borrowed type works without you caring which variant you hold:

let c = sanitize(input);
c.len();  c.contains("x");  c.starts_with('/');   // all fine

Three methods matter:

  • into_owned() consumes the Cow and gives you a String — a move if it was Owned, a clone if it was Borrowed. This is what you call when you are done deferring.
  • to_mut() gives you a &mut String, promoting a Borrowed to Owned by cloning first if necessary. This is the “write” in copy-on-write, and it is how you mutate a Cow in place.
  • matches!(c, Cow::Borrowed(_)) — pattern matching, which is what the harness uses to grade you.

The trap in the starter

The starter builds the output unconditionally:

let cleaned: String = s.chars().map(...).collect();
Cow::Owned(cleaned)

This is correct and it defeats the entire point. collect::<String>() allocates. So does str::replace — which is the other one-liner people reach for, and worth knowing about explicitly: replace always returns a new String, even when it replaced nothing. There is no fast path inside it.

The fix is a cheap check first: scan for a control character, and only build a new string if you find one. Yes, that is two passes over the input in the bad case. Two passes over cache-hot bytes is enormously cheaper than an allocation, and in the good case — which is the common case — it is one pass and no allocation at all.

::: question Two passes in the bad case feels wasteful. Could you do it in one? You can: find the index of the first control character, and if there is one, build the output starting from the already-clean prefix.

match s.find(|c: char| c.is_ascii_control()) {
    None => Cow::Borrowed(s),
    Some(i) => {
        let mut out = String::with_capacity(s.len());
        out.push_str(&s[..i]);
        out.extend(s[i..].chars().map(|c| if c.is_ascii_control() { '_' } else { c }));
        Cow::Owned(out)
    }
}

This is what a serious library does — it is roughly how serde_json escapes strings and how percent-encoding works. It is also four times as long and has an off-by-one you can get wrong.

For a function called once per request, write the two-pass version. For one called once per byte of a gigabyte, write this. Knowing both, and knowing which situation you are in, is the actual skill. :::

Two clippy lints that exist because Cow is easy to get wrong

suspicious_to_owned is default-on, and it catches a genuinely dangerous silent bug. Verified message from clippy 0.1.95:

error: this `to_owned` call clones the `Cow<'_, str>` itself and does not
       cause its contents to become owned

cow.to_owned() type-checks and does nothing you wanted: Cow implements Clone, so to_owned clones the wrapper, still Borrowed, still tied to the same lifetime. You almost always meant into_owned(). Without the lint this compiles and then fails later with a confusing lifetime error, or quietly keeps a borrow alive.

owned_cow is also default-on, and catches the number-one Cow mistake: writing the owned type as the inner type.

struct Message { text: Cow<'static, String> }   // error: needlessly owned Cow type

Cow<'a, B> wants B to be the borrowed form — str, not String; [T], not Vec<T>; Path, not PathBuf. Write Cow<'static, String> and the Borrowed variant holds a &String, so the type is now “either a reference to a heap string or a heap string” — a pointless indirection that can never avoid an allocation, which was the entire purpose.

When not to use Cow

Be as clear about this as about the rest. If your function always modifies its input, Cow costs you a discriminant, a branch at every use site, and a more complicated signature, in exchange for nothing. Just return String.

And if you find yourself adding a Cow field to a struct, read the next problem in this track first — it makes the whole struct generic over a lifetime, and that cascades into every signature that touches it.

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

Loading visualization…