Even in the best case, readability just becomes a Pareto frontier[0], given by expressive limits of the dominant programming paradigm - same single plaintext source code for all. There's only so much complexity, so much cross-cutting concerns, we can cram into the same piece of plaintext, until something gives, until the same code is beatiful to you one week, and incomprehensible the next week, with the only thing that changed is the type of work you're doing on it.
So, beyond evolving over time, I'd also consider the orthogonal aspect of different decompositions being good for different purposes, and that you can't have it all and work on the same, single, high-level plaintext code.
EDIT: And I believe the solution to this, the step forward beyond the Pareto frontier, is what 'valty described here: https://news.ycombinator.com/item?id=39426895 - not coding directly in the same plaintext, but treating the single-source-of-truth code as a database, which you query and update through views/lenses that best fit whatever work you're doing at the moment.
--
> Even in the best case, readability just becomes a Pareto frontier[0], given by expressive limits of the dominant programming paradigm
> People forget that readability isn't a function of specific program - there is no one optimal readability. On the contrary, it's a function of the program and the goals of the reader.
The original Wiki at c2 has a great example [0] comparing the expressive capabilities of functional vs. object-oriented programming, and their suitability towards certain goals - namely, either extending the set of operations the system supports, or expanding the number of data (sub-)types the system models.
In spite of Turing equivalence, some paradigms (even if only in terms of readability, dev ergonomics etc.) are better suited for expressing certain classes of problems than others, which may introduce unnecessary friction trying to solve problems that are mismatched with the paradigm's approach to structuring and decomposing problems (e.g. into compositions of functions, or compositions of objects).
One could start from a real case in a real company, document it thorougly, including different stakeholders. Then rewrite the code to fit this organisation at this point in time.
Then, make a two or three fictional changes to the organisation and circumstances, snapshot these (or follow an organisation longitudinally for long enough that this occurs naturally) and for each snapshot, rewrite and redocument the code to fit those circumstance.
From that, one could "dumb down" the whole thing until it stops making sense and see how simple one could make it.
Probably there are people smart and seasoned enough to write an entirely fictional account of all of this, and still have it make sense - that's not me though.
public class HelloWorld {
public static void main(String[] args) {
// Prints "Hello, World" in the terminal window.
System.out.println("Hello, World");
}
}
This is the full code. This is what I care about on success path: System.out.println("Hello, World");
// Success
Or maybe even just: println("Hello, World");
Or maybe, because I forgot what is what in Java: java.lang.System - The System class contains several useful
class fields and methods. It cannot be instantiated.
|
| System.out : java.io.PrintStream - The "standard" output stream.
| |
| | PrintStream::println(String) -> void - Prints a String and then terminate the line.
v v v
System.out.println("Hello, World");
This is what I care when I'm looking at the module view: HelloWorld (public, class)
main(String[] args) -> void (public, static, entry point)
-- Prints "Hello, World" in the terminal window.
This is what I care when I'm looking at error propagation view: HelloWorld::main() - entry point
Program end, return code 0 [default]
Or maybe, given sufficient metadata in the standard library: HelloWorld::main() - entry point
System.out.println("Hello, World")
-> failed if System.out.checkError() == true
Program end, return code 0 [default]
Or I want a dependency view: HelloWorld
java.lang.System
[java.io.PrintStream]
Etc.Now imagine being able to open the Hello World code, and instantly switch between those views and many others with a single shortcut. Not tooltips above the code, but views that replace code. That is what I'm talking about.
Oh, and views are editable where it makes sense. Even read-only views would improve the development experience a lot, but the magic is in mutable views, so that you don't ever have to touch the full, original form of the source.
EDIT: look at the first vs. third code-block in this comment. People built whole programming languages on the premise that Hello World should look like the third block instead of the first one. That's an extreme case of making a style choice ahead of time, and also a source of endless, pointless debates.
That is, if typing in one of the restricted views, is it possible to create a bug because of something that's currently hidden from you? I feel as soon as that happens to someone, they are going to switch to the "show everything" view and never switch back.
> look at the first vs. third code-block in this comment. People built whole programming languages on the premise that Hello World should look like the third block instead of the first one.
Well that was silly of them. Microsoft managed to retrofit it: https://learn.microsoft.com/en-us/dotnet/csharp/tutorials/to...
Isn't this always the case?
I don't think there is a "full view" like you describe. Even if you're writing raw assembly, you're still working on top of the abstractions provided by the instruction set. There is no such thing as a full view where you can see every detail of everything that is going to happen.
I think the solution to this problem is tests and doing your due diligence in understanding what you're doing.
Having more control over what abstractions you're seeing should help with the understanding part, which should in turn reduce bugs resulting from lack of understanding.
Edit: related thought: it's about improving the tooling. Which I suppose is another thing for someone to learn and possibly misuse. But I don't think it's correct to say that firefighters shouldn't carry axes because they could accidentally kill someone with them.
Part 2: https://m.youtube.com/watch?v=ZZDwB4-DPXE
You can project your programs into different views and add lots of metadata about the program and use that data in various contexts. Also extends to source control and other use cases. It looked really neat.
I'm not sure what happened to it.
There is no single answer there, because which style is better depends on what you're doing. For example, "lots of small functions" makes things easier to understand when you're working horizontally, trying to e.g. understand a module at a certain level of abstraction. However, in the same code, if you're trying to understand a single piece of functionality, e.g. to debug it, it's much easier when you have a vertical view - ideally a single fat function with all helper calls inlined. In first case, all the little functions form high-level languages that aid your understanding; in the latter, they're just noise that kills your working memory with all the jumping-to-definition around the codebase.
Our current programming paradigm forces you to make this style choice ahead of time. You can't have it both ways. This is the Pareto frontier - as you make your codebase easy for one type of work, it becomes hard to do other kinds of work in it. And this is a stupid state to be in, because you will be doing both horizontal and vertical tasks, and many others that benefit in yet different kinds of slicing through code, and you will be switching gears every few days or weeks.
Another concrete example: exceptions vs. algebraic return types (Expect/Result/Maybe/etc.). People love the latter for code locality, and mostly ignore the ridiculous amount of noise this method adds to all code, and/or invent ever more advanced math to paper over it. Exceptions were much better in this regard, if worse in others, but again, I posit that having to make that choice is dumb in the first place. Personally, I'm fine with Result return types. It's just that, 90% of the time, I don't give a damn about them, because I'm working on the success case/golden path, and they're just pure visual noise. It's something I'd like to just not see. But then, remaining 10% of the time, I'd like to make everything other than Result types and control flow disappear, because when working with error handling, success case becomes visual noise.
Inventing new species of monads or syntax keywords to cram this, and async, and other cross-cutting concerns, isn't the solution. The solution is to stop working on raw plaintext source code, and instead work on representations tailored to specific needs, while treating the underyling source code like we treat assembly today.
There's actually at least one programming language that allows you to choose as needed, and it's Visual Basic of all things.
The "On Error Goto" directive says on error you want to jump to an exception handler. "On Error Resume Next" means just keep forging ahead -- which is a terrible idea done badly, but if check the Err object, you can access the status.
It's not quite as good as algebraic return types, but it was an interesting idea in that you can code each bit of code in whichever way works out best.
How do I explain what my prefered "level of abstraction" is and why it is superior to all the others? How can I be convincing with a fuzzy and subjective term "visual noise"? Etc.
That's my point: you shouldn't, because there isn't. Once you hit the Pareto front, there's no superior choice. There are just choices, each of which is better in different situations, and once taken, very expensive to back out of. The problem is being forced to make the choice in advance.
> How can I be convincing with a fuzzy and subjective term "visual noise"?
That's another part of the problem. Different choices may be better for different people. There's no one-size-fits-all here. Which is why, again, it's dumb that we have to make this choice once and for everyone (part of what I mean by "working on single-source-of-truth code").
The way I see it, to stop running in circles on the Pareto front, to move past to more powerful ways of dealing with complexity, we need to make things like "lots of small functions vs. few fat ones" or "exceptions vs. expected" to be subjective, personal, local preferences, not meaningfully different than your editor's color scheme, or syntax highlighting scheme; inlining needs to be as easy as code folding, etc.
I.e. you're talking strategy and architecture, I'm talking not even tactics, but individual minute-to-minute execution.
This is precisely the problem.
The way it should look like is, these trade-offs should be purely local, minute-to-minute editor preferences. Inlining some helpers or removing error types from the code you see, should be no harder than folding a code block or pinning a peeked function definition. Nor should those "changes" have any effect visible to anyone else. You should not take into account any "future readers", because there won't be any - but rather, you should pick a representation you need right this second, and switch to a different one the moment you need that, etc.
But for the major issues like split between "lots of small functions" vs "one big function" even if the IDE could e.g. inline the functions for readability, but that feels a bit risky as it makes it difficult to talk about the code or document a functions' interface if another person is seeing different semantic structure of the code, it's as if they had different names for the same things.
(Of course there is a best way to do it that will optimize the codebase for a function of productivity, reliability and pleasure. The likelihood and the cost of changes are part of that function. Finding and advocating for it is the task of the most experienced, wisest team member. Less experienced team members may (or may not) understand the wisdom of the better decision in time. But this is just my struggle with postmodernism and relativism and besides the point)
Better to KISS and leave abstraction for the future when it actually becomes necessary. If you start out with code that is only as complex as it needs to be in that moment, then it will generally be much easier to change it in the future.
I love this statement and for me personally is also perfectly encompassed in YAGNI. You ain't gonna need it.
Until it's proven you need that piece of code, don't write it.
Wasn't familiar with Parnas' criteria, thanks for sharing.
I do something similar in a different way, which I call "IKEA-oriented development". IME, semi-disposable code is very easy to change over time as mental models and product goals evolve:
Re: "IKEA-oriented development", you make a very good point about the cost of change. I think the semi-disposable code idea overlaps comments from folks elsewhere in this discussion thread talking about the horror of codebases that introduced premature abstractions to cope with expected future changes that then never actually appeared ("YAGNI" is indeed a good rule of thumb).
Your point about "make experimentation effortless" is a good one. The highest productivity environment I worked with that supported rapid experimentation was a small business' monorepo codebase with good test coverage and rapid feedback from CI, where the library code was only used internally by the company's software products (i.e. all the abstractions were implementation details, not part of any external interface). Over time we'd learn that some of our early ideas for abstractions in the internal libraries were flawed, but because these abstractions were internal, and we had confidence in the automated test coverage, it was possible to make quite large scale improvements to abstractions rapidly with confidence as we learned more.
The kind of environment that really bogs down experimentation and impedes change and improvements to abstractions is where an initial idea for an abstraction is resourced with its own development team and turned into a production service, and then another half a dozen internal company services start depending on it. Then it's very easy to end up in situations where everyone becomes aware that the abstraction is flawed, but improving it is less "one developer goes dark for a week or two and emerges with a 50-patch PR that atomically replaces the flawed v1 abstraction with v2 while passing all test suites in all projects that depend upon it" and more "project managers, product owners and enterprise architects compare roadmaps for the next few quarters to figure out how many years it might be until a prototype of the v2 abstraction can be ready for manual testing in the integrated test environment".
Maybe in the worst case there's some initial decomposition of the system that is flawed, then an org chart is spun up defining teams that own components matching the flawed system decomposition, so refactoring to improve the decomposition would also require refactoring the org chart to change people's teams. Then instead of having colleagues indifferent to or supporting a purely technical refactor, people will resist it to avoid change to their roles!
But there might be optimal solutions rooted in information theory and Basyesian probabilities that you can strive to approach while programming. This is about avoiding over-fitting or under-fitting your domain knowledge.
Theoretically speaking, finding the right Bayesian fit optimizes for future evolution of the code and how it generalizes into the unknown, how correct your software will be when faced with things you haven't specifically designed for. More here: https://benoitessiambre.com/abstract.html
If I were to add something to abstract.html blog post, it would be something about Dependency Length Minimization ( https://www.pnas.org/doi/full/10.1073/pnas.1502134112#:~:tex... ) which has important information theoretic ramifications (for example, files with shortened dependencies tend to compress better and LLMs became much better when they solved for managing dependencies with their "attention" mechanism). When an abstraction breaks out a piece of code to enable reuse, the reduction in redundancy should be weighted against the stretching of dependencies to decide whether the abstraction is warranted.
The original article acknowledges this by mentioning "locality".
Other things to take into account is how tests fit into all this. Again more here: https://benoitessiambre.com/abstract.html
Context:
When programming we attempt to design an effective abstraction that models some domain. When designing this abstraction, there are trade-offs between reducing the amount of code required, enabling reuse, reducing coupling, flexibility to accommodate future use cases.
Key Problem:
How do we design an abstraction for our domain model?
Claim 1:
Apply the "Minimum Description Length" (MDL) model selection principle: prefer a domain model embedded in the shortest program able to recreate the dataset of domain knowledge.
Applying MDL model selection will result in an abstraction for the domain model that is both smaller -- giving less code to maintain -- and more likely to generalize to future unknown use cases.
Complication:
Applying the MDL model selection principle relies on having access to a dataset of domain knowledge. We can think of this dataset of domain knowledge as a list of (situation, expected behaviour) pairs -- c.f. a labelled supervised learning dataset, or a gigantic list of requirements. Unfortunately, in typical software projects, no such explicit dataset cataloguing the requirements or expected behaviour in each situation exists.
Claim 2:
We can use the automated test suite as a proxy for the dataset of domain knowledge. When designing our abstraction we should prefer a domain model where the combined size of the logic for the domain model and the size of the corresponding test suite* is minimal.
* with the important caveat that "just cutting out the tests, or removing other safeties like strict types doesn't give you a lower MDL, in that case, you're missing the descriptions of important parts of your data or knowledge".
In my mind this is also related to concepts like scientific notation. Minimum description is not just about minimizing the number of operations, the number of branches, the degree of freedoms of your program. Taking into account variable width also makes a difference. Using a timestamp YYMMDDHHMMSS when you only need YYMMDD creates unintended edge cases the same way using too many digits in scientific notation creates noise and both can result in poor calibration with the uncertainty of the model.
One way to define a test suite that would be very easy for readers to understand could be to rig a test suite of "table-driven tests" for some toy problem.
I'm not sure what a good toy problem would be that would be small enough so it's relatively easy for readers to understand, but large enough to show off MDL being applicable to a more general kind of programming problem and not merely MDL being applied to a statistical modelling exercise (one person's decision tree fit via supervised learning is another person's handwritten function containing a morass of hand written if-else chains -- but we already expect MDL to give reasonable results when applied to decision trees, it's less obvious it gives good results when applied to more general programming problems)
c.f. https://dave.cheney.net/2019/05/07/prefer-table-driven-tests
I'm fairly sure I remember reading somewhere that that piece of advice was originally meant for data/values/configuration, not code, and that applying it to code is itself a mistake that keeps getting repeated.
Like almost anything it can be taken too far or misapplied.
In the quoted example, when I have multiple occurrences of related business logic, I build a vocabulary of reusable sub elements - find the joints and carve, don’t build a giant mutant.
I'd argue the baseline reasonable approach to structuring applications or systems is the one given in Parnas' 1972 paper "On the Criteria To Be Used in Decomposing Systems into Modules":
http://sunnyday.mit.edu/16.355/parnas-criteria.htmlParnas' criterion embeds the understanding that code and systems are not static but need to evolve over time as requirements change or decisions are made, and that different decompositions can be inferior or superior to accommodating that change.
"Don't repeat yourself" refactoring rules of thumb can give poor results if blindly applied. Suppose two sections of application logic just so happen to look similar at this moment in time and get refactored to "remove the duplication" coupling them together, when the two sections of code are subject to different constraints and reasons for change, and will need to evolve separately.