I've been thinking about the notion of "reasoning locally" recently. Enabling local reasoning is the only way to scale software development past some number of lines or complexity. When reasoning locally, one only needs to understand a small subset, hundreds of lines, to safely make changes in programs comprising millions.
I find types helps massively with this. A function with well-constrained inputs and outputs is easy to reason about. One does not have to look at other code to do it. However, programs that leverage types effectively are sometimes construed as having high cognitive load, when it in fact they have low load. For example a type like `Option<HashSet<UserId>>` carries a lot of information(has low load): we might not have a set of user ids, but if we do they are unique.
The discourse around small functions and the clean code guidelines is fascinating. The complaint is usually, as in this post, that having to go read all the small functions adds cognitive load and makes reading the code harder. Proponents of small functions argue that you don't have to read more than the signature and name of a function to understand what it does; it's obvious what a function called last that takes a list and returns an optional value does. If someone feels compelled to read every function either the functions are poor abstractions or the reader has trust issues, which may be warranted. Of course, all abstractions are leaky, but perhaps some initial trust in `last` is warranted.
> A function with well-constrained inputs and outputs is easy to reason about.
It's quite easy to imagine a well factored codebase where all things are neatly separated. If you've written something a thousand times, like user authentication, then you can plan out exactly how you want to separate everything. But user authentication isn't where things get messy.
The messy stuff is where the real world concepts need to be transformed into code. Where just the concepts need to be whiteboarded and explained because they're unintuitive and confusing. Then these unintuitive and confusing concepts need to somehow described to the computer.
Oh, and it needs to be fast. So not only do you need to model an unintuitive and confusing concept - you also need to write it in a convoluted way because, for various annoying reasons, that's what performs best on the computer.
Oh, and in 6 months the unintuitive and confusing concept needs to be completely changed into - surprise, surprise - a completely different but equally unintuitive and confusing concept.
Oh, and you can't rewrite everything because there isn't enough time or budget to do that. You have to minimally change the current uintuitive and confusing thing so that it works like the new unintuitive and confusing thing is supposed to work.
Oh, and the original author doesn't work here anymore so no one's here to explain the original code's intent.
> Where just the concepts need to be whiteboarded and explained because they're unintuitive and confusing.
they're intuitive to somebody - just not the software engineer. This simply means there's some domain expertise which isn't available to the engineer.
IMO the fact that code tends to become hard over time in the real world, is even more reason to lower cognitive load. Because cognitive load is related to complexity. Things like inheritance make it far too easy to end up with spaghetti. So if it's not providing significant benefit, god damn don't do it in the first place (like the article mentions).
> Oh, and the original author doesn't work here anymore so no one's here to explain the original code's intent.
To be fair, even if I still work there I don't know that I'm going to be of much help 6 months later other than a "oh yeah, I remember that had some weird business requirements"
Might I recommend writing those weird business requirements down as comments instead of just hoping someone will guess them six months down the line?
Thank god we’re held to such low standards. Every time I’ve worked in a field like pharmaceuticals or manufacturing, the documentation burden felt overwhelming by comparison and a shrug six months later would never fly.
We are not engineers. We are craftsmen, instead of working with wood, we work with code. What most customers want is an equivalent of "I need a chair, it should look roughly like this."
If they want blueprints and documentation (e.g. maximum possible load and other limits), we can supply (and do supply, e.g. in pharma or medicine), but it will cost them quite a lot more. By the order of magnitude. Most customers prefer cobbled up solution that is cheap and works. That's on them.
Edit: It is called waterfall. There is nothing inherently wrong with it, except customers didn't like the time it took to implement a change. And they want changes all the time.
there is difference between building a dashboard for internal systems and tech that if failed can kill people
Well, a hand watch or a chair cannot kill people, but the manufacturing documentation for them will be very precise.
Software development is not engineering because it is still relatively young and immature field. There is a joke where a mathematician, a physicist and a engineer are given a little red rubber ball and asked to find its volume. The mathematician measures the diameter and computes, the physicist immerses the ball into water and sees how much was displaced, and an the engineer looks it up in his "Little red rubber balls" reference.
Software development does not yet have anything that may even potentially grow into such a reference. If we decide to write it we would not even know where to start. We have mathematicians who write computer science papers; or physicists who test programs; standup comedians, philosophers, everyone. But not engineers.
Most software work in pharma and manufacturing is still CRUD, they just have cultures of rigorous documentation that permeates the industry even when it's low value. Documenting every little change made sense when I was programming the robotics for a genetic diagnostics pipeline, not so much when I had to write a one pager justifying a one line fix to the parser for the configuration format or updating some LIMS dependency to fix a vulnerability in an internal tool that's not even open to the internet.
> Oh, and in 6 months the unintuitive and confusing concept needs to be completely changed into - surprise, surprise - a completely different but equally unintuitive and confusing concept.
But you have to keep the old way of working exactly the same, and the data can't change, but also needs to work in the new version as well. Actually show someone there's two modes, and offer to migrate their data to version 2? No way - that's confusing! Show different UI in different areas with the same data that behaves differently based on ... undisclosed-to-the-user criteria. That will be far less confusing.
As a user 'intuitive' UIs that hide a bunch of undisclosed but relevant complexity send me into a frothing rage.
In many problem spaces, software developers are only happy with interfaces made for software developers. This article diving into the layers of complex logic we can reason about at once perfectly demonstrates why. Developers ‘get’ that complexity, because it’s our job, and think about GUIs as thin convenience wrappers for the program underneath. To most users, the GUI is the software, and they consider applications like appliances for solving specific problems. You aren’t using the refrigerator, you’re getting food. You’re cooking, not using the stove. The fewer things they have to do or think about to solve their problem to their satisfaction, the better. They don’t give a flying fuck about how software does something, probably wouldn’t bother figuring out how to adjust it if they could, and the longer it takes them to figure out how to apply their existing mental models UI idioms to the screen they’re looking at, the more frustrated they get. Software developers know what’s going on behind the scenes so seeing all of the controls and adjustments and statuses and data helps developers orient themselves save figure out what they’re doing. Seeing all that stuff is often a huge hindrance to users that just have a problem they need to solve, and have a much more limited set of mental models and usage idioms they need to use figuring how which of those buttons to press and parameters to adjust. That’s the primary reason FOSS has so few non-technical users.
The problem comes in when people that aren’t UI designers want to make something “look designed” so they start ripping stuff out and moving it around without understanding how it works affect different types of users. I don’t hear too many developers complain about the interface for iMessage for example despite having a fraction of the controls visible at any given time, because it effectively solves their problem, and does so easier than with a visible toggle for read receipts, SMS/iMessages, text size, etc etc etc. It doesn’t merely look designed, it it’s designed for optimal usability.
Developers often see an interface that doesn’t work well for developers usage style, assume that means it doesn’t work well, and then complain about it among other developers creating an echo chamber. Developers being frustrated with an interface is an important data point that shouldn’t be ignored, but our perspectives and preferences aren’t nearly as generalizable some might think.
I'm trying to learn acceptance: how not to get so angry at despicable UIs.
Although I admit I'm kinda failing. My minor successes have been by avoiding software: e.g. giving up programming (broken tools and broken targets were a major frustration) and getting rid of Windows.
Having given up programming, what do you do now?
Sounds like a bunch of excellent excuses why code is not typically well factored. But that all just seems to make it more evident that the ideal format should be more well-factored.
Plus to everything said. It's an everyday life of "maintainer", picking the next battle to pick the best way to avoid sinking deeper and defending the story that exactly "this" is the next refactoring project. All that while balancing different factors as you mention to actually believe oneself, because there are countless of paths..
This puts things really well. I’ll add into it that between the first white boarding session and the first working MVP there’ll be plenty of stakeholders who change their mind, find new info, or ask for updates that may break the original plan
It can be done. Sometimes.
I am so proud and happy, when I can make a seemingly complicated change quickly, because the architecture was well designed and everthing neatly seperated.
Most of the time though, it is exactly like you described. Or randalls good code comic:
Allmost too painful to be funny, when you know the pain is avoidable in theory.
Still, it should not be an excuse to be lazy and just write bad code by default. Developing the habit of making everything as clean, structured and clear as possible allways pays of. Especially if that code, that was supposed to be a quick and dirty throw away code experiment somehow ended up being used and 2 years later you suddenly need to debug it. (I just experienced that joy)
> If someone feels compelled to read every function either the functions are poor abstractions or the reader has trust issues, which may be warranted.
I joined a company with great code and architecture for 3 months last year. They deal with remittances and payments.
Their architecture leads are very clued up, and I observed that they spent a lot of quality time figuring out their architecture and improvements, continuously. They'd do a lot of refactors for all the various teams, and the cadence of feature development and release was quite impressive.
In that period though, I and another long-standing colleague made a few errors that cost the company a lot of money, like an automated system duplicating payments to users for a few hours until we noticed it.
Part of their architectural decision was to use small functions to encapsulate logic, and great care and code review was put into naming functions appropriately (though they were comment averse).
The mistakes we committed, were because we trusted that those functions did what they said they did correctly. After all, they've also been unit tested, and there's also integration tests.
If it weren't for the fortitude of the project manager (great guy hey) in firmly believing in collective responsibility if there's no malice, I'd probably have been fired after a few weeks (I left for a higher offer elsewhere).
---
So the part about trust issues resonates well with me. As a team we made the decision that we shouldn't always trust existing code, and the weeks thereafter had much higher cognitive load.
That sounds like a very difficult situation. Would you be willing to elaborate on what kinds of bugs lay in the pre-existing functions? Was some sort of operation that was supposed to be idempotent (“if you call it with these unique parameters over and over, it will be the same as if you only called it once”) not so? I am trying to imagine what went wrong here. A tough situation, must have been quite painful. How serious were the consequences? If you don’t feel comfortable answering that is okay.
I can't remember the exact detail, but one instance was a function checking whether a user should be paid based on some conditions. It checked the db, and I think because the codebase and db move fast, there was a new enum added a few months prior which was triggered by our transaction type.
So that helped function didn't account for the new enum, and we ended up sending >2 payments to users, in some cases I think over 10 to one user.
The issue was brought to customer support's attention, else we might have only noticed it at the end of the week, which I think would have led to severe consequences.
The consequences never reached us because our PM dealt with them. I suppose in all the financial loss instances, the business absorbed the losses.
In regards to small functions, I think an important - but not often mentioned - aspect is shared assumptions. You can have many small functions with garbage abstractions that each implictly rely on the behaviour of each other - therefore the cognitive load is high. Or, you can have many small functions which are truly well-contained, in which case you may well need not read the implementation. Far too much code falls into the former scenario, IMO.
100% agree and this not only concerns readability. The concept of "locality" turns out to be a fairly universal concept, which applies to human processes just as much as technical ones. Side-effects are the root of all evil.
You don't see a waiter taking orders from 1 person on a table, but rather go to a table and get orders from everybody sitting there.
And as for large methods, I find that they can be broken into smaller once just fine as long as you keep them side-effect free. Give them a clear name, a clear return value and now you have a good model for the underlying problem you are solving. Looking up the actual definition is just looking at implementation details.
> I've been thinking about the notion of "reasoning locally" recently. Enabling local reasoning is the only way to scale software development past some number of lines or complexity. When reasoning locally, one only needs to understand a small subset, hundreds of lines, to safely make changes in programs comprising millions.
That was supposedly the main trait of object-oriented programming. Personally that was how it was taught to me: the whole point of encapsulation and information hiding is to ensure developers can "reason locally", and thus be able to develop more complex projects by containing complexity to specific units of execution.
Half of SOLID principles also push for that. The main benefit of Liskov's substitution principle is ensure developers don't need to dig into each and every concrete implementation to be able to reason locally about the code.
On top of that, there are a multitude of principles and rules of thumb that also enforce that trait. For example, declaring variables right before they are used the first time. Don't Repeat Yourself to avoid parsing multiple implementations of the same routine. Write Everything Twice to avoid premature abstractions and tightly coupling units of execution that are actually completely independent, etc etc etc.
Heck, even modularity, layered software architectures, and even microservices are used to allow developers to reason locally.
In fact, is there any software engineering principle that isn't pushing for limiting complexity and allowing developers to reason locally?
Encapsulation is the good part of object-oriented programming for precisely this reason, and most serious software development relies heavily on encapsulation. What's bad about OOP is inheritance.
Microservices (in the sense of small services) are interesting because they are good at providing independent failure domains, but add the complexity of network calls to what would otherwise be a simple function call. I think the correct size of service is the largest you can get away with that fits into your available hardware and doesn't compromise on resilience. Within a service, use things like encapsulation.
Inheritance is everyone's favorite whipping boy, but I've still never been in a codebase and felt like the existing inheritance was seriously hindering my ability to reason about it or contribute to it, and I find it productive to use on my own. It makes intuitive sense and aids understanding and modularity/code resuse when used appropriately. Even really deep inheritance hierarchies where reasonable have never bothered me. I've been in the industry for at least 8 years and a volunteer for longer than that, and I'm currently in a role where I'm one of the most trusted "architects" on the team, so I feel like I should "get it" by now if it's really that bad. I understand the arguments against inheritance in the abstract but I simply can't bring myself to agree or even really empathize with them. Honestly, I find the whole anti-inheritance zeitgeist as silly and impotent as the movement to replace pi with tau, it's simply a non-issue that's unlikely to be on your mind if you're actually getting work done IMHO.
The problem of inheritance is that it should be an internal mechanism of code reuse, yet it is made public in a declarative form that implies a single pattern of such reuse. It works more or less but it also regularly runs into limitations imposed by that declarativeness.
For example, assume I want to write emulators for old computer architectures. Clearly there will be lots of places where I will be able to reuse the same code in different virtual CPUs. But can I somehow express all these patterns of reuse with inheritance? Will it be clearer to invent some generic CPU traits and make a specific CPU to inherit several such traits? It sounds very unlikely. It probably will be much simpler to just extract common code into subroutines and call them as necessary without trying to build a hierarchy of classes.
Or lets take, for example, search trees. Assume I want to have a library of such trees for research or pedagogic purposes. There are lots of mechanisms: AVL trees, 2-3, 2-3-4, red-black, B-Trees and so on. Again there will be places where I can reuse the same code for different trees. But can I really express all this as a neat hierarchy of tree classes?
So you've never worked on a code base with a 3-level+ deep inheritance tree and classes accessing their grandparent's protected member variables and violating every single invariant possible?
> 3-level+ deep inheritance tree and classes accessing their grandparent's protected member variables
Yes, I have. Per MSDN, a protected member is accessible within its class and by derived class instances - that's the point. Works fine in the game I work on.
> violating every single invariant possible
Sure, sometimes, but I see that happen without class inheritance just as often.
I'm glad it's been useful to you!
I can only share my own experience here. I'm thinking of a very specific ~20k LoC part of a large developer infrastructure service. This was really interesting because it was:
* inherently complex: with a number of state manipulation algorithms, ranging from "call this series of external services" to "carefully written mutable DFS variant with rigorous error handling and worst-case bounds analysis".
* quite polymorphic by necessity, with several backends and even more frontends
* (edit: added because it's important) a textbook case of where inheritance should work: not artificial or forced at all, perfect Liskov is-a substitution
* very thick interfaces involved: a number of different options and arguments that weren't possible to simplify, and several calls back and forth between components
* changing quite often as needs changed, at least 3-4 times a week and often much more
* and like a lot of dev infrastructure, absolutely critical: unimaginable to have the rest of engineering function without it
A number of developers contributed to this part of the code, from many different teams and at all experience levels.
This is a perfect storm for code that is going to get messy, unless strict discipline is enforced. I think situations like these are a good stress test for development "paradigms".
With polymorphic inheritance, over time, a spaghetti structure developed. Parent functions started calling child functions, and child functions started calling parent ones, based on whatever was convenient in the moment. Some functions were designed to be overridden and some were not. Any kind of documentation about code contracts would quickly fall out of date. As this got worse, refactoring became basically impossible over time. Every change became harder and harder to make. I tried my best to improve the code, but spent so much time just trying to understand which way the calls were supposed to go.
This experience radicalized me against class-based inheritance. It felt that the easy path, the series of local decisions individual developers made to get their jobs done, led to code that was incredibly difficult to understand -- global deterioration. Each individual parent-to-child and child-to-parent call made sense in the moment, but the cumulative effect was a maintenance nightmare.
One of the reasons I like Rust is that trait/typeclass-based polymorphism makes this much less of a problem. The contracts between components are quite clear since they're mediated by traits. Rather than relying on inheritance for polymorphism, you write code that's generic over a trait. You cannot easily make upcalls from the trait impl to the parent -- you must go through a API designed for this (say, a context argument provided to you). Some changes that are easy to do with an inheritance model become harder with traits, but that's fine -- code evolving towards a series of messy interleaved callbacks is bad, and making you do a refactor now is better in the long run. It is possible to write spaghetti code if you push really hard (mixing required and provided methods) but the easy path is to refactor the code.
(I think more restricted forms of inheritance might work, particularly ones that make upcalls difficult to do -- but only if tooling firmly enforces discipline. As it stands though, class-based inheritance just has too many degrees of freedom to work well under sustained pressure. I think more restricted kinds of polymorphism work better.)
Encapsulation arguably isn’t a good part, either. It encourages complex state and as a result makes testing difficult. I feel like stateless or low-state has won out.
Hmm, to me encapsulation means a scheme where the set of valid states is a subset of all representable states. It's kind of a weakening of "making invalid states unrepresentable", but is often more practical.
Not all strings are valid identifiers, for example, it's hard to represent "the set of all valid identifiers" directly into the type system. So encapsulation is a good way to ensure that a particular identifier you're working with is valid -- helping scale local reasoning (code to validate identifiers) up into global correctness.
This is a pretty FP and/or Rust way to look at things, but I think it's the essence of what makes encapsulation valuable.
What you’re talking about is good design but has nothing to do with encapsulation. From Wikipedia:
> In software systems, encapsulation refers to the bundling of data with the mechanisms or methods that operate on the data. It may also refer to the limiting of direct access to some of that data, such as an object's components. Essentially, encapsulation prevents external code from being concerned with the internal workings of an object.
You could use encapsulation to enforce only valid states, but there are many ways to do that.
Well whatever that is, that's what I like :)
In theory, you could design a parallel set of software engineering best practices which emphasize long-term memory of the codebase over short-term ability to leaf through and understand it. I guess that would be "reasoning nonlocally" in a useful sense.
In practice I think the only time this would be seen as a potentially good thing by most devs is if it was happening in heavily optimized code.
> The main benefit of Liskov's substitution principle is ensure developers don't need to dig into each and every concrete implementation to be able to reason locally about the code.
Yeah, but doesn't help in this context (enable local reasoning) if the objects passed around have too much magic or are mutated all over the place. The enterprise OOP from 2010s was a clusterfuck full of unexpected side effects.
I suspect that enterprise anything is going to be a hot mess, just because enterprises can't hire many of the best people. Probably the problem we should address as an industry is: how to produce software with mostly low wattage people.
The eventual solution will probably be to replace the low wattage people with high wattage machines.
Out of curiosity I sometimes rewrite things as spaghetti (if functions are short and aren't called frequently) or using globals (if multiple functions have to many params) it usually doesn't look better and when it does it usually doesn't stay that way for very long. In the very few remaining cases I'm quite happy with it. It does help me think about what is going on.
Re: trust issues...I'd argue this is the purpose of automated tests. I think tests are too often left out of architectural discussions as if they are some additional artifact that gets created separately from the running software. The core / foundational / heavily reused parts of the architecture should have the most tests and ensure the consumers of those parts has no trust issues!
I may be wrong, but my view of software is : you have functions, and you have the order in which functions are called. Any given function is straightforward enough, if you define its function clearly and keep it small enough - both of which can reasonably be done. Then we have the problem, which is the main problem, of the order in which functions are called. For this, I use a state machine. Write out the state machine, in full, in text, and then implement it directly, one function per state, one function per state transition.
The SM design doc is the documentation of the order of function calling, it is exhaustive and correct, and allows for straightforward changes in future (at least, as straightforward as possible - it is always a challenge to make changes).
Types? "Option<HashSet<UserId>>" means almost nothing to me. A well defined domain model should indicate what that structure represents.
Even that means a lot more than `{}`, who's tortured journeys I have to painstakingly take notes om in the source code while I wonder what the heck happened to produce the stack trace...
This is absolutely the right way to think about things.
I like thinking about local reasoning in terms of (borrowing from Ed Page) "units of controversy". For example, I like using newtypes for identifiers, because "what strings are permitted to be identifiers" is a unit of controversy.
The larger problem are things that have global effect: databases, caches, files, static memory, etc. Or protocols between different systems. These are hard to abstract away, usually because of shared state.
I've seen functions called getValue() that were actually creating files on disk and writing stuff.
Also, even if the function actually does what advertised, I've seen functions that go 4-5 levels deep where the outer functions are just abstracting optional parameters. So to avoid exposing 3 or 4 parameters, tens of functions are created instead.
I think you do have a point but ideas get abused a lot.
There is an issue of reading a code that is written by somebody else. If it's not in a common style, the cognitive load of parsing how it's done is an overhead.
The reason I used to hate Perl was around this, everyone had a unique way of using Perl and it had many ways to do the same thing.
The reason I dislike functional programming is around the same, you can skin the cat 5 ways, then all 5 engineers will pick a different way of writing that in Typescript.
The reason I like Python more is that all experienced engineers will eventually gravitate towards the idea of Pythonic notion and I've had colleagues whose code looked identical to how I'd have written it.
I'm a fan of using the least number of language features to get the job done. If a language is simple and can be stepped through easily, one benefits from the removal of the added cognitive load of knowing a large number of language features. This provides extra brainspace to understand the problem space and the system one is working on. Most importantly, it makes it easier to have the whole shebang in your mind while you add code (correctly.)
And it adds to maintainability (so long as done in a balanced way!)
I had a boss who saw me looking out a window say, "You look like you are concentrating. I'll come back later."
I document EVERYTHING I think straight away into their appropriate documents so I can forget about it while I'm loading as much of a system's design into my head as I can. It allows me to write good code during that small window of available zen. After years of doing that, I made a document about documentation. Hope it's of use. https://pcblues.com/assets/approaching_software_projects.pdf
Also, it's important that you make your own templates for each of the document types, because thinking about them is part of the design process. It reduces the cognitive load of understanding yet another set of design document templates, and they are malleable in your own hands :)
> 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.
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'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.
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.
> 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”.
> 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.
The way I normally approach this is one big pr for context and then break it into lots of small ones for review.
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.
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.
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
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.
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 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).
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'
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.
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.
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.
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
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.
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.
> 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
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.
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.
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".
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.
You seem to be describing a company where bureaucracy is a feature not a bug.
Been there. Left, live thousands times better.
> 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?
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..
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.
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.
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.)
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)
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 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.
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.
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.
>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'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.
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.
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.)
You're confusing this with a software development process problem. It's really just good old fashioned psychological abuse.
This is the best article I've read on programming in a while. When I code all I do is work on one object or one function at a time. Working usually on the end result. The key here is to develop a quick and easy solution without too many issues. This is also the difference between new and experienced developers. Experienced developers have less fluid intelligence so build reliable simple programs.
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?
> 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.
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.
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.
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.
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.
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.
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.
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.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.”
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.
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.
It's a call for others to take note of the important or profound message being highlighted. So more than just "I agree".
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.
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.
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 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.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.
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.
>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.
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.
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.
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.
It works fine. Not all the methods need to use all the struct members.
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.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.
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)
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.
> 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 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?
The spirit of this piece is excellent, and introduces some useful terms from psychology to help codify - and more importantly, explain - how to make tasks less unnecessarily demanding.
However, as someone who spends their days teaching and writing about cognitive psychology, worth clarifying that this isn’t quite correct:
Intrinsic - caused by the inherent difficulty of a task. It can't be reduced, it's at the very heart of software development.
Intrinsic load is a function of the element interactivity that results within a task (the degree to which different elements, or items, that you need to think about interact and rely upon one another), and prior knowledge.
You can’t really reduce element interactivity if you want to keep the task itself intact. However if it’s possible to break a task down into sub tasks then you can often reduce this somewhat, at the expense of efficiency.
However, you can absolutely affect the prior knowledge factor that influences intrinsic load. The author speaks of the finding from Cowan (2001) that working memory can process 4+—1 items simultaneously, but what most people neglect here is that what constitutes an “item” is wholly depending upon the schemas that a given person has embedded in their long-term memory. Example: someone with no scientific knowledge may look at O2 + C6H12O6 -> CO2 + H2O as potentially up to 18 items of information to handle (then individual characters), whereas someone with some experience of biology may instead handle this entire expression as a single unit - using their knowledge in long-term memory to “chunk” this string as a single unit - ‘the unbalanced symbol equation for respiration’.
Another good article, not directly related to work tasks, but related to unnecessary complexity: https://news.ycombinator.com/item?id=30802349
Another interesting thing is when there is inherent complexity in the system, things remain simple.
For example in game programming, nobody is doing function currying.
And yet in React and frontend land because it is a button on screen which toggles a boolean field in the db, there are graphs, render cycles, "use client", "use server", "dynamic islands", "dependency arrays" etc. This is the coding equivalent of bullshit jobs.
> Types of cognitive load - Extrinsic/Intrinsic
This neatly mirrors the central ideas presented in Out of the Tar Pit [0], which defines accidental and essential complexity.
Reading this paper was probably one of the biggest career unlocks for me. You really can win ~the entire game if you stay focused on the schema and keep in touch with the customer often enough to ensure that it makes sense to them over time.
OOTP presents a functional-relational programming approach, but you really just need the relational part to manage the complexity of the domain. Being able to say that one domain type is relevant to another domain type, but only by way of a certain set of attributes (in a 3rd domain type - join table), is an unbelievably powerful tool when used with discipline. This is how you can directly represent messy real world things like circular dependencies. Modern SQL dialects provide recursive CTEs which were intended to query these implied graphs.
Over time, my experience has evolved into "let's do as much within the RDBMS as we possibly can". LINQ & friends are certainly nice to have if you need to build a fancy ETL pipeline that interfaces with some non-SQL target, but they'll never beat a simple merge statement in brevity or performance if the source & target of the information is ultimately within the same DB scope. I find myself spending more time in the SQL tools (and Excel) than I do in the various code tools.
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.
> 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.
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.
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.
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.
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?
> 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.
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
> 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.
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...
> but nobody wants to say something positive about JavaScript...
For obvious reasons...
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.
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.
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.
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.
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…
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.
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).
Also Scala
> 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.
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.
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)
> 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.
> 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. 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.
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.
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.
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.
Improvement is always the product of submitting a JEP or JSR.
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.
Completely agree! Common Lisp is truly the tool of the Gods.
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.
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.
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/
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.
I agree with the author's point. It was the founding principle of "object oriented" programming in that once the object was a black box, you could just rely on it working and let go of the implementation details. But it is also important in day to day work. I remember when I realized that I could only keep six distinct "disasters" in my head at the same time. Trying to add another one would pop one out of my head. So much of life can be simplified if you systematize things so that the thing to remember is small, like all of the stuff you take on business trips (shave kit, travel charger, noise cancelling headphones, whatever) if you put all of it in a box in your closet labelled "travel kit" then you only have to remember three things, outfits for 'n' days, your laptop, and your travel kit. Got those three and you are good to go.
> Involve junior developers in architecture reviews. They will help you to identify the mentally demanding areas.
This.
And also, a mantra of my own: Listen carefully to any newcomer in the team/company in first 1-3 weeks, until s/he gets accustomed (and/or stop paying attention to somewhat uneasy stuff). They will tell you all things that are, if not wrong, at least weird.
I second this, but one should also be extremely wary of newcomers feedback and try to understand their nature.
Some people are extremely resistant to new ideas, some might be simply lazy, some can't be bothered to read documentations, etc.
Spotting the real person behind the feedback is crucial and often those people need to be fired fast.
I myself tend to be lazy when it comes to learn new stuff/patterns, especially when I am in the middle of having to progress a project so my own feedback may be more of a frustration for my inability to progress due to having to understand first a, b, c and d which may take considerable time and pain for something I can do in an old way in few minutes.
Aye, but the joiners may need prompting as well as getting listened to.
In each place where I've seen something wildly wrong, the problem has been clear in the first few weeks — sometimes even in the first few days* — but I always start with the assumption that if I disagree with someone who has been at it for years, they've probably got good reasons for the stuff that surprises me.
Unfortunately I'm not very convincing: when I do finally feel confident enough to raise stuff, quite often they do indeed have reasons… bad reasons that ultimately prove to be fatal or near-fatal flaws to their business plans, but the issues only seldom get fixed once I raise them.
* one case where the problem was visible in the interview, but I was too young and naive so I disregarded what I witnessed, and I regretted it.
Junior developers probably won't say anything, because they are used to not understanding code, and they are not going to second guess the more-experienced author.
It's definitely on the senior to prompt the junior appropriately. But when you do, they will.
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.
> 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.
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.
The central idea around cognitive load is very good, central to writing good code.
But it's deeply mistaken to oppose smaller (or more correctly: simpler) classes/functions and layered architecture.
Layered architecture and simple (mostly small) classes and methods are critical to light cognitive load.
e.g. You should not be handling database functionality in your service classes, nor should you be doing business logic in your controllers. These different kinds of logic are very different, require different kinds of knowledge. Combining them _increases_ cognitive load, not decreases.
It's not mainly about swapping out dependencies (although this is an important benefit), it's about doing one thing at a time.
To make this more concrete:
If your service layer method requires data to be saved and the results to be sorted, you want to call a data layer method that saves it and a library method that sorts it. You do not want any of that saving or sorting functionality in your service method.
Combining different layers and different tasks so that your module is "deep" rather than "shallow" will make your code much higher cognitive load and create a lot of bugs.
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.
> Layered architecture: Abstraction is supposed to hide complexity, here it just adds indirection
Agree with everything except this. As someone who deals with workflows and complex business domains, separating your technical concerns from your core domain is not only necessary. They are a key means to survival.
Once you have 3 different input-channels and 5 external systems you need to call, you absolutely need to keep your distance not to pollute your core representation of the actual problem you need to solve.
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
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.
Programmers and tech people should understand why cognitive load needs to be reduced.
All of us would scream if we saw how some bureaucrat at a government office makes you fill out some form digitally only to print it out and then type off the printout, only to print it out and give it to their collegue, who.. you get the point.
This is a problem that could be solved perfectly by a good IT process — a process which instead of multiplying work instead reduces it.
Yet programmers and nerds tend to similar wasteful behaviour when it comes to cognitive load. "Why should I explain the code — it speaks for itself" can be similarily foolish. You already spent all that time to think about and understand your code, so why let that all go to waste and throw out all clues that would help even other hardcore nerds to orient themselves? Good code is clear code, good projects are like a good spaceship: the hero who never has been in this ship knows which button to press, because you — the engineer — made sure the obvious button does the obvious thing. More often than not that hero is your future self.
People reading our code, readmes and using our interfaces got all kind of things on their minds, the best we can do is not waste their mental capacity without a good reason.
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.
I've sometimes seen people attack early returns and I've never understood it. To me they make things so much cleaner that it seems like common sense.
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.
I'm sorry but this article is really bad.
I agree that cognitive load matters. What this article completely fails to understand is that you can learn things, and when you've learned and internalised certain things, they stop occupying your mental space.
"Reducing mental load" should be about avoiding non-local reasoning, inconsistencies in style/patterns/architecture, and yes, maybe even about not using some niche technology for a minor use case. But it shouldn't be about not using advanced language features or avoiding architecture and just stuffing everything in one place.
you're right. but it's so hard to enforce.
Aligning the computer's and the humans' thinking processes. Cognitive load is exceptionally important - one of the few uncontravenable facts in human psychology is that healthy human short term memory has a capacity of 5 items plus or minus 2. So reliably 5. And thus the maximum number of thinking balls you should be juggling at one time.
Which then leads to thinking about designs that lead to the management of cognitive load - thus the nature of the balls changes due to things like chunking and context. Which are theoretical constructs that came out of that memory research.
So yes, this is pretty much principal zero - cognitive load and understanding the theory underneath it are the most important thing - and are closely related to the two hard problems in computer science (cache invalidation, naming things and off by one errors).
Thank you for attending my TED talk
That’s why I strangely love some closed enterprise solutions. They are anti-fun, non-extensible behemots that you can learn in around five years and then it’s basically all free. If you can stand everything else, ofc.
The open source world could learn from that, by holding up on spiral rotation of ideas (easily observable to turn 360 in under a decade) and not promoting techniques that are not fundamental to a specific development environment. E.g. functional or macro/dsl ideas in a language that is not naturally functional or macro/that-dsl by stdlib or other coding standards. Or complex paradigms instead of few pages of clear code.
Most of it comes from the ability to change things and create idioms, but this ability alone doesn’t make one a good change/idiom designer. As a result, changes are chaotic and driven by impression rather than actual usefulness (clearly indicated by spiraling). Since globally there’s no change prohibition, but “mainstream” still is a phenomenon, the possibility of temperate design is greatly depressed.
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 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.
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.
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.
When one of these articles come up, I always wonder if the authors have ever looked at APL-family languages and the people who use them, or those who have a similar ultra-compact style even with more mainstream languages; here are the most memorable examples that come to mind:
https://news.ycombinator.com/item?id=8558822
https://news.ycombinator.com/item?id=28491562
What's the "cognitive load" of these? Would you rather stare at a few lines of code for an hour and be enlightened, or spend the same amount of time wading through a dozen or more files and still struggle to understand how the whole thing works?
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.
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.
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.
I was nodding along happily until I watched the composition is better than inheritance linked video and it suggested the abomination of passing a "base" class instance to the save method of a more specific class instance to give the specific class access to functionality on the "base" class. There may be a solid argument for composition over inheritance but this bastardization of functional and OO programming ain't it.
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.
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.
> 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.
I remember I had this argument with a CTO before about cognitive load. I was concerned of the sheer amount of code behind React/Redux for what could've been just a simple plain server rendered with jQuery sprinkled.
Her answer was "if Facebook (before meta) is doing it then so should we."
I said we aren't facebook. But all the engineers sided with her.
Said startup failed after burning $XX million dollars for a product nobody bought.
One day, the world will rediscover functional programming. The absence of state mutation is a beautiful thing.
Further reading: "The Magical Number Seven, Plus or Minus Two" https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus...
I hate these silly little code examples where they show some if condition being changed to another format and act as if that's real advice that helps you program.
Very cool article. Semantics are important and key. Article nails it.
Reduce, reduce, and then reduce farther.
I'll add my 2c: Ditch the cargo cults. Biggest problem in software dev. I say this 30 years in.
Hard lesson for any young engineers: You don't need X, Y, or Z. It's already been invented. Stop re-inventing. Do so obsessively. You don't GraphQL. You don't need NoSQL. Downvote away.
Pick a language, pick a database, get sleep.
Never trust the mass thought. Cargo culting is world destroying.
> Stop re-inventing.
> You don't GraphQL.
Isn't that... ironic? You can Postgres -> GraphQL pretty much without code (pg_graphql). And then link your interfaces with the GraphQL endpoints with little code. Why re-invent with REST APIs?
> "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.
I did my PhD at Stanford about the cognitive aspects of programming, including studies of cognitive load. This article uses pseudoscience to justify folk theories about programming. I would encourage readers to take everything with a grain of salt, and do not wave this article around as a "scientific" justification for anything.
I laid out my objections to the article last year when it first circulated: https://github.com/zakirullin/cognitive-load/issues/22
Congrats on your PhD at Stanford, but some humility has to be part of the scientific process for sure. It looks like a lot of folks called "programmers" agree with the points in the post. If it's such a common experience that should tell you something about the state of affairs.
It's a blog post on the internet. Of course one should take it with a grain of salt. The same applies to any peer-reviewed article on software engineering for example.
Just yesterday, I was watching this interview with Adam Frank [0] one of the parts that stood out was his saying why "Why Science Cannot Ignore Human Experience" (I can't find the exact snippet, but apparently he has a book with the same title.
I'm not saying that the conclusions in the article are false. As a programmer, I prefer composition to inheritance, too. I'm saying that the justifications are presented using a scientific term of art (cognitive load), but the scientific evidence regarding cognitive load isn't sufficient to justify these claims.
Not knowledgeable enough to weigh in here, I just think it's very cool that 1) the authors blog was in public source control and 2) you made a polite github issue with your criticisms and 3) it wasn't deleted.
Where was the word “scientific” mentioned in the article? I don’t think you were the target reader they had in mind when the author wrote this. I’ve been programming since 1986, and this article resonates with my experience. More abstractions, layers, and so on requires my brain to have to keep track of more shit which takes away from doing the work that brought me to work on that bit of code (bug fix, feature work, debugging, etc.)
We’re very proud of you and the hard work you did to earn your PhD, now please stop trotting it out.
The article is attempting to use a scientific term of art, "cognitive load", to justify claims about programming. Those claim cannot be justified given the existing evidence about cognitive load. As I explain in my linked response, I nonetheless agree with many of the claims, but they're best understood as folk theories than scientific theories.
And I don't think condescension will make this a productive discussion!
Maybe in your field the definition of cognitive load has a very specific, academic meaning. This article wasn’t meant for you.
My issue is that this article is trying to use cognitive load in its specific, academic meaning. It says:
> The average person can hold roughly four such chunks in working memory. Once the cognitive load reaches this threshold, it becomes much harder to understand things.
This is a paraphrase of the scientific meaning. "Intrinsic" and "extrinsic" cognitive load are also terms of art coined by John Sweller in his studies of working memory in education.
I agree the article isn't designed to be peer-reviewed science. And I agree the article has real insights that resonate with working developers. But I'm also a fan of honesty in scientific communication. When we say "vaccines prevent disease", that's based on both an enormous amount of data as well as a relatively precise theory of how vaccines work biologically. But if we say "composition reduces cognitive load", that's just based on personal experience. I think it's valuable to separate out the strength of the evidence for these claims.
You’re exhausting.
But, but he wrote a paper. He must be smart...
> 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
> If you keep the cognitive load low, people can contribute to your codebase within the first few hours of joining your company.
I guess there’s a lot of wiggle room for what is really being asserted here, but this seems like an absurd impossible claim.
The FUNDAMENTAL aspect and nature of ALL Software Engineering is: "Controlling Complexity" I dare say its the most important part of the "Engineering" in software engineering. 'Cognitive Load' is another way of saying this..
1. programming => wiring shit together and something happens
2. software engineering => using experience, skills and user and fellow engineer empathy to iterate toward a sustainable mass of code that makes something happen in a predictable way
It is exceedingly difficult to do this right and even when you do it right, what was right at one time becomes tomorrow's 'Chesterton's Fence' (https://thoughtbot.com/blog/chestertons-fence) , but I have worked on projects and code where this was achieved at least somewhat sustainably (usually under the vision of a single, great developer). Unfortunately the economics of modern development means we let the tools and environments handle our complexity and just scrap and rewrite code to meet deadlines..
I mean look at the state of web development these days https://www.youtube.com/watch?v=aWfYxg-Ypm4
Fighting games have a term for this: "mental stack".
I don't program professionally (I work as a DE but I don't consider it as a serious programming venue) so the No. 1 issue while reading medium-large source code is abstraction -- programming patterns.
I hope it improves whence I write an implementation myself.
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 don't understand the architecture section. The title is "layered architecture," but then it talks about Ports/Adapters, which would be hexagonal architecture?
Yes 100%!
Read "Code is For Humans" for more on the subject https://www.amazon.com/dp/B0CN6PQ42B
You can always tell an intellectually limited programmer when their code requires thinking too hard.
a lot of good points but i feel like one of the biggest i've learned is missing...
leaning toward functional techniques has probably had the biggest impact on my productivity in the last 10 years. some of the highest cognitive load in code comes from storing the current state of objects in ones limited memory. removing state and working with transparent functions completely changes the game. once i write a function that i trust does its job i can replace its implementation with its name in my memory and move on to the next one.
Before OOP became popular the usage of global variables was discouraged in procedural languages because it was the cause of many bugs and errors.
In OOP global state variables were renamed to instance variables and are now widely used. The problem why it was discouraged beforehand did not went away by renaming but is now spread all over the place.
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.
Is this bait?
What causes more cognitive load:
filter(odd, numbers)
vs. (n for n in numbers if odd(n))
It depends on the reader too.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.
Also depends on whether it’s obvious why I need a list of odd numbers.
I support Tailwind for precisely this reason.
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.
super interesting "short/long" slider on the right -- what made you come up with this UI concept?
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.
Well said.
Every little advantage matters. Code spacing rules for example, your eyes go to the position where it is expected without a new search. Use simple APIs, don’t use new shiny things, don’t go beyond the most simple abstraction(personal thing)
Layering (properly) is used to manage dependencies. You isolate interface logic from business logic with data in between. It lets you evolve the architecture. This is a useful abstraction, not just something academic.
I suspect part of the challenge is we’re dealing with a graph (of execution paths) but all we have to work with is a tree (file system).
Every person will prefer a different grouping the execution paths that lowers their cognitive load. But for any way you group execution paths, you exclude a different grouping that would have been beneficial to someone working at a different level of abstraction.
So you like your function that fits in one computer screen, but that increases the cognitive load on someone else who’s working on a different problem that has cross-cutting concerns across many modules. If you have separate frontend/backend teams you’ll like Rails, but a team of full stack people will prefer Django (just because they group the same things differently).
I guess this is just Conway’s law again?
Interesting. I am in agreement
But not one word about comments, and only one about naming.
Useful comments go a long way to lessening cognitive load
Good names are mnemonics, not documentation
I have worked on code bases with zero comments on the purposes of functions, and names like "next()"
And I've worked with programmers who name things like "next_stage_after_numeric_input"
I have an issue with complex conditions with or without local variable labels for readability. You really shouldn't have them at all.
At one time, they used to teach that functions should have one entry-point (this is typically a non-issue but can come up with assembly code) and one exit-point. Instead of a complex condition, I much prefer just early returns ie:
// what's going on 1
if (condition1 || condition2) {
return;
}
// what's going on 2
if (condition3 && condition4) {
return;
}
// what's going on 3
if (condition5) {
return;
}
// do the thing
return;
I prefer this style for languages that have either a GC or scoped resource management (eg RAII).
However, I think the single exit point holds merit for C, where an early return can easily and silently cause a resource leak. (Unless you use compiler-specific extensions, or enforce rust-style resource ownership, which is really hard without compiler support.)
AdminController extends UserController extends GuestController extends BaseController
That's nothing... Java enterprise programmer enters the chatCognitive 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.
Enjoyed this post. A lot of it resonated with me, here's some of my thoughts:
Too many small methods, classes or modules
Realized I was guilty of this this year: on a whim I deleted a couple of "helper" structs in a side project, and the end result was the code was much shorter and my "longest" method was about... 12 lines. I think, like a lot of people, I did this as an overreaction to those 20 times indented, multiple page long functions we've all come across and despised.
No port/adapter terms to learn
This came under the criticism of "layered architecture", and I don't think this is fair. The whole point of the ports/adaptors (or hexagon) architecture was that you had one big business logic thing in the middle that communicated with the outside world via little adapters. It's the exact opposite of horizontal layering.
People say "We write code in DDD", which is a bit strange, because DDD is about problem space, not about solution space.
1. I really should re-read the book now I'm a bit more seasoned. 2. I have noticed this is a really common pattern. Something that's more of a process, or design pattern, or even mathematical construct gets turned into code and now people can't look past the code (see also: CRDTs, reactive programming, single page applications...).
Involve junior developers in architecture reviews. They will help you to identify the mentally demanding areas.
Years later I remembered how impress that a boss of mine leveraged my disgust at a legacy codebase, and my failure to understand it as a junior (partly my fault, partly the code bases fault..), by chanelling my desire to refactor into something that had direct operational benefits, and not the shot gun scatter refactoring I kept eagerly doing.
> Too many small methods, classes or modules > Method, class and module are interchangeable in this context
Class, method, functions are NOT the only way to manage cognitive load. Other ways work well for thinking developers:
Formatting - such as a longer lines and lining up things to highlight identical and different bits.
Commenting - What a concept?! using comments to make things more clear.
Syntactic sugar, moderate use of DSL features, macros... - Is this sometimes the right way?
But yeah, if your tool or style guide or programming language even, imposes doing everything through the object system or functions, then someone clearly knew better. And reduced your cognitive load by taking away your choices /s.