What is simplicity in programming and why does it matter?
blog.jakubholy.net
blog.jakubholy.net
https://www.merriam-webster.com/thesaurus/complicated
https://www.merriam-webster.com/thesaurus/complex
... And they literally have the same first definition! Your point is not very clear to me. Can you please help me understand better what you meant here?
In Clojure-land, we always like to distinguish between "accidental" and "essential" complexity as per https://github.com/papers-we-love/papers-we-love/blob/master...
"Accidental" complexity is "your fault" because "you" introduced it into the problem. For example, you need to use some mutable variables and that is making it harder to reason about some code. It would be "simpler" to use immutable values which are closer to pure mathematics and simpler, but you need maximum performance and most hardware is better optimized for mutable state in the extreme case.
"Essential" complexity is part of the problem and can't be reduced away as per current knowledge. For example, you need to know a fair bit of mathematics to be able to understand general relativity.
On the other hand, 'complicated' here is probably meant in the sense that you have a solution for your problem, but that might not be the simplest solution to the problem. In other words, you have satisfied all of the problem's needs but the solution itself is going to be hard to understand for anyone other than the person implementing it. You generally want to avoid such situations.
If you took another look at that TERRIBLE resource, convoluted and byzantine (and probably more) have the exact same definition. Complicated and convoluted do not mean the same thing.
Complex: Consisting of or comprehending various parts united or connected together; formed by combination of different elements; composite, compound. Said of things, ideas, etc.
Complicated: Consisting of an intimate combination of parts or elements not easy to unravel or separate; involved, intricate, confused.
I was able to access it via my public library login. Very neat.
At least that's how I read OP.
Complex - a plain adjective. It's just how the thing is.
Complicated - past tense of the verb "to complicate," used as an adjective. Somebody made this complicated.
But yeah, I still prefer thinking of inherent and accidental complexity. Expresses the actual idea directly.
A DSL is simple to use, but complexity has moved to the compiler
An assembly is complex to use, but the compiler is simple
--
Part of the answer is in expressivity -- in breaking down problems into simpler, combinable parts. Like Unix pipes or lego
But as expressivity increases, the number of combinations increases and hence complexity itself increases
At one limit atoms are the simplest unit, and programmers can combine them to create any known physical object
At the other limit, we have a one key keyboard that can only do one job and be programmed no other way
Both extremes are extremely simple in their own way, and show us that neither language expressivity, nor even language simplicity is a true savior
An emergency shutdown system wants a language comprised of a single big red button
My lunch wants a language of nutrients and flavors for the VM of my tounge and stomach to trigger the happy(); syscall to my brain
-- The best language, and even simplicty itself, must be viewed from both your current position and end goal
But as expressivity increases, the number of combinations increases and hence complexity itself increases.
This is first hard lesson learned for those that approach the challenge of achieving simplicity. The first idea one has is ‘I’ll break everything into the smallest little parts’, and suddenly you have a thousand little parts.
Sony products were considered extremely fragile because they were constructed out of tons of small parts. If you dropped it once, every little piece would fall off. Compare that to the Mac unibody.
Is there any books that touch in this subject directly?
Instead, if you pull up someone’s GitHub and see wonderfully over abstracted code, boom, there’s your guy who is not in the middle of the normal distribution. In other words, they don’t write common code (which means they don’t write clearly, or with common sense, and certainly do not write simply).
Soon as you introduce someone like that into a code base, suddenly things that used to be simple are physically painful to touch. Jumping through files, following the wire to see where it leads, layers of abstraction, and so on. That is a codebase terrorist and one interviews for it. The fact that the entire team must now take on the mental model and enter the headspace of such a person is exhausting.
Hire commoners, and test for obtuse mental models (something that is easily missed in an isolated coding challenge and no way visible in the behavioral portion. But alas, this will fuck with your whole team more than anything else).
What is "over abstracted code"? Perhaps we agree on this point, but I don't know because when people talk about these things no one has any idea of what they mean - we use "over-testing", "under-testing", "over-engineered", "sloppy", "too much abstraction", "simplicity", "complexity", and everyone has different takes on what these words mean but everyone agrees they're bad or good. It's a bit non-sensical.
What is the problem of jumping through files? Do you feel the same about jumping through functions? Is it better to have a soup that you can only test by setting up a new solar system? Is that why testing is considered "expensive"? And what is the solution when products need to be evolved, redesigned or pivoted? Re-write? Re-hire a team? Do the same crap again but with new tools? No tests? Or you simply don't like jumping?
Is it worth it to have bad api's, bad products, that offset millions of hours in work around to all developers (and non-developers as well) who have to use them because someone couldn't be bothered? Shouldn't the premium on salaries for developers conjure some real interest in doing things right? Is it normal that governmental institutions, public companies, banks, etc have awful interfaces, buggy behaviour, etc?
Isn't it normal that if you're commanding a very comfortable salary there should be an expectation of continuous improvement, research and learning for the work you choose to do?
Perhaps I agree with what you're saying, or perhaps I completely oppose it, but I can't tell.
Write a 1 liner? Too terse and hard to understand.
Use multiple lines? Too verbose.
I don’t want to give the people who are going to be deciding my future a target to pick on. It only takes one guy to feel insecure about hiring me to say something like “he over abstracts his code”.
The only things I share are the finished products, it keeps the nitpickers quiet.
What I’m advocating is to probe how people structure the problem. There is a noticeable percentage of people that manage to have technical competence but map problems in a complicated way.
For the part about "having a few data structure", doesn't that means that you lose all type safety? If you have an Order type and a PaidOrder type, the compiler can enforce that you won't use an Order that hasn't been paid in places you shouldn't.
This part is not about this article specifically but about the way people talk about Clojure in general. Most of the time Clojure users compare Clojure to Java, which in a way is fair since Clojure leverages the JVM, but on the other hand Java isn't known for simplicity (part of it may be due to design, part of it is due to being older). Some tradeoffs are presented as strict wins (see above the part about type safety vs a few collections). Most people mention being skeptic at first with Clojure, and now being in love with it. Shouldn't that mean that they should keep looking for new things since they were trapped in some kind of local maximum before?
First programmers make Order and PaidOrder classes to enforce static type checks, then they tell you that the system absolutely cannot support credit card chargebacks because PaidOrder becoming unpaid would ruin everything in the architecture, and you should teach the clients to never charge back.
(def order
{:amount 5.45
:currency "dollar"
:paid true})> State is never simple because it complects - tangles - time and value.
> It took me years to realize that in truth Clojure is a philosophy. The language embodies it, the ecosystem embraces it and grows from it, you the developer eventually soak it up.
The ardent way in which some programmers promote their languages to the detriment of others is indeed like other fanaticisms. I have always thought that software development is the art of choosing the right tool for the task, not making all tasks fit my favourite tool.
The author claims “Simplicity is objective because - if we can distinguish them - we can count how many simpler elements are there, tangled together.” This admits that objectivity depends on distinguishing what the elements are, which is often subjective. So this claim can be seen as false in the sense that you can’t make something objective out of subjective parts, or true in the sense that it’s trying to punt by treating the whole as a separate thing from the elements that comprise it.
One good example of subjective elements is the discussion on state - “State is never simple because it complects - tangles - time and value.” I agree with this, but it’s important to note that state does not encode time explicitly. Time and state are byproducts of assignment. We can remove the notion of time (assignment) from some bit of code and make the code functional. But we can’t move the notion of time to an element of it’s own. And if we could, and then compose it with an element of value, we’d end up right back where we started with some state.
Generic discussions on state can be hard to parse too since there doesn’t exist a computer program without state. We move it around, organize it in various ways. We hide it with interpreters and compilers. But it’s always there. At a high level, there’s no way to detangle time and value when users and user input are involved. A feedback-response cycle often is state at a fundamental level (unless your program is idempotent, such as limited to database lookups). You can’t create anything in a stateless system.
The “What’s in your Toolkit” chart is pretty bad as a supporting argument for what “simple” means, IMO. Methods vs namespaces functions does not have a meaningful difference in this context; namespacing is an organizational tool, it doesn’t simplify things or protect you from complexity. Data vs Syntax is not a choice in any programming language that exists, all of them have both. Actors vs Queues doesn’t make sense to me, I don’t understand what they were trying to say, and suggesting queues are simpler than actors seems backwards from a game programming perspective. Consistency and Inconsistency are things you can have in any programming language at any level of complexity.
Now we can go down the physics rabbit hole and start arguing that everything is tangled at some level but I don't think that's useful for a discussion at this level of analysis.
I think we can stick to the definition that if function A does not call function B, they are completely separate for the purpose of this discussion.
> But we can’t move the notion of time to an element of it’s own. And if we could, and then compose it with an element of value, we’d end up right back where we started with some state.
I see this differently. If you compose a timestamp with a value, it is still a value. This is the essence of temporal data modelling and accounting.
> You can’t create anything in a stateless system.
Agreed. The idea is however to avoid state whenever possible, pushing it to the edges.
> Methods vs namespaces functions does not have a meaningful difference in this context
Depends on how you use them. Classes and methods can be used as quasi namespaces. The nice thing about namespaces is that they provide you with that one, "simple" thing, while classes and methods are overloaded with semantics and utility.
> Data vs Syntax is not a choice in any programming language that exists, all of them have both.
The question is the degree. There are languages with minimal syntax that express more via data and there are languages with differing degrees of homoiconicity.
I agree with the rest of your critique generally, as in that the context and the assumptions around a given model are typically subjective, we objectively deduce the parts from these assumptions.
Say for example from my point of view a given tool might be simple, because I only use it to do that one thing, but you have to use it for that other (maybe intended) purpose as well, so you perceive it as complected.
I agree completely that composing a timestamp with a value is still a value. I think the concept of assignment (values changing over time) is not the same thing as a timestamp at all; a timestamp doesn't represent state in the same way that mutability does.
> The idea is however to avoid state whenever possible, pushing it to the edges.
Yeah, this is where I think the problem lies. Saying "avoid state" isn't particularly helpful, especially if there is a certain amount of state that is essential and necessary. What should I do with the state that is required? What does it mean to push state to the edges, and in what cases does pushing state to the edges simplify my code? What if pushing the state causes complications by having to introduce more complex communication or more complex ordering between the elements of a system?
I feel like one of the implicit suggestions when someone says to avoid state is that there is unnecessary state hanging around. But in practice, while there is sometimes redundant state, most programmers I've worked with are pretty conscientious about duplicating data. Most of the imperative production code I've worked with doesn't have a lot of redundant or duplicated state, it is already designed to minimize the amount of state.
I love writing functional code when I can, it does feel simpler and easy to reason about. The two reasons I can't always write functional code are: performance and necessary state. Performance for functional constructs in various languages is improving, but it's still extremely common for functional designs to be very slow compared to equivalent imperative stateful designs. (And I do a lot of high performance computing as part of my current job.) And we need a better philosophy than just "avoid it" when it comes to all the necessary, unavoidable state.
I think this is driving at the crux of the matter. We've been building our applications on top of our databases. We do stuff, we call APIs, those APIs access databases, they return. The database holds the state. Files hold state. Outside dependencies hold state. State, within a normally designed OOP-based program, is at the inner-most level. Within your logic you query the state, and you thus depend on this state and need to inject those dependencies. When you need to unit test, you need to mock out these dependencies to return fake values.
The Hexagonal Architecture still has state, but that state lies exclusively in the outside layer. When you change the state you are changing it at the outer level. State changes then trigger calls to your domain, they trigger changes to your services. But all of your domains, all of your services are stateless. Given this input I translate this data structure to this output data structure. That does not change state, it merely produces a result. That result makes its way back up the stack until it reaches the outer-most layer. The outer-most layer can then decide to perform the next state change.
None of your inner code has any dependency injection, because you refuse dependencies. The core of your application is now pure. They are now inherently unit testable. Does this input provide this output. You don't need to worry about any dependencies, since none of your logic relies on anything but itself. Mocks become obsolete.
Both of these work really well for certain kinds of data, and both of them also fell apart for me when things got complicated. I haven't thought hard enough about the problems I ran into to be able to categorize them in any concrete or helpful way. But, I do think that the breakdown is related to having to communicate state between components and across the system. Sometimes state changes have a necessary dependency on ordering things which can get tricky if one side is trying hard to avoid state. Sometimes wiring up state changes to the components that need it just adds communication complexity.
The other thing that seems to happen when you put state on the outside is that your data schema evolves toward your code structure. In some ways, you end up duplicating the hierarchy. This has caused me trouble when refactoring. Once you realize some sizeable portion of code needs to be rewritten, but you can't change the data schema to match the new code because the data is production live, all the cleanness of the design starts to break down.
For example, a single atom of carbon is, objectively, simpler than a complex organic molecule which contains many more different atoms.
This is a probably very broken definition, but maybe: simplicity is the most direct route to our goal, from where we're standing
Maybe?
I feel we can say imperative/functional languages are almost identical to each other when compared against the design space that assembly allows -- computed goto, jumpfrom, jumpwhen, so many options, and yet 99% of everything is if/then/switch
Does this mean programming languages are evolving into a meta description of some underlying 'thought-process' innate to God, or the-thing-that-causes-physics?
I would say easier is the correct word here: Kindly dont hand me single atoms, and tell me you're making my job easier"
To clarify, that my limited experience with those type of system. Same for financial transactions.
But I also often assumed that serious people would use more narrow tools for that task. Or at least something more functional.
Are Java / c# the norm in those space ?
Presentation - https://www.infoq.com/presentations/DDD-Clojure/
Code - https://github.com/zololabs/zolodeck
Its a Domain-Driven/Hexagonal Architecture codebase written in Clojure. Its not uncommon to see this sort of approach in Java/C# so it makes comparisons of the code relatively easy.
While the overall code layout is the same as what you'd see in Java/C#, a big distinction is that the "entities" that make up the core functionality of the domain in the Clojure version are simple data structures (rather than classes), and the logic that operates on them are pure functions (rather than methods on the classes themselves).
With this approach, a lot of the hoops you'd usually jump through with dependency injection to enable testability go away, since its easy to test pure functions that operate on simple data structures, no mocking required.
This sort of approach isn't limited to Clojure [0], but Clojure makes it idiomatic.
But it’s plagued by footguns on one side and overengineered „OO“ frameworks on the other. These lead to complexity and suffering. The language requires too much discipline and „special“ knowledge for my taste.
If I asked the best developers in the world to write down all of their wisdom with the goal that they would only be allowed to criticize code they review if a computer could show that one of their rules was violated. And the take the results to an alien world. I assert we would find the vast majority of the rules not helpful. Or if we went to the past and changed the course of history such that all modern programming languages are (at least a little) different. Or if we even took the rules to a different sub industry (videogames to aerospace or vice versa).
Our ideas of quality, simplicity, and everything else all have an unbelievable high dependency on context (at least when they aren't coming from ego, arrogance, ignorance, preference, or anecdote). Even something as small as adding a new library/framework, getting a new team member, or changing a feature or product goal can be enough to invalidate or radically change what guidance we give for success.
We have best practices but that phrase is used to justify mutually exclusive and sometimes downright opposite courses of action. When we see something we dont like we call it a code smell. Smell. Like the most primal and instinct driven sense. Not "hey we have some data on why this is bad" but "this code makes my tummy feel bad".
So while I'm glad that people are still meditating on how to make the code simpler. It seems to come down to a religious exercise or a spiritual awakening. The individual comes back enlightened, energized, and quoting poetry. But whether or not anyone listens to them is dependent on how good of a story they can tell.
And whether or not I can participate depends on whether or not they can actually tell me anything real or true. "But what does it really mean," I ask. They blink as if confused. "But I just told you, yellow is the most simple of the colors."
I want you to tell me why my code is bad. But you have to tell me why before I show it to you. A mathematician can tell me why my calculus might be bad before seeing it. A scientist can tell me why my experiment is bad before seeing it. An engineer can tell me why my circuit is bad before seeing it. Even a writer can make some pretty good guesses as to why my story is bad before seeing it. Nobody has ever been able to tell me why the code was bad ahead of time in any way that wasn't hopelessly invalid by the end of the week.
returning a [data, error] is simpler than a Try...catch.
Simplicity means it's easier to test and debug it, it's not about easier to read or to write, or to compose.
Returning a [data, error] could necessitate passing that error back an arbitrary distance in the call stack until it's handled. This necessarily complicates every procedure between where the error is handled and where it is generated. try/catch bypasses that complication of the code, but complicates the static tracing ability of the system to identify the actual control flow.
Neither approach is strictly simpler as both approaches have distinct complications they add to the system. Your opinion is that simplicity is around debugging and testing, others have different ideas about it because they value different aspects of programming differently than you do.
EDIT: To expand on my point, passing errors back directly integrates the error control flow into the "happy path" control flow. It's for a similar reason that I consider programs written to take advantage of concurrency to be "simpler" than straight linear programs, at least in many cases though not all. Even when the concurrency includes blocking statements that make the two versions equivalent in actual execution time (that is, no parallelism). The reason being that concurrency permits a separation of concerns rather than a tight integration. Real world programs often have a structure like this, where actions on different contexts are interleaved:
create graphics context
get some data
construct a renderable version of data
pass renderable version to graphics context
repeat with variations
The graphics context could sit in its own thread/process which only deals with the rendering, while the data processing sits in its own process. Separating out the two concerns may create a complication to the code (we now have at least two processes) but it also simplifies each block of code as they are more clearly describing their specific responsibility: graphics:
create context
loop:
receive data
render data
data (graphics context):
loop:
get data
construct renderable version of data
transmit data to graphics context
More moving parts, but the programmer is no longer having to manually interleave them. It's exactly the same program in performance (modulo context switching overhead, which may be too much in some cases) on a single core computer but a simpler program to maintain in other regards thanks to the improved clarity the now separated processes provide.Having a single control flow that integrates both error handling and happy path data handling can produce a similar kind of obfuscated interleaving of code which makes the actual program intent (both the error handling and the happy path data handling) harder to understand or maintain in the long run. Again, not always, there are always trade offs. But neither [data, error] nor try/catch are obviously "simpler" than the other in any universal sense.
While it’s fair to consider implementation complexity of the language and associated runtime or libraries, the programs produced in the language also have to be considered.