Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
What will I learn
- You will learn what
Cow<'a, B>is: a smart pointer that is either borrowed or owned, and lets you defer that choice to runtime; - how it avoids allocating and cloning until the exact moment you actually need to mutate;
- how
to_mutperforms the clone-on-write, and howinto_ownedforces ownership out; - how to design a function that returns its input untouched in the common case and a modified copy only when needed;
- when
Cowgenuinely earns its keep, and when it just adds a lifetime and some noise for no gain.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous thirty-five episodes, especially the smart-pointer family --
Box,RcandRefCell(episode 12), interior mutability (episode 32) -- plus ownership, borrowing and lifetimes (episode 10); - The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
- Learn Rust Series (#35) - Weak References & Breaking Reference Cycles
- Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs (this post)
Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
For the last handful of episodes we have been touring the standard library's smart-pointer family. We opened Box back in episode 12, we pried Rc apart in episode 33, we made it thread-safe with Arc in episode 34, and only last time we hung Weak off the side of both to break reference cycles. Today's type belongs to the same neighbourhood, but it answers a completely different question. It is not about sharing a value and it is not about counting owners. It is about a single, very ordinary tension that shows up in almost every program you will ever write: sometimes a function can hand back a reference to its input untouched, and sometimes it must hand back a modified, owned copy -- and you do not know which until you look at the data.
Cow is the standard library's answer to that tension. The name stands for "clone on write", and it is, I argue, one of the most quietly elegant types std ships. Without it you are stuck choosing between two bad options up front: either you always allocate and return an owned String (wasteful when the input was already fine), or you complicate every signature with a pile of conditional branches. Cow lets you borrow when you can and own only when you must, and it makes that choice a runtime value rather than a compile-time commitment. But first, as always, let me clear last episode's homework ;-)
Solutions to Episode 35 Exercises
Episode 35 was weak references and breaking reference cycles. There were three exercises, and here is full runnable code for each.
Exercise 1 asked you to build a two-node structure where each node holds a RefCell<Option<Rc<Node>>>, link them into a strong cycle, give Node a Drop impl that prints, and confirm the destructors never fire:
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
other: RefCell<Option<Rc<Node>>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("dropping a node");
}
}
fn main() {
let a = Rc::new(Node { other: RefCell::new(None) });
let b = Rc::new(Node { other: RefCell::new(None) });
// a points at b, b points back at a: a strong cycle.
*a.other.borrow_mut() = Some(Rc::clone(&b));
*b.other.borrow_mut() = Some(Rc::clone(&a));
println!("a strong = {}", Rc::strong_count(&a)); // 2
println!("b strong = {}", Rc::strong_count(&b)); // 2
// main ends: each count falls from 2 to 1, never to 0.
// Neither "dropping a node" line ever prints -- both nodes are LEAKED.
}
The key insight, and the whole reason last episode existed, is that each count gets stuck at exactly one. When main ends, the a and b bindings drop, which takes each strong count from two down to one -- but no further, because a's allocation is still referenced by b, and b's is still referenced by a. Two allocations propping each other up, unreachable from your code, never freed.
Exercise 2 wanted you to rewrite that structure so one direction is a Weak in stead of an Rc, keep the same Drop impl, and confirm the destructors now DO run:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
other: RefCell<Weak<Node>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("dropping a node");
}
}
fn main() {
let a = Rc::new(Node { other: RefCell::new(Weak::new()) });
let b = Rc::new(Node { other: RefCell::new(Weak::new()) });
*a.other.borrow_mut() = Rc::downgrade(&b); // weak, not strong
*b.other.borrow_mut() = Rc::downgrade(&a);
println!("a strong = {}", Rc::strong_count(&a)); // 1, not 2
// main ends: BOTH "dropping a node" lines print. No leak.
}
One word changed -- Rc::clone became Rc::downgrade, and the field type went from Option<Rc<Node>> to Weak<Node> -- and the leak is gone. Because a Weak touches only the separate weak count, a's strong count reads one, not two, so when the binding drops it reaches zero and the destructor fires.
Exercise 3 asked you to extend the parent-child tree with a parent_value(&self) -> Option<i32> method that upgrades the weak parent pointer and returns the parent's value, or None at the root:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
impl Node {
fn parent_value(&self) -> Option<i32> {
self.parent.borrow().upgrade().map(|p| p.value)
}
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("{:?}", leaf.parent_value()); // Some(5)
println!("{:?}", branch.parent_value()); // None (root has no parent)
}
The upgrade().map(...) chain is the whole idiom: the weak pointer might point at a value that has already been freed, so you upgrade to an Option<Rc<Node>> and only reach for .value inside the Some. Right, homework cleared -- now let us meet the cow ;-)
Cow is borrowed or owned
Cow<'a, B> is an ordinary two-variant enum. One variant, Borrowed, holds a reference &'a B. The other, Owned, holds a fully owned value. It lives in std::borrow, and simplified down to its essence it looks like this:
// Simplified from the standard library (std::borrow::Cow):
enum Cow<'a, B: ToOwned + ?Sized> {
Borrowed(&'a B),
Owned(<B as ToOwned>::Owned),
}
The ToOwned bound is what makes it work. ToOwned (episode 21 touched the conversion family) is the trait that says "given a borrowed B, I know how to produce an owned version of it". For str the owned type is String; for [T] it is Vec<T>; for Path it is PathBuf. So Cow<str> is "either a &str I am borrowing or a String I own", and Cow<[i32]> is "either a &[i32] or a Vec<i32>". Crucially, Cow implements Deref<Target = B> (episode 19), so no matter which variant you are holding, you can call all of B's read-only methods directly -- .len(), .contains(), iteration -- without ever caring whether you are borrowed or owned.
The classic use is a function that only allocates when the input actually needs changing. A sanitiser that replaces spaces with underscores is the textbook example:
use std::borrow::Cow;
fn sanitize(input: &str) -> Cow<str> {
if input.contains(' ') {
Cow::Owned(input.replace(' ', "_")) // allocate a new String, only when needed
} else {
Cow::Borrowed(input) // no allocation: just borrow the caller's string
}
}
fn main() {
let clean = sanitize("already_clean"); // Borrowed: zero allocation
let fixed = sanitize("needs some fixing"); // Owned: one allocation
println!("{clean}"); // already_clean
println!("{fixed}"); // needs_some_fixing
println!("borrowed? {}", matches!(clean, Cow::Borrowed(_))); // true
}
When the input has no spaces, sanitize returns a Cow::Borrowed that points straight at the caller's own string, allocating precisely nothing. Only when a replacement is genuinely required does it build a fresh String and wrap it in Cow::Owned. If most of your inputs are already clean -- and in real systems they usually are -- this saves an allocation on nearly every single call, while the signature stays honest about the fact that sometimes it does own.
to_mut: the clone happens here
So far we have only read through a Cow. The "write" in clone-on-write is triggered by exactly one method: to_mut, which hands you a mutable reference to the owned data. If the Cow is already Owned, you simply get a mutable borrow of what is already there, for free. If it is Borrowed, to_mut first clones the borrowed data into an owned value, swaps the Cow over to the Owned variant, and then gives you the mutable reference:
use std::borrow::Cow;
fn main() {
let mut data: Cow<[i32]> = Cow::Borrowed(&[1, 2, 3]);
println!("len before: {}", data.len()); // reading through Deref: still borrowed, no clone
data.to_mut().push(4); // FIRST mutation: clones the slice into an owned Vec, then pushes
println!("{:?}", data); // [1, 2, 3, 4]
println!("owned now? {}", matches!(data, Cow::Owned(_))); // true
}
Before the to_mut call, data is borrowing a slice and not one byte has been allocated. Reading .len() goes straight through Deref and touches the borrowed slice directly. It is the data.to_mut().push(4) that upgrades the borrowed &[i32] into an owned Vec<i32> so there is something mutable to push onto. This is the heart of the pattern: you pay for ownership only at the exact moment you first write, and not a moment sooner. If a given code path never mutates, the clone never happens at all.
into_owned: force ownership out
Sometimes reading is not enough and borrowing will not do -- you need to leave the borrowing world entirely and hold a value that outlives the input it came from. into_owned consumes the Cow and returns the owned type, cloning if it happened to be Borrowed and simply handing the value over if it was already Owned:
use std::borrow::Cow;
fn first_word(text: &str) -> Cow<str> {
match text.split_whitespace().next() {
Some(word) => Cow::Borrowed(word), // a slice into the input, no allocation
None => Cow::Owned(String::new()), // nothing there: an owned empty String
}
}
fn main() {
let owned: String = first_word("hello world").into_owned();
println!("{owned}"); // hello
}
Returning a Cow from the function keeps it allocation-light on the hot path, and then lets each individual caller decide whether they need to escape into an owned String. A caller that just prints the result never pays; a caller that needs to stash it in a struct for later calls into_owned and pays exactly once. Lowercasing is another perfect fit, since quite some strings you meet are already lowercase, so you should only allocate for the ones that are not:
use std::borrow::Cow;
fn lower_if_needed(s: &str) -> Cow<str> {
if s.chars().any(|c| c.is_uppercase()) {
Cow::Owned(s.to_lowercase()) // only allocate when there is uppercase to fix
} else {
Cow::Borrowed(s)
}
}
fn main() {
println!("{}", lower_if_needed("already")); // borrowed, no allocation
println!("{}", lower_if_needed("MixedCase")); // owned -> mixedcase
}
Borrow-or-own in one signature
A very common shape, once you start noticing it, is a function that ensures some property: it borrows the input unchanged if the property already holds, and returns an owned, fixed-up copy otherwise. Notice the lifetime 'a tying the borrowed output back to the input -- the Owned branch does not use it, but the type still carries it because the Borrowed branch might:
use std::borrow::Cow;
fn ensure_prefix<'a>(s: &'a str, prefix: &str) -> Cow<'a, str> {
if s.starts_with(prefix) {
Cow::Borrowed(s) // already correct: no allocation
} else {
Cow::Owned(format!("{prefix}{s}"))
}
}
fn main() {
let a = ensure_prefix("/home", "/"); // borrowed
let b = ensure_prefix("home", "/"); // owned
println!("{a} {b}"); // /home /home
}
Cow as a flexible input, not just an output
We have used Cow as a return type, which is where it shines brightest, but it is just as handy as a parameter type when a function wants to accept "either a borrowed or an owned string, whatever you have lying around, and I will only copy if I must". Because both &str and String implement Into<Cow<str>>, you can write a single generic bound that accepts both with zero fuss at the call site:
use std::borrow::Cow;
fn greet<'a>(name: impl Into<Cow<'a, str>>) {
let name = name.into();
println!("Hello, {name}!");
}
fn main() {
greet("Alice"); // &str -> Cow::Borrowed, nothing allocated
greet(String::from("Bob")); // String -> Cow::Owned, reuses the existing buffer
}
The caller passing a &str pays nothing, and the caller who already has a String hands over the buffer it already built in stead of forcing a needless copy. This is the pattern a lot of builder-style APIs use to stay ergonomic without being wasteful.
Where the standard library itself uses Cow
This is not some exotic corner of std you will never meet in the wild. The standard library reaches for Cow in exactly the "usually unchanged, occasionally not" places we have been describing. The clearest example is String::from_utf8_lossy, which turns a byte slice into text: if the bytes are already valid UTF-8 it borrows them directly, and only if there is an invalid byte to replace does it allocate a new String:
use std::borrow::Cow;
fn main() {
let valid = &[104u8, 105]; // the bytes for "hi", already valid UTF-8
let s: Cow<str> = String::from_utf8_lossy(valid);
println!("{s} borrowed? {}", matches!(s, Cow::Borrowed(_))); // hi borrowed? true
let invalid = &[104u8, 105, 0xFF]; // 0xFF is not valid UTF-8
let s2: Cow<str> = String::from_utf8_lossy(invalid);
println!("borrowed? {}", matches!(s2, Cow::Borrowed(_))); // false -- it had to allocate
}
Valid input, no allocation, borrowed straight out of the slice; invalid input, one allocation to insert the replacement character. You get the fast path for free and the correct path when you need it, and the return type tells the honest truth about both. String and path normalisation, escaping and un-escaping, config-default filling -- these are the natural homes for Cow, and it is no accident that std decorates precisely these APIs with it.
When Cow is worth it (and when it is not)
Cow shines when three things are true at once: the function is called often, it usually returns its input unchanged, and it only occasionally needs to modify. When that profile holds, dodging the allocation on the common path is a real, measurable win, and Cow buys it without complicating the caller.
Having said that, it is not free of cost, and I do not mean runtime cost -- I mean cognitive cost. The return type now carries a lifetime 'a, which can ripple out into your own signatures and structs; every caller has to reason, at least a little, about borrowed versus owned; and a Cow field in a struct drags that lifetime along with it. So do not go sprinkling Cow over every function that returns a string out of some vague sense that it is "more efficient". If the code path is not hot, or if it almost always has to allocate anyway, a plain String return is simpler, has no lifetime, and is the honest choice. Cow is a precision tool for the specific case where the common path should avoid allocation -- reach for it when profiling or clear intuition points there, and reach for a plain owned type everywhere else.
How Python sees this
Since quite some of you came to Rust from the Learn Python Series, a glance sideways is illuminating, because Python has the spirit of clone-on-write baked in, but hands it to you invisibly and without any type-level signal. Python strings are immutable, and every value is a reference, so a function that returns its input "unchanged" really does return the very same object, no copy made:
def sanitize(s):
if " " in s:
return s.replace(" ", "_") # a brand-new string object is allocated
return s # the SAME object, returned as-is, no copy
a = "already_clean"
b = sanitize(a)
print(a is b) # True -- same object, nothing was allocated
c = sanitize("needs fixing")
print(c, c is a) # needs_fixing False -- a fresh string
That a is b printing True is Python's version of Cow::Borrowed: the unchanged path handed back the identical object and allocated nothing, while the changed path built something new, just like Cow::Owned. The difference is that Python makes this decision for you at runtime with no way to see it in a signature, and its interpreter and garbage collector carry the machinery to make it safe. Rust instead makes the borrowed-or-owned choice a visible part of the type: Cow<str> states, right there in the return type, that the answer might be borrowed and might be owned, and the borrow checker guarantees you never hold a borrowed one longer than the data it points at. Same underlying idea -- avoid copying until you must -- but Rust promotes it from an invisible runtime optimisation to a checked, explicit contract. I argue that is the better bargain for systems code, where knowing exactly when an allocation happens is half the job.
What did we actually learn?
Cow<'a, B>is a two-variant enum:Borrowed(&'a B)orOwned(<B as ToOwned>::Owned). Forstrthe owned side isString, for[T]it isVec<T>, forPathit isPathBuf.- It implements
Deref<Target = B>, so you can call all ofB's read-only methods regardless of which variant you hold, without knowing or caring which it is. - The pattern is borrow when you can, own only when you must: return
Cow::Borrowedon the unchanged path (zero allocation) andCow::Ownedon the path that has to modify. to_mutis where clone-on-write actually happens: it hands you a&mutto the owned data, cloning a borrowed value into an owned one first if needed. You pay for the clone only at the first write.into_ownedconsumes theCowand gives you the owned value, cloning if it was borrowed -- the way to escape the borrow and hold the value for longer.Cowworks as a flexible parameter too, viaimpl Into<Cow<str>>, accepting both&strandStringand only copying when it has no choice.- The standard library uses
Cowin exactly these spots, e.g.String::from_utf8_lossy, which borrows valid UTF-8 and only allocates to fix invalid bytes. - Use it when a hot function usually returns its input unchanged; do not sprinkle it everywhere -- the extra lifetime and mental overhead are only worth paying on the paths that actually matter.
Exercises
Three exercises, from gentle to chewier. Type them yourself before the next episode -- that is where the understanding really sticks.
- Write
fn trim_trailing(s: &str) -> Cow<str>that returnsCow::Borrowed(s)when the string has no trailing whitespace, and aCow::Ownedtrimmed copy only when it does. Test it on"clean"(should stay borrowed) and"messy "(should become owned), confirming each variant withmatches!. - Take a
Cow<[i32]>that starts asCow::Borrowed(&[10, 20, 30]). Read its length twice and confirm (withmatches!) that it is stillBorrowedafter the reads. Then callto_mut().push(40)and confirm it has flipped toOwned. - Write
fn ensure_extension<'a>(name: &'a str, ext: &str) -> Cow<'a, str>that borrowsnameunchanged if it already ends withext, and otherwise returns an ownedStringwithextappended. Then, in a second step, useinto_ownedto store the result in a plainStringfield of a struct that has no lifetime parameter at all.
Bedankt voor het lezen, en tot de volgende keer! ;-)