199. Static Dispatch — Generics Beat Box<dyn Trait> When You Can Afford the Code

Box<dyn Trait> is the reflex when a function “takes something that implements a trait.” But every call through it pays for a vtable hop the compiler can’t see past. Swap it for a generic and the optimizer inlines the whole thing.

This is the morning half of a pair with this afternoon’s #[inline] & Copy bite: both are about giving the optimizer a body it can actually fold into the caller.

The cost: Box<dyn Trait> hides the call from the optimizer

When you accept Box<dyn Fn(i32) -> i32> (or any dyn Trait), the concrete type is erased. At each call site the program loads a function pointer from a vtable and jumps through it. The compiler has no idea what’s on the other end, so it can’t inline the body, can’t constant-fold through it, can’t vectorize a loop around it:

1
2
3
fn apply_all(f: Box<dyn Fn(i32) -> i32>, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect() // indirect call every iteration
}

There’s also a heap allocation just to hold the closure, and the pointer-chase ruins instruction-cache locality in a hot loop.

The fix: a generic parameter monomorphizes to the real type

Take impl Fn (sugar for a generic) instead. The compiler stamps out a specialized copy of apply_all for each concrete f you pass — monomorphization. Inside that copy the closure’s body is fully visible, so it gets inlined and the map loop optimizes as if you’d written the arithmetic by hand:

1
2
3
fn apply_all<F: Fn(i32) -> i32>(f: F, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect() // direct call, inlinable
}

No box, no vtable, no allocation. impl Trait in argument position is the same thing with less typing:

1
2
3
fn apply_all(f: impl Fn(i32) -> i32, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect()
}
1
2
let doubled = apply_all(|x| x * 2, &[1, 2, 3]);
assert_eq!(doubled, vec![2, 4, 6]);

Both generic versions compile to a tight loop with the multiply spliced straight in.

The same trick for returns: impl Trait instead of Box<dyn>

Returning a dyn value forces a box too. If the function only ever returns one concrete type, impl Trait keeps it static — the caller gets the real type and can inline through it:

1
2
3
4
5
6
fn adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n          // one concrete closure type, no Box
}

let add5 = adder(5);
assert_eq!(add5(10), 15);

When dyn is still the right call

Static dispatch trades code size for speed: each instantiation is a fresh copy, so monomorphizing over many types bloats the binary. And generics can’t do heterogeneous collections — Vec<Box<dyn Draw>> holding circles and squares genuinely needs dynamic dispatch, because the element type varies at runtime. Reach for dyn when you need a uniform type for mixed values, want to shrink compile times and binary size, or the call isn’t hot enough to matter. Reach for generics / impl Trait when the call sits in a loop and you want the optimizer to see through it.

1
2
3
4
5
// heterogeneous → dyn is correct
let shapes: Vec<Box<dyn Fn() -> &'static str>> =
    vec![Box::new(|| "circle"), Box::new(|| "square")];
let names: Vec<&str> = shapes.iter().map(|s| s()).collect();
assert_eq!(names, vec!["circle", "square"]);

Rule of thumb: default to a generic, and downgrade to dyn only when you have a reason — mixed types, code-size pressure, or a cold path where the indirection is free.

198. into_iter() to Transform — Move Owned Items Instead of cloned()

You have a Vec you’re about to throw away, and you want a transformed one. Reaching for iter().cloned() (or iter().map(|x| x.clone())) duplicates every element on the way out — but you owned them already. into_iter() moves them straight through.

This is the afternoon half of this morning’s mem::replace bite: both are about moving owned data forward instead of copying it. There, it was an enum behind &mut self; here, it’s the elements of a collection.

The trap: iter().cloned() on a collection you’re discarding

You want to uppercase a list of names. The list is a local you won’t touch again:

1
2
3
4
5
6
fn shout(names: Vec<String>) -> Vec<String> {
    names
        .iter()                       // yields &String
        .map(|s| s.to_uppercase())    // to_uppercase already allocates a fresh String...
        .collect()
}

That one’s not even the worst case — to_uppercase builds a new String regardless. The real waste shows up when the transform keeps the value and you clone just to own it:

1
2
3
4
5
6
7
fn tag(names: Vec<String>) -> Vec<(String, usize)> {
    names
        .iter()                                  // &String
        .enumerate()
        .map(|(i, s)| (s.clone(), i))            // clone purely to own it
        .collect()
}

Every s.clone() heap-allocates a duplicate of a string you were about to drop. The original names gets freed on return — you paid to copy bytes that were headed for the incinerator.

The fix: into_iter() consumes the collection and hands you owned items

into_iter() on a Vec<String> yields String by value, not &String. The transform now moves each element — no clone, no second allocation:

1
2
3
4
5
6
7
fn tag(names: Vec<String>) -> Vec<(String, usize)> {
    names
        .into_iter()                 // yields String, owned
        .enumerate()
        .map(|(i, s)| (s, i))        // move s straight in
        .collect()
}
1
2
3
let names = vec!["ada".to_string(), "linus".to_string()];
let tagged = tag(names);
assert_eq!(tagged, vec![("ada".to_string(), 0), ("linus".to_string(), 1)]);

Each string’s heap buffer is threaded through by pointer. Zero element copies.

The rule of thumb

If you still need the collection afterward, iter() (borrow) is correct — you can’t move out of something you’re keeping. But the moment the collection is yours to consume and you don’t need it again, into_iter() skips a copy of every element. A for x in v loop already does this (it’s into_iter under the hood); the win is remembering that .map, .filter, and friends can start from into_iter() too.

1
2
3
4
5
6
// keep the source → borrow
let total: usize = names.iter().map(|s| s.len()).sum();
println!("{}", names.len()); // still usable

// done with the source → move
let owned: Vec<String> = names.into_iter().filter(|s| s.len() > 3).collect();

cloned() earns its keep when you genuinely need both the original and a copy. When you don’t, it’s a tax on data you’re about to free.

197. Advance a State Machine with mem::replace — Move the Enum Out, No Clone

Transitioning an enum state behind &mut self looks impossible: you can’t move the old variant’s owned data into the new one without the borrow checker stopping you — so people reach for .clone(). mem::replace lets you move the whole state out, leaving a cheap placeholder behind.

This closes out the performance week. Earlier bites covered mem::take, mem::replace, and mem::swap as primitives. Here’s the pattern they were built for: a state machine that moves owned data forward through its transitions.

The setup

A job that walks Queued → Running → Done, carrying an owned String payload from one state into the next:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#[derive(Debug, PartialEq)]
enum Stage {
    Queued { payload: String },
    Running { payload: String, worker: u32 },
    Done { result: String },
}

struct Job {
    stage: Stage,
}

The trap: matching a borrow forces a clone

You only have &mut self, so the obvious move is to match on &self.stage. But that gives you a borrow of payload — to put it in the next state you have to clone it:

1
2
3
4
5
6
7
8
9
fn advance(&mut self) {
    self.stage = match &self.stage {
        Stage::Queued { payload } => Stage::Running {
            payload: payload.clone(), // borrowed, so clone to reuse
            worker: 7,
        },
        // ...
    };
}

Matching on self.stage by value would move out of &mut self — the borrow checker rejects it outright. So clone feels like the only way out. It isn’t.

The fix: replace the whole state, then match by value

mem::replace swaps in a cheap placeholder and hands you the real state by value. Now the match owns payload and can move it straight into the next variant — zero clones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::mem;

impl Job {
    fn advance(&mut self) {
        self.stage = match mem::replace(&mut self.stage, Stage::Done { result: String::new() }) {
            Stage::Queued { payload } => Stage::Running { payload, worker: 7 },
            Stage::Running { payload, worker } => {
                Stage::Done { result: format!("{payload}@{worker}") }
            }
            done => done, // terminal state stays put
        };
    }
}

The placeholder (Done { result: String::new() }) is free — an empty String allocates nothing — and it lives for only the instant before you overwrite self.stage with the match result.

1
2
3
4
5
6
7
let mut job = Job { stage: Stage::Queued { payload: "build".into() } };

job.advance();
assert_eq!(job.stage, Stage::Running { payload: "build".into(), worker: 7 });

job.advance();
assert_eq!(job.stage, Stage::Done { result: "build@7".into() });

The payload string is allocated once and threaded through all three states by pointer. No copy of the bytes ever happens — exactly what the clone-based version threw away on every transition.

196. Return impl Iterator, Not Vec — Let the Caller Decide What to Do

Returning a Vec from a helper allocates eagerly, every time — even when the caller only wants the first match or a running sum. Return impl Iterator instead and the allocation simply never happens unless the caller asks for it.

This is the function-boundary version of yesterday’s bite-195: chaining adapters avoids temporary Vecs inside a pipeline; returning impl Iterator avoids forcing one across a function call.

The eager version

A helper that builds and returns a Vec commits to a heap allocation and a full pass over the data before the caller has said what they want:

1
2
3
fn evens_doubled(nums: &[i32]) -> Vec<i32> {
    nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * 2).collect()
}

If the caller just wants the first result, they still pay for the whole Vec:

1
2
3
let data = [1, 2, 3, 4, 5, 6];
let first = evens_doubled(&data).into_iter().next(); // allocated all 3, used 1
assert_eq!(first, Some(4));

Hand back the iterator instead

Drop the .collect() and return the lazy iterator. The + '_ ties its lifetime to the borrowed slice:

1
2
3
fn evens_doubled(nums: &[i32]) -> impl Iterator<Item = i32> + '_ {
    nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * 2)
}

Now nothing runs until the caller pulls values through — and they pick the consumer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let data = [1, 2, 3, 4, 5, 6];

let v: Vec<i32> = evens_doubled(&data).collect(); // collect if you want to
assert_eq!(v, [4, 8, 12]);

let total: i32 = evens_doubled(&data).sum();       // or fold straight to a number
assert_eq!(total, 24);

let first_big = evens_doubled(&data).find(|&n| n > 5); // or short-circuit
assert_eq!(first_big, Some(8));                    // stops at 8, never doubles 6

The find call never allocates and never touches the last element. The Vec-returning version couldn’t do that — collect() always drains the whole thing first.

The one rule: don’t borrow a local

The iterator you return can borrow your parameters, but not data you created inside the function — that data is dropped when the function ends. Iterators over owned values (like a Range) carry no borrow, so they just work:

1
2
3
4
5
6
fn squares(n: u32) -> impl Iterator<Item = u32> {
    (1..=n).map(|x| x * x)
}

let sq: Vec<u32> = squares(4).collect();
assert_eq!(sq, [1, 4, 9, 16]);

If you must produce owned data inside the function and stream it out, move it into the iterator (e.g. vec.into_iter() or a move closure) rather than returning a borrow of a local.

195. Chain Iterator Adapters — Don't collect() Between Every Step

Every collect::<Vec<_>>() in the middle of a pipeline is a heap allocation and a full pass over your data. Adapters like map and filter are lazy and fuse together — chain them and the whole transformation runs in one pass with zero temporary Vecs.

A Vec between every step

It’s tempting to do one transformation at a time, binding each result to a variable. Every collect() allocates a throwaway Vec and walks the entire sequence before the next step even starts:

1
2
3
4
5
6
7
let nums = [1, 2, 3, 4, 5, 6];

let doubled: Vec<i32> = nums.iter().map(|&n| n * 2).collect();
let big: Vec<i32> = doubled.into_iter().filter(|&n| n % 4 == 0).collect();
let sum: i32 = big.iter().sum();

assert_eq!(sum, 24);

Two intermediate Vecs, two extra allocations, three separate passes — all to compute a single number.

One chain, one pass, no temporaries

The adapters compose directly. Nothing is materialized until the final consumer (sum) pulls values through, so there are no intermediate collections at all:

1
2
3
4
5
6
7
8
9
let nums = [1, 2, 3, 4, 5, 6];

let sum: i32 = nums
    .iter()
    .map(|&n| n * 2)        // 2, 4, 6, 8, 10, 12
    .filter(|&n| n % 4 == 0) // 4, 8, 12
    .sum();                  // 24

assert_eq!(sum, 24);

Each element flows through map then filter then into the sum, one at a time. No buffer is ever allocated.

Laziness means short-circuiting works

Because nothing runs until pulled, a chain only does the work it needs. Add a take(2) and the pipeline stops after producing two results — the elements past that point are never touched:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::cell::Cell;

let visited = Cell::new(0);
let nums = [1, 2, 3, 4, 5, 6, 7, 8];

let first_two: Vec<i32> = nums
    .iter()
    .inspect(|_| visited.set(visited.get() + 1))
    .map(|&n| n * 10)
    .filter(|&n| n > 20)
    .take(2)
    .collect();

assert_eq!(first_two, [30, 40]);
assert_eq!(visited.get(), 4); // stopped early — never looked at 5..8

The intermediate-collect version can’t do this: collect() always drains the whole iterator, so it would have visited all eight elements before take ever saw one.

When you genuinely do need a Vec

The point isn’t “never collect” — it’s “don’t collect between steps.” Collect once, at the end, when you actually need an owned, reusable collection:

1
2
3
4
5
6
7
8
9
let words = ["fast", "lazy", "fused", "iter"];

let shouted: Vec<String> = words
    .iter()
    .filter(|w| w.len() == 4)
    .map(|w| w.to_uppercase())
    .collect();

assert_eq!(shouted, ["FAST", "LAZY", "ITER"]);

One collect, at the end, when the Vec is the actual result. Everything before it stays lazy and allocation-free.

194. Reuse One Buffer with .clear() — Allocate Once, Loop Many Times

with_capacity (bite 193) buys a buffer once instead of growing it repeatedly. But if you allocate a fresh String or Vec inside a loop, you throw that buffer away every iteration. .clear() resets the length to zero while keeping the capacity — so one allocation serves the whole loop.

A fresh allocation every iteration

It’s easy to declare the working buffer inside the loop. Each pass allocates a new heap buffer and drops it at the end of the iteration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let lines = ["alpha", "beta", "gamma"];
let mut out = Vec::new();

for line in lines {
    let mut buf = String::new();   // new heap allocation, every iteration
    buf.push_str(line);
    buf.make_ascii_uppercase();
    out.push(buf.clone());
}

assert_eq!(out, ["ALPHA", "BETA", "GAMMA"]);

Three iterations, three allocate-then-free cycles for the scratch buffer. Scale that to a million lines and it’s a million wasted allocations.

.clear() keeps the capacity

Hoist the buffer out of the loop and clear() it at the top of each pass. clear() sets the length to 0 but leaves the allocated capacity in place, so after the first iteration the buffer is already big enough and never reallocates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let lines = ["alpha", "beta", "gamma"];
let mut out = Vec::new();
let mut buf = String::new();       // allocated once

for line in lines {
    buf.clear();                   // len -> 0, capacity untouched
    buf.push_str(line);
    buf.make_ascii_uppercase();
    out.push(buf.clone());
}

assert_eq!(out, ["ALPHA", "BETA", "GAMMA"]);

The contract is the whole point — clear drops the contents but not the buffer:

1
2
3
4
5
6
7
let mut s = String::with_capacity(64);
s.push_str("hello");
let cap = s.capacity();

s.clear();
assert_eq!(s.len(), 0);            // empty again
assert_eq!(s.capacity(), cap);     // ...but the buffer is still there

The read-into-a-reused-buffer pattern

This shows up constantly when reading input. BufRead::read_line appends to the buffer you give it, so the idiomatic loop clears one String each pass instead of allocating a new one per line:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::io::BufRead;

let input = "12\n34\n56\n";
let mut reader = std::io::BufReader::new(input.as_bytes());

let mut line = String::new();      // one buffer for every line
let mut sum = 0i64;

loop {
    line.clear();                  // required — read_line appends
    let n = reader.read_line(&mut line).unwrap();
    if n == 0 {
        break;                     // 0 bytes read == EOF
    }
    sum += line.trim().parse::<i64>().unwrap();
}

assert_eq!(sum, 102);

The same trick works for any scratch Vecclear() it at the top of the loop and reuse the capacity for the next batch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let mut scratch: Vec<u8> = Vec::new();
let mut total = 0;

for chunk in [&[1u8, 2, 3][..], &[4, 5], &[6]] {
    scratch.clear();
    scratch.extend_from_slice(chunk);
    total += scratch.iter().map(|&b| b as u32).sum::<u32>();
}

assert_eq!(total, 21);

Reach for a fresh Vec/String only when you actually need to keep each result. When the buffer is just scratch space, allocate it once, clear() it, and let the loop run free.

193. Vec::with_capacity — Size Up Front, Skip the Realloc Churn

A Vec you push into one element at a time doesn’t grow one element at a time — it doubles, copying every existing item to a fresh allocation each time it outgrows its buffer. If you already know how many items are coming, Vec::with_capacity buys the whole buffer once.

The hidden cost of push

Vec::new() starts with zero capacity. As you push, it reallocates geometrically — roughly doubling each time — and every reallocation copies all existing elements into the new, larger buffer. Fill a vector with 1000 items and you pay for that copying around ten times over:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let mut v = Vec::new();
let mut reallocs = 0;
let mut last_cap = v.capacity();

for i in 0..1000 {
    v.push(i);
    if v.capacity() != last_cap {
        reallocs += 1;        // the buffer just moved
        last_cap = v.capacity();
    }
}
// ~10 reallocations, each copying everything built so far
assert!(reallocs >= 8);

In a hot loop, that churn is pure waste: allocate, copy, free, allocate bigger, copy again.

Reserve the space once

If you know the final size, hand it to Vec::with_capacity. The buffer is allocated a single time, and push never has to move it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let mut v = Vec::with_capacity(1000);
let mut reallocs = 0;
let mut last_cap = v.capacity();

for i in 0..1000 {
    v.push(i);
    if v.capacity() != last_cap {
        reallocs += 1;
        last_cap = v.capacity();
    }
}
assert_eq!(reallocs, 0);       // zero — the buffer never moved

Capacity is not length: with_capacity(1000) gives you room for 1000 items but the vector is still empty (len() == 0) until you push.

Already have a Vec? Use reserve

When the vector exists and you’re about to add a known number of elements, reserve grows the buffer ahead of time without touching the contents:

1
2
3
4
5
let mut v = vec![1, 2, 3];
v.reserve(100);               // ensure room for 100 *more*

assert!(v.capacity() >= 103);
assert_eq!(v.len(), 3);       // still 3 elements — only capacity changed

Use reserve before a batch of pushes; use reserve_exact when you want the buffer sized precisely, with no geometric slack.

collect often does this for you

Iterators expose a size_hint, so collecting from a sized iterator already reserves the right capacity — no manual call needed:

1
2
let squares: Vec<i32> = (0..1000).map(|x| x * x).collect();
assert_eq!(squares.len(), 1000);

The win is biggest exactly where it matters: tight loops building large vectors. If you can name the size, name it once and let push run free.

192. impl Into<String> — Take Owned or Borrowed Without an Extra Allocation

Bite 191 said: if you only read the argument, take &str. But what if you need to store it? Taking &str and calling .to_owned() always allocates — even when the caller handed you a String it was about to throw away. impl Into<String> fixes that.

The hidden re-allocation

When a function keeps the value, the “take &str” rule turns into a trap:

1
2
3
4
5
struct Label { text: String }

fn make_label(text: &str) -> Label {
    Label { text: text.to_owned() } // always allocates
}

A literal caller has to allocate eventually — fair enough. But look what happens when the caller already owns a String:

1
2
3
4
let owned = String::from("Status: OK");
let label = make_label(&owned);
// `owned` is copied into a brand-new allocation, then `owned` is dropped.
// We threw away a perfectly good String and allocated a second one.

The caller had an owned buffer it no longer needed, and we ignored it.

Accept anything that becomes a String

Take impl Into<String>. A String moves in with zero copying; a &str allocates exactly once — never more:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct Label { text: String }

fn make_label(text: impl Into<String>) -> Label {
    Label { text: text.into() }
}

// Literal: one allocation, unavoidable since we store it.
let a = make_label("Status: OK");

// Owned String: MOVED in. No copy, no second allocation.
let owned = String::from("Status: OK");
let b = make_label(owned);

assert_eq!(a.text, "Status: OK");
assert_eq!(b.text, "Status: OK");

Same call site for both, and the owned case is now free. The conversion happens lazily at the boundary, exactly once, and only when it must.

When you only read: impl AsRef

If you don’t store the value but still want to accept more than deref coercion allows (String, &str, Box<str>, Cow<str>, …), reach for impl AsRef<str>:

1
2
3
4
5
6
7
fn shout(text: impl AsRef<str>) -> String {
    text.as_ref().to_uppercase()
}

assert_eq!(shout("hi"), "HI");                       // &str
assert_eq!(shout(String::from("hi")), "HI");          // String
assert_eq!(shout(Box::<str>::from("hi")), "HI");      // Box<str>

as_ref() is a cheap borrow — no allocation — and the generic accepts every string-like type without forcing the caller to convert first.

The rule of thumb

If the function stores the string, take impl Into<String> so an owned argument moves in for free. If it only reads but you want maximum flexibility, take impl AsRef<str>. Plain &str (bite 191) is still the right default for simple read-only functions — these two just cover the cases it can’t.

191. Accept &str, Not String — Take the Most General Borrow

A function that takes String forces every caller holding a &str to allocate just to call you. Take &str instead — and &[T] over &Vec<T> — and deref coercion lets everyone in for free.

The over-specific signature

This function only ever reads its argument, yet it demands an owned String:

1
2
3
fn greeting(name: String) -> String {
    format!("Hello, {name}!")
}

Now a caller with a string literal — the most common case — has to allocate a whole String just to satisfy the type:

1
let g = greeting("Ferris".to_string()); // pointless allocation

Worse, a caller who only has a borrow (say, a field of someone else’s struct) is stuck: they must .clone() or .to_owned() before they can call you, even though you never keep the value.

Take the borrow

If the body only reads, take &str. Deref coercion means &String and string literals both coerce to &str automatically:

1
2
3
4
5
6
7
fn greeting(name: &str) -> String {
    format!("Hello, {name}!")
}

let owned = String::from("Ferris");
assert_eq!(greeting("Ferris"), "Hello, Ferris!"); // literal, no alloc
assert_eq!(greeting(&owned), "Hello, Ferris!");    // &String coerces to &str

Zero allocations at the call site, and every kind of caller just works.

Same rule for slices

The exact parallel exists for vectors. Taking &Vec<T> locks callers into owning a Vec; taking &[T] accepts a Vec, an array, or any slice:

1
2
3
4
5
6
7
8
9
fn total(nums: &[i32]) -> i32 {
    nums.iter().sum()
}

let v = vec![1, 2, 3];
let a = [4, 5, 6];
assert_eq!(total(&v), 6);       // &Vec<i32> coerces to &[i32]
assert_eq!(total(&a), 15);      // array coerces too
assert_eq!(total(&v[1..]), 5);  // a sub-slice — impossible with &Vec

&Vec<T> couldn’t accept that array or that sub-slice at all. &[T] is strictly more flexible and costs nothing.

The general principle

Borrow the least specific type that still does the job: &str over &String, &[T] over &Vec<T>, &Path over &PathBuf. Owned types in arguments are for when the function actually needs to store the value. If it only reads, hand it the borrow — the caller keeps their allocation, and your function works with more types for free.

#190 Jun 2026

190. Return Cow<str> — Allocate Only When You Actually Change Something

An escaping or normalizing function usually has nothing to do — the input is already clean. Returning String forces an allocation anyway. Return Cow<str> and the common path stays a borrow.

The wasteful version

A function that escapes HTML returns String, so every caller pays for an allocation — even the overwhelming majority whose input contains nothing to escape:

1
2
3
4
5
6
fn escape_html(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

"hello world" has no special characters, yet replace still walks the string three times and hands back a fresh String. In a template renderer or a parser running this over thousands of fields, that’s thousands of pointless heap allocations.

Borrow on the fast path

Cow<str> lets one return type be either a borrow or an owned String. Check first; only allocate when there’s real work:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use std::borrow::Cow;

fn escape_html(input: &str) -> Cow<str> {
    // Fast path: nothing to escape, hand back the original borrow.
    if !input.contains(['&', '<', '>']) {
        return Cow::Borrowed(input);
    }

    // Slow path: build the escaped String exactly once.
    let mut out = String::with_capacity(input.len());
    for c in input.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(c),
        }
    }
    Cow::Owned(out)
}

The clean input never touches the heap; the dirty input allocates once instead of three times:

1
2
3
4
5
6
let clean = escape_html("hello world");
assert!(matches!(clean, Cow::Borrowed(_))); // zero allocation

let dirty = escape_html("a < b & c");
assert!(matches!(dirty, Cow::Owned(_)));
assert_eq!(dirty, "a &lt; b &amp; c");

Callers don’t notice

Cow<str> derefs to &str, so anything that reads the result just works — no .unwrap(), no matching:

1
2
3
4
5
fn render(field: &str) -> usize {
    escape_html(field).len() // Cow derefs to &str
}

assert_eq!(render("plain"), 5);

And when a caller genuinely needs ownership, .into_owned() allocates only if it’s still borrowed:

1
2
let owned: String = escape_html("safe").into_owned();
assert_eq!(owned, "safe");

The rule: any function that might return its input unchanged — escaping, trimming, normalizing, path canonicalization — should return Cow<str>, not String. The signature tells the caller “I’ll borrow when I can,” and the body only reaches for the heap on the path that earns it.