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.
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.
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"
(but most often the comment is is just not updated or updated along with the code but without full understanding, which is what caused the bug that is the reason you are looking at the code in question)
This "has to be perfect in perpetuity or it is of no value" mentality I don't find helpful.
Be kind to FutureDev. Comment the weird "why"s. If you need to change it later, adjust the comment.
And even if they don’t know themselves why they are confused, they can still describe how they are confused.
You can't expect developers, already working 60 hour weeks to meet impossible deadlines, to spend another 15 hours altruistically documenting their code.
Give me readable code over crappy documentation any day. In an ideal world the docs would be correct all of the time, apparently I don’t live in that world, and I’ve grown tired of listening to those who claim we just need to try harder.
Spending 15 hours documenting the code is something no leader should be asking of engineering to do. You should not need to do it. Go back and write better code, one That’s more clear at a glance, easily readable, uses small functions written at a comparable level of abstraction, uses clear, semantically meaningful names.
Before you write a line of documentation, you should ask yourself whether the weird thing you were about to document can be expressed directly in the name of the method of the variable instead. Only once you have exhausted all the options for expressing the concept in code, then, only then, are you allowed to add the line of the documentation regarding it.
But that's what people are talking about when talking about comments. The assumption is that the code is organized and named well already.
The real world of complexity is way beyond the expressiveness of code, unless you want function names like:
prorateTheCurrentDaysPartialFactoryReceiptsToYesterdaysStateOfSalesOrderAllocationsInTheSamePrioritySequenceThatDrivesFinancialReportingOfOwnedInventoryByBusinessUnit()
The code that performs this function is relatively simple, but the multiple concepts involved in the WHY and HOW are much less obvious.
"We don't write any documentation because we can't afford a dedicated QA process to certify it" <- that's dumb.
The best thing you can do to avoid creating debt for later maintainers is to write code that's easy to delete, and adding comments helps with that.
We live in a world with version history, repositories with change requests, communications… code comments are a part of that ecosystem.
A comment that is outright incorrect at inception is still valuable even if it is at least an attempt by the writer to describe their internal understanding of things.
I believe it boils down to a lack of foresight. At some point in time, someone is going to revisit your code, and even just a small `// Sorry this is awful, we have to X but this was difficult because of Y` will go a long way.
While I (try to) have very fluid opinions in all aspects of programming, the usefulness of comments is not something I (think!) I'll ever budge on. :)
You don’t know how many times I’ve seen this with a cute little GitLens inline message of “Brian Smith, 10 years ago”. If Brian couldn’t figure it out 10 years ago, I’m not likely going to attempt it either, especially if it has been working for 10 years.
"This does X" as a comment when it in fact does Y in condition Z means that the probability you are looking at a bug goes up a bit! Without the comment you might not be able to identify that Y is not intentional.
Maybe Y is intentional! In which case the comment that "this is intentional" is helpful. Perhaps the intentionality is also incorrect, and that's yet another data point!
Fairly rare for there to be negative value in comments.
What I'd really like is an AI linter than noticed if you've changed some functionality referenced in a comment without updating that comment. Then, the worst-case scenario is that it doesn't notice, and we're back where we started.
In addition companies don't seem to give a shit about straightforward code, they want LOC per day and the cheapest price possible which leads to tons of crap code.
Your second point is really the crux of business in a lot of ways. The balance of quality versus quantity. Cost versus value. Long-term versus short term gains. I’m sure there are situations where ruthlessly prioritizing short term profit through low cost code is indeed the optimal solution. For those of us who love to craft high-quality code, the trick is finding the companies where it is understood and agreed that long-term value from high-quality code is worth the upfront investment and, more importantly, where they have the cash to make that investment.
This is mostly how large publicly traded corps work, unless they are ran by programmers that want great applications or are required by law, they tend to write a lot of crap.
Companies don't care about LOC, they care about solving problems. 30 LOC or 30k LOC doesn't matter much MOST of the time. They're just after a solution that puts the problem to rest.
I'm sure there's some abysmal shit that's extremely hard to properly abstract. Usually the dev just sucks or they didn't have time to make the code not suck
Aging codebase and the ongoing operations make it even harder to maintain consistently. eventually people surrender.
Issue trackers are much better because then in git you can find tickets attached to the change.
No ticket explaining why - no code change.
Why not in repo? because business people write tickets not devs. Then tickets are passed to QA who also does read the code but also need that information.
It sounds like people are failing at their jobs.
"If only people would do their jobs properly".
So we just fire all the employees and hire better ones only because someone did not pay attention to the comment.
Of course it is an exaggeration - but also in the same line people who think "others are failing at their jobs" - should pick up and do all the work there is to be done and see how long they go until they miss something or make a mistake.
Solution should be systematic to prevent people from failing and not expecting "someone doing their job properly".
Not having comments as something that needs a review reduces workload on everyone involved.
Besides, interfaces for PRs they clearly mark what changed - they don't point what hasn't been changed. So naturally people review what has changed. You still get the context of course and can see couple lines above and below... But still I blame the tool not people.
You do git blame and you see why each line is what it is.
Comments are nice too, but they tend to lie the older they are. Git blame never lies.
Git blame often lies when big merge was squashed. I mostly had these in Perforce so I might be wrong. Also when code travels between source version control servers and different source version control software it also loses information.
I would say in my gamedev practical experience the best comments I saw are TODO implement me and (unit) test code that still runs. First clearly states that you have reached outside of what was planned before and 2nd allows you to inspect what code meant to do.
// TODO: <the name of some ticket>: <what needs to happen here>
e.g. // TODO: IOS-42: Vogon construction fleet will need names to be added to this poetry reading room struct
I've not felt my name is all that important for a TODO, as the ticket itself may be taken up by someone else… AFAICT they never have been, but they could have been.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.
Same difference. Both appellations invoke some sort of idealized professional standards and the conversation is about failing these standards not upholding them. We're clearly very short of deserving a title that carries any sort of professional pride in it. We are making a huge mess of the world building systems that hijack attention for profit and generate numerous opportunities for bad agents in the form of security shortfalls or opportunities to exploit people using machines and code.
If we had any sort of pride of craft or professional standards we wouldn't be pumping out the bug ridden mess that software's become and trying to figure out why in this conversation.
If you can say "I'm a craftsman" or "I'm an engineer" all the power to you. Sadly I don't think we can say that in the collective form.
My cynicism of the software "profession" is entirely a function of experience, and these titles are the (very rare) exception.
The norm is low-quality, low complexity disposable code.
The key point of the comment was that engineers do have standards, both from professional bodies and often legislative ones. Craftsmen do not have such standards (most of them, at least where I am from). Joiners definitely don't.
Edit: I would also disagree with "pumping out bug ridden mess that software's become."
We are miles ahead in security of any other industry. Physical locks have been broken for decades and nobody cares. Windows are breakable by a rock or a hammer and nobody cares.
In terms of bugs, that is extraordinary low as well. In pretty much any other industry, it would be considered a user error, e.g. do not put mud as a detergent into the washing machine.
Whole process is getting better each year. Version control wasn't common in 2000s (I think Linux didn't use version control until 2002). CI/CD. Security analyzers. Memory managed/safe languages. Automatic testing. Refactoring tools.
We somehow make hundreds of millions of lines of code work together. I seriously doubt there is any industry that can do that at our price point.
That is not such a great analogy, in my opinion. If burglars could remotely break into many houses in parallel while being mostly non-trackable and staying in the safety of their own home, things would look differently on the doors and windows front.
The fact is we are better at it because of failure of state to establish the safe environment. Generally protection and safe environment is one of reason for paying taxes.
Not the reason. There is no safe lock, chip or not. You can only make it more inconvenient then the next car to break in.
> The fact is we are better at it because of failure of state to establish the safe environment. Generally protection and safe environment is one of reason for paying taxes.
Exactly backwards. The only real safety is being in a hi-sec zone protected by social convention and State retribution. The best existing lock in a place where bad actors have latitude won't protect you, and in a safe space you barely need locks at all.
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.
That is problem where people don’t understand that point.
Runtime and running application is the chair. Code is design how to make “chair” run on computer.
I say in software development we are years ahead when it comes to handling complexity of documentation with GIT and CI/CD practices, code reviews and QA coverage with unit testing of the designs and general testing.
So I do not agree that software development is immature field. There are immature projects and companies cut corners much more than on physical products because it is much easier to fix software later.
But in terms of practices we are way ahead.
I know that it’s frequently reposted but Peter Naur’s Programming as Theory Building is always worth a reread.
The code doesn’t tell us why decisions were made, what constraints were considered or what things were ruled out
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.
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.
Minimalism isn't a goal-- it's a tool to make a better interface and can easily be overused. The people that think minimalism is a goal and will chop out essential features to make something "look designed" are almost always developers. Same thing with unclear icons. As someone with a design degree that's done UI design but worked as a back-end developer for a decade before that, and worked as a UNIX admin off and on for a decade before that, I am very familiar with the technical perspective on design and it's various echo-chamber-reinforced follies.
It's not like all UI designers are incredibly qualified or don't underestimate the importance of some particular function within some subset of users, and some people that hire designers don't realize that a graphic designer isn't a UI designer and shouldn't be expected to work as one. But 700 times out of 1000, that's something dev said "this is too annoying to implement" or some project manager dropped it from the timeline. Maybe 250 of those remaining times, the project manager says "we don't need designers for this next set of features, right? Dev can just make it look like the other parts of the project?"
Developers read an edward tufte book, think they're experts, and come up with all sorts of folk explanations about what's happening with a design and why people are doing it, then talk about it in venues like this with a million other developers agreeing with them. That does a whole lot more damage to UIs in the wild than bad design decisions made by designers.
edit: I am making a lot of assumptions. I'm assuming that most UIs aren't really designed, or are 'designed' from above with directions that are primarily concerned about aesthetics.
Yeah, citation needed. If your argument that 'non-technical users' (whatever that is - being technical is not restricted to understanding computers and software deeply) don't use software that exposes a lot of data on its internals as exemplified by FOSS having few 'non-technical users' meaning people who are not software developers, this is just false. There are entire fields where FOSS software is huge. GIS comes to mind.
Just like human factors engineering, UI design is a unique discipline that many in the engineering field think they can intuit their way through. They’re wrong and if you look beyond technical people, it’s completely obvious.
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.
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.
A late-breaking change is a business advantage—-learn how to roll with them.
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 one is always implementing new code bases that they keep well factored, they should count their blessings. I think being informed about cognitive load in code bases is still very important for all the times we aren't so blessed. I've inherited applications that use global scope and it is a nightmare to reason though. Where possible I improve it and reduce global scope, but that is not always an option and is only possible after I have reasoned enough about the global scope to feel I can isolate it. As such, letting others know of the costs is helpful to both reduce it from happening and to convince stakeholders of the importance of fixing it after it has happened and accounting for the extra costs it causes until it is fixed.
>The messy stuff is where the real world concepts need to be transformed into code.
I also agree this can be a messy place, and on a new project, it is messy even when the code is clean because there is effectively a business logic/process code base you are inheriting and turning into an application. I think many of the lessons carry over well as I have seen an issue with global scope in business processes that cause many of the same issues as in code bases. When very different business processes end up converging into one before splitting again, there is often extra cognitive load created in trying to combine them. A single instance really isn't bad, much like how a single global variable isn't bad, but this is an anti-pattern that is used over and over again.
One helpful tool is working ones way up to the point of having enough political power and earned enough respect for their designs to have suggestions of refactoring business processes be taken into serious consideration (one also has to have enough business acumen to know when such a suggestion is reasonable).
>the original author doesn't work here anymore so no one's here to explain the original code's intent.
I fight for comments that tell me why a certain decision is made in the code. The code tells me what it is doing, and domain knowledge will tell most of why it is doing the things expected, but anytime the code deviates from doing what one would normally expect to be done in the domain, telling me why it deviated from expected behavior is very important for when someone is back here reading it 5+ years later when no one is left from the original project. Some will suggest putting it in documentation, but I find that the only documentation with any chance of being maintained or even kept is the documentation built into the code.
So then you get into what you’re good at. Maybe you’re good at modeling business logic (even confusing ones!). Maybe you’re good at writing code that is easy to refactor.
Maybe you’re good at getting stuff right the first time. Maybe you’re good at quickly fixing issues.
You can lean into what you’re good at to get the most bang for your buck. But you probably still have some sort of minimum standards for the whole thing. Just gotta decide what that looks like.
That's nothing to do with hardware. The various annoying reasons are not set in stone or laws of physics. They are merely the path dependency of decades of prioritizing shipping soon because money.
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.
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.
This is where Scala/Rust's enforcement of having to handle all arms of a match clause help catch such issues - if you are matching against the enum, you won't even be able to compile if you don't handle all the arms.
(But yes, exhaustiveness checking for sum types is a great feature.)
As it is stated, I don't see where it is your mistake. You should be able to trust things do what they say, and there should be integration testing that happens which adds the appropriate amount of distrust and verification. Even with adequate unit testing, you normally inject the dependencies so it wouldn't be caught.
This seems an issue caused by two problems, inadequate integration testing and bugs in the original function, neither of which are your fault.
Building a sixth sense of when to distrust certain code is something you see from more experienced developers at a company, but you were new so there is no reason to expect you to have it (and the system for making code changes shouldn't depend upon such intuition anyways).
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?
Both DRY and SOLID lead to codebases that can be worse in this respect.
DRY and SRP limit what will be done in a single method or class, meaning that both the logic will eventually be strewn across the codebase, as well as any changes to that will need to take all of the pieces using the extracted logic into account. Sometimes it makes sense to have something like common services, helper and utility classes, but those can be in direct opposition to local reasoning for any non-trivial logic.
Same for polymorphism and inheritance in general, where you suddenly have to consider a whole class structure (and any logic that might be buried in there) vs the immediate bits of code that you’re working with.
Those might be considered decent enough practices to at least consider, but in practice they will lead to a lot of jumping around the codebase, same for any levels of abstraction (resource/controller, service, mappers, Dto/repository, …) and design patterns.
I mean, I'm not saying that those approaches are always wholly bad from an organizational standpoint either, just that there are tradeoffs and whatnot.
> Some rules are better than others, but there's no substitute for reading a lot of code and learning to recognize legibility.
This feels very true though!
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.
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?
Not quite. A simplistic take on inheritance suggests reusing implementations provided by a base class, but that's not what inheritance means.
Inheritance sets a reusable interface. That's it. Concrete implementations provided by a base class, by design, are only optional. Take a look at the most basic is-a examples from intro to OO.
Is the point of those examples reusing code, or complying with Liskov's substitution principle?
The rest of your comment builds upon this misconception, and thus is falsified.
For example, I am sending requests to an HTTP server. There are several authentication methods but when we look at request/method interaction they are similar. So it would be convenient to have standard interface here, something like 'auth.applyTo(request)'. Yet would it be a good idea to try making different 'Auth' methods to be subclasses of each other?
Or another example I'm currently working on: I have a typical search tree, say, AVL, but in my case I need to make references to cells in the tree because I will access it bottom-up. As the tree changes its geometry the data move between cells so I need to notify the data about the address change. This is simple: I merely provide a callback and the tree calls it with each new and changed cell address. I can store any object as long as it provides this callback interface. Does this mean I need to make all objects I am going to store in a tree to inherit some "TreeNotifiable" trait?
Polymorphism happens when we split a system into two components and plan interaction between them. Internals of a component do not matter, only the surface. Inheritance, on the other hand, is a way to share some common behavior of two components, so here the internals do matter. These are really two different concepts.
Liskov substitution won't save you, and I'm quite tired of people saying it will. The problem of spaghetti structures is fundamental to what makes inheritance distinct from other kinds of polymorphism.
Just say no to inheritance.
Part of why I get (more) work done is that I don't bother with the near-useless taxonomical exercises that inheritance invites, and I understand that there are ways of writing functions for "all of these things, but no others" that are simpler to understand, maintain and implement.
The amount of times you actually need an open set of things (i.e. what you get with inheritance) is so laughably low it's a wonder inheritance ever became a thing. A closed set is way more likely to be what you want and is trivially represented as a tagged union. It just so happens that C++ (and Java) historically has had absolutely awful support for tagged unions so people have made do with inheritance even though it doesn't do the right thing. Some people have then taken this to mean that's what they ought to be using.
> 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 don't think that's really how it works. There are plenty of people who have tons of work experience but they've got bad ideas and are bad at what they do. You don't automatically just gain wisdom and there are lots of scenarios where you end up reinforcing bad ideas, behavior and habits. It's also very easy to get caught up in a collective of poorly thought out ideas in aggregate: Most of modern C++ is a great example of the kind of thinking that will absolutely drag maintainability, readability and performance down, but most of the ideas can absolutely sound good on their own, especially if you don't consider the type of architecture they'll cause.
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 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.)
My problem with OO bashing is not that it isn't deserved but seems in denial about pathological abstraction in other paradigms.
Functional programming quickly goes up it's own bum with ever more subtle function composition, functor this, monoidal that, effect systems. I see the invention of inheritance type layering just in adhoc lazy evaluated doom pyramids.
Rich type systems spiral into astronautics. I can barely find the code in some defacto standard crates instead it's deeply nested generics... generic traits that take generic traits implemented by generic structs called by generic functions. It's an alphabet soup of S, V, F, E. Is that Q about error handling, or an execution model or data types? Who knows! Only the intrepid soul that chases the tail of every magic letter can tell you.
I wish there were a panacea but I just see human horrors whether in dynamically-typed monkey-patch chaos or the trendiest esoterica. Hell I've seen a clean-room invention of OO in an ancient Fortran codebase by an elderly academic unaware it was a thing. He was very excited to talk about his phylogenetic tree, it's species and shared genes.
The layering the author gives as "bad OO" admin/user/guest/base will exist in the other styles with pros/cons. At least the OO separates each auth level and shows the relationship between them which can be a blessed relief compared to whatever impenetrable soup someone will cook up in another style.
The case I'm talking about is a perfect fit for inheritance. If not there, then where?
You may not be bitten by such an issue in application code. But I've seen it in library code. Particularly from Google, AWS, various Auth libraries, etc. Due to having to interop with multiple apis or configuration.
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.
> 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.
Add to it a database with all the different kinds of transaction semantics and you have a system that is way above the skillset of the average developer.
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.
I suspect it's much more common that codebases evolve towards requiring this nonlocal reasoning over time than being intentionally designed with it in mind.
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.
They sometimes help. But I think it's deeper than this. A function with inputs and outputs that are well-constrained with very abstract, complex types is still hard to reason about, unless you're used to those abstractions.
I think it's more accurate to say that something is "easy to reason about" if its level of abstraction "closely matches" the level of abstraction your brain is comfortable with / used to. This can vary dramatically between people, depending on their background, experience, culture, etc.
I could describe the Option<HashSet<UserId>> type in terms of functors and applicatives and monads, and though it would describe exactly the same set of valid values, it has a much higher cognitive load for most people.
> However, programs that leverage types effectively are sometimes construed as having high cognitive load, when it in fact they have low load.
Cognitive load is an individual experience. If someone "construes" something as having high cognitive load, then it does! (For them). We should be writing programs that minimize cognitive load for the set of programmers who we want to be able to interact w/ the code. That means the abstractions need to sufficiently match what they are comfortable with.
It's also fine to say "sorry, this code was not intended to have low cognitive load for you".
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.
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 used to be one of those proponents, and have done a 180.
The problems are:
1. The names are never as self-evident as you think, even if you take great care with them.
2. Simply having so many names is an impediment in itself.
The better way:
Only break things up when you need to do. This means the "pieces" of the system correspond to the things you care about and are likely to change. You'll know where to look.
When you actually need an abstraction to share code between parts of the system, create it then.
Compare
fn send_email(addr: &str, subject: &str, body: &str) -> Result<()>
to fn send_email(add: &EmailAddr, subject: &str, body: &str) -> Result<()>
In the second case, the edge cases of an empty or invalid email address don't need to be tested, they are statically impossible.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).
init -> success -> red
init -> failure -> cleanup
red -> success -> red_yellow
red -> failure -> cleanup
red_yellow -> success -> green
red_yellow -> failure -> cleanup
green -> success -> yellow
green -> failure -> cleanup
yellow -> success -> red
yellow -> failure -> cleanup
cleanup -> done -> finish
init/red/etc are states.success/failure/etc are events.
Each state is a function. The function red() for example, waits for 20 seconds, then returns success (assuming nothing went wrong).
To start the state machine, initializes state to "init", and enter a loop, in the loop you call the function for the current state (which makes that state actually happen and do whatever it does), and that function returns its event for whatever happen when it was run, and you then call a second function, which updates state based on the event which just occurred. Keep doing that, until you hit state "finish", then you're done.
More and more complex code requires more structure.
Structure takes time and effort, so we write the minimum amount of structure which is appropriate for the code (where code often grows over time, and then by that growth becomes unmanagable, and then we need more structure, which may require a rewrite to move from the existing structure to a new, fuller structure).
So with methods for organizing code, we go something like, in order of less to more structure,
. lines of code . functions . libraries . OO libraries . classes
A state machine is a form of structure, separate from how we organize code, and moderately high cost. I don't often use one, because most of the code I write doesn't need to be particularly rigorous - but for example I did write a mailing list, and that really is used, so it really did have to be correct, so I wrote out the state machine and implemented based on the state machine.
State machines also help with testing. You can keep track of which states you have tested and which events from each state you have tested.
I've never written a REST API in my life, so I can't tell you if I would use a state machine for that :-)
Although this is often the case, the style of the program can change things significantly. Here are a few, not so uncommon, examples where it starts to break down:
1. When you’re crafting algorithms, you might try to keep code blocks brief, but coming up with precise, descriptive names for each 50-line snippet can be hard. Especially if the average developer might not even fully understand the textbook chapter behind it.
2. At some point you have to build higher than "removeLastElementFromArray"-type of functions. You are not going to get very far skimming domain-specific function names if don’t have any background in that area.
More examples exist, but these two illustrate the point.
I thought this thread was more about (non) maintainability of code consisting of many procedures for each of which names are to be found that will make their usage self-explaining.
From my experience, simple API's with complex and often long implementations can be very well suited. As long as those are low on side effects and normally DRY, as opposed to puristicly DRY.
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.
If understanding a block of code requires knowing a concept that the team feels everyone should know anyway, then it’s not such an imposition. If the code invites you to learn that concept, so much the better. The code is “discoverable” - it invites you to learn more. If the concept is incidental to the problem and/or the team is objectively wrong in their opinion, then you have tribal knowledge that is encroaching on the problem at hand. And whether it’s discoverable or not is neither here nor there. Because understanding the code requires knowing lots of other things, which means either memorization, or juggling more concepts than comfortably fit in short term memory - cognitive overload.
You know you’ve blown past this point when you finally trace the source of a bad piece of data but cannot remember why you were looking for it in the first place.
I’m hoping the problem of cognitive load gets more attention in the near future. We are overdue. But aside from people YouTubing code reviews, I’m still unclear what sorts of actionable metrics or feedback will win out in this arena. Maybe expanding code complexity to encompass the complexity of acquiring the values used in the code, not just the local data flow.
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.
For a software company, this means crafting the product ownership of your team such that the teams can act as independently as possible.
This is where most companies already fail.
Once this has been achieved, you can follow this pattern on smaller and smaller scales down to individual functions in your code.
It's also interesting that in comment to the same article many people argue against PR process. I hardly see how else that level of discipline required not to undermine trust in names of small methods can be maintained for any team with more than 3 developers.
I insisted a lot on this unix (ish as it's not pipes) philosophy. It paid off so far.
We can test each cli app as well as make broader integration tests.
Is ID a string, a number, a GUID? Better check the usage within the function.
Oh, the declaration is `id: number`
Mystery solved.
Even better if the language supports subtyping so it is something like id: userID and userID is a subtype of number.
If there are multiple types of user IDs, I don't want to pass the wrong one into a DB call.
This is often the case when dealing with systems that have internal IDs vs publicly exposed IDs. A good type system can correctly model which I have a hold of.
For complex objects proper typing is even more important. "What fields exist on this object? I better check the code and see what gets accessed!"
Even worse are functions where fields get added (or removed!) to an object as the object gets processed.
Absolute nightmare. The concept of data being a black box is stupid, the entire point of data is that at some point I'll need to actually use it, which is a pain in the ass to do if no one ever defines what the hell fields are supposed to be laying around.
Statically typed code can lead to more complicated code; it can also accurately reflect the complexity inherent in the problem.
Vertical reasoning is reasoning inside a module or function. Here information hiding and clear interfaces help.
Horizontal reasoning is reasoning across the codebase in a limited context; adding a new parameter to a public function is a good example. The compiler helps you find and fix all the use sites, and with good ability to reason vertically at each site, even a change like this is simple.
Or it's open source and the authors were very much into Use The Source, Luke!
Cognitive load is contextual. `Option<HashSet<UserId>>` is readable to someone knowledgeable in the language (`Option`, `HashSet`) and in the system (meaning of `UserId` -- the name suggests it's an integer or GUID newtype, but do we know that for sure? Perhaps it borrows conventions from a legacy system and so has more string-like semantics? Maybe users belong to groups, and the group ID is considered part of the user ID -- or perhaps to uniquely identify a user, you need both the group and user IDs together?).
What is the cognitive load of `Callable[[LogRecord, SystemDesc], int]`? Perhaps in context, `SystemDesc` is very obvious, or perhaps not. With surrounding documentation, maybe it is clear what the `int` is supposed to mean, or maybe it would be best served wrapped in a newtype. Maybe your function takes ten different `Callable`s and it would be better pulled out into an polymorphic type. But maybe your language makes that awkward or difficult. Or maybe your function is a library export, or even if it isn't, it's used in too many places to make refactoring worthwhile right now.
I also quite like newtypes for indicating pragmatics, but it is also a contextually-dependent trade-off. You may make calls to your module more obvious to read, but you also expand the module's surface area. That means more things for people writing client code to understand, and more points of failure in case of changes (coupling). In the end, it seems to me that it is less important whether you use a newtype or not, and more important to be consistent.
In fact, this very trade-off -- readability versus surface area -- is at the heart of the "small vs large functions" debate. More smaller functions, and you push your complexity out into the interfaces and relationships between functions. Fewer large functions, and the complexity is internalised inside the functions.
To me, function size is less the deciding factor [0], but rather whether your interfaces are real, _conceptually_ clean joints of your solution. We have to think at a system level. Interfaces hide complexity, but only if the system as a whole ends up easier to reason about and easier to change. You pay a cost for both interface (surface area) and implementation (volume). There should be a happy middle.
---
[0] Also because size is often a deceptively poor indicator of implementation complexity in the first place, especially when mathematical expressions are involved. Mathematical expressions are fantastic exactly because they syntactically condense complexity, but it means very little syntactic redundancy, and so they seem to be magnets for typos and oversights.
*Not for practical programs
"Should", but does it? If a function returns Option<HashSet<UserId>> I know immediately that this function may or may not return the set, and if it does return the set, they are unique.
This is a fact of the program, I may not know "why" or "when" it does what. But as a caller, I can guarantee that I handled every possible code path. I wouldn't get surprised later one because, apparently, this thing can throw an exception, so my lock didn't get released.
As an argument: An optional filter for a query e.g. "return me posts form these users"
As a return value: The users who liked a post or nothing if it's not semantically valid for the post to be liked for some reason.
> I just don't find that "types" magically solve problems of cognitive load.
Cognitive load is about working memory and having to keep things in it. Without types one only has a name, say "userIds". The fact that it's possible for it to be null and that it's supposed to contain unique values has to be kept in working memory(an increase in cognitive load)
It's like taking "list.map(x -> x*x)" as a proof that parallelism is easy.
Most code is not embarrassingly extractable (or at least not at granularity of 3 lines long methods).
Concrete toy examples help here, so let me just give a straight code example.
Say you have the following interface:
void foo(void on_completed());
void callback();
void bar(int n)
{
foo(callback);
}
Now let's say you want to pass n to your callback. (And before you object that you'd have the foresight to enable that right in the beginning because this is obvious -- that's missing the point, this is just a toy example to make the problem obvious. The whole point here is you found a deficiency in what data you're allowed to pass somewhere, and you're trying to fix it during maintenance. "Don't make mistakes" is not a strategy.)So the question is: what do you do?
You have two options:
1. Modify foo()'s implementation (if you even can! if it's opaque third party code, you're already out of luck) to accept data (state/context) along with the callback, and plumb that context through everywhere in the call hierarchy.
2. Just embed n in a global or thread-local variable somewhere and retrieve it later, with appropriate locking, etc. if need be.
So... which one do you do?
Option #1 is a massive undertaking. Not only is it an O(n) changes for a call hierarchy of size n, but foo() might have to do a lot of extra work now -- for example, if it previously used a lock-free queue to store the callback, now it might lose performance as it might not be able to do everything atomically. etc.
Option #2 only results in 3 modifications, completely independently from the rest of the code: one in bar(), one for the global, and one in the callback.
Of course the benefit of #1 here is that option #1 allows local reasoning when reading the code later, whereas option #2 is spooky action at a distance: it's no longer obvious that callback() expects a global to be set. But the downside is that now you might need to spend several more hours or days or weeks to make it work -- depending on how much code you need to modify, which teams need to approve your changes, and how likely you are to hit obstacles.
So, congratulations, you just took a week to write something that could've taken half an hour. Was it worth it?
I mean, probably yes, if maintenance is a rare event for you. But what if you have to do it frequently? Is it actually worth it to your business to make (say) 20% of your work take 10-100x as long?
I mean, maybe still it is in a lot of cases. I'm not here to give answers, I absolutely agree local reasoning is important. I certainly am a zealot for local reasoning myself. But I've also come to realize that achieving niceness is quite a different beast from maintaining it, and I ~practically never see people try to give realistic quantified assessments of the costs when trying to give advice on how to maintain a codebase.
Have you never heard of the word of our lord and saviour oop, or functions? It's called encapsulation.
You might have learned it through prog langs as it is an embedded ideal
I'm not claiming the idea is novel, just that I haven't encountered a name for it before.
How is the concept of local reasoning distinct from that of encapsulation?
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.
- 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.
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.
I'm exaggerating, but only a little. Point is, in a deep project you may have domain-specialized parts, and those specialties don't overlap well. Like, ideally I'd take you aside for an hour to explain the 101 of the math you're doing and the context surrounding the change, but if neither you nor me have the time, that PR is getting a +2 from me on the "no stupid shit being done, looks legit code-wise; assuming you know your domain and this makes sense" basis.
Rather, the opposite. I often saw people make unnecessary complex or large PRs that were too much workload to review, leading the reviewer to approve, on the grounds of ”seems like you know what you’re doing and tbh I don’t have half a day to review this properly”.
Code review is a ritual. If you ask why we have it people will give you hypothetical answers more often than concrete examples. Personally I’m a proponent of opt-in CRs, ie ask for a second pair of eyes when your spidey senses tell you.
90% of comments in my team’s PRs come with suggestions that can be applied with a click (we use GitLab). It requires almost no effort to apply suggestions and it’s often not much extra work for reviewers to explain and suggest a concrete change.
I agree that reviews should be used pragmatically.
If you treat the people around you as valuable collaborators instead of pawns to be played to fulfill your processes, your appreciation for reviews will transform. Remember that it's their work too.
That just seems like company wide apathy to me. Obviously you have to make an effort to read the code, but there are lots of ways developers can overcomplicate things because they were excited to try a pattern or clever solution. It doesn't make them bad devs, it's just an easy trap to fall into.
These should not pass a code review just because the code "works." It's totally acceptable to say "we're not gonna understand this in 3 months the way it's written, we need to make this simpler" and give some suggestions. And usually (if you're working with people that care about the workload they make for others) they will stop after a few reviews that point this out.
We've done this at our company and it's helped us immensely. Recognizing whether the code is unnecessarily complex or the problem is inherently complex is part of it, though.
The times I've seen a 2nd set of eyes really help with the understandability of code, it was almost always collaboration before or while the code was being written.
I would estimate something like 1 out of 100 PR reviews I've seen in my life were really focussed on improving understandability.
Unfortunately 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.
Obvious signs of cargoculting in my opinion.
To catch stupid mistakes like an extra file, an accidental debug flag, a missing compiler hint that has to be added to migration scripts etc.
To ensure someone who doesn't quite understand the difference between dev and production build pipelines doesn't break it.
To ensure a certain direction is being followed when numerous contractors are working on the code. For example a vague consistency in API designs, API param names, ordering, etc.
To check obvious misunderstandings by juniors and new hires.
To nix architect astronauts before their 'elegant' solution for saving a string to a database in 500 lines gets added.
To check the code is actually trying to solve the ticket instead of a wrong interpretation of the ticket.
To get introduced to parts of the codebase you haven't worked on much.
But as with anything you get from it what you put in.
Without that absolutely critical information, no cost benefit analysis is possible.
In my experience across many companies, PR reviews almost never catch any of those bugs or bring any of those benefits.
Funny part is that not even in highly regulated markets.
ISO270001 or SOC2 are pretty much something every software company will have to do.
Locking yourself into an enormously expensive process with no evidence of its efficacy just because you don't want read up on the process yourself or push back on a misinformed auditor is a terrible business decision.
We recently talked about not requiring reviews for people in L5 and above levels but ultimately got shut down due to compliance.
Just in 2024, I've had three or four caught[0] (and caught a couple myself on the project I have to PR review myself because no-one else understands/wants to touch that system.) I've also caught a couple that would have required a hotfix[1] without being a five-alarm alert "things are down".
[0] including some subtle concurrency bugs
[1] e.g. reporting systems for moderation and support
It's the two-person rule, the two nuclear keyswitches.
There's so many other ways you can inject malicious code with stolen credentials that doesn't require a PR in every production environment I've ever worked in. There's much lower hanging fruit that leaves far fewer footprints.
The presumed claim that no one at the company benefited from a second set of eyes is amazing, too.
In my experience the people who are promoting things to production that shouldn't be will find a way to do it. They'll either wear down the people who want to stop it, or they'll find someone else to approve it who doesn't know why it shouldn't be approved or doesn't care.
My hypothesis is that requiring any 2nd random engineer in the company to approve production code doesn't provide enough value to justify the cost.
There may be other controls that are worth the cost.
However, our industry has been shipping software for a long time without this requirement, and I've seen no evidence that the practice has saved money, reduced the number of bugs, or improved software quality by any other metric. I think it's time we examine the practice instead of taking it on faith that it's a net benefit.
>Not realizing this is extremely telling.
Nice way of saying, I don't agree with you so I must be an idiot.
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’m reviewing the code because I don’t want shit code merged into the code base I am responsible for operating. I’m going to be the one debugging that. Don’t just merge shit you feel like merging.
> 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.
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”.
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.
In most cases where I see responses like this, they're not surprised to wait hours or days for a PR review. In that case, it makes sense to go big, otherwise you'll never get anything done. If you only have to wait half an hour, max, for a PR review; the extra code churn is 1000% worth it.
As a developer, I want my PRs to actually be reviewed by my coworkers and to have issues caught as a second layer of defense, etc.
As a reviewer, I effectively stopped approving things I couldn't give at least a cursory, reasonable glance (and tried to encourage others to follow suit because if we're not reviewing things, why not just push directly to main).
As a consequence, I have:
* tried to review most things within like half an hour of their announcement
in the shared MR channel
* requested a pair programming session and offered to do a pair programming
session for any large and semi-or-fully automated refactoring session,
like running a linter or doing a multi-file variable rename
(the pair programmer immediately comments on and approves the MR when it
appears)
* tried to limit my PRs to approximately 400 lines (not a rigid rule)
There were some specific instances of people not liking the "you must pair program if you're going to touch 400 files in one PR" requirement; but otherwise, I would like to think those on my team liked the more regular PRs, more people doing the PRs, etc, that resulted from this and some healthy culture changes.I would also like to feel like the more junior devs were more willing to say anything at all in the PRs because they could follow the change.
But yeah, it’s hard to get the culture rolling if it isn’t already in place nor has anyone in the company worked with a culture like that.
I've found 15-30 minutes to be plenty of time to review about a day's worth of code. It's enough time to process what the code is doing and iterate over the tests, in general.
Here's a scary thought: if something small takes 15-30 minutes to appropriately process ... how much longer do *large* changes take? Can someone keep all that in their mind that whole time to comprehend and process a huge change?
And a better question, will they?
Um, yes. This is 100% the point. There is no amount of refactoring, bug fixing, or features that cannot be expressed as a chain of trivial changes.
What you usually see happen is that instead of spending a week experimenting with 15 different refactors, is that an engineer opens a PR with what they think they're going to try first. Other engineers point out how they had tried that before and it didn't work; but maybe this other way will. So, they end up "working together" on the refactor instead of one developer getting lost in the sauce for a week seeing what sticks to a wall.
In essence, about the same amount of time is spent; but the code is higher quality and no architecture reviews during code reviews (which is another rule that should exist on a team -- architecture reviews should happen before a single line of code is touched).
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.
I admit that I am pretty allergic to people who avoid working with imperfect code.
And you'd better hope you're not squashing that monstrous thing when you're done.
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.
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).
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.
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'
Like... Increase the performance of a central VM. You'll touch every part of the code, but probably also build a new compiler analysis system. The system is seperate to existing code, but useless without the core changes. Seperating the two can ruin the optimisation meant to be delivered, because the context is no longer front and center. Allowing more quibling to degrade the changes.
I’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.
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
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.
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
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.
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)
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".
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”
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.
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 it's a linter, I shrug and move on.
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?
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.
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
1. your refactoring is probably going in the wrong direction. Team members will be able to help here more than ever. Let them bikeshed, but don't stop working on your main refactor yet. Revist later and integrate their changes.
2. the PR is too small. it will have to be part of a larger PR.
In my experience, people tend to have the first problem, and not the second one, but they think they have the second one. There are many of these "massive refactoring" PRs I've reviewed over the last 20 years where the refactoring makes the code worse, overall. Why? Because refactoring towards a goal (implementing a feature, fixing a bug, etc.) doesn't have the goal refactoring should have: improving code maintainability. So, the refactored code is usually LESS maintainable, but it does what they wanted.
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.
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.
Arguments like "but it works in majority of cases" are a way to delegate fixing issues to somebody else later. Unless noone will be using that code at all, in which case it should not be merged either.
Been there. Left, live thousands times better.
So you blindly accept an ill-informed suggestion because that's the only way you can complete the process.
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.
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.
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.
Yes – I was referring to lazy in the sense of the apocryphal quote from Bill Gates:
“I choose a lazy person to do a hard job, because a lazy person will find an easy way to do it.”
> Only after going through a fundamentals-first approach do you not end up with one kind of spaghetti, but you end up with another kind because it's fundamentally engineered towards producing spaghetti code unless you constantly fight the inertia of spaghettification.
I’ve been guilty of this. Thinking that a given abstraction is unnecessary and overly-complicated, building my own minimal abstraction for my use case, and then slowly creating spaghetti as I account for more and more edge cases.
My question is .. is this getting more common as time goes on, or do I just feel like it is..
(Glib, but in my experience, mostly true.)
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?
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.
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.
Sometimes food smells because it turned bad, and sometimes it's smelly because it's cheese.
(Of course some organizations have coding conventions that are just stupid, but that's a separate issue.)
After over 40 years of programming, I continue to reduce the size of functions and find it easier to write and understand when I return to them. Ten lines are now a personal guideline.
However, a linear function with only tiny loops or conditionals can be easily understood when hundreds of lines are long, but not so much with nested conditionals and loops, where there is natural decomposition into functions.
I observed that the same guidelines became rules problems when test coverage became popular. They soon became metrics rather than tools to think about code and tests. People became reluctant to add sanity check code for things that could should never happen because it brought down code coverage.
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.
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.
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.
Some people who actually wrote a decent amount of code in their lives are sharing that opinion, so your comment just sounds like an ad-hominem attack.
Not everything has to be designed like some standardized mass produced part ready to drop into anything made in the last 40 years. And what is crazy is that even things written to that standard aren’t even compatible and might have very specific dependencies themselves.
Then rules stay and new people just continue with same silly rules instead of thinking if those are really that useful.
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’.
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.
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?
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.
I'm not saying you have to use classes to do this, but they certainly get the job done.
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.
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.
Composition at least makes things a little more obvious where methods are getting their functionality. It also has other benefits in terms of making objects easier to mock.
Modularity is a much bigger concept, related to the engineering of large software systems. These days, “micro-services” is one way that people achieve modularity, but in the old days it was needed for many of the same reasons, but inside the monolith. The overall solution is composed of blocks living at different layers.
Re-use also exists inside modules, of course, by using functions or composition or — shudder — inheritance of code.
Modular programming has value as soon as more than one team needs to work on something. As it’s impossible to predict the future, my opinion is that it always has value to structure a code-base in this way.
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).
myfunction(data) {
# do one thing to the data
...
# now do another
...
}
becomes that: myfunction(data) {
do_one_thing_to_the_data(data)
now_do_another(data)
}
do_one_thing_to_the_data(data) {
...
}
now_do_another(data) {
...
}
Still linear, easier to get an overview, and you can write more modular tests.Plus, if we're dealing with a "long list of tasks" that can't be broken up in reusable chunks, it probably means that you need to share some context, which is way easier to do if you're in the same scope.
One thing I find useful is to structure it in blocks instead, so you can share things but also contain what you don't want shared. So e.g. in rust you could do this:
let shared_computation = do_shared_computation();
let result_one = {
let result = do_useful_things();
other_things(&shared_computation);
result
}
...
I think it's a nice middleground. But you still can't write modular tests. But maybe you don't have to, because again, this is just a long list of tasks you need to do that conceptually can't be broken down, so maybe it's better to just test the whole thing as a unit.Because you don't jump up and down the file to read it.
Each method that you create has a name, and the name is an opportunity to explain the process - naturally, in-line, without comments.
I write code like this all the time - e.g. from my current project: https://github.com/zahlman/bbbb/blob/master/src/bbbb.py . If I wanted to follow the flow of execution, I would be hammering the % key in Vim. But I don't do that, because I don't need or want to. The flow of the function is already there in the function. It calls out to other functions that encapsulate details that would be a distraction if I want to understand the function. The functions have names that explain their purpose. I put effort into names, and I trust myself and my names. I only look at the code I'm currently interested in. To look at other parts of the code, I would first need a reason to be interested in it.
When you look at yourself in the mirror, and notice your hand, do you feel compelled to examine your hand in detail before you can consider anything about the rest of your reflection? Would you prefer to conceive of that image as a grid of countless points of light? Or do you not find it useful that your mind's eye automatically folds what it sees into abstractions like "hand"?
35 years into my journey as a programmer, the idea of a 100-line function frightens me (although I have had to face this fear countless times when dealing with others' code). For me, that's half of a reasonable length (though certainly not a hard limit) for the entire file.
Ironically, the author of the post claims it has the opposite effect.
# Can't import at the start, because of the need to bootstrap the
# environment via `get_requires_for_build_*`.
This comment is a great example of what information you lose when you split linear code into small interrelated methods. You lose ordering and dependencies.Sometimes it's worth it. Sometimes it isn't. In my opinion it's almost never worth it to get to the Uncle Bob's approved length of methods.
10-30 lines is OK. 3 is counterproductive except for a small subset of wrappers, getters etc. Occasionally it's good to leave a method that is 300 lines long.
If your code always does 9 things in that exact order - it's counterproductive to split them artificially into 3 sets of 3 things to meet an arbitrary limit.
Inlining `_read_toml` or `_read_config` would change nothing about the reasoning. The purpose was to make sure the import isn't tried until the library providing it is installed in the environment. This has nothing to do with the call graph within my code. It's not caused by "splitting the code into interrelated methods" and is not a consequence of the dependencies of those functions on each other. It's a consequence of the greater context in which the entire module runs.
The way that the system (which is not under my control) works (I don't have a really good top-down reference handy for this - I may have to write one), a "build frontend" will invoke my code - as a subprocess - multiple times, possibly looking for and calling different hooks each time. The public `get_requires_for_build_wheel` and `get_requires_for_build_sdist` are optional hooks in that specification (https://peps.python.org/pep-0517/#optional-hooks).
However, this approach is left behind from an earlier iteration - I don't need to use these hooks to ask the build frontend to install `tomli`, because the necessary conditions can be (and currently are) provided declaratively in `pyproject.toml` (and thus `tomli` will be installed, if necessary, before any attempts to run my backend code). I'll rework this when I get back to it (I should just be able to do the import normally now, but of course this requires testing).
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.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.
Good abstractions often reduce LOC, but I prefer to think of that as a happy byproduct rather than the goal.
I hold this principle as well.
And I commonly produce one-liner subroutines following it. For me, 15 lines has become disturbingly long.
This is about readability (which includes comprehensibility), not reuse. When I read code from others who take my view, I understand. When I read code from those who do not, I do not, until I refactor. I extract a piece that seems coherent, and guess its purpose, and then see what its surroundings look like, with that purpose written in place of the implementation. I repeat, and refine, and rename.
It is the same even if I never press a key in my editor. Understanding code within my mind is the same process, but relying on my memory to store the unwritten names. This is the nature of "cognitive load".
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.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.
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.
Depends on your goals / constraints. From a performance standpoint, the attribute lookups can often dwarf the overhead of an extra assignment.
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.
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)
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.
If I'm not interested I just jump past the block when reading (given that it's short and tidy)
It determines whether the thing is enabled. Or else some other dev has some 'splainin' to do. I already understand "what it does"; I am not interested in seeing the code until I have a reason to suspect a problem in that code.
If the corresponding logic were inline, I would have to think about it (or maybe read a comment) in order to understand its purpose. The function name tells me the purpose directly, and hides the implementation that doesn't help me understand the bigger picture of the calling function.
Inline code does the opposite.
When the calculation is neatly representable as a single, short, self-evident expression, then yes, I just use a local assignment instead. If I find myself wanting to comment it - if I need to say something about the implementation that the implementation doesn't say directly - using a separate function is beneficial, because a comment in that function then clearly refers to that calculation specifically, and I can consider that separately from the overall process.
Ah, but what exactly does "enabled" mean in this context? Might seem nitpicky, but I might very well have a different opinion than the person who wrote the code. I mean, if it was just `if foo.enabled ..` no one would put it in a new function.. right? :)
I would say a comment does the same, and better because it can be multi line, and you can read it without having to click or move to the function call to see the docs.
And you can jump past the implementation, iff it's short and "tidy" and enough.
Yes, at some point it should be moved out anyway. I'm just weary from reading code with dozens of small functions, having to jump back and forth again and again and again
If the code is working, it means what it needs to mean.
> I mean, if it was just `if foo.enabled ..` no one would put it in a new function. right?
Sure. This is missing the point, however.
> I'm just weary from reading code with dozens of small functions, having to jump back and forth again and again and again
Why do you jump to look at the other parts of the code? Did it fail a test?
No. Working code says nothing about the meaning of a label, which is purely to inform humans. The computer throws it away, the code will work no matter what you name it, even if the name is entirely wrong.
> Why do you jump to look at the other parts of the code? Did it fail a test?
Because people pick bad names for methods, and I've been hurt before. I'm not reading the code just to fix a problem, I'm reading the code to understand what it does (what it ACTUALLY does, not what the programmer who wrote it THOUGHT it does), so I can fix the problem properly.
So you write long functions because other people are bad at writing short ones?
Naming things is hard! Even if you're really good at naming things, adding more names and labels and file separation to a system adds to the complexity of the system. A long function may be complex, but it doesn't leak the complexity into the rest of the system. Creating a function and splitting it out is not a zero cost action.
I write long functions when long functions make sense. I write plenty of short functions too, when that makes sense. I'm not religiously attached to function or file size, I'm attached to preserving the overall system structure and avoiding stuff that makes easy bugs.
If 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 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.
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).
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.
This rule is the same as lines of code type rules. The number itself is not the issue, it could be few parameters and a problem or it could be many parameters and not be an issue at all.
Otherwise, your just hiding the fact that your function requires too many arguments by calling them properties.
Of course, on some very exceptional case, 7 arguments might be relevant after all. If that is like the single one in the code base, and after thorough discussion with everyone implicated in the maintenance of the code it was agreed as an exceptionally acceptable trade-off for some reasons, making sure this would not leak in all the code base as it's called almost everywhere, then let it be.
But if it's a generalized style through the whole codebase, there are obvious lake of care for maintenability of the work and the team is going to pay for that sooner than later.
e.g.
foo(...):
# Fields
a
b
c
d
e
# Methods
f1(f)
f2(f)
f3()
...
f7()
Seeing 10 variables passed in to a function is a code smell.
Whether you put in in a common class / struct or aggregate them in a dict depends on whether or not all those functions are related.
In general, your functions should not be super duper long or super duper intended. Those are also code smells that indicate you have the wrong abstractions.
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.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.
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)
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.
Because you only read the submethod names, and then you already understand what the code does, at the level you're currently interested in.
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 would you jump into those functions and back?
EDIT:
For even a simplest helper, there's many ways to implement it. Half of them stupid, some only incorrect, some handling errors the wrong way or just the wrong way for the needs of that specific callee I'm working on. Stupidity often manifests in unnecessary copying and/or looping over copy and/or copying every step of the loop - all of which gets trivially hidden by extra indirection of a small function calling another small function. That's how you often get accidental O(n^2) in random places.
Many such things are OK or not in context of caller, none of this is readily apparent in function signatures or type system. If the helper fn is otherwise abstracting a small idiom, I'd argue it's only obscuring it and providing ample opportunities to screw up.
I know many devs don't care, they prefer to instead submit slow and buggy code and fix it later when it breaks. I'm more of a "don't do stupid shit, you'll have less bugs to fix and less performance issues for customers to curse you for" kind of person, so cognitive load actually matters for me, and wishing it away isn't an acceptable solution.
Strange. The longer I've been programming, the less I agree with this.
>For even a simplest helper, there's many ways to implement it.
Sure. But by definition, the interface is what matters at the call site.
> That's how you often get accidental O(n^2) in random places.
Both loops still have to be written. If they're in separate places, then instead of a combined function which is needlessly O(n^2) where it should be O(n), you have two functions, one of which is needlessly O(n) where it should be O(1).
When you pinpoint a bottleneck function with a profiler, you want it to be obvious as possible what's wrong: is it called too often, or does it take too long each time?
> If the helper fn is otherwise abstracting a small idiom, I'd argue it's only obscuring it and providing ample opportunities to screw up.
Abstractions explain the purpose in context.
> I'm more of a "don't do stupid shit, you'll have less bugs to fix and less performance issues for customers to curse you for" kind of person
The shorter the function is, the less opportunity I have to introduce a stupidity.
Does this really need explaining? My screen can show 35-50 lines of code; that can be 35-50 lines of relevant code in a "fat" function, or 10-20 lines of actual code, out of order, mixed with syntactic noise. The latter does not lower cognitive load.
To use a real world example where this comes up a lot, lots and lots of code can be structured as something like:
accum = []
for x in something():
for y in something_else():
accum.append(operate_on(x, y))
I find structuring it like this much easier than fully expanding all of these out, which at best ends up being something like accum = []
req = my_service.RpcRequest(foo="hello", bar=12)
rpc = my_service.new_rpc()
resp = my_service.call(rpc, req)
req = my_service.OtherRpcRequest(foo="goodbye", bar=12)
rpc = my_service.new_rpc()
resp2 = my_service.call(rpc, req)
for x in resp.something:
for y in resp2.something_else:
my_frobnicator = foo_frobnicator.new()
accum.append(my_frobnicator.frob(x).nicate(y))
and that's sort of the best case where there isn't some associated error handling that needs to be done for the rpc requests/responses etc.I find it much easier to understand what's happening in the first case than the second, since the overall structure of the operations on the data is readily apparent at a glance, and I don't need to scan through error handling and boilerplate.
Like, looking at real-life examples I have handy, there's a bunch of cases where I have 6-10 lines of nonsense fiddling (with additional lines of documentation that would be even more costly to put inline!), and that's in python. In cpp, go, and java which I use at work and are generally more verbose, and have more rpc and other boilerplate, this is usually even higher.
So the difference is that my approach means that when you jump to a function, you can be confident that the actual structure and logic of that function will be present and apparent to you on your screen without scrolling or puzzling. Whereas your approach gives you that, say, 50% of the time, maybe less, because the entire function doesn't usually fit on the screen, and the structure may contain multiple logical subroutines, but they aren't clearly delineated.
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.
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?
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.
I'm for it if it's possible but it can still make it harder to follow.
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.
Does it essentially just mean "I agree"?
"So much" is applied to intensify that.
So, yes, it's a strong assertion of agreement with the comment they're replying to.
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.
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
Some people seem to love doing the opposite, which IMHO is the biggest problem with the software industry --- there's a huge number of developers who think that the more complex (or in their words, "structured") software is, and the more latest language features it has, is somehow better than the simple "deprecated" stuff that's been working for decades.
I've come to the conclusion that a lot of new language features are there only for the benefit of trendchasers and worshippers of planned obsolescence, and not the users nor the developers on their side.
I guess that set includes me now ¯\_(ツ)_/¯
What are your thoughts on aync/await then (available in Python/JavaScript etc.)?
Not the OP, but I find it hard to have an absolute opinion on this - IMO some recent additions to javascript significantly decrease cognitive load.
async/await is a great example of this (vs then/catch/finally chains), and also:
* spreading of arrays, props, and more arguably args * shortcutting prop/value pairs, e.g. { x:x } as { x }
Some stuff it seems are more confusing, e.g.
* similar but subtly different things like for/of vs for/in vs forEach vs iterating Object.keys(), Object.values(), Object.entries() * what are generators and the yield keyword for?
Generators are great for memory intensive data structures (ie. large lists), as they provide lazy evaluation to languages that are designed to evaluate eagerly. Generators can be considered to be a first step towards coroutines if you will, as the subroutines are the ones yield-ing control back to the event loop - David Beazley has a great talk[0] on it, coding an event loop live from scratch.
> similar but subtly different things like for/of vs for/in vs forEach vs iterating Object.keys(), Object.values(), Object.entries()
Array.forEach[1] is the oldest of that bunch, Object.keys[2] came later, for...in[3]/for...of[4] after that, and Object.values[5]/Object.entries[6] are the newest addition. I personally prefer the new way here.
EDIT: for...in[3], then Array.forEach[1], then Object.keys[2], then the rest it seems.
[0]: https://youtu.be/MCs5OvhV9S4?t=1072 (video)
[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[2]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[3]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[4]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[5]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[6]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
The only pitfall with async/await is all the existing I/O-intensive code where blocking your runtime wasn't seen as a problem that waisted unnecessary resources (yet). Must be This Tall to Write Multi-Threaded Code[0] comes to mind.
[0]: https://bholley.net/blog/2015/must-be-this-tall-to-write-mul...
Several decades of experience debugging race conditions and other obscure concurrency bugs have taught me that the benefits of async code are rarely worth it, and more often than not add unnececessary overhead both at runtime and in development.
My take is that consumerism did a number on our psyche.
Things that you know and have internalised don't occupy your brainspace. That's why you can read this paragraph without having to think about every word individually.
Some "10x" developers are extremely productive because they know the software stack inside and out.
There are also "10x" teams where they share an institutional knowledge of the software stack and are constantly improving and finding more efficient ways of solving a problem. When someone finds a better library, more efficient CI/CD or establish new code practices it is easy to apply it when they have a single stack to focus on.
It's hard to replicate this in teams where they are responsible for 10 micro services, each written in their own language and using different software stacks that are more "optimal" for some use case.
I also document (or comment code) when I am waiting for the flow to kick in. For me, the documentation starts at the appendices. One for input files or api source tricks or describing the input tables in a database. Another for the relevant outputs. Maybe a "justify overall design decisions" section. Just stuff someone would wish if they had to handle it in five years.
Documenting while waiting for the flow is like stretching before exercise. It gets you to that place :)
I got my experience/slaps by fixing my own code in the same codebase for more than ten years. I have empathy for the future maintainers :)
On the other hand, I had an apartment-renting suite I had written get passed on to students. While it was written in Perl (a famously "write once, read never" language according to critics), because I did just one thing per line and had comments at multiple levels (a block header for each function, comments within functions, a changelog at the top), they called back and said it was the easiest thing in the world for them to convert it, despite not knowing Perl at all.
Although I had been programming for years and years by the time I took a FORTRAN course in high school, I had a particularly exacting teacher with a Ph.D. in Computer Science and he drilled us on how we would have to deal with the code of others, or our old code, or code when we did not want to come into work with a cold. Cleverness is held in reserve for data structures and algorithms, when you have no other options. He was very firm on that.
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.
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.
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.
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.
I'm not sure we are on the same page here. I'm saying the absence of redundant syntax of the sort that lets the compiler accurately diagnose 'trivial' syntax errors, that can create scenarios where a typo can give you unintended valid syntax with different logic.
So yes, the conditional shorthand in C would be an example. Getting rid of the braces means you lose an opportunity for the compiler to catch a 'trivial' syntax error, which can lead to different semantics than what the writer intended.
However for curly brances around a conditional lexical scope, the compiler cannot tell you where the closing brace should be, besides before the end of the lexical scope that contains it, like the end of the containing function or class. There can be multiple valid locations before that: every other valid line of code. This is not the same as a semicolon, which must end every line of code.
Can you provide another example?
Ideally, adding/removing/changing a single character in valid source code would always render the code invalid instead of “silently” changing its meaning.
yes, that's true | but this redundancy is not necessary | it's there for historical reasons | there are other ways to designate the separation between sentences | some of those alternatives might even make more sense than the usual convention
The spaces in your “ | ” punctuation are also not strictly needed, yet one would want to keep then for readability and for risk of otherwise mistaking an “|” for an “l” or similar.
Again, something not being strictly needed isn’t a sufficient argument to do without it. There are trade-offs.
yes.that.is.true|spaces.are.not.strictly.needed.at.all|there.are.alternatives.and.there.are.situations.where.using.those.alternatives.actually.makes.sense|however.the.use.of.whitespace.is.so.deeply.ingrained.that.if.you.dont.do.it.the.rendering.of.your.text.will.generally.be.very.annoying.on.contemporary.systems
The Right Answer is to separate the underlying representation from the rendering. We already do this to some extent in modern systems. For example, the meaning of text generally doesn't change if you change the font. This is not always true. The font can matter in math, for example. And some font modifications can carry semantic information -- using italics can provide emphasis, for example.
The Right Way to design a programming language is to have an underlying unambiguous non-redundant canonical representation, and then multiple possible renderings that can be tailored to different requirements. Again, we kinda-sorta do that in modern systems with, for example, syntax coloring. But that's just a half-assed hack layered on top of deeply broken language designs.
yes, that's true but this redundancy is not necessary it's there for historical reasons...
without any breaks. That might be exaggerating compared to your actual position, but surely you can see that "unnecessary in this situation" doesn't imply "unnecessary overall". "Not necessary" if we're cherrypicking, sure.
If my program now has no semicolons and then I write something else that behaves differently than expected, I'm going to be sad. My mental model for programming fares better when semicolons are used, so I will favor writing programs with semicolons. To me, the cost is trivial and the benefit, while minimal, outweights the cost. I consider it separate from actual boilerplate. You can disagree and use other languages, but then we're probably being moreso opinionated than divided into better or worse camps.
To the point of being a straw man.
There was actually a time when neither white space nor punctuation was used andallwordswerejustruntogetherlikethis. Note that it's still possible to decipher that text, it just takes a bit more effort. Natural language is inherently redundant to a certain extent. It's mathematically impossible to remove all redundancy (that would be tantamount to achieving optimal compression, which is uncomputable).
The spaces around the vertical bars in my example were redundant because they always appeared before and after. That is a sort of trivial redundancy and yes, you can remove it without loss of information. It just makes the typography look a little less aesthetically appealing (IMHO). But having something to indicate the boundaries between words and sentences has actual value and reduces cognitive load.
---
[1] https://en.wikipedia.org/wiki/Kolmogorov_complexity#Uncomput...
> You become keenly aware of the fact that 90% of your mental effort is going not towards actually solving the problem at hand, but appeasing the compiler or conforming to some stupid syntax rule that exists for no reason other than that someone at some time in the dim and distant past thought it might be a good idea, and were almost certainly wrong.
You said this originally. I definitely agree for something like parentheses in if conditions in Java, but I think semicolons are a great example of how
> having something to indicate the boundaries between words and sentences has actual value and reduces cognitive load.
It's not bad to have them, it's bad to require them when they aren't necessary. It's bad to make their absence be a fatal syntax error when they aren't necessary. (Some times they are necessary, but that's the exception in contemporary languages.)
Also, I know I'm the one who brought them up, but semicolons are just one small example of a much bigger and more widespread problem. It's a mistake to fixate on semicolons.
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.
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.
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.
This is not true, because an editor can add any on-screen hints that are needed to help a human understand the code. For example, in my editor, Python code gets vertical lines that indicate where the different indentation levels are, so I can easily see when two lines of code far apart on the screen are at the same indentation level, or how many indentation levels lower the next line is after a long, highly-indented block. Python could add an end-of-block marker like Ruby does to make things like this easier to see, or it could try to encode the vertical lines into the language somehow, but I'd derive no benefit because the editor already gives me the visual clues I need.
Type linters like mypy can check your code & report something like "this function call requires str, you're providing str | None" though.
That means you and the compiler front-end are looking at different representations. Sounds doesn't sound like a good idea. Keep it stupid simple: all of our source control tools should work on the same representation, and it should be simpler and portable. Well defined syntax is a good choice here, even if there's some redundancy.
It isn't, but that ship sailed when cpp was invented.
> Keep it stupid simple
Could not agree more. That's why I use Lisp.
The fact that subsequent languages largely avoided that suggests that we can sometimes learn from our mistakes.
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.
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.
For obvious reasons...
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.
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).
func thing()
{
I quite like this approach. It's very simple and consistent, and once you know how it works it's not ever surprising.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.
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.
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.
For the opposite example, here's where my preference comes from: I'm editing a function. There's an argument called order, and most likely it's either an instance of some Order class, which has some attribute I need, or it's an integer representing the I'd of such an order, or it's null. I'm hoping to access that attribute, so I have to figure out the type of order.
In a dynamically typed language, I'll have to look at the caller of my function (goes on my mental stack), see where it gets it order from, potentially go to the caller of that function (goes on my mental stack), etc until I hopefully see where order is instantiated and figure out it's type, so I can take the call sites off of my mind, and just keep the type of order in my mental stack.
But actually, this is wrong, because my function is called by way more functions than the ones I examined. So really, all I know now is that _sometimes_ order is of type Order. To be sure, I have to go to _all_ callers of my function, and all their callers, etc. This grows exponentially.
But let's say I manage, and I find the type of order, and keep it in my mind. Then I need to repeat the same process for other arguments I want to use, which is now harder because I'm keeping the type of order on my mental stack. If I manage to succeed, I can go and edit the function, keeping the types of whatever variables I'm using in my head. I pray that I didn't miss a call site, and that the logic is not too complicated, because my mental stack is occupied with remembering types.
Here's how to do this in a statically typed language: read the type signature. Done.
bool function1(x y z):
bool function2(x y z)
immagine function2 besides returning true/false mutates x in some major way. this is a far bigger and more common problem than typing. a dynamic language with capabilities of runtime debugging is far better equiped to inspect code like thisalso i am not saying that typing is worse than no type information. im saying that typing should be optional as far as the compiler is concerned and typing information should be like documentation that is useful to the compiler. common lisp can be an example of a language that is both dynamic and strongly typed (see SBCL) to the extent that you can implement a statically typed language (ala ML) in it
Have you used a robust, modern debugger, like IntelliJ or Visual Studio? You can do a whole heck of a lot of very, very robust debugging. You can watch, run commands, run sets of commands, inspect anything in great detail, write new code in the watcher, and so on.
and even though static languages can have "repls" as an afterthought (eg GHCi), rest assured that their static typing property is a completely unnecessary cognitive load (at least cognitive but very likely a performance one too) to their functioning
There are many integral types, all of which have different properties, often for good reasons.
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.
And regarding using specific compiler flags, well, projects almost never do that.
I've been using CL at work for pretty much my entire career and have always gotten a huge amount of leverage from it.
By contrast, I've used predominantly Haskell in my career for the last ten years, exactly because it has reduced my cognitive load. So I'm interested in understand the discrepancy here.
Consider the choices in optics libraries, effects systems, regex packages, Haskell design patterns, web frameworks, SQL libraries, and even basic string datatypes. Now consider the Cartesian product of those choices and all their mutual incompatibilities. That's cognitive overload and nobody pays for analysis paralysis.
A stable engineering group with long-term projects can define a reference architecture and these problems are manageable. But consider large enterprise consulting, where I work. We routinely see unrelated new client projects, quickly assembled teams with non-intersecting skill sets, and arbitrary client technical constraints. Here, the idea of a repeatable, reference architecture doesn't fit, and every new project suffered cognitive overload from Haskell's big Cartesian product.
I really hoped Boring Haskell, Simple Haskell, and other pragmatic influences would prevail but Haskell has gone elsewhere. Those values are worth reconsidering, either in Haskell directly, or in a new and simpler language that puts those goals at center of its mission.
(That said, I don't understand what abandonware and a tiny community have to do with cognitive load -- I agree they're bad, I just don't see the connection to cognitive load.)
> I really hoped Boring Haskell, Simple Haskell, and other pragmatic influences would prevail but Haskell has gone elsewhere. Those values are worth reconsidering
I agree with this, except the "gone elsewhere" part. Haskell has far more pragmatic influences today than it did ten years ago when I started using it professionally. The change is slow, but it is in the right direction.
Glancing at r/haskell, people often ask for help in choosing web frameworks, SQL libraries, effect systems and monad transformers, regex libraries, text datatypes, lens packages and so on. Simple Haskell and Boring Haskell tried eliminating those problems but the community ignored their pleas, occasionally dismissing the idea with frank negativity.
> what abandonware and a tiny community have to do with cognitive load -- I agree they're bad, I just don't see the connection to cognitive load.)
Our due diligence on 3rd party libraries investigates how active a library is, which includes github submission frequency, online discussions, blog posts, security fix responsiveness, courseware, etc. Activity runs from extremely high (like pytorch) to graveyard code written long ago by graduate students and researchers. Between those endpoints, the big middle is often murky and requires lots of contingency analysis, given that we're delivering real systems to clients and they must stay absolutely safe and happy. All that analysis is brain-deadening, non-productive cognitive load.
Obviously none of this is unique to Haskell, but it's fair to say that other platforms provide more standardized design conventions, and for my needs, a quicker path to success.
It has taken a few decades but the latest version significantly reduces the boilerplate.
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)
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.
They run into the problem that programming is inherently hard, and no amount of finagling with the language can change that, so you have to have someone on every team with actual talent. But the team can be made of mostly idiots, and some of them can be fired next year if LLMs keep improving.
If you use Lisp for everything, you can't just hire any idiot. You have to be selective, and that costs money. And you won't be able to fire them unless AGI is achieved.
That may be, but since Lisp programmers are easily 10x as productive as ordinary mortals you can pay them, say, 5x as much and still get a pretty good ROI.
> you can't just hire any idiot
Yeah, well, if you think hiring any idiot is a winning strategy, far be it for me to stand in your way.
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?
https://flownet.com/gat/lisp/djbec.lisp
It implements elliptic curve cryptography in Common Lisp using an embedded infix syntax.
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.
Ive never ever run into this problem in the years of writing common lisp. Can you show me an example code that has this? I wager you cannot and you are writing poopoo about something you know poo about and want it to be true just because you are too lazy to move beyond knowing poo
> But this kind of power just isn't conducive to maintainable code.
I can usually run code decades old in common lisp. In fact this is one of its well known features. How much more maintainable can it possibly get :)
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.
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.
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.
tst.c: In function ‘main’:
tst.c:3:5: error: expected ‘=’, ‘,’, ‘;’, ‘asm’ or ‘__attribute__’ before ‘x’
3 | x=1
| ^
tst.c:3:5: error: ‘x’ undeclared (first use in this function)
tst.c:3:5: note: each undeclared identifier is reported only once for each function it appears in
tst.c:3:8: error: expected ‘;’ before ‘}’ token
3 | x=1
| ^
| ;
4 | }
| ~
gcc (Debian 12.2.0-14) 12.2.0I get three errors, all on line 3 rather than 2, and as the first of them says, there are at least four alternatives for resolution besides semicolon.
Full code after adding type to main, including linter message from c.vim:
1 int main () {
2 int x
E 3 x=1 /\* E: 'x' undeclared (first use in this function)
4 }
.
Let’s just take write() as an example. Just calling write() does not guarantee that the full contents were written to the file. You could have the case where write() wrote nothing, or some data, but was interrupted because of a signal before it completed. Or you could have a short write because of a quota or disk limit.
And I’m sure I’ll be nerd sniped shortly with even more obscure examples of the sharp edges awaiting you in such a “simple” interface. (For a more snarky version of my comment, see the last essay “the rise of worse is better” of the Unix haters handbook: https://web.mit.edu/~simsong/www/ugh.pdf)
Point is, this interface just shifts cognitive load on to the application developer - it’s got to go somewhere after all.
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.
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.
"This programming paradigm is bad because cognitive load" becomes identical to "this programming paradigm is bad because it isn't simple" and then simple is such a fuzzy concept that you can define whatever you like as simple and whatever you don't as having high cognitive load.
Good observation. That is absolutely rampant in online programmer discussions/flame-wars. Which is why Rich Hickey's classic presentation Simple Made Easy, although not the last word on the topic, at least tried to bring some objectivity to what simple is.
And whenever it is being used as an argument to reject or approve code, just saying “because cognitive load” should not be accepted as enough. Instead, there should be an accompanying explanation for what exactly in the code raises the cognitive load and what mechanisms come into play that creates the cognitive load according to cognitive science. (Note: I’m using “science” here as opposed to just “psychology”, because the ana/physio of human memory is not exclusive to the domain of psychology.)
> 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.
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.
> No port/adapter terms to learn
and
> we gave it all up in favour of the good old dependency inversion principle
Both use interfaces, just in different ways. We use hexagonal (ports and adapters) pattern in my project. If you asked anyone on my team to define hexagonal architecture they'll have no idea what you're talking about. They just follow the project coding patterns. There's no additional complexity.
> If you think that such layering will allow you to quickly replace a database or other dependencies, you're mistaken
I think most people do not stay at companies long enough to see the price of not abstracting away these things. That's the next developer's problem. The code tends to be tightly coupled to the libraries and frameworks used. Eventually, the projects dependencies have to be upgraded (usually due to security issues) and the migrations are usually incredibly difficult, expensive, and fragile. The product's business logic is tightly coupled with the framework and libraries used at the time. Even if the company realizes that framework has no future, they're kind of locked into their initial decision made a decade ago. At least, that's been my experience.
We have two major products at my company. Both started with the same initial framework, but the project I architected that used hexagonal was migrated to a faster and more modern framework within 4 weeks. The other product had a multi-year migration to a newer version of the same framework (and by the time it was completed is already two major versions outdated). Both products are similar in scale and code size.
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/
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.
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.
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.
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.
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.
Regarding some of the ‘layered architecture’ discussion from the OP, I’d argue that having many modules that are clearly defined is not as large a detriment to cognitive load when an LLM is interpreting it. This is dependent on two factors, each module being clearly defined enough that you can be confident the problem lies within the interactions between modules/components and not within them AND sharing proper/sufficient context with an LLM so that it is focused on the interactions between components so that it doesn’t try to force fit a solution into one of them or miss the problem space entirely.
The latter is a constant nagging issue but the former is completely doable (types and unit testing helps) but flies in the face of the mo’ files, mo’ problems issue that creates higher cognitive loads for humans.
I 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.
Abstracting chunks of compound conditionals into easy-to-read variables is one of my favorite techniques. Underrated.
> isValid = val > someConstant
> isAllowed = condition2 || condition3
> isSecure = condition4 && !condition5
> if isValid && isAllowed && isSecure { //...
Same. Or alternatively I will just put the English version in comments above each portion of the conditional, makes it easy to read and understand.
I mean, at least western people seem to think in recipes, todo lists, or numbered instructions. Which is what procedural code is.
Dogma will chop up those blocks into sometimes a dozen functions, and that's in stuff like Java, functional is even worse for "function misdirection / short term memory overload".
I don't really mind the hundred line method if that thing is doing the real meat of the work. I find stepping through code to be helpful, and those types of methods/functions/code are easy to track. Lots of functions? You have to set breakpoints or step into the functions, and who knows if you are stepping into a library function or a code-relevant function.
Of course a thousand line method may be a bit much too, but the dogma for a long time was "more than ten lines? subdivide into more functions" which was always weird to me.
which has been specifically discussed extensively here:
https://news.ycombinator.com/item?id=27686818
https://news.ycombinator.com/item?id=37975558
https://news.ycombinator.com/item?id=31248641
including more peripherally:
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.
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.
The codebase at my job has far too many abstractions/layers for things that do not provide any benefit for being abstract. It was simply done because it was the "coderly" thing to do.
I do agree that at the least it makes sense to separate out repository logic.
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.
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.
The reason what you outline is bad is because they each impact these metrics. Bypassing layers creates a more tight coupling: you are basically putting code in the wrong place. This also makes the code less coherent. The two go hand in hand. And then you end up doing complex things like reaching deep into layers (which violates Demeter's law).
Anyway, MVC style architectures have always had the problem that they invite people to mix viewer and controller code and logic. And once your business logic mixes with your rendering logic, your code is well on its way of becoming yet another UI project that fell into the trap of tight coupling, low cohesiveness, and high complexity.
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.
For an article on cognitive load, using a gimmick which increases it seems ironic.
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;
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.)
Maybe that dev was "really smart" but then not very senior. Eventually the dev will hopefully use their smarts to make things so simple+dumb that the cognitive load when maintaining all that code is minimized.
One of the first things I try to drill into our junior devs. If the code looks smart, it needs to be fixed until it's really simple and straight forward. "That's impossible" some people might say. And that's why only the really smart folks can achieve it.
That's not very smart in BigCo, instead you want the complexity to leak into adjacent systems and make them depend on you.
I contributed some code to a FOSS project recently which is written in C. In my 10 lines of contributions, 3 were a complex conditional. I'd have loved to do what the article suggests, with some intermediate booleans. But that C version would have required me to define those variables at the beginning of the function, a hundred lines earlier instead of just right there. No way that's going to fly, so now they will need to live with a complex conditional. It's one of those "modern language" features which C fanatics generally frown upon but which makes code much easier to read.
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?
Why? Because if SuperuserController is built on AdminController via composition rather than inheritance, it is magically protected from breakage due to changes in AdminController?
Not buying it.
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 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.
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.
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 feel that in my bones.
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.
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.
Curious, what is your background and day to day work to be able to express your thinking in these terms?
My background is in humanities and health care. I currently work in eldercare. Computers have always been a major interest of mine since an early age, but professionally I have taken another route. I have a general curiosity about systems and theories of different kinds. I do have an education in the basic scientific approach, something I acquired while studying to become a teacher. Soft sciences have often come more easily to me. I lack the benefits of a disciplined study of computer science, something I am sure affects my approach.
My use of the word 'emergent' was inspired by systems theory [0], where the whole is more than the sum of its parts. The parts of a system create emergent behaviors or phenomena. For example: A football team consists of several players, and the strategy of the teamwork is an emergent phenomenon. Similarly, my thought was that when functions are spread out into several modules, they may create unexpected emergent complexity. Without being able to give a concrete example off the top of my head, I think I have struggled with bugs born out of such complexity.
My thought was that the "syntactical encapsulation" (the actual code we write) may not serve the "conceptual class" (the idea we have). We may have a good concept, but we distribute its functionality among too many classes.
1. Somebody writes a program
2. Somebody else (perhaps the same programmer a few years later) tries to read and understand that program
So the "cognitive load" is a property of the communication between the person who writes the program and others who read and (try to) understand the program.
Cognitive Load is an attribute of communication, not of the artifact that is the medium of the communication (i.r. the code written).
Are we writing in a "language" that readers will understand? And are we reading in a way that our assumptions about what the writer is trying communicate to us are correct?
A program is instructions to the computer. But when written in a highj-level language it is also meant to be read by other prrogrammers, not just by the CPU. It is human-to-human communication. How easy is it for somebody to understand what somebody else is trying to tell the computer to do? That's the "Cognitive Load".
And how overcomplicated the code is has a huge (overwhelming everything else) influence on how hard it is to communicate what it does.
So in practice it's fair to call it the property of the code, even if bad documentation or mentoring can make things unnecessarily worse.
Solar system can be modeled as geocentric with enough epicycles, or as heliocentric system with eliptical orbits.
One of these is inherently easier to communicate.
Now maybe there is, or can be an algorithm that takes a piece of code and spits out a number saying how easy it is for a typical programmer to understand it correctly. That would be the measure of "cognitive load".
If we just speak of cognitive load without really specifying how to measure it, we are not where we would like to be.
How do we define, and measure "cognitive load"? It is an easy word to use, but how to measure it?
I laid out my objections to the article last year when it first circulated: https://github.com/zakirullin/cognitive-load/issues/22
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.
FWIW, I can't speak to the science of it but as a programmer I even disagree with many of the conclusions in the article, such as that advanced language features are bad because they increase mental load.
We’re very proud of you and the hard work you did to earn your PhD, now please stop trotting it out.
And I don't think condescension will make this a productive discussion!
> 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.
So the main thing we typically come across is "too many classes" and that's the example the author gave in the article. Nearly all the developers at my company go with the "throw everything into one class" approach regardless whether they're junior, senior, etc. I tend to go the extreme opposite route and usually create small classes for things that seem critical to test in isolation. While I'm coding that small class and writing unit tests, it feels perfect. When I revisit the code, it is a bit overwhelming to understand the relationships between the classes. So I understand the article's criticisms of too many small classes.
However, I have my doubts that moving this class into the main class as a simple method would reduce cognitive load. For one, due to the nature of our tools, e.g. JUnit, I would be forced to make that method public in order to test it (and now it's part of the large class's API contract). So I can either make it public, remove my unit tests, or attempt to make my unit tests part of the greater component's test which really overcomplicates the parent's unit tests. Ignoring the testing issue, is the cognitive load complexity just a file structure issue? Instead of turning those small classes into methods in a large class, I could just use nested classes in a single file. Or, there can be some package naming convention so readers know "these helper classes are only used by this parent class". I would be interested to hear others thoughts, but due to the nature of HN's algorithm, it's likely too late too see many replies :)
I mean it sounds to me like testing is your only problem so it’s kind of hard to ignore.
Personally I prefer fewer larger modules over more smaller modules. Deeply nested code is harder for me to reason about, I need to push stuff on my stack more often as I go down a debugging rabbit hole.
You can never reason locally about a bug. All code looks good locally. Bugs always manifest in the totality of your code and that is where you have to look at it. Your unit tests for the release running on production are all green, someone reviews all those lines that are in there. Locally it all made sense, local reasoning is how you ended up with that bug in production.
If you write a small little class with one method to help implement some bigger interaction, if your change does not touch the unit tests of the ”wrapper” class actually implementing the bigger interaction, what the users use, you are likely writing meaningless code or your test coverage for the actual use cases is not good enough.
I don't think this is a good example. In fact, this is more supportive of having the small little class. The "wrapper" class only needs to have 1-2 unit tests to test the scenarios in which the small little class is invoked without needing to be concerned with the complexity of everything that small little class is actually doing. I've never written a small helper class without writing corresponding tests in the wrapper so I've never had that problem - it's usually the first thing I write after adding the helper class. For that specific problem there's automated and manual processes anyway. Code coverage tools can easily tell you if you missed writing tests for the invocation and reviewers should spot that in PR reviews.
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.
That is absolutely personal, but it pays off massively in the long term to build out a development environment that you're comfortable with. If it's an IDE, you can try to recognize pain points like nested tools you use often and bring them to the front via new buttons or learning hotkeys.
UI is also important, in "modern" IDEs there's an awful trend of not labeling icons, so I strictly use one that does so I don't have to juggle "which doodle is the Git button again" on top of my actual work cognitive load.
Ie. if I write 1452687 and ask you to read it only once and then close eyes and repeat it, you will be able to do this.
If I write 12573945 and ask you to do the same, you will most likely not be able to do it.
Same happens with bigger "bits" of information: you can remember 7 ideas/statements from the textbook you read, so do not trap yourself in "ah that is easy, I will continue reading without taking notes". After every 7 "things" write them down, note the page number where they are and then continue reading. Otherwise it is a waste of time.
[1] https://en.wikipedia.org/wiki/The_Magical_Number_Seven%2C_Pl...
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.
I really have some concerns about this kind of opinion.
I know that we can't follow this rule (or smell) every time, but I already see this affirmation being used by very poor or inexperienced programmers to justify understandable gigantic and hard to test pieces of code.
This is the type of advice that just experienced programmers can understand what this means and know when is applied.
Most part of the time, it's easier to fix a cod
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
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.
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?
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?
I don't think about states much, it's all just muscle memory. Like doing a hadouken in street fighter.
like I doubt I am blazing on windows 11 and VScode either.
I guess there’s a lot of wiggle room for what is really being asserted here, but this seems like an absurd impossible claim.
https://danielbmarkham.com/for-the-love-of-all-thats-holy-us...
Understanding CCL is both critically important and will sink you into deep professional despair once you realize that the coding community has deep-set standards and social mores that prevent serious adoption.
Rules like "limit the number of lines to 300" are meant to be broken. Some 2k-line code files are easy to understand, some are not. It depends. It always depends.
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
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.
> 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
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.
I need somewhere for my objects to live. Objects are not relations.
Scattering their data across five tables, to fit the relational model, so I can later JOIN them together again, has to be the biggest example of cargo culting in the whole field.
> 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?
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"
In most cases having a simpler language forces additional complexity into a program which does noticable add to cognitive load.
I hope it improves whence I write an implementation myself.
Read "Code is For Humans" for more on the subject https://www.amazon.com/dp/B0CN6PQ42B
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.
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.
https://www.joelonsoftware.com/2005/05/11/making-wrong-code-...
By writing code in a certain way, at a glance you could tell if it's correct or wrong. Most people suggesting making types handle this but that's a level of abstraction. If I have
v = a + b
If I don't know what a and b are but have to go check their types then I don't know if that code is correct. You could argue if you have good types then of course it's correct but that still misses the point that I don't know what a and b are.Names help
html = fieldName + fieldValue
But that's not enough. are fieldName and fieldValue safe? No way to tell here. You could make a SafeString class and that would be good but you're still adding the load that you have to look up the definitions of these variables to know what's going on. The info you need is not here at this line.Then again, I've never been able to get myself to follow the advice of that article.
Encapsulated and managed state is just fine.
For every example of encapsulated state, there's probably 9 more examples of global state which is called encapsulated.
And what is managed state? I can think of two examples that can effectively manage it - software transactional memory, and a good rdbms with the isolation level turned way up.
Yeah, this is big brain time.
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.
Cognitive load as the general HN viewer knows it doesn't exist. (Or at least, if the concept solely consists of "thinking hard! thinking many things more hard!", it's not worthy of a phrase)
Cognitive load, and cognitive load theory, do exist as concepts, outside of computer science. Yet none of it is reflected in HN posts. Somebody just heard the phrase once, thought it sounded cool, didn't learn about, and started blogging, making up what they thought it meant. Without the actual body of knowledge and research, its use in computer science is just buzzword fuckery, its understanding by the casual HN reader clear as mud. Without nuance, research and evidence, it's nonsense masquerading as wisdom.
If all you know is parroted anecdotes and personal experience, you're not doing science, you're doing arts & crafts.
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.
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.
AdminController extends UserController extends GuestController extends BaseController
That's nothing... Java enterprise programmer enters the chatThis post is like an examplified grugbrain.dev
In an ideal setting, when solving a problem, we first produce a language for the domain of discourse with a closure propery, i.e., operations of the language defined over terms of the language produce terms in the same language. Abstraction is effectively the process of implementing terms in the language of this domain of discourse using some existing implementation language. When our language does not align with the language of discourse for a problem, this adds cognitive load, because instead of remaining focused on the terms of the language of discourse, we are now in the business of tracking book keeping information in our heads. That book keeping is supposed to be handled by the abstraction in a consistent manner to simulate the language of discourse.
So you end up with a hodgepodge of mixed metaphors and concepts and bits and pieces of languages that are out of context.
Of course, in practice, the language of discourse is often an evolving or developing thing, and laziness and time constraints cause people to do what is momentarily expedient versus correct. Furthermore, machine limitations mean that what might be natural to express in a language of discourse may not be terribly efficient to simulate on a physical machine (without some kind of optimization, at least). So you get half measures or grammatically strange expressions that require knowledge of the implementation constraints to understand.
- use principles as guidelines not gospel
- optimize for readability/maintainability
- and for the love of all that's holy, don't build stuff you don't need yet.
BTW, any other old-timers remember when we just wrote code without worrying if it was SOLID enough? Those were the days...
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.
filter(odd, numbers)
vs. (n for n in numbers if odd(n))
It depends on the reader too.- 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...
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.
filter is a builtin name in Python. There is no confusion here in practice. Static checker such as ruff will tell you if you attempt it accidentally. It is the first rule: A001 builtin-variable-shadowing.
If you are a noob the second variant may be easier to grasp. The first variant has less moving parts.
I remember spending egregiously long time to find a bug that was essentially a typo in some constant. Expressiveness of the language, how many chunks you have to keep in the working memory matters. The chunks can be low/high level depending on what you are trying to do at the moment but you can't escape looking at the low level details at some point.