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.

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