Refactoring Large Functions
robert.muth.org
robert.muth.org
function doFirstBit() {}
function doSecondBit() {}
function big() {
let x,y,z;
[x,y] = doFirstBit();
[x,z] = doSecondBit(x, y);
}
to: function big() {
let x,y,z;
// First bit...
...
// Second bit...
...
}
To me, it's no harder to find the portion of interest in the latter when making a local change, and substantially easier to understand the whole thing (because you can sit down, maybe with a printout if that's your thing, and read through it linearly without having to jump back and forth to the subfunctions). And passing data between the portions can quickly end up nastier than my contrived example above, at which point people start doing things like creating a class just to hold what used to be the function's stack-frame.Not saying very long functions are something to celebrate. But if they're organised sensibly they're not a huge problem, and to be honest probably bother me less than functions which aren't abstracting anything terribly interesting and only get called once.
With a printout? Well, if you allow functions to be so long that you have to print them out, then you probably a lot of other problems with your code base.
The trouble comes from functions where the various bits are all mixed together. This means that comments cannot easily name the sections (for me, one of the main advantages of a function is that it must be given a name), and you cannot easily narrow your reading to just the part you care about.
Another advantage of smaller functions is that some of them end up being general enough functionality to be worth sharing with other code.
Finally, it's possible to test doFirstBit() independently of the other logic in big().
However, I prefer to put `doFirstBit` and `doSecondBit` below `big`, since that way the file is laid out in the order that the code is typically executed. That makes the two code snippets even more similar.
* Each function has well-defined inputs and outputs, no weird dependencies between parts.
* Parts can be reused when needed, and tested independently.
* Separating functions makes you think about good names, and good sets of inputs and outputs. This alone uncovers a few classes of bugs and mental mistakes.
You honestly think having someone adding a sub-function as a dependency and adding more tests is 'good'
Yes.
As an extreme case, one time I made a function with a single line that just delegated to a builtin function. But it had 5 lines of docstring explaining what it was doing in terms of business requirements and 5 lines of comments explaining how the builtin function accomplished that.
Before a few changes like this, the mixture of abstraction levels (what I was doing vs how I was doing it) was a big impediment for me. I would stare at the code for hours trying to figure out if it was correct or not and only being able to hold all the complexity in my head at the very peaks of concentration.
But yeah that was an unusual case.
But I'd add that large function like this tempt the programmer to do something like using one of the intermediate variables in the first "bit" to make a decision in the second "bit" out of convenience. Add ten more "bits" and ten more such "conveniences" and you get an incredibly fragile, bug-prone mess.
Which is to say, a purely lexical-order division of operations is bad because you easily wind-up with undefined defined interface between the various operations. Figuring out both how a simple for-loop really works as well as how the original programmer intended it to work, can be quite hard. Add several for-loops and you get a true mess.
Some people get really good at cranking out these long functions and as you say, it look superficially easier. I wonder if a language could support "functional abstraction brackets" that would allow these long sections but guarantee their logic isn't folded together
Something like
function big() {
let x,y,z;
{{@First_bit //begin forced separation...
...
{{@Second_bit //begin forced
...
}
Here "First_bit" would be guaranteed to be equivalent to functions of just x, y, z. function big() {
let x,y,z;
{
// First bit
let localToThisSection = 42;
...
}
{
// Second bit
...
}
}If you wanted a syntax that exactly mirrored what happens in the gp's second example, you'd need more work. Plus if you force a begin-bracket to have a label, you avoid the editor having to use "fun3756" when automatically abstracting out the subroutine.
function big(x, y, z) {
const firstBitResult = ((x, y, z) => {
...
})(x, y, z)
const secondBitResult = ((x, y, z) => {
...
})(x, y, z)
...
}
I've used this trick when hacking out on quick-hack-turns-into-humongous-mess type projects.A high level function forms the conceptual idea, it contains many functions that are specify that concept, and within each are implementations.
If each function does one thing, it composes other functions, you're forced to follow this type of design.
Big methods that do many things inline with commented sections explaining what the section does, is bad programming. It allows variable reuse and hidden weird dependencies between what should be independent chunks of code in independent functions. It also forces the programmer reading it to always work at the lowest level of implementation rather than a higher level of abstraction where he can see the flow without needing to see all the details of each step. If you can point to a section of code, and put a comment on it saying it does X, then it belongs in a function named X and the comment needs deleted. Comments that explain what code does are a code smell. Comments are for explaining why, not what.
There shouldn't be any big functions to begin with except in a few rare cases like switching on a character or something.
function big(a,b,c)
Into this: nameableIntermediateState = smaller(a, b)
finishUp(c, nameableIntermediateState
This is better because there were things you were doing with A and B that had nothing to do with C.That’s the ideal final state, where your big function doesn’t even know about C. However, refactoring often has to happen in phases, and so a pass through like the kind you wrote is sometimes a good intermediate step.
It’s about teasing apart names into smaller islands that the names never escape. If you are passing names between islands, there’s no value.
http://number-none.com/blow/john_carmack_on_inlined_code.htm...
And I easily agree 100% with him having tried both approaches in bigger projects.
But he's also huge on "don't fight the compiler" and static analysis, all of which are aided by strictly pure funcs, so it seems in-character.
The actual computations around what changed can be pure, but turning them into a concurrently operating loop is done most effectively with a static sequencing(and hence, straightline imperative).
Or in other terms, he is using a different strategy for different parts of the codebase. At the top level it's imperative, but when you drill down into the callstack, it isn't.
A lot of folks work on request-response systems all day, and in those, the necessary state changes tend to be once-per-request and wrapped in transaction or session logic. So it's a lot less crucial to have this kind of strategy for debugging purposes.
>Indeed, if memory serves (it's been a while since I read about this)...
>
>The fly-by-wire flight software for the Saab Gripen (a lightweight
>fighter) went a step further. It disallowed both subroutine calls and
>backward branches, except for the one at the bottom of the main loop.
>Control flow went forward only. Sometimes one piece of code had to leave
>a note for a later piece telling it what to do, but this worked out well
>for testing: all data was allocated statically, and monitoring those
>variables gave a clear picture of most everything the software was doing.
>The software did only the bare essentials, and of course, they were
>serious about thorough ground testing.
>
>No bug has ever been found in the "released for flight" versions of that
>code.
>
> Henry SpencerThen I wait, and wait; until I can't stand it any more, at which point I usually know enough to substantially improve the code.
Assuming it survived that long. Refactoring code that you're going to throw away is not very constructive. And the more effort put into making code pretty, the harder it will be to let go when that's the right thing to do.
None of this works in a corporate setting, where there's never enough time or money to do anything properly. There are no awesome short term profits to be made from prototyping and keeping code in good health. Corporations also usually prefer rigid rules over competence and intuition, which means whatever cure they come up with will be worse than the disease. And as a result, corporate code is usually crap code.
Make a copy of current function as old_function and add unit test to check if input on refactored function still produces same results as old_function.
About batches, could you be more specific, i might be missing something here.
And for fuzzing, it will test your new function, but will not provide any guarantees that your new functions is working exactly like the old one. You might be surprised, bet there could be bugs in old function which can be fixed by refactoring. Sounds nice? But might be some legacy codes is depending on the bug that you fixed, and for backwards compatibility reasons that bug must stay be renamed from "bug" to "functionality".
for parameters in parameter_generator(n=100):
assert(old_version(*parameters) == new_version(*parameters),
f"Versions produced different outputs on {parameters}")
This lets you transform old_version with more confidence that you're not corrupting its logic.Some studies of code quality from the 1980s reported that defects per KLOC was inversely correlated with function length, leveling off around 200–400 LOC per function. Software composed of many tiny functions is more difficult to understand because there is more context "off screen" to keep in your head.
A Cisco study of code reviews found that 200–400 LOC is the limit of how many LOC can be effectively reviewed per hour. Applying the findings of these studies suggests that neither functions nor patches/pull-request diffs should not exceed 200 LOC. FWIW, I have worked on commercial software that had functions many thousands of lines long! :)
The long function has a lot of comments that try to describe the different steps. The comments are often confusing because whoever wrote the comments couldn't quite understand themselves what is happening.
There is a significant frequency of bugs found in the functionality implemented or coordinated by that function.
The long function has poor code coverage.
For me, a long function is usually a code smell.