Hacker News new | past | comments | ask | show | jobs | submit login
Cognitive loads in programming (rpeszek.github.io)
333 points by ajdude on Aug 31, 2022 | hide | past | favorite | 140 comments



One of the goals of any code base of significant size should be to reduce the cognitive load (not lines of code per function).

Assume the developer working here just had a 45 mins sync, has a meaningless repeat meeting about vapourware in 10 mins and a 2 hours town hall meeting after lunch... and still have to deliver that mess of a requirement you made him promise to deliver before any of these interruptions were even on his calendar!

- Always aim for declarative programming (abstract out the how it does it),

- limit the depth of the function calls (rabbit hole developers...),

- separate business logic from framework internals and

- choose composition over inheritance.

- Also avoid traits and mixins (run from the dark magic)

- don't over document the language or framework, only the eyebrow raising lines, the performance sensitive stuff and the context surrounding the file if it isn't obvious.

- name stuff to be easily grepable

Easy rules to go by, (there are probably more), they can make or break your ability to work, so that you can get interrupted 12 times an hour and still commit work.

I don't find these in books, just decades of sweating and pulling my hair "why does it have to be so hard!?" I have met plenty of senior devs who naturally do the same thing now.

The code size fallacy is a prime example of the wrong way to look at it. Plenty of extremely large code base in C++ are far more manageable than small JavaScript apps.

Mixing boilerplate framework junk with your intellectual property algorithms "what makes your software yours" is a sure way to hinder productivity in the long term.

You write code 3-4 times. You read it 365 times a year.

One last thing I recommend if you deal with a lot of interruptions and maybe multiple products, various code bases... keep a

    // @next reintegrate with X
The @next comment 'marker' is a great way to mark exactly which line of which file you were at before you left this project, for a meeting, for lunch, for the day, etc. And it allows you jump back into context by searching for @next and go. Also since it's visual and located, your brain has a much better time remembering the context, since we're good with places and visual landmarks.

It's far more efficient than roughly remembering what I was doing, looking at the last commit, scrolling endlessly through open files. Don't commit @next though :)


Nice list. I would add, near the top, "don't be clever unless it makes the code significantly easier to read for others".

For example, if your code involves a lot of linear algebra, then operator overloading the sensible operators is probably a good thing. But don't use operator overloading just to save some keystrokes.

Over-abstraction is another such case. Try to avoid adding abstraction layers until you need them.

I'd also add "make the code as self-documenting as possible". That means being verbose at the right times, such as writing meaningful identifier names.

And of course "avoid global variables". I've seen people use singletons with a read/write member, which is as much a global variable as any other.


Yes, "avoid global variables".

I forgot to write that one. In fact, I'd say, try to write everything as stateless as possible. That means no shared variables, functional style programming whenever possible (and logical, not for the sake of it).

Debugging a code base with global variables vs. passing what it needs is far easier to reason about when reading the code, but also easier to write tests for. Very good point.

State is evil, but of course you need some otherwise your software doesn't do anything real.


> no shared variables, functional style programming whenever possible

One problem is that saying to write in a "functional style" is not a reliable way to communicate intent today. You must qualify it with an explanation that includes/emphasizes the "no shared variables" part. Otherwise it's ambiguous, because it's not uncommon to come across programmers who make heavy (over)use of closures and refer to it as functional. It's the "literally" vs "figuratively" of the programming world; people say one thing that turns out to be the exact opposite of what it means. This should be called PF ("pseudo-functional") to contrast it with FP (what you're talking about).


We love to hate state, but isn't caching data the key to performance at scale?


> We love to hate state, but isn't caching data the key to performance at scale?

It seems you're mixing things up. "State" in this context doesn't mean caching results. "State" in this case means that your code has internal state that you can't control, thus your components behave unpredictably depending on factors you can't control.

Picking up your caching example, what would you say if your HTTP cache either returned your main page or some random noise in an apparently random way, even though you're always requesting the exact same URL?


You may be correct vis-a-vis OP, but I'll stick to my guns as far as 'state' being any piece of information that has to managed.

If I'm not writing pure functions, then there is some piece of information whose lifecycle suddenly requires care and feeding, especially if there are environmental factors making that state more 'interesting' than the code in view.


The advice is that state is hard to deal with, so you should try to isolate it from the rest of your program. You can even organize stateful components like a cache to isolate the stateful components.

- Make your stateful components "dumb". The cache should just have dumb put,get,delete methods.

- Make your "smart" components stateless. Your component deciding which items to cache or remove or invalidate should be passed all the state they need to make their decisions as immutable parameters. This will make your lifecycle management code much easier to test or log.

- Keep the part gluing the dumb state and the smart functions small

In the end you still have a stateful component, but as a whole it should be easier to work with. For example, it's much easier to test time-based lifecycle decisions if you pass in the current time than if the component grabs the current time from your system clock.


Programming maxims inevitably get compromised by the real world. We just try to do our best.


Using a cache (short of the language tools doing automatic memoization, or memoization hints on a function or procedure maybe) before making the code correct but slow is one of the greatest hallmarks of premature optimization. Remember Knuth said "less than 4 percent of a program typically accounts for more than half of its running time".

Write some simple code that does the thing. Then debug it. Then profile it. Then swap out for some more efficient algorithms if necessary in the hotspots. Then look at microoptimizations. In any case, don't spend more time optimizing that it will save in runtime across the lifecycle of the code.


Typically caching is not supposed to affect correctness, 'only' performance behavior.


hey seriously, i've been grappling with this: what better candidate to be a global variable than a shared cache that otherwise has to be passed through dozens of function calls (which imo adds cognitive load at many places through the stack).


I don't think passing state through functions calls add cognitive load: it just make it visible. Global state has the same cognitive load, except hidden.

If a state is passed through dozens of function calls, this may indicate that it is shared by too many parts of the code, or that it is managed at the wrong layer of the architecture. When using global state, these potential problems are difficult to spot.


> (...) which imo adds cognitive load (...)

Choosing to ignore input data does not reduce cognitive load. You are not supposed to ignore the core function of your work, and call it a good practice.


It's also famously one of the hard problems.


> For example, if your code involves a lot of linear algebra, then operator overloading the sensible operators is probably a good thing.

Operator overloading can be totally useless in linear algebra applications as the algorithms for basic linear algebra operations (read BLAS) aggregate multiple algebraic operations (axpy, gemv,gemm).


True. I was thinking mostly about things like 3d renderers and such, where you do a lot of simple operations on vectors and matrices.

I've written renderers with both operator overloading and without, and the readability of the former is, to me, vastly better.


grug write clever code once, grug need to be clever every day forever.

grug write readable code instead. readable code maybe need clever now, but no need clever in future


> Always aim for declarative programming (abstract out the how it does it)

No, no, no! Do not do this because then I have to go read all of the abstractions to actually figure out what the hell my computer is actually doing.

It seems nice in theory, but I’ve spent way too many hours stepping through over-abstracted declarative code with my debugger, getting frustrated at the opacity of it all.


As I see it, being declarative and limiting depth of function calls go hand-in-hand. Also very, very closely related is locality.

It's really hard to give a good example (because it's not an easy problem), but I really want to see a high-level procedure that describes (declares) every important thing that is going on (from a business standpoint). Then ideally, I only have to dive into each function once to get enough context on the main procedure. Any further dives are library/util-type calls (again, I'm talking super ideally here).

And even though I used the term "procedure" twice there, I'm talking about either FP or OO styles.

And of course (oh boy) the context can change everything.


I agree so much with this. I prefer that all lines in a function are at the same level of abstraction w.r.t your problem domain. One line can't be about customer accounts and the very next one about the file system!

I also feel that Local Reasoning is a very powerful capability in code. In fact, Martin Odersky the creator of Scala pointed to an article that headlined Local Reasoning as one of the clearest explanation of Why FP matters (https://twitter.com/odersky/status/1271182333467602945?lang=...).

Any abstraction (here I include complex types) is a trade-off against local reasoning. Anything that makes the reader of the code drift far away from what they're currently reading on the screen is a hit against local reasoning. So they've to be introduced judiciously.


I really don't know about this, I'm writing audio & media effects in a fairly declarative style with https://github.com/celtera/avendish and I'm so much more productive that it's not even funny - I can rewrite entire effects from scratch in the time that it used to take me to find a bug somewhere


> I’ve spent way too many hours stepping through over-abstracted declarative code, getting frustrated at the opacity of it all.

Then it’s not declarative


True from the reader's perspective. The writer begged to differ!


Arguably, the writer confused "abstract" for "generic".

> The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise. (Djikstra)


That sounds akin to the OO tendency to refactor everything to the point of one executable line of code per class.

Maybe that is the juice that makes Java the 800lb enterprise gorilla that Java is, but that juice seems hardly worth the squeeze of stack traces that go on for multiple screens.


If your abstraction leaks it’s a nightmare, but if not it’s a dream. When was the last time you had to debug a problem in the Django model code itself?


All abstractions eventually leak the higher you go. It's just about whether you want to ignore it.

These leaks are usually performance related.


As someone who works on performance sensitive code with great regularity. I would note that moore's law has beaten most attempts to be smart on performance for the last ~20+ years. If code is written correctly, with correct algorithms, in a reasonably performant language - no amount of micro-optimization will beat moore's law.


Sure. How does this connect with what I mentioned? Not being snarky, but I think I'm missing something because this remark seems to be completely random.


An abstraction that happens to harm performance is rarely a long-term problem. Eventually the hardware catches up. More software has been retired due to crappy abstractions than poor performance.


So how does this contradict what I said? The leak is still there. As I said... people just choose to ignore it.

IO has not been following moores law. So you can see high level abstractions around IO leak like crazy.

And also, there is no "retiring" going on. Retiring something because of a bad abstraction almost never happens, whether it's because of performance or not.

The bad abstractions stay forever. You just don't notice it. You just master the abstraction and keep on using it.

Learning about the leaks and learning how to manipulate the high level abstractions to get around the leaks becomes an intrinsic part of the culture. It becomes so intrinsic that most people can't even wrap their mind around the flaw in the abstraction. They get defensive if you talk about it. It's invisible, but it's still there.

SQL is the most leaky abstraction I have ever seen (in terms of performance). Learning to deal with it and all it's leaks are the norm now. People don't even refer to a query planner as a leak. The technical debt is so entrenched within our culture that it's actually considered to be a great abstraction. And who knows? Maybe mastering SQL with leaks and all is not a bad way to go. But mastering SQL involves dealing with a declarative language and manipulating it in ways that leak into the underlying implementation. No amount of optimization has fixed this.

SQL isn't the only thing with this issue. For SQL the crappy abstraction is just performance related. But you see other abstractions continued to be used because simply it's too hard to retire them. Javascript, CSS, HTML, C++. Python2 (finally this has been retired). Basically it's everywhere.

Abstractions are rarely retired. Abstractions with leaky performance problems are also never retired.


sorry IO is the wrong term. Memory has not been following moores law


I have found this to be very far from the truth, with most compute-bound or memory-bound tasks getting 10x speedups from minimal work by stuffing more work into each instruction and retiring more instructions per cycle without really changing the algorithm.


Yeah but why don’t we have both?

(Actually I think 99% of micro optimisations are harmful, it’s the higher level algorithmic optimisation that gives the best gains.)


Do you mean microopromizations done by programmers or the compiler? Because if the former, I agree (especially regarding old “wisdoms” like left shifting instead of multiplying by two, when that’s the first thing any compiler will do. But it is true of even more “complex” tricks, if anything they hurt performance).


Why is simdjson faster than json parsers that do not contain any simd intrinsics if using pshufb and movemask for string parsing hurts performance?


The former, absolutely. The compiler can do whatever the heck it wants as long as the result is the same.


Some abstractions leak more than others though, and I don’t think that has that much to do with the level at which the abstraction is. ORM-mappers are very leaky and you have to regularly read what SQL they generate and sometimes even read their source code. Most programmers will never have to read assembly generated by compilers and even less compiler source code.


Yeah you don't have to read assembly. But a higher level abstraction of a non-compiled language like python. The leakage occurs and is shown through performance.


Yes as said below, that implies you have leaky abstraction.

In that case, I'm with you, I'd rather see a pile of imperative code...


Every abstraction leakes and that is not a problem at all. Does IP leak under HTTP? Sure it does. But it is still a good abstraction used everywhere. Also, see the quote commented in a sibling comment.


It's bizarre to me how many people seem to be averse to any form of abstraction when literally anything you build is already atop an unbelievably large tower of abstraction.

We should as an industry be teaching people how to build good abstractions, not avoiding them at all costs.


Isn’t the venerable spreadsheet a popular and quite working example of a declarative functional environment?


the OP should preface the abstraction bulletpoint with a caveat that the abstraction must be well formed and logical.

spreadsheets are a great abstraction on calculating values based on tabular data.

ORM is not such a great abstraction on top of SQL.


> ORM is not such a great abstraction on top of SQL.

I agree wholeheartedly with that. I'm not sure that's the direction an ORM should be targeting the abstraction in the first place. It seems more productive to abstract the code away from the point of view of the database than to abstract the database away from the point of view of the code.


or ORM should be only abstracting the slightly different syntax of different database vendor's sql implementation, not the actual mapping of objects or fields to database values.

I think the database's query results should be exposed directly, so that the developer could control the DB properly and directly (such as get access to cursors, etc)


It wouldn't really be an ORM at that point, but there's definitely a point to be made that an ORM is the wrong level of abstraction altogether when dealing with data-heavy applications. I actually really hate seeing SQL embedded in a web application serving things to customers, spread out across a bunch of routes and sometimes different route servers accessing the same database. Having a data API service for your data, or one for each major kind of data (for a store let's say customers, products, orders, etc) that talks to the DB and another layer with the application logic that reads and writes through that layer works well for performance and maintenance. It puts your SQL and your grants in a more controlled realm. The user-facing application and the data API can more easily be maintained, especially if you need a new schema or to further shard your data.


> Assume the developer working here just had a 45 mins sync, has a meaningless repeat meeting about vapourware in 10 mins and a 2 hours town hall meeting after lunch... and still have to deliver that mess of a requirement you made him promise to deliver before any of these interruptions were even on his calendar!

If I have meetings loaded I am telling whoever is planning the sprint I am reducing my load for meetings. We, as software engineers, cannot allow this nonsense to continue. If I am interrupted 12 times a day I am bringing it up and the next sync. If it doesn't get fixed I am leaving. No one pays me to sit in meetings all day. If you want code delivered leave me alone or burn through young guns that don't know what they want yet.

While your post has a lot of good meat it is undermined by this single statement. It's far easier to work when you don't need to proactively reduce cognitive load to caveman levels because developers are by-and-large overworked.


I think the point of that statement was that we should assume the worst case, for the sake of making code easier to deal with for everyone. If the code is easy to deal with for the meeting-ridden hypothetical person, it will definitely be easy to deal with for a non-meeting-ridden hypothetical person.

I understand and agree with your frustration, but I don't agree that the statement undermines the parent comment at all.


I write the @next comment not as a proper comment, but as straight up text, guaranteed to give me a syntax error the next time I compile. No need to remember to search for anything!


I do that as well!

Or this

   Line of code <<<<<<<<<<<<<<<
With pointing, which breaks it.

@next is a nice way to formalise a break though.


> I don't find these in books

Check out A Philosophy of Software Design, 2nd Edition https://a.co/d/dZ8PJpt


I am a student, and have a very long way to go. but Jeremy Howard's fastai (part 2 of the deep learning course) really helped me understand why code needs to be understandable. this has given me a lot of appreciation for the art of writing understandable code.


+1 for this list. Choosing composition over inheritance is generally a good idea, but if the language doesn't support declarative/automatic delegation, building good compositions becomes tedious. Still, inheritance, when used for code reuse, breaks LSP (https://en.wikipedia.org/wiki/Liskov_substitution_principle) and the problems typically manifest, months or years down the line when you try to extend such areas of code


Regarding the @next, i found the best way to get back into a session after being interrupted, was watching yourselve working on the code.

https://github.com/microsoft/codetour

Basically record the last 5 minutes of your work and replay as code tour.


This seems like overkill, and unless it has a Shadowplay-like mode where you're persistently recording and can just dump the last few minutes to the hard drive, I don't think I'd even know (or remember) when to hit record. (So actually, maybe just use Shadowplay instead?)

One other alternative is Idea/Rider's Local History feature which is basically a parallel repo of your entire project that automatically saves a snapshot of your changes every time you save a file. Super useful for all kinds of backtracking, and you don't even need to remember to use it until you actually need it.


> Also avoid traits and mixins (run from the dark magic)

I've seen the word "trait" used to mean different things in different language communities. Do you mean it like in Rust, in Ruby, in Scala, or in C++? (Since you mention mixins, I'm guessing like in Ruby?)


> I don't find these in books, just decades of sweating and pulling my hair "why does it have to be so hard!?"

You've not been reading good books then.

Stuff like "separate business logic from framework internals" is one of the main messages of any book on software architecture, including Bob Martin's classic Clean Architecture.

Then "choose composition over inheritance" is Object-oriented programming 101.

Then the suggestions to not over comment and "name stuff to be easily grepable" are key takes of Bob Martin's Clean Code.

They are all good takes, but hardly unknown.


> Always aim for declarative programming

> limit the depth of the function calls

These two are at odds with each other. The more declarative your code is, the deeper your function calls will be. At the extreme case, you will have an DSL-like code that has an intractable stack trace.


> Also avoid traits and mixins

Too true - I remember working on a PHP project where traits weren't allowed and there were maybe two times that traits would have made life easier but they were easily worked around.


Abstraction basically means creating your own (domain-specific) language and an interpreter for it. It can greatly help code-understanding if you can grok that domain-specific language easily.


The @next is a good idea - gonna snatch that


Multiple levels of abstraction kills me, at least without adequate documentation. Conceptually, it's veey easy to grasp, but in code it becomes a mess making jumps through multiple unfamiliar files in a large code base. Essentially, you end up having to hold all the pertinent information on that code in your head, which largely defeats some of the purposes of breaking them into multiple files.


You’d probably say the same if no abstractions were there. It’s a trade off. Don’t necessarily abstract something, but do it if it makes everything else better and more maintainable and more secure. Probably a good philosophy is to never start with abstraction and leave that to refactors.


One problem that's usually missed with these kinds of "black!", "no, white!" comment chains is that people are working in different environments with completely different points of reference to work from.

Front-end developers hear "no abstractions is bad!", and they don't take into account that something like React already provides an abstraction more powerful than all the abstractions put together in some other programming environments. Having a localised section of UI bundled with everything driving it in a single 20 line file is absolute light years ahead of the dumpster fire of the 2010 web, which in turn was probably light years ahead of whatever people were doing with IE and Netscape in the 90s.

So they do crap like separate into "logic" and "display" components, or they import 6 heavily abstracted libraries on top that give virtually no additional benefit and just add 8,000 lines of third party documentation to the already heavy cognitive burden other developers on the project have to understand the code.

Likewise even though I've never worked on one, I'm sure there's projects in more 'low level' environments where a single good functional abstraction could wipe out 200 lines of code in a flash, and the 3rd party libraries available are actual huge upgrades. If that's all you've ever worked on and you hear an (actual good) front end dev completely shithouse 3rd party libraries you'll likewise be as terrified of their advice as I am when I hear people talk about something being underabstracted.


Yeah, we seem to have gotten better at this by using noSQL databases with one big JSON object for each item instead of relational based on OOP objects.


I find that this type of code is much easier to debug in a debugger, while harder to debug looking at source code.

When in a debugger, I can throw a breakpoint in, inspect inputs and outputs, if they are what I expect, there is (generally[1]) no reason to dive into those other files.

When reading the code from source outside of a debugger, as a new person to the code base, I agree with you and the exact problem you are describing.

[1] Reasonably written code without a bunch of hidden global state.


The real problems come when you need to make the code do something else that no longer fit the abstraction and now it's either rebuild the thing or tack on some extra bit which doesn't make sense with the original design.


I've often thought of this as "developer empathy" when I was working at Amazon. Generally regarding people who create APIs for other developers to use. The idea being that you can make your API simple or complex, and most developers tend to make their API complex, because they think their system is really powerful and cool and want to expose all the power to end users. But, you need to remember that your users aren't just using your API. They are using maybe 100 other ones as well. And there is no way for a person to learn the ins and outs of all 101 complex APIs. Add to this the fact that the more complexity you expose, the more unintentionally coupled your API becomes to your implementation (generally). So, make your APIs simple, and provide backdoors if you really want to expose complexity.

I will say, this did not get me promoted at Amazon.


That just sounds like the designer didn't layer the API properly. A good API has a sensible top level that does all the common use cases, but also allows access to the lower level layers for power users.


Well, yes. I'm talking about API design, and what's good and what's bad. When you're dealing with microservice architecture in Amazon you deal with a whole lot of APIs.


One measure for code quality should be the effort to make a minor change to code, or to fix a simple, common bug.

Can a developer who is familiar with the code, effortless make a change in an area where change was expected at the beginning?

Assuming a typical, minor bug that turns out to originate from the usual suspect places in the code (database query, logical error, incomplete implementation of a requirement, off-by-one/calculation error). Is it usually easy to narrow things down, and to spot the error?

That means the lead architect should regularly try implementing minor features or fix minor bugs and/or pair up with colleagues who do, and draw conclusions about the code accordingly.


That's a good one.

Another measure I use for code quality is: how afraid am I of breaking something when I make changes? This is where typing and tests shine.


Typing and tests definitely help, but nothing works better than having well-factored, decoupled code, so a change in one place doesn’t impact the rest of the code base in unexpected ways. Unfortunately I don’t know of a good way to do that outside of experience + vigilance.


Good abstractions + asserts + minimal assumptions everywhere in the code (also called refactoring-resistant code)


Unexpected is an interesting word. This probably means consistency is important, because expected in one codebase could be unexpected in another.


My pet peeve with this, is that people write code that doesn’t reflect the problem. A messy problem should have a messy solution as that would reflect the problem. As soon you start to write code that’s either for the sake of something else, clean code etc, you deviate from what you are trying to solve. That creates a solution which hard to follow, as the problem is abstract and others may be able to interpret that. But then if you look at some code that does something entirely different, due to language constructs, framework or some diligent programmer trying to write good looking code but has nothing to do with the problem. That creates cognitive load as you need to not just only understand and solve original problem and changes, you need to understand the actual code too.


> A messy problem should have a messy solution as that would reflect the problem

I've never heard this expressed before and it goes against my experience. My entire goal is to find a simple solution to a messy problem. I can think of a lot of simple solutions to messy problems. If programming required me to come up with messy solutions to messy problems I wouldn't want to do it. Most programmers like that "a-ha" moment of a discovering a simple and elegant solution to a messy problem.


Exactly, solution to a messy problem is 3 simple solutions working together, not one messy solution.


I'm a bit old so when I was growing up in the 60's a cartoonist named Rube Goldberg made his living by amusing people with overly-complicated solutions to simple problems. I don't hear him mentioned anymore so I'll mention him now

https://en.wikipedia.org/wiki/Rube_Goldberg_machine


I've been thinking about a Ruby variant that would be called Rube.


That is not always possible - sometimes the problem domain is simply complex enough and we should accept that. Fighting essential complexity is stupid.


I would probably put it like a messy problem can’t have a too elegant solution, in that essential complexity can’t be reduced. If your problem domain have edge cases everywhere you might be able to put an elegant abstraction over the common case, but the edge cases will have to be dealt in an inelegant way.


I think you maybe over interpreted what they said, at least compared to how I interpreted or think about these things.

Highly optimized or robust code or code with a lot of edge cases is "messy" in the sense that there's a lot of details separate from the high-level concept that get mixed in and can't always be modularized or abstracted away or made simple. That's fine though if pieces of code like that are localized to the complex problem they solve. Simple is a relative concept that can change substantially. Simple code closer to hardware is messier that simple code at a higher level.

I once interacted with a developer that optimized everything. Looking at the code, it was impossible to know what actually needed to be optimized because everything was optimized or had the appearance of so.


There are several problems with real messy solutions as the real world isn’t beautiful. Time and time zones comes to mind. Some countries just deletes or add days. Contracts in HR are usually completely a mess as they differ from company to company. The list goes on. I believe the “fallacies-that-programmers-believe-about-X” is a good indication that problems aren’t that clear cut as they seem. And when you do get trapped with the “simple solution that doesn’t fit the next iteration of the problem scope”, where someone else tries to fix your elegant solution to yet fit another problem which the original code didn’t really solve or was thought of, creating a mess.


I once wrote an extremely general and elegant piece of code for an extremely messy problem (some obscure CSV from a source system I cannot influence with a ton of inconsistencies and mistakes that I have to straighten out automatically).

I started out with straightforward and somewhat messy code, but after adjusting that 3 times, I ended up writing something that is more or less a toolset to deal with the problems that data had. The code of the tools is convoluted, but when I have to adjust some things every now and then I just use the tools I built anyways so who cares.

Abstraction in programming should be seen like certain devices, helpers etc. someone would make use of in woodworking. Of course it costs you some time to built them, but it can make your life a lot easier, because ot makes results more consistent and testable.


Yes, I would formulate this as the distance between the mental model of the functionality being implemented and the structure of the code. The interesting question is how to design programming languages, libraries and tooling so that gap remains small.


it's going to take quite some time to read it all since it's long and deserves the time but since it's soliciting early feedback here it is: research and quote all the works done over the last 10 or so years by others in this space!!

The topic of cognitive load in software development is far from rarely considered and in fact it's been somewhat "popular" for several years depending on what communities and circles you participate it on- and off-line.

I'm surprised not to find any mentions to things like:

- the Team Topologies book by Skelton and Pais, published in 2019 where they cover the topic. Particularly of note here is the fact that Skelton has a Computer Science BSc and a Neuroscience MSc

- the many, many, many articles, posts, discussions and conference sessions on congnitive load from the same authors and connected people in subsequent years (I'd say 2021 was a particularly rich year for the topic)

- Dan North sessions, articles and posts from around 2013/2014 in which he talks about code that fits in your head but no more, referencing James Lewis original... insight. E.g. his GOTO 2014 session "Kicking the Complexity Habit" https://www.youtube.com/watch?v=XqgwHXsQA1g&t=510s a quick search returns references to it even in articles from 2020 https://martinfowler.com/articles/class-too-large.html

- Rich Hickey's famous 2011 Simple Made Easy talk https://www.infoq.com/presentations/Simple-Made-Easy/


>research and quote all the works done over the last 10 years or so by researchers in this space!!

I totally understand your point and appreciate you linking those resources, however I think it's important to remember that the author's post is from a personal blog, not from a scientific journal or arxiv.

Perhaps OP would've never posted this if he felt that his "contribution" wasn't novel enough. Additionally, there's a chance that the wording and tone the author used might speak to people who found the articles you mentioned opaque(and vice versa, obviously).

If the author, feeling the urge to write something up, had looked very hard for "prior work" instead of following the flow of their insights gained through experience, perhaps they would've felt compelled to use the same vocabulary as the source, which has its pros(forwarding instead of reinventing knowledge) and cons(propagating opaque terms, self censoring because of a feeling of incompetence in the face of the almighty researchers).

That's one of the great things about blog posts: to be able to write freely without being blamed for incompleteness or prior art omission.

On a different note, I think this may also highlight the fact that the prior work you mentioned isn't easy enough to find. Perhaps knowledge isn't circulating well enough outside of particular circles.


About "Cognitive load being rarely considered", I meant it in actual project work, not in the sense that the idea of applying cognitive psychology to programming is new.

I am sure the topic has been considered in an academic setting. I would not feel qualified to provide a good reading list on the topic.

This is also related to code quality, thus it will have a ton of relevant work.

Thank you for the links, in particular to Skelton, Pais, I will have a look!


Reminds me of this Rich Hickey quote:

“So how do we make things easy? … There's a location aspect. Making something at hand, putting it in our toolkit, that's relatively simple. … Then there's the aspect of how do I make it familiar, right? I may not have ever seen this before. That's a learning exercise. I've got to go get a book, go take a tutorial, have somebody explain it to me. …

Then we have this other part though, which is the mental capability part. And that's the part that's always hard to talk about, the mental capability part because, the fact is, we can learn more things. [But] we actually can't get much smarter. We're not going to move; we're not going to move our brain closer to the complexity. We have to make things near by simplifying them.

But the truth here is not that there are these super, bright people who can do these amazing things and everybody else is stuck, because the juggling analogy is pretty close. Right? The average juggler can do three balls. The most amazing juggler in the world can do, like, 9 balls or 12 or something like that. They can't do 20 or 100. We're all very limited. Compared to the complexity we can create, we're all statistically at the same point in our ability to understand it, which is not very good. So we're going to have to bring things towards us.

And because we can only juggle so many balls, you have to make a decision. How many of those balls do you want to be incidental complexity and how many do you want to be problem complexity?”

https://github.com/matthiasn/talk-transcripts/blob/master/Hi...


Yes! As one of the creators of https://github.com/stitchfix/hamilton this was one of the aims. Simplifying the cognitive burden for those developing and managing data transforms over the course of years, and in particular for ones they didn't write!

For example in Hamilton -- we force people to write "declarative functions" which then are stitched together to create a dataflow.

E.g. example function -- my guess is that you can read and understand/guess what it does very easily. # in client_features.py

@tag(owner='Data-Science', pii='False')

@check_output(data_type=np.float64, range=(-5.0, 5.0), allow_nans=False)

def height_zero_mean_unit_variance(height_zero_mean: pd.Series,

                                   height_std_dev: pd.Series) -> pd.Series:

   """Zero mean unit variance value of height"""

   return height_zero_mean / height_std_dev

To use `height_zero_mean_unit_variance` somewhere else, you'd then find it as a parameter to some other function, and that's the basic gist of how you'd develop a dataflow. To then execute the dataflow, you'd then in a separate file, decoupled from your transform logic, write some code to (1) create the DAG, and then (2) request what you want executed from it.

In terms of reducing cognitive burden for maintainers, by forcing things into a function there's all these nice properties to be had for maintenance:

- unit testing is always possible

- integration testing is super easy, since you can add code, and then _only_ test that path without having to run your entire transform workflow.

- documentation has a natural place & and we can build a DAG visualizing the dataflow that you're looking at

- debugging is methodical since it's straightforward to map an output to a function, check the logic there, and then methodically traverse its dependencies because they are declared.

- you can wrap functions/inject things at run time easily


> I am not a psychologist, these are observations of a coder.

I am a PhD cognitive psychologist and a coder with 50+ year’s, and I’ve worked directly on the topic you’re writing about. There’s a LOT to say about your interesting piece. Way too much actually. We may have to take it offline. But I want to nod to this:

> In a parallel dimension ... computer hardware was based on SKI calculus. ... software has very few bugs, however, this universe has fewer programs, even fewer programmers, and the error messages suck.

This is perhaps the funniest hacker joke I’ve ever heard! (Although, the Lisp error messages are actually pretty great. But I get the joke regardless.)


Would GOMS [0] be a useful framework for thinking about cognitive load in programming? GOMS is a framework for analyzing user workload in system interaction. Bad or gnarly code creates a much more complex interface for the programmer. A quick look did not surface a reference here, but I'd look further at work by David Kieras [1].

[0] https://en.wikipedia.org/wiki/GOMS [1] https://www.researchgate.net/profile/David-Kieras


This is an enjoyable read. The topic of abstraction needs to be explored more here. I wonder if the author would agree that In practice almost all abstractions can be made to leak. Given that, then even experienced developers who understand how to avoid mixing different layers of abstraction will seldom succeed in creating abstractions without leaks. If an abstraction leaks, it’s not germane, it’s incidental complexity.

This applies to blueprints as equally as it does to implementations. The author notes the caveat in soundness of blueprints - not something a common developer can do much about - but if the developer designs blueprints with unintended behaviours - for example the author’s recursive let example - then they’re being very generous with the helpfulness of blueprints since they are no silver bullet to avoid shooting yourself in the foot.

The common case in software development is not a PLT enthusiast. Blueprints are just another way you can shoot yourself in the foot if you hold the tool wrongly. And the common case is not to understand the tool much more than at a fairly superficial level, so mishandling is all but assured.

This means pragmatically, there’s no substitute for an acceptable level of testing in a project.


Naming things to be easily searchable, especially in dynamic languages, is sadly very under valued by a lot of people.


I tend to think of this as an impedance matching[1] (in the EE sense) problem. The best frameworks match the way we think about problems once we've gotten used to them. There has to be some give on the part of the programmer, because of Godel's incompleteness theorems.[2]

I whole heartedly agree that we have to minimize extra steps when reviewing code, but you can only push so much of that burden back through space-time to the green field programmer.

For instance, in Pascal, you have to declare everything first. I'm used to that, so it matches my expectations. However, it also sucks because if you're looking in the middle of code the declarations aren't proximal to the first time they're used. There's an expectation that all the code will be read, which was fine in the academic world which gave birth to Pascal, but not in the world of million line systems.

There are tradeoffs that will take decades to get right, simply because we need to have time to gain enough perspective to adjust things collectively, as a profession.

[Edit -- Tweak link format per suggestions below]

[1] - https://en.wikipedia.org/wiki/Impedance_matching

[2] - https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_...

[3] - https://en.wikipedia.org/wiki/Off-by-one_error


Is there a reason you deliberately make it hard for people to follow the links you put in your comments?


I number them, and space them out to make it easy to select and copy... there's no way to inline hyperlinks here that I'm aware of.

Option #1 -- formatted as a code block

  [1] - https://en.wikipedia.org/wiki/Impedance_matching
  [2] - https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems
  [3] - https://en.wikipedia.org/wiki/Off-by-one_error
Option 2 -- jumble of links

[1] - https://en.wikipedia.org/wiki/Impedance_matching [2] - https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_... [3] - https://en.wikipedia.org/wiki/Off-by-one_error

-

Option 3 -- Extra line breaks everywhere

[1] - https://en.wikipedia.org/wiki/Impedance_matching

[2] - https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_...

[3] - https://en.wikipedia.org/wiki/Off-by-one_error

-

None of those really seems right

[Edit] Sorry, I didn't know about the issues on mobile. it definitely wasn't trying to make it worse on purpose. I added the off by one link to make the examples long enough

The 2 biggest problems in computing... Naming things, Cache Invalidation, and off by 1 errors.


No, but if you didn't put the white space at the front of the line they'd be, you know, links. So people don't have to copy/paste them. Which is sort of the point of hypertext.

EDIT: Most of the parent comment wasn't there when I wrote my initial reply.

Of the three options, the third is the best of a bad situation since we don't get to cleanly inline links like on Reddit and other places. The first is just inconsiderate, especially to mobile users. The second is, as you noted, a mess especially when there are multiple links. The third lets you keep your original intent (clearly listing each link) while letting them still function as links. Which is better than the first option because it's not disrespectful of other people even if it does add some vertical whitespace.


If you don’t put them in a code block, the URLs are automatically linkified by HN.


I’d also suggest to leave out the hyphens, they are just visual noise.


> We ask “How long will it take?”

That is the first problem. We should be asking several questions, and the value of their answers would be weighted against business goals.

How long will it take?

How will it affect future development (pace).

How will it affect the forced total rewrite date (a broader view of the previous question).

And my favorite, how difficult will it affect provability of system behavior? This point is greatly affected by frameworks which impose their own conventions (which change over time).


People write code for computers, but they should also write code for humans. Humans have different parsers and the evolution of the code is dictated by how quickly and efficiently can these human parse the code.


I write code for humans, first of all me. Ensuring correctness, getting the syntax right, and following style conventions, etc is what follows. It's much easier for a human to verify the logic if it's easy to read and comprehend. Good notes in a pull-request is also handy and easy to find with a git-blame and commit hash lookup on GitHub.


Nbdy wrts lke ths 4 hmns so wy do we wrt lke ths for prgrming? I alwys c sht lke "CompPtr". What? Is tht a Computer or Complex Pointer? Wtf?

Basically everyone agrees with you but when it comes time to name stuff all humans suddenly forget everything and think brevity is some sort of required part of naming in code.


Programming code is for humans. Machine code is for machines. The compiler translates from human to machine.


I must be weird because I love YAML. I love the brevity of no closing brackets and the finality of the indentation being the code, rather than just window dressing that can get out of whack.


Anything in Python which uses ZIP() instantly hits my brain exploder, or one of those nested "do it for this array, iteratively, for this conditional"

Anything using the ternary operation concept

Anything using recursion

Anything which has a self or implicit this in it, which is contextually undefined in the point of view, and has to be grokked as "the thing it should be, whatever it is"

Anything which says "you aren't meant to understand this"

The cognitive burden of being an (english?) speaker, with somebody who speaks in complex multi-clause sentences, with parenthetical statements but you don't know until the very last sentence WHAAT THE HELL IT IS they are on about. That guy. Thats generic functions in a function stack calling the one which says which of a million things this stack of calls is being applied to.


That is listing quite a lot of normal programming things. Of course powerful features need more thinking. I think you might discover in many cases these things are not a brain explode.


many years in programming has not cleared these cognitive burdens. 44 and counting. I mean sure, I'd love to stop being astonished, but the truth is I'm not. I had this in card decks and uplifted into glass ttys over time.

I have to re-read how to do lambdas more often than I care to try and remember. Every time I do, I ask myself why I'm doing it.

If you are one of the cogniscenti who don't suffer this cognitive burden please treat the rest of us kindly.

"oh, you mean you can't read music: how funny I can hear the entire orchestra"


At least about recursion I can say it only really clicked, after working through part of SICP and The Little Schemer. It is like avoiding to think about the potentially endlessly deep recursion, but thinking about the current level and what the meaning is.

Sometimes however, there will be a recursion, which dumbfounds me as well :) I still feel I improve though. Just the other day I came up with a pair of mutual recursive functions, as if it was nothing, to solve a specific problem, by splitting it into 2 subproblems (or maybe 2 states in an automaton).

Often there is a very clear idea behind a recursion, that, once one has heard it, becomes completely obvious. Maybe not always.

About ZIP: Imagine you wanted to work on 2-tuples made out of pairs of elements from 2 lists. That's what ZIP does. Maybe also works for more than 2 lists, I don't know.


If that imagine about zip was meant to help.. (truly, I do know what zip does. It's what zip does in context. Mapping your mental model of a list to a list of pairs in context is whats hard. And my head doesn't see lists as tuples any more than staring at an integral stops me internally screaming eeeee because giant E)


I have the same thing, but in the opposite direction: I can read recursion, map/filter/reduce/any kind of higher order function (even the wonky haskell ones, like traverse) just fine.

But the moment I start seeing mutation, or for loops complex than a for each over a data structure (like nested ones, or while, or weird indexing) my brain completely shuts down. In my first professional job (F# developer) I had to look up how to write classes every time we created one (not often, to be fair).

I think it's all a matter of habit and familiarity, although I'll admit that finding places that program functionally (where I'm most proficient) is really hard


Was your first language a LISP? Mine was Fortran IV and then pascal and I truly suspect this may be about birth tongue dialect


I started out at the very beginning doing full stack JS, but at some point I started using Haskell for my personal projects, and my first job was in F#, so I think the combination of those two may just have impacted the way I think about programming a lot


Hi, it is the author here.

My coworker told me that my post is being discussed in Hacker News, I did not know! It will take me some time to digest what was said here. It looks like a lot of good discussion. Thank you!


There is another way to reduce cognitive load, and that is to build tools that allow the user to directly manipulate the abstract syntax tree, and treat the source as a product of the manipulation. Source code can be a view of the AST, and an output of this tool.

DION was mentioned recently in a few threads, their demo[1] is quite impressive.

[1] https://media.handmade-seattle.com/dion-systems/ (watch the 1st video!)


Offtopic:

I'm saddened to see YAML get a bad rap for effectively being shoehorned to fit a task that it's just bad at.

In my experience, YAML is a great format for legibly declaring a dict/array structure, with added benefits over JSON like anchors/references (effectively pointers, which thus allow it to describe a graph), comments, and overloads (great for defaults!).

But YAML has no facilities for conditionals, loops, or any logic. So these get tacked-on, ad-hoc, by systems that need them, and that pulls YAML towards Greenspun's Tenth Rule ("Any sufficiently complicated [system] contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp")

I'm glad better languages like Dhall exist for the problem-space that YAML is just not designed for.


I'm saddened to see people complaining about YAML when it's actually Helm and Jinja and treating configs files as plain text file like it's still the early 2000s that are the problem. In my experience, syntax tree based composition tools like kustomize solves much of the problem for me. I appreciate this is sorta kinda new for devops people, but come on, this idea has been around for decades.


My grief is seeing YAML/JSON browbeaten past being static textual files, and into being interpreted files with nasty embedded functions (looking at YOU, CloudFormation).

My latest project is gaffing all that off and using .xlsx to hold configuration, openpyxl to load the .xlsx, and then transforming the content into a target format (terraform) in the python.

Let each piece in the system do what it's good at.


> Let each piece in the system do what it's good at.

Althought I strongly dislike the idea of introducing Excel in any equation, I wholehearthly agree with that quoted statement of yours. However, if I were you, I would use csv format. It can be easily read/edit from Excel if that's a strict requirement, and in that way, you can keep a grepable plain text versioned in the repository (the csv itself). The best of two worlds I guess. Also, from the top of my head, the Python standard library has a module for reading CSV, so less dependencies too.


> using .xlsx to hold configuration

what the?! I would imagine that .xlsx is even worse than a plain text file for holding configuration. You have to open it with a spreadsheet editor, and how would you diff a binary format when committing into source repository?


Git does binaries.

Excel is on the desktop, and ot has diffing tools.

This is a work project, not something for regular GitHub release.


If you need all of those features I think the best way is to just define objects in TypeScript. You have the full power of a real and well understood language then.


About YAML, I like YAML too, only not so much for 5K+ configuration lines.


Splendid article.

I was thinking that perhaps walking the readers of our code through our architectural decisions(and not just through what our code does) is a good way to lessen their cognitive load. This helps identify decisions that have been taken to look smart or because the foie-gras the author ate on that day went down really well with the Chardonnay and made them feel extra stylish.

This also helps us understand how well we know the tools we're using versus how much we do simply through pattern repetition.


If we improve our programming languages and practices to make code bases more easily understood we will simply increase the scope of what the projects attempt to solve, not reduce their complexity. While this is a good for software it still means we’re going to be moaning about working on complex software.

It’s like how the increased economic productivity due to technology has not made the work week shorter.


very insightful article. I think I will read it several times.

I think my project Glicol (https://github.com/chaosprint/glicol) can also be an example. I try to use text-based style to represent synth connections and many musicians found it quite intuitive.

I have been very addicted to OOP and I tried to design another language with FP paradigm, but finally I landed at this graph-oriented live coding language as I feel the declarative style has less cognition load naturally but I want to avoid the "problematic" aspect of FP.


For example, in JS apps, all of my function arguments include a `shouldThrowError` params (default is falsy), so we pass control to the call site instead of blindly throw error. That makes the whole composition more clean.


This is insanely good! I always thought I am the only person in the world taking that into account when designing/coding something.


Could a programmer with lower cognitive load capability paradoxically lead to code that has less complexity?


I think it depends on what ideas the programmer has and puts to work in the task of telling the computer what to do.

If the ideas are ideal, the program will be as small and fast as it can be, but if they are not then there will be an inefficiency.

That is to say, if someone shows you code that is smaller and faster, then it has more-ideal ideas.

Now few programs are built with ideal ideas[1] and people with "more capability" can use more simpler, less-ideal ideas to tell the computer what to do, but people with "low capability" must only use ideal ideas (or nearly-ideal).

But how likely it is someone with a low cognitive load capability could have the ideal ideas? If you think of programming as a purely incremental thing, then this must seem impossible, but if you believe in evolution then you can probably understand sometimes "weird things happen" and if the weirdness is successful, then you'll get more weird things.

It is for this reason I think the better programmer (more ideal ideas) probably has a lower cognitive load capability, but so does the worse programmer, so the important thing to focus on is a definition of "success", which on the long-scale will be different for the programmer than (say) the company they work for.

Put another way: If you want a job, then making sure you can program like others is important, but if you want to retire young, then making sure your program makes you money and runs forever is probably more important. What would your employer prefer?

[1]: If you aren't following what I mean by ideas here, Iverson calls them "tools of thought" and I can highly recommend reading: https://www.jsoftware.com/papers/tot.htm


I think you need to define "lower cognitive load capability" first.

There's a multitude of reasons why people can't handle cognitive load that all have different dynamics. The combination of traits I've seen lead to consistently less complex code is developers that are highly intelligent and hence more capable of building abstractions and who have a very low tolerance for frustration. The cognitive load of the spaghetti solutions they deal with grates on them a lot more than the average bear and since they are capable of seeing how to achieve the same task with less cognitive load they do it, so that they no longer have to deal with the frustration and can be comfortable.


I think you would probably find a lot of naive programming and other amateur mistakes. Maybe there's a sweet spot where you want to be just smart enough to solve the problem in an elegant way but too lazy to abstract and overcomplicate things.


grug think hard to make simple code => grug think hard once

grug write complex code to look smart => grug have to think hard every day forever


>If we can load only a limited number of “chunks” into working memory, how big can these chunks be? The answer is interesting: it seems that it does not matter

I always try to work at reducing cognitive load and one of the principles I employ is reducing the individual number of chunks in the system as a whole. There are a myriad of ways to do this. One of the most pernicious things code can do is to make us ask questions of it because as soon as a piece of code leaves you wondering it has just loaded a chunk into your working memory hence removed some of the available capacity. What's worse is that chunk isn't valuable on its own. Instead it's a placeholder chunk that takes you from wondering to wandering as you deload all the chunks you had in working memory and start a process of navigating around the codebase to build up enough connections between the pieces to where you can collapse your new understanding down to a single chunk of insight. You then reload all the chunks you originally had and now that your placeholder chunk has been replaced by your newly acquired chunk of insight you can finally reason about the code you were trying to.

This process sometimes takes seconds, sometimes minutes, sometimes hours, sometimes days, sometimes weeks, sometimes months. It's absurdly wasteful. Don't make me think! Opportunities to remove cognitive load are everywhere. You have a method that takes a parameter, what's the first thing a developer wonders about that parameter? Can it be null? If it can be null, how do these other lines of code behave with respect to that? If your programming language has an affordance that allows you to declare "the value of the parameter can't be null at this point" all of those questions drop away. System invariants are the same. You often see developers struggling with code that is trying to work out whether an invariant holds and what to do about it if it is violated at some point during the processing of something. The chunks of working memory used by those "but how do we handle X if Y is true at this point?" are cognitive load that you can completely nuke if you check the invariants hold right at the beginning and only do subsequent processing in the case that the invariants aren't violated. The conversation becomes "no need to worry about handling X here because if this code is running it means Y is definitely false".

There's all kinds of things like different versions of the same framework, or competing frameworks with the same affordances. All of that stuff adds to cognitive load. A developer asks another developer "why do we use both xUnit and NUnit in our tests?". A half an hour conversation between 4 developers ensues. It's amazing the difference you can make by simply subtracting differences!

Cognitive debt isn't the right metaphor, though the existing definition of it recounted in the article certainly reminds of the plight of an IC in the trenches. My term for this is "technical tax". A permanent % reduction in available capacity stemming from the cognitive load of the technical solutions. I'm always working to move the team and the org into a "lower technical tax bracket".

Edit: also I don't necessarily agree that intrinsic cognitive load cannot be reduced. It's only true on a technicality. Yes, that cognitive load is indivisible from that domain and there is nothing you can do to change that. But that's not the point. It isn't the domain that experiences cognitive load, it's the developer working in the domain. What we have to manage is the cognitive load the developer is experiencing and making sure it's at an appropriate level. What you really need to ask of yourself and your developers is "what is the total amount of intrinsic cognitive load across all the domains and pieces of software you are responsible for and is that appropriate or too much for you?". An absolutely classic problem where I work is that teams own far too many things. There's just no way to be across all of it effectively.


Competent*


Alan Kay is a very nice and bright guy and I don't want to hurt his feelings.

But this is a perfectly soluble problem and it is entirely and exclusively Alan's fault that everybody stampeded in the wrong direction to our current situation.




Join us for AI Startup School this June 16-17 in San Francisco!

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: