736 comments

[ 7.0 ms ] story [ 452 ms ] thread
This applies to user experience as well. I've seen designers focus on number of items or number of clicks when mental effort / cognitive load is what matters. Sometimes picking from a list of 50 links is easier. Sometimes answering 7 yes/no questions is easier.
> The companies where we were like ”woah, these folks are smart as hell” for the most part failed

Being clever, for the most part, almost never buys you anything. Building a cool product has nothing to do with being particularly smart, and scaling said product also rarely has much to do with being some kind of genius.

There's this pervasive Silicon Valley throughline of the mythical "10x engineer," mostly repeated by incompetent CEO/PM-types which haven't written a line of code in their lives. In reality, having a solid mission, knowing who your customer is, finding that perfect product market fit, and building something people love is really what building stuff is all about.

At the end of the day, all the bit-wrangling in the world is in service of that goal.

Depends on how you define smart. I worked at a place where income was directly tied to the quality of the ML models. Building what people love wouldn't have been the best strategy there.
Only if your goal is to be an entrepreneur. Not everyone chases that goal nor considers success in that fashion.
We can measure and quantify this cognitive load! I’ve been researching this for a book and have found some really cool research from ~10 years ago. It seems people stopped thinking about this around when microservices became popular (but they have the same problems just with http/grpc calls instead).

There are two main ways to measure this:

1. Cyclomatic/mccabe complexity tells you how hard an individual module or execution flow is to understand. The more decision points and branches, the harder. Eventually you get to “virtually undebuggable”

2. Architectural complexity measures how visible different modules are to each other. The more dependencies, the worse things get to work with. We can empirically measure that codebases with unclear dependency structures lead to bugs and lower productivity.

I wrote more here: https://swizec.com/blog/why-taming-architectural-complexity-...

The answer seems to be vertical domain oriented modules with clear interfaces and braindead simple code. No hammer factory factories.

PS: the big ball of mud is the world’s most popular architecture because it works. Working software first, then you can figure out the right structure.

I’ve been interested in the same topic for a while now and the most difficult part, when explaining the concept to other programmers and defending against it in coding standards/reviews, is how to prove that cognitive load exists.

Cyclomatic complexity seems one indicator, but architectural complexity needs to be clarified. I agree that how much modules expose to each other is one trait, but again, needs clarification. How do you intend to go about this?

Been thinking about custom abstractions (ie those that you build yourself and which do not come from the standard libraries/frameworks) needed to understand code and simply counting them; the higher the number, the worse. But it seems that one needs to find something in cognitive psychology to back up the claim.

> Cyclomatic complexity seems one indicator, but architectural complexity needs to be clarified. I agree that how much modules expose to each other is one trait, but again, needs clarification. How do you intend to go about this?

Too much to summarize in a comment, I recommend reading the 3-blog series linked above. Architectural complexity is pretty well defined and we have an exact way to measure it.

Unfortunately there’s little industry tooling I’ve found to expose this number on the day-to-day. There’s 1 unpopular paid app with an awful business model – I couldn’t even figure out how to try it because they want you to talk to sales first /eyeroll.

I have some prototype ideas rolling around my brain but been focusing on writing the book first. Early experiments look promising.

There IS backing from cognitive research too – working memory. We struggle to keep track of more than ~7 independent items when working. The goal of abstraction (and this essay’s cognitive load idea) is to keep the number of independently moving or impacted pieces under 7 while working. As soon as your changes could touch more stuff than fits in your brain, it becomes extremely challenging to work with and you get those whack-a-mole situations where every bug you fix causes 2 new bugs.

This is true and valuable, but it's worth mentioning that some aspect of cognitive load is subjective. The code I write is always lower cognitive load to me than anyone else's code, even if my code has more cyclomatic complexity and code smells, because I've built up years of neural representations dedicated to understanding my favored way of doing things via practice and repetition. And I lack the neural representations needed to quickly understand other people's code if they approach things differently, even if their approach is just better.

This is not to say we should keep practicing our bad habits, but that we should practice good habits (e.g. composition over inheritance) as quickly as possible so the bad habits don't become ingrained in how we mentally process code.

Looks like a solid post with solid learnings. Apologies for hijacking the thread but I’d really love to have a discussion on how these heuristics of software development change with the likes of Cursor/LLM cyborg coding in the mix.

I’ve done an extensive amount of LLM assisted coding and our heuristics need to change. Synthesis of a design still needs to be low cognitive load - e.g. how data flows between multiple modules - because you need to be able to verify the actual system or that the LLM suggestion matches the intended mental model. However, striving for simplicity inside a method/function matters way less. It’s relatively easy to verify that an LLM generated unit test is working as intended and the complexity of the code within the function doesn’t matter if its scope is sufficiently narrow.

IMO identifying the line between locations where “low cognitive load required” vs “low cognitive load is unnecessary” changes the game of software development and is not often discussed.

With LLM generated code (and any code really) the interface between components becomes much more important. It needs to be clearly defined so that it can be tested and avoid implicit features that could go away if it were re-generated.

Only when you know for sure the problem can't be coming through from that component can you stop thinking about it and reduce the cognitive load.

Agreed.

Regarding some of the ‘layered architecture’ discussion from the OP, I’d argue that having many modules that are clearly defined is not as large a detriment to cognitive load when an LLM is interpreting it. This is dependent on two factors, each module being clearly defined enough that you can be confident the problem lies within the interactions between modules/components and not within them AND sharing proper/sufficient context with an LLM so that it is focused on the interactions between components so that it doesn’t try to force fit a solution into one of them or miss the problem space entirely.

The latter is a constant nagging issue but the former is completely doable (types and unit testing helps) but flies in the face of the mo’ files, mo’ problems issue that creates higher cognitive loads for humans.

> I’d really love to have a discussion on how these heuristics of software development change with the likes of Cursor/LLM cyborg coding in the mix

I would also be interested in reading people’s thoughts about how those heuristics might change in the months and years ahead, as reasoning LLMs get more powerful and as context windows continue to increase. Even if it never becomes possible to offload software development completely to AI, it does seem at least possible that human cognitive load will not be an issue in the same way it is now.

One way that I explain cognitive load to people unfamiliar with the term is to imagine crossing a lawn that has both autumn leaves and dog poop, and picture how much more mental energy one expends when trying to not step on dog poop.
Cognitive load is precisely why I love feature rich languages. Once you have internalized a language the features it has fall away in terms of cognitive load for me. In the same way I don't think about how to ride a bike while I'm riding a bike.

In most cases having a simpler language forces additional complexity into a program which does noticable add to cognitive load.

I think this works only up to the point where the language gets too large and starts creating extra cognitive load all by itself. For me, C++ is a good example of a language that has too many bells and whistles, if I have to stop what I'm doing to look up some weird syntax construct, then having all those extra features stops being useful.
I don't think largeness is the problem. It's language design. C++ is just really badly designed. I'd be very happy with a very large language that takes a long time to get familiar, if all the features in the language are well designed. IMO the current developer landscape is all about "fast onboarding", but that is the totally wrong metric to optimize for. To me it's the difference between someone walking and an airplane. Sure it's very easy to just start walking, you ain't going to go anywhere fast. On the other hand an airplane takes orders of magnitude longer to get going but once it does you won't ever catch up to it by walking.
I think this is a good point. If you learn a language and it's useful, you usually use it for many, many years. So long as the daily driving experience is great, onboarding doesn't have to be that important of a metric.
OMG, so much this.

One of the biggest sources of cognitive load is poor language design. There are so many examples that I can't even begin to list them all here, but in general, any time a compiler gives you an error and tells you how to fix it that is a big red flag. For example, if the compiler can tell you that there needs to be a semicolon right here, that means that there does not in fact need to be a semicolon right there, but rather that this semicolon is redundant with information that is available elsewhere in the code, and the only reason it's needed is because the language design demands it, not because of any actual necessity to precisely specify the behavior of the code.

Another red flag is boilerplate. By definition boilerplate is something that you have to type not because it's required to specify the behavior of the code but simply because the language design demands it. Boilerplate is always unnecessary cognitive load, and it's one sign of a badly designed language. (Yes, I'm looking at you, Java.)

I use Common Lisp for my coding whenever I can, and one of the reasons is that it, uniquely among languages, allows me to change the syntax and add new constructs so that the language meets the problem and not the other way around. This reduces cognitive load tremendously, and once you get used to it, writing code in any other language starts to feel like a slog. You become keenly aware of the fact that 90% of your mental effort is going not towards actually solving the problem at hand, but appeasing the compiler or conforming to some stupid syntax rule that exists for no reason other than that someone at some time in the dim and distant past thought it might be a good idea, and were almost certainly wrong.

Totally agree. I think the biggest and most important things a language designer chooses is what to disallow. For instance, private/package/public etc is one small example of an imposed restriction which makes it easier to reason about changing a large project because if e.g. something is private then you know it's okay and probably easy to refactor. The self-imposed restrictions save you mental effort later. I also love lisps but am a Clojure fan. This is because in Clojure, 90+% of the code is static functions operating on immutable data. That makes it extremely easy to reason about in the large. Those two restrictions are big and impose a lot of structure, but man I can tear around the codebase with a machete because there are so many things that code /can't do/. Also, testing is boneheaded simple because everything is just parameters in to those static functions and assert on the values coming out. I don't have to do some arduous object construction with all these factories if I need to mock anything, I can use "with-redefs" to statically swap function definitions too, which is clean and very easy to reason about. Choosing the things you mr language disallows is one of the most important things you can do to reduce cognitive load.
When the code needs to do something that it can't do, there is a massive cognitive load associated with figuring out how to do something approximating it. When the language or a framework is associated with lots of "how do I X" questions, the answers to which are all "completely rethink your program", that is evidence that the language/framework is not reducing cognitive load.
I'm optimizing for a large, complex corporate projects, not beginners on toy projects. And I would also add that if you search for "how do you do X in Y language", you'll probably find every combination of a lot of languages so I hardly think that is grounds to dismiss Clojure. More and more languages seem to be embracing immutability and static functions are everywhere. The two are just utilized well and believe me, if you need to refactor something then you are so much more at liberty which is certainly a high bar IMHO.
>I'm optimizing for a large, complex corporate projects, not beginners on toy projects.

There's nothing about large, complex corporate projects that demands that languages impose arbitrary restrictions on code, except the fact that so many corporations insist on hiring incompetent fools as programmers, often to save money in the short run by expanding the potential labor pool. They call them "guardrails", but a better metaphor would be the playpen. If you hire only competent developers, then you don't need to put them in a playpen.

>And I would also add that if you search for "how do you do X in Y language", you'll probably find every combination of a lot of languages so I hardly think that is grounds to dismiss Clojure.

Well yeah, it's pretty much the norm in popular programming languages to make certain things impossible. And programming is driven by fads, so we're going to see more and more of this until it finally goes out of fashion one day and some other fad comes along.

Please elaborate what programming language you think isn't a fad and isn't a playpen and why. Restrictions on languages IMHO clearly narrow what you have to think about other code doing. Whether it's marking a field "const" in C++ or having the borrow checker in Rust or having private fields in Java or immutability in Clojure, all those things make it easier to know what a large system of code /cannot/ do, and that makes it easier to maintain. That has nothing to do with people other than it might be you fighting years of code that you wrote yourself.
I disagree with the first point. Say, the compiler figured out your missing semicolon. Doesn't mean it's easy for another human to clearly see it. The compiler can spend enormous compute to guess that, and that guess doesn't even have to be right! Ever been in a situation where following the compiler recommendation produces code that doesn't work or even build? We are optimizing syntax for humans here, so pointing out some redundancies is totally fine.
> Doesn't mean it's easy for another human to clearly see it.

Why do you think that matters? If it's not needed, then it should never have been there in the first place. If it helps to make the program readable by humans then it can be shown as part of the rendering of the program on a screen, but again, that should be part of the work the computer does, not the human. Unnecessary cognitive load is still unnecessary cognitive load regardless of the goal in whose name it is imposed.

In languages (both natural and machine languages) a certain amount of syntax redundancy is a feature. The point of syntax "boilerplate" is to turn typos into syntax errors. When you have a language without any redundant syntactical features, you run the risk that your typo is also valid syntax, just with different semantics than what you intended. IMHO, that's much worse than dealing with a missing semicolon error.
Can you provide an example where syntax that’s required to be typed and can be accurately diagnosed by the compiler can lead to unintended logic? This is not the same thing as like not typing curly braces under an if directive and then adding a second line under it.
> Can you provide an example where syntax that’s required to be typed and can be accurately diagnosed by the compiler can lead to unintended logic?

I'm not sure we are on the same page here. I'm saying the absence of redundant syntax of the sort that lets the compiler accurately diagnose 'trivial' syntax errors, that can create scenarios where a typo can give you unintended valid syntax with different logic.

So yes, the conditional shorthand in C would be an example. Getting rid of the braces means you lose an opportunity for the compiler to catch a 'trivial' syntax error, which can lead to different semantics than what the writer intended.

Yes, these are different things, which is why I discounted curly braces before. Those are not required for an if statement’s scope. Semicolon’s are “required” everywhere. The compiler can easily spot where one should be by parsing an invalid expression because it encounters illegal tokens to append onto the end of a valid expression, eg you cannot have one statement that contains two assignment operators at the same level of precedence.

However for curly brances around a conditional lexical scope, the compiler cannot tell you where the closing brace should be, besides before the end of the lexical scope that contains it, like the end of the containing function or class. There can be multiple valid locations before that: every other valid line of code. This is not the same as a semicolon, which must end every line of code.

Can you provide another example?

> then it can be shown as part of the rendering of the program on a screen

I disagree with this, and can most easily express my disagreement by pointing out that people look at code with a diversity of programs: From simple text editors with few affordances to convey a programs meaning apart from the plain text like notepad and pico all the way up to the full IDEs that can do automatic refactoring and structured editing like the Jet Brains suite, Emacs+Paredit, or the clearly ever-superior Visual Interdev 6.

If people view code through a diversity of programs, then code's on-disk form matters, IMO.

Sure, but nothing stops you from looking at the raw code. Consider looking at compiled code. You can always hexdump the object file, but have a disassembly helps a lot.
People's choice of editor is influenced by what they're editing. For example, virtually every Lisp programmer uses Emacs, even though there are many alternatives out there, including VS Code plugins. And virtually every Java programmer uses a JetBrains IDE or something similar. I'd probably install an IDE if I had to work on a Java codebase. Editing with a diversity of programs isn't universal.
(comment deleted)
Aside from the other good points, this thread is about cognitive load. If a language lets you leave off lots of syntactic elements & let the compiler infer from context, that also forces anyone else reading it to also do the cognitive work to infer from context.

The only overhead it increases is the mechanical effort to type the syntax by the code author; they already had to know the context to know there should be two statements, because they made them, so there's no increased "cognitive" load.

I guess I didn't make this clear. I'm not advocating for semicolons to be made optional. I'm saying that they should not be included in the language syntax at all unless they are necessary for some semantic purpose. And this goes for any language element, not just semicolons.

The vast majority of punctuation in programming languages is unnecessary. The vast majority of type declarations are unnecessary. All boilerplate is unnecessary. All these things are there mostly because of tradition, not because there is any technical justification for any of it.

The point generalises beyond semicolons; everything you leave to context is something other people have to load up the context for in order to understand.

Consider Python; if there are the optional type hints, those can tell you the third parameter to a function is optional. If those are missing, you need to dive into the function to find that out; those type hints are entirely optional, and yet they reduce the cognitive load of anyone using it.

I haven’t used type hints in Python, but can what you’re describing lead to situations where the code cannot run and the interpreter gives you a suggestion on how to fix it?
Type hints have no runtime impact, so they can't make stuff not work.

Type linters like mypy can check your code & report something like "this function call requires str, you're providing str | None" though.

>The point generalises beyond semicolons; everything you leave to context is something other people have to load up the context for in order to understand.

This is not true, because an editor can add any on-screen hints that are needed to help a human understand the code. For example, in my editor, Python code gets vertical lines that indicate where the different indentation levels are, so I can easily see when two lines of code far apart on the screen are at the same indentation level, or how many indentation levels lower the next line is after a long, highly-indented block. Python could add an end-of-block marker like Ruby does to make things like this easier to see, or it could try to encode the vertical lines into the language somehow, but I'd derive no benefit because the editor already gives me the visual clues I need.

The full stop at the end of your last sentence isn’t strictly needed. It isn’t even strictly needed in the preceding sentences (except for the first one with the question mark), because the capitalization already indicates the beginning of the next sentence. We still use full stops because redundancy and consistency help preventing errors and ambiguities. Reducing the tolerances to zero increases the risk of mistakes and misinterpretations.

Ideally, adding/removing/changing a single character in valid source code would always render the code invalid instead of “silently” changing its meaning.

> The full stop at the end of your last sentence isn’t strictly needed.

yes, that's true | but this redundancy is not necessary | it's there for historical reasons | there are other ways to designate the separation between sentences | some of those alternatives might even make more sense than the usual convention

The point was that the full stop is currently a redundant element in most cases, yet we would not want to omit it just for the reason of being redundant.

The spaces in your “ | ” punctuation are also not strictly needed, yet one would want to keep then for readability and for risk of otherwise mistaking an “|” for an “l” or similar.

Again, something not being strictly needed isn’t a sufficient argument to do without it. There are trade-offs.

> The spaces in your “ | ” punctuation are also not strictly needed.

yes.that.is.true|spaces.are.not.strictly.needed.at.all|there.are.alternatives.and.there.are.situations.where.using.those.alternatives.actually.makes.sense|however.the.use.of.whitespace.is.so.deeply.ingrained.that.if.you.dont.do.it.the.rendering.of.your.text.will.generally.be.very.annoying.on.contemporary.systems

The Right Answer is to separate the underlying representation from the rendering. We already do this to some extent in modern systems. For example, the meaning of text generally doesn't change if you change the font. This is not always true. The font can matter in math, for example. And some font modifications can carry semantic information -- using italics can provide emphasis, for example.

The Right Way to design a programming language is to have an underlying unambiguous non-redundant canonical representation, and then multiple possible renderings that can be tailored to different requirements. Again, we kinda-sorta do that in modern systems with, for example, syntax coloring. But that's just a half-assed hack layered on top of deeply broken language designs.

Considering all the "tabs or spaces" flamewars and standardized formatting as with gofmt for Go code, I think this would get restricted at most professional codebases to some person's favored style. Not sure that's a good reason, but it's worth considering. For projects that are solo or along those lines, feel free.
You're being disingenuous. Your suggestion is more like if you wrote

yes, that's true but this redundancy is not necessary it's there for historical reasons...

without any breaks. That might be exaggerating compared to your actual position, but surely you can see that "unnecessary in this situation" doesn't imply "unnecessary overall". "Not necessary" if we're cherrypicking, sure.

If my program now has no semicolons and then I write something else that behaves differently than expected, I'm going to be sad. My mental model for programming fares better when semicolons are used, so I will favor writing programs with semicolons. To me, the cost is trivial and the benefit, while minimal, outweights the cost. I consider it separate from actual boilerplate. You can disagree and use other languages, but then we're probably being moreso opinionated than divided into better or worse camps.

> That might be exaggerating compared to your actual position

To the point of being a straw man.

There was actually a time when neither white space nor punctuation was used andallwordswerejustruntogetherlikethis. Note that it's still possible to decipher that text, it just takes a bit more effort. Natural language is inherently redundant to a certain extent. It's mathematically impossible to remove all redundancy (that would be tantamount to achieving optimal compression, which is uncomputable).

The spaces around the vertical bars in my example were redundant because they always appeared before and after. That is a sort of trivial redundancy and yes, you can remove it without loss of information. It just makes the typography look a little less aesthetically appealing (IMHO). But having something to indicate the boundaries between words and sentences has actual value and reduces cognitive load.

---

[1] https://en.wikipedia.org/wiki/Kolmogorov_complexity#Uncomput...

I think you forgot the analogy. Why is it bad to have semicolons in programs then?

> You become keenly aware of the fact that 90% of your mental effort is going not towards actually solving the problem at hand, but appeasing the compiler or conforming to some stupid syntax rule that exists for no reason other than that someone at some time in the dim and distant past thought it might be a good idea, and were almost certainly wrong.

You said this originally. I definitely agree for something like parentheses in if conditions in Java, but I think semicolons are a great example of how

> having something to indicate the boundaries between words and sentences has actual value and reduces cognitive load.

> Why is it bad to have semicolons in programs then?

It's not bad to have them, it's bad to require them when they aren't necessary. It's bad to make their absence be a fatal syntax error when they aren't necessary. (Some times they are necessary, but that's the exception in contemporary languages.)

Also, I know I'm the one who brought them up, but semicolons are just one small example of a much bigger and more widespread problem. It's a mistake to fixate on semicolons.

> If it helps to make the program readable by humans then it can be shown as part of the rendering of the program on a screen, but again, that should be part of the work the computer does, not the human.

That means you and the compiler front-end are looking at different representations. Sounds doesn't sound like a good idea. Keep it stupid simple: all of our source control tools should work on the same representation, and it should be simpler and portable. Well defined syntax is a good choice here, even if there's some redundancy.

> doesn't sound like a good idea.

It isn't, but that ship sailed when cpp was invented.

> Keep it stupid simple

Could not agree more. That's why I use Lisp.

> It isn't, but that ship sailed when cpp was invented.

The fact that subsequent languages largely avoided that suggests that we can sometimes learn from our mistakes.

No, as python and other languages amply demonstrate, the semicolon is for the compiler, not the developer. If the compiler is sophisticated enough to figure out that a semicolon is needed, it has become optional. That's the OP's point.
But the language spec for Python is what allows for this, not the compiler. \n is just the magic character now except now we also need a \ to make multiline expressions. It’s all trade offs, compilers are not magic
Scala then. Semicolons are optional but you still can have them if you need them
The obvious example would have been JavaScript, but nobody wants to say something positive about JavaScript...
JavaScript has some specific and unique issues. Some silly choices (like auto inserting of semi-colons after empty return) and source code routinely, intentionally getting mangled by minification.
> but nobody wants to say something positive about JavaScript...

For obvious reasons...

> now except now we also need a \ to make multiline expressions.

You never need the backslash in Python to make multiple expressions. There's always a way to do multiline using parentheses. Their own style guidelines discourage using backslash for this purpose.

And you can also do it with triple quotation marks if strings are involved, but it’s still more work for the compiler that someone explicitly did, it’s not magic.
Plain strings work fine. Python has the same behavior as C: If two strings separated by whitespace, it concatenates them. So if I have a long string, I start with an open parenthesis, type one string, go to the next line, and another, and so on over several lines. Python sees it all as one string. Very readable.
If it’s in the language spec as required there and I’m using a compiler that claims to implement that language spec, I want the compiler to raise the error.

Additionally offering help on how to fix it is welcome, but silently accepting not-language-X code as if it were valid language-X code is not what I want in a language-X compiler.

It's not that the semicolon is somehow a special character and that's why it's required/optional. It's the context that makes it necessary or not. Python proves that it's possible to design a language that doesn't need semicolons; it does not mean that e.g. Java or C are well defined if you make semicolons optional.
Yes, semicolons are totally unnecessary. That’s why nobody who works on JavaScript has ever regretted that automatic semicolon insertion was added to the language. It has never prevented the introduction of new syntaxes to the language (like discussed here: <https://github.com/twbs/bootstrap/issues/3057#issuecomment-5...>), nor motivated the addition of awkward grammatical contortions like [no LineTerminator here].
There are plenty of languages that don’t require semicolons and yet manage to avoid those issues: Clojure, Go, Odin…
Clojure delineates everything by explicitly putting statements in parentheses (like any LISP). That's basically the same thing.

Go is an interesting example but it gets away with this by being far stricter with syntax IIRC (for the record, I'm a fan of Go's opinionated formatting).

Funny enough, Go’s grammar does require semicolons. It avoids needing them typed in the source code by automatically adding them on each newline before parsing.
On almost every newline, which is the reason why this doesn't work:

    func thing()
    {
I quite like this approach. It's very simple and consistent, and once you know how it works it's not ever surprising.
Great points. I strongly agree with your first point. Regrettably, I haven't used any language that solves this. (But believe it's possible, and you've demonstrated with one I haven't used).

I'm stuck between two lesser evils, not having the ideal solution you found: 1: Rust: Commits the sin you say. 2: Python, Kotlin, C++ etc: Commits a worse sin: Prints lots of words.. (Varying degrees depending on which of these), where I may or may not be able to tell what's wrong, and if I can, I have to pick it out of a text well.

Regarding boilerplate: This is one of the things I dislike most about rust. (As an example). I feel like prefixing`#[derive(Clone, Copy, PartialEq)]` on every (non-holding) enum is a flaw. Likewise, the way I use structs almost always results in prefixing each field with `pub`. (Other people use them in a different way, I believe, which doesn't require this)

> the compiler can tell you that there needs to be a semicolon right here

I can see that this is an annoyance, but does it really increase cognitive load? For me language design choices like allowing arbitrary arguments to functions (instead of having a written list of allowed arguments, I have to keep it in my head), or not having static types (instead of the compiler or my ide keeping track of types, I have to hold them in my head) are the main culprits for increasing cognitive load. Putting a semicolon where it belongs after the compiler telling me I have to is a fairly mindless exercise. The mental acrobatics I have to pull off to get anything done in dynamically typed languages is much more taxing to me.

Semicolons are just an example, and a fairly minor one. A bigger pet peeve of mine is C-style type declarations. If I create a binding for X and initialize it to 1, the compiler should be able to figure out that X is an integer without my having to tell it.

In fact, all type declarations should be optional, with run-time dynamic typing as a fallback when type inferencing fails. Type "errors" should always be warnings. There should be no dichotomy between "statically typed" and "dynamically typed" languages. There should be a smooth transition between programs with little or no compile-time type information and programs with a lot of compile-time type information, and the compiler should do something reasonable in all cases.

> with run-time dynamic typing as a fallback when type inferencing fails.

I've seen the code that comes out of this, and how difficult it can be to refactor. I definitely prefer strict typing in every situation that it can't be inferred, if you're going to have a language with static types.

It works the other way too. Ive seen plenty of code with strict typing that could have its cognitive load reduced greatly by dynamic typing. A far bigger problem is hidden sideffects and static typing does nothing to fix that
I work in dynamically typed languages a lot, so I don't have many opportunities to feel the way you do. Could you give an example where moving from static to dynamic would reduce cognitive load?

For the opposite example, here's where my preference comes from: I'm editing a function. There's an argument called order, and most likely it's either an instance of some Order class, which has some attribute I need, or it's an integer representing the I'd of such an order, or it's null. I'm hoping to access that attribute, so I have to figure out the type of order.

In a dynamically typed language, I'll have to look at the caller of my function (goes on my mental stack), see where it gets it order from, potentially go to the caller of that function (goes on my mental stack), etc until I hopefully see where order is instantiated and figure out it's type, so I can take the call sites off of my mind, and just keep the type of order in my mental stack.

But actually, this is wrong, because my function is called by way more functions than the ones I examined. So really, all I know now is that _sometimes_ order is of type Order. To be sure, I have to go to _all_ callers of my function, and all their callers, etc. This grows exponentially.

But let's say I manage, and I find the type of order, and keep it in my mind. Then I need to repeat the same process for other arguments I want to use, which is now harder because I'm keeping the type of order on my mental stack. If I manage to succeed, I can go and edit the function, keeping the types of whatever variables I'm using in my head. I pray that I didn't miss a call site, and that the logic is not too complicated, because my mental stack is occupied with remembering types.

Here's how to do this in a statically typed language: read the type signature. Done.

just think of any obvious function where type is obvious. surely writing typing information in those cases is reddundant to the extent that it wouldnt even help with optimisation. but think about something like this:

   bool function1(x y z):
          bool function2(x y z)
immagine function2 besides returning true/false mutates x in some major way. this is a far bigger and more common problem than typing. a dynamic language with capabilities of runtime debugging is far better equiped to inspect code like this

also i am not saying that typing is worse than no type information. im saying that typing should be optional as far as the compiler is concerned and typing information should be like documentation that is useful to the compiler. common lisp can be an example of a language that is both dynamic and strongly typed (see SBCL) to the extent that you can implement a statically typed language (ala ML) in it

> a dynamic language with capabilities of runtime debugging is far better equiped to inspect code like this

Have you used a robust, modern debugger, like IntelliJ or Visual Studio? You can do a whole heck of a lot of very, very robust debugging. You can watch, run commands, run sets of commands, inspect anything in great detail, write new code in the watcher, and so on.

I use them and compared to what I can do with my Common Lisp programs on a far less computationally intensive IDE it is many times poorer. An even more interesting aspect because it is entirely people dependent, is that I find code in an average common lisp project far more readable and understandable than what is regarded as a very good java aplication
to further expand on this, in common lisp i can build up my program dynamically in the repl, allowing me to progrssively make more readable and understandable code after making it do what i want, and then do the final tidying up with type i formation amd necessary documantation when i compile it. i fail to see how any i can program with less cognitive load in any other language, especially strictly static ones
Repls are not unique to dynamically typed languages. Haskell has one.
there are "repls" and repls. even java has a "repl". forget about dynamic/static dychtomy, im yet to see a non-lisp repl. one which provides a true interactive experience with your running program

and even though static languages can have "repls" as an afterthought (eg GHCi), rest assured that their static typing property is a completely unnecessary cognitive load (at least cognitive but very likely a performance one too) to their functioning

Sometimes when people decry lack of typing, it turns out that it's actually a lack of the ability to define a named structure type with named fields, rather than faking one out with an ad hoc hash table representing a collection of properties.
This is why my favorite typing system is Go’s; and apparently now working with Typescript, it also has a similarly powerful and descriptive one.
(comment deleted)
> If I create a binding for X and initialize it to 1, the compiler should be able to figure out that X is an integer without my having to tell it.

There are many integral types, all of which have different properties, often for good reasons.

Sure, but often != always. And if your language forbids type inference then you are burdened with the cognitive load of worrying about types every single time whether there is a good reason in that instance or not.
There's a difference between inference and coercion, and no one here is arguing against inference, as far as I can tell.
I agree up until the end. Languages that let you change the syntax can result in stuff where every program is written in its own DSL. Ruby has this issue to some extent.
Sure, changing the syntax is not something to be done lightly. It has to be done judiciously and with great care. But it can be a huge win in some cases. For example, take a look at:

https://flownet.com/gat/lisp/djbec.lisp

It implements elliptic curve cryptography in Common Lisp using an embedded infix syntax.

Regarding Common Lisp, do you know of any articles that highlight the methods used to "change the syntax and add new constructs so that the language meets the problem and not the other way around."
It's talking about lisp macros, idempotent languages, and a few other features of lispey languages. I'd suggest the book On Lisp, or Lisp in Small Pieces as good places to learn about it, but there are a ton of other resources that may be better suited to your needs.
Also check out clojure, and the books: Norvig's PAIP, or Graham's ANSI Common Lisp.
And don't miss Sonja Keene's book "Object-Oriented Programming in Common Lisp" and Kiczales' "The Art of the Meta-Object Protocol". If you don't reach enlightenment after those, Libgen will refund your money.
> Another red flag is boilerplate. By definition boilerplate is something that you have to type not because it's required to specify the behavior of the code but simply because the language design demands it. Boilerplate is always unnecessary cognitive load, and it's one sign of a badly designed language. (Yes, I'm looking at you, Java.)

The claim that LLMs are great for spitting out boilerplate has always sat wrong with me for this reason. They are, but could we not spend some of that research money on eliminating some of the need for boilerplate, rather than just making it faster to input?

> Another red flag is boilerplate.

I have to disagree. Boilerplate can simply be a one-time cost that is paid at setup time, when somebody is already required to have an understanding of what’s happening. That boilerplate can be the platform for others to come along and easily read/modify something verbose without having to go context-switch or learn something.

Arguing against boilerplate to an extreme is like arguing for DRY and total prevention of duplicated lines of code. It actually increases the cognitive load. Simple code to read and simple code to write is low-cost, and paying a one-time cost at setup is low compared to repeated cost during maintenance.

I've had some C# code inflicted on me recently that follows the pile of garbage design pattern. Just some offshore guys fulfilling the poorly expressed spec with as little brain work as possible. The amount of almost-duplicate boilerplate kicking around is one of the problems. Yeah it looks like the language design encourages this lowest common denominator type approach, and has lead into the supplier providing code that needs substantial refactoring in order be able to create automated tests as the entry points ignore separation of concerns and abuse private v public members to give the pretense of best practices while in reality providing worst practice modify this code at your peril instead. It's very annoying because I could have used that budget to do something actually useful, but on the other hand improves my job security for now.
Sounds like you would have had problems whether there was boilerplate-y code or not.
The extra boilerplate noise with excessive repetition doesn't help one little bit.
If some program can generate that code automatically, the need to generate it, write it to disk, and for you to edit it is proof that there is some flaw in the language the code is written in. When the generator needs to change, the whole project is fucked because you either have to delete the generated code, regenerate it, and replicate your modifications (where they still apply, and if they don't still apply, it could have major implications for the entire project), or you have to manually replicate the differences between what the new version of the generator would generate and what the old version generated when you ran it.

With AST macros, you don't change generated code, but instead provide pieces of code that get incorporated into the generated code in well-defined ways that allow the generated code to change in the future without scuttling your entire project.

>others to come along and easily read/modify something verbose without having to go context-switch or learn something.

They're probably not reading it, but assuming it's exactly the same code that appears in countless tutorials, other projects, and LLMs. If there's some subtle modification in there, it could escape notice, and probably will at some point. If there are extensive modifications, then people who rely on that code looking like the tutorials will be unable to comprehend it in any way.

> Another red flag is boilerplate. By definition boilerplate is something that you have to type not because it's required to specify the behavior of the code but simply because the language design demands it.

Two things: 1) this is often not language design but rather framework design, and 2) any semantic redundancy in context can be called boilerplate. Those same semantics may not be considered boilerplate in a different context.

And on the (Common) Lisp perspective—reading and writing lisp is arguably a unique skill that takes time and money to develop and brings much less value in return. I'm not fan of java from an essentialist perspective, but much of that cognitive load can be offset by IDEs, templates, lint tooling, etc etc. It has a role, particularly when you need to marshall a small army of coders very rapidly.

If the world put even a tenth of the effort into training Lisp programmers as it does into training Java programmers you would have no trouble marshaling an army of Lisp programmers.
The real problem is you cannot ever marshal an army of cheap Lisp programmers, because Lisp programming requires not only learning but raw ability. The big companies are searching for a language that any idiot can learn in a week, with the hope that they can hire thousands of them now, and fire them all next year when LLMs are slightly smarter.

They run into the problem that programming is inherently hard, and no amount of finagling with the language can change that, so you have to have someone on every team with actual talent. But the team can be made of mostly idiots, and some of them can be fired next year if LLMs keep improving.

If you use Lisp for everything, you can't just hire any idiot. You have to be selective, and that costs money. And you won't be able to fire them unless AGI is achieved.

> you cannot ever marshal an army of cheap Lisp programmers

That may be, but since Lisp programmers are easily 10x as productive as ordinary mortals you can pay them, say, 5x as much and still get a pretty good ROI.

> you can't just hire any idiot

Yeah, well, if you think hiring any idiot is a winning strategy, far be it for me to stand in your way.

I don't think it's a winning strategy, but I'm in no position to make hiring or programming-language decisions, and I don't have the market insight that would be required to start my own company.
I would rather take on a lisp job that pays my bills than a Java job that pays my bills + upgrades my car
Completely agree! Common Lisp is truly the tool of the Gods.
The criticisms of Java syntax are somewhat fair, but it's important to understand the historical context. It was first designed in 1995 and intended to be an easy transition for C++ programmers (minimal cognitive load). In an alternate history where James Gosling and his colleagues designed Java "better" then it would have never been widely adopted and ended up as a mere curiosity like Common Lisp is today. Sometimes you have to meet your customers where they are.

It has taken a few decades but the latest version significantly reduces the boilerplate.

Sure. I understand why things are the way they are. But that I don't think that is a reason not to complain about the way things are. Improvement is always the product of discontent.
My gripe with the post is that there is no objective "cognitive load" solution. Arguably this varies from 1 person to another.
I don't think you can have golden rules, if you do, you fall in the usual don't do X, or limit Y to Z lines, etc.

But what you _can_ do is to ask yourself whether you're adding or removing cognitive load as you work and seek feedback from (possibly junior) coworkers.

This is true for exactly the same reason that no one algorithm compresses all types of data equally well.
So with semi-colons, you have three basic options:

1. Not required (eg Python, Go)

2. Required (eg C/C++, Java)

3. Optional (eg Javascript)

For me, (3) is by far the worst option. To me, the whole ASI debate is so ridiculous. To get away with (1), the languages make restrictions on syntax, most of which I think are acceptable. For example, Java/C/C++ allow you to put multiple statements on a single line. Do you need that? Probably not. I can't even think of an example where that's useful/helpful.

"Boilerplate" becomes a matter of debate. It's a common criticism with Java, for example (eg anonymous classes). I personally think with modern IDEs it's really a very minor issue.

But some languages make, say, the return statement optional. I actually don't like this. I like a return being explicit and clear in the code. Some will argue the return statement is boilerplate.

Also, explicit type declarations can be viewed as boilerplate.. There are levels to this. C++'s auto is one-level. So are "var" declarations. Java is more restrictive than this (eg <> for implied types to avoid repeating types in a single declaration). But is this boilerplate?

Common Lisp is where you lose me. Like the meme goes, if CL was a good idea it would've caught on at some point in the last 60 years. Redefning the language seems like a recipe for disaster, or at least adding a bunch of cognitive load because you can't trust that "standard" functions aren't doing standard things.

Someone once said they like in Java that they're never surprised by 100 lines of code of Java. Unlike CL, there's never a parser or an interpreter hidden in there. Now that's a testament to CL's power for sure. But this kind of power just isn't conducive to maintainable code.

I like (3) to be honest and the number of times it has poised any issue is virtually 0.
> Unlike CL, there's never a parser or an interpreter hidden in there

Ive never ever run into this problem in the years of writing common lisp. Can you show me an example code that has this? I wager you cannot and you are writing poopoo about something you know poo about and want it to be true just because you are too lazy to move beyond knowing poo

> But this kind of power just isn't conducive to maintainable code.

I can usually run code decades old in common lisp. In fact this is one of its well known features. How much more maintainable can it possibly get :)

You can reduce your Java boilerplate to annotations or succinct XML or whatever. Code generation is used a lot on the JVM.

Can you show a real compiler message about such a semicolon?

    % cat test.c
    main () {
      int x
      x=1
    }
    % gcc test.c  
    test.c:1:1: warning: type specifier missing, defaults to 'int' [-Wimplicit-int]
    main () {
    ^
    test.c:2:8: error: expected ';' at end of declaration
      int x
           ^
           ;
    1 warning and 1 error generated.
I added int to the main declaration to clean the irrelevant warning, and I get this:

   tst.c: In function ‘main’:
   tst.c:3:5: error: expected ‘=’, ‘,’, ‘;’, ‘asm’ or ‘__attribute__’ before ‘x’
       3 |     x=1
         |     ^
   tst.c:3:5: error: ‘x’ undeclared (first use in this function)
   tst.c:3:5: note: each undeclared identifier is reported only once for each function it appears in
   tst.c:3:8: error: expected ‘;’ before ‘}’ token
       3 |     x=1
         |        ^
         |        ;
       4 | }
         | ~       
gcc (Debian 12.2.0-14) 12.2.0

I get three errors, all on line 3 rather than 2, and as the first of them says, there are at least four alternatives for resolution besides semicolon.

Full code after adding type to main, including linter message from c.vim:

         1 int main () {                                                                        
         2     int x                                                                            
     E   3     x=1 /\* E: 'x' undeclared (first use in this function)                            
         4 }

.
The very same reasons you find CL to lower your cognitive load are why ultimately after 60 years all lisps have been relegated to niche languages despite their benefits, and I say it as a Racket lover. It raises cognitive load for everybody else by having to go through further steps into decoding your choices.

It's the very same reason why Haskell monocle-wielding developers haven't been able to produce one single killer software in decades: every single project/library out there has its own extensions to the language, specific compiler flags, etc that onboarding and sharing code becomes a huge chore. And again I say it as an avid Haskeller.

Haskellers know that, and there was some short lived simple Haskell momentum but it died fast.

But choosing Haskell or a lisp (maybe I can exclude Clojure somewhat) at work? No, no and no.

Meanwhile bidonville PHP programmers can boast Laravel, Symfony and dozens of other libraries and frameworks that Haskellers will never ever be able to produce. Java?

C? Even more.

The language might be old and somewhat complex, but read a line and it means the same in any other project, there are no surprises only your intimacy with the language limiting you. There's no ambiguity.

> But choosing Haskell or a lisp (maybe I can exclude Clojure somewhat) at work? No, no and no.

I've been using CL at work for pretty much my entire career and have always gotten a huge amount of leverage from it.

So do I, but not in large projects and teams that need to scale.
This requirement has become a meme. I can do more on a project alone (spanning several new for me domains) with lisp than I can with a group of 5 or 10 in any other language
(comment deleted)
I’m surprised to hear this from an avid Haskeller and I think it might give the wrong impression to those who are less familiar with Haskell. I’m sure you know this, but for the benefit of others, projects don’t have their own extensions, they just may or may not use some of the extensions provided by GHC. Anyway, that practice is now diminishing given the GHC2021 and GHC2024 “standards”, which just enable a fixed set of stable extensions.

And regarding using specific compiler flags, well, projects almost never do that.

I dumped Haskell specifically because of its enormous cognitive load. All my time and energy went into Haskell and its endless quirks, leaving nothing for the business problem and its stakeholders.
Intriguing! Could you say more about which aspects of Haskell gave it a high cognitive load for you?

By contrast, I've used predominantly Haskell in my career for the last ten years, exactly because it has reduced my cognitive load. So I'm interested in understand the discrepancy here.

Too much experimental software, too much fragmentation in major architectural design approaches, compounded by weak documentation, abandonware, and a tiny community.

Consider the choices in optics libraries, effects systems, regex packages, Haskell design patterns, web frameworks, SQL libraries, and even basic string datatypes. Now consider the Cartesian product of those choices and all their mutual incompatibilities. That's cognitive overload and nobody pays for analysis paralysis.

A stable engineering group with long-term projects can define a reference architecture and these problems are manageable. But consider large enterprise consulting, where I work. We routinely see unrelated new client projects, quickly assembled teams with non-intersecting skill sets, and arbitrary client technical constraints. Here, the idea of a repeatable, reference architecture doesn't fit, and every new project suffered cognitive overload from Haskell's big Cartesian product.

I really hoped Boring Haskell, Simple Haskell, and other pragmatic influences would prevail but Haskell has gone elsewhere. Those values are worth reconsidering, either in Haskell directly, or in a new and simpler language that puts those goals at center of its mission.

Thanks! You seem to be mostly talking about cognitive load arising from having too many choices. Is that right?

(That said, I don't understand what abandonware and a tiny community have to do with cognitive load -- I agree they're bad, I just don't see the connection to cognitive load.)

> I really hoped Boring Haskell, Simple Haskell, and other pragmatic influences would prevail but Haskell has gone elsewhere. Those values are worth reconsidering

I agree with this, except the "gone elsewhere" part. Haskell has far more pragmatic influences today than it did ten years ago when I started using it professionally. The change is slow, but it is in the right direction.

Yes, Haskell's choices are often overwhelming. Think of Ruby On Rails, where the community has a well-worn playbook and everyone knows the game. Then compare Haskell, which hits designers with an overwhelming menu of choices, and the community still hasn't picked the winners.

Glancing at r/haskell, people often ask for help in choosing web frameworks, SQL libraries, effect systems and monad transformers, regex libraries, text datatypes, lens packages and so on. Simple Haskell and Boring Haskell tried eliminating those problems but the community ignored their pleas, occasionally dismissing the idea with frank negativity.

> what abandonware and a tiny community have to do with cognitive load -- I agree they're bad, I just don't see the connection to cognitive load.)

Our due diligence on 3rd party libraries investigates how active a library is, which includes github submission frequency, online discussions, blog posts, security fix responsiveness, courseware, etc. Activity runs from extremely high (like pytorch) to graveyard code written long ago by graduate students and researchers. Between those endpoints, the big middle is often murky and requires lots of contingency analysis, given that we're delivering real systems to clients and they must stay absolutely safe and happy. All that analysis is brain-deadening, non-productive cognitive load.

Obviously none of this is unique to Haskell, but it's fair to say that other platforms provide more standardized design conventions, and for my needs, a quicker path to success.

Thanks, I appreciate you taking the time to describe your experience.
> poor language design

We have an excellent modern-day example with Swift - it managed to grow from a simple and effective tool for building apps, to a “designed by committee” monstrosity that requires months to get into.

Totally agree with this and would add that cognitive load is not just a matter of the code before you, but a function of your total digital environment:

https://vonnik.substack.com/p/how-to-take-your-brain-back

Interruptions and distractions leave a cognitive residue that drastically reduces working memory through the Zeigarnik effect.

Mantras like "methods should be shorter than 15 lines of code" or "classes should be small" turned out to be somewhat wrong.

So much this.

The whole point of functions and classes was to make code reusable. If the entire contents of a 100 line method are only ever used in that method and it's not recursive or using continuations or anything else weird, why the hell would it be "easier to read" if I had to jump up and down the file to 7 different submethods when the function's entire flow is always sequential?

For unit testing those sub-sections in a clear and concise manner (i.e., low cognitive load). As long as the method names are descriptive no jumping to and fro is needed usually.

That doesn't mean every little unit needs to be split out, but it can make sense to do so if it helps write and debug those parts.

Then you need to make those functions public, when the goal is to keep them private and unusable outside of the parent function.

Sometimes it's easy to write multiple named functions, but I've found debugging functions can be more difficult when the interactions of the sub functions contribute to a bug.

Why jump back and forth between sections of a module when I could've read the 10 lines in context together?

> Then you need to make those functions public, […]

That depends on the language, but often there will be a way to expose them to unit tests while keeping them limited in exposure. Java has package private for this, with Rust the unit test sits in the same file and can access private function just fine. Other languages have comparable idioms.

Javascript doesn't, AFAIK. I work in Elixir, which doesn't.

I'm for it if it's possible but it can still make it harder to follow.

>why the hell would it be "easier to read" if I had to jump up and down the file to 7 different submethods when the function's entire flow is always sequential?

If the submethods were clearly named then you'd only need to read the seven submethod names to understand what the function did, which is easier than reading 100 lines of code.

Why is that any easier than having comments in the code that describe each part? In languages that don't allow closures, there's no good way to pass state between the seven functions unless you pass all the state you need, either by passing all the variables directly, or by creating an instance of a class/struct/whatever to hold those same variables and passing that. If you're lucky it might only be a couple of variables, but one can imagine that it could be a lot.
If all the functions need state from all the other functions, that is the problem a class or a struct solves - e.g. a place to store shared state.

If the 7 things are directly related to one another and are _really_ not atomic things (e.g. "Find first user email", "Filter unknown hostnames", etc), then they can be in a big pile in their own place, but that is typically pretty rare.

In general, you really want to let the code be crisp enough and your function names be intuitive enough that you don't need comments. If you have comments above little blocks of code like "Get user name and reorder list", that should probably just go into its own function.

Typically I build my code in "layers" or "levels". The lowest level is a gigantic pile of utility functions. The top level is the highest level abstractions of whatever framework or interface I'm building. In the middle are all the abstractions I needed to build to bridge the two, typically programs are between 2-4 layers deep. Each layer should have all the same semantics of everything else at that layer, and lower layers should be less abstract than higher layers.

My problem with the class/struct approach is it doesn't work if you don't need everything everywhere.

    foo(...):
        f1(a,b,c,d,e,f)
        f2(a,c,d,f)
        f3(b,c,d,e)
        ...
        f7(d,e)
But with long descriptive variable names that you'd actually use so the function calls don't fit on one line. Better imo to have a big long function instead of a class and passing around extra variables.

Though, ideally there isn't this problem in the first place/it's refactored away (if possible).

A function that needs so many parameters is already a no go.

If it doesn't return anything, then it's either a method in a class, or it's a thing that perform some tricky side effect that will be better completely removed with a more sound design.

Creating a class around the too many arguments you want to pass to your function may be a good idea if the concept happens to be coherent and hopefully a bit more long-lived than just the function call.

Otherwise, your just hiding the fact that your function requires too many arguments by calling them properties.

Well, if there is no class that seems to make sense to group them, that's an additional flag that points to additional thoughts on design. Or discussion with fellow developer about it.

Of course, on some very exceptional case, 7 arguments might be relevant after all. If that is like the single one in the code base, and after thorough discussion with everyone implicated in the maintenance of the code it was agreed as an exceptionally acceptable trade-off for some reasons, making sure this would not leak in all the code base as it's called almost everywhere, then let it be.

But if it's a generalized style through the whole codebase, there are obvious lake of care for maintenability of the work and the team is going to pay for that sooner than later.

> A function that needs so many parameters is already a no go.

This rule is the same as lines of code type rules. The number itself is not the issue, it could be few parameters and a problem or it could be many parameters and not be an issue at all.

It works fine. Not all the methods need to use all the struct members.
You access the shared data via the struct / class reference, not as method parameters. That's the benefit.

e.g.

    foo(...):
        # Fields
        a
        b
        c
        d 
        e
        
        # Methods
        f1(f)
        f2(f)
        f3()
        ...
        f7()
Moving them to a higher scope makes it harder to change anything in foo. Now anytime you want to read or write a-e you have to build the context to understand their complete lifecycles. If all the logic were smooshed together, or if it were factored into the original functions with lots of parameters, as ugly as either of them might be, you still have much more assurance about when they are initialized and changed, and the possible scopes for those events are much more obviously constrained in the code.
If all those functions need all those variables, then you're either going to put them in a class, or put all those variables in something like a dict and just pass that in.

Seeing 10 variables passed in to a function is a code smell.

Whether you put in in a common class / struct or aggregate them in a dict depends on whether or not all those functions are related.

In general, your functions should not be super duper long or super duper intended. Those are also code smells that indicate you have the wrong abstractions.

Language syntax defines functional boundaries. A strong functional boundary means you don't have to reason about how other code can potentially influence your code, these boundaries are clearly defined and enforced by the compiler. If you just have one function with blocks of code with comments, you still must engage with the potential for non-obvious code interactions. That's much higher cognitive load than managing the extra function with its defined parameters.
In the ideal case, sure, but if assuming this can't be refactored, then the code

    foo(...):
       // init
       f1(a,b,c,d,e,f)
       f2(a,b,c,d,e,f)
       ...
       f7(a,b,c,d,e,f)
or the same just with a,b,c,d,e,f stuffed into a class/struct and passed around, isn't any easier to reason about than if those functions are inline.
There's at least one reason that something like this is going to be exceedingly rare in practice, which is that (usually) functions return things.

In certain cases in C++ or C you might use in/out params, but those are less necessary these days, and in most other languages you can just return stuff from your functions.

So in almost every case, f1 will have computed some intermediate value useful to f2, and so on and so forth. And these intermediate values will be arguments to the later functions. I've basically never encountered a situation where I can't do that.

Edit: and as psychoslave mentions, the arguments themselves can be hidden with fluent syntax or by abstracting a-f out to a struct and a fluent api or `self`/`this` reference.

Cases where you only use some of the parameters in each sub-function are the most challenging to cleanly abstract, but are also the most useful because they help to make complex spaghetti control-flow easier to follow.

This typically can be coded with something like

def foo(...) = Something.new(...).f1.f2.f7

Note that ellipsis here are actual syntax in something like Ruby, other languages might not be as terse and convinient, but the fluent pattern can be implemented basically everywhere (ok maybe not cobol)

I disagree. Your example tells me the structure of the code at a glance. If it was all inlined I would have to comprehend the code to recover this simple structure. Assuming the F's are well-name that's code I don't have to read to comprehend its function. That's always a win.
>Why is that any easier than having comments in the code that describe each part?

Because 7<<100

> Because 7<<100

But then, 7 << 100 << (7 but each access blanks out your short-term memory), which is how jumping to all those tiny functions and back plays out in practice.

Why does pressing "go to defn" blank your short term memory in a way that code scrolling beyond the top of the screen doesn't?
Because jumping is disorienting, because each defn has a 1-3 lines of overhead (header, delimiters, whitespace) and lives among other defns, which may not be related to the task at hand, and are arranged in arbitrary order?

Does this really need explaining? My screen can show 35-50 lines of code; that can be 35-50 lines of relevant code in a "fat" function, or 10-20 lines of actual code, out of order, mixed with syntactic noise. The latter does not lower cognitive load.

I wouldn't have asked if I didn't have a real curiosity!

To use a real world example where this comes up a lot, lots and lots of code can be structured as something like:

    accum = []
    for x in something():
        for y in something_else():
            accum.append(operate_on(x, y))
I find structuring it like this much easier than fully expanding all of these out, which at best ends up being something like

    accum = []
    req = my_service.RpcRequest(foo="hello", bar=12)
    rpc = my_service.new_rpc()
    resp = my_service.call(rpc, req)
    
    req = my_service.OtherRpcRequest(foo="goodbye", bar=12)
    rpc = my_service.new_rpc()
    resp2 = my_service.call(rpc, req)

    for x in resp.something:
        for y in resp2.something_else:
            my_frobnicator = foo_frobnicator.new()
            accum.append(my_frobnicator.frob(x).nicate(y))
and that's sort of the best case where there isn't some associated error handling that needs to be done for the rpc requests/responses etc.

I find it much easier to understand what's happening in the first case than the second, since the overall structure of the operations on the data is readily apparent at a glance, and I don't need to scan through error handling and boilerplate.

Like, looking at real-life examples I have handy, there's a bunch of cases where I have 6-10 lines of nonsense fiddling (with additional lines of documentation that would be even more costly to put inline!), and that's in python. In cpp, go, and java which I use at work and are generally more verbose, and have more rpc and other boilerplate, this is usually even higher.

So the difference is that my approach means that when you jump to a function, you can be confident that the actual structure and logic of that function will be present and apparent to you on your screen without scrolling or puzzling. Whereas your approach gives you that, say, 50% of the time, maybe less, because the entire function doesn't usually fit on the screen, and the structure may contain multiple logical subroutines, but they aren't clearly delineated.

>which is how jumping to all those tiny functions and back plays out in practice.

Why would you jump into those functions and back?

Because I need to know what they actually do? The most interesting details are almost always absent from the function name.

EDIT:

For even a simplest helper, there's many ways to implement it. Half of them stupid, some only incorrect, some handling errors the wrong way or just the wrong way for the needs of that specific callee I'm working on. Stupidity often manifests in unnecessary copying and/or looping over copy and/or copying every step of the loop - all of which gets trivially hidden by extra indirection of a small function calling another small function. That's how you often get accidental O(n^2) in random places.

Many such things are OK or not in context of caller, none of this is readily apparent in function signatures or type system. If the helper fn is otherwise abstracting a small idiom, I'd argue it's only obscuring it and providing ample opportunities to screw up.

I know many devs don't care, they prefer to instead submit slow and buggy code and fix it later when it breaks. I'm more of a "don't do stupid shit, you'll have less bugs to fix and less performance issues for customers to curse you for" kind of person, so cognitive load actually matters for me, and wishing it away isn't an acceptable solution.

>Because I need to know what they actually do?

Strange. The longer I've been programming, the less I agree with this.

>For even a simplest helper, there's many ways to implement it.

Sure. But by definition, the interface is what matters at the call site.

> That's how you often get accidental O(n^2) in random places.

Both loops still have to be written. If they're in separate places, then instead of a combined function which is needlessly O(n^2) where it should be O(n), you have two functions, one of which is needlessly O(n) where it should be O(1).

When you pinpoint a bottleneck function with a profiler, you want it to be obvious as possible what's wrong: is it called too often, or does it take too long each time?

> If the helper fn is otherwise abstracting a small idiom, I'd argue it's only obscuring it and providing ample opportunities to screw up.

Abstractions explain the purpose in context.

> I'm more of a "don't do stupid shit, you'll have less bugs to fix and less performance issues for customers to curse you for" kind of person

The shorter the function is, the less opportunity I have to introduce a stupidity.

> there's no good way to pass state between the seven functions unless you pass all the state you need,

That’s why it’s better than comments: because it gives you clarity on what part of the state each function reads or writes. If you have a big complex state and a 100 line operation that is entirely “set attribute c to d, set attribute x to off” then no, you don’t need to extract functions, but it’s possible that e.g this method belongs inside the state object.

>Why is that any easier than having comments in the code that describe each part?

Because you only read the submethod names, and then you already understand what the code does, at the level you're currently interested in.

If the variables were clearly named, I wouldn't have to read much at all, unless I was interested in the details. I reitrate: why does the length of the single function with no reuse matter?
It does not matter if function foo is reused, only if the code inside foo that is to be pulled into new function bar is.
I find "is_enabled(x)" to be easier to reason about than

    if (x.foo || x.bar.baz || (x.quux && x.bar.foo))
Even if it's only ever used once. Functions and methods provide abstraction which is useful for more than just removing repetition.
Wouldn't you jump to is_enabled to see what it does?

That's what I always do in new code, and probably why I dislike functions that are only used once or twice. The overhead of the jump is not worth it. is_enabled could be a comment above the block (up to a point, notif it's too long)

> Wouldn't you jump to is_enabled to see what it does?

That depends on a lot of things. But the answer is (usually) no. I might do it if I think the error is specifically in that section of code. But especially if you want to provide any kind of documentation or history on why that code is the way it is, it's easier to abstract that away into the function.

Furthermore, most of the time code is being read isn't the first time, and I emphatically don't want to reread some visual noise every time I am looking at a larger piece of code.

That makes sense. To mee it's not about the function having bad code, but different opinions about what exactly "enabled" means.

If I'm not interested I just jump past the block when reading (given that it's short and tidy)

> Wouldn't you jump to is_enabled to see what it does?

It determines whether the thing is enabled. Or else some other dev has some 'splainin' to do. I already understand "what it does"; I am not interested in seeing the code until I have a reason to suspect a problem in that code.

If the corresponding logic were inline, I would have to think about it (or maybe read a comment) in order to understand its purpose. The function name tells me the purpose directly, and hides the implementation that doesn't help me understand the bigger picture of the calling function.

Inline code does the opposite.

When the calculation is neatly representable as a single, short, self-evident expression, then yes, I just use a local assignment instead. If I find myself wanting to comment it - if I need to say something about the implementation that the implementation doesn't say directly - using a separate function is beneficial, because a comment in that function then clearly refers to that calculation specifically, and I can consider that separately from the overall process.

> It determines whether the thing is enabled.

Ah, but what exactly does "enabled" mean in this context? Might seem nitpicky, but I might very well have a different opinion than the person who wrote the code. I mean, if it was just `if foo.enabled ..` no one would put it in a new function.. right? :)

I would say a comment does the same, and better because it can be multi line, and you can read it without having to click or move to the function call to see the docs.

And you can jump past the implementation, iff it's short and "tidy" and enough.

Yes, at some point it should be moved out anyway. I'm just weary from reading code with dozens of small functions, having to jump back and forth again and again and again

>Ah, but what exactly does "enabled" mean in this context?

If the code is working, it means what it needs to mean.

> I mean, if it was just `if foo.enabled ..` no one would put it in a new function. right?

Sure. This is missing the point, however.

> I'm just weary from reading code with dozens of small functions, having to jump back and forth again and again and again

Why do you jump to look at the other parts of the code? Did it fail a test?

> If the code is working, it means what it needs to mean.

No. Working code says nothing about the meaning of a label, which is purely to inform humans. The computer throws it away, the code will work no matter what you name it, even if the name is entirely wrong.

> Why do you jump to look at the other parts of the code? Did it fail a test?

Because people pick bad names for methods, and I've been hurt before. I'm not reading the code just to fix a problem, I'm reading the code to understand what it does (what it ACTUALLY does, not what the programmer who wrote it THOUGHT it does), so I can fix the problem properly.

So my claim is that you do this less often than you claim to. There is some cutoff where you trust the code enough to not investigate it further. I'm of the opinion that this trust should generally be pretty close to the actual thing you're working on or investigating, and if it isn't that's a cultural issue that won't be solved by just "prefer to inline".
>Because people pick bad names for methods, and I've been hurt before.

So you write long functions because other people are bad at writing short ones?

I have absolutely done this myself in the past and confused myself with bad names. Any criticism I apply to other people also applies to myself: I am not a special case.

Naming things is hard! Even if you're really good at naming things, adding more names and labels and file separation to a system adds to the complexity of the system. A long function may be complex, but it doesn't leak the complexity into the rest of the system. Creating a function and splitting it out is not a zero cost action.

I write long functions when long functions make sense. I write plenty of short functions too, when that makes sense. I'm not religiously attached to function or file size, I'm attached to preserving the overall system structure and avoiding stuff that makes easy bugs.

If you're literally using it just once, why not stick it in a local variable instead? You're still getting the advantage of naming the concept that it represents, without eroding code locality.

However, the example is a slightly tricky basis to form an opinion on best practice: you're proposing that the clearly named example function name is_enabled is better than an expression based on symbols with gibberish names. Had those names (x, foo, bar, baz, etc) instead been well chosen meaningful names, then perhaps the inline expression would have been just as clear, especially if the body of the if makes it obvious what's being checked here.

It all sounds great to introduce well named functions in isolated examples, but examples like that are intrinsically so small that the costs of extra indirection are irrelevant. Furthermore, in these hypothetical examples, we're kind of assuming that there _is_ a clearly correct and unique definition for is_enabled, but in reality, many ifs like this have more nuance. The if may well not represent if-enabled, it might be more something like was-enabled-last-app-startup-assuming-authorization-already-checked-unless-io-error. And the danger of leaving out implicit context like that is precisely that it sounds simple, is_enabled, but that simplicity hides corner cases and unchecked assumptions that may be invalidated by later code evolution - especially if the person changing the code is _not_ changing is_enabled and therefore at risk of assuming it really means whether something is enabled regardless of context.

A poor abstraction is worse than no abstraction. We need abstractions, but there's a risk of doing so recklessly. It's possible to abstract too little, especially if that's a sign of just not thinking enough about semantics, but also to abstract too much, especially if that's a sign of thinking superficially, e.g. to reduce syntactic duplication regardless of meaning.

Pretty sure every compiler can manage optimizing out that method call, so do whichever makes you and your code reviewer happy.
A local variable is often worse: Now I suffer both the noise of the unabstracted thing, and an extra assignment. While part of the goal is to give a reasonable logical name to the complex business logic, the other value is to hide the business logic for readers who truly don't care (which is most of them).

The names could be better and more expressive, sure, but they could also be function calls themselves or long and difficult to read names, as an example:

    if (
        x.is_enabled ||
        x.new_is_enabled ||
        (x.in_us_timezone && is_daytime()) ||
        x.experimental_feature_mode_for_testing 
        )...
That's somewhat realistic for cases where the abstraction is covering for business logic. Now if you're lucky you can abstract that away entirely to something like an injected feature or binary flag (but then you're actually doing what I'm suggesting, just with extra ceremony), but sometimes you can't for various reasons, and the same concept applies.

In fact I'd actually strongly disagree with you and say that doing what I'm suggesting is even more important if the example is larger and more complicated. That's not an excuse to not have tests or not maintain your code well, but if your argument is functionally "we cannot write abstractions because I can't trust that functions do what they say they do", that's not a problem with abstractions, that's a problem with the codebase.

I'm arguing that keeping the complexity of any given stanza of code low is important to long-term maintainability, and I think this is true because it invites a bunch of really good questions and naturally pushes back on some increases in complexity: if `is_enabled(x)` is the current state of things, there's a natural question asked, and inherent pushback to changing that to `is_enabled(x, y)`. That's good. Whereas its much easier for natural development of the god-function to result in 17 local variables with complex interrelations that are difficult to parse out and track.

My experience says that identifying, removing, and naming assumptions is vastly easier when any given function is small and tightly scoped and the abstractions you use to do so also naturally discourage other folks who develop on the same codebase from adding unnecessary complexity.

And I'll reiterate: my goal, at least, when dealing with abstraction isn't to focus on duplication, but on clarity. It's worthwhile to introduce an abstraction even for code used once if it improves clarity. It may not be worthwhile to introduce an abstraction for something used many times if those things aren't inherently related. That creates unnecessary coupling that you either undo or hack around later.

> Now I suffer both the noise of the unabstracted thing, and an extra assignment.

Depends on your goals / constraints. From a performance standpoint, the attribute lookups can often dwarf the overhead of an extra assignment.

I'm speaking solely from a developer experience perspective.

We're talking about cases where the expression is only used once, so the assignment is free/can be trivially inlined, and the attribute lookups are also only used once so there is nothing saved by creating a temporary for them.

I agree except I think 100 lines is definitely worth a method, whereas 15 lines is obviously not worthy for the most cases and yet we do that a lot.

My principle has always been: “is this part a isolated and intuitive subroutine that I can clearly name and when other people see it they’ll get it at first glance without pausing to think what this does (not to mention reading through the implemention)”. I’m surprised this has not been a common wisdom from many others.

Yeah, I find extracting code into methods very useful for naming things that are 1) a digression from the core logic, and 2) enough code to make the core logic harder to comprehend. It’s basically like, “here’s this thing, you can dig into it if you want, but you don’t have to.” Or, the core logic is the top level summary and the methods it calls out to are sections or footnotes.
In recent years my general principle has been to introduce an abstraction (in this case split up a function) if it lowers local concepts to ~4 (presumably based on similar principles to the original post). I’ve taken to saying something along the lines of “abstractions motivated by reducing repetition or lines of code are often bad, whilst ones motivated by reducing cognitive load tend to be better”.

Good abstractions often reduce LOC, but I prefer to think of that as a happy byproduct rather than the goal.

>My principle has always been: “is this part a isolated and intuitive subroutine that I can clearly name and when other people see it they’ll get it at first glance without pausing to think what this does (not to mention reading through the implemention)”.

I hold this principle as well.

And I commonly produce one-liner subroutines following it. For me, 15 lines has become disturbingly long.

I tend toward John Carnack's view. He seemed annoyed that he was being pressed to provide a maximum at all and specified 7000 lines. I don't think I have ever gone that high. But really is just a matter of what you are doing. We expect to reuse things way more often than we actually do. If you wrote out everything you need to do in order and then applied the rule of three to make a function out of everything you did three times, it is very possible you wouldn't remove anything. In which case I think it should just be the one function.
> We expect to reuse things way more often than we actually do.

This is about readability (which includes comprehensibility), not reuse. When I read code from others who take my view, I understand. When I read code from those who do not, I do not, until I refactor. I extract a piece that seems coherent, and guess its purpose, and then see what its surroundings look like, with that purpose written in place of the implementation. I repeat, and refine, and rename.

It is the same even if I never press a key in my editor. Understanding code within my mind is the same process, but relying on my memory to store the unwritten names. This is the nature of "cognitive load".

Because a function clearly defines the scope of the state within it, whereas a section of code within a long function does not. Therefore a function can be reasoned about in isolation, which lowers cognitive load.
You can write long functions in a bad way, don't get me wrong. I'm just saying the rule that the length itself is an anti-pattern has no inherent validity.
I don't agree. If there are side effects happening which may be relevant, the section of code within a long function is executing in a clearly defined state (the stuff above it has happened, the stuff below it won't happen until it finishes) while the same code in a separate function could be called from anywhere. Even without side effects, if it's called from more than one place, you have to think about all of its callers before you change its semantics, and before you look, you don't know if there is more than one caller. Therefore the section of code can be reasoned about with much lower cognitive load. This may be why larger subroutines correlate with lower bug rates, at least in the small number of published empirical studies.

The advantage of small subroutines is not that they're more logically tractable. They're less logically tractable! The advantage is that they are more flexible, because the set of previously defined subroutines forms a language you can use to write new code.

Factoring into subroutines is not completely without its advantages for intellectual tractability. You can write tests for a subroutine which give you some assurance of what it does and how it can be broken. And (in the absence of global state, which is a huge caveat) you know that the subroutine only depends on its arguments, while a block in the middle of a long subroutine may have a lot of local variables in scope that it doesn't use. And often the caller of the new subroutine is more readable when you can see the code before the call to it and the code after it on the same screen: code written in the language extended with the new subroutine can be higher level.

As a non English speaker, what does "so much this" mean?

Does it essentially just mean "I agree"?

Yep, basically “I agree with this statement a lot.” It’s very much an “online Americanism.”
It's a call for others to take note of the important or profound message being highlighted. So more than just "I agree".
In the superlative, yes. It's a fairly new phrase, and hardly in my parlance, but it's growing on me when I'm in informal typed chat contexts.
When someone says "this" they are basically pointing at a comment and saying "this is what I think too".

"So much" is applied to intensify that.

So, yes, it's a strong assertion of agreement with the comment they're replying to.

To paraphrase a recentish comment from jerf, “sometimes you just have a long list of tasks to do”. That stuck with me. Now I’m a bit quicker to realize when I’m in that situation and don’t bother trying to find a natural place to break up the function.
For me it depends. Sometimes I find value in making a function for a block of work I can give its own name to, because that can make the flow more obvious when looking at what the function does at a high level. But arbitrarily breaking up a function just because is silly and pointless.
Plus, laying the list of tasks out in order sometimes makes it obvious how to split it up eventually. If you try to split it up the first time you write it, you get a bunch of meaningless splits, but if you write a 300 line function, and let it simmer for a few weeks, usually you can spot commonalities later.
That's also true, though in this case I'm not necessarily worried about commonalities, just changing the way it reads to focus on the higher level ideas making up the large function.

But revisiting code after a time, either just because you slept on it or you've written more adjacent code, is almost always worth some time to try and improve the readability of the code (so long as you don't sacrifice performance unnecessarily).

Define that function directly in the place where it is used (e.g. as a lambda, if nesting of function definitions is not allowed). Keeps the locality and makes it obvious that you could just have put a comment instead.
A useful trick is to then at least visually structure those 150 lines with comments that separate some blocks of functionality. Keeps the linear flow but makes it still easier to digest.
Why not just do something like this then? This:

    myfunction(data) {
        # do one thing to the data
        ...

        # now do another
        ...
    }
becomes that:

    myfunction(data) {
        do_one_thing_to_the_data(data)
        now_do_another(data)
    }

    do_one_thing_to_the_data(data) {
        ...
    }

    now_do_another(data) {
        ...
    }
Still linear, easier to get an overview, and you can write more modular tests.
Because now you have to jump around in order to see the sequence of events, which can be very frustrating if you have to constantly switch between two of these functions.

Plus, if we're dealing with a "long list of tasks" that can't be broken up in reusable chunks, it probably means that you need to share some context, which is way easier to do if you're in the same scope.

One thing I find useful is to structure it in blocks instead, so you can share things but also contain what you don't want shared. So e.g. in rust you could do this:

    let shared_computation = do_shared_computation();
    
    let result_one = {
        let result = do_useful_things();
        other_things(&shared_computation);
        result
    }
    
    ...
I think it's a nice middleground. But you still can't write modular tests. But maybe you don't have to, because again, this is just a long list of tasks you need to do that conceptually can't be broken down, so maybe it's better to just test the whole thing as a unit.
(comment deleted)
If the arguments to the function required are small, then breaking such a block down makes sense. Otherwise, it usually feels like an unnatural function to me.
We have different ideas about what "linear" means.
Instead of, say, 10 functions in a file that are all individually meaningful, you now have maybe 50 functions that are mostly tiny steps that don't make much sense on their own. Good like finding the "real" 10 functions buried amongst them. It's certainly higher cognitive load in my (painful) experience.
(comment deleted)
It comes down to the quality of the abstractions. If they are well made and well named, you'd rather read this:

  axios.get('https://api.example.com', {
      headers: { 'Authorization': 'Bearer token' },
      params: { key: 'value' }
  })
  .then(response => console.log(response.data))
  .catch(error => console.error(error));
than to read the entire implementations of get(), then() and catch() inlined.
> The whole point of functions and classes was to make code reusable.

I’m amazed that here we are >40 years on from C++, and still this argument is made. Classes never encapsulated a module of reusability, except in toy or academic examples. To try and use them in this way either leads to gigantic “god” classes, or so many tiny classes with scaffolding classes between them that the “communication overhead” dwarfs the actual business logic.

Code base after code base proves this again and again. I have never seen a “class” be useful as a component of re-use. So what is? Libraries. A public interface/api wrapping a “I don’t care what you did inside”. Bunch of classes, one class, methods? So long as the interface is small and well defined, who cares how it’s structured inside.

Modular programming can be done in any paradigm, just think about the api and the internal as separate things. Build some tests at the interface layer, and you’ve got documentation for free too! Re-use happens at the dll or cluster of dll boundaries. Software has a physical aspect to it as well as code.

This is not my experience. Multiple inheritance within a code base of certain sub-functionalities and states is a perfectly good example of reuse. You do not need to go all the way out to the library level. In fact, it is the abstract bases that really minimize the reusable parts that I find most useful.

I'm not saying you have to use classes to do this, but they certainly get the job done.

We are talking about different things. If you want to use inheritance inside your module, behind a reasonable API, in order to re-use common logic, I won’t bat an eye. I won’t know, I’m working with the public part of your module.

If you structure your code so that people in my team can inherit from your base class (because you didn’t make an interface and left everything public), and later you change some of this common logic, then I will curse your name and the manner of your conception.

Since learning functional programming well. I feel a need to use inheritance in C++ maybe a handful of places.

The problem with inherentice reuse is if you need to do something slightly different you are out of luck. Alternatively with functions you call what you need. And can break apart functionality without changing the other reuses.

I know that a lot of people advocate for composition over inheritance. Inheritance can add a lot of complexity especially if it is deep or involves a lot of overrides. It can be difficult to find out where a method came from inside the inheritance chain or if it has been overridden and consequently how it will behave.

Composition at least makes things a little more obvious where methods are getting their functionality. It also has other benefits in terms of making objects easier to mock.

Surely this is use case dependent? I’ve worked on projects where modular programming works well and others where not so much.
Specifically here I am talking about the concept of “re-use”. That is, the ability to write a bunch of code that does a “thing” and use that more than once, without significant modification.

Modularity is a much bigger concept, related to the engineering of large software systems. These days, “micro-services” is one way that people achieve modularity, but in the old days it was needed for many of the same reasons, but inside the monolith. The overall solution is composed of blocks living at different layers.

Re-use also exists inside modules, of course, by using functions or composition or — shudder — inheritance of code.

Modular programming has value as soon as more than one team needs to work on something. As it’s impossible to predict the future, my opinion is that it always has value to structure a code-base in this way.

Indeed and breaking out logic into more global scopes has serious downsides if that logic needs to be modified in the future, if your system still needs to support innovation and improvements, downsides not totally unlike the downsides of using a lot of global variables instead of local ones.

Prematurely abstracting and breaking code out into small high level chunks is bad. I try to lay it out from an information theoretic, mathematical perspective here:

https://benoitessiambre.com/entropy.html

with some implications for testing:

https://benoitessiambre.com/integration.html

It all comes down to managing code entropy.

>why the hell would it be "easier to read" if I had to jump up and down the file to 7 different submethods when the function's entire flow is always sequential?

Because you don't jump up and down the file to read it.

Each method that you create has a name, and the name is an opportunity to explain the process - naturally, in-line, without comments.

I write code like this all the time - e.g. from my current project: https://github.com/zahlman/bbbb/blob/master/src/bbbb.py . If I wanted to follow the flow of execution, I would be hammering the % key in Vim. But I don't do that, because I don't need or want to. The flow of the function is already there in the function. It calls out to other functions that encapsulate details that would be a distraction if I want to understand the function. The functions have names that explain their purpose. I put effort into names, and I trust myself and my names. I only look at the code I'm currently interested in. To look at other parts of the code, I would first need a reason to be interested in it.

When you look at yourself in the mirror, and notice your hand, do you feel compelled to examine your hand in detail before you can consider anything about the rest of your reflection? Would you prefer to conceive of that image as a grid of countless points of light? Or do you not find it useful that your mind's eye automatically folds what it sees into abstractions like "hand"?

35 years into my journey as a programmer, the idea of a 100-line function frightens me (although I have had to face this fear countless times when dealing with others' code). For me, that's half of a reasonable length (though certainly not a hard limit) for the entire file.

    # Can't import at the start, because of the need to bootstrap the
    # environment via `get_requires_for_build_*`.
  
This comment is a great example of what information you lose when you split linear code into small interrelated methods. You lose ordering and dependencies.

Sometimes it's worth it. Sometimes it isn't. In my opinion it's almost never worth it to get to the Uncle Bob's approved length of methods.

10-30 lines is OK. 3 is counterproductive except for a small subset of wrappers, getters etc. Occasionally it's good to leave a method that is 300 lines long.

If your code always does 9 things in that exact order - it's counterproductive to split them artificially into 3 sets of 3 things to meet an arbitrary limit.

>This comment is a great example of what information you lose when you split linear code into small interrelated methods.

Inlining `_read_toml` or `_read_config` would change nothing about the reasoning. The purpose was to make sure the import isn't tried until the library providing it is installed in the environment. This has nothing to do with the call graph within my code. It's not caused by "splitting the code into interrelated methods" and is not a consequence of the dependencies of those functions on each other. It's a consequence of the greater context in which the entire module runs.

The way that the system (which is not under my control) works (I don't have a really good top-down reference handy for this - I may have to write one), a "build frontend" will invoke my code - as a subprocess - multiple times, possibly looking for and calling different hooks each time. The public `get_requires_for_build_wheel` and `get_requires_for_build_sdist` are optional hooks in that specification (https://peps.python.org/pep-0517/#optional-hooks).

However, this approach is left behind from an earlier iteration - I don't need to use these hooks to ask the build frontend to install `tomli`, because the necessary conditions can be (and currently are) provided declaratively in `pyproject.toml` (and thus `tomli` will be installed, if necessary, before any attempts to run my backend code). I'll rework this when I get back to it (I should just be able to do the import normally now, but of course this requires testing).

This is how I work as well, and the reason I tend to write many small functions rather than few large ones is precisely because it reduces cognitive load. You don't have to understand what the canSubmit function does, unless you are interested in knowing what the conditions to submit this form are.

Ironically, the author of the post claims it has the opposite effect.

There's just no way I buy that I could safely make a change in a 100 loc function and know that there won't be an impact 30 lines down, where with a few additional function you can define the shape of interactions and know that if that shape/interface/type is maintained that there won't be unexpected interactions. Its a balance though as indirection can also readily hide and obscure interactions or add unnecessary glue code that also takes up mental bandwidth and requires additional testing to confirm.
That short/long toggle in the top-right seems to expand and collapse the article. It defaults to short. Reading this article in its short form I kept wondering if I was missing something relevant (cognitive load++), but with the long form on I kept wondering if some paragraphs were explicitly intended to be superfluous or tangential (cognitive load++) for the sake of that collapsing trick.

For an article on cognitive load, using a gimmick which increases it seems ironic.

I thought these things were pretty inferable
Something I noticed is that some vim / keyboard only envs are paying a huge cognitive load price by holding various states in their mind and having to expand efforts every time they switching context.

Sometimes there is the added burden of an exotic linux distro or a dvorak layout on a specially shaped keyboard.

Now, some devs are capable of handling this. But not all do, I've seen many claiming they are more productive with it, but when compared to others, they were less productive.

They were slow and tired easily. They had a higher burn out rate. The had too much to pay upfront for their day to day coding task but couldn't see that their idealization of their situation was not matching reality.

My message here is: if you are in such env be very honest with yourself. Are you good enough that you are among the few that actually benefit from it?

hi, tiling window manager and neovim user here.

I don't think about states much, it's all just muscle memory. Like doing a hadouken in street fighter.

They all say that, but it's true for only 20% of my encounters when you actually watch them code next to others.
could it be we just tend to be slower devs in general?

like I doubt I am blazing on windows 11 and VScode either.

I could be many things on an anecdotal experience. Can't make a study for thid at my level.
I think it's all about the framework, the memory palace you build to keep things organized. A secondary factor is the freedom and solitude to prevent extraneous concerns from interrupting you. The brain is not great at true multitasking (doing two or more things at the same time), but it can juggle.
I agree with vast majority of the post, and it matches my experience. What I'm not sure I follow is the part about layered architecture, and what is offered as an alternative. The author quickly gets to a _conclusion_ that

> So, why pay the price of high cognitive load for such a layered architecture, if it doesn't pay off in the future?

where one of the examples is

> If you think that such layering will allow you to quickly replace a database or other dependencies, you're mistaken. Changing the storage causes lots of problems, and believe us, having some abstractions for the data access layer is the least of your worries.

but in my experience, it's crucial to abstract away — even if the interface is not ideal — external dependencies. The point is not to be able to "replace a database", but to _own_ the interface that is used by the application. Maybe the author only means _unnecessary layering_, but the way the argument is framed seems like using external dependency APIs throughout the entire app is somehow better.

I commented on this in another thread here.

What I read it as is don’t over-index on creating separate layers/services if they are already highly dependent on each other. It just adds additional complexity tracing dependencies over the networking stack, databases/datastores, etc that the services are now split across.

In other words: a monolithic design is acceptable if the services are highly intertwined and dependent.

I think the 'Layered Architecture' section is all over the place.

There are a lot of terms thrown around with pretty loose definitions - in this article and others. I had to look up "layered architecture" to see what other people wrote about it, and it looks like an anti-pattern to me:

  In a four-layered architecture, the layers are typically divided into:
    Presentation
    Application
    Domain
    Infrastructure

  These layers are arranged in a hierarchical order, where each layer provides services to the layer above it and uses services from the layer below it, and each layer is responsible for handling specific tasks and has limited communication with the other layers. [1]
It looks like an anti-pattern to be because, as described, each layer depends on the one below it. It looks like how you'd define the "dependency non-inversion" principle. Domain depends on Infrastructure? A BankBalance is going to depend on MySQL? Even if you put the DB behind an interface, the direction of dependencies is still wrong: BankBalace->IDatabase.

Back to TFA:

> In the end, we gave it all up in favour of the good old dependency inversion principle.

OK. DIP is terrific.

> No port/adapter terms to learn

There is a big overlap between ports/adapters, hexagonal, and DIP:

  Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases. [2]
That is, the Domain ("application") is at the bottom of the dependency graph, so that the Infrastructure {Programs, Tests, Scripts} can depend upon it.

> If you think that such layering will allow you to quickly replace a database or other dependencies, you're mistaken.

Layering will not help - it will hinder, as I described above. But you should be able to quickly replace any dependency you like, which is what DIP/PortsAdapters/Hexagonal gives you.

> Changing the storage causes lots of problems, and believe us, having some abstractions for the data access layer is the least of your worries. At best, abstractions can save somewhat 10% of your migration time (if any)

I iterate on my application code without spinning up a particular database. Same with my unit tests. Well worth it.

[1] https://bitloops.com/docs/bitloops-language/learning/softwar... [2] https://alistair.cockburn.us/hexagonal-architecture/

If you feel like you are missing key parts of the argument, like I was, it might help to switch the view from "short" to "long" - there's a little slider on the right hand side of the screen. This adds in some paragraphs that help the piece flow better. (I'd never seen a blog post with this feature before.)
I agree. I find this part of the article weird as it has contracting statements:

> No port/adapter terms to learn

and

> we gave it all up in favour of the good old dependency inversion principle

Both use interfaces, just in different ways. We use hexagonal (ports and adapters) pattern in my project. If you asked anyone on my team to define hexagonal architecture they'll have no idea what you're talking about. They just follow the project coding patterns. There's no additional complexity.

> If you think that such layering will allow you to quickly replace a database or other dependencies, you're mistaken

I think most people do not stay at companies long enough to see the price of not abstracting away these things. That's the next developer's problem. The code tends to be tightly coupled to the libraries and frameworks used. Eventually, the projects dependencies have to be upgraded (usually due to security issues) and the migrations are usually incredibly difficult, expensive, and fragile. The product's business logic is tightly coupled with the framework and libraries used at the time. Even if the company realizes that framework has no future, they're kind of locked into their initial decision made a decade ago. At least, that's been my experience.

We have two major products at my company. Both started with the same initial framework, but the project I architected that used hexagonal was migrated to a faster and more modern framework within 4 weeks. The other product had a multi-year migration to a newer version of the same framework (and by the time it was completed is already two major versions outdated). Both products are similar in scale and code size.

What causes more cognitive load:

   filter(odd, numbers)
vs.

    (n for n in numbers if odd(n))
It depends on the reader too.
Also depends on whether it’s obvious why I need a list of odd numbers.
Rather depends if we can trust that it's Python's "filter" or if it's another language you're making look Pythonic, and we don't know who implemented filter/2 or how.

- The first one might be an in-place filter and mutate "numbers", the second one definitely isn't.

- The first one might not be Python's filter and might be a shadowed name or a monkeypatched call, the second one definitely isn't.

- The first one isn't clear whether it filters odd numbers in, or filters them out, unless you already know filter/2; the second one is clear.

- The first one relies on you understanding first-class functions, the second one doesn't.

- The first one isn't clear whether it relies on `numbers` being a list or can work with any sequence, the second one clearly doesn't use list indexing or anything like it and works on any sequence that works in a `for` loop.

- The first one gives no hint what it will do on an empty input - throw an exception, return an error, or return an empty list. The second one is clear from the patterns of a `for` loop.

- The first one has a risk of hiding side-effects behind the call to filter, the second one has no call so can't do that.

- Neither of them have type declarations or hints, or give me a clue what will happen if "numbers" doesn't contain numbers.

- The first one isn't clear whether it returns a list or a generator, the second one explicitly uses () wrapper syntax to make a generator comprehension.

- The first one has a risk of hiding a bad algorithm - like copying "numbers", doing something "accidentally n^2" - while the second one is definitely once for each "n".

Along the lines of "code can have obviously no bugs, or no obvious bugs" the second one has less room for non-obvious bugs. Although if the reader knows and trusts Python's filter then that helps a lot.

Biggest risk of bugs is that odd(n) tests if a number is part of the OEIS sequence discovered by mathematician Arthur Odd...

> "Neither of them have type declarations or hints, or give me a clue what will happen if "numbers" doesn't contain numbers."

bools in Python are False==0 and True==1, and I'm now imagining an inexperienced dev believing those things are numbers and has no idea they could be anything else, and is filtering for Trues with the intent of counting them later on, but they messed up the assignment and instead of 'numbers' always getting a list of bools it sometimes gets a scalar single bool outside a list instead. They want to check for this case, but don't understand types or how to check them at all, but they have stumbled on these filter/loop which throw when run against a single answer. How useful! Now they are using those lines of code for control flow as a side effect.

This is ridiculous. You can assume that you know what language you are reviewing/working in (sorry, I forgot to mention that the example is in Python). I can remember cases when I was not sure what [human] language I thinking in, but I don't remember a single case when there was a confusion what programming language I'm working in (it is not a factor in cognitive load).

filter is a builtin name in Python. There is no confusion here in practice. Static checker such as ruff will tell you if you attempt it accidentally. It is the first rule: A001 builtin-variable-shadowing.

If you are a noob the second variant may be easier to grasp. The first variant has less moving parts.

The argument isn't about these minor syntactic or API differences. It's about the structure of the code, in the SICP (https://en.wikipedia.org/wiki/Structure_and_Interpretation_o...) sense.
It is not how "cognitive load" is usually understood (as it relates to the working memory, measured by task-involved pupillary response). It involves anything and everything that is not already stored in your long term memory.

I remember spending egregiously long time to find a bug that was essentially a typo in some constant. Expressiveness of the language, how many chunks you have to keep in the working memory matters. The chunks can be low/high level depending on what you are trying to do at the moment but you can't escape looking at the low level details at some point.

Disagree with first example. If that condition is only used once, adding a variable introduces more state to keep track of, that could just be a comment next to the conditional.
The "too smart developers" narrative is pandering - poor design stems from inexperience and its accompanying insecurity, not intelligence. Skilled developers intuitively grasp the value of simplicity.
I find that it comes most from intelligence. I see plenty of super experienced but not very smart engineers design terrible over engineered systems. On the other hand, juniors err in the opposite direction with long functions with deep nested branching and repetition. And the latter is better. Easier to refactor up in abstraction level than down.
Perhaps I'm confused, but it seems to me that your examples actually support my point. You're describing experience-based patterns - seniors over-abstracting vs juniors writing tangled code. Neither case is about intelligence; they're about different types of inexperience leading to different design mistakes.
the latter is not better though, it's terrible actually, deep nesting is the essence of cognitive load
I love what you're saying. But, I've met a lot of people who have say 10-20 years experience designing applications with unnecessary and sometimes incredible cognitive load. There are serious incentives to NOT write "simple" code, let me share a few of them.

Root causes from my perspective look like: 1. Job security type development. Fearful/insecure developers make serious puzzle boxes. "Oh yea wait until they fire me and see how much they need me, I'm the only one who can do this."

2. Working in a vacuum/black hole developers. Red flags are phrases like "snark I could have done this" when working together on a feature with them. Yes, that is exactly the point, and I even hope the junior comes in after and can build off of it too.

3. Mixing work with play "I read this blog post about category theory and found this great way to conceptualize my code through various abstractions that actually deter from runtime performance but sound really cool when we talk about it at lunch".

4. Clout/resume/ego chasing "I want to say something smart at stand up, a conference, or at a future job, so other people know they are not on my level and cannot touch my code or achieve my quality."

Some other red flags. They alone maintain their "pet" projects for everything serious until they couldn't. Minor problems/changes come up, someone else goes in and fixes it. Something serious happens it's a stop the world garbage collection for that developer and they are the only one who can fix it disrupting any other operations they were part of.

I agree with everything you're saying, but these are different issues - not symptoms of excess intellect. Just post-hoc rationalization of poor choices.
Valid point I kind of missed the thesis a bit. My bad.
Yeah it's kind of a weird narrative. Writing complex code is leaps and bounds easier than writing simple code. Often takes both experience and intelligence to see the correct way.
Composition over inheritance is one of the most valuable lessons I learned earlier in my career as a developer. In fact these days, I'm hard-pressed to think of a case in which I would prefer inheritance as my first choice to model any problem. I'm sure there probably are some, but it feels too easy to wield irresponsibly and let bad design creep in.

At a previous job I had, a fairly important bit of code made use of a number of class hierarchies each five or six layers deep, including the massive code smell/design failure of certain layers stubbing out methods on a parent class due to irrelevancy.

To make matters worse, at the point of use often only the base/abstract types were referenced, so even working out what code was running basically required stepping through in a debugger if you didn't want to end up like the meme of Charlie from Always Sunny. And of course, testing was a nightmare because everything happened internally to the classes, so you would end up extending them even further in tests just to stub/mock bits you needed to control.

> AdminController extends UserController extends GuestController extends BaseController

> Cognitive load in familiar projects -- If you've internalized the mental models of the project into your long-term memory, you won't experience a high cognitive load.

^ imo using third-party libraries checks both of these boxes because 1) a fresh-to-project developer with general experience may already know the 3rd party lib, and 2) third party libraries compete in the ecosystem and the easiest ones win

> "Having too many shallow modules can make it difficult to understand the project. Not only do we have to keep in mind each module responsibilities, but also all their interactions."

Not only does it externalize internal complexity, but it creates emergent complexity beyond what would arise between and within deeper modules.

In a sense, shallow modules seem to be like spreading out the functions outside the conceptual class while thinking the syntactical encapsulation itself, rather than the content of the encapsulation, is the crucial factor.

Had to re-read this several times to grasp the meaning..

Curious, what is your background and day to day work to be able to express your thinking in these terms?

Thank you for asking! My phrasing may not have been the most clear.

My background is in humanities and health care. I currently work in eldercare. Computers have always been a major interest of mine since an early age, but professionally I have taken another route. I have a general curiosity about systems and theories of different kinds. I do have an education in the basic scientific approach, something I acquired while studying to become a teacher. Soft sciences have often come more easily to me. I lack the benefits of a disciplined study of computer science, something I am sure affects my approach.

My use of the word 'emergent' was inspired by systems theory [0], where the whole is more than the sum of its parts. The parts of a system create emergent behaviors or phenomena. For example: A football team consists of several players, and the strategy of the teamwork is an emergent phenomenon. Similarly, my thought was that when functions are spread out into several modules, they may create unexpected emergent complexity. Without being able to give a concrete example off the top of my head, I think I have struggled with bugs born out of such complexity.

My thought was that the "syntactical encapsulation" (the actual code we write) may not serve the "conceptual class" (the idea we have). We may have a good concept, but we distribute its functionality among too many classes.

[0] https://en.wikipedia.org/wiki/Systems_theory

Thank you for the explanation. Great thinking and good to have your opinions here on HN!
On the layered architecture section:

I have seen too many architectures where an engineer took “microservices” too far and broke apart services that almost always rely on each other into separate containers/VMs/serverless functions.

I’m not suggesting people build monolithic applications, but it’s not necessarily a good idea to break every service into its own distinct stack.

> Mantras like "methods should be shorter than 15 lines of code" or "classes should be small" turned out to be somewhat wrong.

These hard rules may be useful when trying to instill good habits in juniors, but they become counterproductive when you start constraining experienced developers with arbitrary limits.

It’s really bad when you join a team that enforces rules like this. It almost always comes from a lead or manager who reads too many business books and then cargo cults those books on to the team.

Same deal with DRY, the principle is obviously correct but people can take it too literally. It's so easy to get yourself in a huge mess trying to extract out two or three bits of code that look pretty similar but aren't really used in the same context.
The problem with DRY and generic rules around size, etc. really seems to be figuring out the boundaries, and that's tough to get right, even for experienced devs, plus very contextual. If you need to open up a dozen files to make a small change you're overwhelmed, but then if you need to wade through a big function or change code in 2 places you're just as frustrated.
This is the bane of my existence at the moment after ~20 years into my career, and it frustrates me when I run into these situations when trying to get certain people to review pull requests (because I'm being kind, and adhering to a process, and there is really valuable feedback at times). But on the whole it's like being dragged back down to working at a snails pace.

- Can't refactor code because it changes too many files and too many lines.

- Can't commit large chunks of well tested code that 'Does feature X', because... too many files and too many lines.

- Have to split everything down into a long sequence of consecutive pull requests that become a process nightmare in its own right

- The documentation comments gets nitpicked to death with mostly useless comments about not having periods at the ends of lines

- End up having to explain every little detail throughout the function as if I'm trying to produce a lecture, things like `/* loop until not valid */ while (!valid) {...` seemed to be what they wanted, but to me it made no sense what so ever to even have that comment

This can turn a ~50 line function into a 3 day process, a couple of hundred lines into a multi-week process, and a thousand or two line refactor (while retaining full test coverage) into a multi-month process.

At one point I just downed tools and quit the company, the absurdity of it all completely drained my motivation, killed progress & flow and lead to features not being shipped.

Meanwhile with projects I'm managing I have a fairly good handle on 'ok this code isnt the best, but it does work, it is fairly well tested, and it will be shipped as the beta', so as to not be obstinate.

I don’t have your experience but I personally think some of this feedback can be warranted.

> Can't refactor code because it changes too many files and too many lines.

This really depends on the change. If you are just doing a mass rename like updating a function signature, fair enough but if you changing a lot of code it’s very hard to review it. Lots of cognitive load on the reviewer who might not have the same understanding of codebase as you.

> Can't commit large chunks of well tested code that 'Does feature X', because... too many files and too many lines.

Same as the above, reviewing is hard and more code means people get lazy and bored. Just because the code is tested doesn’t mean it’s correct, just means it passes tests.

> Have to split everything down into a long sequence of consecutive pull requests that become a process nightmare in its own right

This is planning issue, if you correctly size tickets you aren’t going to end up in messy situations as often.

> The documentation comments gets nitpicked to death with mostly useless comments about not having periods at the ends of lines

Having correctly written documentation is important. It can live a long time and if you don’t keep an eye on it can becomes a mess. Ideally you should review it before you submitting it to avoid these issues.

> End up having to explain every little detail throughout the function as if I'm trying to produce a lecture, things like `/* loop until not valid */ while (!valid) {...` seemed to be what they wanted, but to me it made no sense what so ever to even have that comment

I definitely agree with this one. Superfluous comments are a waste of time.

Obviously this is just my option and you can take things too far but I do think that making code reviewable (by making it small) goes a long way. No one wants to review 1000s lines of code at once. It’s too much to process and people will do a worse job.

Happy to hear your thoughts.

I do object to the notion of something being a planning issue when you're talking about a days worth of work.

Implement X, needs Y and Z, ok that was straightforward, also discovered U and V on the way and sorted that out, here's a pull request that neatly wraps it up.

Which subsequently gets turned into a multi-week process, going back & forth almost every day, meaning I can't move on to the next thing, meanwhile I'm looking at the cumulative hourly wages of everybody involved and the cost is... shocking.

Death by process IHMO.

> Implement X, needs Y and Z, ok that was straightforward, also discovered U and V on the way and sorted that out, here's a pull request that neatly wraps it up

This sounds very difficult to review to be honest. At a minimum unrelated changes should be in their own pull request (U and V in your example).

Agree. Another item here that is contextual: what is the cost of a bug? Does it cost millions, do we find that out immediately, or does it take months? Or does it not really matter, and when we’ll find the big it will be cheap? The OP joining a new company might not have the context that existing employees have about why we’re being cautious/clear about what we’re changing as opposed to smuggling in refactors in the same PR as a feature change.

I’m going to be the guy that is asking for a refactor to be in a separate commit/PR from the feature and clearly marked.

It doesn’t justify everything else he mentioned (especially the comments piece) but once you get used to this it doesn’t need to extend timelines.

How do you rework a core process, then? If you rework a major unit that touches just about everything... Sharding something like that can break the actual improvement it is trying to deliver.

Like... Increase the performance of a central VM. You'll touch every part of the code, but probably also build a new compiler analysis system. The system is seperate to existing code, but useless without the core changes. Seperating the two can ruin the optimisation meant to be delivered, because the context is no longer front and center. Allowing more quibling to degrade the changes.

I work as a tech lead, so I get a lot of leeway in setting process. For small PRs, we use the normal “leave comments, resolve comments” approach. For large PRs, we schedule 30m meetings, where the submitter can explain the changes and answer questions, and record any feedback. This ensures everyone is on the same page with the changes, gives folks a chance to rapidly gather feedback, and helps familiarize devs who do not work in that area with what is going on. If the meeting is insufficient to feel like everyone is on the same page and approves the changes, we schedule another one.

These are some of the best meetings we have. They are targeted, educational, and ensure we don’t have long delays waiting for code to go in. Instead of requiring every PR to be small, which has a high cost, I recommend doing this for large/complex projects.

One additional thing to note on small PRs: often, they require significant context, which could take hours or even days, to be built up repeatedly. Contrast that with being able to establish context, and then solve several large problems all at once. The latter is more efficient, so if it can be enabled without negative side effects, it is really valuable.

I want my team to be productive, and I want to empower them to improve the codebase whenever they see an opportunity, even if it is not related to their immediate task.

One minor piece of insight from me is about release management vs pull-requests.

As you say it's much easier to schedule a 30 minute meeting, then we can - with context - resolve any immediate nitpicks you have, but we can also structure bigger things.

'Would this block a release?'

'Can we just get this done in the PR and merge it'

'Ok, so when it's done... what is the most important thing that we need to document?'

Where the fact that even after it's merged, it's going to sit in the repo for a while until we decide to hit the 'release' button', this lets people defer stuff to work on next and defines a clear line of 'good enough'

Yes, wrapping other discoveries into your feature work is a planning issue that might impact on the review burden.
> This is planning issue, if you correctly size tickets you aren’t going to end up in messy situations as often.

No, it’s “this refactor looks very different to the original code because the original code thought it was doing two different things and it’s only by stepping through it with real customer data that you realized with the right inputs (not documented) it could do a third thing (not documented) that had very important “side effects” and was a no-op in the original code flow. Yea, it touches a lot of files. Ok, yea, I can break it up step by step, and wait a few days between approval for each of them so that you never have to actually understand what just happened”.

The way I normally approach this is one big pr for context and then break it into lots of small ones for review.
A PR with self-contained smaller commits would be possible as well.
Yes, though it does depend on how good the commenting system is; and, for something like that, you're still probably going to want a meeting to walk people through such a huge change.

And you'd better hope you're not squashing that monstrous thing when you're done.

I've found processes like this to work better, too. Basically, the one big pr is like building a prototype to throw away. And the benefit is it has to get thrown away because the PR will never pass review.
(comment deleted)
so, it's not just a refactoring then; it's also bug fixes + refactoring. In my experience, those are the worst PRs to review. Either just fix the bugs, or just refactor it. Don't do both because now I have to spend more time checking the bugs you claim to fix AND your refactoring for new bugs.
There are certainly classes of bugs for which refactoring is the path of lowest resistance
And there are multi-PR processes that can be followed to most successfully convert those changes in a comprehensible way.

It'll often include extra scaffolding and / or extra classes and then renaming those classes to match the old classes' name after you're done, to reduce future cognitive load.

I'm unconvinced that adding extra code churn in order to split up a refactor that fixes bugs into a bugfix and a refactor is worthwhile
One metric I like to give my team is to have any new PR start a review in less than 15 minutes and be completed within 15 minutes. So, the longest you should wait is about 30 minutes for a review. That means teams either go "fuck it" and rubber stamp massive PRs -- which is a whole different issue -- or they take it seriously and keep PRs small to get their PRs reviewed in less than 30 minutes.

In most cases where I see responses like this, they're not surprised to wait hours or days for a PR review. In that case, it makes sense to go big, otherwise you'll never get anything done. If you only have to wait half an hour, max, for a PR review; the extra code churn is 1000% worth it.

This is where my stance is.

As a developer, I want my PRs to actually be reviewed by my coworkers and to have issues caught as a second layer of defense, etc.

As a reviewer, I effectively stopped approving things I couldn't give at least a cursory, reasonable glance (and tried to encourage others to follow suit because if we're not reviewing things, why not just push directly to main).

As a consequence, I have:

  * tried to review most things within like half an hour of their announcement
    in the shared MR channel

  * requested a pair programming session and offered to do a pair programming
    session for any large and semi-or-fully automated refactoring session,
    like running a linter or doing a multi-file variable rename
    (the pair programmer immediately comments on and approves the MR when it
    appears)

  * tried to limit my PRs to approximately 400 lines (not a rigid rule)
There were some specific instances of people not liking the "you must pair program if you're going to touch 400 files in one PR" requirement; but otherwise, I would like to think those on my team liked the more regular PRs, more people doing the PRs, etc, that resulted from this and some healthy culture changes.

I would also like to feel like the more junior devs were more willing to say anything at all in the PRs because they could follow the change.

I’ve seen this and variations done by teams to implement the metric. Usually, the “biggest” friction comes from “how do we know a PR needs to be reviewed within the time frame?” To which I always want to answer: “you have a mouth, put noises through it.” Sigh, sometimes I miss the military… anyway, toxic behavior aside, this is usually the biggest thing. I have to remind them that they go get coffee or smoke at least every hour, but rarely at the same time; so maybe then might be a good time to just do a quick check for an open PR. Or turn on notifications. Or if it’s urgent, mention it in the dev team channel.

But yeah, it’s hard to get the culture rolling if it isn’t already in place nor has anyone in the company worked with a culture like that.

I'm all for low-latency reviews, but this target seems crazy: a perfect recipe for a lot of apparent activity for little actual progress. Maybe it depends on the project, but for a lot of projects 15 minutes of review time means you basically are only going to accept trivial changes.
As it turns out, most of the work that most developers do is updating or enhancing CRUD apps. There's already a plan and an intent that just needs to be typed out.

I've found 15-30 minutes to be plenty of time to review about a day's worth of code. It's enough time to process what the code is doing and iterate over the tests, in general.

Here's a scary thought: if something small takes 15-30 minutes to appropriately process ... how much longer do *large* changes take? Can someone keep all that in their mind that whole time to comprehend and process a huge change?

And a better question, will they?

> 15 minutes of review time means you basically are only going to accept trivial changes.

Um, yes. This is 100% the point. There is no amount of refactoring, bug fixing, or features that cannot be expressed as a chain of trivial changes.

What you usually see happen is that instead of spending a week experimenting with 15 different refactors, is that an engineer opens a PR with what they think they're going to try first. Other engineers point out how they had tried that before and it didn't work; but maybe this other way will. So, they end up "working together" on the refactor instead of one developer getting lost in the sauce for a week seeing what sticks to a wall.

In essence, about the same amount of time is spent; but the code is higher quality and no architecture reviews during code reviews (which is another rule that should exist on a team -- architecture reviews should happen before a single line of code is touched).

The most common IME are bugs that come from some wrong conceptual understanding underpinning the code. Rewriting the code with a correct conceptual understanding automatically fixes the bugs.
The classic example of this is concurrency errors or data corruption related to multiple non-atomic writes.
No, I very deliberately did not describe any bug fixes.
> only by stepping through it with real customer data that you realized with the right inputs (not documented) it could do a third thing (not documented) that had very important “side effects” and was a no-op in the original code flow

sounds like the 'nightmare' was already there, not in the refactor. First step should be some tests to confirm the undocumented behaviour.

Some of your complaints seem to be about peer review ('approval'). I found my work life improved a lot once I embraced async review as a feature, not a bug.

As for 'break it up step by step' - I know how much I appreciate reviewing a feature that is well presented in this way, and so I've got good at rearranging my work (when necessary) to facilitate smooth reviews.

> sounds like the 'nightmare' was already there, not in the refactor

I admit that I am pretty allergic to people who avoid working with imperfect code.

> This is planning issue, if you correctly size tickets you aren’t going to end up in messy situations as often.

I think the underlying issue is what is an appropriate “unit of work”. Parent commenter may want to ship a complete/entire feature in one MR. Ticketing obsessed people will have some other metric. Merge process may be broken in this aspect. I would rather explain to reviewer to bring them up to speed on the changes to make their cognitive load easier

This. The solution to long and multiple reviews to MR is single pair review session where most of the big picture aspects can be addressed immediately and verbally discussed and challenged.

IMHO it is the same as chat. If talking about an issue over mail or chat takes more than 3-5 messages, trigger a call to solve it face to face.

code reviews that are too small, i think are worse than ones that are too big, and let through more bugs.

10 different reviewers can each look at a 100 lin change out of the 1000 line total change, but each miss how the changes work together.

theyre all lying by approving, since they dont have the right context to approve

That's rough. Of course some amount of thoughtfulness towards "smallest reasonable change" is valuable, but if you're not shipping then something is wrong.

As for the "comments on every detail" thing... I would fight that until I win or have to leave. What a completely asinine practice to leave comments on typical lines of code.

You always need to look at the track record of the team. If they were not producing solid consistent results before you joined them, it's a very good indicator that something's fishy. All that "they are working on something else that we can't tell you" is BS.

If they were, and you were the only one treated like that, hiring you was a decision forced upon the team, so they got rid of you in a rather efficient way.

I am trying my best to build in an inordinate amount of upfront linting and automated checks just to avoid such things - and then I still need to do a roadshow, or lots of explanations- but that’s probably good.

But the good idea is to say “we all have the same brutal linting standards (including full stops in docs!) - so hopefully the human linger will actually start reading the code for what it is, not what it says”

I'm also a fan of linting everything. Custom linter rules ftw.

This and documenting non-lintable standards so that people are on the same page ("we do controllers like this").

This is how I like to build and run my teams. This makes juniors so much more confident because they can ship stuff from the get go without going through a lengthy nitpicky brutal review process. And more senior devs need to actually look at code and business rules rather than nitpicking silly shit.

> This makes juniors so much more confident because they can ship stuff from the get go without going through a lengthy nitpicky brutal review process.

I had not considered that linters could greatly help new developers in this way, especially if you make it a one-button linting process for all established development environments.

Thanks for the insight! I will use this for the future.

if a colleague wants to argue over placement of a curly boy, I'll fight to the death.

if it's a linter, I shrug and move on.

No wonder why software development used to be expensive if 50 lines of code takes multiples days for several people …
Well maybe they do critical systems.
Valid point, it’s even mandatory in this case. Sometimes people do it for the sake of it. Maybe because there nothing else to make them feel important ? In critical systems I hope it’s the case though
Narrator: "They don't."

(Glib, but in my experience, mostly true.)

Indeed, cognitive load is not the only thing that matters. Non-cognitive toil is also a problem and often enough it doesn't get sufficient attention even when things get really bad.

We do need better code review tools though. We also need to approach that process as a mechanism of effectively building good shared understanding about the (new) code, not just "code review".

This sounds more like a case where you need a “break-the-glass” like procedure where some checks don’t apply. Or the checks should be non blocking anyway.
there is huge incentive for people who don't know how to code/create/do-stuff to slow things down like this b/c it allows them many years of runway at the company.

they are almost always cloaked in virtue signals.

almost every established company you join will already have had this process going for a long time.

doing stuff successfully at such a company is dangerous to the hierarchy and incurs an immune response to shut down or ostracize the doing-of-stuff successfully so the only way to survive or climb is to do stuff unsuccessfully (so they look good)

You seem to be describing a company where bureaucracy is a feature not a bug.

Been there. Left, live thousands times better.

> The documentation comments gets nitpicked to death with mostly useless comments about not having periods at the ends of lines > End up having to explain every little detail throughout the function

For these cases I like to use the ‘suggest an edit’ feature on gitlab/github. Can have the change queued up in the comments and batch commit together, and takes almost no additional time/effort for the author. I typically add these suggestion comments and give an approve at the same time for small nitpicks, so no slow down in the PR process.

I good process would be to just push the proposal to the branch in review.
I still want to let the author have the final say on if they decide to accept or reject the change, or modify it further. Editing the branch directly might cause some rebasing/merge conflicts if they’re addressing other peoples comments too, so I don't typically edit their working branch directly unless they ask me to.
I’m 15 years in and I feel basically the same. I end up making a feature or change, then going back and trying to split it into chunks that are digestible to my colleagues. I’ve got thousands of lines of staged changes that I’m waiting to drip out to people at a digestible pace.

I yearn for the early stage startup where every commit is a big change and my colleagues are used to reviewing this, and I can execute at my actual pace.

It’s really changed the way I think about software in general, I’ve come around to Rich Hickey’s radically simple language Clojure, because types bloat the refactors I’m doing.

I’d love to have more of you where I work, is there some way I can see your work and send some job descriptions and see if you’re interested?

> I end up making a feature or change, then going back and trying to split it into chunks that are digestible to my colleagues.

If you are doing this AFTER you've written the code, it is probably way easier to do it as you go. It's one thing if you have no idea what the code will look like from the beginning -- just go ahead and open the big PR and EXPLAIN WHY. I know that I'm more than happy to review a big PR if I understand why it has to be big.

I will be annoyed if I see a PR that is a mix of refactoring, bug fixes, and new features. You can (and should) have done those all as separate PRs (and tickets). If you need to refactor something, refactor it, and open a PR. It doesn't take that long and there's no need to wait until your huge PR is ready.

Solving creative problems is often iterative, and one things I'm very concerned about when doing engineering management is maintaining momentum and flow. Looking at latency hierarchies is a really good example, you have registers, then cache, then memory, SSD, network etc. and consulting with another human asynchronously is like sending a message to Jupiter (in the best case).

So, with an iterative process, the more times you introduce (at best) hour long delays, you end up sitting on your arse twiddling your thumbs doing nothing, until the response comes back.

The concept of making PRs as you go fails to capture one of the aspects of low-latency problem solving, which is that you catch a problem, you correct it and you revise it locally, without exiting that loop. Which is problematic because not only have you put yourself in a situation where you're waiting for a response, but you've stopped half-way through an unfinished idea.

This comes back to 'is it done', a gut feel that it's an appropriate time to break the loop and incur the latency cost, which for every developer will be different and is something that I have grown to deeply trust and and adjust to for everybody I work with.

What I'm getting at is the iterative problem solving process often can't be neatly dissected into discrete units while it's happening, and after we've reached the 'doneness' point it takes much more work to undo part of your work and re-do it than it took to do originally, so not only do you have the async overhead of every interaction, but you have the cognitive burden of untangling what was previously a cohesive unit of thought - which again is another big time killer

What I mean is, you make your commit, cherry pick it over to the main branch, and open a draft pr. It doesn't break your flow, it doesn't stop anything, and is pretty quick. It also gives you a quick gut-check to see the PR; if you think your team members won't understand "why" it needs to be refactored, then you have one of two problems:

1. your refactoring is probably going in the wrong direction. Team members will be able to help here more than ever. Let them bikeshed, but don't stop working on your main refactor yet. Revist later and integrate their changes.

2. the PR is too small. it will have to be part of a larger PR.

In my experience, people tend to have the first problem, and not the second one, but they think they have the second one. There are many of these "massive refactoring" PRs I've reviewed over the last 20 years where the refactoring makes the code worse, overall. Why? Because refactoring towards a goal (implementing a feature, fixing a bug, etc.) doesn't have the goal refactoring should have: improving code maintainability. So, the refactored code is usually LESS maintainable, but it does what they wanted.

If you make refactor PRs as you go, do you end up merging redactors towards a dead end and then--once you realize it's a dead end--merging even more refractors in the other direction?

I usually wait until I have the big PR done and then merge redactors towards it because then at least I know the road I'm paving has a workable destination.

This is why I design the heckin' huge change at the start, and then cherry pick the actual change (and associated tests) into a ton of smaller PRs, including "refactor here", "make this function + tests", "make this class + tests", "integrate the code + tests", and so on, as many times as necessary to have testable and reviewable units of code.

If I went about and made a ton of changes that all went into dead ends, honestly, I would get pretty demoralized and I think my team would get annoyed, especially if I then went through and rolled back many of those changes as not ending up being necessary.

These same people also want to see your GitHub history filled with deep green come review time. I start to wonder if they think high levels of GitHub activity is a proxy of performance or if it’s a proxy of plying the game the way they insist you play.
Dunno where you get that from, but that was not my intent and is not a metric I use to judge who I’d like to be my coworkers.
Aye. Sign of the times. You're 20+ years in, so I'm preaching to the choir and old-man-yelling-at-cloud here.

Cargo culting + AI are the culprits. Sucks to say, but engineering is going downhill fast. First wave of the shitularity. Architects? Naw, prompt engineers. Barf. Why write good code when a glorified chatbot could do it shittier and faster?

Sign of our times. Cardboard cutout code rather than stonemasonry. Shrinkflation of thought.

Peep this purified downvote fuel:

Everything is bad because everyone is lazy and cargo cults. Web specifically. Full-stop. AI sucks at coding and is making things recursively worse in the long run. LLMs are nothing more than recursive echo chambers of copypasta code that doesn't keep up with API flux.

A great example of this is the original PHP docs, which so, so many of us copypasta'd from, leading to an untold amount of SQL injections. Oopsies.

Simalarily and hunting for downvotes, React is a templating framework that is useful but does not even meet its original value proposition, which is state management in UI. Hilariously tragic. See: original example of message desync state issue on FB. Unsolved for years by the purported solution.

The NoSQL flash is another tragic comedy. Rebuilding the wheel when there is a faster, better wheel already carefully made. Postgres with JSONB.

GraphQL is another example of Stuff We Don't Need But Use Because People Say It's Good. Devs: you don't need it. Just write a query.

-

You mention a hugely important KPI in code. How many files, tools, commands, etc must I touch to do the simplest thing? Did something take me a day when it should have taken 30s? This is rife today, we should all pay attention. Pad left.

Look no further than hooks and contexts in React land for an example. Flawed to begin with, simply because "class is a yucky keyword". I keep seeing this in "fast moving" startups: the diaspora of business logic spread through a codebase, when simplicity and unity is key, which you touch on. Absolute waste of electricity and runway, all thanks to opiniation.

Burnt runways abound. Sometimes I can't help but think engineering needs a turn it off and then on again moment in safe mode without fads and chatbots.

> Everything is bad because everyone is lazy and cargo cults.

It’s an interesting series of events that led to this (personal theory). Brilliant people who deeply understood fundamentals built abstractions because they were lazy, in a good way. Some people adopted those abstractions without fully comprehending what was being hidden, and some of those people built additional abstractions. Eventually, you wind up with people building solutions to problems which wouldn’t exist if, generations above, the original problem had been better understood.

The road is paved with good intentions, it's not they were lazy but they had intent to distill wisdom to save time. Then yes, the abstractions were adopted without fully comprehended what was hidden, and those people then naively built additional layers of abstractions.

So yes, if the original problem had been better understood, then you wouldn't have a generation of React programmers doing retarded things.

Having watched many junior developers tackle different problems with various frameworks, I have to say React is conducive to brainrot by default. Only after going through a fundamentals-first approach do you not end up with one kind of spaghetti, but you end up with another kind because it's fundamentally engineered towards producing spaghetti code unless you constantly fight the inertia of spaghettification.

It's like teaching kids about `GOTO`... That is, IMO, the essence of React.

> it's not they were lazy but they had intent to distill wisdom to save time.

Yes – I was referring to lazy in the sense of the apocryphal quote from Bill Gates:

“I choose a lazy person to do a hard job, because a lazy person will find an easy way to do it.”

> Only after going through a fundamentals-first approach do you not end up with one kind of spaghetti, but you end up with another kind because it's fundamentally engineered towards producing spaghetti code unless you constantly fight the inertia of spaghettification.

I’ve been guilty of this. Thinking that a given abstraction is unnecessary and overly-complicated, building my own minimal abstraction for my use case, and then slowly creating spaghetti as I account for more and more edge cases.

The process is introducing more room for bugs to somehow creep in. Damn.
This is a big problem with reviews where the author is capitulating because they, with gritted teeth, acknowledge it's the only way to get the desired result (jumping over a hurdle).

So you blindly accept an ill-informed suggestion because that's the only way you can complete the process.

I've had a similar experience several times over the years. Even at companies with no working product that ostensibly wanted to 'move fast and break things'. And I do the same thing; quit and move on. I'm pretty convinced people like that more-or-less can't be reasoned with.

My question is .. is this getting more common as time goes on, or do I just feel like it is..

After 20 years of doing this, I’m convinced that required PR reviews aren’t worth the cost.

In the thousands of pull requests I’ve merged across many companies, I have never once had a reviewer catch a major bug (a bug that is severe enough that if discovered after hours, would require an oncall engineer to push a hot fix rather than wait for the normal deployment process to fix it).

I’ve pushed a few major bugs to production, but I’ve never had a PR reviewer catch one.

I’ve had reviewers make excellent suggestions, but it’s almost never anything that really matters. Certainly not worth all the time I’ve spent on the process.

That being said, I’m certainly not against collaboration, but I think required PR reviews aren’t the way to do it.

Wow someone who finally has this same unpopular opinion as I do. I'm a huge fan of review-optional PRs. Let it be up to the author to make that call and if it were really important to enforce it would be more foolproof to do so with automation.

Unfortunately every time I've proposed this it's received like it's sacrilegious but nobody could tell me why PR reviews are really necessary to be required.

The most ironic part is that I once caught a production-breaking bug in a PR while at FAANG and the author pushed back. Ultimately I decided it wasn't worth the argument and just let it go through. Unsurprisingly, it broke production but we fixed it very quickly after we were all finally aligned that it was actually a problem.

>Unfortunately every time I've proposed this it's received like it's sacrilegious but nobody could tell me why PR reviews are really necessary to be required.

Obvious signs of cargoculting in my opinion.

(comment deleted)
I'll bite.

To catch stupid mistakes like an extra file, an accidental debug flag, a missing compiler hint that has to be added to migration scripts etc.

To ensure someone who doesn't quite understand the difference between dev and production build pipelines doesn't break it.

To ensure a certain direction is being followed when numerous contractors are working on the code. For example a vague consistency in API designs, API param names, ordering, etc.

To check obvious misunderstandings by juniors and new hires.

To nix architect astronauts before their 'elegant' solution for saving a string to a database in 500 lines gets added.

To check the code is actually trying to solve the ticket instead of a wrong interpretation of the ticket.

To get introduced to parts of the codebase you haven't worked on much.

But as with anything you get from it what you put in.

None of those are good reasons why PR reviews are necessary. They are examples of things that it's theoretically possible a PR review might catch. But there's no information there about how likely those things are to be caught.

Without that absolutely critical information, no cost benefit analysis is possible.

In my experience across many companies, PR reviews almost never catch any of those bugs or bring any of those benefits.

Well, I catch those things.

Now you have some information.

If you worked with me it would be worth doing mandatory PRs.

One trick, and I'm not being sarcastic, is to read every line. Even if you don't understand the change as a whole that catches things.

Another trick, and again not sarcastic this is genuine advice. Read every line of your own PRs before you submit them. It's surprising how much I catch doing that. Same with git commits. It's also noticeable which of your colleagues don't do this as their PRs are the ones with obvious mistakes.

All of this is much easier and more effective with multiple PRs per day, and breaking bigger tickets into smaller one.

If you're constantly doing big, multi-day commits you're doing development wrong. You lose a lot of the benefits of source control and the tooling around it.

I still do big commits sometimes, especially when refactoring, but even then it's very important to keep those big commits focused on a particular change. If I find bugs or tweak a feature I've been meaning to change, I try and make a new branch and push that to main and then rebase my feature branch off main. Or cherry pick them out before the refactor PR.

> Read every line of your own PRs before you submit them

I do. Everyone should. I’m also a fan of small focused PRs.

> If you worked with me it would be worth doing mandatory PRs.

Given the number of PRs I’ve merged and the number of mistakes that reviewers have caught, I think it’s very unlikely that you’d catch those things at a frequency high enough to justify the cost.

I’m not doubting that you can find those things or that you have found them. But again I have merged thousands of PRs with hundreds of reviewers across numerous companies in multiple languages and I have never had a reviewer catch a major bug.

That’s a large enough sample size with no effect at all, that I’m going to need hard evidence to make me believe that people are finding these things at a high enough rate to justify the cost.

The point of code reviews isn’t to catch bugs. It’s for someone else on the team to read your code and make sure they can understand it. If no one else on your team can understand your code, you shouldn’t be committing it to the repository.
I feel if you ask 5 people what "the point" of codes review is, you'd get 6 different answers.
(comment deleted)
And a 7th complaining about the formatting of the question.
HN moment. I’ve never seen in practice that someone says ”I don’t understand it” and the author says ”good point, I will simplify it”.

Rather, the opposite. I often saw people make unnecessary complex or large PRs that were too much workload to review, leading the reviewer to approve, on the grounds of ”seems like you know what you’re doing and tbh I don’t have half a day to review this properly”.

Code review is a ritual. If you ask why we have it people will give you hypothetical answers more often than concrete examples. Personally I’m a proponent of opt-in CRs, ie ask for a second pair of eyes when your spidey senses tell you.

> I often saw people make unnecessary complex or large PRs that were too much workload to review, leading the reviewer to approve, on the grounds of ”seems like you know what you’re doing and tbh I don’t have half a day to review this properly”

That just seems like company wide apathy to me. Obviously you have to make an effort to read the code, but there are lots of ways developers can overcomplicate things because they were excited to try a pattern or clever solution. It doesn't make them bad devs, it's just an easy trap to fall into.

These should not pass a code review just because the code "works." It's totally acceptable to say "we're not gonna understand this in 3 months the way it's written, we need to make this simpler" and give some suggestions. And usually (if you're working with people that care about the workload they make for others) they will stop after a few reviews that point this out.

We've done this at our company and it's helped us immensely. Recognizing whether the code is unnecessarily complex or the problem is inherently complex is part of it, though.

Our juniors write horribly complex code that senior devs have to ask to simplify. This happens all the time. And the juniors simplify and thank us for teaching and mentoring. It’s a big reason we do reviews. So we can control how dirty the code is before merging and so we can grow each other with constructive feedback. Sometimes it’s also just “LGTM” if nothing smells.

90% of comments in my team’s PRs come with suggestions that can be applied with a click (we use GitLab). It requires almost no effort to apply suggestions and it’s often not much extra work for reviewers to explain and suggest a concrete change.

I agree that reviews should be used pragmatically.

Get (or create) better colleagues. It's usually pretty easy to identify if people are approving pull requests that they don't understand. Pull them aside and have a professional talk about what a pull request review is. People want to do good, but you have to make it clear that you value their opinion.

If you treat the people around you as valuable collaborators instead of pawns to be played to fulfill your processes, your appreciation for reviews will transform. Remember that it's their work too.

Maybe. But then, sure, I can understand the code you wrote - on a syntactic/operational level. This adds Foos to bar instead of baz, and makes Quux do extra Frob() call. Whatever, that's stupid stuff below junior level. What would actually matter is for me to understand why you're doing this, what it all means. Which I won't, because you're doing some code for symbolic transformation of equations for optimizing some process, and I'm doing data exchange between our backend and a million one-off proprietary industrial formats, and we only see each other on a team call once a week.

I'm exaggerating, but only a little. Point is, in a deep project you may have domain-specialized parts, and those specialties don't overlap well. Like, ideally I'd take you aside for an hour to explain the 101 of the math you're doing and the context surrounding the change, but if neither you nor me have the time, that PR is getting a +2 from me on the "no stupid shit being done, looks legit code-wise; assuming you know your domain and this makes sense" basis.

To do a good job on code review, reviewer must understand the challenge and how it is best to address it better than the programmer who implemented it. Necessarily!

To do an adequate job, reviewer must understand the task at least equivalently well.

The above is possible, and probably even desirable, but it adds a massive overhead in developer time for any non-trivial change. Enforcing good code review can approximately double development costs for the company, and inevitably creates a lot of programming & architecture work—nearly equivalent to any other programming & architecture work, minus the time spent hitting keyboard keys—for engineers, who might not exactly enjoy it.

As a result, it is very rare, and most reviews just tick boxes and enforce taste.

I personally hate to review substantial implementations. It is work minus the fun part. During work, I get to come up with a solution and bring it to life. Instead, during review I have to devise a solution only to use it as a reference point to assess another’s solution. This can also cause review suggestions that are difficult to enact (if my solution is sufficiently different and I think better than the original, that’s a lot of work to redo).

I watched pre merge code reviews become a requirement in the industry and catching bugs was almost always the #1 reason given.

The times I've seen a 2nd set of eyes really help with the understandability of code, it was almost always collaboration before or while the code was being written.

I would estimate something like 1 out of 100 PR reviews I've seen in my life were really focussed on improving understandability.

Unfortunately for compliance reasons PRs are required.

Funny part is that not even in highly regulated markets.

ISO270001 or SOC2 are pretty much something every software company will have to do.

Curious because I am not familiar: are PRs required or are PR reviews required?
Well "Peer Review" or "Code Review" is required - pull requests are easiest way to have it all documented with current state of art tooling. Otherwise you have to come up with some other way to document that for purpose of the audit.
Yes, this is why we have required PR reviews at my company. It is to meet compliance controls.

We recently talked about not requiring reviews for people in L5 and above levels but ultimately got shut down due to compliance.

SOC2 doesn't require code reviews. SOC2 is just a certification that you are following your own internal controls. There's nothing that says required PR reviews have to be one of your internal controls. That's just a common control that companies use.
I would argue that "common control that companies use" falls under "industry standard" and I would say it would make it harder to pass certification without PR reviews documented on GitHub or something alike. So it does not require but everyone expects you to do so :)
The reason that this is common is that a company hires a SOC2 consultant who tells them that PR reviews are required despite that fact that this is a complete fabrication.

Locking yourself into an enormously expensive process with no evidence of its efficacy just because you don't want read up on the process yourself or push back on a misinformed auditor is a terrible business decision.

The fact that there is a PR review process in place, makes commiters try harder. And that's good!
Or try less because they have to spend time doing pr reviews
Yes, same for QA sometimes.. dev sets bar lower as the QA can test it. Just makes a bunch of back and forth. And when stuff breaks nobody feels responsible.
Required PR reviews means that if someone steals your credentials, or kidnaps your child, you can't get something into production that steals all the money without someone else somewhere else having to push a button also.

It's the two-person rule, the two nuclear keyswitches.

This is definitely not why PR reviews are required. Most companies don't really know why they require them, but I've definitely never heard one say it was because they were afraid of malicious code from stolen credentials.

There's so many other ways you can inject malicious code with stolen credentials that doesn't require a PR in every production environment I've ever worked in. There's much lower hanging fruit that leaves far fewer footprints.

> I have never once had a reviewer catch a major bug

Just in 2024, I've had three or four caught[0] (and caught a couple myself on the project I have to PR review myself because no-one else understands/wants to touch that system.) I've also caught a couple that would have required a hotfix[1] without being a five-alarm alert "things are down".

[0] including some subtle concurrency bugs

[1] e.g. reporting systems for moderation and support

I agree with you. If you give each dev a kind of sand-box to "own" within a project they'll learn to find their own bugs, write both simple and robust code, lots of param checking — grow as an engineer that way.
Allowing anyone to promote anything to production without any other eyes on it is problematic. Not realizing this is extremely telling.

The presumed claim that no one at the company benefited from a second set of eyes is amazing, too.

>Allowing anyone to promote anything to production without any other eyes on it is problematic.

In my experience the people who are promoting things to production that shouldn't be will find a way to do it. They'll either wear down the people who want to stop it, or they'll find someone else to approve it who doesn't know why it shouldn't be approved or doesn't care.

My hypothesis is that requiring any 2nd random engineer in the company to approve production code doesn't provide enough value to justify the cost.

There may be other controls that are worth the cost.

However, our industry has been shipping software for a long time without this requirement, and I've seen no evidence that the practice has saved money, reduced the number of bugs, or improved software quality by any other metric. I think it's time we examine the practice instead of taking it on faith that it's a net benefit.

>Not realizing this is extremely telling.

Nice way of saying, I don't agree with you so I must be an idiot.

but there isn't actually a second set of eyes because the second set of eyes you're thinking about is complaining about formatting or slamming the approve button without actually looking
I'm one of the rare individuals who really tries to review code and leave helpful comments. I've been on the receiving end of really big PRs and can say I understand why you're being told to break things up into smaller chunks.

Most of the devs who submit large PRs just don't have a good grasp of organizing things well enough. I've seen this over and over again and it's due to not spending enough time planning out a feature. There will be exceptions to this, but when devs keep doing it over and over, it's the reviewer's job to reject it and send it back with helpful feedback.

I also understand most people don't like the friction this can create and so you end you with 80% of PRs being rubber stamped and bugs getting into production because the reviewers just give up on trying to make people better devs.

The reviewer's job is primarily to ensure business continuity, and only marginally to make people better devs.
When I review code I never think I am there to make people better devs.

I’m reviewing the code because I don’t want shit code merged into the code base I am responsible for operating. I’m going to be the one debugging that. Don’t just merge shit you feel like merging.

> mostly useless comments about not having periods at the ends of lines

Oh my god, this sounds like a nightmare. I definitely would not be able to tolerate this for long.

Did you try to get them to change? Were you just not in a senior enough position for anyone to listen?

As a reviewer I've seen numerous examples of PRs that were basically out of sync with the rest of the project, did not solve the problem they were supposed to solve, or added buggy or unmaintainable code.

Arguments like "but it works in majority of cases" are a way to delegate fixing issues to somebody else later. Unless noone will be using that code at all, in which case it should not be merged either.

It's the same with writing. The best authors occasionally break the rules of grammar and spelling in order to achieve a specific effect. But you have to learn the rules first, and break them only intentionally rather than accidentally. Otherwise your writing ends up as sloppy crap.

(Of course some organizations have coding conventions that are just stupid, but that's a separate issue.)

If a function is longer than what I can display on a single screen, it better has to be argumented with very exceptional relevant requirements, which is just as straight forward to judge for anyone with a bit of experience.
I've seen a book promoting the idea that methods should not be longer than 5 lines.

Of course now I know these ridiculous statements are from people hardly wrote any code in their lives, but if I'd read them at 18 I would have been totally misled.

Weirdly if you do break everything down into purely functional components it's entirely possible to uncompromisingly make every concept a few lines of code at most, and you will end up with some extremely elegant solutions this way.

You wouldn't be misled at all, only that the path you'd go down is an entirely different one to what you expected it to be.

> I know these ridiculous statements are from people hardly wrote any code in their lives

Some people who actually wrote a decent amount of code in their lives are sharing that opinion, so your comment just sounds like an ad-hominem attack.

I disagree that it's an attack, I've also never heard anyone say methods should be less than 5 lines. 5 lines is an insane limit, 15 is much more reasonable. This kind of enforcement reeks to me of unnecessarily "one-lining" complicated statements into completely unreadable garbage. I mean seriously though, 5 lines? Why not 4, or 3, or 6? 15 lines of well thought out code is infinitely preferable to 3 different 5-line monstrosities. Who(m'st've) among us that actually writes code would preach such a guideline, and can i please see their code for reference. Maybe they are just better than us, i still don't think that makes it a reasonable general rule. And i disagree that calling that out as crazy counts as a personal ad-hominem attack against this nebulous entity
You're confusing this with a software development process problem. It's really just good old fashioned psychological abuse.
I like to call these smells, not rules. They're an indication that something might be wrong because you've repeated code, or because your method is too long, or because you have too many parameters. But it might also be a false positive because in this instance it was acceptable to repeat code or have a long method or have many parameters.

Sometimes food smells because it turned bad, and sometimes it's smelly because it's cheese.

>It almost always comes from a lead or manager who reads too many business books and then cargo cults those books on to the team.

Worse, they behave as though they have profound insights, and put themselves on an intellectually elevated pedestal, which the rest of their ordinary team mortals cannot achieve.

I'm an experienced developer and I enforce these kinds of rules upon myself without giving it much thought, and I very much prefer the results.
In my experience it usually devs that do that to themselves after reading stuff on the internet and thinking “I want to be a professional and I want to show it to everyone.”

Then rules stay and new people just continue with same silly rules instead of thinking if those are really that useful.

Hard rules are the problem. There is a lot of "it depends."

After over 40 years of programming, I continue to reduce the size of functions and find it easier to write and understand when I return to them. Ten lines are now a personal guideline.

However, a linear function with only tiny loops or conditionals can be easily understood when hundreds of lines are long, but not so much with nested conditionals and loops, where there is natural decomposition into functions.

I observed that the same guidelines became rules problems when test coverage became popular. They soon became metrics rather than tools to think about code and tests. People became reluctant to add sanity check code for things that could should never happen because it brought down code coverage.

There are certainly functions written too cleverly to be apparent how they manage to work at all in a few lines. By my own hand six months ago sometimes. The solution is an unsexy one but always works: write a books worth of comments near that function code that explains absolutely everything and why it was done.
Doing things the right way always introduces a shackle to your ankle. Oh am I to package my functions as discrete packages I call via library name carefully crafted to install into some specific folder structure that I now have to learn and not make mistakes with. Or I can do it “improperly” and just write a function and start using it immediately.

Not everything has to be designed like some standardized mass produced part ready to drop into anything made in the last 40 years. And what is crazy is that even things written to that standard aren’t even compatible and might have very specific dependencies themselves.

Every SOLID, Clean Code, DRY and so on are all terrible advice sold by a bunch of people who haven’t worked in software development since before Python was invented. Every one of those principles are continently vague so that people like Uncle Bob can claim that you got it wrong when it doesn’t work for you. Uncle Bob is completely correct though, but maybe the reason you many others got it wrong is because the principles are continently vague. Continently because people like Uncle Bob are consultants who are happy to sell your organisation guidance. I think the biggest nail in the coffin of everything from TDD to Clean Architecture should be that they clearly haven’t worked. It’s been more than 20 years and software is more of a mess than if ever was. If all these “best practices” worked, they would have worked by now.

YAGNI is the only principle I’ve seen consistently work. There are no other mantras that work. Abstractions are almost always terrible but even a rule like “if you rewrite it twice” or whatever people come up with aren’t universal. Sometimes you want an abstraction from the beginning, sometimes you never want to abstract. The key is always to keep the cognitive load as low as possible as the author talks about. The same is true for small functions, and I’ve been guilty of this. It’s much worse to have to go through 90 “go to definition” than just read through one long function.

Yet we still teach these bad best practices to young developers under the pretence that it works and that everything else is technical debt. Hah, technical debt doesn’t really exist. If you have to go back and replace part of your Python code with C because it’s become a bottle neck that means you’ve made it. 95% of all software (and this number is angry man yelling at clouds) will never need to scale because it’ll never get more than a few thousand users at best. Even if your software blows up chances are you won’t know where the future bottle necks will be so stop trying to solve them before you run into them.

I think Cognitive Load on a developer includes distractions/interruptions. Constant slack notifications, taps on the shoulder, meetings, etc. increase cognitive load. It's context switching. One only has so much memory and focus, to switch tasks one has additional overhead, thinking and memory/storage demands.
IMO cognative load is much easier to manage when required (human) memory use is less of a factor. In practical terms, this means maximising the locality of reasoning, i.e., having everything you need in front of you to make a decision. One of the reasons I favour rust is precisely because this factor has been a focus in the design.