Software Engineering · Refactoring

Extract Function and the Preconditions the Compiler Checks

Extraction is presented as a mechanical edit, but it is only safe when the extracted body captures no free variables, no control flow, and no implicit receiver.

Extract function is the first refactoring anyone learns and the one most often applied without checking. The idea is simple enough to state in a sentence: move a contiguous run of statements into a new function and replace the run with a call. The sentence omits every condition that makes the move valid, and each omitted condition corresponds to a class of bug that a reviewer has to catch by reading.

The conditions are not stylistic. They follow from what a function boundary does to a program’s environment, and they can be enumerated exactly.

What a function boundary changes

A function call establishes a new scope. Names that were reachable at the call site are, inside the callee, reachable only through parameters. That single fact generates the first precondition: the extracted body may reference no variable that the new function does not receive.

Take a body that reads two locals and writes a third:

struct item { int n; int *v; int w; };

int process(struct item *it, int limit) {
    int total = 0;
    for (int i = 0; i < it->n; i++) {
        if (it->v[i] > limit) continue;
        total += it->v[i] * it->w;
    }
    /* candidate body: the loop above */
    return total;
}

Extraction produces a function with three inputs and one output: it, limit, and total. The input total is the awkward one, because the loop both reads and writes it. If the extracted function takes total by value and returns the new total, the call site becomes total = sum_under(it, limit, total). If it takes a pointer, the signature becomes void sum_under(struct item *it, int limit, int *total). Both are correct; the first states the data flow in the signature and the second avoids a copy on every call. Either way the extraction forced a decision that the one-line description concealed.

The general rule: collect the set of variables read or written by the body but declared outside it, which is the free-variable set, and make each one a parameter, a return value, or both. A body with a large free-variable set is a signal that the surrounding function is doing too much, not that the extraction should be abandoned.

The receiver is a free variable too

In an object-oriented language, every unqualified member access inside a method reads the implicit receiver. Moving the body out of the class either passes this explicitly or makes the new function a method on the same class, which is usually the right answer. Moving it to a static helper that takes the receiver as its first argument changes dispatch: a call that previously went through a virtual method now resolves at the call site, and if a subclass overrode that method, the extracted code calls the base version.

That last point is not hypothetical. It is the standard way an extract-method refactoring silently changes behavior in a hierarchy: the body referenced this.compute(), dispatch resolved dynamically against the actual object, and the extracted static helper calls a specific implementation.

Captured state in closures

In a language with closures, extraction into a nested function captures the enclosing scope by reference, so the free-variable problem disappears. It is replaced by a lifetime problem. If the extracted closure escapes, the captured variables must outlive the frame that declared them, and a language without a borrow checker will let that happen.

fn make_accumulator() -> impl FnMut(i32) -> i32 {
    let mut total = 0;                       // owned by this frame
    move |x| { total += x; total }           // moved into the closure
}

Without move, the closure would borrow total, and returning it would produce a borrow-checker error rather than a dangling reference. Languages that permit the equivalent without complaint are the ones where this refactoring class produces use-after-free bugs.

Control flow that does not survive the move

Statements can be moved; jumps cannot, at least not for free. A body containing return, break, continue, goto, or a throw that the caller was expected to handle has an exit path that leads somewhere in the original function. Once the body is in a callee, those jumps either terminate the callee, which is a different destination, or must be encoded in the return value.

The encodings are mechanical and each one costs something:

Original constructEncoding after extractionCost
return x mid-bodyReturn x, caller returns itNone, if the extraction covers the tail
Early return in a loop bodyReturn a sentinel or an optionalCaller must re-test the sentinel
break out of an inner loopReturn a flag, caller breaksExtra branch per iteration
continueReturn a flag, caller continuesExtra branch per iteration
throw handled by caller’s tryPropagate, or return an error valueChanges exception to value in the second case

Extraction of a loop body containing continue is the most common instance. The naive move produces a callee whose internal continue applies to the callee’s own loop, not the caller’s, and the loop terminates on the first skipped item. The fix is a boolean return, and the fix changes the shape of the code enough that the diff no longer looks mechanical.

A second control-flow condition is subtler. A body that assigns to a variable which the code after the body reads creates an output that must be returned. A body that assigns to a variable which the code before it also assigned is a data dependency the extraction must respect: if the body reads the variable before writing it, the incoming value is an input; if it only writes, the incoming value is irrelevant and the parameter can be dropped.

Side effects and the order of evaluation

Two expressions with side effects, evaluated in a fixed order, cannot be split across a call boundary without preserving that order. Consider a body that calls log() and then mutates a global. Extraction preserves the order as long as both stay inside the same callee. Extraction that moves only the mutation out and leaves the call behind reorders them.

The same hazard appears with short-circuit operators. A body containing a() && b() evaluates b() only when a() is true. Passing both as arguments to the extracted function evaluates both unconditionally. The extraction is only valid if the callee receives thunks or if the short circuit is rebuilt inside it.

C’s sequence points make this concrete. Between two sequence points, the order of evaluation of function arguments is unspecified, so a call f(g(), h()) may run g before h or after. If the extracted function’s arguments carry side effects that the original statements ordered explicitly, the extraction introduced an ordering the language does not guarantee.

Making the edit mechanical

The safe procedure is to compute the preconditions before touching the code, and to let the compiler and the test suite confirm them afterwards.

import ast, textwrap

def free_names(fn_src, extra_bound=()):
    """Names the function reads or writes that it neither binds nor receives."""
    fn = ast.parse(textwrap.dedent(fn_src)).body[0]
    bound = {a.arg for a in fn.args.args} | set(extra_bound)
    used = set()
    for node in ast.walk(fn):
        if isinstance(node, ast.Name):
            if isinstance(node.ctx, ast.Store):
                bound.add(node.id)            # assigned here, so local
            else:
                used.add(node.id)
        elif isinstance(node, ast.arg):
            bound.add(node.arg)
    return sorted(used - bound)

The function above is a small static approximation: it treats every assignment target in the function as a local, which is correct for Python’s rule that a name assigned anywhere in a function is local throughout it, and it ignores comprehension scopes, global and nonlocal declarations, and attribute access. That is enough to answer the question that matters before extraction, which is whether the free set is small enough to pass as parameters. A free set of two or three is an extraction. A free set of fifteen is a signal that the candidate body is not cohesive.

After the edit, three checks confirm what the analysis predicted. The compiler rejects unresolved names, which catches missing parameters. The type checker catches arity and type mismatches at call sites. The test suite catches the semantic differences that neither can see, such as a reordered side effect or a continue that became a return.

Where extraction stops being a refactoring

Extraction changes the call stack. Anything that observes the stack observes a difference, so a program that generates diagnostics from a stack trace will produce different output. In a language with tail calls this is sometimes free; in most languages it is not.

Extraction also changes what the compiler can inline. A body that was inside a hot loop is now behind a call, and whether the call disappears depends on the optimizer’s budget. Under the definition of equivalence in The Equivalence Relation That Refactoring Depends On, this is permitted, because cost is outside the observation set. Under a latency target it may not be, and the honest response is to say so rather than to discover it later.

The refactoring that most often follows extraction is a change in how the new function is called, not in what it does. Once a body has a name and a signature, it can be moved behind an interface, overridden, or replaced wholesale, which is the mechanism described in Branch by Abstraction and the Cost of Two Live Paths.

References

  1. Refactoring: Improving the Design of Existing Code, second edition — Martin Fowler, Addison-Wesley, 2018
  2. The Java Language Specification, Java SE 17 Edition — James Gosling, Bill Joy, Guy Steele, Gilad Bracha, Alex Buckley, Oracle
  3. Programming Language Pragmatics, fourth edition — Michael L. Scott, Morgan Kaufmann, 2015