Skip to main content
  1. Posts/

Ownership in Rust 3. Clone guilt and the Republic of types

·9 mins
Rafael Fernandez
Author
Rafael Fernandez
Mathematics, programming, and life stuff
Ownership in Rust - This article is part of a series.
Part 3: This Article

The Jedi Order had a dogma problem. Their rules were designed to prevent the dark side, and for centuries they worked. But the rules calcified into absolutes. “Attachment leads to suffering” became “never form bonds.” “Discipline prevents the dark side” became “suppress all emotion.” When the galaxy needed pragmatism, the Jedi could only offer doctrine.

The Rust community has its own version of this: clone guilt.

The previous posts covered the mechanics: what clone does, what it costs, and the six ownership strategies for sharing state. This post closes the series with the question nobody benchmarks: is the fear of clone doing more harm than the clone itself?

The anatomy of clone guilt
#

It works like this:

  1. You are fighting the borrow checker.
  2. Lifetimes are propagating through three struct layers.
  3. You reach for .clone().
  4. It compiles.
  5. You feel guilty. “I should have figured out the lifetimes.” “A real Rustacean would not need this.” “The borrow checker is supposed to prevent copies, and I just bypassed it.”
  6. You spend 30 minutes refactoring to eliminate the clone.
  7. The code is harder to read, the structs have lifetime parameters, and every API that touches them is more constrained.
  8. You saved 50 nanoseconds per call.

This is not an exaggeration. It happens in code reviews, in conference talks, in Reddit threads. Someone posts working code with a .clone(). The first comment: “you can avoid that clone by restructuring to use references.” The restructuring is proposed without measuring whether the clone matters.

The guilt comes from a real place. Rust’s culture of zero-cost abstractions teaches you that every allocation is a failure. The borrow checker exists to avoid unnecessary copies. The compiler practically screams when you try to use a moved value. Clone feels like circumventing the safety system.

But there is a difference between respecting a principle and turning it into dogma.

The numbers nobody benchmarks
#

Operation                        Approximate cost
────────────────────────────────────────────────────
Clone an i32                     < 1 ns
Clone a PathBuf (50 bytes)       ~50 ns
Clone a small Config struct      ~100 ns
Clone a Vec<u8> (1 KB)           ~100 ns
Arc::clone()                     ~5-10 ns
────────────────────────────────────────────────────
A syscall (read/write)           ~1-10 μs
A context switch                 ~1-10 μs
Clone a Vec<u8> (1 MB)           ~100-500 μs
────────────────────────────────────────────────────
An HTTP request (local)          ~1-10 ms
A database query                 ~1-50 ms
Reading 1 MB from disk           ~1-10 ms
An HTTP request (remote)         ~10-100 ms
────────────────────────────────────────────────────

A PathBuf clone costs ~50 nanoseconds. A database query costs ~1-50 milliseconds. The ratio is 20,000x to 1,000,000x. Spending 30 minutes to eliminate a 50 ns clone in code that makes a database call is optimizing noise. You would get better performance by optimizing the query itself, or by batching I/O, or by caching results.

When someone tells you “avoid that clone” without profiling data, ask: “what is the hot path here, and how does this clone compare to the I/O?”

If the clone is inside a tight loop processing millions of items with no I/O, it matters. Measure it, optimize it, remove it.

If the clone is in an initialization path, a request handler that calls a database, or a TUI event loop that redraws at 60 fps, it is invisible in any profile. The guilt is wasted.

The clippy lint: right tool, wrong doctrine
#

Clippy has a lint called redundant_clone:

let s = String::from("hello");
let t = s.clone();  // warning: redundant clone — `s` is not used after this
drop(t);

This lint is correct and useful. If the original value is not used after the clone, a move is strictly better: zero cost, same result. The lint catches a genuine waste.

The problem is what the lint reinforces culturally. It trains Rust developers to associate “clone” with “warning.” Every yellow squiggle under .clone() reinforces the instinct that cloning is wrong. The lint says “this specific clone is redundant.” The developer hears “cloning is bad.”

Clippy does not have a lint for “you spent 30 minutes adding lifetime parameters to avoid a 50 ns clone.” It does not warn about struct Processor<'a, 'b> when a simple struct Processor { config: Config } would have been clearer. The tool is asymmetric: it flags unnecessary clones but not unnecessary complexity.

Use the lint. Remove redundant clones. But do not let it become your general theory of ownership.

When clone beats lifetimes
#

There is a class of code where avoiding the clone makes everything worse:

// Without clone: three layers of lifetime parameters
struct Processor<'a> {
    config: &'a Config,
    name: &'a str,
}

struct Server<'a> {
    processor: Processor<'a>,
}

struct Application<'a> {
    server: Server<'a>,
}

// Every function that creates an Application needs the Config to outlive it:
fn run<'a>(config: &'a Config) -> Application<'a> { /* ... */ }

Now try to spawn Application into a thread or an async task:

tokio::spawn(async move {
    application.run().await;  // ERROR: Application borrows Config, requires 'static
});

The lifetime blocks you. The fix options:

  1. Restructure ownership so Application owns Config by value.
  2. Use Arc<Config> and thread it through every layer.
  3. Clone the config and let Application own its copy.

Option 1 is ideal if you are designing the API from scratch. Option 2 makes sense if Config is expensive. Option 3 is correct when Config is 200 bytes and the engineering cost of options 1 or 2 exceeds the value they provide.

The Jedi Order would say “never clone.” Qui-Gon Jinn would say “clone the config, ship the feature, and revisit if it ever shows up in a profile.”

The formal theory: why Rust makes duplication visible
#

There is a mathematical reason why Rust treats clone the way it does, and understanding it transforms clone from a concession into a deliberate design tool.

Affine types: use at most once
#

Rust’s ownership model is an affine type system. In type theory, affine types enforce a simple rule: every value can be used at most once. You can consume it (move) or discard it (drop), but you cannot silently use it twice.

This is distinct from linear types (use exactly once; Rust allows dropping unused values, so it is affine, not linear) and from unrestricted types (use as many times as you want; this is every garbage-collected language).

The three structural rules
#

In linear logic (the mathematical framework underlying linear types), classical logic’s free use of values is governed by three structural rules:

Rule What it allows Status in Rust
Weakening Discard a resource without using it Allowed (values can be dropped)
Contraction Duplicate a resource Forbidden by default
Exchange Reorder resources Allowed

The critical rule is contraction: the ability to use a resource more than once. In Java, Python, Go, every value assignment is an implicit contraction: the runtime silently shares or copies references, and you never think about it.

Rust forbids contraction by default. When you write let y = x, the value moves. x is gone. This is the affine discipline: at most once.

Clone as the explicit reintroduction of contraction
#

Clone is not a workaround. It is the controlled reintroduction of contraction into an affine type system.

When a type implements Clone, the type author declares: “this value can be meaningfully duplicated.” When you write .clone(), you are exercising that declaration at a specific point in the code.

The key word is explicit. In Java:

Config config2 = config;  // Shares a reference. Silent. Invisible.

In Go:

config2 := config  // Copies the struct. Silent. Invisible.

In Rust:

let config2 = config.clone();  // Duplicates the value. Visible. Intentional.

The Rust version tells you three things that the Java and Go versions do not:

  1. Duplication is happening. Not sharing, not moving. A new, independent copy exists.
  2. It has a cost. The type implements Clone, which means it does something: allocation, copy, refcount bump.
  3. It is deliberate. The programmer chose this point to duplicate. It can be found, reviewed, profiled, and questioned.

That visibility is the feature. Not the burden.

Copy: when contraction is so cheap it can be implicit
#

Copy types are the exception that proves the rule. For types where duplication is a bitwise memcpy, integers, floats, booleans, small tuples of Copy types, Rust allows implicit contraction:

let x: i32 = 42;
let y = x;  // Copy: implicit contraction. x is still valid.

Copy is the language saying: “the cost of making this explicit would exceed the value of the visibility.” Four bytes copied on the stack is not worth writing .clone() for. But the type must opt in, and the constraints are strict: no heap allocations, no Drop implementation. The implicit contraction is only allowed when it is guaranteed to be trivial.

The balance
#

The Jedi Order’s mistake was not having rules. It was treating rules as ends rather than means. The Rule of Two was not inherently wrong; it was wrong when rigidly applied to Anakin, whose situation required nuance.

Clone guilt follows the same pattern. The principle, “avoid unnecessary copies,” is sound. The dogma, “every .clone() is a code smell,” is not. The principles serve you. You do not serve the principles.

When to feel the guilt
#

  • Cloning a Vec<u8> with a million elements inside a hot loop.
  • Cloning a HashMap with 10,000 entries on every HTTP request.
  • Cloning when a move would work (let Clippy catch this).
  • Cloning because “I cannot figure out the lifetimes” when the lifetimes encode a real invariant you should preserve.

When to let the guilt go
#

  • Cloning a config struct to avoid three layers of lifetime parameters.
  • Cloning a PathBuf wrapper to satisfy an API that takes ownership.
  • Cloning a small value to pass into an async move block.
  • Cloning when the alternative is Rc<RefCell<>> for a struct that holds 50 bytes.

The takeaway
#

Clone is not a failure mode. It is the explicit reintroduction of contraction into an affine type system. Every language allows duplication; Rust is the one that makes you acknowledge it.

The question is never “should I clone?” in the abstract. It is always:

  • What is inside the struct? (50 bytes or 50 megabytes?)
  • Where does this clone happen? (hot loop or initialization?)
  • What is the alternative? (simple lifetimes or three layers of Arc<Mutex<>> for a PathBuf?)

Know your types. Measure before optimizing. And stop treating a 50-nanosecond operation like a disturbance in the Force.

Reference:

Ownership in Rust - This article is part of a series.
Part 3: This Article