I registered a study before looking at the data: of the long methods that Extract Method can't fix, how many can function injection take apart? I said at least a quarter. The answer is a bit over one in five. I was wrong, and this post is about what the 580 repositories showed instead, because what they showed turned out to be more useful than being right: a way to classify the methods no split reaches, and from that three studies to refine the tools down to the few methods that are a real concern, then to check whether the same measures hold on AI-written code. The full paper, with every number generated by script, is What extraction cannot slim, and whether function injection can.
The short version
- Between a fifth and a quarter of long methods can't be fixed by Extract Method. Across 3 million methods in 580 Java projects, 5.6% of methods are long (26 code lines or more), and 22.8% of the long methods are stuck: no set of extractions gets the method under the cap without a return escaping a piece, a piece needing to hand back two values, or a piece needing more than five parameters.
- Function injection takes apart a bit over one stuck method in five, not one in four. 22.2% of the stuck methods, interval 20.3% to 24.3%, so my hypothesis is refuted. Function injection is not a general fix for stuck methods.
- Nearly half of the stuck methods are stuck only because of early returns. Guard clauses, a
returninside anif. Extract Method can't take a piece that returns. A flow can, and the flow took apart three in four of those methods without inverting a single guard. - Where Extract Method works, a sequence of functions is a little cheaper about a third of the time, and only a little. The methods where functions win are pipelines: stages in order, each stage using what the stage before produced. The saving is one or two values per method. That saving is not a reason to adopt a framework. That saving is a reason to write the pipeline as functions if you're already in one.
- The quarter of stuck methods that nothing splits is mostly not logic. A third of that quarter is tables and literals: giant switches of short cases, array initialisers, builder chains, argument lists. Most of the rest of that quarter is dense code where a jump lives inside a loop or a
try, or where every cut would need too many values; a smaller part is a nested block too big to cut, such as an else-if chain or atryinside atry.
What was measured
580 open-source Java repositories, drawn at random by a separate tool with a seed fixed in advance, each repository pinned to the last commit before December 2020 so that none of the code was written with an AI assistant. OfficeFloorSlim, built on SinewSlim, scanned every production .java file and reported, for each long method, three things:
- whether SinewSlim can slim the method by extraction alone, pricing each cut by what crosses the cut (how that pricing works);
- whether the method comes apart as injected functions, where a return ends a function and a function can hand on several values;
- the split a developer would actually make, called the combined split: functions where the flow divides, extracted methods inside the functions, priced by the sum of both.
The costs were fixed in those tools before the sample was drawn. The study computes no cost of its own, because I wrote the tools and the framework, and a cost I could tune would be worth nothing.
| Count | Share | |
|---|---|---|
| Methods with a body | 3,026,902 | |
| Long (26 code lines or more) | 168,466 | 5.6% of methods |
| Extract Method slims the method | 130,033 | 77.2% of long |
| Stuck | 38,433 | 22.8% of long |
Those shares were the same whether a project had 200 stars or 50,000.
What the stuck methods are
Each stuck method was put in one bucket, testing the conventional fix first, so that function injection gets credit only for the methods nothing simpler fixes:
| The stuck method… | Count | Of stuck |
|---|---|---|
| would slim by extraction if the method's early returns didn't exist | 17,123 | 44.6% |
| slims by extraction if a record carries two values back | 3,727 | 9.7% |
| only function injection takes the method apart | 8,524 | 22.2% |
| nothing splits the method | 9,059 | 23.6% |
The 22.2% is the registered answer, and 22.2% is under the quarter I set. But look at the first row. "Would slim if the early returns didn't exist" is not a refactoring you can apply. To get rid of the early returns you invert the guards, thread a flag through nested ifs, or extract a piece that returns a flag the caller tests. Most developers would call the first two a worse design than the long method. The combined split handles the return as the return stands, and the combined split took apart 76% of those early-return methods without restructuring them. Of the stuck methods the conventional fixes can't reach at all, the combined split takes apart about half. That is the clearest thing in the data: single-exit extraction can't deal with an early return, and a flow can.
Where Extract Method already works
130,033 long methods can be slimmed by Extract Method alone. For each of those, the combined split was free to choose: keep the method as a method and extract pieces from the method, or cut the method into injected functions with extracted pieces inside the functions. The combined split chose functions, and the functions cost less than extraction, for 29% of those methods. Measured against plain extraction with no framework wrapper at all, the functions cost less for 17%. The median saving was 2. So the gain from functions is real, small, and specific to one shape of method.
The shape is a pipeline: 96% of the methods where functions cost less are straight sequences, stage after stage with no branching, each stage using what the stage before produced. Here is why a pipeline is cheaper as functions and other shapes are not. Extract Method prices a value twice when the value crosses a cut: once on the way out of the piece that produced the value, as a return, and once on the way into the piece that reads the value, as a parameter. Extract Method also prices the method's own parameters into every extracted piece that reads them. A flow between functions prices a value once, in the function that declares the value as a parameter, and a flow never prices the method's own parameters, because the framework injects them into whichever function needs them. The consequence: a boundary that one computed value crosses costs the same under both. Where two or three values cross one boundary together, or where a method parameter such as options is read in every stage, the flow is cheaper than the extraction. Where a boundary carries nothing at all, the flow still costs 1 and the extraction costs 0, so the extraction is cheaper. That is the whole mechanism, and the paper works two short examples through the formulas term by term.
The stuck methods that only injection takes apart have the opposite shape: 64% of those methods branch, an if or switch whose arms each do real work, usually with an exit inside an arm. A branch with an exit inside is exactly where a single-exit extracted piece fails, and exactly where a decision function with a flow per arm does not.
The quarter that nothing splits
9,059 stuck methods have no plan from either side. After the registered results were in, I re-read all 9,059 methods from source to see what the methods are. This re-reading wasn't registered; the re-reading is there to decide what the tools should do with the methods they stop at, which is the programme at the end of this post.
- A third of the 9,059 are tables and literals (3,030 methods): switches of short cases, flat lookup switches, array initialisers, long argument lists, builder chains, string concatenations, constructors that only call
super(…). The cognitive complexity of these methods is about zero. These methods are long because they hold data or options, not logic. If your linter flags one of these methods, the linter is flagging a table, and arguably the linter shouldn't. - Most of the rest of the 9,059 is dense. In 91% of the methods blocked by a jump, the jump is inside a loop, a
tryor aswitch, and a step that breaks out of the loop enclosing that step can't become a function. Where anifblocks the split, the obstacle is the values the branches pass on, not the branching. And raising the five-value limit helps less than you'd think: a limit of six rescues 4.4% of the 9,059, a limit of ten rescues 12.5%, and a third of the data-coupled methods have no plan even at ten. - Shape, not complexity, keeps a method on this list. At the same length, the 9,059 methods are no more complex than the methods injection rescues.
- The list clusters. 32 projects hold 40% of the 9,059 from 22% of the long methods. The tables cluster hardest, which looks like generated code or a house style of building data in code. The dense logic is spread evenly across projects, which looks like how people write.
What this means for the method you're looking at
| The method looks like | What 580 projects say |
|---|---|
| Stages in order, each stage using what the last stage produced, no early exits | Extract Method works. Written as a sequence of functions the method is cheaper by a value or two. Write the sequence if you're already in a framework that sequences functions. Don't adopt a framework for that saving. |
| Guard clauses and early returns you don't want to invert | This shape is where extraction is stuck and a flow isn't. Three in four of these methods came apart as the methods stand. If the choice is inverting your guards or letting a framework end a function at the return, this is the case for the framework. |
An if or switch whose arms each do a lot, with exits inside the arms | A decision function with a flow per arm takes the method apart where a single-exit piece can't. |
| A giant switch of short cases, an array literal, a builder chain | The method is a table. Nothing splits a table and nothing should. Leave the table, or move the data out of code. |
A long loop body with a break or return in the body, a try with resources | Nothing splits this method today, by extraction or by injection. Redesign the method, or leave the method and say why. |
The one line to take away: function injection takes apart a bit over one stuck method in five, and nearly all of the methods function injection wins are early returns and pipelines. If your long methods are neither, function injection has nothing for you. What the study has for you instead is the table above: a long method is now one of a handful of shapes, each with a known fix or a known reason there isn't one, and the programme below is about making the tools say which.
Where injection did worse, and what to be careful about
Every failure is published. Where both extraction and the injection split have a plan, the injection split's worst seam carries more values than extraction's widest piece for 4.5% of methods, 4,993 of them, each method listed. The injection split also cuts more: more functions than extraction makes pieces for two thirds of methods. And the seam comparison leans towards injection by definition, since a function's seam doesn't count the method's parameters and a piece's width does.
"Stuck" means stuck under SinewSlim's rules: one exit, one return value, at most five parameters a piece. An IDE can extract a run with an early return by returning a flag the caller tests; SinewSlim doesn't search that form of extraction. Nobody wrote any of this code for function injection, so a plan is what the split would cost, not what anyone paid. And I created OfficeFloor and both tools, and have a commercial interest in OfficeFloor's adoption, which is why the sample was drawn by someone else's seed, the costs were fixed at or before registration, and the hypothesis was written down where the data could refute the hypothesis. The data did.
What's next
Three follow up studies to consider, each one a change to the tools, each one narrowing what the next has to explain, each registered the way this study was: a dated commit of the plan and the seed in a public repository before the sample is drawn, with scripts anyone can rerun. I'm a tool and framework developer trying to understand how software behaves, not an academic. The repository is the registration.
- The classes become rules in the tool. The re-reading of the 9,059 methods used explicit thresholds: a switch whose cases are all three lines or fewer is a flat table, a chain of four or more calls is a builder, eight or more arguments is a long argument list, a jump is placed by the loop or
trythat contains the jump, and a data-coupled method is marked by the first limit at which a plan appears. Those rules go into SinewSlim, so a method the split can't plan gets a class and the advice that fits the class: leave this table where it is or move the data to configuration, extract this case, extract this loop body with the jump as a status result, group what this stage produces. Two tests: a fresh sample drawn the same way gives the same class shares, within this study's intervals; and reviewers agree with the class and the advice, class by class, with the tables first, because if reviewers don't call a table a long method, the trigger changes. - Each class gets the change that reaches the class, and what's left is the definition of a god method. A jump inside a loop,
tryorswitch(1,128 methods): the splitter learns named outcomes, so thebreak,continueorreturnbecomes a named exit of the function, wired in configuration. Anifwhose arms pass values on (267 methods): the arms become functions with the value as the flow's argument. Data coupling (3,237 methods): the limit of five is swept from three to ten over the whole population, because a higher limit moves the other categories too. Each change is tested on the methods already listed and read by two numbers: how many of the class now split, and whether what came out got denser, since a split that slims by growing denser hasn't helped. What remains when every class has had the change that reaches the class is a method over the cap that no split and no rule-following rewrite takes apart. That's a definition of a god method with no human judgement in it, and the hypothesis is that the remainder is under one long method in a hundred, dense logic, and the one place the notice should say "redesign". - The same measures on AI-written code. Everything here predates assistants. The third study draws the same kind of sample from code written after assistants were in general use and compares the long share, the stuck share, the class mix and the remainder against the figures here, which are the baseline. What the measures don't say, and I'll keep saying so: nothing about how methods couple to each other or whether the code changes well. They confirm decomposition, and only that.
Run it yourself
Everything is a script in long-method-slim-study, and every number in the paper is generated from the committed analysis.
If you find a method where the split does worse that isn't in the list, or a reason the split fails that isn't in the table, I want to hear about it.
No comments:
Post a Comment