- make it easy to extend (lets's call it SOLID in this context)
- make it easy to rewrite (let's call it KISS)
OO developers think that the first approach is The Only Way but both can be right, especially with high-level declarative languages and with tasks small enough (relative to the language expressive power).
SOLID makes it easier to modify programs (in predetermined directions) without understanding the whole.
KISS makes it easier to understand the whole program.
SOLID accumulates inefficiencies over time because people program with tunnel vision.
KISS takes longer to modify as it grows and eventually exceeds the capacity of human brain.
SOLID is mostly OO, KISS is mostly declarative.
If you want to feel safe changing code unit tests are nice, best practices and design patterns are nice too, good architecture is appreciated. But the best is just to understand everything that happens. Only then truly fearless programming is possible.
Once you go big enough you have to use SOLID but before that it's a cost.
> SOLID makes it easier to modify programs (in predetermined directions) without understanding the whole.
In my experience, in predetermined directions is almost never the direction the piece of code needs to evolve. Maybe I'm bad at reasoning about the future changes, but I'd guess that most people are utterly bad about this.
This makes OCP more like an ideal to be aspired to, and beaten with when one inevitably fails.
> But the best is just to understand everything that happens.
Agreed, but unless you have written all the code yourself then this is hard to do. We have to be able to black-box things in order to manage complexity and make progress quickly. For this SOLID helps but if it is applied a too fine a granularity then it can make things worse (and I think micro testing things with mocks etc can cause this)
"There is no theoretical reason that anything
is hard to change about software.
If you pick any one aspect of software
then you can make it easy to change,
but we don’t know how to make everything
easy to change. Making something
easy to change makes the overall system
a little more complex, and making
everything easy to change makes the entire
system very complex. Complexity is
what makes software hard to change.
That, and duplication."
I can see how SOLID and OOP seem to be related, but the principals of SOLID can (and IMO should) be applied to FP as well. In fact one of the clarifications in Uncle Bob’s letter hints at this, by expressing that substitution is about subtypes, not inheritance.
This is of course common in statically typed FP languages like Haskell (or FP approaches in statically types imperative languages like TypeScript, Swift, so on). But even in dynamic languages like Clojure, substitution of subtypes of a given interface is pervasive; I would even say it’s foundational to the language’s core concepts.
I’d even go further to say that FP languages and approaches are much more likely to guide developers toward SOLID naturally than imperative languages and approaches.
I feel that SOLID doesn't even work well with OOP. Overall it's just saying "be more modular" in a programming paradigm that's one of the least modular styles ever created.
I look at it with the rubrik: KISS is for coding. SOLID is for system design.
There is overlap obviously but SOLID becomes even more important when you have N+2 systems that interoperate. KISS is one way to help ensure you have a SOLIDly designed system when you are coding it. But it helps to understand the principles in SOLID when you are designing a monolith or a set of microservices.
Written like a programmer! And therein lies the bug. Unless SOLID is the negation of KISS, the world tends to have more than two options. How about DRY, for instance? Off by at least one.
Software is still if statements, while loops, and assignment statements — Sequence, Selection, and Iteration.
That seems an extremely narrow view of modern programming. But then isn't this the same man who made the claim about there not being any new programming languages left to find?
There's a style of programming with no concept of loops or jumps. It's called functional programming. There's other styles that are drastically different as well.
Well, at the risk of being pedantic, I think you're being pedantic: at the machine-code level, all those function calls resolve to jumps and their recursive base-cases are terminating cases of loops. Functional programming is a great way of organizing loops and jumps, but they're still loops and jumps.
He's not talking about machine level code. He's talking about while loops in regular programming. I don't think the person was aware that there are paradigms that avoid looping.
To be more pedantic and get even lower level than machine code, at the theoretical level the basis of computation can be either procedural or functional. See lambda calculus and Turing machine for more info.
You don't think Uncle Bob knows there are paradigms that avoid looping? Sorry, but that is absurd. This isn't some random internet blogger. Uncle Bob has proven himself knowledgable enough to know something as basic as that, even if he is a controversial figure.
And please let me know if "the person" refers to someone else. I've read the comment chain all the way up and can only make sense of what you said if "the person" == Uncle Bob.
I don't know about uncle bob. But certainly what he said is untrue. Programming doesn't even have "while loops" as a primitive. From that wrong statement I assume uncle bob doesn't know much.
Of course I could be wrong. I don't know the guy. But that statement alone is enough for me to make a negative judgement against him.
Well, all electronic computers all the way back to the Mark II have had unconditional branches (jumps) and conditional branches: I guess you're strictly correct that "while loops" aren't technically primitives, but conditional branches are and the only reason you'd ever use one is to perform the equivalent of an if or a while.
I'd say one of the biggest trends in the industry in recent times has been away from mutability -- and with it ubiquitous assignment and the kind of stateful everything environment that OOP tends to foster -- and towards much more controlled state management, often immutability by default, and generally a more declarative programming style.
I think the emphasis on traditional structural programming is also a very dated view. I mean, sure, at machine code level, you're ultimately doing sequences and conditional jumps one way or another, and most imperative languages do still provide general sequence, selection and iteration structures. But in the mainstream languages, numerous other control structures from exceptions to pattern matching on algebraic data types are in fairly widespread use, and tools like concurrency, asynchronicity and using higher order functions are also widely relevant.
I believe you're referring to his statement that essentially no new programming paradigms have been discovered since the 60s? Are you saying you disagree? Basically every major programming paradigm had been discovered by the 60s. I'd be happy to hear otherwise.
Basically every major programming paradigm had been discovered by the 60s.
At the most coarse, generic level, perhaps. But there's been huge amounts of research both academically and industrially into, for example, how to structure functional programs, how to manage side effects, and working within concurrent/distributed/asynchronous environments. Saying that nothing new has happened in programming languages since the 1960s because the major paradigms had all been discovered already is like saying nothing new has happened in automotive design since the 1960s because private vehicles still usually have 2 or 4 wheels.
I think that generic programming wasn't really a thing until Stepanov with the STL in the 1990s. There may have been glimmerings of it, but I know of no actual, significant implementations before that.
Most think ML[0] did it first, and dare I say did it right first. It gets rid of entire categories of boilerplate (interfaces come to mind) because types are fully inferred. In other words, if `funpost(request)` uses .User, .Body, .Time, from request, the only requirement is that the 'request' object has those properties with correct types further down the chain in usage. Other languages can do this but not typically to the degree in ML's type system (known as Hindley-Milner [1]). There are times it can't quite figure it out but it is, again, less frequent than in others.
If you want to get pedantic I'd argue Generic programming is a subset of metaprogramming. To that end I'll go ahead and state that Lisp (1958) is probably the language I have done the MOST programming I would describe as 'generic'.
SOLID is just a random arbitrary acronym about some principles that are somewhat obvious. However most people don't fully understand these principles deeply. What happens is if you are using OOP you are not using SOLID to its full extent. Ironic that SOLID comes from OOP.
Either way SOLID are arbitrary rules of thumb. Grouped together arbitrarily to spell out SOLID and kind of maybe sort of right depending on your opinion.
1. SRP. Gather together the things that change for the same reasons. Separate things that change for different reasons.
I disagree. Unless this thing is talking about namespacing which is more of an aesthetic/psychological thing... You should not organize your program this way where logic of one thing is tied with the logic of another thing. All things should be ungrouped as much as possible.
You should also separate all things that "change" and all things that don't "change" and try to keep the things that "change" as small as possible.
All logical modules in your program should be separated as much as possible at the lowest layer, nothing should be grouped... and higher layers should be compositions of lower layers. But the basis of your program at the lowest layer: everything should be separated and nothing should be grouped.
In other words use combinators as MUCH as possible. OOP's promotion of methods that operate on shared mutable external scope actually prevent you from doing this.
2. A Module should be open for extension but closed for modification.
Agreed. The problem is if your program is OOP your modules are objects which group methods together through shared mutable state. It means that you cannot extend one method without modifying the entire object.
So basically OOP practitioners can never modify an addOne method into addTwo. They can't even add an addTwo method into the object as that constitutes modification. What they can do is inject that entire object into another object. Either do this or use inheritance which OOP says is just bad.
Instead. Use combinators. You have an addOne combinator? Create an addTwo combinator. Recompose your combinators to form the higher level logic you wanted.
3. A program that uses an interface must not be confused by an implementation of that interface.
Yeah I mean sure. Don't have your flatten Function be able to operate on an integer.
4. Keep interfaces small so that users don’t end up depending on things they don’t need.
Agreed the problem is, the definition of an object in OOP usually ties muteable state and logic together. Your average object has a getter and setter which are tied together by external scope, so by definition OOP ties together things that shouldn't belong together. You are not keeping your interfaces small.... by definition it is already too big.
If you defined the logic as I said above separating state changes away from combinators you'd have even smaller interfaces. One for changing state. And the other logic is stateless.
5. Dependency inversion principle.
I mean yeah. Write your logic in layers. The problem is OOP people only use this principle at the module level.
The better way to program is to have all your methods follow the dependency inversion principle. You can't do this if methods are modifying external scope shared by another method. Use combinators and by definition every sector of your program is following this principle not just "modules"
Because you cannot predict the future of your program. Coupled logic that seems correct now may need to be uncoupled in the future.
By having every sector of logic be ungrouped it leads to maximum reusability for the future.
Technical debt is at the lowest possible level in projects where every piece of logic is uncoupled. In fact the very phenomenon of Technical debt arises from logic that's hard to uncouple. By having every sector of your program be a lego brick you are preparing your program for an inevitable future where the structure must be reconfigured.
Grouping logic together is like super gluing lego bricks together because it seems like they belong together.
There's a term for this style of coding that's actually negative. It's called ravioli code. But the negativity mostly applies to when this style of coding is used in OOP.
> In fact the very phenomenon of Technical debt arises from logic that's hard to uncouple.
No, one kind of technical debt arises that way. Not the only kind. There's another kind that arises when everything is in such small pieces that nobody can tell how the pieces relate to each other. You can easily understand each piece, but you have to go through a chain of a bazillion function calls to see what's actually going on.
> By having every sector of your program be a lego brick you are preparing your program for an inevitable future where the structure must be reconfigured.
This is like economists, who have correctly predicted nine of the last two recessions. Sure, you're prepared for when the change comes (if you can find where to make it - see above). You've also prepared for all the changes that never come. That's rather wasteful.
> Because you cannot predict the future of your program. Coupled logic that seems correct now may need to be uncoupled in the future.
Sure, it may. And when that happens, we'll decouple it. And in the meantime, we'll enjoy the coupling that, at the moment, is the correct thing.
See, this is all in the context of SOLID, the very first piece of which is "Single Responsibility". We don't couple things on a whim.
>No, one kind of technical debt arises that way. Not the only kind. There's another kind that arises when everything is in such small pieces that nobody can tell how the pieces relate to each other. You can easily understand each piece, but you have to go through a chain of a bazillion function calls to see what's actually going on.
All this logic must exist regardless of whether it's coupled or uncoupled. You do not alleviate the logical burden by coupling logic together or uncoupling the logic. If you call this "technical debt" then you cannot get rid of it. Why even call it debt then as logic is intrinsic to application.
In fact decoupling logic can improve readability as it marks each piece of logic with a contextual function name. Either way you do not alleviate complexity by coupling logic. You alleviate this complexity problem by providing logical layers, using namespaces, and using good function naming.
Coupling logic into Objects does nothing objectively as it does not eliminate complexity it only makes sure that the complexity is LESS modular.
Let's be clear though. I am saying that the lower levels of your application should be ALL uncoupled logic. Then you build logical layers on top of your primitives that consists of compositions of your logic that build higher and higher. So a person on layer 5 only needs to go to layer 4 to understand it, and does not need to go to layer 1 to understand every primitive.
>This is like economists, who have correctly predicted nine of the last two recessions. Sure, you're prepared for when the change comes (if you can find where to make it - see above). You've also prepared for all the changes that never come. That's rather wasteful.
Analogies don't offer proof of an argument. Additionally it's not a good analogy. Economists don't completely understand what causes recessions and how to prevent them. We do, however, completely understand the difference between uncoupled logic and coupled logic.
>Sure, it may. And when that happens, we'll decouple it. And in the meantime, we'll enjoy the coupling that, at the moment, is the correct thing.
This is the main point of tech debt. It is hard to decouple. You can have all logic decoupled at the lowest layer and still have everything be readable. I think the previous sentence is just a type of programming you haven't dealt with. You've never wrote a program with decoupled logic that was readable. Think deeper. There is no reason why coupled logic should be more readable then decoupled logic. The Logic still exists either way, you either compose two modules and give it a name or you don't even use modules shove all your logic into a module with a single name.
>See, this is all in the context of SOLID, the very first piece of which is "Single Responsibility". We don't couple things on a whim.
The problem is OOP does this all the time. It couples state with logic. These two things should be uncoupled.
> Let's be clear though. I am saying that the lower levels of your application should be ALL uncoupled logic. Then you build logical layers on top of your primitives that consists of compositions of your logic that build higher and higher. So a person on layer 5 only needs to go to layer 4 to understand it, and does not need to go to layer 1 to understand every primitive.
Yep. If you do this right you only need to understand one or two layers at a time. Great example of this is the Akka Toolkits for JVM and .NET; Every time I've hacked on a given subsection, the amount of domain knowledge I've had to have was 'Basics anyone using Akka has to know' and then just the layer I was working with and maybe one other.
> The problem is OOP does this all the time. It couples state with logic. These two things should be uncoupled.
Agreed. And dare I say, when you start going from coupled state+logic, and go more towards functional... you'll find you can possibly shed a layer or two in your overall design.
You seem to think that FP is the one right answer. You are mistaken. There are places where it's the right answer, or at least a very good one - the best definition I've seen is when your program looks like a pipe.
And there are places where FP is the wrong answer - where your program has state, and there are multiple parts to the state, and those parts have to be kept in sync with each other. At that point, bundling the data to the functions is warranted, because it limits the number of functions that can put the data in an inconsistent state.
And if you're going to say "don't structure the data that way", well, there are times when that's the nature of the problem, not just the nature of the program to solve the problem.
>You seem to think that FP is the one right answer. You are mistaken. There are places where it's the right answer, or at least a very good one - the best definition I've seen is when your program looks like a pipe.
All programs are pipes from IO to IO or from one state to another state. Purity lives in the pipes, impurity lives in the IO nodes between the pipes. The goal is to keep the impurity as small as possible and have most of your logic live in the pipe, as combinators are the most composeable primitive. I also never said FP is the one right answer. Most programs need to modify state. In FP everything is immutable so your program cannot be pure FP in most cases. What I am saying is to segregate state and IO from logic. Example:
void addOneToState()
can be broken down into
int addNTo(int n, int m)
void changeStateTo(int x)
I have split the code above into a method that is devoid of logic and only updates state and logic that lives as a stateless combinator. All programs are made from a series of pipes and tubes. You just need to find the pipe and modularize it and seperate it from the parts that can't be pipes.
>At that point, bundling the data to the functions is warranted.
It's never warranted. There is no benefit. Whether you couple it or uncouple it from data the logic still exists, the only difference is coupling. Adding coupling does not improve your code in any other way other than adding coupling.
>And if you're going to say "don't structure the data that way", well, there are times when that's the nature of the problem, not just the nature of the program to solve the problem.
There is no data structure that has to be coupled with logic. However you structure your data it can always be decoupled from logic. Always.
>where your program has state, and there are multiple parts to the state, and those parts have to be kept in sync with each other.
This doesn't change anything.
State makeNewState (State oldState) {
newY = addNTo(oldState.y, 1)
return New State {
x = oldState.x
y = newY
somethingToBeInSyncTo = newY
}
}
void updateState(State x)
"I also never said"? Yeah, I suspected that you and lerptime were the same person.
> It's never warranted. There is no benefit.
When you make such a statement, you sound like someone with fairly limited experience. In particular, from your example, I suspect that you've never worked in a multi-threaded embedded system. somethingToBeInSyncTo, done that way, means that any other thread holding a reference, holds a reference to the old value, while this thread has the new one. And updating running threads with new values on the fly is... problematic.
There's a reason why some programs are not written the way you propose...
>Yeah, I suspected that you and lerptime were the same person.
to get around the post limit. Keep it on the DL.
>When you make such a statement, you sound like someone with fairly limited experience. In particular, from your example, I suspect that you've never worked in a multi-threaded embedded system. somethingToBeInSyncTo, done that way, means that any other thread holding a reference, holds a reference to the old value, while this thread has the new one. And updating running threads with new values on the fly is... problematic.
I find that you're the one who seems inexperienced. My example is thread safe ... makeNewState is a combinator. It makes no mutations. Like IO, and mutations, the problems with threading happen with mutation. Thus, locks and other procedural commands used to deal with threading live in the void IO function.
We're more referring to coupling functions with external data and mutations via objects. If you're talking about synchronizing two threads, this again needs to be handled with parent IO function outside the logic of your pipeline combinators. You can still segregate logic away from threading and IO.
My combinator in this case is useable for your example case of synchronizing two threads. If you organized your code in ways where my combinator will be unusable than you have failed to decouple your logic away from threading, IO and mutation.
> I find that you're the one who seems inexperienced.
35 years professional software engineering, 30 years in embedded. I'm going to guess that you have less.
Your way of organizing code works great, on paper. Maybe on a small project. For a real embedded system, with multiple threads, with state data being used everywhere... your approach doesn't make much sense. It makes a much worse design than shared mutable state. (Yes, shared mutable state is in fact as evil as you have think it is. It's still better than trying to make your alternative work in that environment.)
Why is state data used everywhere? Because embedded systems often respond in different ways depending on the state of external inputs, and don't go read the state at the time they make the decision on how to respond. If those decisions are spread through the bulk of the code, there's really not much left to put in your combinator. And there's no point in trying to add that paradigm to a system that's going to have tons of shared mutable state anyway.
>35 years professional software engineering, 30 years in embedded. I'm going to guess that you have less.
Doesn't change the fact that you're wrong and 35 years of programming doesn't ensure that you're a good developer. There are examples of crap code and patterns that have been around perpetually.
>Why is state data used everywhere? Because embedded systems often respond in different ways depending on the state of external inputs
State being everywhere doesn't preclude it to being segregated from logic.
>If those decisions are spread through the bulk of the code, there's really not much left to put in your combinator. And there's no point in trying to add that paradigm to a system that's going to have tons of shared mutable state anyway.
The decision is what goes in the combinator.
#IO functions.
Data getIOFromModuleA()
void sendIOToModuleA(Response r)
Data getIOFromModuleB()
void sendIOToModuleB(Response r)
....
# combinators, reuseable in both module A and module B because of segregation from IO and State
Decision makeDecision(Data x)
Response createResponse(Decision x)
It's the same pattern for State. Just replace the word IO with State. Response can be anything, it can be the entire new state of the program. It can represent a delta of a change, it can be a response specific to a module. Also again, all threading commands go into State or IO functions.
OOP fails to segregate code this way and if you don't organize your code like this then you have 35 years of experience of navigating someone elses mess or your own.
So I said you were inexperienced. You said I was. I listed my experience. You said it was the wrong experience, but conspicuously declined to state your own.
So I'll just leave you with this. "In theory, there is no difference between theory and practice. In practice, there is." You've got your theory. Nice theory. Use it for a decade of actual real-world work, and let me know how it works out. I'll be especially interested if any of that work is in embedded.
I said it as a minor minor retort. You want the full story? The full story is I don't care for experience. I find experience doesn't correlate with actual skill. Many experienced developers act and seem very inexperienced which is what I said about you: You "seem inexperienced" because you act that way. The very act of bringing up experience is off topic and a sign of lack of maturity.
If you have to know I'd say I have about 15 years of experience in research, gaming, embedded and web. I will say that the pattern I described can be followed in every one of those fields but it's a niche pattern because it is indeed promoted by people who do FP.
I don't know why you focus on embedded, but embedded people tend to follow these patterns the least due mostly to C++. The overall promoted pattern in C++ is to pass references everywhere and it's highly unlikely that someone in that world will follow this pattern because you have to go out of the way to do it and learn about it. It requires a disciplined approach.
The big problem is if you need to slightly modify a bunch of values in an array of 100 objects. It's hard to figure out the right way to do it in a combinator. The common pattern used nowadays is instead of having combinators handle it, have the combinators generate logical instructions to send to the IO function similar to how in web development your stateless servers pass SQL instructions to the SQL server. The IO function processes the instructions and handles the threading, mutation and IO. Here's a library that does this:
https://github.com/google/cpp-frp <---- real world example.
Nothing I said is "theory" in the sense that you put it. These are rare but actual patterns that are used successfully by people in the know. You are not in the "know" despite your experience. As a result for most of your program experience you've just been dealing with and fixing mess after mess after mess.
Don't be stupid. I'm listening to you. I'm actually responding to you. You say theory is different from the real world so I present you with a real world solution and examples of how it works with embedded specifically. Your job should you choose to continue is to use other real world examples to counter my arguments. That's all, if you convince me it's done, If i convince you it's also done. Until then leaving the discussion before it's resolved is pointless.
Don't back out, I'm not fighting for the last word. I'm having a discussion with you in hopes you can convince me. But you haven't really said anything substantial other than things along the lines of "Trust me I have lots of experience, your stuff is just theory."
If you start introducing the real arguments then this discussion can actually go somewhere.
You can move state to the boundary, and IO to the boundary, and then... what's left? It was all boundary.
A bit more realistically, here's an automobile cruise control that uses the same button for "reduce set speed" and "set the speed". (Don't blame me, I've had more than one car with that exact setup).
void buttonXEvent() {
if (cruiseControlEnabled) {
if (cruiseSpeedSet) {
cruiseSpeed--;
} else {
cruiseSpeed = currentSpeed;
cruiseSpeedSet = true;
}
writeSpeedToEngineComputer(currentCruiseSpeed);
} // else do nothing
}
Your example uses free variables extensively. It means that all your logic cannot move outside of the context of the free variable which means they cannot be reused. Even IO functions should not use external context.
I see no respect in which your solution is superior.
By the time you implement getCurrentSpeed(), etc., you're going to have a fair amount more code than me. (writeSpeedToEngineComputer() doesn't count, because I need to implement that also.)
Your reason for doing so is, I think, in your last two paragraphs. But this isn't particularly reusable code - I'm not going to use getNextCruiseSpeed() or handleButtonPress() in the windshield wiper controller. It's pretty much tied to the specific use. (There may be a handleButtonPress() in the windshield wiper controller, but I can't reuse this one.)
So you offer me something that is more verbose, based on a benefit that I can't actually benefit from.
(I presume that getCurrentSpeed() returning void was just because of quick typing.)
The point was to prove that I can pull the combinator out of your unsegregated code and moularize IO away from logic.
The other point of this was to improve code modularity in every corner of your code. Why is this good? If all your logic can be shifted around then that means that you can reuse code to anticipate future changes.
The most insidious form of technical debt is unanticipated technical debt. You encounter it when a new requirement makes you realize that you can't reuse segments of your code. You realize that you grouped things together incorrectly. You failed to have the foresight to make your code more modular.
It's a problem people think they can solve in hindsight but they can't in reality because they cannot predict the future so they will inevitably group code incorrectly. And therein lies the problem: grouping. Don't group your code. Make all your logic modular and just organize your code with superficial schemes like namespaces.
The problem with OOP is that the very nature of an object is a grouping. OOP is all about grouping the code around shared mutable variables within a context. You are forced to assume a grouping and you may be right in some cases (like cruisecontrol) but you will inevitably be mistaken in many other cases. This "mistake" is the origin of the most insidious form of technical debt.
The other problem with OOP is that they think that this style of programming is modular. They have no idea how un-modular it is. So they use it extensively with patterns thinking they're improving modularity.
The most modular primitive is the combinator. That is why to prevent technical debt as much as possible, the key is to move as much code as possible into the combinators.
I think you're basically describing the idea of having pure computations on pure data objects as much as possible? In which case, I believe that's fairly idiomatic in functional languages. And it's becoming more common in languages like C# and Java that started out OOP but have introduced enough functional niceties to make that style more idiomatic. I've been doing it in Java for at least three years. Here's a comment I wrote about it from then:
Uncle Bob sounds like someone trying to tutor a young golfer by talking about how his shoulders are aligned, how wide apart his feet are, how he's gripping the handle, etc. Dan North sounds like he's objecting to that style and prefers just yelling "JUST HIT THE BALL FAR" at his students over and over.
I'll be honest I almost never find myself agreeing with what I understand Bob Martin is advocating but this whole post makes sense to me. I may have to re-read some of his other stuff. I don't do much OOP but all of these principals make sense in a FP context as well and I think he does a good job of untangling accidental associations with specific OOP features/patterns in this article.
> His points were that the Open-Closed principle isn’t very important anymore because most of the code we write isn’t contained in large monoliths and making changes to small microservices is safe and easy.
IMHO, Open-Closed applies just as well to entire microservice architectures as it does to individual classes. For example, I've seen services for general-purpose tasks such as job scheduling or generating PDF reports which required devs to change the code to add a new job type or report type. These services were replaced by services where the new application-specific types could be defined through an API, which meant that those services were "done" and reduced the amount of work required to use them.
54 comments
[ 3.4 ms ] story [ 112 ms ] thread- make it easy to extend (lets's call it SOLID in this context)
- make it easy to rewrite (let's call it KISS)
OO developers think that the first approach is The Only Way but both can be right, especially with high-level declarative languages and with tasks small enough (relative to the language expressive power).
SOLID makes it easier to modify programs (in predetermined directions) without understanding the whole.
KISS makes it easier to understand the whole program.
SOLID accumulates inefficiencies over time because people program with tunnel vision.
KISS takes longer to modify as it grows and eventually exceeds the capacity of human brain.
SOLID is mostly OO, KISS is mostly declarative.
If you want to feel safe changing code unit tests are nice, best practices and design patterns are nice too, good architecture is appreciated. But the best is just to understand everything that happens. Only then truly fearless programming is possible.
Once you go big enough you have to use SOLID but before that it's a cost.
In my experience, in predetermined directions is almost never the direction the piece of code needs to evolve. Maybe I'm bad at reasoning about the future changes, but I'd guess that most people are utterly bad about this.
This makes OCP more like an ideal to be aspired to, and beaten with when one inevitably fails.
Maybe a better way to put it is that SOLID is an ideal that OOP aspires to be but fails.
Either way SOLID is a bad set of axiomatic design principles. It's sort of an arbitrary group of rule of thumb principles picked to spell out SOLID.
Agreed, but unless you have written all the code yourself then this is hard to do. We have to be able to black-box things in order to manage complexity and make progress quickly. For this SOLID helps but if it is applied a too fine a granularity then it can make things worse (and I think micro testing things with mocks etc can cause this)
"There is no theoretical reason that anything is hard to change about software. If you pick any one aspect of software then you can make it easy to change, but we don’t know how to make everything easy to change. Making something easy to change makes the overall system a little more complex, and making everything easy to change makes the entire system very complex. Complexity is what makes software hard to change. That, and duplication."
https://martinfowler.com/ieeeSoftware/whoNeedsArchitect.pdf
There is a simple rule that Martin fails to ever mention.
Use combinators as much as possible and segregate as much logic as possible from IO. That's it.
This is of course common in statically typed FP languages like Haskell (or FP approaches in statically types imperative languages like TypeScript, Swift, so on). But even in dynamic languages like Clojure, substitution of subtypes of a given interface is pervasive; I would even say it’s foundational to the language’s core concepts.
I’d even go further to say that FP languages and approaches are much more likely to guide developers toward SOLID naturally than imperative languages and approaches.
There is overlap obviously but SOLID becomes even more important when you have N+2 systems that interoperate. KISS is one way to help ensure you have a SOLIDly designed system when you are coding it. But it helps to understand the principles in SOLID when you are designing a monolith or a set of microservices.
Written like a programmer! And therein lies the bug. Unless SOLID is the negation of KISS, the world tends to have more than two options. How about DRY, for instance? Off by at least one.
That seems an extremely narrow view of modern programming. But then isn't this the same man who made the claim about there not being any new programming languages left to find?
Most of these styles aren't new though.
To be more pedantic and get even lower level than machine code, at the theoretical level the basis of computation can be either procedural or functional. See lambda calculus and Turing machine for more info.
And please let me know if "the person" refers to someone else. I've read the comment chain all the way up and can only make sense of what you said if "the person" == Uncle Bob.
Of course I could be wrong. I don't know the guy. But that statement alone is enough for me to make a negative judgement against him.
I think the emphasis on traditional structural programming is also a very dated view. I mean, sure, at machine code level, you're ultimately doing sequences and conditional jumps one way or another, and most imperative languages do still provide general sequence, selection and iteration structures. But in the mainstream languages, numerous other control structures from exceptions to pattern matching on algebraic data types are in fairly widespread use, and tools like concurrency, asynchronicity and using higher order functions are also widely relevant.
At the most coarse, generic level, perhaps. But there's been huge amounts of research both academically and industrially into, for example, how to structure functional programs, how to manage side effects, and working within concurrent/distributed/asynchronous environments. Saying that nothing new has happened in programming languages since the 1960s because the major paradigms had all been discovered already is like saying nothing new has happened in automotive design since the 1960s because private vehicles still usually have 2 or 4 wheels.
Corrections welcome...
You're cool.
Most think ML[0] did it first, and dare I say did it right first. It gets rid of entire categories of boilerplate (interfaces come to mind) because types are fully inferred. In other words, if `funpost(request)` uses .User, .Body, .Time, from request, the only requirement is that the 'request' object has those properties with correct types further down the chain in usage. Other languages can do this but not typically to the degree in ML's type system (known as Hindley-Milner [1]). There are times it can't quite figure it out but it is, again, less frequent than in others.
[0] - https://en.wikipedia.org/wiki/ML_(programming_language) [1] - https://en.wikipedia.org/wiki/Hindley%E2%80%93Milner_type_sy...
Either way SOLID are arbitrary rules of thumb. Grouped together arbitrarily to spell out SOLID and kind of maybe sort of right depending on your opinion.
I disagree. Unless this thing is talking about namespacing which is more of an aesthetic/psychological thing... You should not organize your program this way where logic of one thing is tied with the logic of another thing. All things should be ungrouped as much as possible.You should also separate all things that "change" and all things that don't "change" and try to keep the things that "change" as small as possible.
All logical modules in your program should be separated as much as possible at the lowest layer, nothing should be grouped... and higher layers should be compositions of lower layers. But the basis of your program at the lowest layer: everything should be separated and nothing should be grouped.
In other words use combinators as MUCH as possible. OOP's promotion of methods that operate on shared mutable external scope actually prevent you from doing this.
Agreed. The problem is if your program is OOP your modules are objects which group methods together through shared mutable state. It means that you cannot extend one method without modifying the entire object.So basically OOP practitioners can never modify an addOne method into addTwo. They can't even add an addTwo method into the object as that constitutes modification. What they can do is inject that entire object into another object. Either do this or use inheritance which OOP says is just bad.
Instead. Use combinators. You have an addOne combinator? Create an addTwo combinator. Recompose your combinators to form the higher level logic you wanted.
Yeah I mean sure. Don't have your flatten Function be able to operate on an integer. Agreed the problem is, the definition of an object in OOP usually ties muteable state and logic together. Your average object has a getter and setter which are tied together by external scope, so by definition OOP ties together things that shouldn't belong together. You are not keeping your interfaces small.... by definition it is already too big.If you defined the logic as I said above separating state changes away from combinators you'd have even smaller interfaces. One for changing state. And the other logic is stateless.
I mean yeah. Write your logic in layers. The problem is OOP people only use this principle at the module level.The better way to program is to have all your methods follow the dependency inversion principle. You can't do this if methods are modifying external scope shared by another method. Use combinators and by definition every sector of your program is following this principle not just "modules"
Why?
By having every sector of logic be ungrouped it leads to maximum reusability for the future.
Technical debt is at the lowest possible level in projects where every piece of logic is uncoupled. In fact the very phenomenon of Technical debt arises from logic that's hard to uncouple. By having every sector of your program be a lego brick you are preparing your program for an inevitable future where the structure must be reconfigured.
Grouping logic together is like super gluing lego bricks together because it seems like they belong together.
There's a term for this style of coding that's actually negative. It's called ravioli code. But the negativity mostly applies to when this style of coding is used in OOP.
> In fact the very phenomenon of Technical debt arises from logic that's hard to uncouple.
No, one kind of technical debt arises that way. Not the only kind. There's another kind that arises when everything is in such small pieces that nobody can tell how the pieces relate to each other. You can easily understand each piece, but you have to go through a chain of a bazillion function calls to see what's actually going on.
> By having every sector of your program be a lego brick you are preparing your program for an inevitable future where the structure must be reconfigured.
This is like economists, who have correctly predicted nine of the last two recessions. Sure, you're prepared for when the change comes (if you can find where to make it - see above). You've also prepared for all the changes that never come. That's rather wasteful.
> Because you cannot predict the future of your program. Coupled logic that seems correct now may need to be uncoupled in the future.
Sure, it may. And when that happens, we'll decouple it. And in the meantime, we'll enjoy the coupling that, at the moment, is the correct thing.
See, this is all in the context of SOLID, the very first piece of which is "Single Responsibility". We don't couple things on a whim.
>No, one kind of technical debt arises that way. Not the only kind. There's another kind that arises when everything is in such small pieces that nobody can tell how the pieces relate to each other. You can easily understand each piece, but you have to go through a chain of a bazillion function calls to see what's actually going on.
All this logic must exist regardless of whether it's coupled or uncoupled. You do not alleviate the logical burden by coupling logic together or uncoupling the logic. If you call this "technical debt" then you cannot get rid of it. Why even call it debt then as logic is intrinsic to application.
In fact decoupling logic can improve readability as it marks each piece of logic with a contextual function name. Either way you do not alleviate complexity by coupling logic. You alleviate this complexity problem by providing logical layers, using namespaces, and using good function naming.
Coupling logic into Objects does nothing objectively as it does not eliminate complexity it only makes sure that the complexity is LESS modular.
Let's be clear though. I am saying that the lower levels of your application should be ALL uncoupled logic. Then you build logical layers on top of your primitives that consists of compositions of your logic that build higher and higher. So a person on layer 5 only needs to go to layer 4 to understand it, and does not need to go to layer 1 to understand every primitive.
>This is like economists, who have correctly predicted nine of the last two recessions. Sure, you're prepared for when the change comes (if you can find where to make it - see above). You've also prepared for all the changes that never come. That's rather wasteful.
Analogies don't offer proof of an argument. Additionally it's not a good analogy. Economists don't completely understand what causes recessions and how to prevent them. We do, however, completely understand the difference between uncoupled logic and coupled logic.
>Sure, it may. And when that happens, we'll decouple it. And in the meantime, we'll enjoy the coupling that, at the moment, is the correct thing.
This is the main point of tech debt. It is hard to decouple. You can have all logic decoupled at the lowest layer and still have everything be readable. I think the previous sentence is just a type of programming you haven't dealt with. You've never wrote a program with decoupled logic that was readable. Think deeper. There is no reason why coupled logic should be more readable then decoupled logic. The Logic still exists either way, you either compose two modules and give it a name or you don't even use modules shove all your logic into a module with a single name.
>See, this is all in the context of SOLID, the very first piece of which is "Single Responsibility". We don't couple things on a whim.
The problem is OOP does this all the time. It couples state with logic. These two things should be uncoupled.
Yep. If you do this right you only need to understand one or two layers at a time. Great example of this is the Akka Toolkits for JVM and .NET; Every time I've hacked on a given subsection, the amount of domain knowledge I've had to have was 'Basics anyone using Akka has to know' and then just the layer I was working with and maybe one other.
> The problem is OOP does this all the time. It couples state with logic. These two things should be uncoupled.
Agreed. And dare I say, when you start going from coupled state+logic, and go more towards functional... you'll find you can possibly shed a layer or two in your overall design.
And there are places where FP is the wrong answer - where your program has state, and there are multiple parts to the state, and those parts have to be kept in sync with each other. At that point, bundling the data to the functions is warranted, because it limits the number of functions that can put the data in an inconsistent state.
And if you're going to say "don't structure the data that way", well, there are times when that's the nature of the problem, not just the nature of the program to solve the problem.
All programs are pipes from IO to IO or from one state to another state. Purity lives in the pipes, impurity lives in the IO nodes between the pipes. The goal is to keep the impurity as small as possible and have most of your logic live in the pipe, as combinators are the most composeable primitive. I also never said FP is the one right answer. Most programs need to modify state. In FP everything is immutable so your program cannot be pure FP in most cases. What I am saying is to segregate state and IO from logic. Example:
can be broken down into I have split the code above into a method that is devoid of logic and only updates state and logic that lives as a stateless combinator. All programs are made from a series of pipes and tubes. You just need to find the pipe and modularize it and seperate it from the parts that can't be pipes.>At that point, bundling the data to the functions is warranted.
It's never warranted. There is no benefit. Whether you couple it or uncouple it from data the logic still exists, the only difference is coupling. Adding coupling does not improve your code in any other way other than adding coupling.
>And if you're going to say "don't structure the data that way", well, there are times when that's the nature of the problem, not just the nature of the program to solve the problem.
There is no data structure that has to be coupled with logic. However you structure your data it can always be decoupled from logic. Always.
>where your program has state, and there are multiple parts to the state, and those parts have to be kept in sync with each other.
This doesn't change anything.
y is still in sync with somethingToBeInSyncTo.> It's never warranted. There is no benefit.
When you make such a statement, you sound like someone with fairly limited experience. In particular, from your example, I suspect that you've never worked in a multi-threaded embedded system. somethingToBeInSyncTo, done that way, means that any other thread holding a reference, holds a reference to the old value, while this thread has the new one. And updating running threads with new values on the fly is... problematic.
There's a reason why some programs are not written the way you propose...
to get around the post limit. Keep it on the DL.
>When you make such a statement, you sound like someone with fairly limited experience. In particular, from your example, I suspect that you've never worked in a multi-threaded embedded system. somethingToBeInSyncTo, done that way, means that any other thread holding a reference, holds a reference to the old value, while this thread has the new one. And updating running threads with new values on the fly is... problematic.
I find that you're the one who seems inexperienced. My example is thread safe ... makeNewState is a combinator. It makes no mutations. Like IO, and mutations, the problems with threading happen with mutation. Thus, locks and other procedural commands used to deal with threading live in the void IO function.
We're more referring to coupling functions with external data and mutations via objects. If you're talking about synchronizing two threads, this again needs to be handled with parent IO function outside the logic of your pipeline combinators. You can still segregate logic away from threading and IO.
My combinator in this case is useable for your example case of synchronizing two threads. If you organized your code in ways where my combinator will be unusable than you have failed to decouple your logic away from threading, IO and mutation.
35 years professional software engineering, 30 years in embedded. I'm going to guess that you have less.
Your way of organizing code works great, on paper. Maybe on a small project. For a real embedded system, with multiple threads, with state data being used everywhere... your approach doesn't make much sense. It makes a much worse design than shared mutable state. (Yes, shared mutable state is in fact as evil as you have think it is. It's still better than trying to make your alternative work in that environment.)
Why is state data used everywhere? Because embedded systems often respond in different ways depending on the state of external inputs, and don't go read the state at the time they make the decision on how to respond. If those decisions are spread through the bulk of the code, there's really not much left to put in your combinator. And there's no point in trying to add that paradigm to a system that's going to have tons of shared mutable state anyway.
Doesn't change the fact that you're wrong and 35 years of programming doesn't ensure that you're a good developer. There are examples of crap code and patterns that have been around perpetually.
>Why is state data used everywhere? Because embedded systems often respond in different ways depending on the state of external inputs
State being everywhere doesn't preclude it to being segregated from logic.
>If those decisions are spread through the bulk of the code, there's really not much left to put in your combinator. And there's no point in trying to add that paradigm to a system that's going to have tons of shared mutable state anyway.
The decision is what goes in the combinator.
It's the same pattern for State. Just replace the word IO with State. Response can be anything, it can be the entire new state of the program. It can represent a delta of a change, it can be a response specific to a module. Also again, all threading commands go into State or IO functions.OOP fails to segregate code this way and if you don't organize your code like this then you have 35 years of experience of navigating someone elses mess or your own.
So I'll just leave you with this. "In theory, there is no difference between theory and practice. In practice, there is." You've got your theory. Nice theory. Use it for a decade of actual real-world work, and let me know how it works out. I'll be especially interested if any of that work is in embedded.
If you have to know I'd say I have about 15 years of experience in research, gaming, embedded and web. I will say that the pattern I described can be followed in every one of those fields but it's a niche pattern because it is indeed promoted by people who do FP.
I don't know why you focus on embedded, but embedded people tend to follow these patterns the least due mostly to C++. The overall promoted pattern in C++ is to pass references everywhere and it's highly unlikely that someone in that world will follow this pattern because you have to go out of the way to do it and learn about it. It requires a disciplined approach.
The big problem is if you need to slightly modify a bunch of values in an array of 100 objects. It's hard to figure out the right way to do it in a combinator. The common pattern used nowadays is instead of having combinators handle it, have the combinators generate logical instructions to send to the IO function similar to how in web development your stateless servers pass SQL instructions to the SQL server. The IO function processes the instructions and handles the threading, mutation and IO. Here's a library that does this:
Nothing I said is "theory" in the sense that you put it. These are rare but actual patterns that are used successfully by people in the know. You are not in the "know" despite your experience. As a result for most of your program experience you've just been dealing with and fixing mess after mess after mess.You get the last word, if you want.
Don't back out, I'm not fighting for the last word. I'm having a discussion with you in hopes you can convince me. But you haven't really said anything substantial other than things along the lines of "Trust me I have lots of experience, your stuff is just theory."
If you start introducing the real arguments then this discussion can actually go somewhere.
A bit more realistically, here's an automobile cruise control that uses the same button for "reduce set speed" and "set the speed". (Don't blame me, I've had more than one car with that exact setup).
How would you write this in your approach?Combinators can be reused everywhere.
By the time you implement getCurrentSpeed(), etc., you're going to have a fair amount more code than me. (writeSpeedToEngineComputer() doesn't count, because I need to implement that also.)
Your reason for doing so is, I think, in your last two paragraphs. But this isn't particularly reusable code - I'm not going to use getNextCruiseSpeed() or handleButtonPress() in the windshield wiper controller. It's pretty much tied to the specific use. (There may be a handleButtonPress() in the windshield wiper controller, but I can't reuse this one.)
So you offer me something that is more verbose, based on a benefit that I can't actually benefit from.
(I presume that getCurrentSpeed() returning void was just because of quick typing.)
The other point of this was to improve code modularity in every corner of your code. Why is this good? If all your logic can be shifted around then that means that you can reuse code to anticipate future changes.
The most insidious form of technical debt is unanticipated technical debt. You encounter it when a new requirement makes you realize that you can't reuse segments of your code. You realize that you grouped things together incorrectly. You failed to have the foresight to make your code more modular.
It's a problem people think they can solve in hindsight but they can't in reality because they cannot predict the future so they will inevitably group code incorrectly. And therein lies the problem: grouping. Don't group your code. Make all your logic modular and just organize your code with superficial schemes like namespaces.
The problem with OOP is that the very nature of an object is a grouping. OOP is all about grouping the code around shared mutable variables within a context. You are forced to assume a grouping and you may be right in some cases (like cruisecontrol) but you will inevitably be mistaken in many other cases. This "mistake" is the origin of the most insidious form of technical debt.
The other problem with OOP is that they think that this style of programming is modular. They have no idea how un-modular it is. So they use it extensively with patterns thinking they're improving modularity.
The most modular primitive is the combinator. That is why to prevent technical debt as much as possible, the key is to move as much code as possible into the combinators.
https://news.ycombinator.com/item?id=14687947
IMHO, Open-Closed applies just as well to entire microservice architectures as it does to individual classes. For example, I've seen services for general-purpose tasks such as job scheduling or generating PDF reports which required devs to change the code to add a new job type or report type. These services were replaced by services where the new application-specific types could be defined through an API, which meant that those services were "done" and reduced the amount of work required to use them.